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

做服务端开发工作好几年,挖了不少坑,也见过其他人挖了不少坑,也被别人挖的坑坑过不少次 🐶

每当事故发生时,大家都像热锅上的蚂蚁,点雷的人十分沮丧 && 紧张;leader 们一边忍着怒火假装云淡风轻,一边调动各种资源来救火,leader 的 leader 们一分钟一催进度;受影响的用户/客户投诉如潮水涌来;当然,还有吃瓜群众看热闹不嫌事大

同样作为工程师,经常会思考其他行业的工程质量是如何保证的,比如航空航天、船舶制造、汽车制造、建筑行业等等。如果互联网行业的故障率发生在火箭发射领域,那么恐怕是完全不敢想象的一件事

绝不让此事再发生”,经常听到的台词,可现实中,同样的事情必定还会再出现,只是看什么时候、由谁触发罢了

优秀的团队,往往在出事时对犯错的人有足够容忍,更加注重剖析事故根因,以及如何用可靠的手段解决,思考工作流程、系统上是否可以做优化。因为他们知道,在复杂的现实世界中,人,永远都是会出错的动物,是不可控因素。为了达到质量目标,一定要将不可控转变为可控

但,始终存在一个难以令人接受的事实:**系统失效存在必然性。**否认系统失效的必然性,会夺走人们控制和限制它的能力。一旦接受“系统必然会失效”这一事实,就有能力使系统对特定的失效做出相应的反应。正如汽车工程师创造出的碰撞缓冲区——为保护乘客而首先被撞毁的区域——你可以为系统创建一种安全失效模式,这种模式包含被损坏区域,并且为系统其他部分提供保护。这种自我保护决定了整个系统的韧性。Chiles 将这些保护措施称为“裂纹阻断器”。就像通过构建碰撞缓冲区来吸收撞击力并保护乘客安全,可以先确定系统的哪些特性是必不可少的,然后内建系统失效方式,防止重要特性出现裂纹。如果不设计系统失效方式,那么系统就会出现各种不可预测的问题,一旦出现,这些问题通常都是危险的

Important

对于任何事故,首要任务都是止损和恢复服务,其次才是调查原因!

对于任何事故,首要任务都是止损和恢复服务,其次才是调查原因!

对于任何事故,首要任务都是止损和恢复服务,其次才是调查原因!

在航空事故调查分析中常提到 Reason 模型

Reason 模型的核心点在于其系统观的视野,在对不安全行为的直接分析之外,更深层次地剖析出影响行为人的潜在的团队、场所、组织因素,以一个逻辑统一的事故反应链将所有相关因素进行了理论串联。

案例:某日凌晨 2 点 40 分左右,一架 A330 飞机在从虹桥国际机场 15 号桥位拖往跑道上进行发动机试车的过程中,飞机在 K4 道口的滑行道上偏离滑行线,导致飞机 5、6、7、8 号主轮进入滑行道边草地之中……。构成一起人为原因的严重不安全事件。

如果按照结果导向、事后查处的思维,事件的直接原因是指挥员和拖车司机的失误造成,假如指挥员正确指挥、驾驶员认真操作,就不会发生。

但应用 Reason 模型进一步分析,就会发现:那天因为整修跑道,原计划滑行的滑行道入口关闭,改由 K4 道口进入;因机场已关闭,滑行道边灯关闭。由此可见,行进线路的变化、夜间灯光不足等环境因素也是构成事件发生的工作场所条件。

同时,驾驶员、指挥员对滑行路线状况不熟、指挥交流方式缺陷等也是造成事故的隐患条件。再进一步分析,可以发现,在拖行飞机的相关程序、人员资质管理、制度程序落实方面存在着不足,而这正是组织系统的缺陷。这就不难发现,如果我们仅把着眼点放在最后的结果—操作人员身上,那么相类似的事件还会发生。

James Reason 教授将安全管理比喻为“打一场没有最终胜利的游击战”,是一场为了发现事故隐患、消除或控制其隐患的永不停息的奋争。为了使系统更加安全,我们可做的事情是无止境的。

在安全管理体系中有效的运用 Reason 模型可以帮助我们去发现系统的安全隐患,减少不安全事件发生的可能,避免不安全事件的重复发生。

进而,通过风险管理可以帮助我们发现系统中的风险所在,并确定相关的优先等级,进而采取措施消除它们或减缓其后果,真正实现安全关口前移,确保持续的安全、可靠的安全。

在每次系统事故的背后,都有一条由一个个事件构成的失效链。一个小问题会导致另一个问题,后者再导致下一个问题。若事后查看整个系统失效链,会发现系统失效似乎不可避免。如果试图估算失效链上所有事件都会发生的概率,会发现概率极低,但这仅限于将每个事件都视作独立事件。硬币没有记忆力,所以每次投掷它时,出现正反两面的概率都相同,并与以前的投掷无关。然而,导致失效的事件并不是相互独立的。一个点或一个层次的系统失效,实际上增加了其他点或其他层次发生系统失效的概率。如果数据库响应变慢,应用程序服务器更有可能耗尽内存。因为这些层次是耦合在一起的,所以这些事件并非彼此独立

