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
<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>application.yml configuration
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 DUALpointcut 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
@Component
@ConfigurationProperties(prefix = "pack.datasource")
public class RWDataSourceProperties {
private String pointcut;
private HikariConfig master;
private List<HikariConfig> slaves = new ArrayList<>();
}Read‑write configuration class
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;
}
}Data source routing
public class BaseRoutingDataSource extends AbstractRoutingDataSource {
@Resource
private DataSourceHolder holder;
@Override
protected Object determineCurrentLookupKey() {
return holder.get();
}
} 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);
}
}Slave load algorithm
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;
}
} @Bean
@ConditionalOnMissingBean
public BaseSlaveLoad slaveLoad() {
return new PollingLoad();
}
@Bean
public DataSourceHolder dataSourceHolder() {
return new DataSourceHolder();
}Data source AOP
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();
}
} @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;
}Enable annotation
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 {}Methods or classes annotated with @SlaveDB will operate on a read DB.
Packaging and usage
mvn install -Dmaven.test.skip=trueAdd the starter to another project:
<dependency>
<groupId>com.pack</groupId>
<artifactId>xg-component-rw</artifactId>
<version>1.0.0</version>
</dependency>Enable the feature in the main application:
@SpringBootApplication
@EnableRW
public class BaseWebApplication {
public static void main(String[] args) {
SpringApplication.run(BaseWebApplication.class, args);
}
}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:
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
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.
