java

关注公众号 jb51net

关闭
首页 > 软件编程 > java > SpringBoot数据库读写分离

SpringBoot实现数据库读写分离的3种方法小结

作者:风象南

为了提高系统的读写性能和可用性,读写分离是一种经典的数据库架构模式,在SpringBoot应用中,有多种方式可以实现数据库读写分离,本文将介绍三种主实现方案,大家可以根据需要自行选择

一、数据库读写分离概述

在大型应用系统中,随着访问量的增加,数据库常常成为系统的性能瓶颈。为了提高系统的读写性能和可用性,读写分离是一种经典的数据库架构模式。它将数据库读操作和写操作分别路由到不同的数据库实例,通常是将写操作指向主库(Master),读操作指向从库(Slave)。

读写分离的主要优势:

在SpringBoot应用中,有多种方式可以实现数据库读写分离,本文将介绍三种主实现方案。

二、方案一:基于AbstractRoutingDataSource实现动态数据源

这种方案是基于Spring提供的AbstractRoutingDataSource抽象类,通过重写其中的determineCurrentLookupKey()方法来实现数据源的动态切换。

2.1 实现原理

AbstractRoutingDataSource的核心原理是在执行数据库操作时,根据一定的策略(通常基于当前操作的上下文)动态地选择实际的数据源。通过在业务层或AOP拦截器中设置上下文标识,让系统自动判断是读操作还是写操作,从而选择对应的数据源。

2.2 具体实现步骤

第一步:定义数据源枚举和上下文持有器

// 数据源类型枚举
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();
    }
}

第三步:配置数据源

@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拦截器,根据方法匹配规则自动切换数据源

@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

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

第六步:使用注解方式灵活控制数据源(可选增强)

// 定义自定义注解
@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);
    }
}

2.3 优缺点分析

优点:

缺点:

适用场景:

三、方案二:基于ShardingSphere-JDBC实现读写分离

ShardingSphere-JDBC是Apache ShardingSphere项目下的一个子项目,它通过客户端分片的方式,为应用提供了透明化的读写分离和分库分表等功能。

3.1 实现原理

ShardingSphere-JDBC通过拦截JDBC驱动,重写SQL解析与执行流程来实现读写分离。它能够根据SQL语义自动判断读写操作,并将读操作负载均衡地分发到多个从库。

3.2 具体实现步骤

第一步:添加依赖

<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

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显示,方便调试

第三步:创建数据源配置类

@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机制来实现这一需求。

// 定义主库查询注解
@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);
    }
}

3.3 优缺点分析

优点:

缺点:

适用场景:

四、方案三:基于MyBatis插件实现读写分离

MyBatis提供了强大的插件机制,允许在SQL执行的不同阶段进行拦截和处理。通过自定义插件,可以实现基于SQL解析的读写分离功能。

4.1 实现原理

MyBatis允许拦截执行器的queryupdate方法,通过拦截这些方法,可以在SQL执行前动态切换数据源。这种方式的核心是编写一个拦截器,分析即将执行的SQL语句类型(SELECT/INSERT/UPDATE/DELETE),然后根据SQL类型切换到相应的数据源。

4.2 具体实现步骤

第一步:定义数据源和上下文(与方案一类似)

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拦截器

@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插件

@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();
    }
}

第四步:强制主库查询注解(可选)

@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();
    }
}

4.3 优缺点分析

优点:

缺点:

适用场景:

五、三种方案对比与选型指南

5.1 功能对比

功能特性方案一:AbstractRoutingDataSource方案二:ShardingSphere-JDBC方案三:MyBatis插件
自动识别SQL类型❌ 需要手动或通过规则指定✅ 自动识别✅ 自动识别
多从库负载均衡❌ 需要自行实现✅ 内置多种算法❌ 需要自行实现
与分库分表集成❌ 不支持✅ 原生支持❌ 需要额外开发
开发复杂度⭐⭐ 中等⭐ 较低⭐⭐⭐ 较高
配置复杂度⭐ 较低⭐⭐⭐ 较高⭐⭐ 中等

5.2 选型建议

选择方案一(AbstractRoutingDataSource)的情况:

选择方案二(ShardingSphere-JDBC)的情况:

选择方案三(MyBatis插件)的情况:

六、实施读写分离的最佳实践

6.1 数据一致性处理

从库数据同步存在延迟,这可能导致读取到过期数据的问题。处理方法:

// 实现延迟检测的示例
@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();
    }
}

6.2 事务管理

读写分离环境下的事务处理需要特别注意:

6.4 监控与性能优化

# HikariCP连接池配置示例
spring:
  datasource:
    master:
      # 主库偏向写操作,连接池可以适当小一些
      maximum-pool-size: 20
      minimum-idle: 5
    slave:
      # 从库偏向读操作,连接池可以适当大一些
      maximum-pool-size: 50
      minimum-idle: 10

七、总结

在实施读写分离时,需要特别注意数据一致性、事务管理和故障处理等方面的问题。

通过合理的架构设计和细致的实现,读写分离可以有效提升系统的读写性能和可扩展性,为应用系统的高可用和高性能提供有力支持。

无论选择哪种方案,请记住读写分离是一种架构模式,而非解决所有性能问题的万能药。在实施前应充分评估系统的实际需求和潜在风险,确保收益大于成本。

到此这篇关于SpringBoot实现数据库读写分离的3种方法小结的文章就介绍到这了,更多相关SpringBoot数据库读写分离内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

您可能感兴趣的文章:
阅读全文