4.1 热点数据:识别与打散
分库分表解决了总量问题,但解决不了「倾斜」问题。如果某个分片装的全是大商家的订单,这个分片会比其他分片负载高 5 倍,成为木桶最短的那块板。这一节讲我们如何识别热点、如何用「打散」手段把热点摊平。
场景:少数 Key 占了多数流量
热点数据的典型形态是:少量 Key 承担了大部分访问。订单系统里最明显的热点有两个。一个是「大店铺」:某头部商家日均 3 万单,占全站单量的 4%,它的订单全集中在少数几个分片。另一个是「热门商品」:秒杀类商品的库存行是瞬时读写焦点,QPS 可以冲到 8000。
| 热点类型 | 典型对象 | 访问特征 | 危害 |
|---|---|---|---|
| 写热点 | 秒杀库存行 | 单行 QPS 8000 | 行锁排队、连接耗尽 |
| 读热点 | 爆款商品详情 | 单 Key QPS 2 万 | 缓存击穿、IO 打满 |
| 分片热点 | 大店铺订单写 | 单片写 QPS 600 | 分片倾斜 |
| 时间热点 | 大促前 10 分钟 | 全片瞬时高峰 | 整体抖动 |
识别热点的手段是埋点加采样:在数据访问层记录每个 Key 的访问计数,写入 CLS 统一归并,按分钟粒度出 TOP 热点榜。这套采样每天带来约 2% 的性能损耗,但换来的是「热点可观测」——4.3 节的慢查询定位也依赖同一套归集链路。
方案:三层打散策略
热点的处理按「离用户越近越好」的分层原则:读热点先在应用层加本地缓存,再在 Redis 层分片,最后才到数据库;写热点则只能靠「拆 Key」把单行压力拆成多行。单行拆多行是库存场景的经典操作:库存 1000 拆成 10 个子库存行各 100,扣减时随机选一行轮询扣,单行锁竞争从 8000 QPS 降到 800 QPS。
| 打散手段 | 适用热点 | 实现成本 | 局限 |
|---|---|---|---|
| 应用本地缓存 | 读热点 | 低 | 一致性靠 TTL |
| Redis 分片缓存 | 读热点 | 低 | 缓存穿透风险 |
| 库存子行拆分 | 写热点 | 中 | 扣减逻辑变复杂 |
| 订单二次哈希 | 分片热点 | 中 | 改路由规则 |
| 限流与排队 | 时间热点 | 低 | 牺牲吞吐 |
秒杀类库存拆行的完整设计可以参考 PHP 实战:秒杀系统,那本书对此有更细致的代码级讨论,核心理念都是把单点竞争摊平。
热点监控大盘
热点治理的前提是热点可见。我们的监控大盘分三层:分钟级 Key 计数榜、分片级负载对比、接口级耗时分布。三层各有用途:Key 榜用来发现热点本身,分片对比用来发现倾斜,耗时分布用来评估热点影响面。
| 监控维度 | 数据来源 | 告警规则 | 典型问题 |
|---|---|---|---|
| Key 访问 TOP | 应用层采样 | 超中位数 10 倍 | 缓存击穿风险 |
| 分片 QPS 对比 | 数据库监控 | 最大片超过均值 2 倍 | 倾斜 |
| 分片 IO 对比 | 云监控 | 单片超过均值 3 倍 | 热点已入盘 |
| 接口耗时分布 | 全链路追踪 | p99 突增 2 倍 | 用户已受影响 |
大盘上线后最有趣的发现是:分片 0 的负载常年比其他片高 30%,因为 OrderID 末位是 0 的订单在某些渠道批次中偏多(哈希前的原始分布有周期性规律)。这再次印证了 2.2 节的经验:灌数测试不能只测均匀数据,要测业务特征子集。
打散方案的组合使用
真实场景里热点不会只有一种形态,打散手段往往组合使用。大促期间我们对头部商家同时用了四层手段,各管一段:
| 防线 | 手段 | 覆盖的问题 |
|---|---|---|
| 第一层 | 应用本地缓存 5 秒 | 读热点不落 Redis |
| 第二层 | Redis 分片缓存 | 本地过期后的兜底 |
| 第三层 | 库存子行 10 段 | 写热点不锁单行 |
| 第四层 | 限流队列 | 超出容量的流量排队 |
四层防线的触发顺序固定:请求先撞本地缓存,穿透后走 Redis,写入时打在子行上,流量洪峰超过第四层容量就在队列里排队等待。这套组合把大促期间分片的 QPS 峰值从预估的 9000 压到实际的 2200,剩余压力都在实例舒适区内。容量问题永远比优化问题好解决,打散的终点是限流。
配置:库存子行拆分
库存表从一行库存改为 N 个子库存行,扣减逻辑相应改造。核心是扣减要带子行选择策略,以及库存不足时的「借还」兜底:
CREATE TABLE t_stock_segment (
sku_id BIGINT,
seg_no INT,
stock INT,
PRIMARY KEY (sku_id, seg_no)
);
扣减时按 seg_no = hash(thread_id) % 10 选行,单行扣不出来就借相邻段的库存。借还操作用一个负债表记录,异步平账。这套逻辑上线后,秒杀时段的库存扣减耗时 p99 从 1200 毫秒降到 80 毫秒。
子行拆分的数量有讲究。拆 10 段把单行竞争降到 800 QPS,拆 100 段降到 80 QPS,但段数越多「借还」越频繁(某段扣完了要去借),管理成本上升。实测 10 到 20 段是甜区。另外子行拆分对「库存正好是单数」的场景有特殊处理要求:末段做零数收容,避免出现库存被拆碎后无法整单扣减的边界 case。
踩坑:热点是动态的
最大的坑是「热点榜单不会自己更新」。系统上线时配的监控阈值是按当时的流量定的,半年后业务增长阈值过时,某天的猝发热点没触发告警。之后我们把热点检测改成「相对阈值」:以过去 7 天同时段中位数为准,超出 3 倍即告警,而不是固定 QPS 数。
第二个坑是缓存击穿。秒杀开始瞬间,热 Key 的本地缓存同时过期,所有请求穿透到数据库。解法是本地缓存的 TTL 加随机抖动(避免同时过期),以及热点 Key 在快过期时主动异步续期。
第三个坑是分片热点打散会破坏按订单号的路由。订单二次哈希(大店铺订单分散到更多分片)让按订单号查单的路由变复杂,需要多查一次映射。最后我们只对一个超级商家做了特殊处理(手动切分到固定 4 个分片),没有做通用的二次哈希——通盘考虑路由复杂度后,少数特殊 case 手工处理更经济,过度设计通用方案往往得不偿失。热点治理的本质是动态再平衡,不存在一劳永逸的方案。
第四个坑是热点治理的「钟摆效应」。某次把秒杀库存拆成 100 段,单行竞争确实降了,但「汇总查询」变慢了:看某 SKU 的总库存要聚合 100 行。拆得越碎,点查越快聚合越慢。最终方案是「拆 10 段加汇总缓存」,聚合结果放 Redis 定时刷新。任何打散都要同时回答「打散后怎么聚合」这个问题。
第五个坑是大促前的「热点预检」。大促开始前两小时,我们会把历史大促的热点店铺清单拉出来预热:提前把这些店铺的缓存与路由信息刷好、库存子行预扣额分配均匀。预检清单是逐次大促积累出来的,第一次搞大促时没有预检,开场 5 分钟缓存击穿直接把某分片打挂了 30 秒。热点治理在平时是优化,在大促前是生存线。
小结
热点治理三层走:读热点上缓存、写热点拆 Key、分片热点做二次哈希或人工切分。监控要用相对阈值跟随业务增长。所有打散手段都有代价(路由变复杂、一致性窗口变长),动手前先确认热点是否真实存在,早期宁可手工处理特殊 case 也不要过度设计通用方案。热点治理的度量指标要写进每周的运维周报,倾斜率与缓存命中率连续三周恶化才触发下一轮治理,避免运动式优化,这一点在治理类工作里比技术本身更关键。
操作清单:热点治理五步
| 步骤 | 动作 | 主要产出 |
|---|---|---|
| 1 埋点 | 数据访问层加 Key 计数 | 采样日志 |
| 2 归并 | CLS 聚合 TOP 榜 | 热点报表 |
| 3 判定 | 相对阈值告警 | 告警规则 |
| 4 打散 | 缓存、拆行、切分 | 代码改造 |
| 5 复核 | 两周后回看倾斜指标 | 治理报告 |
五步里最容易被跳过的是第 5 步。打散做完没有复核,就不知道是真治好了还是把热点挪到了别的地方——我们历史上就有一次把分片热点打散后,热点转移到了归并层,总耗时反而上升。复盘制度治好了这种「自以为修好了」。
延伸阅读
- PHP 实战:秒杀系统:库存拆行与防超卖的完整方案
- Go 实战:IM 系统:长连接场景的热点房间与打散实践
- MySQL 实战:分库分表订单系统:回到本书,4.2 节讲冷数据归档
