Backend Development 10 min read

Mastering Read‑Write Splitting in Spring Boot: A Complete Guide

This article explains how to implement database read‑write separation in a Spring Boot application by configuring master and slave data sources, creating a routing datasource, managing thread‑local context, and using an AOP‑based annotation to switch between read and write operations.

Architect's Guide
Architect's Guide
Architect's Guide
Mastering Read‑Write Splitting in Spring Boot: A Complete Guide

Introduction

In high‑concurrency scenarios, read‑write separation is a common optimization for databases: a master (write) and one or more slaves (read) reduce contention and protect the database. This article explains how to implement read‑write separation in a Spring Boot project.

Read‑write separation diagram
Read‑write separation diagram

1. Master‑Slave DataSource Configuration

Define two DataSource beans (master and slave) using DruidDataSourceBuilder and bind them to properties

spring.datasource.master

and

spring.datasource.slave

. Then create a

DataSourceRouter

that extends

AbstractRoutingDataSource

and registers the two sources in a map.

<code>@Configuration
@MapperScan(basePackages = "com.wyq.mysqlreadwriteseparate.mapper", sqlSessionTemplateRef = "sqlTemplate")
public class DataSourceConfig {

    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.master")
    public DataSource master() {
        return DruidDataSourceBuilder.create().build();
    }

    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.slave")
    public DataSource slaver() {
        return DruidDataSourceBuilder.create().build();
    }

    @Bean
    public DataSourceRouter dynamicDB(@Qualifier("master") DataSource masterDataSource,
                                     @Autowired(required = false) @Qualifier("slaver") DataSource slaveDataSource) {
        DataSourceRouter dynamicDataSource = new DataSourceRouter();
        Map<Object, Object> targetDataSources = new HashMap<>();
        targetDataSources.put(DataSourceEnum.MASTER.getDataSourceName(), masterDataSource);
        if (slaveDataSource != null) {
            targetDataSources.put(DataSourceEnum.SLAVE.getDataSourceName(), slaveDataSource);
        }
        dynamicDataSource.setTargetDataSources(targetDataSources);
        dynamicDataSource.setDefaultTargetDataSource(masterDataSource);
        return dynamicDataSource;
    }

    @Bean
    public SqlSessionFactory sessionFactory(@Qualifier("dynamicDB") DataSource dynamicDataSource) throws Exception {
        SqlSessionFactoryBean bean = new SqlSessionFactoryBean();
        bean.setMapperLocations(new PathMatchingResourcePatternResolver()
                .getResources("classpath*:mapper/*Mapper.xml"));
        bean.setDataSource(dynamicDataSource);
        return bean.getObject();
    }

    @Bean
    public SqlSessionTemplate sqlTemplate(@Qualifier("sessionFactory") SqlSessionFactory sqlSessionFactory) {
        return new SqlSessionTemplate(sqlSessionFactory);
    }

    @Bean(name = "dataSourceTx")
    public DataSourceTransactionManager dataSourceTransactionManager(@Qualifier("dynamicDB") DataSource dynamicDataSource) {
        DataSourceTransactionManager tm = new DataSourceTransactionManager();
        tm.setDataSource(dynamicDataSource);
        return tm;
    }
}
</code>

2. DataSource Routing Logic

The router overrides

determineCurrentLookupKey()

to obtain the current data source name from a thread‑local holder.

<code>public class DataSourceRouter extends AbstractRoutingDataSource {
    @Override
    protected Object determineCurrentLookupKey() {
        return DataSourceContextHolder.get();
    }
}
</code>

3. Thread‑Local Context Holder

A simple utility that stores the current data source name in a

ThreadLocal

to make the routing decision thread‑safe.

<code>public class DataSourceContextHolder {
    private static final ThreadLocal<String> context = new ThreadLocal<>();

    public static void set(String datasourceType) {
        context.set(datasourceType);
    }

    public static String get() {
        return context.get();
    }

    public static void clear() {
        context.remove();
    }
}
</code>

4. Annotation and AOP for Switching

Define a

@DataSourceSwitcher

annotation that specifies the target data source and whether to clear the context after execution. An AOP aspect intercepts methods annotated with this annotation, sets the context before proceeding, and optionally clears it.

<code>@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Documented
public @interface DataSourceSwitcher {
    DataSourceEnum value() default DataSourceEnum.MASTER;
    boolean clear() default true;
}
</code>
<code>@Slf4j
@Aspect
@Order(1)
@Component
public class DataSourceContextAop {

    @Around("@annotation(com.wyq.mysqlreadwriteseparate.annotation.DataSourceSwitcher)")
    public Object setDynamicDataSource(ProceedingJoinPoint pjp) throws Throwable {
        boolean clear = false;
        try {
            Method method = getMethod(pjp);
            DataSourceSwitcher ds = method.getAnnotation(DataSourceSwitcher.class);
            clear = ds.clear();
            DataSourceContextHolder.set(ds.value().getDataSourceName());
            log.info("Switching datasource to: {}", ds.value().getDataSourceName());
            return pjp.proceed();
        } finally {
            if (clear) {
                DataSourceContextHolder.clear();
            }
        }
    }

    private Method getMethod(JoinPoint pjp) {
        MethodSignature signature = (MethodSignature) pjp.getSignature();
        return signature.getMethod();
    }
}
</code>

5. Usage Example

Apply the annotation on service or DAO methods. Read‑only methods use

DataSourceEnum.SLAVE

, while write methods use

DataSourceEnum.MASTER

.

<code>@Service
public class OrderService {

    @Resource
    private OrderMapper orderMapper;

    @DataSourceSwitcher(DataSourceEnum.SLAVE)
    public List<Order> getOrder(String orderId) {
        return orderMapper.listOrders(orderId);
    }

    @DataSourceSwitcher(DataSourceEnum.MASTER)
    public List<Order> insertOrder(Long orderId) {
        Order order = new Order();
        order.setOrderId(orderId);
        return orderMapper.saveOrder(order);
    }
}
</code>

Conclusion

Read‑write separation is achieved by configuring separate master and slave data sources, routing queries through a custom

AbstractRoutingDataSource

, and managing the current data source with a thread‑local holder. The AOP‑based annotation makes switching transparent and keeps the code clean.

Summary diagram
Summary diagram
JavaAOPDatabaseSpring BootRead-Write SplittingDataSource Routing
Architect's Guide
Written by

Architect's Guide

Dedicated to sharing programmer-architect skills—Java backend, system, microservice, and distributed architectures—to help you become a senior architect.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.