概述

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 条记录恰好是订单状态更新——从库重启后,查询订单状态返回了旧数据,用户看到"已支付"变成"待支付",引发客诉。

根因:自愈系统的动作太激进了。数据库相关的恢复动作不应该自动执行,尤其是涉及数据一致性的操作。

修复

  1. 把数据库相关的自愈动作全部改为"通知人工确认"
  2. 自愈系统增加"数据一致性预检"——执行恢复动作前先检查主从延迟,延迟超过阈值就不执行
  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%
MTTR40 分钟8 分钟-80%
月度变更故障数114-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 个关键决策,每个都不是银弹,都有代价和适用边界:

  1. SLI 重新定义——从服务器存活到用户成功,这是一切度量和管理的前提。SLI 定义错了,后面的所有工作都在错误的方向上发力
  2. 故障域隔离——把爆炸半径从 100% 砍到 10%,但分片的复杂度是长期负担,百万日活以下不推荐
  3. 错误预算门禁——用数据替代拍脑袋,但需要配套变更速率限制和冷却期,否则月初会集中爆发
  4. 告警降噪——三层降噪把信噪比提高 49 倍,但自愈动作的边界必须严格划定,数据库操作不允许自动执行
  5. 自动故障恢复——MTTR 从 40 分钟到 8 分钟,但每次自愈动作必须有 cooldown 和最大重试限制
  6. 容量冗余策略——按服务重要度分级冗余,核心链路 2N,非核心 N+1,HPA 的 stabilizationWindowSeconds 必须从默认 300 秒改到 60 秒

3 个踩坑时刻的共同教训:自动化是一把双刃剑。错误预算按月重置但没限制单日变更速率、自愈系统对数据库操作太激进、金丝雀流量没覆盖边界场景——每一个坑都是"自动化设计不够保守"导致的。

我的实践原则:自动化的第一目标是不添乱,第二目标才是提效率。一个不会误触发的自愈系统,比一个能处理 100 种故障但偶尔误判的系统更有价值。

最后,可用性提升不是一次性项目,而是一个持续迭代的过程。错误预算会波动、用户旅程会变化、系统架构会演进。你能做的最好的事情,是建立一个可量化的反馈循环——SLI 告诉你哪里出了问题,SLO 告诉你目标在哪,错误预算告诉你什么时候该踩刹车。

参考资料与致谢

本文在撰写过程中参考了以下资料,感谢原作者的贡献:

  1. The Calculus of Service Availability — ACM Queue / Google SRE Team,关于服务可用性数学模型和依赖链可用性计算的权威论述
  2. Availability Calculator — SRE.xyz,可用性等级与停机时间换算工具,文中可用性对照表数据来源
  3. Reliability Maturity Model — Microsoft Azure Well-Architected Framework,可靠性成熟度模型和冗余策略参考
  4. Quantifying Availability and Scalability Requirements — Microsoft Learn,可用性量化需求和架构选型方法论
  5. 企业级全链路 SRE 稳定性工程体系建设方案 — 腾讯云开发者社区,多层级 SLO 治理和自愈体系实践参考
  6. 追逐五个 9 的隐形成本 — 网易,可用性提升的边际成本递减分析