Contents

再谈软件质量:关于事故的思考

线上系统做久了,会越来越确信两件事:

  • 系统失效存在必然性:复杂系统里,“偶发”只是时间问题。
  • 事故处理的第一优先级永远是止损与恢复服务,其次才是调查原因:先把影响面控制住,再谈复盘与改进。

接受“必然会出事”不是消极,而是把目标从“让事故不发生”改为“让事故发生时不扩散、可定位、可恢复”。工程上这对应三类能力:隔离、降级、恢复。

很多行业会用 Reason 模型 分析事故:不要只盯着“最后一个犯错的人”,而要追到组织流程、环境条件、工具与制度的缺陷。对软件事故同样适用。

事故并不神秘,常见根因只有两类:变化耦合

  • 变化:代码、配置、发布、依赖、流量、环境、权限、证书过期
  • 耦合:一个点变慢/失败,会把压力沿调用链传染到更多点,形成失效链

把变化按可控程度分两类更实用:

  • 主动变更:发布、配置、开关、DDL、容量调整
  • 非主动变更:流量突增、下游抖动、网络/机房故障、攻击与爬虫

主动变更的问题不是“会错”,而是“错了之后扩散太快”。非主动变更的问题是“总会发生”,你只能提前设计应对方式。

一个典型的互联网产品服务端架构大致如下(接入层 → 服务层 → 存储层):

互联网典型服务端架构

每个 RPC、每个 socket、每次数据库访问,都是一个集成点。集成点最可怕的不是“直接报错”,而是“变慢”:线程被占住、连接池被打满、排队变长,最后把本来局部的问题放大成全局雪崩。

最小防线通常是三件套:

  • 超时:给每段外部调用设置明确超时(最好区分连接超时与请求超时)
  • 熔断/降级:下游异常时主动失败或降级,避免拖住自身资源
  • 隔离:线程池/连接池/舱壁(bulkhead)把故障限制在局部

一个常见实践是给“下游调用”设定固定预算,例如:外部依赖超时 200–500ms(按业务分级),并把剩余预算留给自身处理与返回。超时值不一定要“最优”,但一定要“明确且一致”,否则你会在排查时被随机超时拖死。

集群水平扩展能消除单点,但当某些节点因为内存泄漏、负载相关 bug、热点请求而崩溃时,流量会倾斜到健康节点,导致健康节点负载上升、进一步崩溃,最终整层退化。

202411261356821

应对思路通常是:

  • 快速识别并摘除坏节点:健康检查要覆盖“业务可用性”,而不仅是进程存活
  • 让扩容/替换快过扩散:自动替换实例、预留容量、快速启动
  • 把热点变可控:热点 key 保护、请求合并、缓存穿透治理

多线程(或 Go 的 goroutine)让并发变容易,也让“阻塞”更隐蔽。典型症状包括:

  • 上游请求堆积、P99 延迟飙升,但 CPU 并不高
  • 连接池耗尽(HTTP client、DB pool、Redis pool)
  • goroutine/线程数不断上涨

根因常常是:下游变慢 + 无超时/超时过大 + 重试叠加 + 缺少隔离。

用户会消耗资源,也会做奇怪的事;恶意用户更是持续存在。常见的工程动作:

  • 限流与配额:按 IP/用户/业务维度做速率限制与并发限制
  • 鉴权与防刷:验证码、风控、设备指纹、签名校验
  • 保护共享资源:把 DB、缓存、外部依赖当作“稀缺资源”来配额

开发/测试环境规模小,很多放大效应根本观察不到;到生产才会“咬人”。最典型的两类放大:

  • 扇出放大:一次请求触发 N 次下游调用(尤其是循环调用、分页不当、批量接口缺失)
  • 重试风暴:上游重试叠加下游变慢,导致指数级放大

这也是为什么“营销活动/热点事件”经常能把系统打穿:它不是单纯流量大,而是把你系统里隐含的放大效应瞬间激活。

事故无法杜绝,但影响可以被限制。把目标拆成三段会更可执行:事前让变更更安全,事中把系统拉回稳态,事后把教训沉淀成改动。

主动变更治理的目标不是“永不出错”,而是“错了也不会扩散”:

  1. 变更分级与审批:越关键的变更需要越严格的 review 与审批
  2. 标准化发布:Checklist + SOP(依赖顺序、回滚路径、观察指标、负责人)
  3. 灰度与渐进放量:小流量验证 → 指标达标再放量
  4. 随时止损的开关:功能开关、降级开关、只读模式、限流阀值可动态调整
  5. 配置变更治理:配置校验(类型/范围/依赖)、双人复核、审计与回滚

一个简单但常被忽略的 Checklist 模板(示意):

  • 这次变更能否 一键回滚(代码/配置/开关)?
  • 这次变更的 观察指标 是什么(错误率、P95/P99、QPS、依赖失败率)?
  • 是否做 灰度,灰度比例与观察时长是多少?
  • 发生异常时,谁有权限执行 止损动作(回滚、开关、限流)?
为生产环境而设计

把“稳定性方法论”换成你能落地的动作:

  • 超时与快速失败:下游慢时宁可失败,也不要拖死线程与连接池(避免慢性死亡)
  • 限流与排队:入口限流 + 关键资源(DB/Redis/下游)限流;必要时用队列削峰
  • 熔断与降级:依赖不可用时返回缓存/默认值/降级页面,或拒绝非关键请求
  • 重试要克制:指数退避 + 抖动;区分可重试/不可重试错误;避免级联重试
  • 分区与隔离:舱壁(bulkhead)、线程池隔离、连接池隔离,避免一处故障拖死全局
  • 稳态与清理策略:缓存上限、日志滚动、历史数据归档/清理,防止资源无限增长
  • “崩溃并恢复”:让进程可快速重启,配合健康检查与自动替换实例
  • 演练:压测、容量评估、故障注入/混沌工程,验证“失败时是否按预期工作”

持续交付不是“跑通流水线”,而是让交付过程可预期、可回滚、可观测。常见关注点:

  • 更新机制:计划内停机 vs 不停机更新(热重启/滚动发布)
  • CI/CD:构建、测试、扫描、发布、回滚自动化
  • 环境差异:公有云/私有云/私有化交付导致的配置、依赖、网络差异
  • 版本治理:兼容性策略、灰度策略、回滚策略

事故发生后建议把动作分层:

  • 止损:限流/降级/熔断/开关/回滚,先把影响面控制住
  • 定位:先看 SLI 指标(错误率、延迟、饱和度)与调用链,再看日志与现场
  • 恢复:修复或绕过故障点,确保系统回到可接受状态

这依赖两个前置条件:可观测性(指标、日志、链路)与可回滚性(快速回滚、配置回滚、开关撤回)。

复盘不应该停在“根因分析”,而要能落到两类改动:

  • 流程改动:变更分级、审批、灰度策略、值班与响应机制
  • 系统改动:补齐超时/隔离/降级/限流/告警/自动化恢复/对账

最后一句话:质量与稳定性不是口号,它来自持续投入与长期纪律。