数据库读写分离与动态路由实战: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,会走下面五步:

  1. 解析:SQL 进来先扔给 Parser(底层是 ANTLR4),生成 AST。提取表名、操作类型、排序字段、聚合函数这些关键信息。5.x 版本加了热点 SQL 缓存,相同结构的 SQL 第二次进来直接跳过解析阶段,损耗可以忽略。
  2. 路由 :拿着解析结果去匹配 ReadWriteSplittingRule。写操作(INSERT/UPDATE/DELETE)以及带 FOR UPDATESELECT ... INTO 的强一致读,默认绑死主库。普通 SELECT 按负载均衡算法挑一个从库。
  3. 改写:如果走了分库分片,这里会补全缺失的路由字段、替换物理表名。纯读写分离场景下,这一步主要处理 Hint 标记透传和事务上下文绑定。
  4. 执行:改写后的 SQL 交给 HikariCP 执行。ShardingSphere 内部有并行执行引擎,多个从库的查询会并发发出去,而不是串行。
  5. 归并:各节点返回结果后,框架在内存里做流式排序、分页裁剪、聚合计算。用的是游标迭代器,不会把全量数据一次性捞到内存。

事务绑定机制 :这块是生产最容易翻车的地方。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-resourcesHintManager 底层是 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 是常态,别指望架构能把它抹平。业务侧做好三道防线:

  1. 强依赖场景直接 Hint 读主:支付、风控、用户核心资料修改,写完后立刻查,别赌延迟。
  2. Redis 脏读标记窗口 :写操作成功后,往 Redis 塞一个 ORDER_UPDATE:{id},TTL 设 2~3 秒。查询时先查这个 Key,存在就路由主库,不存在再走从库。几行代码,解决 80% 的客诉。
  3. 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(),务必在 finallyclose()。跨方法传递 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/updatemaster/slave 拆分计数。如果发现 select 跑到 master 的比例异常偏高,检查是不是 HintManager 泄漏或者事务注解用错了。
  • 结合 MySQL Exporter 的 Seconds_Behind_Master。延迟超过阈值且从库路由请求量没降下来,直接触发告警,自动降权或切流。

6.2 混沌演练别省

每月在压测环境做一次"断网+权重切换"演练。模拟从库宕机、配置中心推送失败、主库切 VIP 等场景。验证路由降级是否生效、连接池回收是否干净、业务有没有大面积超时。跑顺了,上线才不慌。

7. 写在最后

读写分离不是银弹,它本质是用"延迟容忍"和"运维复杂度"去换"读吞吐量"。跑顺这套组合拳,核心就三件事:管住事务边界,别让 HintManager 泄漏,配好配置中心热更新。别一上来就搞分库分表,先把读写分离的监控和降级链路做实。系统扛不住了,架构再往 HTAP 或云原生 Serverless 演进也不迟。代码跑在生产环境,少点花哨,多点兜底,比什么都强。


🎁 福利时间

如果你正在备战面试或者想要学习其他知识,给大家推荐一个宝藏知识库,作者整理了一些列 Java 程序员需要掌握的核心知识,有需要的自取不谢。

知识库地址:https://farerboy.com/


相关推荐
Zane19942 小时前
调用了 async 函数却没执行?一文讲透协程、event loop 与 await
后端·python
Zane19942 小时前
HashMap 为什么要在长度16、容量必须是2的幂这些细节上较劲
java·后端
l1258652 小时前
# RAG噪声知识库治理:一致性与可信度的四层防线设计
数据库·人工智能·python·自然语言处理·langchain
掘金者阿豪2 小时前
若依启动突然报 Redis MISCONF?一次从应用报错到磁盘爆满的完整排查记录
后端
paopaokaka_luck2 小时前
基于springboot3+vue3的支教志愿者管理系统(AI问答、协同过滤算法、Echarts图形化分析)
spring boot·echarts
boooooooom3 小时前
尝尝咸淡:从朴素 RAG 到 Graph RAG——一个烹饪问答系统的三层检索升级之路
前端·后端·llm
聚美智数3 小时前
图片水印-图片剪裁-图片缩放API接口介绍
java·服务器·数据库
JavaPub-rodert3 小时前
Go 后台如何同时兼容 MySQL、PostgreSQL、SQLite 和 SQL Server?从 ShiyuAdmin 看 GORM 多数据库适配
数据库·mysql·postgresql·golang·javapub·王仕宇
瀚高PG实验室4 小时前
SQL优化案例:存储过程、复杂SQL拆成简单SQL提升整体性能
数据库·sql·postgresql·瀚高数据库