文档目录

8.6 流量回放与金丝雀发布

上一节:8.5 告警设计:燃烧率与领先指标 | 下一节:8.7 容量规划 配套代码:08-continuous-performance/06-traffic-replay-and-canary


一句话结论

流量回放解决「人造流量不像真实流量」,金丝雀解决「上线后才发现问题」。 两者配合:回放做上线前的验证,金丝雀做上线时的保护。


一、用「彩排」和「矿井金丝雀」理解这两件事

流量回放 = 彩排

话剧正式演出前会彩排:用真实的剧本、真实的走位、真实的灯光。

但有一条铁律:彩排时演员不能真的受伤(道具刀是假的)。对应到后端:回放时不能让写操作污染生产数据。

金丝雀 = 矿井里的金丝雀

旧时矿工会带一只金丝雀下矿。金丝雀对瓦斯更敏感——它先倒下,矿工就知道要撤。

关键:金丝雀是"小范围先试",而不是"全量上线后观察"。


二、流量回放的四种数据来源

来源 保真度 成本 适用
生产访问日志回放 高(真实分布) 中(要脱敏、要处理写操作) 推荐
生产 trace 回放 高 高(需要 tracing 系统) 大型项目
从生产统计生成(幂律分布) 中 低 快速搭环境
纯人工构造 低 低 ❌ 容易失真(第 5.5 节)

为什么推荐访问日志:它天然包含真实的访问分布(幂律热点)、真实的参数、真实的时序。

# 从 PostgreSQL 导出真实的 ID 访问分布
psql -tAc "
SELECT user_id FROM request_log
WHERE created_at > now() - interval '1 day'
ORDER BY random() LIMIT 100000;" > sampled-ids.txt

# 在 k6 里按这个列表取样(保持真实分布)
# const ids = open('./sampled-ids.txt').split('\n');
# const id = ids[Math.floor(Math.random() * ids.length)];

三、写操作怎么处理(最关键的问题)

回放写操作会污染数据,而且会累积(每次回放都写一遍)。

四种处理方式

方式 做法 优点 缺点
① 只回放读请求 过滤掉所有写操作 最简单、最安全 覆盖不到写路径
② 写操作改写到影子库 配置让影子实例连影子数据库 覆盖完整 需要额外的库
③ 写操作"空转" 用 mock 让写调用直接返回成功 能测到应用层逻辑 测不到真实写性能
④ 参数脱敏后写到测试库 改 ID 空间(加前缀),写到测试库 覆盖完整 需要数据隔离设计

推荐组合:

对于大多数场景:① 只回放读 + 对写路径用【合成流量】单独压测
对于核心写路径:② 影子库
对于安全的写路径(如"更新最后访问时间"):③ 空转

副作用必须阻断

有些操作的副作用不能靠"连影子库"解决:

副作用 必须的处理
发短信/邮件 在影子环境禁用(配置开关)
调用外部支付 禁用(或指向沙箱)
推送消息 禁用
写对象存储 指向影子 bucket
调用第三方 API(计费) 禁用或限流

实现方式:

// 用一个明确的开关控制所有副作用
object SideEffectGuard {
    @Volatile var enabled = System.getenv("ENABLE_SIDE_EFFECTS")?.toBoolean() ?: true

    fun <T> guarded(name: String, block: () -> T): T? {
        if (!enabled) {
            log.warn("副作用已被阻断(影子环境):{}", name)
            metrics.counter("side_effect.blocked", "name", name).increment()
            return null
        }
        return block()
    }
}

// 使用
SideEffectGuard.guarded("send-sms") { smsClient.send(phone, msg) }

这个开关必须在影子环境设为 false——而且要有指标监控被阻断的次数(用来确认它真的生效了)。


四、数据脱敏

回放生产日志意味着日志里的数据会进入测试环境——必须脱敏。

数据类型 脱敏方式
手机号、邮箱、身份证 替换为格式相同的假数据(保持长度与格式,避免影响校验逻辑)
姓名、地址 替换为假名
银行卡、支付信息 不要回放(或用固定的测试卡号)
用户 ID 建议做映射(保持关联关系,但换成新 ID 空间)
token / 密钥 必须替换(旧 token 会失效)

脱敏的两个原则:

  1. 保持格式——如果手机号被替换成 ***,那么校验手机号格式的代码路径就测不到了。
  2. 保持关联——同一个用户的多次请求应该映射到同一个新 ID(否则会破坏缓存命中率与业务逻辑)。
# tools/sanitize_logs.py
"""
脱敏生产日志,准备用于流量回放。
"""
import hashlib
import json
import re
import sys


