一次分表键选错,把我们整张表拖垮了
事情要从一个慢查询告警说起。
凌晨两点,我被企业微信的告警震醒。打开一看,某张订单表的平均查询耗时从 20ms 飙升到了 3000ms,数据库 CPU 直接打满。这张表我们上个月刚做了分库分表,按道理不应该出这种问题。当时脑子里第一反应是“哪个业务方又写了个不走分表键的 SQL”,结果查下来发现,问题比我想的严重得多——不是 SQL 写错了,是分表键从一开始就选错了。
复盘:分表键选错是怎么把整张表拖垮的
这张表是订单记录表 t_order_record,字段结构简化如下:id、order_no(订单编号)、shop_id(店铺 ID)、buyer_id(买家 ID)、create_time、amount,以及十几列业务字段。我们的业务场景是 B2B 平台,一个店铺每天可能产生数万笔订单,买家分散在全国各地。当时选分表键的时候,团队讨论下来选择了 shop_id,理由很直接:大部分查询都是店铺维度的,比如“某个店铺今天的订单列表”“某店铺的月流水统计”,用 shop_id 做分表键,这些查询都能直接路由到单分片,性能最优。
上线第一个月,一切正常。问题出在两个月后的一场促销活动上。
平台上有 3 个头部店铺,占了全平台交易量的 60% 以上。促销当天,这 3 个店铺的订单量是平时的 20 倍。由于分表键是 shop_id,这 3 家店铺的所有订单全部落入了同一个分片。这个分片的写入 TPS 瞬间从常规的 2000 飙到了 40000,磁盘 IO 被打到瓶颈,连带影响了同分片上的其他表。更要命的是,促销期间运营侧大量拉取这 3 家店铺的实时订单数据,查询压力也全部集中到了这一个分片上。数据库连接池耗尽,整个分片直接不可用,最终拖垮了这张表的所有分片——因为我们的订单详情页聚合逻辑需要跨分片查买家信息,热点分片挂了之后,整个聚合链路超时,全平台订单查询都受影响。
这就是典型的“数据热点”问题。分表键选了 shop_id,而业务流量高度集中在少数几个店铺上,导致数据分布严重倾斜。我们用 ShardingSphere 5.3.0 做的分片,t_order_record 分了 8 个分片,理论上每个分片承载 12.5% 的流量。但实际监控数据显示,促销期间 3 号分片(头部店铺所在分片)的读写负载占全量的 71%,其余 7 个分片加起来不到 30%。这种倾斜度,分表的意义已经不存在了。
重新选键:我们是怎么做的
问题定性之后,接下来就是重新选分表键。但线上表已经有 2000 多万条数据,直接改分表键意味着数据迁移,停服时间至少几小时,这在 B2B 场景下不可接受。我们最终走了一条渐进式的路。
第一步,分析所有 SQL 的路由模式。
我拉了一周的全量 SQL 日志,逐条分析 WHERE 条件里出现了哪些字段。结果如下:带 shop_id 的查询占 47%,带 order_no 的占 31%,带 buyer_id 的占 12%,带 create_time 范围查询的占 8%,其余散列条件占 2%。表面看 shop_id 确实是最高频的查询字段,但这里有个陷阱——shop_id 的数据分布严重不均。一个分表键好不好,不光看查询覆盖率,还要看数据分布的均匀度。shop_id 的基数是 1000 多个店铺,但头部 3 个店铺占了 60% 的数据量,这种分布根本不适合做分表键。
第二步,评估候选键的数据分布。
我们圈定了三个候选键:order_no、buyer_id,以及复合键 shop_id + create_time。用生产数据跑了一遍分布模拟:
order_no:全局唯一,雪花算法生成,数据分布完全均匀,8 个分片各承载 12.5% 左右。但问题在于,按order_no分片意味着绝大多数查询都要带order_no才能路由,而实际上很多查询只有shop_id或buyer_id,这些 SQL 会变成全分片扫描。buyer_id:买家 ID,基数 80 万,头部买家占比最高不超过 0.3%,分布相对均匀。模拟结果显示 8 个分片负载差异在 5% 以内。而且买家维度的查询虽然只占 12%,但都是高频 C 端接口,对延迟敏感。shop_id + create_time复合键:能解决热点问题吗?不能。复合键的分片路由还是依赖第一个字段的哈希,shop_id的倾斜问题依然存在,只是把时间维度加进去做范围裁剪而已,写入热点没解决。
第三步,决定用 buyer_id 作为新分表键,同时引入二级索引表。
这个决策的核心逻辑是:分表键的第一优先级不是查询覆盖率,是数据均匀度。数据分布均匀了,单分片不会被打爆,全分片扫描的代价是可控的(8 个分片并行查询,合并结果即可)。但如果数据倾斜,热点分片挂了,整个表都不可用,这是零和问题。
但 buyer_id 的查询覆盖率只有 12%,剩下的 88% 查询怎么办?我们建了一张路由索引表 t_order_route,字段只有三个:order_no、shop_id、buyer_id。这张表按 order_no 分片,数据量很小(只有原表的 5%),专门用来做“非买家维度查询”的路由查找。比如运营要查某个店铺的订单列表,SQL 变成两步:
-- 第一步:从路由表查出该店铺的所有 buyer_id(或 order_no)
SELECT buyer_id FROM t_order_route WHERE shop_id = ? AND create_time BETWEEN ? AND ?;
-- 第二步:带上 buyer_id 去订单表查询,走分片路由
SELECT * FROM t_order_record WHERE buyer_id IN (?, ?, ...) AND create_time BETWEEN ? AND ?;
这一步的代价是多了 1 次路由表查询,但路由表数据量小,查询延迟在 5ms 以内,完全可接受。而且 shop_id 维度查询本身就是低频运营操作,对延迟不敏感。
数据迁移:双写 + 存量回填
线上表不能停,我们用了标准的双写方案。在 DAO 层加了一个开关,开启后所有写入操作同时写旧表(shop_id 分片)和新表(buyer_id 分片 + 路由表)。双写期间读操作依然走旧表,保证业务不受影响。双写跑了 3 天,数据一致性校验通过后,开始存量数据回填。回填脚本按 create_time 分段读取旧表数据,逐条写入新表,跑了大概 6 个小时,2000 万条数据全部迁移完成。最后一步是切读,把读流量从旧表切到新表,观察 30 分钟无异常后,下线旧表。
整个过程从发现问题到完成迁移,用了 4 天。迁移后重新跑了促销压测,8 个分片负载差异控制在 7% 以内,单分片 TPS 上限从之前的瓶颈 40000 提升到了 120000(因为数据均匀后,每个分片都能充分利用硬件资源)。
选分表键的方法论
这次事故让我重新梳理了分表键的选择逻辑。以前总把“查询覆盖率”放在第一位,现在看来是错的。正确的优先级应该是:
1. 数据均匀度 > 查询覆盖率。
分表键的哈希分布必须足够均匀。判断标准很简单:取生产数据,按候选键做一次哈希分片模拟,看各分片数据量差异是否在 10% 以内。超过这个阈值,就要警惕热点风险。基数太小的字段(比如枚举类型、状态字段)直接排除;基数够大但分布不均匀的字段(比如 shop_id,虽然基数有 1000,但数据量集中在少数值上)也要排除。
2. 查询覆盖率不够,用二级索引补。
如果最优数据分布的键查询覆盖率只有 10%-30%,别硬着头皮选一个覆盖率高但分布不均的键。建路由索引表是成熟方案,代价是一次额外的轻量查询,收益是分片负载均衡,这笔账怎么算都划算。路由表本身也要选一个分布均匀的分片键,通常用主键或唯一业务编号即可。
3. 复合键不能解决热点问题。
很多人以为用 hot_field + random_suffix 或者 shop_id + create_time 做复合键能分散热点,实际上分片算法对复合键的处理是取第一个字段做哈希,热点字段依然会落到同一个分片。除非你在应用层手动拼接一个分散度高的新字段(比如 shop_id + 随机数取模),但这会让查询路由变得极其复杂,不推荐。
4. 先压测,再上线。
选完分表键之后,别直接上线。用生产环境的数据量和分布比例,搭一套预发环境,模拟极端流量(比如头部店铺流量放大 20 倍),看分片负载是否均衡。我们这次就是少了这一步,才会在促销当天翻车。
常见问题
为什么不直接用 order_no 做分表键?它是唯一且均匀的
因为很多查询不带 order_no。比如运营后台查“某店铺昨天的所有订单”,或者买家在订单列表页按时间筛选,这些场景下 order_no 不在 WHERE 条件里,SQL 会变成全分片扫描。8 个分片全扫一遍,再在内存里合并排序取分页,当单个分片的数据量上千万时,这种查询的延迟是不可接受的。order_no 适合做分表键的场景是“所有查询都带订单号”,比如物流追踪、支付回调这类单一主键查询为主的业务。
路由索引表会不会成为新的瓶颈?
路由表的数据量只有主表的 5% 左右,而且只存三个字段,单条记录几十字节。按照我们 2000 万订单的规模,路由表也就 200 万条,分 8 个分片后每个分片 25 万条,索引做好后在 5ms 内完成查询完全没问题。真正需要注意的是路由表的分片键也要选好,我们用 order_no 做路由表的分片键,写入均匀,查询走索引,不会出热点问题。
双写期间数据一致性怎么保证?
我们没有用分布式事务,太重了。双写时先写新表,再写旧表。如果新表写成功旧表写失败,依赖一个异步补偿任务扫描新表最近 5 分钟的数据,比对旧表是否缺失,缺失就补写。最终一致性延迟在 30 秒以内,业务可接受。切读之前做了一次全量数据校验,对比两边的 order_no 和关键字段的 MD5,差异率低于 0.001% 才执行切流。