ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

分库分表键选型复盘:为何弃用order_id改用券实例ID

分库分表键选型复盘:为何弃用order_id改用券实例ID 做优惠券/权益发放这些年我接手过一个让我印象很深的系统日均发放 500w 张券码券码表从单库单表演进到必须分库分表。当时团队里第一个跳出来的方案就是按 order_id 分片——原因很简单券是挂在订单下面的订单表早就分了券表顺着订单走似乎顺理成章。结果这个顺理成章在线上埋了一颗大雷我们最终弃用了 order_id改用券实例 ID 做分片键才把集群扩展性、查询延迟和运维复杂度一起拉回正轨。这篇文章就是这次分库分表键选型的完整复盘为什么 order_id 不合适、券实例 ID 优势在哪里、迁移路上有哪些容易被低估的坑以及我在事后总结出的选型清单。内容适合正在做券码、权益、卡券类系统或者公司正要启动分库分表改造的朋友。你不需要看过 ShardingSphere 源码只要熟悉 MySQL 和常见的分库分表概念就能跟着这篇文章把选型逻辑理清楚实操部分我也给了可以直接套用的路由规则、兜底方案和压测指标改完就能用。1. 先看访问模式再谈分片键券实例与订单的数据归属差异1.1 券码发放链路里到底哪张表在扛 500 万先明确核心数据模型。一个典型的发券链路通常有三层订单表order用户下单、支付成功后的订单记录一个订单可包含多条券。券批次表coupon_batch运营创建的券批次例如双11满100减50券批次里有规则、总量、有效期。券实例表coupon_instance每一张实际发放的券码对应一条记录拥有全局唯一的券实例 IDcoupon_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:11.2:1大促单分片写入峰值打满 1 片均匀分布无单点核销查询 P994.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:N1:N 的场景尤其要小心大 N 扎堆。可以用现有数据实际跑一次hash % 64模拟算出分片间数据量的方差。如果最大分片和最小分片差一个数量级直接换候选键。这个模拟脚本很简单却能在选型阶段就暴露 90% 的问题。6.3 第三问路由阶段能不能定下分片一个合格的分片键必须让中间件或业务层在没有其他条件的情况下仅凭 SQL 条件就能唯一确定分片号。如果做不到说明它只是听起来像主键不是真正的路由键。order_id 在券实例表上的最大问题就是只有查订单详情时才能定位分片而券的高频查询全都不带它所以它天然不适合参与路由计算。我在实际系统里用这三问给所有候选键打分。券实例 ID 在三问里全通过order_id 在第二问、第三问直接亮红灯。这也是为什么最终能坚持放弃 order_id——不是因为它不好而是它承担的职责从一开始就错了。最后说一点个人体会分库分表键的选型本质上是在回答我的数据到底是怎么被访问的。很多团队对着一张表的外键关系就开始分片最后一定会在高频查询和大促热点上还债。我的建议是在动手建分片规则之前先花两天把线上 SQL 按频次拉个排行让数据替你说话。另外如果团队里有人提议用订单号分片、反正券挂在订单下不妨把我在 3.2 节记录的那次核销 P99 从 60ms 涨到 5s 的事故讲给他听。顺序对了整个系统的扩展性才会跟着对。
返回列表