30、可靠性测试与度量:单元测试、集成测试、压力测试、故障注入测试、可靠性指标计算、SLA/SLO/SLI

聊到可靠性,很多人第一反应是「代码写得稳不稳」。但说实话,代码写得再漂亮,没经过验证,我心里还是不踏实。我见过太多上线前信心满满、上线后手忙脚乱的案例了。

这一章,咱们就聊聊怎么用测试和度量,把「可靠性」从口号变成可量化、可验证的东西。说白了,就是给系统做一次全面体检,再给它的健康水平打个分。

单元测试:最基础的防线

单元测试,我个人的习惯是把它当作「代码的说明书」。你写一个函数,它的输入输出是什么?边界情况怎么处理?单元测试把这些都写清楚。

为什么要做?因为它是成本最低的 bug 发现方式。一个 bug 在单元测试阶段发现,改几行代码就行;要是流到线上,可能就是 P0 事故了。

核心原则:
  • 每个函数/方法都要覆盖正常路径和异常路径
  • 测试用例要独立,不依赖外部环境(数据库、网络等)
  • 运行速度要快,最好秒级完成

举个例子,一个简单的用户注册函数:

// 用户注册函数
func RegisterUser(username, email string) error {
    if username == "" || email == "" {
        return ErrInvalidInput
    }
    // 检查用户名是否已存在
    exists, err := db.CheckUsername(username)
    if err != nil {
        return err
    }
    if exists {
        return ErrUserExists
    }
    // 创建用户
    return db.CreateUser(username, email)
}

对应的单元测试:

func TestRegisterUser(t *testing.T) {
    // 测试空用户名
    err := RegisterUser("", "test@example.com")
    if err != ErrInvalidInput {
        t.Errorf("期望 ErrInvalidInput,得到 %v", err)
    }

    // 测试正常注册
    err = RegisterUser("newuser", "new@example.com")
    if err != nil {
        t.Errorf("期望 nil,得到 %v", err)
    }

    // 测试重复用户名
    err = RegisterUser("existing", "existing@example.com")
    if err != ErrUserExists {
        t.Errorf("期望 ErrUserExists,得到 %v", err)
    }
}
我的经验:单元测试覆盖率不用追求 100%,但核心业务逻辑的覆盖率至少要 80% 以上。我见过团队死磕覆盖率到 95%,结果测的都是 getter/setter,意义不大。

集成测试:把零件组装起来看

单元测试测的是单个零件,集成测试就是把零件组装起来,看看接口能不能对上。我在项目中遇到过好几次,单元测试全绿,一集成就崩——因为两个模块对同一个字段的理解不一样。

集成测试的重点:

  • 模块间的接口契约是否一致
  • 数据流是否完整(A 模块输出 → B 模块输入)
  • 外部依赖(数据库、缓存、消息队列)是否正常工作

举个例子,用户注册后要发欢迎邮件,这就是一个典型的集成点:

func TestUserRegistrationAndEmail(t *testing.T) {
    // 模拟数据库和邮件服务
    db := setupTestDB()
    emailSvc := setupMockEmailService()

    svc := NewUserService(db, emailSvc)

    // 注册用户
    user, err := svc.Register("test@example.com", "password123")
    if err != nil {
        t.Fatalf("注册失败: %v", err)
    }

    // 验证用户已存入数据库
    savedUser, _ := db.GetUserByID(user.ID)
    if savedUser == nil {
        t.Error("用户未存入数据库")
    }

    // 验证邮件已发送
    if !emailSvc.WasEmailSent(user.Email, "welcome") {
        t.Error("欢迎邮件未发送")
    }
}
注意:集成测试要尽量使用真实的依赖(比如测试数据库),而不是全部 mock。mock 太多,测出来的结果可能和线上不一致。我曾经吃过这个亏,mock 的 Redis 返回永远正常,线上 Redis 一抖动就出问题。

压力测试:看看系统能扛多少

压力测试,说白了就是「往死里压」,看看系统在极限情况下表现如何。我习惯用三个指标来衡量:

指标 说明 典型目标值
QPS(每秒查询数) 系统能处理的请求量 根据业务需求,如 1000 QPS
响应时间(P99) 99% 的请求在多少毫秒内完成 < 200ms
错误率 请求失败的百分比 < 0.1%

压测工具我常用 wrk 和 Locust。举个例子,用 wrk 压一个 API:

# 压测 30 秒,12 个线程,400 个并发连接
wrk -t12 -c400 -d30s http://localhost:8080/api/users

输出结果:

Running 30s test @ http://localhost:8080/api/users
  12 threads and 400 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    45.67ms   12.34ms  256.78ms   85.23%
    Req/Sec   723.45    89.12     1.2k      70.45%
  216789 requests in 30.00s, 34.56MB read
  Requests/sec:   7226.30
  Transfer/sec:      1.15MB

看到这个结果,我心里就有数了:这个接口能扛 7000+ QPS,P99 延迟在 100ms 以内,还不错。但如果 P99 超过 500ms,那就得优化了。

避坑指南:压测时一定要监控 CPU、内存、网络 IO。我曾经压测时只看 QPS,结果 CPU 跑满了 100%,QPS 再高也没用,因为用户实际体验会卡死。

