做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-JDBC
2.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,应用不知道userId,SQL没法路由。
我当时的处理方式是在业务上做约束。订单号生成时把用户标识相关的路由码嵌进去,设计成“时间前缀 + 用户路由码 + 雪花序列”的结构,其中路由码就是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 + JDK8,ShardingSphere用的5.2.1版本。这里要特别提醒:5.x的配置结构和4.x差别很大,网上一搜能搜到大量旧版配置,name和type对不上,照着配基本跑不起来。
Maven依赖很简单:
<dependency> <groupId>org.apache.shardingsphere</groupId> <artifactId>shardingsphere-jdbc-core-spring-boot-starter</artifactId> <version>5.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 ComplexKeysShardingAlgorithm<LocalDateTime> { @Override public Collection<String> doSharding(Collection<String> availableTargetNames, ComplexKeysShardingValue<LocalDateTime> shardingValue) { Collection<String> 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 { List<TOrder> 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位long:1位符号位+41位毫秒时间戳+10位机器ID+12位序列。它的好处是趋势递增,适合做索引和排序。但坑也不少。第一个坑是时钟回拨:如果服务器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跑一遍核心用例,把每一个可能的路由结果都存在文档里,等上线出问题时有据可查。分库分表本身不难,难的是把每个细节都照顾到。