恒美微站 Logo 恒美微站
  • 首页
  • 关于我们
  • 建站服务
  • 主题模板
  • 案例展示
  • 资讯中心
  • 联系我们

Spring Boot 3 多数据源切换失效排查:切面顺序与事务边界

  • 首页
  • 资讯中心
  • /
  • Spring Boot 3 多数据源切换失效排查:切面顺序与事务边界

相关资讯

Linux服务器故障排查实战:从CPU到内存与磁盘的完整指南 2026/9/30 3:20:33
Unity转微信小游戏资源缓存过期解决方案:版本目录隔离实战 2026/9/30 3:15:33
基于改进PSO优化RBF神经网络的变压器故障诊断方法 2026/9/30 3:15:33

最新资讯

时间循环对抗玩法移植公开:核心机制与实现细节
2800张实拍手机YOLO数据集:工业级目标检测落地实践
LLaMA开源大模型实战:架构、微调与私有化部署全解析
段页式内存管理:段表页表、地址转换与Python模拟
DeepSeek私有化部署实战:从硬件选型到避坑指南
Docker容器技术与微服务架构:从namespaces、cgroups到镜像分层

今日推荐

模型优化器实战:从FP32到INT8的推理加速与精度平衡
LangGraph+FastAPI构建可审计AI编码助手
基于图像预处理与几何特征的人脸脸型发型搭配系统实现

本周热门

从像素到笔画:srt-whiteboard-animation骨架笔迹追踪实现(Zhang-Suen细化+8邻接追踪)
网站建设的英语怎么说?别只背单词,看完这套安全完整流程才敢上线
新手入门看这篇:建设网站加盟避坑指南与SEO实操

本月精选

自研推理加速器Redwood:两周内实现PyTorch模型高效部署的实战教程
V4L2摄像头采集实战:从camera_client.rar到出图全流程解析
从“谁发明了钢琴键”到知识问答智能体:RAG与记忆工程实践

Spring Boot 3 多数据源切换失效排查:切面顺序与事务边界