class Sanitizer:
    def __init__(self, salt="replay-salt"):
        self.salt = salt
        self.id_map = {}

    def stable_id(self, original: str) -> str:
        """保持关联的 ID 映射:同一输入 → 同一输出"""
        if original not in self.id_map:
            h = hashlib.sha256((self.salt + original).encode()).hexdigest()[:12]
            self.id_map[original] = f"replay-{h}"
        return self.id_map[original]

    def phone(self, value: str) -> str:
        """保持格式:138****1234 → 13900001234(格式相同、内容假)"""
        if len(value) == 11 and value.isdigit():
            return "1" + "39" + "0" * 5 + value[-3:]
        return value

    def sanitize_line(self, line: str) -> str:
        obj = json.loads(line)
        for key in list(obj.keys()):
            if key in ("user_id", "order_id", "session_id"):
                obj[key] = self.stable_id(str(obj[key]))
            elif key in ("phone", "mobile"):
                obj[key] = self.phone(str(obj[key]))
            elif key in ("token", "authorization", "api_key"):
                obj[key] = "replay-token-placeholder"
            elif key in ("email",):
                obj[key] = "user@example.com"
        return json.dumps(obj, ensure_ascii=False)


def main(in_path, out_path):
    s = Sanitizer()
    with open(in_path) as fin, open(out_path, "w") as fout:
        for line in fin:
            if line.strip():
                fout.write(s.sanitize_line(line) + "\n")
    print(f"✅ 脱敏完成:{in_path} → {out_path}")
    print(f"   映射了 {len(s.id_map)} 个 ID(保持关联关系)")


if __name__ == "__main__":
    main(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else "sanitized.jsonl")

五、影子流量(Shadow Traffic)

做法:把生产流量复制一份发给新版本,只观察不返回(或返回后丢弃)。

              ┌─→ 生产版本 → 返回给用户
用户请求 ──→ 网关
              └─→ 影子版本 → 只记录,不返回

三个必须处理的问题

问题 处理
写操作污染 影子版本写影子库,或禁用写
放大下游压力 下游依赖会承受双倍压力——必须限流(或让影子版本走 mock 的下游)
副作用 同流量回放:禁用短信/支付/推送

实现方式

方式 说明
网关复制(推荐) 在网关层做流量镜像(Nginx mirror、Istio mirror)
应用内双写 在应用里复制请求到影子服务(耦合,不推荐)
服务网格 Istio/Envoy 的 mirror 能力
# Nginx 流量镜像
location /api/ {
    mirror /shadow;                    # 复制一份到 /shadow
    mirror_request_body on;
    proxy_pass http://production;
}

location = /shadow {
    internal;
    proxy_pass http://canary-version$request_uri;
    # 影子版本的响应被丢弃
}

注意:mirror 的请求会同步发送但响应被忽略——如果影子版本很慢,可能拖累 Nginx(用 proxy_read_timeout 限制)。


六、金丝雀发布与自动回滚

基本流程

① 部署金丝雀实例(1 个,占总量的 5%)
② 把 5% 流量切给金丝雀
③ 持续对比金丝雀组与基线组的关键指标
④ 达标 → 逐步扩大比例(5% → 25% → 50% → 100%)
⑤ 不达标 → 自动回滚

判定指标与阈值

必须在发布前定义好(第 7.8 节的「判定标准在实验前定好」):

指标 正常范围 回滚阈值
P99 相对基线 ±10% > +20%
错误率(相对) ±0.1pp > +0.5pp
饱和度(pending) = 0 > 5 持续 3 分钟
GC 停顿 与基线相当 > 基线 × 2
容器节流 < 1% > 10%

注意「相对」:不要用绝对阈值(第 8.2 节)——金丝雀组和基线组在同一时间、同一环境,所以可以直接对比(这比与历史基线对比更可靠)。

自动回滚的实现

#!/usr/bin/env bash
# tools/canary-monitor.sh <BASELINE_LABEL> <CANARY_LABEL> [DURATION_MIN]
#
# 持续对比金丝雀与基线,超阈值则触发回滚。
set -uo pipefail

BASELINE="${1:?usage: canary-monitor.sh <baseline> <canary> [duration]}"
CANARY="${2:?}"
DURATION="${3:-10}"
PROM="${PROM_URL:-http://localhost:9090}"

# 回滚阈值(在发布前定义)
P99_DELTA_THRESHOLD=0.20       # P99 恶化 > 20% 回滚
ERROR_DELTA_THRESHOLD=0.005    # 错误率上升 > 0.5pp 回滚
PENDING_THRESHOLD=5            # pending > 5 持续 3 分钟回滚

