一个内部流程高峰把系统压垮:容量估算、排队与降级的顺序

一、内部系统的容量假设为什么系统性偏低
据公开报道,宝马推出一项自愿离职补偿方案后,大量员工同时提交申请,内部系统因请求过多一度瘫痪,方案目标提前达成。这里不去讨论政策本身,只讨论一件事:这类系统为什么会在自己人的点击下倒下。
面向外部的系统,容量假设有一串历史数据支撑:日活、转化漏斗、时段分布、大促曲线。内部系统这几项基本都是空的,取而代之的是三种更弱的依据——按”日常流量乘以一点裕度”估算、按”反正只有几百人用”估算、以及按上一个类似项目的经验估算。这三种都隐含同一个错误前提:内部系统的到达过程是平稳的。
真实情况恰恰相反。内部流程的触发常常是同一个通知:邮件、公告或者一次宣讲。通知发出之后的很短一段时间里,同一批人对同一个入口发起同一类操作,这在流量形态上等价于一次规模不可预估的秒杀,但它没有报名页、没有预热、没有任何人把它当活动看。更麻烦的是需求侧无法被限流”劝退”——外部用户今天买不到明天还能买,内部员工今天交不了会一直刷新,甚至打电话、找同事代填、走线下纸质再来一遍,压力从系统转到人,最后仍然回到系统。
于是这类事故有一个共同结构:容量估算偏低,防护手段缺失,而用户行为会把缺口放大而不是缓解。
二、三类洪峰,形状不同,处置也不同
| 洪峰类型 | 到达形态 | 最脆弱的环节 | 处置重心 |
|---|---|---|---|
| 政策窗口期 | 通知后集中爆发,窗口内持续高位,截止前再一次翘尾 | 表单提交与审批发起链路 | 排队与异步化,把”提交成功”与”处理完成”解耦 |
| 批量操作 | 少数用户触发大量请求,单次操作扇出成百上千条 | 后端任务与数据库连接 | 扇出上限与任务分批,接口做请求方配额 |
| 故障后重试风暴 | 失败之后客户端定时重试,与恢复过程叠加形成振荡 | 超时与重试策略、连接池 | 退避加抖动、把重试预算算进容量 |
第三类最值得单独说。它不是外部冲击,而是我们自己造的:一次短暂失败让所有客户端在同一时刻重试,重试把系统再次打倒,恢复与打倒交替出现,曲线呈锯齿振荡。判断依据很简单——看请求的间隔分布是不是高度集中在某个固定值上。是的话,说明超时和重试配置本身已经成了一个流量发生器。治理办法是指数退避加随机抖动,并且让服务端在过载时明确返回可退避的信号,而不是含糊地超时。
三、没有历史曲线时怎么做上界估算
没有历史流量不等于不能估。我们用的方法是一句话:用业务基数乘参与率再乘单位时间集中度得到一个上界,然后按最慢环节校准。
第一步把三个量分开取值。业务基数是这个流程可能触达的人数或单据数,从组织主数据或历史台账里取,不靠感觉。参与率要按”最坏情况”取,而不是”预期值”——对一次性申报类流程,参与率的上界应当接近全部,因为这种流程往往同时带有截止期与不确定性,观望的人会在最后阶段一起动手。集中度要按通知方式估算:全员同时收到通知,集中度就高;分批通知或引导错峰,集中度就低。
第二步把峰值折算成需要处理的量,而不是折算成需要扛住的请求数。这两个口径的差别决定方案是否成立:请求可以在几秒内到达,但处理可以排到几十分钟之后。只要系统能给用户一个”已受理,正在处理”的确定状态,需要扛住的就是入口排队,而不是全链路吞吐。
第三步按最慢环节校准。把审批链路画成一条串行资源链:入口服务、校验、数据库写、审批引擎、消息与通知,逐个算出各自的持续处理能力,取最小值作为系统真实吞吐。内部流程里最小的那一级几乎从来不是 Web 层,而是审批引擎和数据库写。用入口层的吞吐做容量规划,是这类事故最常见的直接原因。
第四步留一个”可接受时长”作为设计输入:业务能接受提交后多久出结果。这个数字决定要不要异步、要不要只读降级,也是后面所有取舍的依据。
四、必须提前做的六项,顺序有意义
窗口期到来之前,这六件事按顺序做完才有意义;只挑两件做,通常白做。
- 读写分离与连接池上限先定死。把列表查询、状态查询这类读流量从主库摘出去,并为连接池设上限而不是让它自动膨胀。自动膨胀在洪峰下等于把压力原样转嫁给数据库,看起来服务还活着,实际是拖垮了所有人都用不了的那个共享资源。
- 入口排队与令牌桶。允许提交,但按最慢环节的能力发放令牌,多余的请求进入队列而不是直接进入执行。队要有上限和明确提示,无上限的队列只是把故障时间往后推。
- 幂等与重复提交防护。洪峰下用户会反复点击、客户端会重试,因此每个提交必须带幂等键,服务端对重复请求返回同一结果而不是生成第二条单据。没有这一条,后面所有补偿逻辑都是在给脏数据擦屁股。
- 异步化与状态查询。把”提交成功”与”办理完成”分开,给用户一个可轮询或可推送的状态页。这一条的价值不只是抗流量,它还消除了用户因为”不知道有没有提交上”而重复操作的动机,从源头上削掉一部分峰。
- 把大事务拆成可续跑的小步。审批流里一个动作常常牵涉多表写入与外部通知,一个大事务失败会整体回滚并重做。拆成有明确状态的步骤,每步可重入,失败从下一步续跑。
- 只读降级。过载时优先保查询与状态可见,暂时关闭非必要的写操作和附加功能,并给出可见的提示。降级的意义是让系统保持”部分可用”,而不是在”全可用”和”完全不可用”之间跳变。
五、限流值怎么定,以及”看起来慢其实是锁”
限流值不是拍出来的一个数,它要回答三个问题,我们按这三问来定。
第一问:按哪个依赖定并发。答案永远是链路里最慢的那个依赖,而不是本服务的处理能力。做法是逐个依赖做单链路压测得到各自可持续的处理能力,取最小值再减去为其他流量预留的份额,作为并发上限。
第二问:重试预算留了多少。上游会因为超时重试,因此实际到达量大于业务量。合理做法是给每个调用方约定重试比例,并把这部分显式算进限流值里——否则限流会把正常重试当成攻击,形成”被限流所以重试更多”的正反馈。
第三问:自适应限流会不会自我放大。基于成功率的自适应算法在恢复期尤其危险:成功率一低就收紧,收紧之后积压释放又立刻打满,系统跟着振荡。稳妥配置是限流阈值变化幅度设下限、恢复要慢于收紧、并且保留人工设定值的优先级。自适应可以作为补充,不适合当主控制手段。
审批流场景还有一个特别常见的假象:接口耗时上升、吞吐上不去,看起来像”算不动”,实际是锁等待。集中提交会带来热点行——同一张单据的主记录、同一个序列号、同一条汇总库存或者同一个批次计数器,行锁把这些请求串成一条队。表现为并发不高但延迟陡增,数据库等待事件集中在锁上。治理方向是消灭热点而不是加索引:把计数拆成多行再汇总、把状态更新拆成明细插入、更新时带上版本号做乐观锁并在冲突时退避重试。乐观锁在极高冲突下同样会退化,需要给退避次数设上限并让冲突比例成为可观测指标——冲突率本身就该被监控,它是”该拆热点了”的信号。
六、上线前的压测口径,和事后要留下的三件
压测最容易犯的错是压对了接口、压错了链路。我们的口径要求是三条。
阶梯压而不是冲击压。按业务链路逐步提高并发,每一档观察耗时与错误率的拐点。拐点位置比峰值数字有用,它告诉你系统从哪里开始退化,也告诉你限流值该设在哪一档之下。
按业务链路压,不要按单接口压。真实瓶颈常在链路组合上:提交加审批加通知同时发生时的连接占用、以及读流量对主库的干扰。单接口压测得到的结论放到链路上往往不成立。
验证降级开关真的生效。这一条最常被跳过,因为开关在测试环境”看起来能点”。要压到过载状态下去切降级,观察的是效果而不是状态位:查询是否仍可用、写是否被正确拒绝并返回可理解的提示、开关关闭之后积压是否被平稳消化而不是瞬间冲垮。
洹拓科技在给客户做这类内部系统的交付与复盘时,要求事故之后必须留下三件东西,缺任何一件,下一次同样会重演。峰值流量记录:真实到达曲线、峰值倍数与持续时间,这是下一个项目做上界估算的唯一可靠输入。限流与降级的触发日志:什么时候触发、拒绝了多少、排队多久、恢复用了多长时间。用户可见的重试指引:告诉申请人现在是什么状态、下一步该做什么。第三件看着像文案工作,实际上它决定了洪峰的第二个波峰会不会出现——没有明确指引时,用户会用刷新来表达焦虑。
最后归纳成一句可执行的话:先算最慢环节,再决定扛不扛;扛不住就先做排队、幂等和状态可见,降级永远是顺序而不是可选项。