2.1 分片键选择:订单号还是用户
分片键(sharding key)决定了每一行数据被写到哪个分片,也决定了查询能不能只命中一个分片。选对了,90% 的查询是单片精准路由;选错了,每次查询都要向所有分片广播。这一节讲我们怎么从两个候选字段里做选择,以及选择错误的代价。
场景:订单系统的两条查询入口
订单系统对外有两条最主要的数据入口。C 端用户打开「我的订单」按用户 ID 查列表;客服与商家后台处理售后时按订单号查详情。前者还会带分页与状态过滤,后者基本是单条精准查询。流量上两者都不小:按用户查占查询量的 55%,按订单号查占 38%,剩下 7% 是商家统计与对账。
这就把选择逼到了二选一:分片键要么选用户 ID,要么选订单号。两个候选的对照如下:
| 评估维度 | user_id 分片 | order_no 分片 |
|---|---|---|
| 按用户查列表 | 单片命中 | 广播 16 片 |
| 按订单号查详情 | 广播 16 片 | 单片命中 |
| 数据均匀性 | 用户订单量不均,有热点 | 订单号随机,天然均匀 |
| 扩容迁移 | 用户数据整体重分布 | 订单号重哈希,粒度细 |
| 售后与客服查单 | 广播,慢 | 单片,快 |
| 表关联(绑定表) | 订单与用户同片,join 可用 | 订单与明细同片即可 |
方案:订单号分片加二级路由
最终选了订单号。理由是「按订单号查」这条链路对延迟最敏感——客服处理退款时如果查单要 2 秒,客诉会立刻堆积;而用户查列表是异步分页场景,慢一点用户感知弱。另外 user_id 分片有一个隐患:大商家一个用户可能下几万单,也可能一个用户 id 都没有订单,数据天然不均匀,分片会倾斜。
订单号分片的代价是按用户查列表要广播。我们的解法是二级路由:写订单时把「user_id 到 order_no 列表」的索引存一份到独立的用户订单索引表(也按 user_id 分片),查询时先查索引拿到 order_no 列表,再精准路由到对应分片。这样把广播查询变成两次单点查询,p99 从 1.8 秒降到 60 毫秒以内。这套二级索引的做法与 Java 实战:订单系统 中「查列表走索引表」的方案同源,只是分库之后索引表变成了独立分片。
| 查询场景 | 路由方式 | 实测耗时 |
|---|---|---|
| 按订单号查详情 | 订单号哈希,单片 | 4ms |
| 按用户查列表 | 先查索引表,再按 order_no 集合路由 | 22ms |
| 按店铺统计 | 汇总表(见 3.1 节) | 120ms |
| 按支付时间范围 | 广播加归并,仅内部用 | 900ms |
配置:分片键与索引表的写法
订单号本身就是雪花 ID,天然均匀随机,哈希取模直接可用。配置上只需要指定分片列与算法:
tables:
t_order:
actualDataNodes: ds_${0..15}.t_order
databaseStrategy:
standard:
shardingColumn: order_no
shardingAlgorithmName: order_mod
t_user_order_idx:
actualDataNodes: ds_${0..15}.t_user_order_idx
databaseStrategy:
standard:
shardingColumn: user_id
shardingAlgorithmName: user_mod
t_user_order_idx 是用户订单索引表,按 user_id 分片。绑定表规则让 t_order 与 t_order_detail(按 order_no 分片)始终落在同一物理库,订单与明细join 不跨片。索引表写入代码如下:
// 下单成功后写索引(与订单写入同一事务各自落到对应分片)
idxDao.insert(new UserOrderIdxDO()
.setUserId(order.getUserId())
.setOrderNo(order.getOrderNo())
.setCreateTime(order.getCreateTime()));
索引表与订单主表不在同一分片(一个按 user_id 一个按 order_no),所以不能用本地事务保证一致,只能靠 3.4 节的对账兜底。这是订单号分片方案的固定代价,要么接受不一致由对账修,要么改成 TCC——我们选了前者,订单业务允许分钟级不一致。
索引表本身的索引设计有个细节值得说。它最常见的查询是「按 user_id 倒序取最近 20 条 order_no」,所以主键索引建 (user_id, id DESC) 而不是 (user_id, create_time)。原因是自增主键 id 与创建时间严格同序,而用主键排序可以避免 create_time 的额外排序成本,深分页场景下差距很明显。另外索引表只保留近 90 天数据,更早的索引走归档后的冷查询链路(见 4.2 节)。
索引表的存储成本也不可忽略。每条索引约 50 字节(user_id 加 order_no 加冗余字段),一天 80 万条就是 40MB,三年近 40GB。按 user_id 分 16 片后单片 2.5GB,放在 buffer pool 里绰绰有余,查询走内存几乎没有磁盘 IO。这笔账在方案评审时要算给老板看:补偿方案的存储与计算成本,换来了 93% 查询的单片路由能力。
踩坑:按用户 ID 分片的热点倾斜
上线前用户 ID 方案的模拟压测里另一个坑暴露得更早:某个测试账号有 300 万条测试订单,全部落在分片 7,该分片磁盘与 CPU 双双告警。生产环境虽然没有这么极端,但 TOP10 用户的订单量确实占了不小的比例。订单号随机分布则完全没有这个问题。评估分片键一定要做「倾斜度检查」——TOP K 的 Key 占比超过 1% 就要警惕。
第三个坑是分片键不可变。订单写入后 order_no 不能改(改了路由就对不上),任何业务需求如果暗示「订单可以换一个用户归属」,都必须在写入前完成。我们遇到过合并账号需求,最后用「新订单写新用户、老订单保留原归属」的折中方案解决。
分片键评估清单
这一节最后给一份可复制的评估清单。分片键候选字段要逐项打分,五项里有三项不达标就换人:
| 评估项 | user_id | order_no | 检查方法 |
|---|---|---|---|
| 查询覆盖度 | 55% 查询带 | 38% 查询带但延迟最敏感 | 统计慢查询 top100 的过滤条件 |
| 分布均匀性 | 不均,热点倾斜 | 均匀 | 采样一个月数据按 Key 前缀分组 |
| 值不可变性 | 可变(合并账号场景) | 不可变 | 梳理所有 UPDATE 语句 |
| 扩容迁移粒度 | 整户重分布 | 行级重分布 | 模拟一次翻倍迁移 |
| 补偿方案成本 | 需二级索引表 | 绑定表即可 | 评估索引表自身的维护代价 |
打分结果是 order_no 四胜一负,这个结论在三年后的每一次架构评审里都经受住了反复质疑,唯一输的那项(查询覆盖度低)由二级索引表补回来。这套清单后来也用在了订单明细表上:明细表同样按 order_no 分片,与主表互为绑定表,一次写入同库完成,join 不跨片。
二级索引表的维护成本要提前算清楚。索引表每天新增 80 万行(每单一条),三年下来 9 亿行——它自己也变成了大表,最后同样按 user_id 分 16 片解决。任何绕开问题的补偿方案,最后都会成为新的问题, workaround 的尽头还是分库分表。 所以评估分片键时要把「补偿方案的成本」一起算进去,而不是假装它不存在。
这个教训后来被写进了团队的架构评审检查单:任何涉及分片键、路由算法的变更,无论看起来多小,都必须走数据重分布评审,禁止在线热替换。
小结
分片键选择的三个核心维度是覆盖最高频查询:排序查询入口的延迟敏感度、数据分布的均匀性、扩容时的迁移粒度,三者综合评估。订单系统选订单号,再建二级索引补偿用户维度的查询,是这次项目最成功的设计决策。如果业务里有两个高频维度,优先保护对延迟最敏感的那个;另一个维度用二级索引补偿,并提前接受索引表自身的维护成本与一致性窗口。
延伸阅读
- Java 实战:订单系统:订单查询入口的原始设计与分片键评估输入
- Go 实战:IM 系统:IM 消息按会话分片的对照实践
- MySQL 实战:分库分表订单系统:回到本书,2.2 节讲路由算法