echo "═══ 金丝雀监控 ═══"
echo "  基线组: $BASELINE"
echo "  金丝雀: $CANARY"
echo "  时长  : ${DURATION} 分钟"
echo

query() {
  curl -s --get "$PROM/api/v1/query" --data-urlencode "query=$1" \
    | python3 -c "import json,sys; d=json.load(sys.stdin); r=d['data']['result']; print(r[0]['value'][1] if r else 'NaN')" 2>/dev/null || echo "NaN"
}

p99() {
  query "histogram_quantile(0.99, sum by (le) (rate(http_request_duration_seconds_bucket{version=\"$1\"}[5m])))"
}

error_rate() {
  query "sum(rate(http_requests_total{version=\"$1\",status=~\"5..\"}[5m])) / sum(rate(http_requests_total{version=\"$1\"}[5m]))"
}

pending() {
  query "max(db_pool_pending{version=\"$1\"})"
}

ROLLBACK=0
PENDING_BREACH_COUNT=0

# 用一个 Python 脚本做判定,避免在 bash 里拼复杂的浮点比较
cat > /tmp/canary_verdict.py <<'PYEOF'
import sys

b_p99, c_p99, b_err, c_err, c_pend = sys.argv[1:6]
p99_threshold = float(sys.argv[6])
err_threshold = float(sys.argv[7])
pend_threshold = float(sys.argv[8])

def num(x):
    try:
        return float(x)
    except (ValueError, TypeError):
        return 0.0

b_p99, c_p99 = num(b_p99), num(c_p99)
b_err, c_err, c_pend = num(b_err), num(c_err), num(c_pend)

delta = (c_p99 - b_p99) / b_p99 if b_p99 else 0.0
err_delta = c_err - b_err

verdict = "OK"
reason = ""
if delta > p99_threshold:
    verdict, reason = "ROLLBACK", f"P99 恶化 {delta*100:.1f}% > {p99_threshold*100:.0f}%"
elif err_delta > err_threshold:
    verdict, reason = "ROLLBACK", f"错误率上升 {err_delta*100:.2f}pp"
elif c_pend > pend_threshold:
    verdict, reason = "PENDING_BREACH", f"pending={c_pend:.0f} > {pend_threshold:.0f}"

print(f"{delta*100:.1f}|{verdict}|{reason}")
PYEOF

for i in $(seq 1 "$DURATION"); do
  B_P99=$(p99 "$BASELINE"); C_P99=$(p99 "$CANARY")
  B_ERR=$(error_rate "$BASELINE"); C_ERR=$(error_rate "$CANARY")
  C_PEND=$(pending "$CANARY")

  VERDICT_LINE=$(python3 /tmp/canary_verdict.py \
    "$B_P99" "$C_P99" "$B_ERR" "$C_ERR" "$C_PEND" \
    "$P99_DELTA_THRESHOLD" "$ERROR_DELTA_THRESHOLD" "$PENDING_THRESHOLD")

  DELTA="${VERDICT_LINE%%|*}"
  REST="${VERDICT_LINE#*|}"
  VERDICT="${REST%%|*}"
  REASON="${REST#*|}"

  echo "[$i/$DURATION] 基线 P99=${B_P99}ms 金丝雀 P99=${C_P99}ms(${DELTA}%)  pending=${C_PEND}"

  case "$VERDICT" in
    ROLLBACK)
      echo "  ❌ $REASON"
      ROLLBACK=1
      break
      ;;
    PENDING_BREACH)
      PENDING_BREACH_COUNT=$((PENDING_BREACH_COUNT + 1))
      echo "  ⚠️  $REASON(第 ${PENDING_BREACH_COUNT} 次,连续 3 次则回滚)"
      if [ "$PENDING_BREACH_COUNT" -ge 3 ]; then
        echo "  ❌ 连接池 pending 持续超标"
        ROLLBACK=1
        break
      fi
      ;;
    *)
      PENDING_BREACH_COUNT=0
      ;;
  esac

  sleep 60
done

echo
if [ "$ROLLBACK" -eq 1 ]; then
  echo "⛔ 触发回滚"
  scripts/rollback-canary.sh
  exit 1
else
  echo "✅ 金丝雀监控通过(${DURATION} 分钟)"
  echo "   可以继续扩大流量比例"
fi

一个重要的注意点

金丝雀的「流量比例」要和「判定指标」匹配:

