深度解析塑造未来的技术文章。

防御式工程:抵御灾难的系统设计

了解防止生产事故的防御式工程模式,包括 molly guard、干运行模式、软删除,以及为何确认弹窗形同虚设。

一个红色紧急按钮罩在钢制面板上的透明翻盖塑料防护罩下

一家中型金融科技公司的初级工程师在周五下午运行了一个数据库迁移脚本。这个脚本本来是用来清理预发布(staging)环境里的孤立记录的。结果它却连上了生产数据库,删除了 420 万条客户交易记录。备份还是三天前的。公司接下来 72 小时全员进入事故响应状态,手动从支付处理商的日志里逐条核对、恢复数据。总成本超过 40 万美元,包括工程人力、客户赔偿和监管报告。

这个脚本没有环境检查,没有干运行(dry-run)参数,也没有会显示当前连接的是哪个数据库的确认提示。连接字符串来自一个环境变量,而这个变量在工程师的笔记本上两周前调试时恰好被设成了生产环境。这些失误每一个都是可以避免的。

为什么“你确定吗?”弹窗没用

软件里最常见的安全机制就是确认弹窗,但它也是最没用的。关于确认疲劳的研究表明,用户看过几次“你确定吗?”之后,几乎百分之百都会直接点过去。提示变得隐形,只是你原本就打算做的那件事路上的又一次点击而已。

问题不在于确认本身是个坏主意,而在于通用的确认提示几乎不包含任何信息。“你确定要继续吗?”根本没告诉你即将做什么。对比一下:“你即将从 prod-us-east-1 上的 transactions 表中删除 4,217,893 行数据,请输入数据库名称以确认。”第二种写法会逼你真正读一读眼前发生的事。这就是减速带和 molly guard 的区别。

什么是 Molly Guard,它为什么重要

“molly guard”这个说法来自大型机上的 Big Red Button(大红按钮)。人们在按钮上加一个物理塑料罩,防止误触关机。据说这个名字是为了纪念一位程序员年幼的女儿 Molly,她总爱去按那个按钮。在软件领域,molly guard 指的是任何一种让破坏性操作不容易误操作,但在确实需要时依然能顺利执行的机制。

Linux 软件包 molly-guard 做的正是这件事。它会拦截 SSH 会话中的 shutdown、reboot 和 halt 命令,并要求你输入即将关机的那台机器的主机名。你没法靠惯性直接点过去,必须先证明自己知道当前在哪台机器上。

$ sudo reboot
Good grief! You're on a remote SSH session to prod-web-03.
Please type the hostname to confirm: prod-web-03
# Compare with the useless version:
$ sudo reboot
Are you sure? (y/n): y
# *types y without reading, muscle memory*

原则很简单:确认环节应该要求操作者提供能证明他理解该操作的信息。输入“y”什么也证明不了,输入主机名、表名或受影响的记录数,才说明你真的看过警告。

干运行模式:先看后动

每一个破坏性操作都应该有干运行模式。这里的“应该”不是最佳实践那种软性建议,而是那种你迟早会后悔没有它的“应该”。干运行会完整执行操作的逻辑,记录下本来会发生什么,然后停下来,没有任何副作用,一切都看得清清楚楚。

这种模式实现起来很直接。下面是一个内置干运行的迁移脚本:

#!/bin/bash
set -euo pipefail
DRY_RUN=${DRY_RUN:-true}  # Default to dry-run!
DB_HOST=$(get_db_host)
DB_NAME=$(get_db_name)
echo "Target: $DB_HOST / $DB_NAME"
echo "Mode: $([ "$DRY_RUN" = true ] && echo 'DRY RUN' || echo 'LIVE')"
ROWS=$(psql -h "$DB_HOST" -d "$DB_NAME" -t -c \
"SELECT count(*) FROM orphaned_records WHERE created_at < now() - interval '90 days'")
echo "Records to delete: $ROWS"
if [ "$DRY_RUN" = true ]; then
echo "Dry run complete. Set DRY_RUN=false to execute."
exit 0
fi
# Require explicit confirmation for live runs
read -p "Type '$DB_NAME' to confirm deletion: " CONFIRM
if [ "$CONFIRM" != "$DB_NAME" ]; then
echo "Aborted."
exit 1
fi
psql -h "$DB_HOST" -d "$DB_NAME" -c \
"DELETE FROM orphaned_records WHERE created_at < now() - interval '90 days'"
echo "Deleted $ROWS records."

