在大型应用系统中,随着访问量的增加,数据库常常成为系统的性能瓶颈。为了提高系统的读写性能和可用性,读写分离是一种经典的数据库架构模式。它将数据库读操作和写操作分别路由到不同的数据库实例,通常是将写操作指向主库(Master),读操作指向从库(Slave)。
读写分离的主要优势:
在SpringBoot应用中,有多种方式可以实现数据库读写分离,本文将介绍三种主实现方案。
这种方案是基于Spring提供的AbstractRoutingDataSource抽象类,通过重写其中的determineCurrentLookupKey()方法来实现数据源的动态切换。
AbstractRoutingDataSource的核心原理是在执行数据库操作时,根据一定的策略(通常基于当前操作的上下文)动态地选择实际的数据源。通过在业务层或AOP拦截器中设置上下文标识,让系统自动判断是读操作还是写操作,从而选择对应的数据源。
第一步:定义数据源枚举和上下文持有器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
// 数据源类型枚举 public enum DataSourceType { MASTER, // 主库,用于写操作 SLAVE // 从库,用于读操作 }
// 数据源上下文持有器 public class DataSourceContextHolder { private static final ThreadLocal<DataSourceType> contextHolder = new ThreadLocal<>();
public static void setDataSourceType(DataSourceType dataSourceType) { contextHolder.set(dataSourceType); }
public static DataSourceType getDataSourceType() { return contextHolder.get() == null ? DataSourceType.MASTER : contextHolder.get(); }
public static void clearDataSourceType() { contextHolder.remove(); } } |
第二步:实现动态数据源
1 2 3 4 5 6 |
public class DynamicDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { return DataSourceContextHolder.getDataSourceType(); } } |
第三步:配置数据源
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
@Configuration public class DataSourceConfig {
@Bean @ConfigurationProperties(prefix = "spring.datasource.master") public DataSource masterDataSource() { return DataSourceBuilder.create().build(); }
@Bean @ConfigurationProperties(prefix = "spring.datasource.slave") public DataSource slaveDataSource() { return DataSourceBuilder.create().build(); }
@Bean public DataSource dynamicDataSource() { DynamicDataSource dynamicDataSource = new DynamicDataSource();
Map<Object, Object> dataSourceMap = new HashMap<>(2); dataSourceMap.put(DataSourceType.MASTER, masterDataSource()); dataSourceMap.put(DataSourceType.SLAVE, slaveDataSource());
// 设置默认数据源为主库 dynamicDataSource.setDefaultTargetDataSource(masterDataSource()); dynamicDataSource.setTargetDataSources(dataSourceMap);
return dynamicDataSource; }
@Bean public SqlSessionFactory sqlSessionFactory() throws Exception { SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean(); sqlSessionFactoryBean.setDataSource(dynamicDataSource());
// 设置MyBatis配置 // ...
return sqlSessionFactoryBean.getObject(); } } |
第四步:实现AOP拦截器,根据方法匹配规则自动切换数据源
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
@Aspect @Component public class DataSourceAspect {
// 匹配所有以select、query、get、find开头的方法为读操作 @Pointcut("execution(* com.example.service.impl.*.*(..))") public void servicePointcut() {}
@Before("servicePointcut()") public void switchDataSource(JoinPoint point) { // 获取方法名 String methodName = point.getSignature().getName();
// 根据方法名判断是读操作还是写操作 if (methodName.startsWith("select") || methodName.startsWith("query") || methodName.startsWith("get") || methodName.startsWith("find")) { // 读操作使用从库 DataSourceContextHolder.setDataSourceType(DataSourceType.SLAVE); } else { // 写操作使用主库 DataSourceContextHolder.setDataSourceType(DataSourceType.MASTER); } }
@After("servicePointcut()") public void restoreDataSource() { // 清除数据源配置 DataSourceContextHolder.clearDataSourceType(); } } |
第五步:配置文件application.yml
1 2 3 4 5 6 7 8 9 10 11 12 |
spring: datasource: master: jdbc-url: jdbc:mysql://master-db:3306/test?useSSL=false username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver slave: jdbc-url: jdbc:mysql://slave-db:3306/test?useSSL=false username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver |
第六步:使用注解方式灵活控制数据源(可选增强)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
// 定义自定义注解 @Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface DataSource { DataSourceType value() default DataSourceType.MASTER; }
// 修改AOP拦截器,优先使用注解指定的数据源 @Aspect @Component public class DataSourceAspect {
@Pointcut("@annotation(com.example.annotation.DataSource)") public void dataSourcePointcut() {}
@Before("dataSourcePointcut()") public void switchDataSource(JoinPoint point) { MethodSignature signature = (MethodSignature) point.getSignature(); Method method = signature.getMethod();
DataSource dataSource = method.getAnnotation(DataSource.class); if (dataSource != null) { DataSourceContextHolder.setDataSourceType(dataSource.value()); } }
@After("dataSourcePointcut()") public void restoreDataSource() { DataSourceContextHolder.clearDataSourceType(); } }
// 在Service方法上使用 @Service public class UserServiceImpl implements UserService {
@Override @DataSource(DataSourceType.SLAVE) public List<User> findAllUsers() { return userMapper.selectAll(); }
@Override @DataSource(DataSourceType.MASTER) public void createUser(User user) { userMapper.insert(user); } } |
优点:
缺点:
适用场景:
ShardingSphere-JDBC是Apache ShardingSphere项目下的一个子项目,它通过客户端分片的方式,为应用提供了透明化的读写分离和分库分表等功能。
ShardingSphere-JDBC通过拦截JDBC驱动,重写SQL解析与执行流程来实现读写分离。它能够根据SQL语义自动判断读写操作,并将读操作负载均衡地分发到多个从库。
第一步:添加依赖
1 2 3 4 5 6 7 8 9 10 |
<dependency> <groupId>org.apache.shardingsphere</groupId> <artifactId>shardingsphere-jdbc-core-spring-boot-starter</artifactId> <version>5.2.1</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> |
第二步:配置文件application.yml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
spring: shardingsphere: mode: type: Memory datasource: names: master,slave1,slave2 master: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://master-db:3306/test?useSSL=false username: root password: root slave1: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://slave1-db:3306/test?useSSL=false username: root password: root slave2: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://slave2-db:3306/test?useSSL=false username: root password: root rules: readwrite-splitting: data-sources: readwrite_ds: type: Static props: write-data-source-name: master read-data-source-names: slave1,slave2 load-balancer-name: round_robin load-balancers: round_robin: type: ROUND_ROBIN props: sql-show: true # 开启SQL显示,方便调试 |
第三步:创建数据源配置类
1 2 3 4 5 6 7 8 9 10 11 12 13 |
@Configuration public class DataSourceConfig {
// 无需额外配置,ShardingSphere-JDBC会自动创建并注册DataSource
@Bean @ConfigurationProperties(prefix = "mybatis") public SqlSessionFactoryBean sqlSessionFactoryBean(DataSource dataSource) { SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean(); sqlSessionFactoryBean.setDataSource(dataSource); return sqlSessionFactoryBean; } } |
第四步:强制主库查询的注解(可选)
在某些场景下,即使是查询操作也需要从主库读取最新数据,ShardingSphere提供了hint机制来实现这一需求。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
// 定义主库查询注解 @Target({ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface MasterRoute { }
// 创建AOP切面拦截器 @Aspect @Component public class MasterRouteAspect {
@Around("@annotation(com.example.annotation.MasterRoute)") public Object aroundMasterRoute(ProceedingJoinPoint joinPoint) throws Throwable { try { HintManager.getInstance().setWriteRouteOnly(); return joinPoint.proceed(); } finally { HintManager.clear(); } } }
// 在需要主库查询的方法上使用注解 @Service public class OrderServiceImpl implements OrderService {
@Autowired private OrderMapper orderMapper;
@Override @MasterRoute public Order getLatestOrder(Long userId) { // 这里的查询会路由到主库 return orderMapper.findLatestByUserId(userId); } } |
优点:
缺点:
适用场景:
MyBatis提供了强大的插件机制,允许在SQL执行的不同阶段进行拦截和处理。通过自定义插件,可以实现基于SQL解析的读写分离功能。
MyBatis允许拦截执行器的query和update方法,通过拦截这些方法,可以在SQL执行前动态切换数据源。这种方式的核心是编写一个拦截器,分析即将执行的SQL语句类型(SELECT/INSERT/UPDATE/DELETE),然后根据SQL类型切换到相应的数据源。
第一步:定义数据源和上下文(与方案一类似)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
public enum DataSourceType { MASTER, SLAVE }
public class DataSourceContextHolder { private static final ThreadLocal<DataSourceType> contextHolder = new ThreadLocal<>();
public static void setDataSourceType(DataSourceType dataSourceType) { contextHolder.set(dataSourceType); }
public static DataSourceType getDataSourceType() { return contextHolder.get() == null ? DataSourceType.MASTER : contextHolder.get(); }
public static void clearDataSourceType() { contextHolder.remove(); } }
public class DynamicDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { return DataSourceContextHolder.getDataSourceType(); } } |
第二步:实现MyBatis拦截器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 |
@Intercepts({ @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}), @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class}), @Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class}) }) @Component public class ReadWriteSplittingInterceptor implements Interceptor {
@Override public Object intercept(Invocation invocation) throws Throwable { Object[] args = invocation.getArgs(); MappedStatement ms = (MappedStatement) args[0];
try { // 判断是否为事务 boolean isTransactional = TransactionSynchronizationManager.isActualTransactionActive();
// 如果是事务,则使用主库 if (isTransactional) { DataSourceContextHolder.setDataSourceType(DataSourceType.MASTER); return invocation.proceed(); }
// 根据SQL类型选择数据源 if (ms.getSqlCommandType() == SqlCommandType.SELECT) { // 读操作使用从库 DataSourceContextHolder.setDataSourceType(DataSourceType.SLAVE); } else { // 写操作使用主库 DataSourceContextHolder.setDataSourceType(DataSourceType.MASTER); }
return invocation.proceed(); } finally { // 清除数据源配置 DataSourceContextHolder.clearDataSourceType(); } }
@Override public Object plugin(Object target) { if (target instanceof Executor) { return Plugin.wrap(target, this); } return target; }
@Override public void setProperties(Properties properties) { // 可以从配置文件加载属性 } } |
第三步:配置数据源和MyBatis插件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
@Configuration public class DataSourceConfig {
@Bean @ConfigurationProperties(prefix = "spring.datasource.master") public DataSource masterDataSource() { return DataSourceBuilder.create().build(); }
@Bean @ConfigurationProperties(prefix = "spring.datasource.slave") public DataSource slaveDataSource() { return DataSourceBuilder.create().build(); }
@Bean public DataSource dynamicDataSource() { DynamicDataSource dynamicDataSource = new DynamicDataSource();
Map<Object, Object> dataSourceMap = new HashMap<>(2); dataSourceMap.put(DataSourceType.MASTER, masterDataSource()); dataSourceMap.put(DataSourceType.SLAVE, slaveDataSource());
dynamicDataSource.setDefaultTargetDataSource(masterDataSource()); dynamicDataSource.setTargetDataSources(dataSourceMap);
return dynamicDataSource; }
@Bean public SqlSessionFactory sqlSessionFactory(@Autowired ReadWriteSplittingInterceptor interceptor) throws Exception { SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean(); sqlSessionFactoryBean.setDataSource(dynamicDataSource());
// 添加MyBatis插件 sqlSessionFactoryBean.setPlugins(new Interceptor[]{interceptor});
// 其他MyBatis配置 // ...
return sqlSessionFactoryBean.getObject(); } } |
第四步:强制主库查询注解(可选)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
@Configuration public class DataSourceConfig {
@Bean @ConfigurationProperties(prefix = "spring.datasource.master") public DataSource masterDataSource() { return DataSourceBuilder.create().build(); }
@Bean @ConfigurationProperties(prefix = "spring.datasource.slave") public DataSource slaveDataSource() { return DataSourceBuilder.create().build(); }
@Bean public DataSource dynamicDataSource() { DynamicDataSource dynamicDataSource = new DynamicDataSource();
Map<Object, Object> dataSourceMap = new HashMap<>(2); dataSourceMap.put(DataSourceType.MASTER, masterDataSource()); dataSourceMap.put(DataSourceType.SLAVE, slaveDataSource());
dynamicDataSource.setDefaultTargetDataSource(masterDataSource()); dynamicDataSource.setTargetDataSources(dataSourceMap);
return dynamicDataSource; }
@Bean public SqlSessionFactory sqlSessionFactory(@Autowired ReadWriteSplittingInterceptor interceptor) throws Exception { SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean(); sqlSessionFactoryBean.setDataSource(dynamicDataSource());
// 添加MyBatis插件 sqlSessionFactoryBean.setPlugins(new Interceptor[]{interceptor});
// 其他MyBatis配置 // ...
return sqlSessionFactoryBean.getObject(); } } |
优点:
缺点:
适用场景:
功能特性 | 方案一:AbstractRoutingDataSource | 方案二:ShardingSphere-JDBC | 方案三:MyBatis插件 |
---|---|---|---|
自动识别SQL类型 | ? 需要手动或通过规则指定 | ? 自动识别 | ? 自动识别 |
多从库负载均衡 | ? 需要自行实现 | ? 内置多种算法 | ? 需要自行实现 |
与分库分表集成 | ? 不支持 | ? 原生支持 | ? 需要额外开发 |
开发复杂度 | ?? 中等 | ? 较低 | ??? 较高 |
配置复杂度 | ? 较低 | ??? 较高 | ?? 中等 |
选择方案一(AbstractRoutingDataSource)的情况:
选择方案二(ShardingSphere-JDBC)的情况:
选择方案三(MyBatis插件)的情况:
从库数据同步存在延迟,这可能导致读取到过期数据的问题。处理方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
// 实现延迟检测的示例 @Component @Slf4j public class ReplicationLagMonitor {
@Autowired private JdbcTemplate masterJdbcTemplate;
@Autowired private JdbcTemplate slaveJdbcTemplate;
private AtomicBoolean slaveTooLagged = new AtomicBoolean(false);
@Scheduled(fixedRate = 5000) // 每5秒检查一次 public void checkReplicationLag() { try { // 在主库写入标记 String mark = UUID.randomUUID().toString(); masterJdbcTemplate.update("INSERT INTO replication_marker(marker, create_time) VALUES(?, NOW())", mark);
// 等待一定时间,给从库同步的机会 Thread.sleep(1000);
// 从从库查询该标记 Integer count = slaveJdbcTemplate.queryForObject( "SELECT COUNT(*) FROM replication_marker WHERE marker = ?", Integer.class, mark);
// 判断同步延迟 boolean lagged = (count == null || count == 0); slaveTooLagged.set(lagged);
if (lagged) { log.warn("Slave replication lag detected, routing read operations to master"); } else { log.info("Slave replication is in sync"); } } catch (Exception e) { log.error("Failed to check replication lag", e); slaveTooLagged.set(true); // 发生异常时,保守地认为从库延迟过大 } finally{ // 删除标记数据 masterJdbcTemplate.update("DELETE FROM replication_marker WHERE marker = ?", mark); } }
public boolean isSlaveTooLagged() { return slaveTooLagged.get(); } } |
读写分离环境下的事务处理需要特别注意:
1 2 3 4 5 6 7 8 9 10 11 |
# HikariCP连接池配置示例 spring: datasource: master: # 主库偏向写操作,连接池可以适当小一些 maximum-pool-size: 20 minimum-idle: 5 slave: # 从库偏向读操作,连接池可以适当大一些 maximum-pool-size: 50 minimum-idle: 10 |
在实施读写分离时,需要特别注意数据一致性、事务管理和故障处理等方面的问题。
通过合理的架构设计和细致的实现,读写分离可以有效提升系统的读写性能和可扩展性,为应用系统的高可用和高性能提供有力支持。
无论选择哪种方案,请记住读写分离是一种