Backend Development 9 min read

Mastering Read‑Write Splitting in Spring Boot 2.2 with AOP

This guide demonstrates how to implement a one‑write‑multiple‑read data source strategy in Spring Boot 2.2 using AOP, configurable pointcuts, custom routing, load‑balancing algorithms, and a reusable starter that can be packaged as a Maven jar.

Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Mastering Read‑Write Splitting in Spring Boot 2.2 with AOP

Environment: springboot2.2.6RELEASE

Goal: one write data source and multiple read data sources, configurable via pointcut; default operations use the write DB, while methods matching the target or annotated with a specific annotation use read DBs. Packaged as a reusable JAR.

Principle: implemented through Spring AOP.

pom.xml dependencies

<code>&lt;dependency&gt;
  &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;
  &lt;artifactId&gt;spring-boot-starter&lt;/artifactId&gt;
&lt;/dependency&gt;
&lt;dependency&gt;
  &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;
  &lt;artifactId&gt;spring-boot-starter-data-jpa&lt;/artifactId&gt;
&lt;/dependency&gt;
&lt;dependency&gt;
  &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt;
  &lt;artifactId&gt;spring-boot-configuration-processor&lt;/artifactId&gt;
  &lt;optional&gt;true&lt;/optional&gt;
&lt;/dependency&gt;</code>

application.yml configuration

<code>pack:
  datasource:
    pointcut: execution(public * net.greatsoft.service.base.*.*(..)) || execution(public * net.greatsoft.service.xxx.*.*(..))
    master:
      driverClassName: oracle.jdbc.driver.OracleDriver
      jdbcUrl: jdbc:oracle:thin:@10.100.102.113:1521/orcl
      username: test
      password: test
      minimumIdle: 10
      maximumPoolSize: 200
      autoCommit: true
      idleTimeout: 30000
      poolName: MbookHikariCP
      maxLifetime: 1800000
      connectionTimeout: 30000
      connectionTestQuery: SELECT 1 FROM DUAL
    slaves:
      - driverClassName: oracle.jdbc.driver.OracleDriver
        jdbcUrl: jdbc:oracle:thin:@10.100.102.113:1521/orcl
        username: dc
        password: dc
        minimumIdle: 10
        maximumPoolSize: 200
        autoCommit: true
        idleTimeout: 30000
        poolName: MbookHikariCP
        maxLifetime: 1800000
        connectionTimeout: 30000
        connectionTestQuery: SELECT 1 FROM DUAL
      - driverClassName: oracle.jdbc.driver.OracleDriver
        jdbcUrl: jdbc:oracle:thin:@10.100.102.113:1521/orcl
        username: empi
        password: empi
        minimumIdle: 10
        maximumPoolSize: 200
        autoCommit: true
        idleTimeout: 30000
        poolName: MbookHikariCP
        maxLifetime: 1800000
        connectionTimeout: 30000
        connectionTestQuery: SELECT 1 FROM DUAL</code>

pointcut defines which methods are intercepted to use the read DB; master configures the write DB; slaves is a list of read DB configurations.

Property configuration class

<code>@Component
@ConfigurationProperties(prefix = "pack.datasource")
public class RWDataSourceProperties {
  private String pointcut;
  private HikariConfig master;
  private List<HikariConfig> slaves = new ArrayList<>();
}</code>

Read‑write configuration class

<code>public class RWConfig {
  private static Logger logger = LoggerFactory.getLogger(RWConfig.class);

  @Bean
  public HikariDataSource masterDataSource(RWDataSourceProperties props) {
    return new HikariDataSource(props.getMaster());
  }

  @Bean
  public List<HikariDataSource> slaveDataSources(RWDataSourceProperties props) {
    List<HikariDataSource> list = new ArrayList<>();
    for (HikariConfig config : props.getSlaves()) {
      list.add(new HikariDataSource(config));
    }
    return list;
  }

  @Bean
  @Primary
  @DependsOn({"masterDataSource", "slaveDataSources"})
  public AbstractRoutingDataSource routingDataSource(@Qualifier("masterDataSource") DataSource master,
                                                    @Qualifier("slaveDataSources") List<HikariDataSource> slaves) {
    BaseRoutingDataSource ds = new BaseRoutingDataSource();
    Map<Object, Object> targetDataSources = new HashMap<>(2);
    targetDataSources.put("master", master);
    for (int i = 0; i < slaves.size(); i++) {
      targetDataSources.put("slave-" + i, slaves.get(i));
    }
    ds.setDefaultTargetDataSource(master);
    ds.setTargetDataSources(targetDataSources);
    return ds;
  }
}</code>