注意其中三点:默认就是干运行(你必须主动选择执行破坏,而不是主动选择避免);在执行任何操作之前,它会先显示目标数据库和受影响的行数;即使是真正执行的模式,也要求你输入数据库名称。三层防护,任何一层都足以避免我开头讲的那次事故。

数据库迁移的安全网

数据库迁移是任何部署流水线里风险最高的操作之一。它们往往不可逆,直接作用于共享状态,一次糟糕的迁移就能把整个应用拖垮。然而大多数团队还是把它当成普通的部署步骤来对待。

下面是一套从基础到严密的安全机制层级:

  1. 环境检查:迁移脚本在执行前验证自己是否指向预期的环境。听起来很显然,但你会惊讶地发现这一步有多常被遗漏。
  2. 迁移前快照:在任何迁移运行之前自动创建数据库快照。如果迁移失败或出现问题,几分钟而不是几小时就能恢复。
  3. 行数守卫:如果一次迁移会影响超过 N 行,就必须明确确认。一次意外触及数百万行的迁移,几乎总是个 bug。
  4. 语句超时:为迁移查询设置较激进的超时时间。一个运行 45 分钟的迁移会锁住表并拖慢性能,尽早失败才是正确的。
  5. 仅限向后兼容:强制要求每个迁移都与当前已部署的代码向后兼容。这意味着不能重命名列,不能在没有默认值的情况下添加 NOT NULL 约束,也不能删除仍被引用的列。
# Rails example using strong_migrations gem
class AddPhoneToUsers < ActiveRecord::Migration[7.1]
def change
# This will raise an error with a safe alternative:
# add_column :users, :phone, :string, null: false
#
# Instead, do it in two steps:
add_column :users, :phone, :string
# Then in a separate migration after backfill:
# change_column_null :users, :phone, false
end
end

像 Rails 里的 strong_migrations、PostgreSQL 的 squawk,以及 MySQL 的 skeema 这类工具,能在危险的迁移模式进入生产环境之前把它们拦下来。如果你没在用这类工具,那就是在指望代码评审抓出那些隐蔽的迁移问题,而评审者确实经常会漏掉。

软删除与撤销窗口

硬删除是一个在确定性很强的瞬间做出的永久性决定,问题在于那种确定感往往是错的。软删除,也就是把记录标记为已删除而不真正移除,能给你留出一个从失误中恢复的窗口。

-- Hard delete: gone forever
DELETE FROM projects WHERE id = 4872;
-- Soft delete: recoverable
UPDATE projects
SET deleted_at = now(),
deleted_by = 'user:priya'
WHERE id = 4872;
-- Recovery is trivial
UPDATE projects
SET deleted_at = NULL,
deleted_by = NULL
WHERE id = 4872;

这种权衡是真实存在的:软删除会让查询变复杂(到处都得加上 WHERE deleted_at IS NULL),增加存储开销,还可能让人对数据的真实状态产生困惑。但对于任何可能被误删的面向用户的数据,这笔账是值得的。GitHub 并不会在你删除仓库后立刻删除它,而是保留 90 天;Slack 会为了合规保留已删除的消息;Gmail 的垃圾箱则是 30 天后才清空。这些都不是意外,而是经过深思熟虑的工程决策。

同样的原则也适用于基础设施。与其直接终止一台 EC2 实例,不如先把它停掉;与其直接删除数据库,不如把它改名为 mydb_deleted_20260315,再设个日历提醒,两周后再真正删掉。把停止的实例或改名的数据库保留几天,成本几乎可以忽略不计,而从备份恢复的代价则要高得多。

部署安全:金丝雀、熔断与回滚

部署是另一类破坏性操作,很多团队对它的谨慎程度都不够。一次糟糕的部署和一张被删掉的数据库表一样能让生产环境瘫痪,而且它发生得要频繁得多。

