ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

流量洪峰下内容平台崩溃:从缓存热点到回源风暴的排查与治理

流量洪峰下内容平台崩溃:从缓存热点到回源风暴的排查与治理

1. 流量洪峰下的“崩溃”通常不是单一故障

先说个背景。我所在的团队主要负责某跨区域内容平台的稳定性保障,平时做的就是容量规划、压测演练和故障应急。内容平台这种产品有个特点:日活高、请求曲线陡、热点扩散快。平时看着冗余很充裕,一旦某个内容被大量转发,或者某个区域某段时间集中涌入新用户,整个系统的薄弱点会在一两分钟内全部暴露出来。这次“崩了”的事件,本质上就是一个典型的流量洪峰压垮链路的故事。

很多人以为系统“崩了”就是服务器宕机或者数据库挂了,实际情况要复杂得多。一个内容平台从用户点击到看到视频,中间要经过DNS解析、CDN边缘节点、负载均衡、应用服务、缓存集群、存储层等多个环节。任何一个环节出现性能劣化,用户侧的感知都可能是同样的结果——刷不出来、一直转圈、报错提示。真正排查的时候,最忌讳的就是一上来就怀疑“服务器挂了”,因为大部分“崩溃”并不是单点故障,而是链路中某个环节先达到瓶颈,然后引发连锁反应。

我的经验是,遇到这类问题第一步不是看代码,而是先把“崩溃”这个词翻译成可观测的指标。用户说“崩了”,工程师要回答的问题是:哪些用户、在哪个区域、什么时间段、访问什么功能、失败率是多少、延迟涨了多少。只有把这些信息拼起来,才能定位到瓶颈是在接入层、缓存层还是存储层。

这次事件的复盘给我最大的触动不是某个技术方案有多高明,而是“先定义问题,再动手排查”这个朴素原则有多重要。后面我会把整条排查链路拆开讲,从现象到根因,再到架构调整和长期改进,尽量把当时我们怎么想的、怎么判断的、怎么一步步缩范围的完整思路还原出来。

2. 用户视角的“打不开”与服务端观测到的异常波形

2.1 从投诉画出的故障地图

事故一开始并不是监控先报警的,而是运营那边先收到反馈,说部分海外用户在刷视频时频繁出现“加载失败”“一直转圈”的提示。等到我们登录监控看板的时候,错误率曲线已经明显抬头了。值得注意的是,这种异常并不是在所有区域同时出现的,而是先从某个区域的用户开始,然后像涟漪一样扩散到周边区域。

这里有个很重要的经验:判断故障范围时,不能只看全局平均错误率,要分区域、分运营商、分客户端版本去看。全局错误率可能只有3%,感觉不严重,但如果拆开看,某个区域某个运营商路径下的错误率可能已经超过30%了。故障范围的分析能帮你快速判断问题出在哪个层面——如果是特定区域,那大概率是区域入口或区域内的节点出了问题;如果是全量,那就要怀疑跨区域的公共链路或核心服务。

我们把反馈按区域、运营商、IP段做了聚合,发现受影响用户主要集中在几个相邻区域的边缘节点覆盖范围内。这个分布特征很关键,它让我第一时间把怀疑范围从“核心服务挂了”缩小到了“某个区域的接入链路存在异常”。

2.2 监控指标里的三组关键信号

拿到故障地图后,我习惯性地去看三组指标:QPS曲线、延迟分位数、错误率分布。

当日活跃用户数在一个小时内上升了将近40%,而且新增流量的访问目标高度集中,大量请求都指向同一个热点内容池。这种流量特征是最容易触发故障的——不是所有请求都变多,而是集中在少数内容上的请求突然暴涨,让原本做好的缓存分片瞬间部分失效。

延迟分位数同样反常:P50变化不大,说明大多数正常用户的访问没有受到太大影响;但P99延迟从平时的百毫秒级别飙到了秒级。P50和P99的差距迅速拉大,通常意味着大部分请求还能正常处理,但有一小部分请求遇到了某种阻塞或排队,这类请求在重试后又会叠加新的压力。

