Appearance
线上 bug 回滚记录
“回滚”这个词,很多时候会先让人想到 Git 操作,比如 git revert、git reset。但在生产环境里,回滚远不止代码层面。
线上回滚真正要解决的事:从发现故障到用户恢复正常,这个时间窗口怎么压到最短。 代码回退只是其中一环,而且往往不是最难的一环。CDN 缓存怎么刷新?数据库 schema 变更怎么回退?已经在消息队列里的数据怎么办?灰度中的用户怎么切回旧版本?
这些问题凑在一起,才是回滚的工程体系。
回滚的底层模型
回滚的本质是一个时间窗口管理问题:
text
故障发生时间点(T0)
│
▼
故障被发现(T1) ← 这个间隔决定了故障的影响面
│
▼
决策:回滚 or 向前修复(T2) ← 这个决策的质量决定了恢复速度
│
▼
执行回滚(T3) ← 这个操作的耗时决定了用户受影响的总时长
│
▼
用户恢复正常(T4)
总故障时长 = T4 - T0
可优化空间 = (T1 - T0) + (T2 - T1) + (T3 - T2) + (T4 - T3)
= 发现延迟 + 决策延迟 + 执行延迟 + 生效延迟四个延迟,各有各的优化手段:
- 发现延迟:监控、告警、拨测、用户反馈渠道
- 决策延迟:故障分级规范、回滚决策树、on-call 授权
- 执行延迟:一键回滚、自动化 pipeline、预置回滚脚本
- 生效延迟:CDN 刷新时间、缓存过期时间、DNS 切换时间
大部分团队只关注"执行延迟"(怎么回滚得更快),但实际故障总时长里,发现延迟和生效延迟往往占比更大。你回滚只用了 30 秒,但故障跑了 10 分钟才被发现——优化回滚速度的意义不大。
什么时候回滚,什么时候向前修复
不是所有线上 bug 都该回滚。回滚有代价——上一个版本也未必没问题,而且回滚意味着你放弃了新版本里所有的正向变更(功能上线、bug 修复、性能优化)。
决策框架:
text
线上 bug 被确认
│
├── 影响面评估
│ ├── 核心业务流程不可用(下单/支付/登录)→ 立即回滚
│ ├── 非核心功能异常(某些页面样式错乱)→ 评估向前修复时间
│ └── 仅影响内部用户(管理后台)→ 优先向前修复
│
├── 修复时间估算
│ ├── 根因明确,修复 < 30 min → 向前修复
│ ├── 根因不明确,排查需要时间 → 先回滚,再慢慢排查
│ └── 修复涉及数据库变更 → 评估数据回滚的复杂度
│
└── 时间窗口考虑
├── 业务高峰期(大促/周末晚高峰)→ 回滚优先,保稳定
└── 低峰期(凌晨 3 点)→ 向前修复,避免第二天回滚这里记录一个偏保守但实用的原则:如果在 5 分钟内不能确定根因,先回滚。 排查可以在系统恢复后继续进行,但用户侧的故障窗口需要先压住。
什么时候不该回滚:
- 回滚会丢失数据:如果新版本已经产生了用户数据(订单、支付记录),回滚代码会让这些数据不可见。这种情况应该向前修复,或者在回滚前先做好数据兼容。
- 回滚本身有风险:如果上一个版本也有已知的严重 bug,回滚等于用一个 bug 换另一个 bug。
- 回滚会引发连锁故障:比如回滚操作会触发数据库 migration 的回退,而这个回退操作本身没有经过测试。
分层回滚体系
线上系统不是一个整体,回滚也不能只用一种方式。不同的层有不同的回滚策略和回滚速度。
第一层:配置回滚(最快,< 30s)
配置回滚是成本最低、速度最快的回滚方式。如果 bug 可以通过关闭某个 feature flag、调整某个配置项来缓解,优先走配置回滚。
bash
# feature flag 关闭有问题的功能
# 大多数 feature flag 平台(LaunchDarkly / 自建系统)支持秒级生效
curl -X PATCH "https://feature-flags.internal/api/flags/new-checkout-flow" \
-d '{"enabled": false}'Feature flag 在回滚场景下的价值被严重低估了。一个功能上线时如果带着 feature flag,出问题时关掉就行——不需要回滚代码,不需要重新部署,不需要等 CDN 刷新。能在 10 秒内通过配置关闭的故障,不应该走 10 分钟的代码回滚流程。
但不是所有代码都适合包在 feature flag 里。改数据库 schema、改底层工具函数、改公共组件——这些变更 flag 很难覆盖。feature flag 是回滚的第一道防线,不是唯一的防线。
第二层:代码回滚(标准手段)
前端静态资源回滚:
前端的代码回滚和其他系统不太一样。因为静态资源部署后,回滚的关键不是"重新部署旧代码",而是"让用户加载到旧版本的资源"。
bash
#!/bin/bash
# 前端回滚脚本 —— 核心逻辑:把 HTML 指回旧版本的资源
# 前提:旧版本的构建产物还保留在 OSS/CDN 上
LAST_STABLE=$(ls -t /var/www/releases/ | head -1)
CURRENT=$(readlink /var/www/current)
echo "回滚: $CURRENT → $LAST_STABLE"
# 1. 切换软链接(Nginx 指向旧版本的 HTML)
ln -sfn /var/www/releases/$LAST_STABLE /var/www/current
nginx -s reload
# 2. 如果 HTML 走了 CDN,刷新 CDN 缓存
curl -X POST "https://cdn.aliyuncs.com/purge" \
-d '{"urls": ["https://example.com/index.html"]}'
# 3. 验证
sleep 3
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" https://example.com/)
if [ "$HTTP_CODE" != "200" ]; then
echo "回滚验证失败,HTTP $HTTP_CODE"
# 通知 on-call
exit 1
fi
echo "回滚完成"前端回滚里一个常见陷阱是:旧版本的静态资源(JS/CSS)可能已经从 CDN 上被清掉。 如果部署脚本在上传新版本后立即删除旧资源,回滚链路实际上已经断开。更稳妥的做法是让带 content hash 的静态资源至少保留 7 天,甚至长期保留。
后端服务回滚:
bash
# K8s 回滚 —— 最简洁的回滚方式
kubectl rollout undo deployment/user-service -n production
# 回滚到指定版本
kubectl rollout undo deployment/user-service -n production --to-revision=42
# 查看回滚状态
kubectl rollout status deployment/user-service -n production但这只适用于"只是代码变了,数据库没变"的场景。如果新版本改了数据库 schema,K8s 回滚不会自动回退数据库。所以真正的服务回滚需要同时考虑代码和数据的兼容性。
Git 层面的回滚:
bash
# Revert:最安全的方式,保留历史记录
git revert <bad-commit-sha> --no-edit
git push origin main
# Reset:只适用于还没被其他人拉取的分支
# 如果 commit 已经被 push 且可能被其他人拉取了,绝对不要 reset
git reset --hard <good-commit-sha>git revert vs git reset 的选择:永远优先 revert。reset 重写历史,在多人协作的分支上是灾难——你 reset 之后 force push,同事 pull 的时候冲突一片,修冲突的时间可能比回滚本身还长。
第三层:数据库回滚(最危险)
数据库变更是回滚里最难处理的部分。代码可以秒级回退,但数据一旦写入就很难撤销。
Schema 变更的回滚策略:
text
变更类型 回滚策略 风险等级
─────────────────────────────────────────────────────────────
新增列(允许 NULL) 直接删列 低
新增列(有 DEFAULT) 删列,无数据损失 低
新增列(NOT NULL) 需要回填数据,需评估 中
删列 需要从备份恢复列数据 高
改列类型 需要数据转换,可能丢失精度 高
新增表 直接删表 低
删表 需要从备份恢复整表 极高一条铁律:涉及删列、改列类型、删表的变更,必须做"兼容性回滚"设计——不是"可以",是"必须"。见过太多次"我们觉得没问题"然后出了问题回滚不了的 case。 具体做法:
sql
-- ❌ 直接删列 —— 回滚时无法恢复数据
ALTER TABLE orders DROP COLUMN legacy_status;
-- ✅ 兼容性删列 —— 先标记废弃,下个版本再真删
-- 版本 N:标记废弃,代码不再读写该列
-- ALTER TABLE orders ADD COLUMN new_status VARCHAR(32);
-- 版本 N+1:确认无问题后删除(此时已保留了 N 版本的回滚能力)
ALTER TABLE orders DROP COLUMN legacy_status;数据变更的回滚策略:
数据变更(UPDATE/DELETE)的回滚比 schema 变更更难。核心思路:在变更前先备份受影响的数据。
sql
-- 在执行数据变更前,创建回滚用的备份表
CREATE TABLE orders_rollback_20260604 AS
SELECT * FROM orders WHERE status = 'pending' AND created_at < '2026-01-01';
-- 执行变更
UPDATE orders SET status = 'archived'
WHERE status = 'pending' AND created_at < '2026-01-01';
-- 如果需要回滚
INSERT INTO orders SELECT * FROM orders_rollback_20260604;
DROP TABLE orders_rollback_20260604;这种"先备份再操作"的模式在 DBA 圈子里是基本操作,但在应用开发者写的 migration 里经常被忽略。如果一个 migration 会影响超过 1000 行数据,创建备份表的时间成本远小于"回滚不了"的代价。
第四层:基础设施回滚
Docker 镜像、K8s 配置、Nginx 配置、DNS 记录——这些基础设施的变更有自己的回滚路径。
bash
# Docker 回滚
docker service rollback my-service
# Helm 回滚
helm rollback my-release 3 -n production
# Terraform 回滚(通过 state 回退)
terraform state pull > backup.tfstate # 操作前先备份 state
terraform apply -var="image_tag=v1.2.3" # 回退到旧版本的配置基础设施回滚的一个关键原则:基础设施的变更也要走版本管理。 Nginx 配置、K8s yaml、Terraform 代码——全部放在 Git 里。出问题时,git log 就是你的回滚历史。手动改服务器配置然后忘了改了什么,这种情况在生产环境里比代码 bug 更让人头疼。
回滚的前置条件:监控与发现
回滚体系再好,发现不了故障等于零。监控不是回滚的一部分,但是回滚的前置条件。
故障发现的三个层级
text
L1: 主动发现(你比别人先知道)
├── 拨测(合成监控):每 1-5 分钟模拟用户访问核心页面/API
├── 指标告警:错误率 > 阈值、P95 延迟 > 阈值、QPS 异常下降
└── 日志告警:ERROR 级别日志突增
L2: 被动发现(别人告诉你)
├── 用户反馈:客服系统、社交媒体、应用商店评论
└── 内部反馈:运营/产品/测试发现异常
L3: 后知后觉(看报表才知道)
└── 日报/周报数据异常 —— 这时候故障可能已经持续了几天L1 基本上应该成为默认能力。如果故障大多仍然靠 L2(用户反馈)发现,说明监控体系里还有明显盲区。一个直接的判断方式是回看过去 3 个月的 P0 故障来源:如果用户反馈占多数,回滚再快也只是补后手,故障窗口实际上是从“用户发现”开始计算的。
部署后的自动验证
回滚之前需要确认"线上真的出问题了",而不是"监控误报"或者"只有你一个人网络不好"。部署后的自动验证链路:
bash
#!/bin/bash
# post-deploy-health-check.sh
# 部署后 30s → 1min → 3min → 5min 各跑一次
check_health() {
local errors=0
# 1. 核心页面 HTTP 200
for url in "https://example.com/" "https://example.com/api/health"; do
http_code=$(curl -s -o /dev/null -w "%{http_code}" "$url" --max-time 10)
if [ "$http_code" != "200" ]; then
echo "FAIL: $url → $http_code"
((errors++))
fi
done
# 2. 错误率检查(从 Sentry / 自建埋点 API 拉取)
error_rate=$(curl -s "https://sentry.internal/api/error-rate?minutes=5" | jq '.rate')
if (( $(echo "$error_rate > 0.02" | bc -l) )); then
echo "FAIL: 错误率 $error_rate > 2% 阈值"
((errors++))
fi
# 3. 首屏时间检查(从 RUM 数据拉取)
p95_lcp=$(curl -s "https://rum.internal/api/lcp?percentile=95&minutes=5" | jq '.value')
if (( $(echo "$p95_lcp > 5000" | bc -l) )); then
echo "FAIL: P95 LCP ${p95_lcp}ms > 5s 阈值"
((errors++))
fi
return $errors
}
check_health
if [ $? -ne 0 ]; then
echo "健康检查未通过,触发回滚"
/usr/local/bin/rollback.sh
fi这里的重点不是脚本本身,而是 自动验证 + 自动回滚的闭环。如果每次都要等人看监控、判断、执行回滚,故障窗口通常会被额外拉长 5-10 分钟。
要不要自动回滚
自动化回滚的决策需要权衡:
- 自动回滚适用:错误率突然飙升(从 0.1% → 5%)、核心页面 5xx、API 可用率跌破阈值。这些信号足够明确,不需要人判断。
- 不适合自动回滚:首屏时间小幅上升(2s → 3s)、非核心功能异常(某个弹窗不显示了)、只影响小部分用户的边缘 case。这些场景需要人判断——可能不是代码问题,是 CDN 节点故障或者某个第三方服务挂了。
比较稳妥的做法是:自动回滚只覆盖 P0 级别信号(核心业务不可用的明确证据),其他情况仍然走“告警 → 人判断 → 手动回滚”流程。 例如把自动回滚条件约束为错误率上升 >10x 且持续 >3 分钟,或核心页面可用率 < 90%。
回滚工具链
GitHub Actions 回滚 Workflow
把回滚做成一个可以手动触发的 workflow,而不是 ssh 到服务器上敲命令:
yaml
name: Rollback
on:
workflow_dispatch:
inputs:
environment:
description: '目标环境'
required: true
type: choice
options:
- staging
- production
version:
description: '回滚到哪个版本(留空则回滚到上一个版本)'
required: false
type: string
jobs:
rollback:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
steps:
- name: 确认回滚
run: |
echo "⚠️ 即将回滚 ${{ inputs.environment }} 环境"
echo "目标版本: ${{ inputs.version || '上一个版本' }}"
echo "触发人: ${{ github.actor }}"
echo "时间: $(date -u)"
- name: 执行回滚
run: |
if [ -n "${{ inputs.version }}" ]; then
ssh deploy@server "cd /var/www && ./rollback.sh ${{ inputs.version }}"
else
ssh deploy@server "cd /var/www && ./rollback.sh"
fi
- name: 健康检查
run: |
sleep 10
curl -f https://example.com/api/health || exit 1
- name: 通知
run: |
curl -X POST "${{ secrets.WEBHOOK_URL }}" \
-H "Content-Type: application/json" \
-d '{
"text": "🔄 回滚完成: ${{ inputs.environment }} → ${{ inputs.version || 'prev' }}\n触发人: ${{ github.actor }}"
}'workflow_dispatch 手动触发+ environment 保护机制:production 环境的回滚可以要求特定人员 approve。这意味着回滚不是你一个人的决策——workflow 里加了审批环节,防止冲动回滚。
回滚 CLI 工具
在团队内部维护一个回滚 CLI,把回滚操作的复杂度封装起来:
bash
#!/bin/bash
# rollback —— 团队回滚工具
ROLLBACK_DIR="/var/www/releases"
CURRENT=$(readlink /var/www/current)
case "$1" in
list)
echo "当前版本: $CURRENT"
echo "可用回滚版本:"
ls -1t $ROLLBACK_DIR | head -10 | while read v; do
if [ "$ROLLBACK_DIR/$v" = "$CURRENT" ]; then
echo " * $v (当前)"
else
echo " $v"
fi
done
;;
rollback)
TARGET="${2:-$(ls -1t $ROLLBACK_DIR | head -2 | tail -1)}"
echo "回滚: $CURRENT → $ROLLBACK_DIR/$TARGET"
echo -n "确认? [y/N] "
read -r confirm
if [ "$confirm" != "y" ]; then
echo "取消"
exit 0
fi
ln -sfn "$ROLLBACK_DIR/$TARGET" /var/www/current
nginx -s reload
echo "回滚完成。新版本: $(readlink /var/www/current)"
# 记录回滚日志
echo "$(date -Iseconds) rollback $1 → $TARGET by $(whoami)" >> /var/log/rollback.log
;;
diff)
TARGET="${2:-$(ls -1t $ROLLBACK_DIR | head -2 | tail -1)}"
diff -r "$CURRENT" "$ROLLBACK_DIR/$TARGET" --exclude='assets' | head -50
;;
*)
echo "用法: rollback {list|rollback [version]|diff [version]}"
;;
esac这个工具的价值不只是"执行回滚",而是提供了回滚前必需的几个操作:list(看有哪些版本可以回滚)、diff(看回滚会影响什么)、确认(防止误操作)。 这些步骤单独做很容易被忽略,打包进工具里就成了强制流程。
回滚演练
大部分团队的回滚流程第一次被用到就是 P0 故障的紧急情况。相当于"灭火演练只在着火时进行"。
演练频率和内容
text
回滚演练计划
├── 月度:核心服务的回滚(选一个低峰时段,真实执行回滚操作)
├── 季度:全链路回滚(前端 + 后端 + 数据库,模拟真实故障场景)
└── 每次大版本上线前:回滚计划预演(确认回滚路径和责任人)演练要验证的不是"回滚脚本能不能跑",而是:
- 回滚版本是否真实可用:上次部署保留的旧版本,现在还能不能正常启动、正常服务?依赖的外部服务版本是不是还兼容?
- 回滚时间是否符合预期:之前预估 30 秒回滚,实际跑下来是 30 秒还是 3 分钟?
- 回滚后数据是否一致:回滚到旧代码后,旧代码能不能正确读取新代码写入的数据?
- 回滚通知链路是否畅通:回滚后相关人员是否在规定时间内收到了通知?
演练中发现过的真实问题
这些不是我编出来的,是实际演练中暴露的:
- 某次演练发现:保留在服务器上的"旧版本"因为依赖了一个已下线的 API 版本(v1 被下线了但旧代码调的还是 v1),启动就 crash。回滚版本以前能跑不代表现在还能跑——外部依赖也在变。
- 某次演练发现:CDN 刷新 HTML 缓存的实际生效时间是 8 分钟,不是文档里写的 3-5 分钟。后来发现 CDN 供应商的刷新队列在高峰期会拥堵——他们的 SLA 是"平均生效时间",不是"最慢生效时间"。回滚预案里的时间估算必须用 P99,不能用平均值。
- 某次演练发现:数据库 migration 回滚脚本上次能跑,但因为中间有人手动在 production 库上改过表结构(加了个索引),回滚脚本执行时 DROP COLUMN 报"依赖对象存在"的错误。
这些事如果不是演练提前暴露,真的故障时碰上,回滚就失败了。回滚失败 + 线上故障同时发生——这是运维最怕的组合。
回滚的反模式
1. 用新的 commit 来"修复"回滚
text
❌ git revert → git revert(revert 了 revert,commit 历史变成一团乱麻)
✅ git revert → 确认系统稳定 → 在新的 commit 里正确修复 → 正常部署revert 了 revert 之后,Git 历史变得难以追踪——哪个 commit 是真正的代码,哪个是回滚操作?正确做法:revert 之后,把被 revert 的 commit 当成"还没合入的代码",在新的分支上修复后重新提 PR。
2. 回滚后不做复盘
回滚成功不等于问题解决。根因还在代码里,只是暂时被"隐藏"了。回滚后 24 小时内必须启动复盘——不是因为流程要求,是因为几天后你已经记不清当时的决策细节了。复盘的产物不是"责任人追责",而是一个明确的 action item:什么时候、以什么方式把修复后的代码重新上线。
3. 回滚脚本写完了从来没跑过
回滚脚本在写的时候环境是好的、依赖是完整的、网络是畅通的。3 个月后真的需要回滚时,这些前提条件可能都不成立了。回滚脚本必须定期执行——哪怕只是在 staging 环境跑一遍确认没挂。
4. 回滚是唯一选择的心态
不是所有问题都需要回滚。以下情况优先考虑向前修复:
- 影响范围小(< 1% 用户)
- 修复方案明确且风险低(改一行配置 vs 回滚整个版本)
- 回滚本身有数据丢失风险
"回滚"和"向前修复"是两个并行的工具,不是"回滚优先"或"修复优先"。选择哪一个的标准只有一个:哪种方式能让用户更快恢复正常。
5. 部署之后立即清除了旧版本
CDN 上删了旧版本的 JS/CSS、服务器上删了旧版本的构建产物、Docker registry 里覆盖了旧镜像——这些都是为了省存储空间。但存储成本远低于故障成本。保留策略:最近 5 个版本的构建产物 + 最近 30 天的数据库备份快照。这是回滚能力的物理基础。
几个 Principal 级别的判断
1. 回滚能力不是附加功能,是系统设计的一部分。
大部分系统的部署流程是正向设计的(push → build → deploy),回滚是后来加上去的。这导致回滚和部署流程脱节——部署自动化了,回滚还要手动 SSH。正确的做法是:部署和回滚在设计阶段就是一对操作,共享同一套工具和流程。 能做 deploy,就必须能做 rollback。Deploy pipeline 里的每一步,都应该有对应的 undo 操作。
2. Feature flag 是回滚的最高形式。
能用 feature flag 关闭的代码,回滚成本会显著下降。feature flag 不只是 A/B 测试工具,它本质上也是生产环境里的“紧急刹车”。涉及核心业务流程的变更,通常都值得在架构评审阶段补一条检查:这个变更能不能通过 feature flag 独立关闭?
3. 回滚的瓶颈往往不在技术,在决策链。
"我看到告警了,但不确定要不要回滚,先拉个群讨论一下"——这个"讨论"可能持续 10 分钟,比实际回滚操作的 30 秒长 20 倍。所以回滚流程里最需要优化的是决策速度:on-call 人员必须有独自决定回滚的权限。宁可不经讨论就回滚了一个可以被向前修复的问题,也不能因为等讨论而让故障多跑了 10 分钟。
4. 数据层的回滚是唯一需要敬畏的部分。
代码回滚错了可以再回滚,配置改错了可以再改。但数据写坏了——删了表、改了字段类型导致精度丢失、UPDATE 没加 WHERE 条件——这些是不可逆或极难逆向的。数据库 migration 的回滚应该像拆炸弹一样谨慎:备份先行、先在 staging 环境完整跑一遍(包括回滚的 SQL)、确认备份数据可以正确恢复。
5. 回滚之后复盘的质量,决定了同类故障会不会再次发生。
复盘不是为了找责任人,是为了找系统性缺陷。每次回滚后至少回答三个问题:(1) 为什么这个 bug 通过了测试进入了生产环境?(2) 从 bug 上线到被发现,中间过了多久,监控为什么没更早发现?(3) 回滚操作本身有没有可以改进的地方(速度、自动化程度、副作用)?
6. 回滚目标 = 用户侧尽量无感知。
回滚的最高境界不是"我们 5 分钟就回滚了",而是"用户根本没注意到出过问题"。这需要:故障发现快(< 1 min)、决策快(< 1 min)、回滚执行快(< 30s)、CDN/缓存生效快(< 1 min)。总和 < 3.5 分钟的故障窗口内,大部分用户甚至不会刷新页面——故障对他们来说不存在。
这基本就是回滚工程化想达到的状态。
回滚检查清单
text
部署前:
□ 旧版本的构建产物 / Docker 镜像已保留
□ 回滚脚本已更新(如果有新依赖或新配置)
□ 数据库 migration 有对应的回滚 SQL
□ feature flag 可以独立关闭本次变更的功能
□ on-call 排班已确认,回滚通知渠道畅通
部署后:
□ 部署后 5 分钟内持续观察错误率、首屏时间、API 成功率
□ 确认回滚版本列表中新版本已被记录
□ 确认回滚脚本可用(至少执行了 --dry-run)
故障发生时:
□ 第一时间确认影响面(哪些用户、哪些功能、多严重)
□ 5 分钟内判断:回滚 or 向前修复
□ 执行回滚前确认:数据是否已备份、回滚版本是否可用
□ 回滚后验证:核心页面 200、错误率恢复正常
□ 回滚后通知:相关方 + 附带故障简述和预计修复时间
故障恢复后:
□ 24 小时内启动复盘
□ 复盘的 action item 有明确的责任人和完成时间
□ 修复后的代码重新上线前,在 staging 环境验证了回滚场景