
做Java后端六年最让我头疼的不是业务逻辑而是数据库慢得像蜗牛。去年接手一套订单系统日订单量逼近百万单表数据量冲上几千万接口超时、死锁、半夜告警轮着来。团队讨论一圈结论很一致必须分库分表。方案选型阶段我们在ShardingSphere-JDBC和MyCat之间来回比较最后选了前者落地。这篇文章不是理论科普而是把从分片键设计、容量估算、ShardingSphere-JDBC接入再到数据一致性处理和线上排障的完整过程记录下来。网上讲分库分表的文章很多但大多是概念图真正动手时会踩到一堆配置和路由的坑。所以我把关键决策和踩坑细节都写进来了不藏着掖着。如果你也在做Java百万级订单系统正被表的容量和性能逼到墙角这篇实战记录应该能帮你少走弯路。1. 为什么订单系统必须分库分表1.1 单库单表瓶颈究竟卡在哪先聊点真实的。我们的订单主表有二十多个字段里面还包括几条索引数据量到了三千万行左右时你会发现一切都不对了。主键索引的B树层高从前期的3层逐渐逼近4层二级索引回表的代价也在放大。单纯的select by id看起来还能扛但一旦带上订单状态、时间范围筛选哪怕有联合索引扫描范围也会迅速变大慢SQL每次都把慢查询日志打满。更隐蔽的问题是写入。订单系统的特征是高频写入而且存在热点店铺和热点用户。单表三千万行以后每一次插入都要同时维护主键索引和好几条二级索引IO放大很严重。再加上MySQL的行锁、间隙锁、自增锁冲突并发一上来死锁日志隔一会儿就冒一条DBA半夜被叫醒的次数比我还多。连接池也会先撑不住。单库连接数默认百来个大一点就到上限应用里多线程一压连接等待直接拖垮所有接口。有人会想既然慢那就加机器、做读写分离。可读写分离解决不了写入单点。订单数据是持续累积的只做读写分离主库的写入压力依然在历史数据也在不断膨胀。分库分表的核心目的是把数据按维度拆散到多个物理节点上去让单实例的索引体积、连接数、写入压力都维持在一个可控区间。这不是炫技是数据量到了之后不得不走的一条路。1.2 分库分表前先想清楚这三件事动手之前我建议你先别急着看技术选型先把业务盘明白。第一件事历史数据能不能归档很多订单系统实际上90%以上的查询都集中在最近三个月三年前的老订单基本只有合规对账会捞出来。如果允许做冷热分离那数据保留量会小一个数量级分库分表的压力也完全不一样。我们当时把三年前的订单按月归档到另一个历史库在线订单库只保留三年容量规划瞬间轻松很多。第二件事你的查询维度到底是什么订单系统最典型的两个场景是“用户查自己的订单”和“根据订单号查详情”。如果还有大量运营后台的按店铺、按商品、按状态组合查询那分库分表的代价会非常大因为组合条件往往会跨分片。常用的解法是把在线查询收敛到两个分片键上复杂分析走离线数仓或者Elasticsearch。我们不能既要又要否则分库分表方案很难落地。第三件事未来三年的增长量是多少分片数不是拍脑袋定的。如果现在日均100万单三年后翻倍那现有分片数得按三年后的峰值去预留。前期多分几张表只是配置多敲几个数字后期扩容可是要迁数据的。所以先算账再建表这是我在这次项目里最大的体会。2. 分库分表方案选型为什么最后选了ShardingSphere-JDBC2.1 主流方案对比市面上常见方案无非客户端模式、代理模式以及自研中间层。这里列一个我实际调研时的对比表方案代表实现优势劣势客户端分库分表ShardingSphere-JDBC嵌入应用进程无网络额外跳跃性能高支持Spring Boot生态仅限Java技术栈对业务代码有轻微侵入代理模式ShardingSphere-Proxy多语言可用对业务透明集中管理所有请求多一跳性能有损耗运维组件更复杂数据库中间件MyCat出现早支持PL/SQL和分片能力社区活跃度下滑复杂查询能力相对有限自研数据访问层企业自建路由框架完全贴合业务可定制化强研发周期长后续维护成本高这里说句公道话MyCat在早期确实解决了很多人的问题但它的架构和社区活跃度已经不太适合新项目押注。自研路由更不用提除非你有专门的基础设施团队否则一个分片规则都够全团队忙很久。我们团队是典型的业务研发团队人力有限最终还是在ShardingSphere-JDBC和ShardingSphere-Proxy之间选因为两者底层分片能力一致只是部署模式不同。2.2 ShardingSphere-JDBC适合业务团队的三个理由第一个理由省运维。JDBC模式就是一个普通的数据库驱动应用启动时把ShardingSphere数据源注入Spring容器不需要额外部署一台中间件机器。我们运维资源不多少一个组件就意味着少一堆监控和告警配置。第二个理由性能好。请求没有经过代理层SQL解析、路由、改写、归并都在应用进程内完成。百万级订单系统对延迟敏感能省一跳是一跳。实测下来简单查询的额外耗时在1ms以内的量级业务上完全可接受。第三个理由功能覆盖够用。ShardingSphere-JDBC支持分片、读写分离、广播表、分布式ID、分布式事务等还提供Hint强制路由。我们后续要做数据迁移和灰度切换它也能配合。虽然网上有人吐槽它的SQL解析能力在某些复杂SQL上会抛异常但订单系统的核心SQL大多能改写遇到实在不支持的用Hint或者拆SQL就能解决。比起自己造轮子成熟框架的风险还是小很多。3. 订单表拆分设计分片键、分片策略与容量估算3.1 分片键选择order_id 与 user_id 的博弈这是整个设计里最核心的一步。订单系统的高频查询就两类按用户查订单列表按订单号查订单详情。如果按order_id分片订单详情好说但用户订单列表会散落到所有分片必须做全库路由再归并数据量大时就是灾难。如果按user_id分片用户订单列表很舒服可订单详情就麻烦了因为前端只传一个orderId应用不知道userIdSQL没法路由。我当时的处理方式是在业务上做约束。订单号生成时把用户标识相关的路由码嵌进去设计成“时间前缀 用户路由码 雪花序列”的结构其中路由码就是userId取模后的分片值。这样用户列表查user_id天然定位到分片订单详情查orderId时应用层从订单号里解析出路由码再拼上userId条件SQL也能定位到同一个分片。注意这条路需要全站接口配合所有订单详情接口都要求带上userId或者由网关统一解析订单号后注入。刚开始业务方觉得麻烦后来形成规范后反而觉得很合理。如果没有这个条件那就得建一张全局订单映射表先用orderId查出userId再走分片路由成本会多一次查询。3.2 容量规划算一笔账分片数量最好根据实际情况算而不是照搬别人的32库64表。以我们为例日均订单量峰值100万假设一年365天不归档的话一年就是3.65亿单。如果保留三年在线总数据量约10.95亿行。MySQL单表在现代化机器上为了保证写入和查询稳定单表两三千万行就该考虑拆分了。但我不会把每张表压到极限一般控制在500万行以内。按500万行每表算10.95亿行需要至少219张表。考虑业务突发和未来三年增长最终分成16个物理库每个库64张表总共1024张物理分片表。算下来每张表每月数据量大约35万行三年累计100万行左右远低于500万的警戒线。16个库分摊写入连接单库压力也很均衡。这个数字不是越大越好分片数太多会导致元数据管理、连接管理、分布式事务复杂度成倍上升够用就好。3.3 分片策略取模还是时间范围市面上的策略无非两大类取模和时间范围。纯取模的好处是数据分布均匀坏处是扩库时几乎全量迁移纯时间范围的好处是天然适合归档和冷热分离坏处是热点都集中在当前表单表压力会随着时间越滚越大。我在项目里用的是混合策略数据库分片按照user_id做HASH取模表分片按照订单时间按月路由。这样数据库实例之间的数据相对均匀而单张表只存一个月的数据既不会出现某一张表无限增长又方便按月归档。查询用户最近订单时通常也会带时间范围ShardingSphere会路由到对应的几个月表上做归并。代价是跨月查询会访问多张表但订单表的结构简单、索引清晰归并代价远小于单表几千万行带来的压力。4. 接入ShardingSphere-JDBC的完整落地过程4.1 依赖引入与基础配置我们项目是Spring Boot 2.7 JDK8ShardingSphere用的5.2.1版本。这里要特别提醒5.x的配置结构和4.x差别很大网上一搜能搜到大量旧版配置name和type对不上照着配基本跑不起来。Maven依赖很简单dependency groupIdorg.apache.shardingsphere/groupId artifactIdshardingsphere-jdbc-core-spring-boot-starter/artifactId version5.2.1/version /dependency引入之后需要在application.yaml里配置数据源和分片规则。ShardingSphere会把多个物理数据源统一包装成一个逻辑数据源Spring容器里所有用到DataSource的地方比如MyBatis、JdbcTemplate都不用改只需要把数据源名称和配置对上。我们当时为了快速验证先在本地用两个库两张表跑通最小闭环配置略简化过。刻意没有直接在配置里写死所有表而是用表达式生成spring: shardingsphere: datasource: names: ds0,ds1 ds0: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://127.0.0.1:3306/order_ds0 username: root password: xxxx ds1: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://127.0.0.1:3306/order_ds1 username: root password: xxxx rules: sharding: tables: t_order: actualDataNodes: ds${0..1}.t_order_${0..1} databaseStrategy: standard: shardingColumn: user_id shardingAlgorithmName: dbMod tableStrategy: standard: shardingColumn: user_id shardingAlgorithmName: tableMod shardingAlgorithms: dbMod: type: HASH_MOD props: sharding-count: 2 tableMod: type: HASH_MOD props: sharding-count: 2 props: sql-show: true这段配置的意思是逻辑表t_order真实落在ds0和ds1两个库里每库各有一张物理表t_order_0、t_order_1。路由时根据user_id的值做HASH取模先定库再定表。sql-show打开后可以在日志里看到真实下发的SQL和路由结果调试阶段建议打开上线前再关掉。4.2 标准分片算法配置示例HASH_MOD算法适合分布均匀且分片数固定的场景但它对分片键的取值比较敏感。如果user_id本身就是哈希过的直接取模没什么问题如果是连续自增的可能还要先对值做一次哈希再取模。ShardingSphere的HASH_MOD内部已经对分片值做了哈希处理配置上不用我们操心。如果分片规则更复杂比如我们最终线上用的“库按user_id取模表按order_time月份路由”标准算法就不够用了。这时需要自定义算法类实现对应的接口。我写了一个按月分表的算法核心逻辑是解析逻辑表名t_order_yyyyMM根据order_time计算出应该路由到哪张物理表Component public class OrderMonthAlgorithm implements ComplexKeysShardingAlgorithmLocalDateTime { Override public CollectionString doSharding(CollectionString availableTargetNames, ComplexKeysShardingValueLocalDateTime shardingValue) { CollectionString result new HashSet(); LocalDateTime start shardingValue.getColumnNameAndShardingValuesMap().get(order_time).iterator().next(); String suffix start.format(DateTimeFormatter.ofPattern(yyyyMM)); String target t_order_ suffix; if (availableTargetNames.contains(target)) { result.add(target); } else { throw new ShardingException(分表不存在: target); } return result; } }这个类里有一点要注意ComplexKeysShardingValue里的查询条件可能是一个范围如果SQL里order_time传的是between就需要遍历所有涉及的月份表而不能只取第一条。否则跨月查询会漏数据。处理范围的逻辑不难把开始月份和结束月份之间的所有表都加进result即可。4.3 强制路由和hint机制处理跨片查询不是所有查询都带user_id。客服后台经常只拿一个orderId来查而且订单号里的路由码不一定能转换成userId条件。这种时候ShardingSphere会走全分片路由把16个库的表全查一遍再合并数据少还能忍数据多了就很慢。更稳妥的做法是使用Hint强制路由。用HintManager可以手动指定查询落在某个分片上HintManager hintManager HintManager.getInstance(); hintManager.addDatabaseShardingValue(t_order, 4); hintManager.addTableShardingValue(t_order, 202410); try { ListTOrder list orderMapper.selectByOrderId(orderId); } finally { hintManager.close(); }这里的4是某个userId取模后的库序号202410是对应的月份表。执行SQL时即使条件里没有分片键ShardingSphere也会按照Hint指定的值路由。要注意的是HintManager是个ThreadLocal资源用完后必须close线程池复用线程时一旦忘记关闭会导致后续所有请求都被路由到错误的分片。我在项目里吃过这个亏后来统一封装了一个“开启Hint查询”的工具方法try-finally保证释放才彻底消掉线上偶发事故。5. 分库分表下的事务、ID生成与数据一致性5.1 分布式ID方案选型雪花算法的坑分库分表后最不能用的就是数据库自增主键每个分片各自自增肯定重复。所以我在设计之初就决定用分布式ID最终选了雪花算法。雪花算法的核心就是64位long1位符号位41位毫秒时间戳10位机器ID12位序列。它的好处是趋势递增适合做索引和排序。但坑也不少。第一个坑是时钟回拨如果服务器NTP同步导致时钟往后退同一毫秒内可能生成重复ID。我们的线上实例避开这个问题用了一个带“时钟回拨等待”的增强版回拨时间小于阈值就sleep等待超过阈值就抛异常。第二个坑是机器ID分配多实例部署时如果每个实例的workerId配置一样并发一高必然重复。workerId不能写死在配置文件里最好从配置中心或者数据库统一分配。第三个坑坑得很隐蔽雪花ID是19位long前端JS的Number最大安全整数只有2^53-1直接用JSON返回会在浏览器里精度丢失。这个必须统一把Long类型的ID序列化成字符串我见过不止一个团队上线后才发现订单号莫名变了。5.2 本地消息表最终一致性方案订单创建后通常要发消息给下游做库存扣减、物流创建、积分累计。最开始的方案是在事务里直接发MQ后来发现消息丢失问题很难完全避免要么事务提交前就把消息发出去下游可能读到一条之后订单回滚的消息要么事务提交后再发进程一旦崩溃消息就直接没了。我们最终用的是本地消息表定时任务。核心思路是创建订单的同一个本地事务里往订单表写入数据的同时往本地消息表插入一条待发送记录两张表使用相同的分片键user_id所以路由到同一个物理库保证两个操作要么一起成功要么一起失败。后续有个扫表任务去每个分片里轮询status为0的消息发送MQ成功后再把status改为1。如果MQ发送后没确认就继续重试直到下游通过接口幂等去重。这个方案落地时有几个注意事项。本地消息表必须和业务表用同一个分片键否则ShardingSphere会把两个insert路由到不同分片根本没法保证本地事务。生产环境我用的是独立的job集群扫描所有分片扫描频率30秒一轮顺序不能错先删全表扫描大SQL再按分片键批量扫描避免一次拉太多数据。5.3 分布式事务框架本地实测体会有人会问那跨分库的强一致事务怎么办我也试过引入Seata AT模式。Seata AT模式通过代理数据源在业务SQL执行前后记录undo_log利用全局锁和两阶段提交实现分布式事务。配合ShardingSphere使用时需要把ShardingSphere创建的逻辑数据源再包一层Seata代理然后在每个物理分片库里建undo_log表。我本地做了几百次压测结论很明确能用但性能不便宜。AT模式的分支事务每执行一次都要生成前后镜像和undo_log再加上全局锁的管理创建订单加扣库存这类短事务耗时从原来的几十毫秒涨到几百毫秒。在高频写入的订单主链路上这种损耗是致命的吞吐量几乎腰斩。我个人的建议是百万级订单系统尽量把同步事务边界压到最小把真正需要跨库强一致的操作拆出来或者走TCC大部分场景用本地消息表最终一致就够用了。强一致分布式事务听着美好但在订单核心链路上代价必须提前算清楚。6. 实战中踩过的坑与排查方法6.1 分页查询变慢上线后出现最多的慢SQL不是复杂查询而是简单的订单列表分页。用户端、管理后台都在用LIMIT offset, size。比如要查第5000页的数据ShardingSphere会把LIMIT 250000, 20下发到每个分片每个分片都读取前250020行然后归并排序最后才返回20条。数据量一大这个操作必然把数据库打爆。解决办法是放弃深分页。前端列表改为游标分页也就是“上一页最后一条记录的时间或ID”作为查询条件SQL变成SELECT * FROM t_order WHERE user_id ? AND create_time ? ORDER BY create_time DESC LIMIT 20;这样每个分片都只是按索引范围取20条归并成本极低。后台系统如果需要跳页我会限制最大页数超过一定深度强制用导出或异步任务处理。另外ShardingSphere有个参数叫max-connections-size-per-query默认是1表示每个查询对每个物理库最多用一个连接。如果你觉得分片多导致归并连接慢可以适当调大但要注意连接池别被占满。6.2 分布式全局表广播表处理订单表里经常要关联一些字典数据比如订单状态、支付渠道名称。一开始图省事想把这些表配置成ShardingSphere的broadcastTables让每个分片库都放一份全量副本。配置起来很简单只要在rules里加一行broadcastTables: - t_dict但实际运行后我发现广播表在更新时会向所有分片发写请求如果和业务表在一个本地事务里批量更新无法保证所有分片上的副本同时一致。一个分片成功、另一个分片失败字典数据就永久不一致了排查起来非常痛苦。后来这类数据我全部挪到独立配置库应用启动时加载到本地缓存再定期刷新Redis。分片库里不再存字典表查询时由应用层补全字典字段彻底绕开广播表的一致性问题。6.3 扩容/数据迁移注意事项我们前期按user_id取模分了16个库但如果用户量翻倍16个库可能不够。取模分片最怕扩容因为换一个mod值后大量老数据会路由到错误分片。所以扩容前一定要想好迁移方案。我的做法是“全量增量”双跑。先把新分片集群建好写一个迁移程序从旧库按主键分批读取数据按照新分片规则写入新库同时订阅旧库binlog增量把迁移期间产生的新数据同步到新库。等增量追平后把应用配置切换成新分片规则先灰度一部分流量观察SQL路由和响应时间最后全量切换。这个过程中最容易被忽略的是数据校验不能只看行数一样就认为没问题要按分片键抽检几条记录的完整字段防止迁移程序写入时丢列或改了排序规则。6.4 常见问题速查表最后整理一张踩坑速查表全是实际运维中反复出现的问题现象可能原因排查与解决INSERT执行报无法定位分片SQL没带分片键或分片键映射关系没传检查分片键字段对纯orderId查询用Hint强制路由日志显示SQL没有按预期路由分片算法表达式写错逻辑表和物理表命名不匹配打开sql-show对比实际下发的物理SQL和路由结果分页查询特别慢深分页导致每个分片取大量数据再归并改成游标分页禁止深页数查询雪花ID到前端变了19位Long超过JS Number精度序列化配置里把Long统一转成String广播表数据不一致更新广播表时部分分片失败避免广播表字典数据走独立库缓存跨月查询丢数据自定义算法只取了起始时间的第一张表按月范围收集所有涉及的分表追加到result这些坑很多是配置或设计上的一行之差但爆发在线上就是大事故。我自己的习惯是每次改分片规则前先在测试环境打开sql-show跑一遍核心用例把每一个可能的路由结果都存在文档里等上线出问题时有据可查。分库分表本身不难难的是把每个细节都照顾到。