
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 做热更新。
yaml
spring:
shardingsphere:
datasource:
names: master, slave1, slave2
master:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://10.0.1.10:3306/db_order?useSSL=false&serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf8
username: ${DB_USER}
password: ${DB_PWD}
hikari:
maximum-pool-size: 30
minimum-idle: 10
connection-timeout: 20000
idle-timeout: 600000
max-lifetime: 1800000
leak-detection-threshold: 30000
slave1:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://10.0.2.11:3306/db_order?useSSL=false&serverTimezone=Asia/Shanghai
username: ${DB_USER}
password: ${DB_PWD}
slave2:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://10.0.2.12:3306/db_order?useSSL=false&serverTimezone=Asia/Shanghai
username: ${DB_USER}
password: ${DB_PWD}
rules:
readwrite-splitting:
data-sources:
rw_ds:
write-data-source-name: master
read-data-source-names: slave1,slave2
load-balancer-name: weighted_lb
transactional-read-data-source-name: master
load-balancers:
weighted_lb:
type: WEIGHT
props:
slave1: 30
slave2: 70
配置里有几个点必须盯死:
transactional-read-data-source-name: master:线上必开。不开的话,事务内读请求可能飘到从库,主从延迟一来直接查出新数据不一致。- 负载均衡选
WEIGHT还是ROUND_ROBIN:看机器配置。如果从库规格不一(比如老机器和新机器混部),用WEIGHT按比例切流;如果规格一致,直接用轮询最省心。IDLE_PRIORITY看着高级,但实际生产里长尾延迟往往跟网络抖动有关,优先选空闲连接反而可能把请求打到网络不稳定的节点,慎用。 - 连接池参数:
maxLifetime一定要比 MySQL 服务端的wait_timeout少 30 秒以上。不然 ShardingSphere 拿着过期连接去路由,底层直接报Connection is closed。
代码级强制读主 :
有些场景(比如支付成功页立刻查订单状态),业务逻辑强依赖最新数据。别改事务,直接用 HintManager:
java
public OrderDetail getOrderAfterPay(Long orderId) {
// ThreadLocal 级别的强一致路由
try (HintManager hintManager = HintManager.getInstance()) {
hintManager.setMasterRouteOnly();
return orderMapper.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 拦截后调用:
java
hintManager.addReadDataSourceShardingValue("analytics_slave");
框架解析到该标记,直接路由到分析实例。物理隔离比什么限流降级都管用。
4.3 跨节点分页查询的坑
分库分片叠加读写分离后,LIMIT 100, 10 这种分页是性能杀手。ShardingSphere 的 LimitRewriteEngine 会把它改写成 LIMIT 0, 110,原因很简单:路由层不知道全局偏移量,只能让每个节点把前 110 条都捞出来,然后在内存里排序归并,最后截取第 100~110 条。
超过 5 万行数据后,这种改写必 OOM。线上统一规范:弃用 OFFSET,改用游标查询(Seek Method):
sql
SELECT * FROM t_order WHERE id > ? AND status = 1 ORDER BY id ASC LIMIT 10
利用主键索引范围扫描,各分片执行效率极高,合并成本也直线下降。
4.4 分布式主键生成
自增 ID 在分片环境下会产生热点和跨库冲突。直接用 ShardingSphere 内置的雪花算法:
yaml
rules:
sharding:
key-generators:
snowflake:
type: SNOWFLAKE
props:
worker-id: ${WORKER_ID}
max-vibration-offset: 4095
worker-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_millis:P95/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/