一套最低限度可用的部署安全方案应包括:

  • 金丝雀部署:将 1% 到 5% 的流量导向新版本。如果错误率飙升,就在影响范围扩大之前自动回滚。
  • 自动回滚触发器:定义错误率阈值、延迟阈值和健康检查失败条件,并让它们自动触发回滚。别指望凌晨两点有人正好注意到问题。
  • 事故期间冻结部署:只要存在进行中的事故,就禁止所有部署。救火的时候,最不需要的就是有人顺手部署一堆无关的改动。
  • 一键回滚:回滚应该比向前发布更容易。如果你的回滚流程还需要 SSH 登录服务器并手动执行命令,那你其实没有回滚流程。
# Kubernetes progressive delivery with Argo Rollouts
apiVersion: argoproj.io/v1alpha1
kind: Rollout
spec:
strategy:
canary:
steps:
- setWeight: 5
- pause: { duration: 5m }
- analysis:
templates:
- templateName: error-rate-check
- setWeight: 25
- pause: { duration: 5m }
- setWeight: 50
- pause: { duration: 10m }
- setWeight: 100
# Auto-rollback if error rate exceeds 1%
abortScaleDownDelaySeconds: 30

这里的核心思路是逐步承诺。不要一步从 0 跳到 100%,而是小步推进,每一步都验证,并且在每个阶段都保留撤退的能力。这比一次性把所有实例全部更新要慢,但当它第一次在坏版本波及全部用户之前把它拦下来时,你会庆幸多花的每一分钟。

架构层面的预防:让错误操作变得不可能

最好的安全机制不是要求你小心,而是让做错事在结构上变得不可能。这就是护栏和警示牌之间的区别。

  • 不可变基础设施:如果你根本无法 SSH 登录生产服务器,就不可能误在上面执行命令。如果部署总是从已知镜像创建全新实例,配置漂移也就无从发生。
  • 最小权限访问:工程师的笔记本上不该有生产数据库的凭证,没有例外。使用即时(just-in-time)访问工具,发放带审计记录的临时凭证。
  • 按环境隔离凭证:如果预发布和生产使用不同的凭证存储,你就根本无法用预发布的工具意外连上生产环境。
  • 删除保护:AWS 允许你为 EC2 实例开启终止保护,为 RDS 数据库开启删除保护。对于任何重要的资源都应该打开。这只是五秒钟的配置改动,却能把一整类灾难性失误直接杜绝。

如果有人可以用一条命令就毁掉生产环境,那问题不在这个人身上,而在于那个允许一条命令毁掉生产环境的系统。

我见过不少团队在生产事故之后的应对方式,是增加更多文档、更多检查清单、更多培训。这些确实有帮助,但它们都建立在人不会犯错的假设上。人并不完美。更好的做法是改变系统,让这类错误根本无法发生;即便发生了,影响范围也能被控制,恢复也足够快。

打造安全优先的工程文化

工具和架构固然重要,但真正决定这些东西能否落地的是文化。那些把安全机制当成额外负担或官僚流程的团队,在赶工期的时候一定会跳过它们,而工期压力几乎是永久存在的。

我见过最有效的做法,是把安全机制当作一等的工程需求,而不是锦上添花的选项。每一个破坏性操作都要经过设计评审,并且专门问几个问题:如果它跑在错误的目标上会怎样?如果它执行了两次会怎样?如果它用的是过期数据会怎样?我们要怎么撤销它?

无责复盘(blameless post-mortem)如今已经是基本要求。但我认为更重要、却不太常见的做法是预先复盘(pre-mortem)。在推出一个有风险的变更之前,把团队召集起来问一句:“假设这件事彻底搞砸了,那会是怎样的情景?”当你把它framed成想象而不是预测时,人们出奇地擅长找出故障模式。他们找出来的这些失败模式,正是你接下来要去构建的安全机制。

那位删掉生产数据库的初级工程师呢?他至今仍在公司工作,现在已经是团队里最积极推动防御式工程的人之一。那次事故不是他的错,而是系统性的失败。而现在,这套系统已经很难被轻易打破了。