错误码的分布也透露了信息:超时类错误占了一半以上,连接被重置的错误也不少,还有一部分是缓存未命中后回源存储时产生的慢请求。这三组信号放在一起,基本可以勾勒出问题的基本面貌——接入层和应用层还活着,但后端的某个公共资源已经压力过大,开始拖慢整个链条了。

3. 沿着请求链路一格格排查:从接入层到存储层

3.1 接入层和负载均衡:先排除最外面的嫌疑

确定是区域性流量异常后,我按“接入层→应用层→缓存层→存储层”的顺序逐段排查。这种排查方式看起来简单,但在压力状态下非常管用,能避免你在排查过程中被各种噪声指标带偏。

先看接入层。负载均衡的CPU、连接数、并发数都在设计水位内,没有出现连接队列溢出的情况。边缘节点的带宽使用率虽然涨了不少,但还没到物理上限。这说明请求到达服务端后,接入层有能力正常分发,问题不在最外层。

接着看应用层。应用服务的CPU和内存使用率都比较平稳,但线程池的活跃线程数明显偏高,而且有大量线程阻塞在等待下游响应。这个现象让我把注意力往下层移动——应用层本身没有瓶颈,它是在等下游返回。这时候如果再盯着应用层查,就是在浪费时间了。

3.2 缓存集群:命中率这张晴雨表

果不其然,问题在缓存层开始显现。正常情况下,热点内容的缓存命中率应该维持在95%以上,但当时已经掉到了70%左右。可能你会觉得70%也不算太低,但在高QPS的背景下,命中率每下降一个百分点,回源到存储层的请求量都会放大好几倍。

缓存命中率为什么会被打下来?原因是新增的流量高度集中,一部分热点分片的热度超过了单节点的承载能力,触发了缓存淘汰。原本应该长期驻留在内存里的热点数据,因为某个分片压力过大被频繁挤出,后来访问时只能重新回源加载。而回源加载的成本非常高,因为存储层要做真实的查询、拼装内容、写回缓存,这中间还有网络开销和锁竞争。

这里我想多说一句,缓存集群的容量不能只按“平均热点数据量”来规划,一定要按“最热分片的数据量”来规划。热点数据的分布是严重倾斜的,20%的内容可能承载了80%的流量,如果按平均值规划,热点分片一定会先被打爆,然后牵一发动全身。

3.3 存储层:回源风暴的终局

缓存失效之后,所有的请求压力都转移到了存储层。存储层的CPU使用率倒不算高,但它出现了大量的锁等待和慢查询。尤其是热点内容池相关的数据表,单行数据被并发读取的争用非常严重,有些查询从平时的毫秒级退化到了秒级。

存储层的慢查询又反过来加重了缓存层的压力——什么数据都没读到,缓存自然也没法回填,后续请求依然会再次打过来,这就是典型的“回源风暴”。到了这一步,整个链路其实已经进入恶性循环了:请求进不来是死,能进来的也全部卡在存储这一环。

我后来画链路图复盘时,把这次事件形容成“一个局部热点被打爆,导致整条链路温度失控”。问题的起点是缓存分片的热点倾斜,但真正造成大面积不可用的,是回源风暴引发的连锁阻塞。

4. 为什么热点分片会在关键时刻成为最脆弱的环节

4.1 一致性哈希与热点数据分布

要理解缓存分片为什么不扛打,得先从数据分布策略讲起。我们当时的缓存集群用了一致性哈希来分片数据,好处是节点扩缩容时迁移的数据量少,坏处是它只负责把数据均匀地映射到不同节点,无法感知“某个内容实际有多少访问量”。

一致性哈希解决的是数据分布的均匀性问题,但它没有办法解决访问分布的倾斜性问题。一个节点上分到的数据量可能是均匀的,但如果某个数据是超级热点,那这个节点承担的实际访问量就会远超平均水平。集群里的节点容量按平均热度规划,可热点分片需要的是数倍于平均值的冗余能力。

这次事件里,某个热点内容池恰好落在了一个分片上,单分片的请求量很快就超过了它的最大承载能力。节点CPU飙高、内存频繁淘汰、请求排队,然后这个分片上的其他本来正常的缓存数据也被连累,一起变成慢请求。这就是典型的局部故障放大为全局故障。