技术社区在如何处理失误方面存在分歧

  • 一个阵营表示要构建具有容错功能的系统。应该捕捉异常、检查错误代码,并且通常要防止失误演变为错误
  • 另一个阵营则表示,以容错为目标是徒劳的。这就像试图制造具有防误操作的设备一样白费功夫,因为总会出现更傻的傻瓜。无论试图发现和消除什么失误,都会发生意想不到的事情。所以,应该任其崩溃并替换,这样就可以从已知的良好状态重新开始

然而,两个阵营对以下两件事的看法是一致的

  • 第一,失误总会发生,且永远无法杜绝,必须防止失误转变为错误
  • 第二,即使在尽力防止系统出现失效和错误时,也必须决定承担失效或错误的风险是否利大于弊

摘自:《发布!设计与部署稳定的分布式系统(第 2 版)》

生产环境中出现的每次系统失效都是独一无二的。没有两起事故会完全沿着同一条系统失效链发展:由相同的因素触发,具有相同的损坏情况,以相同的方式蔓延。然而,随着时间的推移,确实能发现一些系统失效模式

从整体来看,事故发生绝大部分都是变更产生(甚至可以说是 100%)。变更又可以大致分为

  1. 主动变更
    1. 人为主动变更(配置修改、版本发布等)
  2. 非主动变更
    1. 业务流量变化(如明星热点事件)
    2. 不可预期变更(如机房断电、地震等)

在业务发展过程中,产品运营可能经常调整一些后台业务配置,操作有误时可能产生严重的后果。比如营收类业务配置一些奖励下发,配错奖品数量、价值,可能在短时间内造成巨大的资产损失;

在线上版本发布过程中,前端、客户端、服务端均会经常性进行线上发布,当遇到发布有问题的版本,或发布依赖顺序不正确等场景,可能会导致功能不可用或更大的影响

如今一个典型的互联网产品的服务端架构如图

互联网典型服务端架构

接入层包含就近接入、四层和七层负载均衡、长、短链接网关服务等

逻辑服务层是各业务自己负责的核心部分,现如今基本上都是微服务集群

存储层提供数据的缓存、持久化存储等能力

不同层可能都有多个服务节点,同层之间也存在互相调用的关系,每个服务节点都有多个调用上游和多个调用下游,将这样的服务节点称为集成点

集成点是系统的头号杀手。每一个传入的连接都存在稳定性风险。每个 socket、进程、管道或 RPC 都会停止响应。即使是对数据库的调用,也可能会以明显而微妙的方式停止响应。系统收到的每一份数据,都可能令系统停止响应、崩溃,甚至产生其他的冲击

要点:

  • 每个集成点最终都会以某种方式发生系统失效,所以需要为系统失效做好准备
  • 为各种形式的系统失效做好准备
  • 系统失效会迅速蔓延
    • 若系统代码缺乏一定的防御性,那么远程系统失效会以层叠失效的方式迅速演变为系统问题
  • 采用一些模式来避免集成点问题,如
    • 熔断
    • 超时
    • 中间件解耦

集群水平扩展是常见的模式,不易遭遇单点故障导致系统异常,但如果出现部分节点的缺陷(通常是内存泄漏或负载相关的崩溃),就会导致其他节点需要承担更多工作,产生同层连累反应,严重时造成整层服务可用性下降

202411261356821

要点:

  • 记住:一台服务器的停机会波及其他服务器
    • 由于一台服务器停机,其他服务器必须负担其工作负载,这样就会发生同层连累反应。增加的负载使得剩余的服务器更易发生系统失效。同层连累反应会迅速让整层系统停机。依赖该层系统的其他层级必须做好防护措施,否则将会陷入层叠失效
  • 寻找资源泄漏
  • 寻找难以捕捉的时序缺陷
  • 采用自动扩容
    • 应该为云端的每个自动扩展组创建健康状况检查机制。自动扩展将关闭未通过健康状况检查的服务器实例,并启动新的实例。只要自动扩展机制的响应速度比同层连累反应的蔓延速度快,那么系统服务就依然可用

多线程技术使应用程序服务器具有足够的容量扩展能力,来满足 Web 上最大站点的需求。但这也引入了产生并发错误的可能性

此外在如今 Go 大行其道的背景下,Goroutine 的管理和调度成本也是值得探究的话题

用户有时也会对系统造成影响,一些要点如:

要点:

  • 用户会消耗资源
  • 用户会做奇怪和随机的事
  • 恶意用户总是存在的
    • 安全对抗
    • 反爬策略
    • 法律手段
  • 正常用户大量涌入
    • QQ 音乐的典型例子:周杰伦发新专辑了…