故障注入测试:主动搞破坏

故障注入,就是故意让系统出问题,看看它能不能优雅地处理。这招很狠,但很有效。我常用的故障注入手段:

  • 网络故障:模拟网络延迟、丢包、断连
  • 服务故障:让某个微服务返回 500 或超时
  • 资源耗尽:模拟磁盘满、内存不足、CPU 过载

举个例子,用 Chaos Monkey 或 Litmus 工具,注入一个 Redis 故障:

# 使用 Litmus 注入 Redis 故障
apiVersion: litmuschaos.io/v1alpha1
kind: ChaosEngine
metadata:
  name: redis-fault
spec:
  appinfo:
    appns: "default"
    applabel: "app=redis"
    appkind: "deployment"
  chaosServiceAccount: litmus-admin
  experiments:
    - name: pod-delete
      spec:
        components:
          env:
            - name: TOTAL_CHAOS_DURATION
              value: "60"  # 故障持续 60 秒
            - name: CHAOS_INTERVAL
              value: "10"  # 每 10 秒删除一个 Pod

然后观察系统行为:

  • 是否有降级策略?比如从 Redis 降级到本地缓存
  • 恢复后是否能自动重连?
  • 数据是否丢失?
警告:故障注入别在生产环境直接搞!先在预发环境验证,确认降级策略没问题后,再考虑灰度到生产。我见过有人直接在线上搞故障注入,结果降级没生效,直接挂了半小时。

可靠性指标计算:用数据说话

测试完了,怎么量化可靠性?我常用这几个指标:

指标 公式 说明
MTBF(平均故障间隔) 总运行时间 / 故障次数 系统平均多久出一次故障
MTTR(平均修复时间) 总故障时间 / 故障次数 出故障后平均多久恢复
可用性(Availability) MTBF / (MTBF + MTTR) × 100% 系统正常运行时间的百分比

举个例子:

  • 一个月内系统故障了 3 次,每次修复平均 30 分钟
  • 总运行时间:30 天 × 24 小时 = 720 小时
  • MTBF = 720 / 3 = 240 小时(平均 10 天出一次故障)
  • MTTR = (30 分钟 × 3) / 3 = 30 分钟
  • 可用性 = 240 / (240 + 0.5) × 100% ≈ 99.79%

这个 99.79% 意味着什么?一个月大概有 1 小时左右的不可用时间。如果业务要求 99.99%(四个九),那一个月只能宕机 4 分多钟——差距还是很大的。

我的建议:不要盲目追求「五个九」(99.999%),成本会指数级上升。先搞清楚业务到底需要多少,再定目标。比如内部管理系统,99.9% 可能就够了;但支付系统,99.99% 是底线。

SLA / SLO / SLI:把可靠性变成合同

这三个概念,我刚开始也容易搞混。简单来说:

  • SLI(服务级别指标):你具体测量什么。比如「API 响应时间」、「错误率」
  • SLO(服务级别目标):你希望达到的目标。比如「P99 响应时间 < 200ms」
  • SLA(服务级别协议):你和客户签的合同。比如「如果可用性低于 99.9%,赔偿 10% 费用」

举个例子,一个典型的 SLO 定义:

# 服务级别目标(SLO)
apiVersion: "slo.sre/v1"
kind: "ServiceLevelObjective"
metadata:
  name: "api-latency-slo"
spec:
  description: "API 响应时间 SLO"
  service: "user-service"
  sli:
    metric: "http_request_duration_seconds"
    threshold: 0.2  # 200ms
    percentile: 99   # P99
  target: 99.9%      # 99.9% 的请求要在 200ms 内完成
  window: 30d        # 滚动 30 天统计

然后基于这个 SLO,我们可以设置告警:

# 告警规则:当 SLO 接近违约时告警
- alert: SLOViolationRisk
  expr: |
    (1 - (sum(rate(http_request_duration_seconds_bucket{le="0.2"}[5m]))
         / sum(rate(http_request_duration_seconds_count[5m]))))
    > 0.001  # 错误率超过 0.1%
  for: 10m
  labels:
    severity: critical
  annotations:
    summary: "API 响应时间 SLO 面临违约风险"
经验之谈:SLO 别定得太死,留点余量。比如你实际能做到 99.99%,SLO 定 99.9% 就好。这样即使偶尔波动,也不会触发 SLA 赔偿。我见过团队把 SLO 定得和实际能力一样,结果每次故障都要赔钱,得不偿失。

知识体系总览

说了这么多,咱们用一张图把整个可靠性测试与度量的体系串起来:

可靠性测试与度量知识体系 单元测试 集成测试 压力测试 故障注入测试 代码覆盖率 接口契约验证 QPS / 延迟 / 错误率 故障恢复能力 MTBF / MTTR / 可用性 SLI / SLO / SLA 告警规则 / 赔偿条款 可量化的可靠性保障

这张图把整个流程串起来了:从测试方法开始,到测试产出,再到指标计算,最后落到可量化的可靠性保障。每一步都环环相扣,缺一不可。

好了,这一章的内容就到这里。记住,可靠性不是靠嘴说的,是靠测试和度量一步步验证出来的。把上面这些方法用起来,你的系统会越来越稳。


公众号:蓝海资料掘金营,微信deep3321