ARTICLE DETAIL

资讯详情

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

分库分表键选型实战:日均500万券码系统为何放弃order_id改选券实例ID

分库分表键选型实战:日均500万券码系统为何放弃order_id改选券实例ID 做营销中台的兄弟应该都见过这种场景运营一键配置活动券码像洪水一样往库里灌一天 500 万条写入数据库 CPU 直接飙红。我这两年一直在搞券务系统的存储架构日均 500 万券码发放这种体量下最烧脑的不是机器不够、也不是 SQL 写得不优雅而是分库分表键选型。这个键选错后面所有扩容、查询、幂等、对账全都要跟着遭殃。我们系统最开始也是拍脑袋用 order_id 做分片键上线之后被各种诡异问题折磨了两个月最后全部推翻迁到了券实例 ID 上。这篇文章就把我们当时的思考过程、踩过的坑、以及最终落地的一整套方案完整写出来希望能给正在做或者准备做券码、营销类高并发存储的同学一点参考。1. 先看清业务本质券码发放到底在写什么、读什么键选型这事表面上是个 hash 函数选哪个、取模还是范围的问题本质上是业务读写模型的问题。你要是没把业务的数据特征摸透选什么键都是碰运气。1.1 券码数据的两张核心表到底长什么样券码发放场景有两张核心表一张是券实例表记录每一张券的实例信息另一张是券码绑定表记录券码与订单、用户的绑定关系。很多人误以为券码系统最重要的是券码本身其实不是券码只是一个标识真正的数据主体是券实例。以我们线上的表结构为例券实例表的关键字段有这些instance_id券实例 ID全局唯一对应一张具体发放的券template_id券模板 ID对应活动配置的券样式和面额code券码字符串用户最终拿到的那个码order_id触发发放的订单 ID注意发券不一定是订单交易也可能是签到、分享、游戏奖励user_id领取用户status状态机未绑定、已绑定、已核销、已过期created_time、expired_time时间戳字段券码绑定表就更细了一张券实例可能绑定多个订单维度记录比如锁定单、核销单。这两张表加起来的写入量就是日均 500 万这个数量级而且并发峰值集中在活动开场的头几分钟。1.2 读写比和访问模式才是选键的决策依据我在做存储选型前习惯先统计线上真实的访问模式不靠猜。用小流量打点或者直接翻慢查询日志把读和写的比例、按什么条件查、一次查多少行全部拉出来看。券码系统的访问模式有几个非常鲜明的特征写多读少发券时写用户核销时更新状态真正的深度查询并不多按 code 反查 instance 是最高频的读路径用户拿着券码来核销系统第一件事是拿 code 去换 instance_id按 order_id 查券列表是低频管理需求运营或者对账系统偶尔会查一个订单下发了哪些券按 user_id 查券列表是中频用户端需求用户打开卡包看自己有哪些券这些访问模式直接决定了一个结论高频路径上的核心维度是“券实例”订单只是券生命周期里的一个过客。键选型的方向就从这里开始清晰了。2. order_id 当分片键的致命伤均匀性、扩展性、查询路径全出问题2.1 你以为 order_id 很均匀其实促销一开就是灾难很多人的第一反应是分片键必须均匀order_id 是雪花生成的分散度高拿它做 hash 肯定没问题。理论上是这样但券码发放场景有一个特殊变量——营销活动的放大效应。一个用户在促销活动里下一笔订单系统可能一次性发放 3 张券一张满减券、一张品类券、一张无门槛券。平时这个比例没问题但遇到秒杀或者大促某一款爆品的订单量可能瞬间占到全站订单量的 30% 以上。订单 ID 本身虽然分散但同一笔订单关联的券码写入在时间窗口内是扎堆的。更要命的是营销活动里还有一类“无订单发券”的场景比如签到发券、分享助力发券。这类场景压根没有 order_id大家最开始的做法是塞一个默认值或者用会话 ID 代替结果就是这一类的券码全部落到同一个分片上单分片写入量是其他分片的几十倍。热分片问题直接把你辛辛苦苦做的高可用架构打回原形。2.2 按 order_id 分片逆查 code 就是一场灾难分片键选 order_id 之后所有查询条件如果是 order_id那确实很爽直接路由到对应分片。但券码业务最常见的两个高频查询一个是按 code 查 instance一个是按 instance_id 查绑定关系全都跟 order_id 没关系。这两个查询在分了 16 个库的表里就意味着每一次都要把请求广播到 16 个分片然后汇聚结果。平时量小还行日均 500 万的量级下这种查询直接把数据库连接池打满。我当时做过一次压测单次广播查询在 16 分片下的耗时虽然只有几十毫秒但并发一起来连接数和数据库 CPU 双双飙升。慢 SQL 日志里全是这种广播查询DBA 天天来找我喝茶。2.3 幂等和聚合天然绑定了 order_id但券码的生命周期不归 order_id 管订单是有终态的支付完、发货完、确认收货订单的生命周期就结束了。但券码不一样一张券从发放到核销可能跨几周甚至几个月它的状态流转次数远多于订单。分库分表键选型有一个隐含原则分片键最好选数据生命周期中最稳定的那个业务主键。order_id 虽然稳定但它是“触发者”不是“载体”。券码的状态流转、核销记录都挂在 instance_id 下按 order_id 分片等于把本该聚合在一起的数据硬生生拆到多个分片上。举一个我们踩过的真实例子用户领了一张券绑定订单是 A后来因为订单退款系统要自动作废这张券。作废逻辑需要先按 instance_id 查到券再更新状态。如果是 order_id 分片这个过程要广播查询然后跨分片更新稍不注意就出现状态不一致。这还只是一个券的操作大促退款潮一起来这种跨分片更新的成功率会肉眼可见地往下掉。3. 券实例 ID 的胜出逻辑它凭什么同时解决三大问题3.1 均匀性是命根子hash 打散后没有业务死角券实例 ID 是每张券生成的唯一标识底层通常用雪花算法或者类雪花算法生成二进制位上天然具备随机性和单调性。拿它做 hash 取模分片数据的分布均匀性几乎是完美的。为什么它比 order_id 均匀因为 order_id 受业务行为影响而 instance_id 是系统内部生成不依赖任何外部输入。不管用户是下单领券还是签到领券每生成一张券实例就有一个 instance_id 产出从源头上一视同仁地进入分片算法。我见过最极端的一次大促同一个模板的券在开场 5 分钟内发出去 80 万张用 instance_id 做分片键80 万张券均匀落在 16 个分片上每个分片 5 万张压力完全可控。同一个场景如果按 order_id 分片爆品订单集中在少数几个订单号上那画面不敢想。3.2 全局唯一 单调递增天生适合做 shard key分片键有一个隐藏要求是值本身不能重复否则 hash 路由会冲突。有个来自金融支付场景的朋友说过一句话我觉得特别在理分布式系统里唯一 ID 的设计价值不只是去重它还是数据路由的锚点。券实例 ID 的生成策略我们单独做了一个发号器服务基于雪花算法改造去掉机器 ID 的强绑定换成业务 ID 自增序列保证两个能力全局唯一不管哪个机房、哪台机器生成的 instance_id都不会重复趋势递增单个分片内的数据写入基本顺序化索引维护成本低页分裂少这两个特性让它在作为分片键时非常舒服。全局唯一保证了 hash 的确定性趋势递增让相邻时间创建的券大概率落在同一个分片对按时间范围扫数据也友好。3.3 聚合查询路径最短所有核心操作都是单分片事务这是我认为最核心的一点。选分片键不只要看数据分布还要看事务的边界。分库分表之后事务只有在一个分片内才是完整的本地事务跨分片就得引入分布式事务性能和一致性都打折。券实例上发生的核心操作全部可以按 instance_id 收敛领券先插入 instance 记录再插入绑定记录同一个 instance_id锁定更新券状态按 instance_id 路由单分片核销更新状态 写入核销记录按 instance_id 路由单分片退款作废更新状态 写作废流水按 instance_id 路由单分片用 order_id 做分片键时这些操作大多需要跨分片完成而切到 instance_id 之后全部退化为单分片操作事务不再需要分布式协调。这个收益在日均 500 万写入量级下非常可观本地事务的吞吐和可靠性远超 XA 或 TCC。反范式设计在这里也有用武之地我们在绑定表里冗余了 order_id 和 user_id 字段但分片键只用 instance_id。要按 order_id 查数据时先通过一张映射表找到 instance_id 集合再精确路由到分片避免广播。4. 落地实操分片规则、ID 生成、数据访问层改造全记录4.1 分片数量怎么定不能拍脑袋按三年容量倒推分库分表的第一步永远是定分片数量。这个数量定少了一两年就得扩容定多了资源浪费、运维复杂度上升。我们当时的算法是倒推的目标支撑日均 500 万券码发放峰值按 10 倍预估即 5000 万/天单表数据量控制MySQL InnoDB 单表建议控制在 2000 万行以内留足余量到 3000 万三年数据总量5000 万/天 × 365 天 × 3 年 ≈ 55 亿行分片数量55 亿 ÷ 2000 万 ≈ 275 个物理分片当然不可能直接上 275 个分片那运维会疯的。我们采用 库 × 表 两级分片结构最终定的是 16 个库 × 32 张表 512 个物理分片。为什么是 16 × 32因为 512 是 2 的 9 次方方便未来用二进制位路由也适配取模运算的位运算优化。每个分片承载大概 1000 万行完全在 InnoDB 舒适区内。这个方案支撑到活动最高峰单日 5000 万张券也能扛住日常 500 万的量级可以说是游刃有余。4.2 路由算法实战hash 取模还是别的哈希取模是分库分表最经典的方案很多人纠结要不要上一致性哈希。我的建议是除非你有频繁扩缩容的硬需求否则普通 hash 取模就够了一致性哈希反而会让路由规则变得不可控。我们的路由计算分两步库序号 (instance_id.hashCode() 511) / 32表序号 (instance_id.hashCode() 511) % 32这里有个关键细节取模必须用 hashCode 的绝对值否则负数会算出负索引直接崩掉。Java 里 Integer.MIN_VALUE 的绝对值还是负数所以正确写法是Math.floorMod(instance_id.hashCode(), 512)或者用位运算hash 511。分片路由信息我们用了一个轻量配置表维护每个分片对应一个独立的数据源路由逻辑封装在 ShardingSphere 或者自研的 DAO 层里。我们的项目早期用的 ShardingSphere后来为了减少黑盒依赖改成了自研路由框架核心逻辑其实就是一个函数没有想象中那么复杂。4.3 券实例 ID 发号器不能直接用简单自增分片键要求全局唯一但 MySQL 的自增 ID 只能保证单表单库内唯一多库多表下直接废掉。所以必须有一个全局发号器。我们的实现基于雪花算法但做了一点改造。雪花算法的经典 64 位结构是1 位符号位 41 位时间戳 10 位机器 ID 12 位序列号。这个结构在容器化部署下有个问题容器实例频繁重建机器 ID 分配不及时容易出现时钟回拨或者 ID 重复。我们的改造方案是41 位时间戳 5 位业务标识 4 位分片号仅用于归属标记 12 位序列号 2 位预留。分片号不参与路由计算只是标记这个券大概率应该在哪个分片真正路由还是按完整 ID hash。这么做的好处是排查问题时看一眼 ID 就能快速定位到潜在分片对运维排查帮助很大。发号器做成独立服务每个实例批量从 Redis 取序列段内存里自增减少对 Redis 的 QPS 压力。单机每毫秒可以生成 4096 个 ID实测集群支撑日均 500 万毫无压力。4.4 数据访问层改造分片键上下文传递是最大的工程选完键只是第一步真正麻烦的是让全链路都能正确传递分片键。我们的数据访问层经历了三轮迭代才稳定下来。第一轮显式传参每个 DAO 方法都多一个 instanceId 参数。结果就是方法签名臃肿调用方有时候忘记传直接广播查询。第二轮ThreadLocal 绑定上下文Service 层入口设置当前线程的 instanceIdDAO 层从上下文拿。解决了到处传参的问题但引入了一个新坑线程池异步任务会丢上下文。第三轮改写线程池用装饰器模式包装 Task提交任务时把父线程的上下文快照带过去。至此才算真正稳定。这个改造过程让我得到一个结论分库分表键选型选完键只完成了 30% 的工作剩下 70% 都是数据访问层的改造和踩坑。4.5 核心 SQL 的改写示例分片键确定后原有的 SQL 要做相应调整。以我们的券码绑定表为例改造前后的思路完全不同。改造前order_id 分片信用查询-- 反查券码全分片广播 SELECT * FROM coupon_binding WHERE code XXX;改造后instance_id 分片直接路由-- 已知 code 时先通过映射表取 instance_id SELECT instance_id FROM code_mapping WHERE code XXX; -- 拿到 instance_id 后精确路由 SELECT * FROM coupon_binding WHERE instance_id XXXX AND code XXX;这里加了一张 code_mapping 表它本身也按 instance_id 分片code 字段建立唯一索引。这样既能保证 code 的反查效率又不破坏分片的干净路由。另一个高频操作是按用户查券这个我们也处理了用户维度的分片我们不强行收敛到 instance_id而是建了一张用户券映射表按 user_id 分片存储用户拥有的 instance_id 列表。查询时先按 user_id 路由拿到 instance_id 列表再精确查券明细。用户一次最多几十张券这个量级下查两个分片完全没问题。5. 常见问题与排查技巧实录这些坑我替你们踩过了5.1 隐性广播查询慢 SQL 的隐形杀手分片键选好之后最大的敌人不是数据量而是那些你以为路由了、实际没路由的隐性广播查询。我们的慢查询监控上线后第一周就抓到 20 多条广播 SQL全是在隐式调用中丢了分片键。典型场景一个 Service 方法从缓存里取 instanceId缓存 miss 后走到 DAO结果那个 DAO 方法还是旧版签名没有走 ThreadLocal 上下文直接把查询变成了无分片键的全表扫。排查办法很简单但很有效在路由框架里加一个开关强制要求所有 SQL 必须携带分片键否则直接抛异常。上线一周跑出来的异常就是所有漏网之鱼。5.2 分片键传错值定位问题能让你怀疑人生这是最隐蔽的坑。代码里有两个变量一个叫 orderId一个叫 instanceId在某个跳转逻辑里赋值赋反了。结果就是数据没有报错但永远查不到因为路由到了一个错误的分片。遇到这种问题建议做一个分片位置校验工具。我们内部做了一个小工具输入 instanceId 可以算出它应该在哪个分片然后去对应分片直接查数据是否存在。排查效率能提升 80% 以上。5.3 跨分片数据一致性千万不要试图自己做分布式事务分库分表之后最忌讳的就是为了省事自己写分布式事务。我们有段时间为了处理退款作废用了一张本地消息表在应用层做了两阶段提交结果就是数据经常性对不上对账脚本写到怀疑人生。后来换了思路所有需要跨分片的数据操作都改造成事件驱动。核心操作在单分片内完成同时发一个 MQ 消息下游消费消息再更新其他分片的数据。配合对账任务兜底一致性和性能都得到了保障。再强调一遍分库分表环境里能用单分片事务解决的问题绝不要跨分片必须跨分片时优先考虑异步消息而不是分布式事务框架。5.4 大促前的容量评估不能只看当前水位日均 500 万是平均值大促会把它放大 10 倍甚至更多。每次大促前我们都会做一次容量评估用的指标不是 QPS而是分片写入倾斜率。做法很简单挑一个业务高峰小时把每个分片的写入量拉出来看方差。如果某个分片写入量是平均值的 2 倍以上说明路由逻辑有问题或者有隐藏热点必须在上线前解决。一次做活动配置时因为一个技术方案是直接把运营配置的券模板 ID 做成了路由因子导致某个固定模板的券全部集中到一个分片。这个问题的触发点在于券模板 ID 在活动里是一个常量直接用常量做 hash结果毫无悬念是单点。排查出来之后我们把路由因子重新校准到 instance_id 上问题立刻消失。5.5 冷数据归档分库分表不能解决所有存储问题分库分表解决了写入和查询的扩展性但历史数据越积越多依然会拖累性能。我们的方案是状态为“已核销”且超过 180 天的数据通过离线任务迁移到归档库线上只保留活跃数据和近期数据。归档表的键可以简单粗暴地用 created_time 做时间分区因为归档场景基本没有实时路由需求按月分区查询按时间扫描即可。这个方案让线上数据体量保持在稳定水平也为未来进一步扩容留出了空间。6. 一些关于键选型的个人判断做完这个迁移项目之后再回头看所有分库分表键选型的讨论我觉得最核心的判断标准只有一个分片键要选那个贯穿数据生命周期、且所有高频访问都能路由到单分片上的业务键。order_id 在订单系统里是完美的分片键但在券码系统里它只是触发维度不是载体维度。强行拿它做分片等于把聚合逻辑打散把简单问题复杂化。还有一个经验是键选型一旦确定尽量不要频繁改变。我们这次从 order_id 迁到 instance_id前后花了三周数据迁移、双写校验、灰度切流每一步都如履薄冰。如果你还在设计阶段花一周时间把业务读写模型梳理清楚比上线后推翻重来划算得多。最后分享一个小技巧无论选什么键在设计阶段画一张“业务操作 × 查询维度”的矩阵表把每个操作可能使用的查询条件列出来再判断每个条件能否命中分片键。这张表画完选型结论基本就出来了。我们当时就是靠这张表让团队里所有开发都理解了为什么必须放弃 order_id。
返回列表