再谈软件质量:关于事故的思考
线上系统做久了,会越来越确信两件事:
- 系统失效存在必然性:复杂系统里,“偶发”只是时间问题。
- 事故处理的第一优先级永远是止损与恢复服务,其次才是调查原因:先把影响面控制住,再谈复盘与改进。
接受“必然会出事”不是消极,而是把目标从“让事故不发生”改为“让事故发生时不扩散、可定位、可恢复”。工程上这对应三类能力:隔离、降级、恢复。
很多行业会用 Reason 模型 分析事故:不要只盯着“最后一个犯错的人”,而要追到组织流程、环境条件、工具与制度的缺陷。对软件事故同样适用。
事故从哪里来:变更 + 耦合
事故并不神秘,常见根因只有两类:变化 与 耦合。
- 变化:代码、配置、发布、依赖、流量、环境、权限、证书过期
- 耦合:一个点变慢/失败,会把压力沿调用链传染到更多点,形成失效链
把变化按可控程度分两类更实用:
- 主动变更:发布、配置、开关、DDL、容量调整
- 非主动变更:流量突增、下游抖动、网络/机房故障、攻击与爬虫
主动变更的问题不是“会错”,而是“错了之后扩散太快”。非主动变更的问题是“总会发生”,你只能提前设计应对方式。
典型失效模式:它们怎么把小问题放大
一个典型的互联网产品服务端架构大致如下(接入层 → 服务层 → 存储层):

1)集成点:一次调用就是一次风险
每个 RPC、每个 socket、每次数据库访问,都是一个集成点。集成点最可怕的不是“直接报错”,而是“变慢”:线程被占住、连接池被打满、排队变长,最后把本来局部的问题放大成全局雪崩。
最小防线通常是三件套:
- 超时:给每段外部调用设置明确超时(最好区分连接超时与请求超时)
- 熔断/降级:下游异常时主动失败或降级,避免拖住自身资源
- 隔离:线程池/连接池/舱壁(bulkhead)把故障限制在局部
一个常见实践是给“下游调用”设定固定预算,例如:外部依赖超时 200–500ms(按业务分级),并把剩余预算留给自身处理与返回。超时值不一定要“最优”,但一定要“明确且一致”,否则你会在排查时被随机超时拖死。
2)同层连累反应:一台挂了,其他更容易挂
集群水平扩展能消除单点,但当某些节点因为内存泄漏、负载相关 bug、热点请求而崩溃时,流量会倾斜到健康节点,导致健康节点负载上升、进一步崩溃,最终整层退化。

应对思路通常是:
- 快速识别并摘除坏节点:健康检查要覆盖“业务可用性”,而不仅是进程存活
- 让扩容/替换快过扩散:自动替换实例、预留容量、快速启动
- 把热点变可控:热点 key 保护、请求合并、缓存穿透治理
3)线程/协程阻塞:慢 I/O 是最常见的资源泄漏
多线程(或 Go 的 goroutine)让并发变容易,也让“阻塞”更隐蔽。典型症状包括:
- 上游请求堆积、P99 延迟飙升,但 CPU 并不高
- 连接池耗尽(HTTP client、DB pool、Redis pool)
- goroutine/线程数不断上涨
根因常常是:下游变慢 + 无超时/超时过大 + 重试叠加 + 缺少隔离。
4)用户与攻击:随机性与对抗性同时存在
用户会消耗资源,也会做奇怪的事;恶意用户更是持续存在。常见的工程动作:
- 限流与配额:按 IP/用户/业务维度做速率限制与并发限制
- 鉴权与防刷:验证码、风控、设备指纹、签名校验
- 保护共享资源:把 DB、缓存、外部依赖当作“稀缺资源”来配额
5)放大效应:小请求在生产规模下变成大灾难
开发/测试环境规模小,很多放大效应根本观察不到;到生产才会“咬人”。最典型的两类放大:
- 扇出放大:一次请求触发 N 次下游调用(尤其是循环调用、分页不当、批量接口缺失)
- 重试风暴:上游重试叠加下游变慢,导致指数级放大
这也是为什么“营销活动/热点事件”经常能把系统打穿:它不是单纯流量大,而是把你系统里隐含的放大效应瞬间激活。
如何降低事故影响:事前/事中/事后
事故无法杜绝,但影响可以被限制。把目标拆成三段会更可执行:事前让变更更安全,事中把系统拉回稳态,事后把教训沉淀成改动。
事前:把“可控的错误”变得更可控(主动变更)
主动变更治理的目标不是“永不出错”,而是“错了也不会扩散”:
- 变更分级与审批:越关键的变更需要越严格的 review 与审批
- 标准化发布:Checklist + SOP(依赖顺序、回滚路径、观察指标、负责人)
- 灰度与渐进放量:小流量验证 → 指标达标再放量
- 随时止损的开关:功能开关、降级开关、只读模式、限流阀值可动态调整
- 配置变更治理:配置校验(类型/范围/依赖)、双人复核、审计与回滚
一个简单但常被忽略的 Checklist 模板(示意):
- 这次变更能否 一键回滚(代码/配置/开关)?
- 这次变更的 观察指标 是什么(错误率、P95/P99、QPS、依赖失败率)?
- 是否做 灰度,灰度比例与观察时长是多少?
- 发生异常时,谁有权限执行 止损动作(回滚、开关、限流)?
事前:为生产环境而设计(非主动变更)

把“稳定性方法论”换成你能落地的动作:
- 超时与快速失败:下游慢时宁可失败,也不要拖死线程与连接池(避免慢性死亡)
- 限流与排队:入口限流 + 关键资源(DB/Redis/下游)限流;必要时用队列削峰
- 熔断与降级:依赖不可用时返回缓存/默认值/降级页面,或拒绝非关键请求
- 重试要克制:指数退避 + 抖动;区分可重试/不可重试错误;避免级联重试
- 分区与隔离:舱壁(bulkhead)、线程池隔离、连接池隔离,避免一处故障拖死全局
- 稳态与清理策略:缓存上限、日志滚动、历史数据归档/清理,防止资源无限增长
- “崩溃并恢复”:让进程可快速重启,配合健康检查与自动替换实例
- 演练:压测、容量评估、故障注入/混沌工程,验证“失败时是否按预期工作”
事前:为交付而设计
持续交付不是“跑通流水线”,而是让交付过程可预期、可回滚、可观测。常见关注点:
- 更新机制:计划内停机 vs 不停机更新(热重启/滚动发布)
- CI/CD:构建、测试、扫描、发布、回滚自动化
- 环境差异:公有云/私有云/私有化交付导致的配置、依赖、网络差异
- 版本治理:兼容性策略、灰度策略、回滚策略
事中:救火不是“找人背锅”,而是跑流程
事故发生后建议把动作分层:
- 止损:限流/降级/熔断/开关/回滚,先把影响面控制住
- 定位:先看 SLI 指标(错误率、延迟、饱和度)与调用链,再看日志与现场
- 恢复:修复或绕过故障点,确保系统回到可接受状态
这依赖两个前置条件:可观测性(指标、日志、链路)与可回滚性(快速回滚、配置回滚、开关撤回)。
事后:复盘要产出“可执行的改动”
复盘不应该停在“根因分析”,而要能落到两类改动:
- 流程改动:变更分级、审批、灰度策略、值班与响应机制
- 系统改动:补齐超时/隔离/降级/限流/告警/自动化恢复/对账
最后一句话:质量与稳定性不是口号,它来自持续投入与长期纪律。