做优惠券/权益发放这些年,我接手过一个让我印象很深的系统:日均发放 500w 张券码,券码表从单库单表演进到必须分库分表。当时团队里第一个跳出来的方案,就是按 order_id 分片——原因很简单,券是挂在订单下面的,订单表早就分了,券表顺着订单走似乎顺理成章。结果这个"顺理成章"在线上埋了一颗大雷,我们最终弃用了 order_id,改用券实例 ID 做分片键,才把集群扩展性、查询延迟和运维复杂度一起拉回正轨。这篇文章就是这次分库分表键选型的完整复盘:为什么 order_id 不合适、券实例 ID 优势在哪里、迁移路上有哪些容易被低估的坑,以及我在事后总结出的选型清单。
内容适合正在做券码、权益、卡券类系统,或者公司正要启动分库分表改造的朋友。你不需要看过 ShardingSphere 源码,只要熟悉 MySQL 和常见的分库分表概念,就能跟着这篇文章把选型逻辑理清楚;实操部分我也给了可以直接套用的路由规则、兜底方案和压测指标,改完就能用。
1. 先看访问模式,再谈分片键:券实例与订单的数据归属差异
1.1 券码发放链路里,到底哪张表在扛 500 万
先明确核心数据模型。一个典型的发券链路通常有三层:
- 订单表(order):用户下单、支付成功后的订单记录,一个订单可包含多条券。
- 券批次表(coupon_batch):运营创建的券批次,例如"双11满100减50券",批次里有规则、总量、有效期。
- 券实例表(coupon_instance):每一张实际发放的券码对应一条记录,拥有全局唯一的券实例 ID(coupon_instance_id),同时记录 order_id、batch_id、status、领取人 user_id、核销时间等字段。
在日均 500w 的场景里,真正的写入洪峰落在券实例表:一个批次一次性生成几十万条记录,状态字段要经历"未领取→已领取→已核销/已过期"的多次更新。查询压力也同样集中在券实例表:用户领券时更新状态,核销时按码查券,对账时按批次扫。所以分库分表几乎必然落在券实例表上,而分片键的选择,直接决定了后面的每一次查询是"单分片直达"还是"全库广播"。
1.2 分片键选型的三个评估维度
我在做选型前,会把所有候选键放在三个维度下打分:
- 分布均匀性:作为分片键的字段,其取值经过散列后能否均匀落在所有分片上。反例就是把自增主键直接 mod 分片数——如果业务有规律地批量插入,mod 后照样倾斜。
- 查询覆盖度:分片键必须能出现在最高频、最核心的 SQL 的 where 条件中,且高频访问类型能覆盖逻辑表访问的大头。如果高频查询不带分片键,就只能全库扫描。
- 不可变性:分片键的值一旦写入就不应该变。用 user_id 分片,用户 ID 不变就没问题;用手机号分片,一旦支持换绑手机号,就是迁移噩梦。
1.3 一个容易踩的误区:先有订单再发券
很多团队第一反应是 order_id,背后的逻辑很简单:数据模型上券实例表里有个 order_id 外键,ORM 建关系的时候默认按 order_id 关联,写代码时也习惯性用订单上下文去捞券。这是典型的"从订单视角看券"。
但券的生命周期和订单完全不同。订单支付完成后就进入静态状态,券却要持续流转几个月甚至跨年。日活查询里 90% 是"这张券现在能用吗""这个券码核销了没",这类查询天然只带券实例 ID,不带订单号。数据模型上的主从关系,不能直接移植成分片路由关系——这是我对这次踩坑最大的认知转变。
2. order_id 为什么看起来很合理,其实是把隐性债务背在身上
2.1 "反正先有订单再发券"的思维陷阱
系统最初确实是订单驱动的:用户下单支付,回调里生成券,券表里存 order_id,一切看起来顺理成章。问题在于,这个"顺理成章"只描述了数据从哪来,没描述数据往哪查。
打个比方:你在一家公司入职时,工位是跟着项目组分配的。后来项目组解散了、你换组了,但门禁卡还是按老项目组的路由规则刷。老项目组的门禁能保证你进门吗?有些门进不去,你就得跑遍整栋楼找能刷开的门。order_id 分片就是这样——数据不是跟着订单走的,是你的查询请求跟着券走,路由却停留在订单逻辑里。
2.2 生命周期错位:订单归档了,券还在流转
订单数据一般有明确的归档策略:3 个月后进冷库,1 年后大表清理。但券实例表不能这么干,券的有效期可能长达一年,核销记录还要保存更久。把券实例表按订单分片,意味着订单侧的任何物理操作(归档、清理、表结构变更)都会牵扯到券实例数据,两边的运维节奏被绑死。
更隐蔽的问题是:订单量的波动和券发放量的波动不是同一个周期。订单可能集中在晚上下单,券发放却可能被活动秒杀、批量赠送推高到白天任何一秒。按订单分片,本质上就是让券数据的命运绑定在订单数据的统计规律上,大促一来,两个规律叠加,谁也不知道热点会砸在哪。
2.3 分片键分布的第一个危机:大订单热点
回到最致命的点。hash(order_id) % 64的前提是"每个订单的券数量大致相同",但发券业务的现实是幂律分布:绝大多数订单 1~2 张券,一小撮订单一次买几百上千张。
假设某次团购订单一次性买了 500 张电子兑换券。这 500 条券实例记录会因为同一个 order_id 全部命中同一个分片。平时每分片写入 7.8w 行,大促时某分片瞬间多 50w 行写入,其他 63 个分片在旁边看戏。这个分片的主库 CPU、IO、binlog 同步全部被打满,主从延迟从几百毫秒飙升到几十秒,而用户侧对这批券的领取、核销操作恰恰最集中。
更隐蔽的是,这类大订单往往是"公司采购""渠道赠送",不是普通 C 端用户行为,一旦出问题影响的是整个批次,运营和客服的工单直接起飞。
2.4 分片键与查询条件的错配:全库广播才是常态
如果只是写入热点,也许还能忍。真正无法忍的是:几乎每一条高频查询都不带 order_id。
用户领券:
UPDATE coupon_instance SET status = 2, claim_user_id = ? WHERE coupon_instance_id = ? AND status = 1;核销查券:
SELECT coupon_instance_id, batch_id, expire_time, status FROM coupon_instance WHERE coupon_instance_id = ?;这两条 SQL 是券系统里 TPS 最高的。它们只有 coupon_instance_id,没有 order_id。在 order_id 分片的 schema 下,中间件拿到这样的 SQL 只能全分片广播,把所有分片的结果聚合回来再判断命中了哪个。80 个分片就是 80 次子查询合并,每一条核销请求都要等最慢的那个分片。分片数越多,广播成本越高——理论上加分片是线性扩容,实际变成"加节点提升一点并发,但每次查询的放大系数也在涨",扩到某个阈值后收益归零甚至为负。
3. 线上事故复盘:order_id 分片在五百万日发量级下的崩坏瞬间
3.1 第一次报警:单分片写入延迟飙升,其余分片 P99 正常
场景:大促前夜,运营在后台创建了一个 20w 库存的券批次,系统按批次生成券实例。由于该批次的券全部挂在同一个"企业采购订单"下,hash(order_id)之后 20w 行集中打进同一个分片。
当时的现象是:整体集群的 CPU 并不高,但某一个分片的主库打到了 80% 以上,从库同步延迟 20 分钟,该分片上的用户领券接口大量 504。监控面板上,分片间的写入曲线一条冲顶、其余平躺,这种图我看一次心痛一次。
排查链路如下:先看慢 SQL,发现全部是单分片上的批量 INSERT 和同分片的 UPDATE;再看 explain,热分片的 innodb_buffer_pool 里全是同一批券的页;最后把 SQL 里的 order_id 拿来一 hash,发现同一个订单号的记录全落在 31 号分片。定位就清楚了:大订单热点。
3.2 第二次报警:核销接口 P99 从 60ms 涨到 5s
高峰期核销集群的 CPU 被打满,但单分片负载都不高——因为流量被均匀发到了所有分片。问题不在某个分片,而在于每一条核销请求都在全库广播。
这里有个很多人忽略的细节:分库分表中间件处理不带分片键的 SQL 时,要并发发给所有分片,再在中间件内存里做 merge。分片数越多,网络往返越多,内存排序负担越重。80 片时就意味着每条核销请求产生 80 次子查询,任何一个分片的抖动都会被放大成整体超时。我们当时的 P99 直接涨了两个数量级,DBA 排查到凌晨发现连慢 SQL 都没几条——单看每个分片都很快,但整体就是慢,这正是广播查询的典型特征。
3.3 第三次报警:翻页和去重出现"灵异现象"
分片键选错还会带出一个低级但恶心的 bug:如果逻辑主键是(order_id, coupon_no),在 order_id 分片下,主键约束只能在单分片内生效。跨分片查"某个券码是否存在"时,两个分片各有一半数据,全局唯一性无法保证。我们曾出现线上重复的券码,排查到最后发现是历史数据迁移脚本没带分片键,把一部分数据 hash 到了别的分片,中间件又没做全局去重。
这一轮下来,团队基本达成共识:order_id 作为分片键,不是改改 SQL 就能救的,属于架构层面的选择失误。
4. 改用券实例 ID 分片后的路由设计与兜底方案
4.1 为什么券实例 ID 天生适合当分片键
券实例 ID 有几个难以替代的优势:
- 分布均匀:券实例 ID 由全局发号器生成,不管用雪花算法还是独立序列,取模后都能均匀散列到各个分片,不会有"同一批券扎堆"的问题。
- 命中最高频查询:领券、查券、核销、退券,SQL 全带 coupon_instance_id,路由可以直接收敛到单分片。
- 值唯一且不可变:券实例 ID 从生成到过期归档都不会变,完全符合分片键的不可变性要求。
- 生命周期独立:券实例表可以按自己的节奏扩缩容,订单归档时不用和券数据联动。
4.2 sharding 规则怎么定:mod 与一致性哈希的取舍
我们最终采用了分 16 个逻辑库、每库 4 张表、共 64 个分片的方案。路由函数如下:
public static int route(long couponInstanceId) { return (int) ((couponInstanceId >> 4) & 63); }为什么右移 4 位再取模?因为雪花 ID 的后几位受同一毫秒内序号影响,相邻 ID 在 mod 低位上可能扎堆。右移 4 位后取低 6 位,相当于忽略掉同一毫秒的序列低位,让分布更接近均匀。实际生产里你也可以直接用Math.floorMod(id, 64),只要发号器质量过关,差别很小。
但这里有个更重要的约束:扩容时能不能平滑。直接 mod N 的规则,扩容到 N×2 时映射全乱,数据要整个重洗一遍。更稳的做法是引入虚拟桶:
- 固定 1024 个虚拟桶,
bucket = couponInstanceId % 1024 - 维护一张桶到物理分片的映射表
- 扩容时只迁移部分桶的数据,比如把 0~511 桶从旧分片迁到新分片
这样业务路由永远只算id % 1024,物理拓扑的变动只是配置变更。我们的迁移期就是靠这张桶映射表完成 16 库扩到 32 库的,全程没有停服重洗全量数据。
4.3 高频 SQL 的路由效果
切换后的核心 SQL 全部收敛到单分片:
-- 用户领券:单分片直达 UPDATE coupon_instance SET status = 2, claim_user_id = ? WHERE coupon_instance_id = ? AND status = 1; -- 核销查券:单分片直达 SELECT coupon_instance_id, batch_id, expire_time, status FROM coupon_instance WHERE coupon_instance_id = ?;批次对账的 SQL 会带上券实例 ID 范围,扫描时也能从原来 64 片广播收敛到少数几个分片:
SELECT COUNT(*), status FROM coupon_instance WHERE batch_id = ? AND coupon_instance_id BETWEEN ? AND ? GROUP BY status;4.4 按 order_id 反查怎么办:基因法、旁路索引表、ES 兜底
切换分片键后,按订单查券确实会从"直查"变成"反查"。但按订单查券的频次很低,主要在售后页面和运营后台。我们给了三种兜底,按频次选择。
基因法(适合低频按订单查,且要求同订单券同分片)
把订单 ID 的生成和分片路由关联起来:订单号 64 位里预留 6 位作为"分片基因",由该订单生成的第一张券实例 ID 的分片序号决定,其余位用雪花。券实例表里存完整 order_id。按订单查券时,从 order_id 的前 6 位直接解析出分片号,路由到单个分片。这样既保存了 order_id 的逆向可查性,又不会破坏券实例 ID 的主路由。
但基因法有限制:必须保证同一个订单的所有券都落在同一个分片,而hash(coupon_instance_id) % 64做不到同订单的券落在同一分片。所以基因法和全局散列天然互斥。我们最终没有在主链路上用基因法,而是走了旁路索引。
旁路索引表(推荐大多数团队用)
建一张极窄的映射表:
CREATE TABLE coupon_order_mapping ( order_id BIGINT NOT NULL, coupon_instance_id BIGINT NOT NULL, shard_no INT NOT NULL, create_time DATETIME NOT NULL, PRIMARY KEY (order_id, coupon_instance_id), KEY idx_coupon (coupon_instance_id) );按 order_id 分片。按订单查券时,先查映射表拿到券实例 ID 和 shard_no,再回主表查详情。映射表只有主键和一个索引,单条记录几百字节,查询快且维护简单。这是目前性价比最高的方案。
同步 ES(适合复杂多维检索)
如果运营后台要做"按用户、按状态、按时间"的组合筛选,就别硬用 MySQL 扛了,把券实例的关键字段同步一份到 ES,按订单、用户、批次多维检索。这也是我们最终线上运行的形态:MySQL 扛事务和核心路由,ES 扛运营查询。
5. 这个改造最容易被低估的四个细节
5.1 存量数据迁移与双写的顺序
切换到券实例 ID 分片,不是改个配置就完事。我们的迁移步骤:
- 双写:改造期间新旧两套表同时写入,新库以 coupon_instance_id 为主键分片。
- 存量回填:先跑历史券实例,按 coupon_instance_id 重新 hash 落位。回填期间暂停批量领取,避免新旧数据错乱。
- 增量校验:每天跑对账任务,对比新旧库的发券量、状态明细,diff 出的差异生成修复 SQL。
- 灰度切读:先切 5% 的读流量到新库,稳定后逐步放大,最后某一刻切写。
最容易翻车的是第 2 步:很多团队想在旧库上"在线改分片键",结果还是逃不掉全量导一遍。与其在旧 schema 上打补丁,不如直接上新库新 schema,用双写+对账干净利落地完成切换。
5.2 全局发号器:分片键与主键解耦
分片键改成 coupon_instance_id 后,一定不要用 MySQL 自增做主键。因为在分片架构下,两个分片上可能出现相同主键的记录,全局唯一性直接崩。
我们的方案是独立发号器,基于雪花算法改造:
long id = (timestamp - baseEpoch) << 22 | (workerId << 10) | sequence;发号器单独部署,允许应用层批量预取一段区间,避免每次插入都远程调一次。我们批量取 1000 个 ID 再攒批 INSERT,性能损耗几乎为零。另一个细节是:发号器的 workerId 在容器环境下要用实例 IP 或 registry 注册来分配,不能简单取 hostname hashCode,否则两套环境的 workerId 可能冲突。
5.3 扩容时不要直接改 mod
前面提到的桶映射就是为了扩容准备的。如果你已经上线了id % 64,要扩到 128 片,数据必须全量重洗。重洗期间,新旧数据并存时的查询结果可能不一致,业务侧会观测到"券明明发了,状态却查不到"之类的诡异现象。
所以我现在选分片键时会多问一句:这个键对应的分片算法,能不能让我在不停服的情况下加节点?如果答案是不能,我就重新考虑桶方案,哪怕初期复杂一点。线上加节点这种操作,平时不出问题,一出就是大事故。
5.4 压测别只测平均值,要测分片均衡度
改造上线前,我们把压测数据构造得很刻意:70% 是 1 张券的小订单,20% 是 5~20 张的中订单,10% 是 200 张以上的大订单,外加几个 2000 张的极端订单。观察指标除了整体 TPS、P99 之外,重点看分片间的数据量和 QPS 分布。
| 指标 | order_id 分片 | 券实例 ID 分片 |
|---|---|---|
| 分片最大/最小数据量比值 | 40:1 | 1.2:1 |
| 大促单分片写入峰值 | 打满 1 片 | 均匀分布,无单点 |
| 核销查询 P99 | 4.8s(广播) | 48ms(单分片直达) |
| 扩容到 128 片的数据迁移量 | 全量重洗 | 仅迁移部分桶 |
这张表就是当初说服团队切换的核心证据。压测现场我们直接在监控里拉出各分片 QPS 分布曲线,order_id 版是一条尖刺加一堆平线,券实例 ID 版是整齐的 64 条并行——截图往群里一丢,反对意见自动消失了。这里要提醒一句:压测脚本里一定要构造大订单,别只发均匀分布的小订单,否则热点问题根本压不出来。
6. 踩过坑之后,我做分片键选型的"三问"清单
6.1 第一问:这个表被最频繁查询的字段是什么?
不是看 DDL 里的索引,而是去看慢日志、看监控里频次排行前 20 的 SQL。如果排第一的 where 条件是 coupon_instance_id,而分片键是 order_id,这本身就是违和的。很多团队选分片键前根本没拉过 SQL 分布,全凭对业务的直觉。我现在的习惯是:动手之前,先把最近一周的监控数据导出来,按 SQL 模板聚合排序,数据会告诉你真正的高频键是什么。
6.2 第二问:这个字段的分布是否足够均匀?
想清楚业务数据的真实分布:是不是幂律分布?会不会有超大规模聚合的 case?是 1:N 还是 M:N?1:N 的场景尤其要小心"大 N 扎堆"。可以用现有数据实际跑一次hash % 64模拟,算出分片间数据量的方差。如果最大分片和最小分片差一个数量级,直接换候选键。这个模拟脚本很简单,却能在选型阶段就暴露 90% 的问题。
6.3 第三问:路由阶段能不能定下分片?
一个合格的分片键,必须让中间件或业务层在没有其他条件的情况下,仅凭 SQL 条件就能唯一确定分片号。如果做不到,说明它只是"听起来像主键",不是真正的路由键。order_id 在券实例表上的最大问题就是:只有查订单详情时才能定位分片,而券的高频查询全都不带它,所以它天然不适合参与路由计算。
我在实际系统里用这三问给所有候选键打分。券实例 ID 在三问里全通过,order_id 在第二问、第三问直接亮红灯。这也是为什么最终能坚持放弃 order_id——不是因为它不好,而是它承担的职责从一开始就错了。
最后说一点个人体会:分库分表键的选型,本质上是在回答"我的数据到底是怎么被访问的"。很多团队对着一张表的外键关系就开始分片,最后一定会在高频查询和大促热点上还债。我的建议是,在动手建分片规则之前,先花两天把线上 SQL 按频次拉个排行,让数据替你说话。另外,如果团队里有人提议"用订单号分片、反正券挂在订单下",不妨把我在 3.2 节记录的那次核销 P99 从 60ms 涨到 5s 的事故讲给他听。顺序对了,整个系统的扩展性才会跟着对。