Implementing Read-Write Separation for Java Databases: A Complete Spring Boot Guide
Read-write separation routes write operations to a primary MySQL master and read operations to slave replicas, significantly improving application scalability when implemented using Spring Boot's AbstractRoutingDataSource and AOP-based routing logic.
Implementing read-write separation for Java databases is essential for high-traffic applications that need to scale beyond single-node database limitations. According to the doocs/advanced-java repository, this pattern leverages native MySQL replication to distribute query load while maintaining data consistency across nodes. The implementation documented in docs/high-concurrency/mysql-read-write-separation.md provides both the theoretical foundation and practical Java configuration patterns needed for production deployment.
How MySQL Master-Slave Replication Works
Before implementing the Java routing layer, you must understand the underlying replication mechanism that keeps data synchronized between nodes. The process operates through three distinct stages as illustrated in the repository's documentation at docs/high-concurrency/images/mysql-master-slave.png:
- Master writes to the binary log – Every data-modifying statement is recorded in the binlog on the primary server.
- Slave I/O thread copies the binlog – Each replica establishes a connection to the master, fetches binlog entries, and stores them locally in a relay log.
- Slave SQL thread replays the relay log – The replica parses the relay log and executes the statements, reconstructing the master's state.
This asynchronous pipeline enables horizontal read scaling but introduces architectural considerations that your Java application must handle.
Key Challenges in Database Read-Write Separation
When implementing read-write separation in Java applications, three primary issues require mitigation strategies:
-
Replication lag – Slaves apply changes sequentially, potentially causing stale reads where recent writes aren't immediately visible.
- Mitigation: Use semi-synchronous replication to force at least one slave acknowledgment before commit, enable parallel replication to speed up relay log replay, or route critical "read-after-write" queries directly to the master.
-
Master failure – If the primary crashes before pending changes propagate, unreplicated data can be lost.
- Mitigation: Deploy semi-synchronous replication or a Raft-based high-availability layer to ensure durability.
-
Write hotspot – A single master can bottleneck under heavy write loads.
- Mitigation: Implement sharding to split write workloads across multiple masters or adopt write-scaling solutions like Vitess.
Implementing Read-Write Routing in Spring Boot
The Java implementation uses two DataSource beans (master and slaves) with a custom routing component that selects the appropriate database at runtime based on the operation type.
Configuring Multiple DataSources
First, define separate connection pools for your master and slave nodes in application.yml:
spring:
datasource:
master:
url: jdbc:mysql://master-db:3306/app
username: root
password: secret
slave:
url: jdbc:mysql://slave-db:3306/app
username: root
password: secret
Creating the Routing Context Holder
Implement a ThreadLocal holder to track the current routing key without interfering with concurrent requests:
public enum DBType {
READ, WRITE
}
public class DBContextHolder {
private static final ThreadLocal<DBType> context = new ThreadLocal<>();
public static void set(DBType dbType) {
context.set(dbType);
}
public static DBType get() {
return context.get();
}
public static void clear() {
context.remove();
}
}
Building the AbstractRoutingDataSource Implementation
Create a routing data source that extends AbstractRoutingDataSource and overrides determineCurrentLookupKey():
public class ReadWriteRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
// Default to WRITE when no explicit hint is set
DBType dbType = DBContextHolder.get();
return (dbType == null) ? DBType.WRITE : dbType;
}
}
Registering the Routing DataSource
Configure the routing mechanism as your primary data source bean, mapping the routing keys to physical connections:
@Configuration
public class DataSourceConfig {
@Bean
@ConfigurationProperties(prefix = "spring.datasource.master")
public DataSource masterDataSource() {
return DataSourceBuilder.create().build();
}
@Bean
@ConfigurationProperties(prefix = "spring.datasource.slave")
public DataSource slaveDataSource() {
return DataSourceBuilder.create().build();
}
@Bean
public DataSource routingDataSource(DataSource masterDataSource,
DataSource slaveDataSource) {
Map<Object, Object> targetDataSources = new HashMap<>();
targetDataSources.put(DBType.WRITE, masterDataSource);
targetDataSources.put(DBType.READ, slaveDataSource);
ReadWriteRoutingDataSource routingDataSource = new ReadWriteRoutingDataSource();
routingDataSource.setTargetDataSources(targetDataSources);
routingDataSource.setDefaultTargetDataSource(masterDataSource);
return routingDataSource;
}
@Bean
public SqlSessionFactory sqlSessionFactory(DataSource routingDataSource) throws Exception {
SqlSessionFactoryBean sessionFactory = new SqlSessionFactoryBean();
sessionFactory.setDataSource(routingDataSource);
return sessionFactory.getObject();
}
}
Enabling Automatic Routing with Spring AOP
Use Aspect-Oriented Programming to automatically switch data sources based on the @Transactional annotation's readOnly attribute:
@Aspect
@Component
public class ReadWriteAdvice {
@Pointcut("@annotation(org.springframework.transaction.annotation.Transactional)")
public void transactionalMethods() {}
@Before("transactionalMethods() && @annotation(tx)")
public void setDataSource(Transactional tx) {
// Simple convention: read-only = READ, otherwise = WRITE
if (tx.readOnly()) {
DBContextHolder.set(DBType.READ);
} else {
DBContextHolder.set(DBType.WRITE);
}
}
@After("transactionalMethods()")
public void clear() {
DBContextHolder.clear();
}
}
Handling Replication Lag in Java Applications
When implementing read-write separation for Java databases, you must account for the inevitable delay between master writes and slave visibility. Write-oriented service methods annotated with @Transactional (default readOnly=false) automatically route to the master, while read-only queries use @Transactional(readOnly = true) to hit the slave.
For operations requiring immediate consistency—such as reading a record immediately after insertion—explicitly route to the master to avoid stale data:
// Force master routing for critical read-after-write scenarios
DBContextHolder.set(DBType.WRITE);
User newUser = userRepository.findById(id); // Hits master despite being SELECT
DBContextHolder.clear();
Alternatively, annotate the method with @Transactional(readOnly = false) to ensure the routing advice directs the query to the master node.
Summary
- MySQL replication uses binlog streaming and relay log replay to synchronize slaves with the master, forming the foundation of read-write separation.
- Replication lag is the primary architectural challenge, requiring semi-synchronous replication or master-routing for critical reads.
- Spring Boot implementation leverages
AbstractRoutingDataSourcewith aThreadLocalcontext holder to switch between master and slave connections. - AOP-based routing inspects
@Transactionalannotations to automatically route writes to the master and reads to slaves. - Explicit master routing remains available for scenarios where immediate consistency trumps read scalability.
Frequently Asked Questions
What is the primary benefit of read-write separation in Java applications?
Read-write separation horizontally scales database read capacity by distributing query load across multiple slave replicas while maintaining a single master for writes. This pattern prevents the primary database from becoming a bottleneck under high-traffic conditions, significantly improving application throughput for read-heavy workloads.
How does AbstractRoutingDataSource determine which database to use?
The AbstractRoutingDataSource calls determineCurrentLookupKey() before each database operation to retrieve a routing key from the ThreadLocal context holder. This key maps to specific DataSource instances registered in setTargetDataSources(). When the AOP advice detects @Transactional(readOnly = true), it sets the READ key; otherwise, it defaults to WRITE, routing to the master database.
What are the risks of replication lag when implementing read-write separation?
Replication lag causes temporary data inconsistency where slaves remain slightly behind the master. In Java applications, this manifests as "stale reads" where recently written data appears missing. Critical business operations—such as displaying user-created content immediately after submission—may require explicit master routing or semi-synchronous replication to ensure data visibility.
Can I force a query to use the master database for critical reads?
Yes. While the AOP advice handles most routing automatically, you can manually set the routing key using DBContextHolder.set(DBType.WRITE) before executing a query, then clear the context afterward. Alternatively, ensure the method uses @Transactional(readOnly = false) to trigger the write routing logic, guaranteeing the query executes against the master node regardless of the operation type.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →