再谈软件质量:关于事故的思考
🧠 头脑风暴
做服务端开发工作好几年,挖了不少坑,也见过其他人挖了不少坑,也被别人挖的坑坑过不少次 🐶
每当事故发生时,大家都像热锅上的蚂蚁,点雷的人十分沮丧 && 紧张;leader 们一边忍着怒火假装云淡风轻,一边调动各种资源来救火,leader 的 leader 们一分钟一催进度;受影响的用户/客户投诉如潮水涌来;当然,还有吃瓜群众看热闹不嫌事大
同样作为工程师,经常会思考其他行业的工程质量是如何保证的,比如航空航天、船舶制造、汽车制造、建筑行业等等。如果互联网行业的故障率发生在火箭发射领域,那么恐怕是完全不敢想象的一件事
“绝不让此事再发生”,经常听到的台词,可现实中,同样的事情必定还会再出现,只是看什么时候、由谁触发罢了
优秀的团队,往往在出事时对犯错的人有足够容忍,更加注重剖析事故根因,以及如何用可靠的手段解决,思考工作流程、系统上是否可以做优化。因为他们知道,在复杂的现实世界中,人,永远都是会出错的动物,是不可控因素。为了达到质量目标,一定要将不可控转变为可控
但,始终存在一个难以令人接受的事实:**系统失效存在必然性。**否认系统失效的必然性,会夺走人们控制和限制它的能力。一旦接受“系统必然会失效”这一事实,就有能力使系统对特定的失效做出相应的反应。正如汽车工程师创造出的碰撞缓冲区——为保护乘客而首先被撞毁的区域——你可以为系统创建一种安全失效模式,这种模式包含被损坏区域,并且为系统其他部分提供保护。这种自我保护决定了整个系统的韧性。Chiles 将这些保护措施称为“裂纹阻断器”。就像通过构建碰撞缓冲区来吸收撞击力并保护乘客安全,可以先确定系统的哪些特性是必不可少的,然后内建系统失效方式,防止重要特性出现裂纹。如果不设计系统失效方式,那么系统就会出现各种不可预测的问题,一旦出现,这些问题通常都是危险的
对于任何事故,首要任务都是止损和恢复服务,其次才是调查原因!
对于任何事故,首要任务都是止损和恢复服务,其次才是调查原因!
对于任何事故,首要任务都是止损和恢复服务,其次才是调查原因!
在航空事故调查分析中常提到 Reason 模型
Reason 模型的核心点在于其系统观的视野,在对不安全行为的直接分析之外,更深层次地剖析出影响行为人的潜在的团队、场所、组织因素,以一个逻辑统一的事故反应链将所有相关因素进行了理论串联。
案例:某日凌晨 2 点 40 分左右,一架 A330 飞机在从虹桥国际机场 15 号桥位拖往跑道上进行发动机试车的过程中,飞机在 K4 道口的滑行道上偏离滑行线,导致飞机 5、6、7、8 号主轮进入滑行道边草地之中……。构成一起人为原因的严重不安全事件。
如果按照结果导向、事后查处的思维,事件的直接原因是指挥员和拖车司机的失误造成,假如指挥员正确指挥、驾驶员认真操作,就不会发生。
但应用 Reason 模型进一步分析,就会发现:那天因为整修跑道,原计划滑行的滑行道入口关闭,改由 K4 道口进入;因机场已关闭,滑行道边灯关闭。由此可见,行进线路的变化、夜间灯光不足等环境因素也是构成事件发生的工作场所条件。
同时,驾驶员、指挥员对滑行路线状况不熟、指挥交流方式缺陷等也是造成事故的隐患条件。再进一步分析,可以发现,在拖行飞机的相关程序、人员资质管理、制度程序落实方面存在着不足,而这正是组织系统的缺陷。这就不难发现,如果我们仅把着眼点放在最后的结果—操作人员身上,那么相类似的事件还会发生。
James Reason 教授将安全管理比喻为“打一场没有最终胜利的游击战”,是一场为了发现事故隐患、消除或控制其隐患的永不停息的奋争。为了使系统更加安全,我们可做的事情是无止境的。
在安全管理体系中有效的运用 Reason 模型可以帮助我们去发现系统的安全隐患,减少不安全事件发生的可能,避免不安全事件的重复发生。
进而,通过风险管理可以帮助我们发现系统中的风险所在,并确定相关的优先等级,进而采取措施消除它们或减缓其后果,真正实现安全关口前移,确保持续的安全、可靠的安全。
在每次系统事故的背后,都有一条由一个个事件构成的失效链。一个小问题会导致另一个问题,后者再导致下一个问题。若事后查看整个系统失效链,会发现系统失效似乎不可避免。如果试图估算失效链上所有事件都会发生的概率,会发现概率极低,但这仅限于将每个事件都视作独立事件。硬币没有记忆力,所以每次投掷它时,出现正反两面的概率都相同,并与以前的投掷无关。然而,导致失效的事件并不是相互独立的。一个点或一个层次的系统失效,实际上增加了其他点或其他层次发生系统失效的概率。如果数据库响应变慢,应用程序服务器更有可能耗尽内存。因为这些层次是耦合在一起的,所以这些事件并非彼此独立
技术社区在如何处理失误方面存在分歧
- 一个阵营表示要构建具有容错功能的系统。应该捕捉异常、检查错误代码,并且通常要防止失误演变为错误
- 另一个阵营则表示,以容错为目标是徒劳的。这就像试图制造具有防误操作的设备一样白费功夫,因为总会出现更傻的傻瓜。无论试图发现和消除什么失误,都会发生意想不到的事情。所以,应该任其崩溃并替换,这样就可以从已知的良好状态重新开始
然而,两个阵营对以下两件事的看法是一致的
- 第一,失误总会发生,且永远无法杜绝,必须防止失误转变为错误
- 第二,即使在尽力防止系统出现失效和错误时,也必须决定承担失效或错误的风险是否利大于弊
事故是怎么来的?
摘自:《发布!设计与部署稳定的分布式系统(第 2 版)》
生产环境中出现的每次系统失效都是独一无二的。没有两起事故会完全沿着同一条系统失效链发展:由相同的因素触发,具有相同的损坏情况,以相同的方式蔓延。然而,随着时间的推移,确实能发现一些系统失效模式
从整体来看,事故发生绝大部分都是变更产生(甚至可以说是 100%)。变更又可以大致分为
- 主动变更
- 人为主动变更(配置修改、版本发布等)
- 非主动变更
- 业务流量变化(如明星热点事件)
- 不可预期变更(如机房断电、地震等)
主动变更
在业务发展过程中,产品运营可能经常调整一些后台业务配置,操作有误时可能产生严重的后果。比如营收类业务配置一些奖励下发,配错奖品数量、价值,可能在短时间内造成巨大的资产损失;
在线上版本发布过程中,前端、客户端、服务端均会经常性进行线上发布,当遇到发布有问题的版本,或发布依赖顺序不正确等场景,可能会导致功能不可用或更大的影响
非主动变更
如今一个典型的互联网产品的服务端架构如图
接入层包含就近接入、四层和七层负载均衡、长、短链接网关服务等
逻辑服务层是各业务自己负责的核心部分,现如今基本上都是微服务集群
存储层提供数据的缓存、持久化存储等能力
集成点
不同层可能都有多个服务节点,同层之间也存在互相调用的关系,每个服务节点都有多个调用上游和多个调用下游,将这样的服务节点称为集成点
集成点是系统的头号杀手。每一个传入的连接都存在稳定性风险。每个 socket、进程、管道或 RPC 都会停止响应。即使是对数据库的调用,也可能会以明显而微妙的方式停止响应。系统收到的每一份数据,都可能令系统停止响应、崩溃,甚至产生其他的冲击
要点:
- 每个集成点最终都会以某种方式发生系统失效,所以需要为系统失效做好准备
- 为各种形式的系统失效做好准备
- 系统失效会迅速蔓延
- 若系统代码缺乏一定的防御性,那么远程系统失效会以层叠失效的方式迅速演变为系统问题
- 采用一些模式来避免集成点问题,如
- 熔断
- 超时
- 中间件解耦
同层连累反应
集群水平扩展是常见的模式,不易遭遇单点故障导致系统异常,但如果出现部分节点的缺陷(通常是内存泄漏或负载相关的崩溃),就会导致其他节点需要承担更多工作,产生同层连累反应,严重时造成整层服务可用性下降
要点:
- 记住:一台服务器的停机会波及其他服务器
- 由于一台服务器停机,其他服务器必须负担其工作负载,这样就会发生同层连累反应。增加的负载使得剩余的服务器更易发生系统失效。同层连累反应会迅速让整层系统停机。依赖该层系统的其他层级必须做好防护措施,否则将会陷入层叠失效
- 寻找资源泄漏
- 寻找难以捕捉的时序缺陷
- 采用自动扩容
- 应该为云端的每个自动扩展组创建健康状况检查机制。自动扩展将关闭未通过健康状况检查的服务器实例,并启动新的实例。只要自动扩展机制的响应速度比同层连累反应的蔓延速度快,那么系统服务就依然可用
线程阻塞
多线程技术使应用程序服务器具有足够的容量扩展能力,来满足 Web 上最大站点的需求。但这也引入了产生并发错误的可能性
此外在如今 Go 大行其道的背景下,Goroutine 的管理和调度成本也是值得探究的话题
用户问题
用户有时也会对系统造成影响,一些要点如:
要点:
- 用户会消耗资源
- 用户会做奇怪和随机的事
- 恶意用户总是存在的
- 安全对抗
- 反爬策略
- 法律手段
- 正常用户大量涌入
- QQ 音乐的典型例子:周杰伦发新专辑了…
自黑式攻击
一个很好的例子就是 Xbox 360 游戏机刚开始预售那会儿。很明显,其需求量在美国远远超过了供应量。当一家大型电子产品零售商发出促销预售的电子邮件时,其中就包含了接受预售订单的确切日期和时间。这封电子邮件在同一天出现在 FatWallet、TechBargains 以及其他大型比价网站的页面上。其中还特意地包含了一个绕过 Akamai 的深层链接,使得所有图像、JavaScript 文件和样式表,都能直接从原始服务器中提取。在预售开始前的一分钟,整个网站就像新星一样被点亮,然后就黯淡下来。60 秒内,这个网站就彻底消失了。每个曾经在零售网站工作过的人,都经历过这样的事。有时候,优惠码被重复使用了一千次。有时候,定价错误使得一个 SKU 的订购次数等于其他所有产品的订购总数。正如 Paul Lord 所说:“好的营销可以随时杀死你。”
要点:
- 保持沟通渠道畅通
- 自黑式攻击发源于组织内部,人们通过制造自己的“快闪族”行为和流量高峰,加重对系统本身的伤害。这时,可以同时帮助和支持这些营销工作并保护系统,但前提是要知道将会发生的事情。确保没有人发送大量带有深层链接的电子邮件,可以将大量的电子邮件分批陆续发送,分散高峰负载。针对首次点击优惠界面的操作,创建一些静态页面作为“登陆区域”。注意防范 URL 中嵌入的会话 ID
- 保护共享资源
- 当流量激增时,编程错误、意外的放大效应和共享资源都会产生风险。注意“搏击俱乐部”软件缺陷,此时前端负载的增加,会导致后端处理量呈指数级增长
- 快速地重新分配实惠的优惠
- 任何一个认为能限量发布特惠商品的人,都在自找麻烦。根本就没有限量分配这回事。即使限制了一个超划算的特惠商品可以购买的次数,系统仍然会崩溃
放大效应
在开发环境中,每个应用程序都只在一台机器上运行。在测试环境中,几乎每个应用程序都只安装在一两台机器上。然而,当到了生产环境时,一些应用程序看起来非常小,另一些应用程序则是中型、大型或超大型的。由于开发环境和测试环境的规模很少会与生产环境一致,因此很难在前两个环境中,看到放大效应跳出来“咬人”
如何降低事故影响
同前文所讲,事故发生有必然性,那么我们的思路应该由「事倍功半地求事故不发生」,转变为「允许少量事故发生,但发生时可以依赖系统的稳定性容灾设计降低影响」
主动变更的管控
因这类变更明确是由人来操作,所以在具体场景下,可以有不同的管控手段,比较常见的有:
- 发布系统卡点,设置审批流程,越关键的发布需要越多人来共同 review
- 发布流程标准化,完善发布 checklist,操作严格按照 SOP 进行
- 常态化宣传质量管理意识,宣讲典型案例,使防范意识深入人心
- 上线灰度流程,逐步放量,严格遵守观察时间要求
- 业务系统设计中还要注意留有各种止损手段
非主动变更的管控
从大的角度讲,可以总结为两个方面:
- 为生产环境而设计
- 为交付而设计
为生产环境而设计
在每一层,都有一系列工程手段来保障系统稳定性
举例来讲,常见的保障系统稳定性的手段和方法:
- 超时
- 快速失败(而非缓慢响应)
- 延迟重试
- 限流
- 熔断
- 分区容灾
- 稳态
- 减少人为操作变更
- 避免资源无限增长,做好清理策略
- 限制缓存
- 滚动式写日志
- 清除带有业务逻辑的历史数据
- 任其崩溃并恢复(Erlang 的哲学)
- 握手机制
- 测试
- 集成测试
- 单元测试
- 压力测试
- 功能测试
- 全链路故障注入,混沌工程
- 中间件解耦
- 调速器(节流阀)
为交付而设计
软件的持续交付,涉及到许多具体工程细节问题
比如
- 更新机制
- 计划内停机
- 不停机更新(如何实现热重启)
- CI、CD、流水线
- 部署环境差异,公有云、私有云、私有化等
- 版本问题如何处理
- …
思考
软件工程之所以被称为“工程”,是因为它不仅仅是编写代码或开发软件的过程,更是一门系统化、规范化的学科,涉及到计划、设计、构建、测试和维护软件系统的各个方面。系统化方法、规范化流程、项目管理、团队协作、工具技术、生命周期等等名词在软件工程中一样非常重要,而如同其他所有工程领域一样,质量和稳定性的保障,源于持续的、坚定的资源投入。
避免事故或降低事故影响,不能仅在事后亡羊补牢。而是从事前、事中、事后全盘考虑。
- 事前:从项目设计时就需要考虑容灾架构和后期维护
- 事中:事故出现时需要及时止损。及时止损又依赖高效的故障定位手段(可观测性系统的建设)和停止故障的手段(快速回滚机制等)
- 事后:完善的复盘,补齐短板,无论是人力上的还是系统功能上的
- 常态化投入:日常巡检、定期系统性容灾演练、值班机制等