Spring Boot + MyBatis + AOP 实现读写分离
思路:配置主从多个数据源,通过 AbstractRoutingDataSource 按线程上下文动态路由;再定义 @Master / @Slave 两个注解,用 AOP 在方法执行前切换数据源,实现无侵入的读写分离。
定义注解
1import java.lang.annotation.ElementType;
2import java.lang.annotation.Retention;
3import java.lang.annotation.RetentionPolicy;
4import java.lang.annotation.Target;
5
6/**
7 * 主库可读写
8 */
9@Target(ElementType.METHOD)
10@Retention(RetentionPolicy.RUNTIME)
11public @interface Master {
12}
1import java.lang.annotation.ElementType;
2import java.lang.annotation.Retention;
3import java.lang.annotation.RetentionPolicy;
4import java.lang.annotation.Target;
5
6/**
7 * 从库可读
8 */
9@Target(ElementType.METHOD)
10@Retention(RetentionPolicy.RUNTIME)
11public @interface Slave {
12}
数据库配置
1spring:
2 datasource:
3 master:
4 jdbc-url: jdbc:mysql://192.168.1.22:3307/test
5 username: root
6 password: 123456
7 driver-class-name: com.mysql.cj.jdbc.Driver
8 slave1:
9 jdbc-url: jdbc:mysql://192.168.1.22:3307/test
10 username: root # 只读账户
11 password: 123456
12 driver-class-name: com.mysql.cj.jdbc.Driver
13 slave2:
14 jdbc-url: jdbc:mysql://192.168.1.22:3307/test
15 username: root # 只读账户
16 password: 123456
17 driver-class-name: com.mysql.cj.jdbc.Driver
数据源枚举:
1public enum DBTypeEnum {
2 MASTER, SLAVE1, SLAVE2;
3}
MyBatis 配置
数据源配置,把多个真实数据源装进路由数据源:
1import org.springframework.beans.factory.annotation.Qualifier;
2import org.springframework.boot.context.properties.ConfigurationProperties;
3import org.springframework.boot.jdbc.DataSourceBuilder;
4import org.springframework.context.annotation.Bean;
5import org.springframework.context.annotation.Configuration;
6
7import javax.sql.DataSource;
8import java.util.HashMap;
9import java.util.Map;
10
11@Configuration
12public class DataSourceConfig {
13 @Bean
14 @ConfigurationProperties("spring.datasource.master")
15 public DataSource masterDataSource() {
16 return DataSourceBuilder.create().build();
17 }
18
19 @Bean
20 @ConfigurationProperties("spring.datasource.slave1")
21 public DataSource slave1DataSource() {
22 return DataSourceBuilder.create().build();
23 }
24
25 @Bean
26 @ConfigurationProperties("spring.datasource.slave2")
27 public DataSource slave2DataSource() {
28 return DataSourceBuilder.create().build();
29 }
30
31 @Bean
32 public DataSource routingDataSource(@Qualifier("masterDataSource") DataSource masterDataSource,
33 @Qualifier("slave1DataSource") DataSource slave1DataSource,
34 @Qualifier("slave2DataSource") DataSource slave2DataSource) {
35 Map<Object, Object> targetDataSources = new HashMap<>();
36 targetDataSources.put(DBTypeEnum.MASTER, masterDataSource);
37 targetDataSources.put(DBTypeEnum.SLAVE1, slave1DataSource);
38 targetDataSources.put(DBTypeEnum.SLAVE2, slave2DataSource);
39 RoutingDataSource routingDataSource = new RoutingDataSource();
40 routingDataSource.setDefaultTargetDataSource(masterDataSource);
41 routingDataSource.setTargetDataSources(targetDataSources);
42 return routingDataSource;
43 }
44}
MyBatis 会话工厂与事务管理器都指向路由数据源:
1import org.apache.ibatis.session.SqlSessionFactory;
2import org.mybatis.spring.SqlSessionFactoryBean;
3import org.springframework.context.annotation.Bean;
4import org.springframework.context.annotation.Configuration;
5import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
6import org.springframework.jdbc.datasource.DataSourceTransactionManager;
7import org.springframework.transaction.PlatformTransactionManager;
8import org.springframework.transaction.annotation.EnableTransactionManagement;
9
10import javax.annotation.Resource;
11import javax.sql.DataSource;
12
13@Configuration
14@EnableTransactionManagement
15public class MyBatisConfig {
16
17 @Resource(name = "routingDataSource")
18 private DataSource routingDataSource;
19
20 @Bean
21 public SqlSessionFactory sqlSessionFactory() throws Exception {
22 SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean();
23 sqlSessionFactoryBean.setDataSource(routingDataSource);
24// sqlSessionFactoryBean.setMapperLocations(new PathMatchingResourcePatternResolver().getResources("classpath:mapper/*.xml"));
25 return sqlSessionFactoryBean.getObject();
26 }
27
28 @Bean
29 public PlatformTransactionManager platformTransactionManager() {
30 return new DataSourceTransactionManager(routingDataSource);
31 }
32}
动态数据源,根据线程上下文返回查找键:
1import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
2import org.springframework.lang.Nullable;
3
4public class RoutingDataSource extends AbstractRoutingDataSource {
5 @Nullable
6 @Override
7 protected Object determineCurrentLookupKey() {
8 return DBContextHolder.get();
9 }
10}
数据源切换 holder,从库用计数器轮询做负载均衡:
1import java.util.concurrent.atomic.AtomicInteger;
2
3public class DBContextHolder {
4 private static final ThreadLocal<DBTypeEnum> contextHolder = new ThreadLocal<>();
5
6 private static final AtomicInteger counter = new AtomicInteger(-1);
7
8 public static void set(DBTypeEnum dbType) {
9 contextHolder.set(dbType);
10 }
11
12 public static DBTypeEnum get() {
13 return contextHolder.get();
14 }
15
16 public static void remove() {
17 contextHolder.remove();
18 }
19
20 public static void master() {
21 set(DBTypeEnum.MASTER);
22 System.out.println("切换到master");
23 }
24
25 public static void slave() {
26 // 轮询
27 int index = counter.getAndIncrement() % 2;
28 if (counter.get() > 9999) {
29 counter.set(-1);
30 }
31 if (index == 0) {
32 set(DBTypeEnum.SLAVE1);
33 System.out.println("切换到slave1");
34 } else {
35 set(DBTypeEnum.SLAVE2);
36 System.out.println("切换到slave2");
37 }
38 }
39}
AOP 切面
方法执行前按注解切库,执行后清理 ThreadLocal,避免线程复用导致的数据源错乱:
1import org.aspectj.lang.annotation.After;
2import org.aspectj.lang.annotation.Aspect;
3import org.aspectj.lang.annotation.Before;
4import org.springframework.stereotype.Component;
5
6@Aspect
7@Component
8public class DataSourceAop {
9 @Before("@annotation(com.example.demo.annotation.Master)")
10 public void master() {
11 DBContextHolder.master();
12 }
13
14 @Before("@annotation(com.example.demo.annotation.Slave)")
15 public void slave() {
16 DBContextHolder.slave();
17 }
18
19 @After("@annotation(com.example.demo.annotation.Slave)||@annotation(com.example.demo.annotation.Master)")
20 public void afterSwitchDB() {
21 DBContextHolder.remove();
22 }
23}
使用方式
在 Service 方法上标注注解即可:
1@Service
2public class UserServiceImpl implements UserService {
3 @Autowired
4 private UserMapper userMapper;
5
6 /**
7 * 主库写入数据
8 */
9 @Override
10 @Master
11 public int save(User user) {
12 return userMapper.save(user);
13 }
14
15 /**
16 * 从库查询数据
17 */
18 @Override
19 @Slave
20 public User get() {
21 return userMapper.get();
22 }
23}
1public interface UserMapper {
2
3 @Insert("insert into t_test(name)values(#{name})")
4 int save(User user);
5
6 @Select("select * from t_test where id>=(select floor(rand() * (select max(id) from t_test))) order by id limit 1")
7 User get();
8}
验证
1@SpringBootTest
2class DemoApplicationTests {
3 @Autowired
4 private UserService userService;
5
6 @Test
7 void testDb() throws InterruptedException {
8 for (int i = 0; i < 10; i++) {
9 User user = userService.get();
10 System.out.println(user);
11 TimeUnit.SECONDS.sleep(1);
12 user.setName(UUID.randomUUID().toString());
13 userService.save(user);
14 }
15 }
16}
输出可以看到查询在两个从库间轮询、写入固定走主库:
1切换到slave2
2User{id=8, name='e87f057f-9dc9-4f59-8ff4-3a01682487d0'}
3切换到master
4切换到slave1
5User{id=2, name='d786167b-d29f-49eb-bb1d-dc48f01f761c'}
6切换到master
7切换到slave2
8User{id=6, name='c680a686-3ede-419d-bd66-de82966d5f96'}
9切换到master
10切换到slave1
11User{id=5, name='07d2a8d4-3414-49a0-bc37-065a1688a55f'}
12切换到master
13切换到slave2
14User{id=3, name='bac785d9-743d-48db-9b74-c2d51baa878fa'}
15切换到master
16切换到slave1
17User{id=7, name='0c5866a0-208b-4530-8c8d-9502dea753ff'}
18切换到master
19切换到slave2
20User{id=3, name='bac785d9-743d-48db-9b74-c2d51baa878fa'}
21切换到master
22切换到slave1
23User{id=8, name='e87f057f-9dc9-4f59-8ff4-3a01682487d0'}
24切换到master