4.2 热点内容池的请求放大效应

内容平台的流量还有一个特性,就是热点扩散的速度极快。某个视频被头部博主转发后,带来的访问往往是同时段、同目标的高并发请求。如果这部分新增流量全部涌向一个缓存分片,那缓存是否命中就变得不那么重要了——就算命中了,分片节点自身的吞吐能力也会成为瓶颈。

我做过一个粗略估算:热点内容的单条访问QPS如果超过分片节点总QPS上限的五分之一,这个分片就基本进入了危险区。因为我们还要为正常流量、数据同步和内部任务预留资源,节点又不是只服务这一条热点数据。

当时我们没有对热点内容做特殊的分散处理,这是最根本的架构缺陷。你可以在缓存前面加一层本地缓存,也可以对热点key做多副本分散,或者用读写分层的结构把高并发读请求导向专门的只读节点,这几种方案都能缓解单分片的压力。但在当时,我们这些机制都没有,所以只能眼睁睁地看着热点把分片压垮。

4.3 从“补容量”到“削热点”的思路转变

复盘时我们把这次根因归纳为“热点数据的单点放大效应”,而不是简单的容量不足。如果只按流量峰值补容量,实际上是治标不治本——因为你不可能无限扩张缓存集群来应对极端的访问倾斜,成本不允许,扩容速度也跟不上热点扩散的速度。

真正要做的是“削峰”——把集中打在少数热点数据上的流量,在架构层面做分散和缓冲。我们后面做的改进包括三层:一是对热点数据做识别和标记,在网关层或缓存客户端做本地短时缓存;二是对确实需要回源的热点请求做合并回源,减少穿透到存储层的实际请求量;三是给存储层设置更灵活的限流和降级策略,宁可丢弃一部分非核心请求,也要保住核心链路的可用性。

有一次演练时,我们把一个模拟热点内容的QPS压到了正常峰值的五倍,旧架构下缓存命中率直接跌破60%,新架构下还能维持在90%以上。这个对比让我特别深刻地理解了“容量方案解决的是平均流量,架构方案解决的才是极端流量”。

5. 回源风暴的连锁反应:存储层是如何被拖垮的

5.1 慢查询与锁等待的放大效应

回源风暴听起来是个很技术的问题,实际表现却非常直观:大量的请求同时去存储层拿同一批内容,每个请求都要经过权限校验、元数据查询、内容读取、序列化拼装这些步骤。

在这些步骤里,元数据的查询是最容易出现锁争用的。因为内容平台的数据一般都有关联关系,比如说某条视频关联的创作者信息、话题信息、推荐权重等,这些元数据被高频访问时,数据库的行锁和事务日志就成了天然的瓶颈点。

慢查询一旦出现,就会产生放大效应:一个慢查询占用数据库连接的时间可能是正常查询的几十倍,连接池很快被占满,其他正常查询不得不排队等待。从监控上看,就是数据库的活跃连接数飙升、等待时间拉长、整体吞吐量反而下降。很多团队第一次经历故障时会误以为是数据库资源不足,其实大部分时候是锁等待和连接占满造成的“假性资源耗尽”。

5.2 连接池参数在极限压力下的行为差异

这里有一个值得展开的细节:同样的连接池配置,在正常流量和极限压力下的行为是完全不一样的。正常流量下,连接池的连接复用率高,建连开销可以忽略;但在回源风暴时,每个请求都可能等待很久都拿不到连接,应用端的连接池就会频繁地新建连接、释放连接、再排队等待,这种建连风暴会进一步耗尽应用端的线程资源。

我们在排查时观察到,应用服务的线程池活跃线程数一直在高位,但CPU使用率并不高,大量的线程其实都在等待存储层返回。这个现象已经足够说明问题了——瓶颈在下游,而且下游正在被不可控的请求淹没。

当时最紧急的措施是临时调低应用层连接池的最大连接数,同时打开存储层的快速失败开关,把等待时间超过200毫秒的请求直接返回失败。这个动作表面上是“拒绝更多请求”,实际上是保护存储层不被持续压垮,让已经进来的请求有机会被处理完。故障处理的第一原则,是保服务而不是保请求,先止血再修复。

