订单表4亿行压垮数据库,ShardingSphere-JDBC分库分表实战全记录 订单表从两千多万行涨到接近四亿时间不到两年。某天夜里一次例行对账任务把所有查询线程拖垮连接池被打满报警电话一个接一个。我翻出最近一周的慢查询日志发现同一张订单表的普通查询从3~5毫秒恶化到120毫秒起步这还是在高配置机器和一堆索引加持的情况下。从那一刻起我心里很清楚分库分表必须被安排上日程。而最终选择ShardingSphere-JDBC作为集成的核心组件是团队反复对比、实测之后得出的结论。这篇内容不是官方文档的翻译而是我们做分库分表集成时踩过的真实路径包括为什么选它、依赖怎么落、分片规则怎么配、上线前哪些问题必须提前想清楚。适合正在评估数据增长预案、或者准备用ShardingSphere-JDBC改造订单、流水这类大表的同学参考。1. 单库存活率见底我们为什么最终被逼到分库分表这一步1.1 一张订单表能把整个数据库拖垮谈到分库分表很多人第一反应是“大厂才需要”。这句话放到数据量一年翻四倍的系统里就是纯粹的自我安慰。我们当时的场景很简单订单主表单表行数接近4亿单行因为带了明细快照超过1KB主键索引和两个普通索引的体积几乎追平数据文件本身。随着索引层级加深B树从3层往4层跳一次点查从3毫秒慢慢爬升到几十毫秒这还只是单纯走主键。更致命的是并发一上来连接数直接耗尽。数据库连接数是最容易被低估的瓶颈。连接池通常配到20~50个连接当一个批量对账任务把某条SQL跑成10秒级别后面所有正常订单查询都要排队。慢查询高发时段基本就是业务卡顿时段。主库的CPU、内存、磁盘IO三条指标一起报警时问题已经不是“能不能优化”的层面而是“单库单表这台发动机已经过载”。1.2 缓存、读写分离和归档为什么救不了场遇到读慢常规思路是先上缓存。我们确实在热点店铺和用户维度做了Redis缓存但缓存只能兜住几十个高频热点。订单链路的数据带有强烈时效性用户永远在刷新不同的订单列表缓存命中率并不理想。更关键的是缓存解决不了写入写请求仍然压在主库上索引更新的锁竞争、磁盘IO的写放大、大批量任务带来的临时大事务一个都没减少。归档是另一个常见手段。我们尝试过把三年前的历史订单搬到归档库结果业务部门立刻反馈老订单被售后流程、财务对账频繁引用不能简单移走。到这里结论已经清晰流量和存量都在涨单库单表不管怎么调优都只会在三个月到半年内再次触顶。数据拆分是绕不开的路。1.3 判断“该不该拆”的三个硬指标如果你们也在纠结我给三个硬性指标单表行数超过3000万且月增长超过20%数据库连接池在业务高峰持续出现等待配置调大后内存开销反而拖垮应用索引维护成本已经开始拖累DML批量插入时索引更新耗时占比超过40%。三个指标满足两个以上再考虑拆。如果只是读慢读写分离和缓存更省事如果只是历史数据多归档更省事。真正让分库分表有意义的是“总量和增量同时不可逆地上涨”这个过程一旦启动拆得越晚迁移成本越高。2. 选型拉锯战ShardingSphere-JDBC为什么赢过服务端代理方案2.1 JDBC模式与Proxy模式的本质差异ShardingSphere生态里最常让新人纠结的是JDBC模式和Proxy模式。JDBC模式本质上是嵌入在应用进程里的一个数据源代理应用拿到的DataSource就是一个由它包装过的分片数据源。SQL经过解析、路由、改写、执行、结果合并这些流程每个节点都在应用进程内完成不额外部署中间件服务。Proxy模式则是独立运行一个数据库代理进程应用还是用标准数据库协议连接代理由代理在后端路由到真实的物理库。这两种模式没有绝对优劣但对我们的技术栈来说差异很大。团队主力都是JavaJDBC模式可以用依赖的方式直接集进Spring Boot工程运维不用维护多一套代理服务链路少一跳延迟也更低。而Proxy模式适合多语言团队、或者DBA主导、不希望应用感知分片规则的场景代价是多了一层网络转发和部署运维复杂度。2.2 与同类中间件对比后留下的结论市面上服务端代理类的方案不少但实际验证下来有几个共性痛点第一SQL兼容面取决于代理侧的解析能力复杂的子查询、关联更新很容易成为黑名单第二运维得单独管理代理集群高可用方案要自己兜底第三灰度切换不如应用内数据源来得灵活。ShardingSphere-JDBC在我们当时的POC里表现比较均衡。它支持的SQL解析面够广分片算法可以配置化结合定制类绑定表、广播表都是在配置规则里直接表达不用改业务代码。团队是纯Java技术栈集成开发成本最低。最后拍板还有一个关键理由保留渐进式改造的空间。JDBC模式可以让某几个大数据量表先走分片其他普通表继续走原生数据源不用一口气把整个系统推到中间件上。2.3 版本选择别拿新版找刺激版本这块我们踩过小坑。4.x和5.x在配置模型上差异非常大网上很多老文章给的XML或注解配置基本没法直接用。我们稳定落地在5.3系列Spring Boot 2.7配合对应starter依赖。如果你的项目还在Spring Boot 2.x不用急着追最新版如果已经迁到Spring Boot 3.x请一定确认所选ShardingSphere版本对Jakarta生态的适配情况并单独跑一遍全量回归。生产环境不追求最新追求“稳定且社区验证充分”。另外我们也短暂考虑过自研分片中间件后来果断放弃。分片路由、结果合并、SQL改写这些坑远比想象中多能走开源成熟方案就绝不自造轮子社区里踩过坑的人多文档和案例本身就是隐形资产。3. 依赖落地与多数据源共存的起始陷阱3.1 引入starter依赖后的第一个异常配置pom很简单dependency groupIdorg.apache.shardingsphere/groupId artifactIdshardingsphere-jdbc-core-spring-boot-starter/artifactId version${shardingsphere.version}/version /dependency但引入之后启动时经常会遇到“数据源类型无法确定”或某些自动配置冲突的报错。原因是ShardingSphere会注册一个主DataSource如果工程里已有自己的多数据源管理或其它连接池自动配置可能出现两类DataSource互相争抢。建议在启动类或配置里排除和原数据源自动装配相关的配置同时以ShardingSphere的配置作为分片数据源唯一入口。另外一个值得提前说明的点是分页插件。如果用了某些物理分页插件拦截SQL后再交给ShardingSphere往往会因为语义差异导致排序结果错乱。我们最终在分片数据源这条链路上关掉了物理分页插件改在业务层做游标分页这一点后面会单独讲。3.2 分片库和独立库的隔离不要把酱油缸倒进醋缸改造时我们系统里还有配置表、任务调度表这些与用户无关的小表。如果不加区分让它们也走同一个分片DataSource等于把所有查询都套上分片解析性能和兼容性都会有风险。我们最后的布局是分片表独占ShardingSphere包装过的数据源普通小表继续用Spring原生DataSource。在Service层按事务边界拆开涉及分片表的服务使用分片DataSource涉及配置表的服务使用普通DataSource互不越界。这个看起来简单但早期确实容易写串一旦写串普通表的SQL被当成逻辑表去路由结果就是各种“找不到表”。3.3 YAML起步Java类接管复杂算法5.x配置可以从Spring Boot YAML起步分片规则、绑定表、广播表都集中在一份配置里看得见摸得着适合团队前期统一review。等到分片键是组合条件、或需要对分片结果做额外过滤时再用自定义算法类兜底。配置方式适合场景需要注意的点Spring Boot YAML分片规则简单算法用内建类型即可配置变更要重新发布应用Java API/算法类组合分片键、特殊过滤逻辑类要显式声明为业务组件提前单测混合式基础规则YAML复杂算法Java约定好职责边界避免配置互相覆盖YAML里引用自定义类的写法sharding-algorithms: order_complex_algorithm: type: CLASS_BASED props: strategy: complex algorithmClassName: com.example.order.sharding.OrderShardingAlgorithm分片规则不复杂的尽量用内置算法别人review配置时一眼就能看懂只有内置算法表达不了的时候才交给Java类两边复杂度都控制住。4. 订单表拆分清单分片键、绑定表、广播表与分布式主键4.1 分片键选型user_id决定库时间决定表分片键选错后面全白干。我们候选有user_id、order_id、order_time三个最终选的是库层按user_id取模表层按order_time按月份分表。候选分片键优势问题user_id天然匹配用户订单查询场景数据分布均匀运营侧按时间查会全库路由order_time时间范围查询友好月表可控单用户历史数据散落写入易倾斜order_id雪花ID全局唯一排序分页友好业务查询很少直接带订单ID入口取舍原因是库分片的增量压力被用户维度打散月表则天然控制单表体积和索引深度。代价是SQL里必须同时带上user_id和order_time缺一个就会走全路由。这需要在接口层强制约定我们给查询入口增加统一参数校验解析不到分片键直接返回参数错误从源头堵住低质量SQL。4.2 表关联的三个关键配置绑定表、广播表和月表扩展核心配置如下以t_order和t_order_item为例spring: shardingsphere: props: sql-show: true datasource: names: ds0, ds1 ds0: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://192.168.1.10:3306/order_db_0?useSSLfalse username: order_app password: xxxxxx ds1: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://192.168.1.11:3306/order_db_1?useSSLfalse username: order_app password: xxxxxx rules: sharding: binding-tables: - t_order,t_order_item broadcast-tables: - t_config tables: t_order: actual-data-nodes: ds$-{0..1}.t_order_$-{202401..202412} database-strategy: inline: sharding-column: user_id algorithm-expression: ds$-{user_id % 2} table-strategy: standard: sharding-column: order_time sharding-algorithm-name: order_month_algorithm keyGenerateStrategy: column: order_id keyGeneratorName: order_snowflake t_order_item: actual-data-nodes: ds$-{0..1}.t_order_item_$-{202401..202412} database-strategy: inline: sharding-column: user_id algorithm-expression: ds$-{user_id % 2} table-strategy: standard: sharding-column: order_time sharding-algorithm-name: order_month_algorithm keyGenerateStrategy: column: item_id keyGeneratorName: order_snowflake sharding-algorithms: order_month_algorithm: type: INTERVAL props: datetime-pattern: yyyy-MM-dd HH:mm:ss sharding-suffix-pattern: yyyyMM datetime-lower: 2024-01-01 00:00:00 datetime-interval-amount: 1 datetime-interval-unit: MONTHS key-generators: order_snowflake: type: SNOWFLAKE props: worker-id: 1逐项拆开讲actual-data-nodes描述物理节点分布ds$-{0..1}.t_order_$-{202401..202412}意思就是ds0、ds1每个库各12张月表。database-strategy按user_id取模决定进入ds0还是ds1。table-strategy用内置interval算法根据order_time计算出对应的月份物理表。这里有个关键坑actual-data-nodes里的月份范围写死到了2025年1月没有任何表可用路由直接报错。我们当时是提前一个月执行扩展任务先创建下一季度月表再更新发布配置纯手工操作很容易漏建议把建表SQL和配置变更写成一个定时巡检脚本。绑定表的意义在于t_order和t_order_item的分片规则完全一致声明为绑定表后两者的join查询会被改写成同一个分片节点上的join避免出现2×2的笛卡尔积扫描。广播表则相反t_config这种低频变更、高关联频率的表直接在每个物理库各放一份查询在本库内完成不需要跨越数据源。4.3 雪花ID改造worker-id分配不当会直接撞主键分库分表后数据库自增主键彻底失效我们用SNOWFLAKE生成order_id。第一次上线时踩到一个非常隐蔽的坑测试环境单实例没事预发两台实例同时写入订单ID大量重复唯一索引直接炸了。排查后才发现两台实例的yaml里worker-id默认一致同一个雪花进程生成的时间序列相撞。修复方案是让每个实例的worker-id按IP末位加端口映射来分配并加启动自检如果两台实例解析到相同worker-id就拒绝启动。另外注意如果服务器时钟回拨雪花算法也会出现重复ID后续我们加了带延迟等待的本地时钟校验逻辑兜底。从结果看雪花ID作为全局排序键还有额外收益整体趋势递增分页排序可以直接用order_id做游标。5. 上线后最先打脸的问题深分页、事务边界和跨库关联5.1 深分页性能反而更慢上线前我们预判过“分页会变慢”但真正慢的程度还是出乎意料。原来的老系统用PageHelper翻到第5000页很轻松分片之后同样的pageNum5000, pageSize20每个数据节点都会执行limit 100020, 20然后把所有节点返回的100020条数据拉到应用层排序再去第5000页的数据。这等于把“深分页”的代价乘以了分片数量内存和耗时同时失控。我们的解决办法是放弃跳页式分页改用游标分页-- 前端把上一页最后一条order_id传回来 SELECT * FROM t_order WHERE user_id ? AND order_time ? AND order_id ? ORDER BY order_id LIMIT 20;因为order_id是雪花ID全局趋势递增天然适合做游标。这个方案上线后分页查询耗时稳定在个位数毫秒级。如果业务实在需要跳页我们的建议是先用缓存或搜索索引拿到目标页的ID集合再按ID集合去分片库批量取数千万不要把深分页直接压给分片数据源。5.2 事务边界LOCAL、XA和BASE怎么选分库分表之后数据库事务边界必须重新定义。ShardingSphere-JDBC自身支持三种事务类型LOCAL只保证单个分片内的本地事务XA提供跨分片强一致事务BASE是最终一致性事务需要对接外部的AT事务框架。我们一开始想当然只要方法加了Transactional就觉得安全后来发现跨库写入时本地事务管不到另一个库。调整后的策略分两层核心写链路尽量把分片键带上让数据落在单个分片内这种情况下老老实实用LOCAL事务就够了少数同时更新多个分片的操作才显式切换到XA或BASE事务并且通过开关控制避免每条普通写SQL都背上分布式事务的延时。这里最需要警惕的是“事务失效”的假象有时跨库方法加了事务注解表面没报错但数据一致性已经出问题。建议在关键链路上增加一致性校验任务定时对比分片间的数据而不是依赖事务注解本身。5.3 跨库关联的替代方案绑定表解决了join的笛卡尔积但不等于可以随意跨库join。实际执行时t_order和t_order_item如果已经分到同一个库join没问题一旦要关联的用户维表、店铺维表在其他数据源就只能应用层合并。我们的做法是尽量用冗余换join。订单表直接冗余了店铺名称、商品标题快照字段查询列表页几乎不需要再join详情表真正要取可变状态的地方才走二次查询。另外高频关联的字典表、配置表直接设计成广播表每个分片库都有一份。这套组合下来线上真正需要跨数据源的查询寥寥无几整体性能才稳得住。6. 存量数据迁移与灰度回退的设计6.1 先全量同步再增量双写从单库切到分片库存量数据迁移是我们最紧张的一步。流程上拆成三段新建order_db_0和order_db_1按分片规则建好月表索引、约束统一建齐用同步工具做全量数据导入按user_id取模计算目标库按order_time计算月表全量完成后开启增量双写业务代码同时写老库和新库新库写入失败不能阻塞主链路必须塞进重试队列。双写期间重复数据问题不可避免所以目标表主键和唯一索引必须提前建好靠数据库约束去重。全量同步不要求一次跑完但每个批次要记录进度位点断点续传比一次性抽取更实用。6.2 影子库验证与分片键灰度迁移之前我们在测试环境搭了一套完全等价的影子库先把线上流量复制一部分进去验证SQL路由结果和物理表分布是否符合预期。这一步能发现大量“不带分片键”的隐藏SQL影子库里路由不到目标表直接报错比上线后排查高效得多。灰度放量时我们直接按user_id取模灰度和分片键严格对齐。先是1%用户流量切到新库读取放量观察一周对比新旧库同一批订单的关键字段是否一致确认无差异后再逐步扩大到10%、50%最后才切写流量。灰度期间要有一个开关总控任何异常都能一键把流量全部切回老库。6.3 回退预案开关、兜底和一个月SQL审计回退预案是分库分表改造里最容易忽略的部分。我们的方案是全局路由开关和双写开关分开管理切写之后的老库继续允许写入用来保证任何时刻回退老库数据都是完整的。上线后的第一个月SQL审计必须做起来。我们每天拉取慢查询日志筛选出不带分片键、路由到全部分片节点的SQL逐条通知业务方整改。这个动作坚持一个月后整个系统的路由命中率从94%提升到99.5%以上后面基本就不再担心接口偶发的全路由全表扫描问题。最后再分享一个实际体会集成ShardingSphere-JDBC本身并不复杂复杂的是团队共识和业务SQL治理。如果你们也准备做分库分表建议先把所有SQL列出来逐个标注是否带分片键、是否允许走广播表、是否可以用冗余解决这件事越早做越省心。数据量增长从来不会等你准备好但只要路由规则清晰、回退路径明确拆分就没有想象中那么可怕。