# Implementing Read-Write Separation for Java Databases: A Complete Spring Boot Guide

> Implement read-write separation for Java databases with Spring Boot. Route writes to master and reads to slaves using AbstractRoutingDataSource and AOP for enhanced scalability.

- Repository: [Doocs/advanced-java](https://github.com/doocs/advanced-java)
- Tags: how-to-guide
- Published: 2026-02-28

---

**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](https://github.com/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`](https://github.com/doocs/advanced-java/blob/main/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`:

1. **Master writes to the binary log** – Every data-modifying statement is recorded in the **binlog** on the primary server.
2. **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**.
3. **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`](https://github.com/doocs/advanced-java/blob/main/application.yml):

```yaml
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:

```java
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()`:

```java
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:

```java
@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:

```java
@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:

```java
// 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 `AbstractRoutingDataSource` with a `ThreadLocal` context holder to switch between master and slave connections.
- **AOP-based routing** inspects `@Transactional` annotations 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.