5.3 降级策略:果断切掉非核心链路

存储层在风暴中非常挣扎的时候,我们同时做了一个关键的决策:把一部分非核心链路降级掉。主要是两类,一类是内容推荐相关的关联查询,另一类是互动数据的实时刷新。这两类功能在产品上很重要,但在“打不开App”面前,它们是可以暂时牺牲的。

降级之后,核心的播放链路从“多次关联查询+内容拼装”变成了“直接读取主内容+基础元数据”,查询深度大幅降低,存储层的压力瞬间减轻了不少。这里我的经验是:降级方案不能在故障发生时才临场设计,必须在系统上线前就定义好“哪些功能在什么条件下可以关闭”,并且做好开关和演练。临时设计的降级方案往往不够干净,容易在慌乱中引入新的问题。

我们没有做数据库的主从切换,因为当时不是写入量爆发导致的主库故障,只是读请求风暴把连接池打满了。很多时候,主从切换看起来是最酷的处理手段,但在回源风暴场景下,切换反而可能让本来就脆弱的链路变得更不稳定。少做动作、做准动作,是故障处理里非常重要的原则。

6. 流量调度与区域隔离:下一次风暴来之前做完的三件事

6.1 把热点流量分散到更多节点上

根因找到之后,我们做了一次比较大的架构调整,核心思路是三个字:分散、隔离、缓冲。

第一件事是解决热点数据单点的问题。我们在缓存客户端里加了一层热key识别逻辑,对于超过设定阈值的访问频率,会把同一个key的请求分散到多个副本节点上,并在本地增加一段短时间的缓存。这样一来,即使热点数据在实际访问量上被放大了很多倍,单一节点的压力也不会无限增长。

这个改造看起来简单,但落地的时候有个细节要注意:副本数据的一致性。不同副本上的数据可以有一定的延迟,但不能差异过大。我们给热点数据的多副本设置了较短的有效期,并保证主副本写入完成后,立即异步同步到其他副本。对于内容平台这种“读多写少、一致性强要求不高”的场景,这个方案在成本和效果之间找到了一个平衡点。

6.2 区域级流量调度和熔断机制

第二件事是加强区域级的流量调度能力。以前我们把所有区域的流量都打进同一个接入集群,好处是调度简单,坏处是当一个区域出现异常流量时,很难快速把这块流量切换到其他区域,只能在整个集群层面被动扛压。

改造后,我们在每个区域入口做了独立的流量识别和调度策略。当某个区域的错误率或延迟超过阈值时,系统会自动把一部分读流量切换到相邻区域的健康节点上。这种机制有点像城市交通的应急车道——平时不走,但一旦主路拥堵,可以快速分流给其他道路。

同时,我们在网关层增加了基于错误率的熔断器。熔断器的粒度不是全局的,而是按服务和方法维度隔离。如果某个服务连续一段时间错误率超过阈值,网关就不再往这个服务转发新的请求,而是直接返回兜底结果。这样即使某个底层服务再出问题,影响范围也能被限制在局部,不会再像上次那样滚雪球扩大到整个链路。

6.3 内容平台的性能容量估算方法

第三件事是重新设计了容量估算模型。以前我们做容量评估的时候,习惯用“历史峰值+缓冲系数”的方式,这种方法在业务形态稳定的情况下是够用的,但对内容平台这种热点驱动特征明显的业务来说,并不现实。热点内容的访问量可以在几分钟内翻数倍,根本没有规律可循,历史峰值根本不具备参考意义。

我们把容量模型改成了“基础流量按历史峰值冗余30%,热点增量按内容池规模和扩散系数动态评估”。具体来说,我们会根据内容池的头部内容数量、历史热点扩散速度、当前活跃用户规模等数据,计算出一个“热点放大系数”,然后把这个系数乘以预期的单热点QPS,作为缓存和存储层预留资源的依据。

这个模型在几次大流量事件的复盘验证中表现不错,大家可以去查一查自己业务的热点倾斜度,大多数内容型业务的热点系数都在5到30之间,这说明如果你的容量规划里完全没考虑热点倾斜,你的系统其实一直处在低位悬空的状态。