❌ 金丝雀只接 5% 流量,观察 5 分钟
   → 样本太少(假设总 QPS 1000,5 分钟只有 15000 个请求)
   → P99 的估计不稳定,容易误判

✅ 两个选择:
   ① 接更多流量(比如 20%),或
   ② 观察更长时间(比如 30 分钟),或
   ③ 只对「错误率」和「饱和度」这类稳定指标做自动判定,
      P99 做人工观察

这是第 5.6 节「样本量决定精度」在金丝雀场景的应用。


七、本节小结

  1. 流量回放解决「人造流量不像真实」,金丝雀解决「上线后才发现」。
  2. 数据来源优先用生产访问日志(天然包含真实分布)。
  3. 写操作有四种处理:只回放读、影子库、空转、参数脱敏后写测试库——没有一种能覆盖所有场景。
  4. 副作用必须阻断(短信、支付、推送),且要有指标监控阻断次数。
  5. 脱敏的两个原则:保持格式(否则测不到校验逻辑)、保持关联(否则破坏缓存命中率)。
  6. 影子流量要处理三个问题:写污染、下游压力放大(会承受双倍流量)、副作用。
  7. 金丝雀的判定阈值必须在发布前定义(相对基线的变化,而不是绝对阈值)。
  8. 注意流量比例与样本量:5% 流量 + 5 分钟不足以可靠判断 P99——要么加大流量,要么延长时间,要么只对稳定指标自动判定。

八、自测

  1. 你要做流量回放,但生产流量里有「创建订单」「发送短信」「调用支付」三类操作。请说出你分别怎么处理,以及为什么。
  2. 为什么金丝雀的判定要用「相对基线的变化」而不是「绝对阈值」?请说明金丝雀场景与 CI 门禁场景的差异。
  3. 金丝雀只接 5% 流量,观察 5 分钟后 P99 从 95 ms 变成 130 ms。请说明这个结论是否可靠,以及你会怎么做。
  1. 三类操作的处理:① 创建订单——这是写操作,会污染数据库且累积。处理方式取决于需求:如果只想测读路径,直接过滤掉;如果要覆盖下单路径,让影子实例连影子数据库(或用参数脱敏 + 独立 ID 空间写到测试库)。不能直接回放到生产库。② 发送短信——这是外部副作用,回放会导致真实用户收到短信(骚扰 + 成本)。必须在影子环境禁用(用配置开关),并且要监控被阻断的次数来确认开关生效。③ 调用支付——最危险:会产生真实资金流动。必须完全禁用(或指向支付沙箱),而且这是**绝对不能"试一下"**的类别。共通原则:写操作要么隔离(影子库/测试库),要么阻断;副作用要么禁用,要么指向沙箱;资金相关的绝不回放。
  2. 差异:① CI 门禁是历史对比——今天的代码 vs 上周的基线,中间可能发生环境变化(runner 型号、依赖版本、数据量),所以不能用绝对阈值(换了 runner 就全线失败,第 8.2 节)。② 金丝雀是同时对比——金丝雀组和基线组在同一时间、同一环境、同一负载下运行,唯一差别就是代码版本。所以相对差异非常干净——同一时刻的环境噪声对两组的影响是相同的,差异可以直接归因到代码。用绝对阈值的问题:① 不同业务线的接口延迟差异很大(有的 10ms 有的 500ms),绝对阈值无法统一;② 业务增长后基线本身会变化;③ 更重要的是——绝对阈值会漏掉"相对恶化":比如某接口正常是 10ms,涨到 15ms(+50%)看起来绝对值很小,但相对退化严重,值得回滚。
  3. 结论不可靠。原因:① 样本量不足——假设总 QPS 是 1000,5% 即 50 QPS,5 分钟只有 15000 个请求;P99 意味着只取最慢的 150 个样本,统计上非常不稳定(第 5.6 节);② 环境噪声——金丝雀实例刚启动,可能有 JIT 未预热、连接池未填满、缓存冷的问题(第 2 章 2.4 节),这本身就会导致 P99 偏高。该怎么做:① 先确认金丝雀实例已预热(部署后等几分钟再开始观察,或用预热脚本);② 延长观察时间(比如 30 分钟)或加大流量比例(比如 20%),以获得足够样本;③ 对比时优先看更稳定的指标——错误率、连接池 pending、GC 停顿这些不容易受样本量影响的指标;④ 如果 P99 仍然超标,用依赖级指标做延迟分解(第 6.2 节)找到具体变慢的环节,而不是仅凭总 P99 决定;⑤ 保守处理:如果其他指标正常,可以先暂停扩大流量(而不是立刻回滚),继续观察 30 分钟再决定。