一个很好的例子就是 Xbox 360 游戏机刚开始预售那会儿。很明显,其需求量在美国远远超过了供应量。当一家大型电子产品零售商发出促销预售的电子邮件时,其中就包含了接受预售订单的确切日期和时间。这封电子邮件在同一天出现在 FatWallet、TechBargains 以及其他大型比价网站的页面上。其中还特意地包含了一个绕过 Akamai 的深层链接,使得所有图像、JavaScript 文件和样式表,都能直接从原始服务器中提取。在预售开始前的一分钟,整个网站就像新星一样被点亮,然后就黯淡下来。60 秒内,这个网站就彻底消失了。每个曾经在零售网站工作过的人,都经历过这样的事。有时候,优惠码被重复使用了一千次。有时候,定价错误使得一个 SKU 的订购次数等于其他所有产品的订购总数。正如 Paul Lord 所说:“好的营销可以随时杀死你。”

要点:

  • 保持沟通渠道畅通
    • 自黑式攻击发源于组织内部,人们通过制造自己的“快闪族”行为和流量高峰,加重对系统本身的伤害。这时,可以同时帮助和支持这些营销工作并保护系统,但前提是要知道将会发生的事情。确保没有人发送大量带有深层链接的电子邮件,可以将大量的电子邮件分批陆续发送,分散高峰负载。针对首次点击优惠界面的操作,创建一些静态页面作为“登陆区域”。注意防范 URL 中嵌入的会话 ID
  • 保护共享资源
    • 当流量激增时,编程错误、意外的放大效应和共享资源都会产生风险。注意“搏击俱乐部”软件缺陷,此时前端负载的增加,会导致后端处理量呈指数级增长
  • 快速地重新分配实惠的优惠
    • 任何一个认为能限量发布特惠商品的人,都在自找麻烦。根本就没有限量分配这回事。即使限制了一个超划算的特惠商品可以购买的次数,系统仍然会崩溃

在开发环境中,每个应用程序都只在一台机器上运行。在测试环境中,几乎每个应用程序都只安装在一两台机器上。然而,当到了生产环境时,一些应用程序看起来非常小,另一些应用程序则是中型、大型或超大型的。由于开发环境和测试环境的规模很少会与生产环境一致,因此很难在前两个环境中,看到放大效应跳出来“咬人”

同前文所讲,事故发生有必然性,那么我们的思路应该由「事倍功半地求事故不发生」,转变为「允许少量事故发生,但发生时可以依赖系统的稳定性容灾设计降低影响」

因这类变更明确是由人来操作,所以在具体场景下,可以有不同的管控手段,比较常见的有:

  1. 发布系统卡点,设置审批流程,越关键的发布需要越多人来共同 review
  2. 发布流程标准化,完善发布 checklist,操作严格按照 SOP 进行
  3. 常态化宣传质量管理意识,宣讲典型案例,使防范意识深入人心
  4. 上线灰度流程,逐步放量,严格遵守观察时间要求
  5. 业务系统设计中还要注意留有各种止损手段

从大的角度讲,可以总结为两个方面:

  1. 为生产环境而设计
  2. 为交付而设计
为生产环境而设计

在每一层,都有一系列工程手段来保障系统稳定性

举例来讲,常见的保障系统稳定性的手段和方法:

  1. 超时
  2. 快速失败(而非缓慢响应)
  3. 延迟重试
  4. 限流
  5. 熔断
  6. 分区容灾
  7. 稳态
    1. 减少人为操作变更
    2. 避免资源无限增长,做好清理策略
      1. 限制缓存
      2. 滚动式写日志
      3. 清除带有业务逻辑的历史数据
  8. 任其崩溃并恢复(Erlang 的哲学)
  9. 握手机制
  10. 测试
    1. 集成测试
    2. 单元测试
    3. 压力测试
    4. 功能测试
    5. 全链路故障注入,混沌工程
  11. 中间件解耦
  12. 调速器(节流阀)

软件的持续交付,涉及到许多具体工程细节问题

比如

  • 更新机制
    • 计划内停机
    • 不停机更新(如何实现热重启)
  • CI、CD、流水线
  • 部署环境差异,公有云、私有云、私有化等
  • 版本问题如何处理

软件工程之所以被称为“工程”,是因为它不仅仅是编写代码或开发软件的过程,更是一门系统化、规范化的学科,涉及到计划、设计、构建、测试和维护软件系统的各个方面。系统化方法、规范化流程、项目管理、团队协作、工具技术、生命周期等等名词在软件工程中一样非常重要,而如同其他所有工程领域一样,质量和稳定性的保障,源于持续的、坚定的资源投入。

避免事故或降低事故影响,不能仅在事后亡羊补牢。而是从事前、事中、事后全盘考虑。

  • 事前:从项目设计时就需要考虑容灾架构和后期维护
  • 事中:事故出现时需要及时止损。及时止损又依赖高效的故障定位手段(可观测性系统的建设)和停止故障的手段(快速回滚机制等)
  • 事后:完善的复盘,补齐短板,无论是人力上的还是系统功能上的
  • 常态化投入:日常巡检、定期系统性容灾演练、值班机制等