ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring Boot读写分离实战:从数据源路由到事务与主从延迟避坑

Spring Boot读写分离实战:从数据源路由到事务与主从延迟避坑 简介PDF文档围绕Spring Boot数据库读写分离这一典型优化场景展开面向具备一定Java Web基础、希望在业务中降低主库压力并提升并发读性能的开发者系统梳理了基于AbstractRoutingDataSource实现动态数据源路由的完整实现方案。包体为1个PDF文件约48KB内容短小精悍聚焦核心代码与实现思路。目前已有1008人浏览学习说明该方案在真实项目中具有较高的参考热度。文档重点展示了ReadWriteSplitRoutingDataSource路由类、DbContextHolder线程持有者、ReadOnlyConnection注解与AOP拦截器的配合方式读者可以理解ThreadLocal如何按线程维护主从库状态、环绕通知如何自动切换数据源并据此掌握“读操作走从库、写操作走主库”的落地写法还可将相关设计迁移至自己的项目。此外文中还提及事务管理中读写操作的隔离级别等注意事项对应对高并发查询、维护数据一致性以及做数据库层性能调优的中高级开发者都有不错的借鉴意义。1. Spring Boot 读写分离为什么多配一个数据源不代表能上线Spring Boot 要实现数据库读写分离多数人第一反应是配两个数据源、写个路由切面然后让查询走从库。这套思路方向没错但真正落到生产环境你会发现事务、主从延迟、连接池、失败转移每一个都能让你的“读写分离”变成事故现场。最反直觉的一点是在事务里读写分离规则会被打穿刚写入的数据可能下一秒就“读不到”。这套方案适合正在做 Spring Boot 2.3.x/2.6.x 或 3.x 服务端改造的开发者也适合那些想在不引入重中间件的前提下先让读流量从主库上卸下来的团队。我会把从数据源路由到切面、从主从延迟到多从库负载的完整路径讲清楚每一步都给出能直接抄走的代码和参数。2. 从数据源路由开始用 AbstractRoutingDataSource 搭出读写分离最小骨架2.1 为什么 Spring Boot 默认只给你一个 DataSourceSpring Boot 的 DataSourceAutoConfiguration 在 classpath 里只有一个连接池实现时会自动创建一个 HikariDataSource并且把 DataSource、PlatformTransactionManager、JdbcTemplate 全部绑到这个数据源上。也就是说默认情况下你的 Transactional 和 MyBatis 的 SqlSessionFactory 都盯着同一个连接池读写分流这件事根本没有位置。要做读写分离核心思路不是“两个数据源各管各的”而是用一个路由数据源RoutingDataSource作为门面它在每次获取连接时根据当前线程上下文选择主库或从库连接。Spring 提供了一个现成的抽象类 AbstractRoutingDataSource父类维护着一个 targetDataSources 集合子类只需要实现 determineCurrentLookupKey() 方法返回本次要用的数据源 key。这是最轻量、不引入额外组件的做法。如果你用 Spring Boot 3注意包名已经切到 jakarta但 AbstractRoutingDataSource 的位置没变而 2.3.x 到 2.6.x 里 HikariCP 的默认配置项略有差异比如 maximum-pool-size 在 2.4 后默认变为 10配置时别拿旧参数硬套。选型上单体应用或小型微服务用这个方案完全够用如果是高并发分库分表场景可以考虑 ShardingSphere但那是引入一个中间件不是本文讲的这条最小路径。2.2 最小落点定义 DynamicDataSource 和一个 ThreadLocal 上下文先定义一个数据源 key 的上下文用 ThreadLocal 保存当前线程该走哪个库。必须记住在线程池环境下清理否则下一次复用线程会把路由信息带到无关请求里。代码public class DataSourceContext { private static final ThreadLocalString HOLDER new ThreadLocal(); public static void set(String dbType) { HOLDER.set(dbType); } public static String get() { return HOLDER.get(); } public static void clear() { HOLDER.remove(); } }逻辑说明set 在切面里调用get 在路由数据源的 determineCurrentLookupKey 里调用clear 在 finally 里调用。ThreadLocal.remove() 必须做而不是直接 set(null)否则在线程池复用时可能出现错乱。使用其他 InheritableThreadLocal 变体是个坑异步线程会继承主线程的路由 key导致异步任务也跑到从库所以这里明确用 ThreadLocal。参数说明key 用字符串即可建议用常量 MASTER / SLAVE不要用数字索引。后续配置文件里数据源名称一变字符串常量更容易对应。上下文类用 static 方法还是实例方法无所谓但一定要保证全局唯一别在 Controller 里 new 一个。然后是路由数据源public class DynamicDataSource extends AbstractRoutingDataSource { Override protected Object determineCurrentLookupKey() { return DataSourceContext.get(); } }逻辑说明AbstractRoutingDataSource 在 afterPropertiesSet() 时会建立 targetDataSources 与 resolvedDataSources 的映射determineCurrentLookupKey 返回 null 时会走到默认数据源 defaultTargetDataSource。因此如果上下文没有设置路由 key就默认走主库这比默认走从库安全得多。这也是为什么 defaultTargetDataSource 必须手动设置不设置的话连主库都找不到。参数说明构造时传入的两个 Map一个 targetDataSources 包含真实数据源一个 defaultTargetDataSource 指定主库。建议把默认数据源设成主库这样所有没被切面匹配到的请求不会误读从库。如果业务上希望默认走从库必须保证所有写操作都被切面覆盖否则就等着线上事故。2.3 配置多数据源HikariCP 连接池参数怎么分布常见做法是准备两个数据源 Bean主库和从库分别用不同的连接池参数。写入并发不高主库连接池可以小一点读多写少时从库连接池要明显放大但也不能无脑调到 100MySQL 默认 max_connections 是 151每个连接池实例都在建立独立连接多个实例容易把 MySQL 的连接数打满。配置示例Configuration public class DataSourceConfig { Bean ConfigurationProperties(spring.datasource.master) public DataSource masterDataSource() { return DataSourceBuilder.create().type(HikariDataSource.class).build(); } Bean ConfigurationProperties(spring.datasource.slave) public DataSource slaveDataSource() { return DataSourceBuilder.create().type(HikariDataSource.class).build(); } Bean public DataSource routingDataSource() { MapObject, Object targets new HashMap(); targets.put(MASTER, masterDataSource()); targets.put(SLAVE, slaveDataSource()); DynamicDataSource routing new DynamicDataSource(); routing.setTargetDataSources(targets); routing.setDefaultTargetDataSource(masterDataSource()); return routing; } }逻辑说明ConfigurationProperties 直接绑定 spring.datasource.master 和 spring.datasource.slave 前缀。注意 DataSourceBuilder.create().type(HikariDataSource.class) 是为了强制使用 Hikari避免 classpath 里多个连接池实现时 Spring Boot 不知道选哪个。如果没有强制 typeSpring Boot 2.3.x 默认用 Hikari到 3.x 依然如此不过显式指定更安全。参数说明application.yml 里的连接池参数分开写。比如主库 maximum-pool-size20最小空闲5从库 maximum-pool-size50最小空闲10。Hikari 的配置项还有 connection-timeout 和 idle-timeout从库节点延迟高时要调大 connection-timeout默认 30000 毫秒在跨机房场景不一定够。注意从库连接是只读复制链路不要在从库上强制开启 auto-commitfalse那会导致每条查询都开启事务占用连接时间变长。注意routingDataSource 这个 Bean 的名字不要叫 dataSource否则会与 Spring Boot 自动配置冲突。如果项目里已存在其他同名 Bean优先用 Primary 标记路由数据源并考虑排除 DataSourceAutoConfiguration 的自动装配。2.4 把 MyBatis 和事务管理器绑到路由数据源上数据源路由有了但 MyBatis 的 SqlSessionFactory 和 Spring 的事务管理器还需要显式指向路由数据源否则它们拿到的是某个具体数据源读写分离永远不生效。常见做法是手动声明Bean public SqlSessionFactory sqlSessionFactory(Qualifier(routingDataSource) DataSource dataSource) throws Exception { MybatisSqlSessionFactoryBean factory new MybatisSqlSessionFactoryBean(); factory.setDataSource(dataSource); factory.setMapperLocations(new PathMatchingResourcePatternResolver() .getResources(classpath*:mapper/*.xml)); return factory.getObject(); } Bean public DataSourceTransactionManager transactionManager(Qualifier(routingDataSource) DataSource dataSource) { return new DataSourceTransactionManager(dataSource); }逻辑说明Qualifier 指定注入 routingDataSource这样 MyBatis 和事务管理器都通过路由数据源获取连接路由逻辑才能真正介入。如果项目里还用 JdbcTemplate也需要用 Qualifier 注入路由数据源。参数说明事务管理器使用 DataSourceTransactionManager 时默认的事务传播行为是 REQUIRED在读写分离场景下只要事务内第一个操作决定了数据源后面就不会切。所以事务管理器只是绑定路由数据源还不够还要配合切面在事务开始前设置 key详见下一章。3. 切面、事务和主从延迟读写分离真正容易翻车的地方3.1 用 AOP 让查询走从库、写入走主库数据源路由和数据源配置都就绪后需要一个切面把“读”和“写”分开。常见做法是约定方法名前缀select/get/list/count 走从库insert/update/delete/save 走主库。也可以自定义注解 DataSource(SLAVE) 标在 Mapper 或 Service 方法上。注解方式更精确但侵入性强且容易漏标。我一般会先用方法名规则兜底再用注解做例外控制。切面实现Aspect Component public class DataSourceAspect { Before(annotation(ds)) public void switchDataSource(JoinPoint point, DataSource ds) { DataSourceContext.set(ds.value()); } Around(execution(* com.example.service.*.*(..))) public Object routeByMethodName(ProceedingJoinPoint pjp) throws Throwable { String methodName pjp.getSignature().getName(); if (methodName.startsWith(get) || methodName.startsWith(select) || methodName.startsWith(list)) { DataSourceContext.set(SLAVE); } else { DataSourceContext.set(MASTER); } try { return pjp.proceed(); } finally { DataSourceContext.clear(); } } }逻辑说明Around 拦截 service 包下所有方法按方法名判断走哪个库。这里需要特别注意的是Before(annotation(ds)) 和 Around 同时存在时执行顺序不好控建议二选一。上面代码把注解和名字混在一个切面里实际项目中我会把注解切面和名字切面分开并且用 Order 控制优先级不然某个方法同时命中了两个切面路由 key 会被后者覆盖。参数说明切面只切 Service 层不要切 Mapper 层。Service 层方法往往包含多个 Mapper 调用切在 Mapper 上会让一个事务内反复切换数据源极易导致连接错乱。DataSourceContext.clear() 必须在 finally 里执行否则线程池复用后路由信息被带到下一个请求。如果方法名规则不够用可以用自定义注解显式声明。比如Target({ElementType.METHOD, ElementType.TYPE}) Retention(RetentionPolicy.RUNTIME) public interface DataSource { String value() default MASTER; }然后在一个独立的切面里读取注解值。这样可以在 Mapper 方法上写 DataSource(SLAVE)也可以在整个 Service 类上标注默认值。注解里的 value 默认值是 MASTER这与默认数据源保持一致避免有人漏标之后被方法名规则误伤。3.2 事务边界为什么 Transactional 会冻结你的路由这是读写分离最大的黑匣子Spring 的 Transactional 默认用 DataSourceTransactionManager 管理事务而事务管理器在开启事务时就从 DataSource 中获取了一个连接之后整个事务内的所有数据库操作都复用这个连接数据源路由不会再发生。也就是说如果事务内第一个操作走了从库那么事务中后续的写操作也会写在从库上这会产生两个结果要么主从复制把从库覆盖要么你的事务提交在从库上主库完全没有这条数据。更隐蔽的情况是 Service 方法加了 Transactional方法内部先 select 后 update。路由切面先设置 SLAVE事务管理器拿到从库连接update 执行在从库上业务不报错但数据没进主库。这是线上最容易出现的“表面无事数据不见”事故。解决思路有两种。第一种事务内强制走主库在切面判断当前是否已有活动事务若有则直接设置 MASTERif (TransactionSynchronizationManager.isActualTransactionActive()) { DataSourceContext.set(MASTER); }逻辑说明isActualTransactionActive 表示当前线程已绑定到事务说明事务管理器已经取过连接这时必须切换回主库或者让事务管理器使用路由数据源并保持主库连接。这里要注意判断时机要在事务管理器开启事务之后、执行 SQL 之前判断所以切面放在 Service 方法执行前是不够的因为 Transactional 的拦截器比自定义切面更优先除非你的事务边界在更外层。更好的做法是调整切面优先级让路由切面的执行顺序在事务切面之前这样事务管理器拿到的就是主库连接。参数说明如果使用注解 DataSource 强制走从库但又需要事务应该在 Service 方法上明确 Transactional 且路由到 MASTER不要让方法名规则参与。比如批量导入场景里整个事务必须写主库方法叫 batchImportLetters按方法名前缀会被识别为写库OK但有个别方法名以 get 开头却要做事务性写入比如 getAndLock必须用 DataSource(MASTER) 压过方法名规则。第二种思路更简单规定所有 Transactional 方法全部走主库只有无事务的只读查询才走从库。这个规则虽然损失了一部分从库性能但边界最清晰很适合中小团队。你可以在切面里这样写if (methodName.startsWith(get) !TransactionSynchronizationManager.isActualTransactionActive()) { DataSourceContext.set(SLAVE); } else { DataSourceContext.set(MASTER); }这里“是否为活动事务”的检查必须在进入方法前做但 Spring 事务拦截器可能在方法进入前已经创建了事务所以正确姿势是把路由切面的 Order 设为比事务切面更小的值让路由先执行。经验上先用 Order(1) 给事务切面留位置再用 Order(0) 定义路由切面两个切面互不覆盖。3.3 主从复制的延迟刚写的数据读不到怎么办即使事务边界处理好了主从复制延迟依然是读写分离的天然缺陷。MySQL 默认基于 binlog 的异步复制主库提交事务到从库应用完成之间有时间差。真实场景里最常见的是用户提交订单后跳转订单详情订单写到了主库详情页查询走从库从库还没同步到这条记录用户看到一张空白订单页。处理办法很简单但非常关键读多写少的场景里对一致性要求高的读操作必须走主库。订单详情这类刚产生数据的读按订单号或用户 ID 做路由例如订单号末位为偶数走主库或者用 ThreadLocal 标记“当前请求已经发生过写操作”发生写操作后同一请求后续读都走主库。我一般会提供一个 ForceMaster 注解标在 Service 方法上强制走主库比如订单详情、支付回调、库存扣减后的查询。代码Target({ElementType.METHOD, ElementType.TYPE}) Retention(RetentionPolicy.RUNTIME) public interface ForceMaster {}切面里判断方法或类上有没有 ForceMaster有就直接 MASTER。同时MyBatis 的二级缓存不要在这种场景开否则从库延迟带来的数据空洞会被缓存掩盖更难排查。主从延迟如果很严重可以在从库端调整参数比如 MySQL 8.0 的 replication_applier_configuration 里设置 parallel workers或者半同步复制。大多数业务场景先保证关键读走主库比盲目上强一致方案更划算。如果你们用的是云数据库看有没有提供数据延迟监控从库延迟超过阈值时在路由层面自动切到主库这也是一个常见的兜底。从成本角度看写后读一致性要求高就把这部分读也打在主库上要求低的列表页、报表页才放心走从库。4. 读写分离的 5 个避坑点从连接池耗尽到事务失效4.1 从库节点宕机后业务直接 500没有 fallback 的读写分离等于裸奔现象从库数据库宕机所有只读接口瞬间大量超时和 500主库压力飙升排查半天发现是路由数据源只配置了一个从库没有故障转移。原因AbstractRoutingDataSource 只在获取连接时按 key 选择目标数据源它并不知道目标是否健康。从库连接失败异常会直接抛给上层HikariCP 不会自动把流量切到主库。解决至少为从库配置重试和降级。最简单的是在切面 catch 到从库抛出的异常时设置一个标记位让接下来的请求临时走主库同时告警。可以这样Component public class SlaveHealthManager { private final AtomicInteger failCount new AtomicInteger(0); private volatile boolean forceMaster false; public void onSlaveError(Throwable e) { if (failCount.incrementAndGet() 3) { forceMaster true; } } }逻辑说明每次从从库抛错就累加连续三次把全局开关切到 true切面里读取 forceMaster 后强制走 MASTER。这个标记要配合定时任务或监控脚本恢复否则从库恢复后流量也不会回去。恢复逻辑是每隔一段时间尝试放一次流量到从库成功就把 failCount 清零。参数说明阈值 3 不能太小网络抖动就可能误切也不能太大否则单次从库故障会拖垮所有读接口。实际项目里我会用 5 秒内失败 3 次作为阈值这个值可以对标负载均衡器里的健康检查参数。如果用了 Nacos / Consul可以动态修改路由规则把 SLAVE 指向主库。对数据库中间件如 ShardingSphere 自带读负载均衡和禁用从库功能小团队不想自己维护故障转移可以直接交给它。4.2 事务里路由到从库写入“成功”但主库查不到现象订单状态更新接口返回成功主库里查不到状态变更但从库里有数据一段时间后又消失了。原因事务隔离级别下从库连接参与事务事务提交到了从库主库没有这份数据。主从复制是单向的从库上的写操作不会被复制回主库后续从库重放主库 binlog 时把这条数据覆盖掉。解决事务内强制走主库。在自定义切面里用 TransactionSynchronizationManager.isActualTransactionActive() 判断当前是否处于活动事务中如果是直接设置 MASTER。同时检查是不是有人在方法内部调用了 this.getXxx() 这种自调用导致自定义切面没有拦截到。注意Spring 的 Transactional 是代理生效自调用 this.saveOrder() 不会经过代理事务和路由同时失效。要么注入自身代理要么把被调方法拆到另一个 Service。这是血泪经验我见过不止一次代码里写 this.xxx()查了半天才发现切面根本没进。4.3 方法名规则误伤只读事务长事务把连接占满现象某些查询方法逻辑复杂Service 方法名以 get 开头但内部做了多步查询和计算整体加上了 Transactional(readOnly true)。这些请求全部打到从库而业务接口又慢连接池被长时间占用从库连接池先耗尽然后主库也开始超时。原因readOnly 事务虽然不写库但同样会在事务管理器里绑定一个从库连接占用时间取决于方法执行时长。如果接口耗时几秒这个连接就会被占几秒吞吐量稍微一高连接池就爆。解决只读事务不要滥用。能在 Service 方法内部分段查询就不要加事务加了事务就把连接绑定住了。如果确实要保证一致性读可以指定路由到主库并且设置事务超时 Transactional(timeout 3)。另外 HikariCP 的 maximum-pool-size 不要只看总数要算上事务内并发占用50 个连接在慢查询下也就只能扛 10 个并发长事务。注意连接池耗尽时的错误信息通常是“Connection is not available, request timed out after 30000ms”出现这个现象先看慢查询和连接占用不要急着调大 maximum-pool-size。调大连接池只是把问题后移慢查询和长事务才是根因。4.4 SQL 里有跨库操作读写分离后表找不到现象原本主库里有 user 表和 user_log 表读写分离后从库是只读副本某个 SQL 关联查询两张表都正常但某次上线后查询报“Table doesnt exist”。原因从库的复制过滤配置了只复制部分库表或者从库初始化时漏了某张表。读写分离的代码本身没错但数据同步链路不完整导致只要路由到从库就报错。解决上线前用脚本对比主从库的表结构、表数量、行数。MySQL 可以通过 information_schema.tables 对比。同步链路用 canal / DataX / 云数据库自带的灾备链路时务必确认 DDL 会同步不少工具只同步 DML从库不会自动建表。注意如果是分库分表中间件读写分离只是其中的一个规则不要希望数据同步也由它接管。常见做法是从库由 binlog 同步或者使用云数据库的主从实例DBA 需要介入。纯业务开发团队最容易在这里踩坑因为建表语句是 DBA 手动在主库执行的忘了在从库执行或者复制链路忽略了 DDL就会出现只读接口偶发报错。4.5 DataSource 注解和事务切面执行顺序错乱注解被方法名规则覆盖现象某个方法标注了 DataSource(MASTER)但实际执行时仍然走了从库。原因Spring AOP 多个切面的执行顺序由 Order 决定如果方法名路由切面的 order 值更小、优先级更高它在注解切面之前运行设置完 SLAVE 后注解切面再设置 MASTER理论上应该是 MASTER 覆盖 SLAVE。但顺序反了注解切面先执行方法名规则后执行路由被覆盖回 SLAVE。解决给注解切面设置更高优先级比如 Order(0)方法名切面 Order(1)。并且切面里不要直接设置 SLAVE默认逻辑应该是如果数据源 key 已经通过注解设置方法名规则不要覆盖。用 if (DataSourceContext.get() null) 再设置方法名规则这样注解优先。注意动态数据源 key 的删除别清得太早。如果事务内先走主库再走从库事务管理器已经持有主库连接此时修改 key 不会影响当前事务但会影响下一个请求。所以 finally 里的 clear 必须确保在同一线程、同一事务范围内。可以把 clear 放在 Servlet 过滤器里而不是每个切面方法里减少重复删除。5. 从单主单从到多从库负载验证读的是哪个库和两种进阶写法要验证读写分离是否真的生效不要只看接口能返回直接看当前线程拿到的连接属于哪个数据源。常见做法是在 DynamicDataSource 的 determineCurrentLookupKey 里打印日志Override protected Object determineCurrentLookupKey() { String key DataSourceContext.get(); log.debug(current route key {}, key); return key; }逻辑说明每次获取连接都会打一行日志配合压测工具看 SLAVE 出现比例。也可以直接在数据源 Bean 的 getConnection 后通过 HikariPoolMXBean 查看当前活跃连接数确认从库连接池确实有流量。多从库的负载均衡最简单的写法是在路由时轮询。定义一个 AtomicInteger每次 calculate key 时自增取模private final AtomicInteger counter new AtomicInteger(0); private final String[] slaves {SLAVE1, SLAVE2}; Override protected Object determineCurrentLookupKey() { String key DataSourceContext.get(); if (SLAVE.equals(key)) { return slaves[counter.getAndIncrement() % slaves.length]; } return key; }逻辑说明具体从库节点 key 在运行时动态计算比在切面里指定更灵活。需要扩展权重时可以把数组改成带权重的列表按区间取模。进阶参数多从库场景下HikariCP 的 minimum-idle 别设太高每个从库实例连接池都维持大把空闲连接全部加起来对数据库服务器的内存压力不小。我一般主库 minimum-idle2从库 minimum-idle5测试环境甚至直接归零。多从库节点的连接池总数加起来要控制在 MySQL max_connections 的七成以内留出给运维工具和复制链路的余量。最后一个经验读写分离上线后我习惯在压测脚本里做“写后读”场景写入一条随机 key马上查询这条 key如果多次查不到说明关键读没有走主库。这个验证比单纯看路由日志更接近真实伤害。分清强制主库的边界再上多从库负载才不会让读写分离变成事故放大器。希望帮到你。本文还有配套的精品资源点击获取
返回列表