从"面状流量"到"点状流量"
前四篇治理的都是"面状"压力:总量超预期,靠限流分层、熔断止损、降级舍车。但有一类事故长得不一样——总 QPS 没超任何阈值,所有压力却集中在同一个键上。场景:内容平台热搜榜第一的明星突然塌房,平时 0.3% 的查询流量在十分钟内变成 26%,而这 26% 全打在同一个缓存键上。Redis 集群按 key 哈希分片,单个键永远只落在一个分片上——集群扩十倍,那个分片还是被单键打穿,接着是它的连接数、它的网卡、它的 CPU。这就是"点状流量":分片架构天然无法用水平扩容解决的倾斜。本篇讲两件事:怎么在热点形成的头几分钟探测到它,以及探测到之后靠多级缓存把它接住。
热点探测:不能靠人眼看监控
热点的准确定义通常是:单位时间访问量超过集群平均热点水位线若干倍的单个键。探测的工程约束是它必须便宜——你不可能为每个键维护精确计数器,1 亿个键的 HashMap 本身就是一个内存炸弹。主流做法是采样计数:每 N 个请求抽 1 个进滑动窗口做频次统计,用"窗口内占比"而不是绝对次数做判据(绝对阈值在流量涨落时会疯报或漏报,占比天然自适应)。抽 1/100 的代价是精度损失:占比 2% 以下的键会淹没在长尾噪声里——但真正的热点恰恰不怕采样,26% 的键被采到的概率依然接近 26%,采样只是把噪声地板抬高,不改变头部结构。更讲究的实现(如各类代理层的 count-min sketch)本质也是"用概率换内存"。
importrandomfromcollectionsimportdeque,Counter rnd=random.Random(2026)N,n_keys=100000,2000# 幂律需求分布: rank1 是爆点, rank2/3 次热, 其余长尾probs=[1.0/(i+1)**1.2foriinrange(n_keys)]keys=["hot_%04d"%iforiinrange(n_keys)]# 真值: 用同一种子跑全量流统计真实 Top5oracle=random.Random(2026)truth=Counter(oracle.choices(keys,weights=probs,k=N))top_truth=truth.most_common(3)print("全流真实 Top3: %s"%[(k,c)fork,cintop_truth])print("第一名独占全流 %.1f%% 的请求"%(100.0*top_truth[0][1]/N))# 采样探测器: 每 100 个请求取 1 个(共 1000 事件), 60 秒滑动窗口events,t=[],0.0foriinrange(0,N,100):k=rnd.choices(keys,weights=probs)[0]t+=0.06*rnd.random()# 1000 个采样事件约 60 秒走完events.append((t,k))WIN,SHARE,MIN_CNT=60.0,0.02,10# 窗口内占比 >=2% 且至少 10 次window,cnt,hot=deque(),Counter(),{}fort_i,kinevents:window.append((t_i,k))cnt[k]+=1whilewindow[0][0]<t_i-WIN:_,old=window.popleft()cnt[old]-=1ifcnt[k]>=MIN_CNTandcnt[k]>=SHARE*len(window):hot[k]=max(hot.get(k,0),cnt[k])print("采样事件数 %d, 探测出热点 %d 个"%(len(events),len(hot)))fork,vinsorted(hot.items(),key=lambdax:-x[1])[:5]:print(" 热点 %s 窗口峰值 %d 次采样 -> 还原全量约 %d 次/分"%(k,v,v*100))运行输出:
全流真实 Top3: [('hot_0000', 22292), ('hot_0001', 9711), ('hot_0002', 6057)] 第一名独占全流 22.3% 的请求 采样事件数 1000, 探测出热点 9 个 热点 hot_0000 窗口峰值 236 次采样 -> 还原全量约 23600 次/分 热点 hot_0001 窗口峰值 97 次采样 -> 还原全量约 9700 次/分 热点 hot_0002 窗口峰值 58 次采样 -> 还原全量约 5800 次/分 热点 hot_0003 窗口峰值 43 次采样 -> 还原全量约 4300 次/分 热点 hot_0004 窗口峰值 38 次采样 -> 还原全量约 3800 次/分1/100 采样、60 秒窗口,真实 Top3 全部命中且排名无误——头部热点在幂律分布下大到采样都掩不住。注意还原值 23600 次/分对应约 393 QPS 的真实流量在一个键上:这个数字对 MySQL 是灾难,对单进程内存是毛毛雨——这正是"把热点抬进本地缓存"的经济学依据。探测器输出的是名单,动作要跟着名单自动走:进名单的键下发"本地化+复制"指令,掉出名单一段时间后回收。
多级缓存:各层拦各层的量
接住热点的经典结构是三层:L1 进程内缓存(本地)→ L2 共享缓存(Redis)→ 回源(DB/下游)。三层各司其职:L1 容量极小(几千个键、几十 MB),只留得住最高频的头部,好处是零网络开销、且天然按实例分摊——这正是"热点复制"的形态:同一个键在 10 个应用实例里各存一份,单点流量被切成 10 份;L2 提供全量共享视图和一致性收敛点;L1 的过期比 L2 短得多(秒级 vs 分钟级),保证本地副本不会长期脱离共享层的更新。用固定种子的流量画像重放三种拓扑,看每一层各拦下什么:
importrandomfromcollectionsimportOrderedDict rnd=random.Random(99)# 热搜事件流量画像: 爆款 sku_0000 占约 26%, 9 个次热各约 1.2%, 其余长尾weights=[560]+[25]*9+[1]*1331keys=["sku_%04d"%iforiinrange(1341)]stream=rnd.choices(keys,weights=weights,k=20000)classLRU:def__init__(self,cap):self.d,self.cap=OrderedDict(),capdefget(self,k):ifkinself.d:self.d.move_to_end(k)returnTruereturnFalsedefput(self,k):self.d[k]=Trueiflen(self.d)>self.cap:self.d.popitem(last=False)defreplay(name,local_cap=0,n_local=10,shared_cap=0):locals_=[LRU(local_cap)for_inrange(n_local)]iflocal_capelse[]shared=LRU(shared_cap)ifshared_capelseNonel1h=l2h=origin=0foridx,kinenumerate(stream):iflocals_andlocals_[idx%n_local].get(k):l1h+=1continueifsharedandshared.get(k):l2h+=1iflocals_:locals_[idx%n_local].put(k)# L2 命中后回写本层 L1continueorigin+=1ifshared:shared.put(k)iflocals_:locals_[idx%n_local].put(k)n=len(stream)print("%-24s L1 命中 %5.1f%% | L2 命中 %5.1f%% | 回源 %5.1f%%"%(name,100.0*l1h/n,100.0*l2h/n,100.0*origin/n))print("请求流 %d 个 (虚拟流量画像, 种子固定)"%len(stream))replay("仅共享缓存 cap=64",shared_cap=64)replay("仅本地缓存 10x16",local_cap=16)replay("L1(10x16)+L2(64) 多级",local_cap=16,shared_cap=64)运行输出:
请求流 20000 个 (虚拟流量画像, 种子固定) 仅共享缓存 cap=64 L1 命中 0.0% | L2 命中 36.7% | 回源 63.3% 仅本地缓存 10x16 L1 命中 29.5% | L2 命中 0.0% | 回源 70.5% L1(10x16)+L2(64) 多级 L1 命中 29.5% | L2 命中 7.5% | 回源 63.0%三行输出值得逐行读。仅共享缓存:36.7% 命中听着还行,但爆款那 26% 全挤在同一个 Redis 键所在分片上——命中率是命中率,单点是单点,两码事。仅本地缓存:29.5% 命中(基本就是爆款+次热),且天然摊到 10 个实例;但长尾键每台各存各的,回源率反而最高(70.5%)。多级组合是正解:L1 吃下 29.5%(热点被 10 份本地副本复制,Redis 单键压力归零),L2 只需再接 7.5%,回源 63% 几乎全是长尾——而长尾流量摊在 1341 个键上,每个键都微不足道。多级缓存对总量的削减有限(63.3%→63.0%),对倾斜的削减是决定性的:它把"一个键的战争"化成了"一千个键的散步"。
回源三闸门:击穿、失效风暴与逻辑过期
剩下的 63% 回源里还藏着三个经典事故。缓存击穿:爆款过期瞬间千军万马齐回源。解法是回源加互斥(single-flight):同一键并发只放一个请求去 DB,其余等待或返回旧值。更进一步是逻辑过期:键里带一个"逻辑到期时间",物理 TTL 很长;发现逻辑过期时由抢到互斥的实例异步重建,其他请求先用旧值——热点键宁可旧一秒,不可空窗一万并发。失效风暴:同批写入的键 TTL 相同,到期时刻齐刷刷失灵。解法简单粗暴——TTL 加 ±10~20% 随机抖动。预热与兜底:可预见的热点(运营排期里的明星塌房位、晚会节目单)提前推入 L1/L2;L2 整个抖动时,L1 里带"秒级过期、允许陈旧"的热点副本就是最后防线——实验第三行里 L1 独立扛住的那 29.5%,在共享层故障时就是它的全部使命。
常见陷阱与清单
- 用绝对 QPS 阈值判热点:流量整体涨 5 倍时满屏误报,整体跌时漏报——判据用窗口内占比(如 >1% 即热点)。
- 本地缓存不设防弹衣:L1 无容量上限(大对象堆积 OOM)、无淘汰策略(LRU/LFU 至少要有)、过期长于 L2(各实例数据长期分裂)。
- 热点复制不做名单回收:键掉出热点名单后不清理本地副本,10 台机器为过气键白占内存。
- 只测命中率不测单键 QPS:监控面板按命令数统计 Redis 一切正常,热点事故从来都是"平均数陷阱",必须看 top key 排行。
- 落地顺序建议:先上 top-key 实时监控(redis-cli
--hotkeys/代理层统计)→ 采样探测 + 名单自动下发 → L1 热点复制 → 回源 single-flight 与逻辑过期 → TTL 抖动与预热例行化。
热点解决的是"读侧的极端倾斜"。写侧也有一个看似简单实则处处是坑的问题:高并发下如何生成全局唯一、趋势递增、还不能被猜的 ID——自增 ID 一压测就发现分库分表撞号,UUID 一上量就发现索引爆炸。下一篇《高并发流量治理实战(6):分布式 ID 生成:Snowflake 时钟回拨与号段模式》把发号器这件事讲透。
参考来源
- Wikipedia: Count–min sketch: https://en.wikipedia.org/wiki/Count%E2%80%93min_sketch
- Wikipedia: Cache replacement policies: https://en.wikipedia.org/wiki/Cache_replacement_policies
- Redis 官方文档: Eviction(内存淘汰策略): https://redis.io/docs/latest/develop/reference/eviction/
- AWS 最佳实践: Caching best practices(多级缓存与热点处理): https://aws.amazon.com/caching/best-practices/
tags: 缓存, 热点Key, Redis, 高并发
本系列已结集为免费专栏《高并发流量治理实战:从限流到全链路压测》 https://blog.csdn.net/weixin_67153745/category_13213830.html ;系统性进阶推荐付费专栏《提示词工程实战:从入门到生产级 Prompt 设计》限时 ¥19.9,首篇免费试读:https://blog.csdn.net/weixin_67153745/category_13213600.html