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 会失效) |
脱敏的两个原则:
- 保持格式——如果手机号被替换成
***,那么校验手机号格式的代码路径就测不到了。 - 保持关联——同一个用户的多次请求应该映射到同一个新 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 节「样本量决定精度」在金丝雀场景的应用。
七、本节小结
- 流量回放解决「人造流量不像真实」,金丝雀解决「上线后才发现」。
- 数据来源优先用生产访问日志(天然包含真实分布)。
- 写操作有四种处理:只回放读、影子库、空转、参数脱敏后写测试库——没有一种能覆盖所有场景。
- 副作用必须阻断(短信、支付、推送),且要有指标监控阻断次数。
- 脱敏的两个原则:保持格式(否则测不到校验逻辑)、保持关联(否则破坏缓存命中率)。
- 影子流量要处理三个问题:写污染、下游压力放大(会承受双倍流量)、副作用。
- 金丝雀的判定阈值必须在发布前定义(相对基线的变化,而不是绝对阈值)。
- 注意流量比例与样本量:5% 流量 + 5 分钟不足以可靠判断 P99——要么加大流量,要么延长时间,要么只对稳定指标自动判定。
八、自测
- 你要做流量回放,但生产流量里有「创建订单」「发送短信」「调用支付」三类操作。请说出你分别怎么处理,以及为什么。
- 为什么金丝雀的判定要用「相对基线的变化」而不是「绝对阈值」?请说明金丝雀场景与 CI 门禁场景的差异。
- 金丝雀只接 5% 流量,观察 5 分钟后 P99 从 95 ms 变成 130 ms。请说明这个结论是否可靠,以及你会怎么做。
- 三类操作的处理:① 创建订单——这是写操作,会污染数据库且累积。处理方式取决于需求:如果只想测读路径,直接过滤掉;如果要覆盖下单路径,让影子实例连影子数据库(或用参数脱敏 + 独立 ID 空间写到测试库)。不能直接回放到生产库。② 发送短信——这是外部副作用,回放会导致真实用户收到短信(骚扰 + 成本)。必须在影子环境禁用(用配置开关),并且要监控被阻断的次数来确认开关生效。③ 调用支付——最危险:会产生真实资金流动。必须完全禁用(或指向支付沙箱),而且这是**绝对不能"试一下"**的类别。共通原则:写操作要么隔离(影子库/测试库),要么阻断;副作用要么禁用,要么指向沙箱;资金相关的绝不回放。
- 差异:① CI 门禁是历史对比——今天的代码 vs 上周的基线,中间可能发生环境变化(runner 型号、依赖版本、数据量),所以不能用绝对阈值(换了 runner 就全线失败,第 8.2 节)。② 金丝雀是同时对比——金丝雀组和基线组在同一时间、同一环境、同一负载下运行,唯一差别就是代码版本。所以相对差异非常干净——同一时刻的环境噪声对两组的影响是相同的,差异可以直接归因到代码。用绝对阈值的问题:① 不同业务线的接口延迟差异很大(有的 10ms 有的 500ms),绝对阈值无法统一;② 业务增长后基线本身会变化;③ 更重要的是——绝对阈值会漏掉"相对恶化":比如某接口正常是 10ms,涨到 15ms(+50%)看起来绝对值很小,但相对退化严重,值得回滚。
- 结论不可靠。原因:① 样本量不足——假设总 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 分钟再决定。