Data source routing

<code>public class BaseRoutingDataSource extends AbstractRoutingDataSource {
  @Resource
  private DataSourceHolder holder;

  @Override
  protected Object determineCurrentLookupKey() {
    return holder.get();
  }
}</code>
<code>public class DataSourceHolder {
  private ThreadLocal<Integer> context = new ThreadLocal<Integer>() {
    @Override
    protected Integer initialValue() {
      return 0;
    }
  };

  @Resource
  private BaseSlaveLoad slaveLoad;

  public String get() {
    Integer type = context.get();
    return (type == null || type == 0) ? "master" : "slave-" + slaveLoad.load();
  }

  public void set(Integer type) {
    context.set(type);
  }
}</code>

Slave load algorithm

<code>public interface BaseSlaveLoad {
  int load();
}

public abstract class AbstractSlaveLoad implements BaseSlaveLoad {
  @Resource
  protected List<HikariDataSource> slaveDataSources;
}

public class PollingLoad extends AbstractSlaveLoad {
  private int index = 0;
  private int size = 1;

  @PostConstruct
  public void init() {
    size = slaveDataSources.size();
  }

  @Override
  public int load() {
    int n = index;
    synchronized (this) {
      index = (++index) % size;
    }
    return n;
  }
}
</code>
<code>@Bean
@ConditionalOnMissingBean
public BaseSlaveLoad slaveLoad() {
  return new PollingLoad();
}

@Bean
public DataSourceHolder dataSourceHolder() {
  return new DataSourceHolder();
}</code>

Data source AOP

<code>public class DataSourceAspect implements MethodInterceptor {
  private DataSourceHolder holder;

  public DataSourceAspect(DataSourceHolder holder) {
    this.holder = holder;
  }

  @Override
  public Object invoke(MethodInvocation invocation) throws Throwable {
    Method method = invocation.getMethod();
    String methodName = method.getName();
    SlaveDB slaveDB = method.getAnnotation(SlaveDB.class);
    if (slaveDB == null) {
      slaveDB = method.getDeclaringClass().getAnnotation(SlaveDB.class);
    }
    if (methodName.startsWith("find") || methodName.startsWith("get") ||
        methodName.startsWith("query") || methodName.startsWith("select") ||
        methodName.startsWith("list") || slaveDB != null) {
      holder.set(1);
    } else {
      holder.set(0);
    }
    return invocation.proceed();
  }
}
</code>
<code>@Bean
public AspectJExpressionPointcutAdvisor logAdvisor(RWDataSourceProperties props, DataSourceHolder holder) {
  AspectJExpressionPointcutAdvisor advisor = new AspectJExpressionPointcutAdvisor();
  logger.info("Executing expression: {}", props.getPointcut());
  advisor.setExpression(props.getPointcut());
  advisor.setAdvice(new DataSourceAspect(holder));
  return advisor;
}
</code>

Enable annotation

<code>public class RWImportSelector implements ImportSelector {
  @Override
  public String[] selectImports(AnnotationMetadata importingClassMetadata) {
    return new String[] { RWConfig.class.getName() };
  }
}

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Import({RWImportSelector.class})
public @interface EnableRW {}

@Retention(RUNTIME)
@Target({TYPE, METHOD})
public @interface SlaveDB {}
</code>

Methods or classes annotated with @SlaveDB will operate on a read DB.

Packaging and usage

<code>mvn install -Dmaven.test.skip=true</code>

Add the starter to another project:

<code>&lt;dependency&gt;
  &lt;groupId&gt;com.pack&lt;/groupId&gt;
  &lt;artifactId&gt;xg-component-rw&lt;/artifactId&gt;
  &lt;version&gt;1.0.0&lt;/version&gt;
&lt;/dependency&gt;</code>

Enable the feature in the main application:

<code>@SpringBootApplication
@EnableRW
public class BaseWebApplication {
  public static void main(String[] args) {
    SpringApplication.run(BaseWebApplication.class, args);
  }
}
</code>

Testing shows the first query hits the write DB and subsequent queries are routed to the read replicas as configured.

Separate data in the two read replicas is demonstrated by the screenshots.

Write DB illustration:

backendJavaAOPSpring BootRead-Write SplittingDataSourcemulti-datasource
Spring Full-Stack Practical Cases
Written by

Spring Full-Stack Practical Cases

Full-stack Java development with Vue 2/3 front-end suite; hands-on examples and source code analysis for Spring, Spring Boot 2/3, and Spring Cloud.

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.