7. 全链路压测的几个反直觉结论:为什么“测过了”还是会出事

7.1 用平均值跑压测是最大的假安全感

很多人可能会好奇,我们之前难道没做过压测吗?其实做过,但当时压测的方式有问题——我们用平均值来设计压测场景,流量模型是均匀分布的,每个接口的压力都差不多。这种压测方式最大的问题在于它验证的是“系统的平均承载能力”,而不是“系统在极端热点倾斜下的表现”。

真实故障从来不是均匀分布的。你提前预估了某个接口QPS是1万,于是按1万去压测,测完发现系统稳定,觉得万事大吉。但故障当天,可能其中有2000的QPS全部打在了同一条数据上,这条数据所在的节点就垮了,然后像多米诺骨牌一样牵连到整个链路。用平均值压测,本质上是在验证一个永远不会发生的场景。

7.2 全链路压测必须构造“倾斜流量”

我们后来重新设计了压测的流量模型,核心原则是“模型可以糙,但倾斜度必须真实”。我们会从线上采集真实的流量特征,包括热点内容的集中度、不同区域的流量分布、不同时间段的请求形态,然后在压测环境中按这个特征构造流量。

第一次用倾斜流量压测的时候,系统在十分钟内就出现了和正式故障一模一样的表现:缓存命中率下滑、存储层慢查询增多、连接池占满。那一刻说实话,大家反而松了一口气——因为终于能在可控环境下复现问题、验证修复方案了。

建议大家做压测的时候多想一想“最坏情况”:如果一条数据突然变成全网热点,你的缓存扛得住吗?如果一个区域的服务不可用了,其他区域能兜住吗?如果存储层延迟从1毫秒变成500毫秒,你的应用层会怎么表现?这些问题测试通过,比报喜不报忧的“平均压测通过”有价值得多。

7.3 故障演练的频率:每季度至少一次

另一个团队觉得“很反直觉”的经验是:故障演练不是越频繁越好,但没有演练比什么都可怕。

我们现在的节奏是把故障演练纳入每个季度的常规工作中,每次演练针对一个已经发生过的故障场景,按“模拟故障→观察现象→按照预案处置→恢复验证→复盘”的流程走一遍。演练不要提前通知所有人,让小伙伴们在没有心理准备的情况下直面告警,这样的演练才能暴露真实问题。

有一句话说得很对:故障演练的目的不是证明系统稳定,而是暴露系统不稳定的地方。你愿意在可控的演练环境里暴露问题,好过在真实的用户流量面前被迫暴露问题。

8. 复盘文档之外:我在事后沉淀的几条排查经验

事件解决之后,复盘文档当然要写,但比起文档,我更想分享几条直接沉淀到工作习惯里的经验。

第一,任何“看似突然”的故障都有前兆。回看监控历史数据,其实在故障前的30分钟,热点分片的响应延迟已经出现了一轮小幅攀升,只是没有超过告警阈值,被我们忽略了。事后我们把这块的告警阈值往前挪了一步,宁可误报,不可漏报。告警的意义不是在你已经知道故障发生的时候再响一次,而是在你看不出来的时候提前告诉你。

第二,处理故障时最忌讳在群里频繁请示确认。紧急状态下,一线工程师需要的是处置授权和清晰的决策链。我们后来约定:符合降级条件时,值班工程师有权直接执行降级操作,事后再补审批流程。故障处置里的时间是以秒计的,等一个汇报审批下来,故障早就在链条里传导得更深了。

第三,热点数据的治理不是一个一次性项目,而是一个持续运营项。热点是业务天然产生的,躲不掉,只能靠机制去持续发现、标记和缓解。我们后来搭了一个简单的热点分析任务,把实时流量里的热点内容名单定期同步给网关和缓存层,让它们动态调整策略。这个机制上线后,同类事件再也没有发生过一次。

我始终觉得,稳定性工作的成就感不在于系统永远不出故障,而在于每次故障都能变成一次学习和加固的机会。这次事件的整个过程让我更加确信,对内容平台这类业务来说,真正缺的往往不是多少台服务器,而是对流量倾斜这一本质问题的充分理解,以及针对它提前布下的那一层层防御。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进