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