数据库读写分离与动态路由实战:Spring Boot + ShardingSphere-JDBC 生产配置

发布时间:2026/8/22 11:32:29
数据库读写分离与动态路由实战:Spring Boot + ShardingSphere-JDBC 生产配置 1. 为什么不用AbstractRoutingDataSource自己写切换逻辑业务刚跑起来时读写比例拉到 8:2 甚至 9:1单库 CPU 和连接数很快就会打满。这时候上读写分离不是赶时髦是系统续命的刚需。很多团队早期会自己搞一套基于AbstractRoutingDataSource AOP 的方案。Demo 里跑着挺顺一上生产就出问题。核心就三点事务里读错库切面切在方法入口如果方法里先读后写或者嵌套调用时事务传播了很容易把SELECT打到从库紧接着UPDATE因为主从延迟报错。改个节点要重启路由规则硬编码在代码或本地配置里扩容从库得发版重启线上不敢动。结果集归并自己扛跨节点ORDER BY、分页、聚合函数自己写合并逻辑极其容易出 Bug内存稍微不注意就 OOM。线上真踩过几次坑后我们直接切到 ShardingSphere-JDBC。它不玩代理那套直接作为 Jar 包塞进 JVM本质上就是个增强型 JDBC 驱动。业务代码改个依赖、配个 YAML 就能跑通路由、改写、归并全在底层处理干净。2. 核心工作流SQL 进来后到底经历了什么别被官方文档的架构图绕晕实际跑在 JVM 里的逻辑很直接。一条 SQL 交给 ShardingSphere会走下面五步解析SQL 进来先扔给 Parser底层是 ANTLR4生成 AST。提取表名、操作类型、排序字段、聚合函数这些关键信息。5.x 版本加了热点 SQL 缓存相同结构的 SQL 第二次进来直接跳过解析阶段损耗可以忽略。路由拿着解析结果去匹配ReadWriteSplittingRule。写操作INSERT/UPDATE/DELETE以及带FOR UPDATE、SELECT ... INTO的强一致读默认绑死主库。普通SELECT按负载均衡算法挑一个从库。改写如果走了分库分片这里会补全缺失的路由字段、替换物理表名。纯读写分离场景下这一步主要处理 Hint 标记透传和事务上下文绑定。执行改写后的 SQL 交给 HikariCP 执行。ShardingSphere 内部有并行执行引擎多个从库的查询会并发发出去而不是串行。归并各节点返回结果后框架在内存里做流式排序、分页裁剪、聚合计算。用的是游标迭代器不会把全量数据一次性捞到内存。事务绑定机制这块是生产最容易翻车的地方。ShardingSphere 会监听 Spring 的TransactionSynchronizationManager。只要当前线程被Transactional包裹框架会自动把路由上下文锁死在MASTER。同一个事务里哪怕你写的是SELECT也会强制走主库。事务提交或回滚后上下文解除下一波请求才恢复负载均衡。这个设计直接规避了“写后立刻读不到”的脏读问题。3. YAML 配置与动态路由拓扑用shardingsphere-jdbc-core-spring-boot-starter 5.4.1 Spring Boot 3。生产环境现在基本全切 YAML配合 Nacos/Apollo 做热更新。spring:shardingsphere:datasource:names:master,slave1,slave2master:type:com.zaxxer.hikari.HikariDataSourcedriver-class-name:com.mysql.cj.jdbc.Driverjdbc-url:jdbc:mysql://10.0.1.10:3306/db_order?useSSLfalseserverTimezoneAsia/ShanghaiuseUnicodetruecharacterEncodingutf8username:${DB_USER}password:${DB_PWD}hikari:maximum-pool-size:30minimum-idle:10connection-timeout:20000idle-timeout:600000max-lifetime:1800000leak-detection-threshold:30000slave1:type:com.zaxxer.hikari.HikariDataSourcedriver-class-name:com.mysql.cj.jdbc.Driverjdbc-url:jdbc:mysql://10.0.2.11:3306/db_order?useSSLfalseserverTimezoneAsia/Shanghaiusername:${DB_USER}password:${DB_PWD}slave2:type:com.zaxxer.hikari.HikariDataSourcedriver-class-name:com.mysql.cj.jdbc.Driverjdbc-url:jdbc:mysql://10.0.2.12:3306/db_order?useSSLfalseserverTimezoneAsia/Shanghaiusername:${DB_USER}password:${DB_PWD}rules:readwrite-splitting:data-sources:rw_ds:write-data-source-name:masterread-data-source-names:slave1,slave2load-balancer-name:weighted_lbtransactional-read-data-source-name:masterload-balancers:weighted_lb:type:WEIGHTprops:slave1:30slave2:70配置里有几个点必须盯死transactional-read-data-source-name: master线上必开。不开的话事务内读请求可能飘到从库主从延迟一来直接查出新数据不一致。负载均衡选WEIGHT还是ROUND_ROBIN看机器配置。如果从库规格不一比如老机器和新机器混部用WEIGHT按比例切流如果规格一致直接用轮询最省心。IDLE_PRIORITY看着高级但实际生产里长尾延迟往往跟网络抖动有关优先选空闲连接反而可能把请求打到网络不稳定的节点慎用。连接池参数maxLifetime一定要比 MySQL 服务端的wait_timeout少 30 秒以上。不然 ShardingSphere 拿着过期连接去路由底层直接报Connection is closed。代码级强制读主有些场景比如支付成功页立刻查订单状态业务逻辑强依赖最新数据。别改事务直接用HintManagerpublicOrderDetailgetOrderAfterPay(LongorderId){// ThreadLocal 级别的强一致路由try(HintManagerhintManagerHintManager.getInstance()){hintManager.setMasterRouteOnly();returnorderMapper.selectById(orderId);}}务必用try-with-resources。HintManager底层是ThreadLocal线程池复用场景下如果不close()下一个借到该线程的请求会继承主库路由直接造成读压力打满主库。4. 线上高频场景处理4.1 动态扩缩容与故障摘除ShardingSphere-JDBC 本身不带 TCP 探针。生产上我们靠配置中心热推送。Nacos 监听到 MySQL 延迟飙升或节点宕机直接把 YAML 里的read-data-source-names列表改掉。ShardingSphere 5.x 支持配置热加载推过去秒级生效流量自动切走。缩容时先把权重置 0观察 Grafana 上的活跃连接数归零后再停实例全程不重启业务。4.2 慢查询隔离报表查询或者全表扫描如果混进业务从库极易把慢查询线程池打满连带阻塞主从同步。稳妥做法是单独起一个analytics只读实例。路由隔离不用搞复杂的 SQL 拦截。直接在业务层加个自定义注解AOP 拦截后调用hintManager.addReadDataSourceShardingValue(analytics_slave);框架解析到该标记直接路由到分析实例。物理隔离比什么限流降级都管用。4.3 跨节点分页查询的坑分库分片叠加读写分离后LIMIT 100, 10这种分页是性能杀手。ShardingSphere 的LimitRewriteEngine会把它改写成LIMIT 0, 110原因很简单路由层不知道全局偏移量只能让每个节点把前 110 条都捞出来然后在内存里排序归并最后截取第 100~110 条。超过 5 万行数据后这种改写必 OOM。线上统一规范弃用OFFSET改用游标查询Seek MethodSELECT*FROMt_orderWHEREid?ANDstatus1ORDERBYidASCLIMIT10利用主键索引范围扫描各分片执行效率极高合并成本也直线下降。4.4 分布式主键生成自增 ID 在分片环境下会产生热点和跨库冲突。直接用 ShardingSphere 内置的雪花算法rules:sharding:key-generators:snowflake:type:SNOWFLAKEprops:worker-id:${WORKER_ID}max-vibration-offset:4095worker-id必须全局唯一。K8s 环境通常通过 InitContainer 把 Pod IP 或 StatefulSet 的 ordinal 哈希成 0~31 的整数注入环境变量。千万别写死时钟回拨或者节点复用会导致 ID 冲突。5. 生产避坑指南5.1 主从延迟的三层防御主从延迟 10ms~5s 是常态别指望架构能把它抹平。业务侧做好三道防线强依赖场景直接 Hint 读主支付、风控、用户核心资料修改写完后立刻查别赌延迟。Redis 脏读标记窗口写操作成功后往 Redis 塞一个ORDER_UPDATE:{id}TTL 设 2~3 秒。查询时先查这个 Key存在就路由主库不存在再走从库。几行代码解决 80% 的客诉。Canal 异步对账兜底资金和账务链路不容错。上 Canal 监听 Binlog跟应用流水做定时对账。发现不一致发补偿任务重试。容忍秒级延迟换高可用。5.2 连接池泄漏排查动态路由包装了物理连接如果 DAO 层没正确释放Hikari 很快报Connection is not available。开启 Hikari 的leak-detection-threshold: 30000。日志一旦出现Connection leak detected顺着堆栈找谁拿了连接没还。MyBatis/Spring Data JPA 基本能自动管理但如果你手动调了DataSource.getConnection()务必在finally里close()。跨方法传递 Connection 是大忌ShardingSphere 的逻辑连接绑定在线程上传出去容易错位。Transactional(readOnly true)能降一点主库 MVCC 快照开销但不会改变路由策略该读主还是读主。5.3 长事务与分布式一致性长事务占着主库连接不释放Binlog 堆积延迟直接爆炸。微服务里坚决禁止跨服务的Transactional。写操作走 Saga 或本地消息表强一致场景上 Seata。ShardingSphere 的 XA 事务桥接能用但两阶段提交性能损耗 30% 起步只适合低频对账别往高频交易链路塞。6. 监控与日常运维中间件跑起来只是开始可观测性不到位出了问题全靠猜。6.1 核心监控指标直接部署shardingsphere-agent暴露 Prometheus 格式指标。Grafana 看板重点盯这三个shardingsphere_sql_execute_latency_millisP95/P99 延迟。从库延迟突增通常伴随着这个指标抖动。shardingsphere_readwrite_splitting_sql_routing_count按select/update和master/slave拆分计数。如果发现select跑到 master 的比例异常偏高检查是不是 HintManager 泄漏或者事务注解用错了。结合 MySQL Exporter 的Seconds_Behind_Master。延迟超过阈值且从库路由请求量没降下来直接触发告警自动降权或切流。6.2 混沌演练别省每月在压测环境做一次“断网权重切换”演练。模拟从库宕机、配置中心推送失败、主库切 VIP 等场景。验证路由降级是否生效、连接池回收是否干净、业务有没有大面积超时。跑顺了上线才不慌。7. 写在最后读写分离不是银弹它本质是用“延迟容忍”和“运维复杂度”去换“读吞吐量”。跑顺这套组合拳核心就三件事管住事务边界别让 HintManager 泄漏配好配置中心热更新。别一上来就搞分库分表先把读写分离的监控和降级链路做实。系统扛不住了架构再往 HTAP 或云原生 Serverless 演进也不迟。代码跑在生产环境少点花哨多点兜底比什么都强。 福利时间如果你正在备战面试或者想要学习其他知识给大家推荐一个宝藏知识库作者整理了一些列 Java 程序员需要掌握的核心知识有需要的自取不谢。知识库地址https://farerboy.com/