概述
99.5% 的可用性听起来不错——直到你算一笔账。
一年 8760 小时,99.5% 意味着允许停机 43.8 小时。换算到每个月,差不多 3.65 小时的服务中断。如果是核心交易系统,这 3.65 小时可能就是几十万的订单损失。如果赶在大促期间,损失会翻几十倍。
99.9% 呢?年停机 8.77 小时,月均不到 44 分钟。停机时间砍掉 80%。
0.4 个百分点的提升,背后不是加几台服务器那么简单。我在某出行平台推动过一次这样的改造,从立项到稳定运行在 99.9% 以上,花了整整 14 个月。这期间踩的坑、做的权衡、推翻重来的方案,比写代码本身多得多。
这篇文章不讲理论框架,只拆解我们做过的 6 个关键工程决策,以及 3 个让我后背发凉的踩坑时刻。每个决策都附上我们当时的选型对比和最终方案,你拿去就能对照自己的系统查漏补缺。
可用性的数学本质:为什么 0.4% 这么难
先搞清楚一件事:可用性不是加法,是乘法。
假设你的系统由 3 个服务串联组成,每个服务可用性 99.5%。整个链路的可用性是多少?
0.995 × 0.995 × 0.995 = 0.9851 ≈ 98.51%
三个"还不错"的服务串联起来,整体可用性直接跌破 99%。年停机从单服务的 43.8 小时暴涨到 128 小时——超过 5 天。
反过来想:如果链路有 10 个服务,每个要达到整体 99.9% 的可用性,单个服务的可用性得是多少?
0.999^(1/10) ≈ 0.99990 → 99.99%
十个服务每个都得做到四个九。这就是为什么微服务架构下可用性提升的难度呈指数级增长。
关键认知:提升可用性不是给单个服务加机器,而是同时做三件事——减少串联依赖数量、提高单个服务可用性、在关键路径上做冗余和降级。
Google SRE 团队在 The Calculus of Service Availability 中提到一个观点:大多数服务的内部目标应该定在 99.99%,而不是对外承诺的 99.9% 或 99.95%。因为用户感知到的不可用,远早于 SLA 违约的那一刻。你觉得系统"还没挂",用户已经在刷新页面骂街了。
| 可用性等级 | 年停机 | 月停机 | 用户体验感知 |
|---|---|---|---|
| 99% | 3.65 天 | 7.3 小时 | 明显卡顿,客服炸了 |
| 99.5% | 43.8 小时 | 3.65 小时 | 高峰期有明显感知 |
| 99.9% | 8.77 小时 | 43.8 分钟 | 偶尔短暂卡顿 |
| 99.99% | 52.6 分钟 | 4.38 分钟 | 几乎无感知 |
| 99.999% | 5.26 分钟 | 26 秒 | 完全无感知 |
从 99.5% 到 99.9%,砍掉的是 35 小时的年停机。从 99.9% 到 99.99%,只砍 8 小时。但后者的工程成本是前者的 5-10 倍。边际收益递减非常明显。
我的建议:先把 99.9% 做扎实,再考虑往上走。很多团队连 99.9% 都没稳住,就喊着要追四个九,最后四五个九的口号喊了一年,实际还在 99.5% 附近挣扎。
决策一:重新定义 SLI——从"服务器活着"到"用户成功"
我们犯过的错
改造初期,我们的可用性监控长这样:
# 旧版 SLI:检查服务端口存活
- alert: ServiceDown
expr: up{job="order-service"} == 0
for: 1m
这个告警的含义是:只要 order-service 的 HTTP 端口能响应,就算"可用"。
但它不管:端口活着但所有请求返回 500 算不算可用?P99 延迟从 50ms 飙到 5 秒算不算可用?下单接口成功但支付回调全部超时算不算可用?
我们的 SLI 定义有根本性问题——它度量的是"服务器活着",而不是"用户成功"。
重新定义 SLI
改造后,我们按用户旅程定义 SLI。以"用户下单"这条核心链路为例:
# 用户旅程:下单 → 支付 → 订单确认
# SLI 定义:用户视角的成功率
# SLI-1: 下单请求成功率(排除用户主动取消)
sli_order_create_success:
expr: |
sum(rate(http_requests_total{
job="order-service",
code!~"5..",
path="/api/v1/orders",
method="POST"
}[5m]))
/
sum(rate(http_requests_total{
job="order-service",
path="/api/v1/orders",
method="POST"
}[5m]))
# SLI-2: 下单请求延迟 P99 < 500ms
sli_order_create_latency_p99:
expr: |
histogram_quantile(0.99,
sum(rate(http_request_duration_seconds_bucket{
job="order-service",
path="/api/v1/orders",
method="POST"
}[5m])) by (le))
# SLI-3: 支付回调成功率
sli_payment_callback_success:
expr: |
sum(rate(payment_callback_total{status="success"}[5m]))
/
sum(rate(payment_callback_total[5m]))
三个 SLI 覆盖了用户从下单到支付确认的完整旅程。如果三个都达标,用户大概率体验正常;如果任何一个亮红灯,就算服务器端口"活着",我们也认为可用性受损。
SLO 设定
SLI 定义好后,SLO 怎么定?我们的做法是分两级:
# SLO 配置:基于 SLI 的目标值
slo_targets:
order_create_success:
target: 0.999 # 月度成功率 ≥ 99.9%
window: 28d # 28 天滚动窗口
burn_rate_alerts:
- threshold: 14.4 # 1小时窗口,2%预算消耗
window: 1h
- threshold: 6.0 # 6小时窗口
window: 6h
order_create_latency_p99:
target: 0.999 # 99.9% 的请求 P99 < 500ms
threshold: 0.5 # 500ms
window: 28d
payment_callback_success:
target: 0.9995 # 支付回调更高要求 99.95%
window: 28d
注意支付回调的 SLO 定得比下单更高(99.95% vs 99.9%)。因为支付失败对用户的伤害远大于下单慢——用户可以等 2 秒下单,但不能接受扣了钱没收到回调。
关于 SLI/SLO 体系更系统的设计方法,可以参考 相关文章:SRE 核心理念:SLI、SLO 与错误预算。
踩坑点:SLI 的分母选择
这个坑我们踩过。最初下单成功率的分母是"所有 POST /api/v1/orders 请求",包括用户主动取消的请求。结果某次营销活动期间,大量用户下单后又取消,分母暴增,成功率"看起来"下降了,告警触发,值班团队全员起来排查——结果什么问题都没有。
修复方法:在 SLI 定义中排除用户主动行为:
# 排除用户主动取消的请求(HTTP 499)
sli_order_create_success_fixed:
expr: |
sum(rate(http_requests_total{
job="order-service",
code!~"5..|499", # 排除 5xx 和 499(客户端主动断开)
path="/api/v1/orders",
method="POST"
}[5m]))
/
sum(rate(http_requests_total{
job="order-service",
code!~"499", # 分母也排除 499
path="/api/v1/orders",
method="POST"
}[5m]))
经验:SLI 的分母和分子必须只包含"系统可控"的请求行为。用户主动取消、非法参数、权限不足这些都不应该算作系统不可用。
决策二:故障域隔离——把爆炸半径砍到最小
为什么要做故障域隔离
可用性提升的本质是减少故障影响范围。一个服务挂了,是影响 10% 的用户还是 100% 的用户?这就是爆炸半径。
Google SRE 有个原则:单点故障不应该影响超过 10% 的用户流量。我们改造前的系统,订单服务是个大单体,挂一次影响全部用户——爆炸半径 100%。
隔离策略对比
我们评估了三种隔离方案:
| 方案 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 服务拆分 | 大单体拆成微服务 | 故障自然隔离 | 拆分成本高,引入网络延迟 | 单体超过 50 万行代码 |
| 租户隔离 | 按租户/用户分片 | 爆炸半径可控 | 分片逻辑复杂,跨片查询难 | 多租户 SaaS |
| 单元化部署 | 按地域/机房部署独立单元 | 爆炸半径最小,延迟最优 | 建设成本最高,需流量调度 | 大规模全球化服务 |
我们选的是租户隔离 + 部分单元化的混合方案。核心思路:按用户 ID 取模分片到 10 个独立实例组,每个实例组有完整的数据库分片。
// 用户路由:根据 userID 分片到不同实例组
func GetShardGroup(userID int64) int {
return int(userID % 10)
}
// 健康检查:自动剔除不健康的分片
func GetHealthyShardGroups() []int {
var healthy []int
for i := 0; i < 10; i++ {
if shardHealth[i].IsHealthy() {
healthy = append(healthy, i)
}
}
// 如果超过 3 个分片不健康,触发降级
if len(healthy) < 7 {
triggerDegradation()
}
return healthy
}
这个方案的效果:单个分片故障最多影响 10% 用户。即使 3 个分片同时挂了,也只影响 30%——比单体时代的 100% 好太多。
故障域隔离的代价
隔离不是免费的。最大的代价是跨片一致性。
改造前,查询用户订单列表只需要一条 SQL:
SELECT * FROM orders WHERE user_id = ? ORDER BY created_at DESC LIMIT 20;
改造后,用户的数据可能分布在 10 个分片上。如果用户在多个分片都有订单(迁移场景),查询就变成跨片聚合:
// 跨分片查询
func QueryUserOrders(userID int64) ([]Order, error) {
shardGroup := GetShardGroup(userID)
// 先查主分片
primaryOrders, err := shardDB[shardGroup].QueryOrders(userID)
if err != nil {
return nil, err
}
// 如果用户有迁移历史,可能需要查多个分片
if userHasMigrationHistory(userID) {
for _, shard := range getMigrationShards(userID) {
orders, err := shardDB[shard].QueryOrders(userID)
if err != nil {
// 降级:部分分片查不到不报错,返回已有数据
log.Printf("shard %d query failed: %v, degrading", shard, err)
continue
}
primaryOrders = append(primaryOrders, orders...)
}
}
// 排序并截断
sort.Slice(primaryOrders, func(i, j int) bool {
return primaryOrders[i].CreatedAt.After(primaryOrders[j].CreatedAt)
})
return primaryOrders, nil
}
我的建议:如果你的系统日活不到百万级,不要急着做分片隔离。先用服务拆分把爆炸半径从 100% 降到 30-50%,投入产出比更高。分片隔离的复杂度会在运维侧形成持续负担。
关于跨机房容灾的更完整方案设计,可以参考 相关文章:跨机房 RPO<5min、RTO<30min 的架构决策与踩坑实录。
决策三:变更管理改造——用错误预算替代人工审批
人工审批为什么不靠谱
改造前,我们的变更流程是:开发提交 → 技术 Leader 审批 → SRE 审批 → 运维执行。平均审批周期 2-3 天。
这套流程的逻辑假设是:人审能发现问题。
但实际数据打脸了。我们统计了一个季度的变更故障:
- 人工审批拦截的变更:0 个(一个都没拦住)
- 人工审批通过但出事的变更:11 个
- 人工审批耽误的紧急修复:6 个(因为审批太慢,修复延迟导致故障扩大)
人工审批的问题在于:审批人看的是"流程是否完整"(有没有测试报告、有没有评审纪要),而不是"变更是否安全"。他们不具备判断技术风险的信息——代码 diff 他们看不懂,影响范围他们评估不了。
用错误预算驱动变更决策
我们改用错误预算门禁替代人工审批。核心逻辑:
# 错误预算驱动的变更策略
change_gate:
# 当前错误预算消耗率
current_burn_rate: 2.3 # 2.3 倍燃烧速率
rules:
# 预算充足(消耗 < 50%):自由发布
- condition: "burn_rate < 0.5 AND budget_remaining > 50%"
action: "allow"
description: "错误预算充足,正常发布"
# 预算紧张(消耗 50%-80%):需要 SRE 签字
- condition: "burn_rate > 0.8 OR budget_remaining < 50%"
action: "require_sre_approval"
description: "错误预算紧张,需 SRE 确认风险"
# 预社耗尽(消耗 > 80%):冻结非紧急变更
- condition: "burn_rate > 1.0 OR budget_remaining < 20%"
action: "freeze"
description: "错误预算即将耗尽,只允许紧急修复变更"
exceptions:
- "P0 故障修复"
- "安全漏洞修复"
这套机制的好处是数据驱动,不靠人拍脑袋。错误预算还有的时候,你随便发——系统扛得住。错误预算快没了,就自觉收手——系统已经快到极限了。
关于错误预算的消耗策略和行动纲领,更详细的框架设计可以参考 相关文章:错误预算的消耗策略与行动纲领。我们之前还专门讨论过用错误预算门禁替代人工 CAB 的完整方案,参见 相关文章:用错误预算门禁替代人工 CAB,变更类故障减少 60%。
金丝雀发布自动化
变更门禁放行后,不是直接全量发布,而是走金丝雀:
# 金丝雀发布策略
canary:
stages:
- name: "canary-5%"
weight: 5
duration: 10m
success_criteria:
error_rate: "< 0.1%"
p99_latency: "< 500ms"
rollback_on_failure: true
- name: "canary-20%"
weight: 20
duration: 15m
success_criteria:
error_rate: "< 0.1%"
p99_latency: "< 500ms"
depends_on: "canary-5%"
- name: "canary-50%"
weight: 50
duration: 20m
success_criteria:
error_rate: "< 0.1%"
p99_latency: "< 500ms"
depends_on: "canary-20%"
- name: "full-rollout"
weight: 100
duration: 0
depends_on: "canary-50%"
每个阶段自动检查错误率和延迟。任何一项不达标,自动回滚,不需要人工介入。这套机制把"发布出事"的概率从人工时代的每次发布 3-5% 降到了 0.5% 以下。
决策四:监控告警降噪——从 300 条/分钟到精准信号
告警风暴的真实代价
改造前,我们的告警系统在高峰期能产出 300 条/分钟。值班手机每 10 秒震动一次,大部分是"CPU 使用率超 80%“这类信息告警。
告警风暴的直接后果:告警疲劳。值班工程师从"每条告警都认真看"变成"等告警攒够一波再扫一眼”。真正重要的告警淹没在噪音里,MTTD(平均检测时间)居高不下。
告警降噪的目标不是"减少告警数量",而是提高信噪比。从 300 条/分钟里的 5 条有效告警,变成 8 条/小时里的 7 条有效告警。
三层降噪策略
# 第一层:告警分级
alert_levels:
P0:
description: "核心服务不可用"
notify: ["phone", "sms", "slack"]
page: true
examples:
- "订单服务 5xx 错误率 > 1%"
- "支付网关全部实例不可用"
P1:
description: "服务降级但可用"
notify: ["slack"]
page: false
examples:
- "P99 延迟 > 1s 但 < 3s"
- "单实例不可用但有冗余"
P2:
description: "需关注但不紧急"
notify: ["daily_report"]
page: false
examples:
- "磁盘使用率 > 70%"
- "慢查询数量增加"
# 第二层:告警聚合(Alertmanager 路由)
route:
group_by: ["alertname", "cluster", "service"]
group_wait: 30s # 等 30 秒聚合同类告警
group_interval: 5m # 同组告警 5 分钟发一次
repeat_interval: 4h # 4 小时内不重复发送
routes:
- matchers: ["severity=critical"]
receiver: "oncall-phone"
group_wait: 0s # P0 不等待,立即通知
repeat_interval: 1h
- matchers: ["severity=warning"]
receiver: "oncall-slack"
group_wait: 60s # P1 等 1 分钟聚合
repeat_interval: 4h
# 第三层:告警抑制
inhibit_rules:
# 如果服务完全不可用,抑制该服务的子组件告警
- source_matchers: ['alertname="ServiceUnavailable"', 'severity="critical"']
target_matchers: ['service=~".+"']
equal: ["service", "cluster"]
# 如果集群不可达,抑制该集群的所有告警
- source_matchers: ['alertname="ClusterDown"']
target_matchers: ['cluster=~".+"']
equal: ["cluster"]
三层降噪的效果:
| 指标 | 改造前 | 改造后 | 变化 |
|---|---|---|---|
| 日均告警数 | ~12,000 | ~180 | -98.5% |
| 有效告警占比 | 1.7% | 85% | +49x |
| 告警→触达延迟 | 2-5 分钟 | <5 秒 | -96% |
| 误报率 | 82% | 12% | -85% |
告警触达 5 秒是什么概念?从故障发生到值班手机震动,不超过 5 秒。这比之前 2-5 分钟的检测延迟快了一个数量级。
关于告警策略设计的更完整方法论,可以参考 相关文章:告警策略设计:从噪声到信号。
决策五:自动化故障恢复——从人工排障到自愈
MTTR 是可用性的杠杆
可用性的计算公式:
Availability = MTBF / (MTBF + MTTR)
- MTBF(平均故障间隔时间):两次故障之间的平均时间
- MTTR(平均恢复时间):从故障发生到恢复的平均时间
提升可用性有两条路:增大 MTBF(减少故障频率)或减小 MTTR(加快恢复速度)。
实践告诉我们:减小 MTTR 比增大 MTBF 更可控。MTBF 取决于代码质量、架构设计、变更管理,这些是长期工程。MTTR 取决于监控、告警、预案、自动化——短期内可以快速提升。
我们的 MTTR 从改造前的 40 分钟降到 8 分钟。怎么做的?
自愈系统架构
# 自愈决策引擎
self_healing:
detection:
# 检测信号来源
sources:
- health_check: "HTTP /health 接口"
- metrics: "Prometheus 指标异常检测"
- alert: "Alertmanager 告警触发"
# 检测窗口:连续 3 次失败才触发
failure_threshold: 3
failure_window: 30s
diagnosis:
# 根因分类
categories:
- name: "OOM_Killed"
detection: "container_oom_events > 0"
action: "restart_pod"
- name: "HighLatency"
detection: "p99_latency > 2s AND cpu_usage < 80%"
action: "scale_up"
- name: "HighErrorRate"
detection: "error_rate > 1%"
action: "rollback_last_change"
- name: "DiskFull"
detection: "disk_usage > 95%"
action: "clean_logs_and_restart"
remediation:
# 自动恢复动作
actions:
restart_pod:
command: "kubectl delete pod -n {namespace} {pod_name}"
cooldown: 60s
max_retries: 2
scale_up:
command: "kubectl scale deployment -n {namespace} {deployment} --replicas={current}+2"
cooldown: 120s
max_retries: 1
rollback_last_change:
command: "kubectl rollout undo deployment -n {namespace} {deployment}"
cooldown: 300s
max_retries: 1
require_human_confirm: false # P0 自动回滚
clean_logs_and_restart:
command: |
kubectl exec -n {namespace} {pod_name} -- find /var/log -name "*.log" -mtime +1 -delete
kubectl delete pod -n {namespace} {pod_name}
cooldown: 180s
max_retries: 1
自愈的边界:什么能自动做,什么不能
自愈系统最大的风险是误触发。一个误判的自动回滚,可能把正常发布的版本回退掉,引发更大的混乱。
我们的原则:
| 场景 | 是否自动执行 | 理由 |
|---|---|---|
| Pod OOM 重启 | ✅ 自动 | 无状态服务重启安全,恢复快 |
| 磁盘满清理日志 | ✅ 自动 | 清理过期日志无风险 |
| 实例扩容 | ✅ 自动 | 增加容量不会破坏数据 |
| 版本回滚 | ⚠️ 有条件自动 | 仅 P0 告警自动回滚,且限制 5 分钟内只回滚一次 |
| 数据库主从切换 | ❌ 人工确认 | 数据一致性风险太高,不允许自动执行 |
| 网络路由切换 | ❌ 人工确认 | 影响范围大,需要人工评估 |
我的建议:自愈系统的第一原则不是"多智能",而是"不添乱"。宁可漏掉一些可以自动恢复的故障让值班手动处理,也不要让自愈系统成为新的故障源。每次自动恢复动作必须有 cooldown 时间和最大重试次数限制。
MTTR 优化的完整四层防御方案,可以参考 相关文章:MTTR 从 40 分钟到 8 分钟:用四层防御把故障定位提速 5 倍。
决策六:容量与冗余策略——不是堆机器
冗余不是越多越好
很多人的第一反应:提升可用性就是加机器、加副本、加冗余。但冗余是有成本的——不只是服务器费用,还有维护复杂度、数据一致性风险、故障切换的不确定性。
我们对比了三种冗余策略:
方案 A:N+1 冗余
- N 个实例承载正常流量,1 个实例做热备
- 优点:成本低,架构简单
- 缺点:故障切换时容量骤降 1/N,高峰期可能雪崩
- 适用:流量平稳的服务
方案 B:2N 冗余
- 双倍实例,主备各承载 50% 流量
- 优点:故障切换零感知,容量不降
- 缺点:成本翻倍
- 适用:核心交易链路
方案 C:N+M 冗余
- N 个实例承载流量,M 个实例弹性扩容
- 优点:成本可控,弹性应对突发
- 缺点:扩容有延迟(1-3 分钟),切换期间有短暂降级
- 适用:流量有明显波峰波谷的服务
我们的选型:
| 服务类型 | 冗余策略 | 理由 |
|---|---|---|
| 订单创建 | 2N | 不能容忍任何降级 |
| 订单查询 | N+1 | 查询慢一点用户能接受 |
| 支付网关 | 2N + 跨机房 | 支付不能挂 |
| 报表生成 | N+M | 非实时,可降级 |
| 消息推送 | N+1 | 延迟推送可接受 |
容量规划的数据驱动
容量不是拍脑袋定的。我们基于历史数据 + 增长预测做容量规划:
# 容量规划脚本(简化版)
import numpy as np
def calculate_capacity_needed(historical_qps, growth_rate, peak_multiplier=2.5):
"""
基于历史 QPS 计算所需容量
historical_qps: 过去 30 天每分钟 QPS 数组
growth_rate: 月增长率(如 0.1 表示 10%)
peak_multiplier: 峰值倍数(大促期间通常 2-3 倍)
"""
# 当前峰值 QPS
current_peak = np.percentile(historical_qps, 99)
# 预测 3 个月后的峰值
future_peak = current_peak * (1 + growth_rate) ** 3 * peak_multiplier
# 单实例承载能力(压测数据)
capacity_per_instance = 500 # QPS
# 需要的实例数(加 20% 安全余量)
instances_needed = int(np.ceil(future_peak / capacity_per_instance * 1.2))
return {
'current_peak_qps': int(current_peak),
'predicted_peak_qps_3mo': int(future_peak),
'instances_needed': instances_needed,
'current_instances': len(historical_qps), # 假设已知
}
实测数据:在我们 120+ 微服务迁移到 K8s 后,通过 HPA(水平 Pod 自动扩缩容)+ 合理的 resource requests/limits 配置,扩缩容速度从 25 分钟降到 1 分钟。这意味着面对突发流量,系统可以在 60 秒内完成扩容,不再需要提前预留大量冗余实例。
但 HPA 有一个坑值得说:stabilizationWindowSeconds 默认值是 300 秒(5 分钟)。意味着流量峰值来的时候,HPA 要等 5 分钟才开始扩容——黄花菜都凉了。我们改成了 60 秒:
# HPA 配置:缩短扩容等待时间
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: order-service-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: order-service
minReplicas: 4
maxReplicas: 20
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
behavior:
scaleUp:
stabilizationWindowSeconds: 60 # 默认 300,改成 60
policies:
- type: Percent
value: 100 # 每次最多扩容 100%
periodSeconds: 30
- type: Pods
value: 4 # 或每次扩容 4 个 Pod
periodSeconds: 30
selectPolicy: Max
scaleDown:
stabilizationWindowSeconds: 300 # 缩容保持默认,避免抖动
policies:
- type: Percent
value: 10
periodSeconds: 60
踩坑实录:3 个让我后背发凉的时刻
踩坑一:错误预算的"月度重置"陷阱
错误预算的消耗是按月统计的。月初预算充足,团队放心发布。月底预算快耗尽,冻结变更。
问题出在"月初重置"这个机制上。
某月 1 号,所有服务的错误预算重置为 100%。开发团队一看"预算满了",排了一堆积压的变更——6 个服务同时发版。结果金丝雀阶段没问题,全量发布后服务间的兼容性问题集中爆发,错误预算在 2 号一天就烧掉 40%。
根因:错误预算按月重置,但没有限制"单日变更数量"。月初预算充足不等于系统容错能力充足。
修复:增加单日变更速率限制——不管预算还剩多少,每个工作日最多 2 个非紧急变更。同时引入"变更冷却期"——大变更后 4 小时内不允许其他变更,给系统足够的观察窗口。
踩坑二:自愈系统误触发导致数据不一致
某次数据库主从延迟告警触发,自愈系统判断"从库异常",自动执行了从库重启。重启过程中,正在进行的 binlog 同步被中断,导致从库数据与主库偏差了 3 条记录。
这 3 条记录恰好是订单状态更新——从库重启后,查询订单状态返回了旧数据,用户看到"已支付"变成"待支付",引发客诉。
根因:自愈系统的动作太激进了。数据库相关的恢复动作不应该自动执行,尤其是涉及数据一致性的操作。
修复:
- 把数据库相关的自愈动作全部改为"通知人工确认"
- 自愈系统增加"数据一致性预检"——执行恢复动作前先检查主从延迟,延迟超过阈值就不执行
- 从库重启前先等待 binlog 同步完成,而不是立即重启
// 改进后的自愈决策:数据库操作需人工确认
func shouldAutoHeal(alert Alert) bool {
// 数据库相关告警不自动恢复
if alert.Category == "database" {
notifyHumanForConfirmation(alert)
return false
}
// 检查数据一致性
if alert.Category == "replication" {
lag := checkReplicationLag(alert.Instance)
if lag > 5*time.Second {
log.Printf("replication lag %v too high, skip auto-heal", lag)
return false
}
}
return true
}
踩坑三:金丝雀流量太少,没覆盖到边界场景
金丝雀发布的流量比例从 5% 开始。5% 的流量在低峰期可能只有 2-3 QPS,很多边界场景根本触发不到。
某次发布在金丝雀阶段一切正常,全量后立刻炸了——因为金丝雀的 5% 流量恰好没有命中"大额订单"的用户群体。大额订单走了不同的业务分支,代码里有个未处理的空指针异常。
根因:金丝雀流量分配是随机的,没有按业务维度做分层。5% 的随机流量可能完全错过某些用户群体。
修复:金丝雀流量分配改为"分层路由":
# 分层金丝雀:确保每个用户群体都被覆盖
canary_routing:
strategy: "stratified"
groups:
- name: "small_order" # 小额订单用户
weight: 5 # 金丝雀流量占比
user_selector: "order_amount < 100"
- name: "large_order" # 大额订单用户
weight: 5
user_selector: "order_amount >= 100"
- name: "new_user" # 新用户
weight: 5
user_selector: "register_days < 7"
- name: "vip_user" # VIP 用户
weight: 5
user_selector: "user_level >= 5"
每个用户群体都有 5% 的金丝雀流量覆盖。虽然总流量还是 5%,但确保了每个业务场景都被测试到。
成本与收益:0.4% 值多少钱
改造 14 个月,投入了什么:
| 投入项 | 人力 | 时间 | 费用(估) |
|---|---|---|---|
| SLI/SLO 体系搭建 | 2 人 | 3 个月 | — |
| 故障域隔离改造 | 4 人 | 6 个月 | — |
| 变更管理平台 | 2 人 | 4 个月 | — |
| 监控告警降噪 | 1 人 | 2 个月 | — |
| 自愈系统开发 | 2 人 | 4 个月 | — |
| 容量规划工具 | 1 人 | 2 个月 | — |
| 额外冗余资源 | — | 持续 | 约 15% 基础设施成本增加 |
回报是什么:
| 收益项 | 改造前 | 改造后 | 变化 |
|---|---|---|---|
| 年停机时间 | 43.8 小时 | 8.2 小时 | -81% |
| MTTR | 40 分钟 | 8 分钟 | -80% |
| 月度变更故障数 | 11 | 4 | -64% |
| 告警噪音 | 12,000/天 | 180/天 | -98.5% |
| 值班团队规模 | 5 人轮值 | 3 人轮值 | -40% |
最后一行是隐性收益中最有价值的:值班团队从 5 人缩减到 3 人,释放的 2 人投入到了工程改进工作中。这才是可持续的正循环——用工程手段替代人力,把人解放出来做更高价值的事。
99.9% 之后:要不要继续追 99.99%
很多人会问:做到 99.9% 之后,要不要继续追 99.99%?
我的答案是:看业务,别看面子。
从 99.9% 到 99.99%,年停机只减少 8 小时。但工程成本是 99.5%→99.9% 的 3-5 倍。你需要:
- 跨机房双活甚至多活部署(相关文章:系统韧性工程:从被动救火到主动防御的架构演进)
- 全链路灰度能力
- 更精细的流量调度和故障切换
- 更严格的变更管控(可能影响发布速度)
这些投入对金融交易、医疗急救、航空航天等场景是值得的。但如果你的系统是内容分发、社交社区、工具类应用,99.9% 已经够用了——用户不会因为每年少 8 小时的停机时间而对你的产品产生质变的感知。
追求可用性提升的决策应该基于错误预算和业务影响,而不是"别人的系统都是四个九"。把追四个九的精力省下来,投到功能迭代和用户体验上,投入产出比可能更高。
总结
从 99.5% 到 99.9%,表面上是 0.4 个百分点,实质上是一次系统工程方法论的重构。
6 个关键决策,每个都不是银弹,都有代价和适用边界:
- SLI 重新定义——从服务器存活到用户成功,这是一切度量和管理的前提。SLI 定义错了,后面的所有工作都在错误的方向上发力
- 故障域隔离——把爆炸半径从 100% 砍到 10%,但分片的复杂度是长期负担,百万日活以下不推荐
- 错误预算门禁——用数据替代拍脑袋,但需要配套变更速率限制和冷却期,否则月初会集中爆发
- 告警降噪——三层降噪把信噪比提高 49 倍,但自愈动作的边界必须严格划定,数据库操作不允许自动执行
- 自动故障恢复——MTTR 从 40 分钟到 8 分钟,但每次自愈动作必须有 cooldown 和最大重试限制
- 容量冗余策略——按服务重要度分级冗余,核心链路 2N,非核心 N+1,HPA 的 stabilizationWindowSeconds 必须从默认 300 秒改到 60 秒
3 个踩坑时刻的共同教训:自动化是一把双刃剑。错误预算按月重置但没限制单日变更速率、自愈系统对数据库操作太激进、金丝雀流量没覆盖边界场景——每一个坑都是"自动化设计不够保守"导致的。
我的实践原则:自动化的第一目标是不添乱,第二目标才是提效率。一个不会误触发的自愈系统,比一个能处理 100 种故障但偶尔误判的系统更有价值。
最后,可用性提升不是一次性项目,而是一个持续迭代的过程。错误预算会波动、用户旅程会变化、系统架构会演进。你能做的最好的事情,是建立一个可量化的反馈循环——SLI 告诉你哪里出了问题,SLO 告诉你目标在哪,错误预算告诉你什么时候该踩刹车。
参考资料与致谢
本文在撰写过程中参考了以下资料,感谢原作者的贡献:
- The Calculus of Service Availability — ACM Queue / Google SRE Team,关于服务可用性数学模型和依赖链可用性计算的权威论述
- Availability Calculator — SRE.xyz,可用性等级与停机时间换算工具,文中可用性对照表数据来源
- Reliability Maturity Model — Microsoft Azure Well-Architected Framework,可靠性成熟度模型和冗余策略参考
- Quantifying Availability and Scalability Requirements — Microsoft Learn,可用性量化需求和架构选型方法论
- 企业级全链路 SRE 稳定性工程体系建设方案 — 腾讯云开发者社区,多层级 SLO 治理和自愈体系实践参考
- 追逐五个 9 的隐形成本 — 网易,可用性提升的边际成本递减分析