发布时间:2026/9/30 3:20:33
Spring Boot 3 多数据源切换失效排查:切面顺序与事务边界 上周帮一个朋友排查线上事故他那边 Spring Boot 2.7 升级到 3.2 之后多数据源切换突然失效所有查询都打到了主库上压测时主库连接池直接被打满。翻了两小时代码最后发现问题出在一个Order注解上——切面顺序没注意事务先于数据源切换开启连接早就绑死了。这类问题很典型也是我这些年做 Spring Boot 3 整合 MyBatis-Plus 动态数据源时反复踩到的坑。市面上讲多数据源的文章不少但大部分停留在贴代码能跑一旦涉及 Spring Boot 3 的包结构调整、MyBatis-Plus 逻辑删除与多数据源的叠加、事务与切换的先后顺序就很少有人说清楚了。这篇内容我打算把整套方案从头到尾拆一遍从为什么要做多数据源、技术选型怎么权衡到依赖配置、路由数据源实现、AOP 切面、事务边界处理、逻辑删除字段在多库环境下的表现再到我自己整理的排查速查表。适合正在做读写分离、分库分表前置改造或者手上同时要连业务库和统计库的开发者阅读。小白的部分我会补基础概念有经验的朋友可以直接跳到第 4、5 章看实现细节和避坑点。1. 多数据源这件事先想清楚要解决什么问题1.1 三种典型场景先分清很多人一上来就问多数据源怎么配其实这个问题本身太笼统。我一般会先反问你要解决的是哪一类问题因为不同的场景实现复杂度和选型完全不一样。第一类是读写分离。这种场景下主库负责写从库负责读两边表结构完全一致只是数据同步有延迟。业务代码里通常只需要根据方法语义自动路由insert、update、delete走主库select走从库。这种情况下数据源之间是同构的切换逻辑可以做得非常轻甚至可以用注解 AOP 全自动完成。第二类是多业务库。比如订单库、用户库、商品库拆开了每个库的表结构完全不同业务代码里需要显式指定这次操作去哪个库。这种场景下路由键往往跟业务参数强相关比如按用户 ID 取模、按租户 ID 路由注解式切换就不够用了需要在代码里手动指定或者做一个路由策略引擎。第三类是业务库加统计库。业务库承担在线交易统计库是 T1 同步过来的宽表专门跑报表和复杂聚合查询。这种场景最容易出事因为统计库的查询往往很慢一个慢 SQL 会把在线业务的连接池拖垮所以必须做数据源级别的隔离并且给统计库单独配一套线程池和超时策略。提示动手之前先明确你的场景属于哪一类这直接决定了后面是选注解式切换还是手动切换也决定了事务边界怎么划。我见过太多项目把三种场景混在一起做最后代码里到处是if (isStatQuery) {...}维护成本极高。我的建议是同一套路由框架可以共用但路由策略要分层实现别硬塞进一个切面里。1.2 版本选型的坑Spring Boot 3 不是改个版本号Spring Boot 3 最大的变化是基线和命名空间。它要求 Java 17 起步javax.*全部换成了jakarta.*。这件事对多数据源整合的影响比想象中大。最直接的影响是 MyBatis-Plus 的 starter 换了。Spring Boot 2.x 时代用的是mybatis-plus-boot-starter到了 Spring Boot 3 必须换成mybatis-plus-spring-boot3-starter否则启动时会因为javax.servlet、javax.annotation这些类找不到而报错。这个错误信息通常长这样java.lang.ClassNotFoundException: javax.annotation.Resource看着像是依赖缺失其实是坐标选错了。连接池这边也一样。Druid 在 Spring Boot 3 下要用druid-spring-boot-3-starter老的druid-spring-boot-starter会因为自动配置类里引用了javax包下的类而失效。HikariCP 反而是最省心的Spring Boot 3 默认就带不用额外引。还有一个容易被忽略的点Spring Boot 3 对ConfigurationProperties的绑定校验更严格了。以前 yml 里多写了几个没对应字段的属性启动时只是忽略现在如果开了spring.config.import或者用了ignoreUnknownFields false会直接抛绑定异常。做多数据源配置的时候如果数据源属性是自定义前缀记得检查一下有没有多余的字段。组件Spring Boot 2.x 坐标Spring Boot 3.x 坐标备注MyBatis-Plusmybatis-plus-boot-startermybatis-plus-spring-boot3-starter3.5.5 起提供Druiddruid-spring-boot-starterdruid-spring-boot-3-starter1.2.20 起提供HikariCPspring-boot-starter-jdbc 自带spring-boot-starter-jdbc 自带无需单独引动态数据源dynamic-datasource-spring-boot-starterdynamic-datasource-spring-boot3-starter4.2.0 起提供我自己的习惯是升级 Spring Boot 3 的时候先把所有 starter 的坐标列成一张表逐个确认有没有对应的 boot3 版本再动手改代码。这一步花十分钟能省下后面两小时的排查时间。2. 技术选型手写路由还是用现成组件2.1 手写方案的核心原理拆解Spring 本身就提供了多数据源的基础设施核心是AbstractRoutingDataSource这个抽象类。它的设计非常巧妙对外它就是一个普通的DataSource但对内它持有了一组目标数据源每次调用getConnection()的时候它会回调determineCurrentLookupKey()方法拿到一个 key再从内部 Map 里找出对应的数据源。理解这个机制是理解整个多数据源方案的关键。它本质上是一个代理 查找表的结构代理对外屏蔽了多数据源的复杂度查找表把 key 映射到具体的数据源实例上。而 key 从哪来这就轮到ThreadLocal出场了把当前线程要用的数据源标识存在 ThreadLocal 里切面负责在方法执行前写入、执行后清理。手写方案的好处是可控。你能清楚地知道每一步发生了什么出问题的时候能直接定位到determineCurrentLookupKey返回值是不是对的。缺点是所有细节都要自己处理ThreadLocal 的清理、嵌套调用的栈式管理、默认数据源兜底、事务边界对齐一个都不能漏。2.2 用现成组件的取舍如果不想自己造轮子dynamic-datasource是目前用得最多的现成方案它的 Spring Boot 3 适配版本是dynamic-datasource-spring-boot3-starter。它的 API 设计得很简洁一个DS(slave)注解就能完成切换底层同样基于AbstractRoutingDataSource但是它帮我们做了一堆事情DS支持方法级和类级、支持 SpEL 表达式动态取数据源名、支持DSTransactional实现多数据源事务、支持数据源分组和负载均衡。那到底选哪个我的判断标准是这样的如果你只是做简单的读写分离或者数据源数量固定、路由规则简单用dynamic-datasource能省掉大量代码。但如果你的路由规则跟业务参数强绑定比如要根据租户 ID 做哈希路由或者需要在运行时动态增减数据源那手写的可控性会更好也更容易做单元测试。注意不管选哪个方案dynamic-datasource的DS和 Spring 的Transactional一起用时同样存在切面顺序的问题它内部是靠DsAspect的 order 保证的如果自己又加了别的切面顺序可能被破坏。这里我给一个实操建议先用dynamic-datasource快速把功能跑通把业务逻辑验证完再评估要不要换成手写实现。反过来先手写、后来发现需要动态事务改造成本会很高。选型这件事先把不确定性高的部分验证掉比追求架构完美更重要。3. 依赖与配置的落地细节3.1 依赖清单与版本对齐先说依赖。我一般会显式指定版本号不用 Spring Boot 的依赖管理去覆盖因为 MyBatis-Plus 和动态数据源组件之间偶尔会有版本兼容问题。properties java.version17/java.version mybatis-plus.version3.5.5/mybatis-plus.version dynamic-ds.version4.2.0/dynamic-ds.version /properties dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-jdbc/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version${mybatis-plus.version}/version /dependency dependency groupIdcom.baomidou/groupId artifactIddynamic-datasource-spring-boot3-starter/artifactId version${dynamic-ds.version}/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-aop/artifactId /dependency /dependencies这里有几个点值得说。spring-boot-starter-aop一定要显式引虽然很多 starter 会间接带进来但显式声明能让切面依赖关系更清晰。MySQL 驱动在 Spring Boot 3 里坐标变成了com.mysql:mysql-connector-j老坐标mysql:mysql-connector-java已经废弃混用会导致驱动类加载失败。还有一个隐藏坑MyBatis-Plus 3.5.5 之前的版本在 Spring Boot 3 环境下分页插件的自动配置类有个条件注解判断有问题在某些场景下不生效。我遇到过好几次分页返回全部数据的情况就是版本没升到位。建议直接用 3.5.5 及以上。3.2 yml 配置怎么写才不容易出错配置这块我用的是显式声明多个独立数据源的方式而不是dynamic-datasource的spring.datasource.dynamic前缀。原因是前者更通用将来换成手写方案时配置文件不用改。spring: datasource: master: driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://127.0.0.1:3306/biz_main?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: app_user password: your_password hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 3000 idle-timeout: 600000 max-lifetime: 1800000 slave: driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://127.0.0.1:3307/biz_main?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: app_reader password: your_password hikari: maximum-pool-size: 15 minimum-idle: 5 connection-timeout: 3000 read-only: true mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: assign_id logic-delete-field: deleted logic-not-delete-value: 0 logic-delete-value: 1 table-underline: true两个细节必须强调。第一用jdbc-url而不是url。当你手动构造DataSource的时候HikariCP 只认jdbc-url这个属性名写url会得到一个jdbcUrl is required with driverClassName的启动异常。这个错误我第一次遇到的时候排查了很久因为直觉上会觉得url才是常规写法。第二read-only: true要从库上加。这不只是语义标记MySQL 驱动拿到这个属性后会把连接设为只读某些情况下能触发数据库层面的优化。但要注意如果从库上跑的是带临时表或者带SELECT ... FOR UPDATE的语句只读连接会直接报错所以统计类查询和带锁查询要分开处理。连接池参数我一般按最大连接数 数据库单实例承载上限 / 应用实例数来估算比如数据库能扛 200 连接部署了 8 个实例那每个实例的池子上限就设 20 到 25留点余量。这个算法虽然粗糙但能避免最常见的应用重启把数据库连接打满问题。4. 核心实现路由数据源与切面切换4.1 ThreadLocal 上下文要按栈来管存数据源 key 的容器我不建议只用一个ThreadLocalString而应该用ThreadLocalDequeString也就是栈结构。原因很实在方法调用会嵌套。比如OrderService.query()默认走从库内部调用了OrderMapper.updateStatus()需要走主库如果只有单值主库执行完就把整个线程的数据源标记清空了外层方法继续执行时就丢了上下文。public final class DataSourceContextHolder { private static final ThreadLocalDequeString CONTEXT ThreadLocal.withInitial(ArrayDeque::new); private DataSourceContextHolder() { } public static void push(String dataSourceKey) { CONTEXT.get().push(dataSourceKey); } public static String peek() { DequeString deque CONTEXT.get(); return deque.isEmpty() ? null : deque.peek(); } public static void poll() { DequeString deque CONTEXT.get(); if (!deque.isEmpty()) { deque.poll(); } } public static void clear() { CONTEXT.remove(); } }注意最后那个remove()它和poll()的作用完全不同。poll()只是把栈顶弹掉ThreadLocal 本身还挂在线程上remove()才是真正把整个 ThreadLocal 从线程本地变量表里摘掉。在线程池环境下如果只poll()不remove()当某个线程因为异常导致栈没弹干净下一次复用这个线程时就会读到脏数据源。提示DataSourceContextHolder.clear()一定要放在切面的finally里而且要保证无条件的 finally不要在 finally 里再写 if 判断。我线上遇到过一次诡异的数据串库两个租户的数据莫名交叉了。最后定位到就是 ThreadLocal 没清理干净线程池复用线程时读到了上一个请求残留的数据源 key。这个问题在本地测试几乎复现不了因为本地每次请求都新建线程只有上线后用了线程池才会暴露。4.2 路由数据源子类的写法继承AbstractRoutingDataSource只需要实现determineCurrentLookupKey()这一个方法。写法很简单但兜底逻辑要想清楚。public class DynamicRoutingDataSource extends AbstractRoutingDataSource { Override protected Object determineCurrentLookupKey() { String key DataSourceContextHolder.peek(); return key; } }返回 null 的时候AbstractRoutingDataSource会自动使用setDefaultTargetDataSource()设置的那个默认数据源。所以我这里直接返回 null不做额外兜底让默认数据源机制去处理。这样做的好处是上下文没设置时行为是确定的不会因为写死了一个字符串而在数据源重命名时出问题。配置类里注册两个目标数据源和一个路由数据源Configuration public class DataSourceConfig { Bean(masterDataSource) ConfigurationProperties(prefix spring.datasource.master) public DataSource masterDataSource() { return DataSourceBuilder.create().type(HikariDataSource.class).build(); } Bean(slaveDataSource) ConfigurationProperties(prefix spring.datasource.slave) public DataSource slaveDataSource() { return DataSourceBuilder.create().type(HikariDataSource.class).build(); } Bean Primary public DataSource dynamicDataSource( Qualifier(masterDataSource) DataSource master, Qualifier(slaveDataSource) DataSource slave) { MapObject, Object targetDataSources new HashMap(4); targetDataSources.put(master, master); targetDataSources.put(slave, slave); DynamicRoutingDataSource routing new DynamicRoutingDataSource(); routing.setTargetDataSources(targetDataSources); routing.setDefaultTargetDataSource(master); return routing; } }Primary这个注解不能省。项目里可能存在多个DataSource类型的 BeanSpring 在自动配置SqlSessionFactory的时候会按类型注入如果不标Primary会抛NoUniqueBeanDefinitionException。同样的原因如果项目里用了DataSourceTransactionManager它注入的也应该是这个路由数据源而不是某个具体数据源。4.3 切面顺序整个方案最容易翻车的地方先说结论切换数据源的切面必须比事务切面先执行。这条规则背后是连接绑定的时机问题。Spring 的声明式事务在方法进入时会通过DataSourceTransactionManager调用dataSource.getConnection()拿到一个连接并把它绑定到当前线程。对于路由数据源来说这个getConnection()调用会触发determineCurrentLookupKey()。也就是说数据源是在事务开启的那一刻就定死的事务进行中再切换上下文已经晚了——连接不会换。所以切面的 order 必须小于事务的 order。Spring 事务拦截器的 order 默认是Ordered.LOWEST_PRECEDENCE也就是Integer.MAX_VALUE我们要让自定义切面的 order 明显小于它。Target({ElementType.METHOD, ElementType.TYPE}) Retention(RetentionPolicy.RUNTIME) Documented public interface DS { String value() default master; }Aspect Component Order(Ordered.HIGHEST_PRECEDENCE) public class DataSourceAspect { Around(annotation(ds)) public Object around(ProceedingJoinPoint point, DS ds) throws Throwable { String key ds.value(); DataSourceContextHolder.push(key); try { return point.proceed(); } finally { DataSourceContextHolder.poll(); } } Around(within(ds) !annotation(com.example.ds.DS)) public Object aroundClass(ProceedingJoinPoint point, DS ds) throws Throwable { DataSourceContextHolder.push(ds.value()); try { return point.proceed(); } finally { DataSourceContextHolder.poll(); } } }这里我拆了两个切点方法级注解优先级高于类级注解。!annotation(DS)这个条件的作用是当方法上已经有DS时类级切点就不再介入避免 push 两次导致栈里有两个相同的值虽然结果一样但会让栈的深度语义变得不清晰。Order(Ordered.HIGHEST_PRECEDENCE)用在这里是有讲究的。HIGHEST_PRECEDENCE是Integer.MIN_VALUE理论上没有比它更靠前的了。但要注意如果项目里还有别的切面也用了这个 order 值它们的相对顺序就不确定了这时候应该用具体的数字比如Order(-100)留出调整空间。我自己更倾向于写具体数字可读性更好也方便排查。4.4 多数据源下的 MyBatis-Plus 配置Spring Boot 3 环境下MyBatis-Plus 的自动配置会去容器里找DataSource找到我们标了Primary的路由数据源之后正常创建SqlSessionFactory。分页插件、乐观锁插件这些InnerInterceptor只需要注册一次放在MybatisPlusInterceptor里全局生效跟具体数据源无关。Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); return interceptor; } }有个细节容易忽略PaginationInnerInterceptor一定要显式指定DbType.MYSQL。不指定的时候MyBatis-Plus 会尝试从连接里推断数据库类型如果你的从库是 MySQL、统计库是另一个数据库推断逻辑在切换数据源时可能返回不一致的结果导致分页方言错乱。显式指定能规避这个问题。5. 事务、逻辑删除和多数据源的那些叠加效应5.1 一个事务里切换数据源是无效的这句话我要重复一遍因为几乎每个做多数据源的人都会在这里栽一次一个已经开启的 Spring 事务内切换数据源不会生效。原因前面讲过连接在事务开启时就已经和具体数据源绑定了后续的上下文变化影响不到已经拿到的连接。那实际业务里遇到主库写完立刻要读主库的需求怎么办有三种处理方式。第一种是把查询放到事务外。把写操作和读操作拆成两个方法写方法有事务读方法没有这样读方法进入时才会触发新的getConnection()上下文切换就能生效。缺点是代码结构会变两个方法之间没有事务保证。第二种是用REQUIRES_NEW传播级别。给需要切库的方法加Transactional(propagation Propagation.REQUIRES_NEW)它会挂起当前事务新开一个事务也就会重新获取连接。这种方法有效但要注意连接池压力两个事务同时持有连接池子小的时候容易死锁。第三种是强制走主库。在注解上显式写DS(master)并且确保这个方法的调用链上最外层进入时就是主库。这个最省事适合写后立刻读这种强一致场景。注意REQUIRES_NEW和DS一起用时DS的切面 order 必须比事务切面靠前否则新事务开启时读到的还是旧的数据源上下文。这个组合是排查难度最高的场景之一。我在一个订单项目里就遇到过这个问题下单后立刻查订单详情走的是从库因为主从延迟导致查不到刚插入的数据。当时的解决方案是给下单后立即查询的链路加了一个强制主库的注解等业务方接受最终一致之后再逐步放开。5.2 逻辑删除字段在多数据源下的表现MyBatis-Plus 的逻辑删除热词里那个 查询deleted 说的就是它在多数据源环境下有两个高频坑。第一个是全局配置和表结构不一致。如果你在global-config里配置了logic-delete-field: deleted那 MyBatis-Plus 会对所有表在查询时自动追加deleted 0条件。如果某个库里的某张表根本没有deleted字段比如统计库里的宽表执行查询就会直接报Unknown column xxx.deleted in where clause。这个问题在多数据源下特别常见因为不同库的表结构往往是不同步的。解决方式有两种要么在每个数据源对应的库里都补上deleted字段要么不要用全局配置改成在实体类字段上单独加TableLogic。我更推荐后者因为显式声明比隐式全局规则可控得多。Data TableName(t_order) public class Order { TableId(type IdType.ASSIGN_ID) private Long id; private String orderNo; TableLogic(value 0, delval 1) private Integer deleted; }这里delval命名有点反直觉它表示的是已删除时的值。value 0是未删除的值。这两个参数写反了会导致删除逻辑变成把未删除标记成未删除看似没报错实际数据全乱。第二个坑是逻辑删除和唯一索引的冲突。加了逻辑删除之后删除操作其实是一条UPDATE ... SET deleted 1记录还在表里。如果原本的业务表上有唯一索引比如order_no唯一删除一条记录后再插入相同的order_no就会撞唯一约束。常见的做法是把唯一索引改成(order_no, deleted)联合唯一但这样做又有个问题同一order_no只能被逻辑删除一次删第二次时deleted值还是 1还是会冲突。真正稳妥的做法是在deleted字段上存时间戳或者删除时的 ID让每次删除的值都不一样。MyBatis-Plus 支持在delval里写 SQL 函数比如delval UNIX_TIMESTAMP()但这样字段类型就变成了 bigint要提前规划好。这个方案我在两个项目里用过验证下来是最干净的。5.3 跨库查询和分布式事务的边界有些需求表面上是一个查询实际上要跨两个库拼数据。比如查用户详情同时附带订单统计。如果这两个数据分别在用户库和订单库就会出现一次请求两次切库的情况。这种场景要特别小心因为SqlSession在一次操作中会缓存连接。如果两个查询在同一个线程里连续执行而且没有事务包裹理论上每次getConnection()都会重新路由是可以切换的。但 MyBatis-Plus 的一级缓存是基于SqlSession的两次查询之间如果SqlSession没有重新创建缓存行为可能和预期不一致。我一般会明确区分跨库查询不要放在一个SqlSession生命周期里。实际的工程做法是把跨库调用拆成两个独立的方法通过应用层组装结果。牺牲一点性能换取行为的确定性这笔账是划算的。至于跨库写入的一致性如果业务上真的需要强一致那就要引入分布式事务框架了。但要提醒一句分布式事务的复杂度远高于多数据源本身绝大多数业务场景用最终一致性加补偿机制就够了。别为了多数据源引入一整套分布式事务链路那是另一个量级的工程投入。6. 常见问题速查与排查技巧6.1 问题速查表下面这张表是我这几年攒下来的基本上把多数据源切换相关的典型故障覆盖了。遇到问题先查这张表能省掉大量试错时间。现象大概率原因排查方法解决方式启动报 jdbcUrl is required配置里写了 url 而非 jdbc-url检查 yml 键名改成 jdbc-url报 javax.annotation.Resource 找不到starter 坐标没换成 boot3 版本检查 pom 依赖换 mybatis-plus-spring-boot3-starter所有查询都走主库切换无效切面 order 大于事务 order看切面顺序日志切面加 Order(负数)偶发数据串库ThreadLocal 未清理检查 finally 块clear 放在无条件 finally分页返回全量数据分页插件未注册或版本低看 SQL 是否带 LIMIT升 MyBatis-Plus 到 3.5.5报 Unknown column deleted全局逻辑删除配置与表结构不符检查目标库表结构改用 TableLogic 单字段声明事务内切换数据源失效连接在事务开启时已绑定确认是否在事务内用 REQUIRES_NEW 或拆出事务从库查询报连接只读从库配了 read-only 但执行了写操作检查 SQL 类型写操作强制主库连接池被打满池子大小与实例数不匹配看活跃连接数按实例数重新计算上限6.2 三个我踩过最深的坑第一个坑是切面顺序导致的事务内切换失效。这个前面说过了但我要补充一个排查技巧在determineCurrentLookupKey()里打一行日志把当前线程和数据源 key 打出来。如果发现事务方法执行时 key 是 null走了默认数据源那就是切面顺序反了。这个方法比对着代码想半天快得多。第二个坑是从库的字符集和主库不一致。这个跟数据源切换本身没关系但多数据源部署时经常出现因为从库往往是单独搭建的。表现是同样的中文查询主库正常从库乱码。排查方法是执行SHOW VARIABLES LIKE character_set%对比两边。从库的character_set_server和collation_server必须和主库一致否则 join 的时候还会报排序规则不兼容。第三个坑是连接池的 max-lifetime 设置不当导致从库连接失效。MySQL 服务端有个wait_timeout默认 8 小时空闲连接会被服务端断开。如果客户端池子的max-lifetime设得比服务端wait_timeout还大就会复用到已经被服务端断掉的死连接报Communications link failure。我的经验是max-lifetime设成服务端wait_timeout的 80% 左右比如服务端是 8 小时池子设 5 到 6 小时。HikariCP 会主动做连接存活检测这个机制配上合理的max-lifetime基本能根治这个问题。6.3 上线前我必做的几项检查每次上线多数据源相关的改动我都会走一遍这个清单分享出来供参考。第一项是强制核对切面顺序。在切面上写死Order(-100)这种明确数字不要用HIGHEST_PRECEDENCE然后在启动日志里确认切面注册顺序。Spring 在 debug 级别会打出切面链虽然日志很长但值得看一次。第二项是给每个数据源做一个健康检查接口。启动后手动调一次确认每个数据源都能连通并且返回SELECT 1的结果。这比等业务流量打进来再发现问题好得多。RestController RequestMapping(/health/ds) public class DataSourceHealthController { DS(master) GetMapping(/master) public String master() { return master-ok; } DS(slave) GetMapping(/slave) public String slave() { return slave-ok; } }第三项是在测试环境构造数据源切换失败场景。比如手动把从库停掉看应用是否降级到主库连接超时是否在预期范围内。这个测试能暴露出池子配置和超时配置的问题比看配置文件里的数字直观得多。第四项是确认逻辑删除字段在所有相关库里的存在性。写一条脚本把所有目标库的所有表都扫一遍检查deleted字段是否存在类型是否一致。这一步在分库场景下尤其重要因为很容易漏掉某个历史遗留的分片。7. 一点个人经验做多数据源这件事代码本身不难难的是把边界情况想全。我自己的体会是把数据源是在什么时候确定的这个问题彻底搞清楚剩下的问题就都好解决了。连接是在事务开启时确定的或者在没有事务时是每次操作确定的这两句话能解释绝大多数切换不生效的现象。还有一个习惯值得养成给路由数据源的实现加一个开关。生产环境如果出问题能通过配置一键把所有流量切回主库先保证业务可用再慢慢排查。这个开关我在三个项目里都留了两次真的用上了。至于那个deleted字段的问题我的建议是尽早统一规范。要么所有库都统一加字段、统一用全局配置要么所有实体都显式标注TableLogic。最怕的是一半用全局、一半用注解出了问题都不知道该去哪儿找。约定比技巧重要尤其在多数据源这种细节密集的场景里。

关于恒美微站

恒美微站专注于为个体商户、工作室提供极简自助建站服务,让每个人都能轻松拥有专业网站。

快速链接

  • 关于我们
  • 建站服务
  • 主题模板
  • 案例展示
  • 资讯中心

服务项目

  • 可视化建站
  • 拖拽编辑
  • 主题定制
  • SEO 优化
  • 网站托管

联系方式

  • 📍 地址:北京市朝阳区建国路 88 号
  • 📞 电话:400-888-8888
  • ✉️ 邮箱:info@hmyw.cn
  • 🕐 时间:周一至周日 9:00-18:00

© 2024 恒美微站 hmyw.cn 版权所有 | 京 ICP 备 12345678 号