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.
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><dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency></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><dependency>
<groupId>com.pack</groupId>
<artifactId>xg-component-rw</artifactId>
<version>1.0.0</version>
</dependency></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:
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.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.