一个"只改了一行缓存 key"的版本,发布后十分钟订单量掉了三成——问题不是那行代码,而是新 key 让缓存全部未命中,数据库连接池在三十秒内被击穿。代码本身可回滚,五分钟就退了;但已经写进库里的新格式数据回不去,恢复花了四个小时。
灰度要处理的就是这类事:把"一次全量发布"切成"多次小规模暴露",再配一条随时能走通、且真的走过一次的回头路。
1. 灰度解决什么,不解决什么
| 灰度能解决 | 灰度解决不了 |
|---|---|
| 新代码的缺陷影响面可控 | 数据迁移已经执行、无法回退 |
| 性能回归在小流量下被发现 | 双写/协议不兼容导致的互斥 |
| 可量化地比较新旧版本 | 配置中心的全局开关误改 |
| 提供"停止放量"这一动作 | 依赖方的配额、证书、DNS 变更 |
**灰度的前提是新旧版本能同时活着。**如果这次变更要求"库表结构只能有一套",灰度就不是可选项,而是必须先做兼容改造。判断顺序是:先问"旧版本能不能和新的共存",再问"按什么维度切"。
2. 分层方式与实现
| 维度 | 实现 | 优点 | 风险 |
|---|---|---|---|
| 实例比例 | nginx upstream 权重、K8s 副本数 | 简单、无需改代码 | 同一用户命中不同版本 |
| 流量比例 | 服务网格(Istio VirtualService)、网关权重 | 精细可控 | 依赖网格覆盖度 |
| 用户/租户 | 应用层开关、请求头路由 | 体验一致、可指定内测用户 | 需实现分桶逻辑 |
| 地域/机房 | 路由规则、多集群发布 | 影响面清晰 | 跨区数据一致性 |
# nginx 权重灰度:10% 流量走新版本
upstream order_api {
server 10.0.0.11:8080 weight=9; # 旧版本
server 10.0.0.12:8080 weight=1; # 新版本
keepalive 64;
}
# Istio:按权重切流
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata: {name: order}
spec:
hosts: [order]
http:
- route:
- destination: {host: order, subset: v1}
weight: 90
- destination: {host: order, subset: v2}
weight: 10
实例比例 vs 流量比例的差别在会话:按实例切,同一用户会来回命中两个版本,如果新版本改了返回结构或本地缓存格式,表现就是"时好时坏"。要求请求级体验一致时,必须按用户分桶。
3. 蓝绿与金丝雀
| 维度 | 蓝绿 | 金丝雀 |
|---|---|---|
| 切换粒度 | 一次性全量切换 | 按比例渐进 |
| 资源成本 | 两套完整环境 | 少量新实例 |
| 回滚速度 | 切回路由,秒级 | 停止放量并回收少量实例 |
| 风险暴露 | 切换瞬间 100% | 线性增长 |
| 适用 | 版本差异大、需整体切换 | 长尾风险、需要观察期 |
选择标准很直接:**如果回滚只能靠"切回整环境",蓝绿更稳;如果能接受同一时刻两个版本共存,金丝雀更省。**两者不冲突,常见形态是"新版本先金丝雀放量到 1%,稳定后转蓝绿整体切换"。
4. 观察窗口看什么
| 观察项 | 指标形态 | 与谁比 |
|---|---|---|
| 错误率 | 5xx / 业务失败率 | 同期旧版本基线 |
| 延迟分位 | p95/p99 | 旧版本同分位 |
| 业务指标 | 下单成功率、支付转化 | 发布前 24 小时同时段 |
| 日志异常量 | 异常类名计数、WARN 速率 | 旧版本实例 |
| 资源 | 连接池使用、队列深度 | 旧版本实例 |
# 新旧版本错误率对比(按 version 标签分组)
sum by (version) (rate(http_server_requests_seconds_count{status=~"5.."}[5m]))
/ sum by (version) (rate(http_server_requests_seconds_count[5m]))
基线必须是同一时刻的旧版本,不是"昨天的全局平均"。整体流量有日周期,拿昨天的数当基线,等于把一个趋势变化误判成版本问题。同时注意样本量:1% 流量下五分钟可能只有几十个请求,错误率的置信区间很宽——放量太慢其实也测不出问题。
5. 回滚的三种形态
| 形态 | 动作 | 耗时量级 | 风险 |
|---|---|---|---|
| 切流量 | 权重归零、路由改回 | 秒级 | 新版本写入的数据被旧版本读到 |
| 回滚镜像 | 重新部署旧 tag | 分钟级 | 镜像与配置、依赖版本不匹配 |
| 回滚数据 | 反向脚本、从备份恢复 | 小时级 | 回滚期间写入丢失、脚本本身有 bug |
# 回滚前先确认:当前部署的版本与实际流量版本
$ kubectl -n prod get deploy order -o jsonpath='{.spec.template.spec.containers[0].image}'
$ kubectl -n prod rollout undo deploy/order --to-revision=REV
$ kubectl -n prod rollout status deploy/order --timeout=180s
顺序永远是"先止血再查因":切流量不需要理解故障,镜像回滚需要版本号准确,数据回滚需要判断依据。事故现场最贵的成本是决策时间,所以把前两种做成一条命令或一个按钮。
6. 回滚前必须验证的四件事
| 验证项 | 具体问题 | 验证方式 |
|---|---|---|
| 数据结构兼容 | 旧版本读新版本写的行会不会崩 | 用旧版本连一份含新数据的库跑冒烟 |
| 配置向后兼容 | 旧版本能否解析新配置项 | 配置项用"未知忽略"策略,实测一次 |
| 缓存格式 | 缓存里的新格式旧版本能否处理 | 版本前缀化 key,或发布时切 namespace |
| 消息格式 | 新旧消费者能否共存 | schema 兼容规则 + 双消费测试 |
# 缓存 key 加版本前缀:新版本读 acct:v2:*, 旧版本继续读 acct:v1:*
cache:
key-prefix: "acct:v2:"
ttl: 30m
第三条最容易漏:缓存不是数据库,没人给它做兼容评审。旧版本读到新格式的 JSON 反序列化失败时,表现是"缓存穿透 → DB 被打满",看起来像数据库问题。
7. 数据库变更的兼容策略
| 阶段 | 动作 | 是否可回滚 |
|---|---|---|
| 1 | 加可空列、加索引(前后兼容) | 是 |
| 2 | 双写新旧字段 | 是 |
| 3 | 读切到新字段、回填历史数据 | 是(回切读旧字段) |
| 4 | 停止双写 | 是(恢复双写) |
| 5 | 删除旧列 | 否,需备份 |
-- 阶段 1:只加可空列,旧版本完全无感
ALTER TABLE orders ADD COLUMN channel_code VARCHAR(16) NULL;
-- 阶段 5:删列之前必须先确认没有旧版本实例在跑
SELECT COUNT(*) FROM information_schema.processlist WHERE info LIKE '%channel_code%';
核心原则是**"扩展 → 迁移 → 收缩"三段式**:任何一次发布只跨一段,代码与结构永远有一层冗余。把三步压进一次发布的直接后果是——灰度期间新旧版本必须同时理解两种结构,而这通常没人测。
8. 自动化门禁
# 放量前的自动对比:任一条件不满足就停止放量
canary-gate:
interval: 60s
abort_on:
- metric: error_rate
condition: "> baseline * 2 + 0.005"
- metric: p99_latency
condition: "> baseline * 1.5"
- metric: business_order_success
condition: "< baseline * 0.98"
steps: [1, 5, 25, 50, 100]
| 要点 | 说明 |
|---|---|
| 相对阈值而非绝对值 | 不同接口基线差异大 |
| 含业务指标 | 只看技术指标抓不住"成功但算错" |
| 每步有最小观察时长 | 太短等于没观察 |
| 失败即自动停 | 停比退更快,先停再判断 |
| 门禁本身要有测试 | 门禁失效和没有门禁一样 |
门禁的价值不是取代人,而是把"要不要继续放量"从一次会议变成一个不需要讨论的判断。
9. 排错:八种灰度事故
| 现象 | 原因 | 处理 |
|---|---|---|
| 灰度实例拖垮数据库 | 与正式实例共享连接池,新版本连接数翻倍 | 独立连接池上限,或按比例压低 |
| 功能正常但慢 | 只测功能不测性能 | 观察窗口强制看 p99 |
| 回滚脚本从未跑通 | 只在文档里存在 | 每次发布前演练一次回滚 |
| 灰度期间配置漂移 | 有人手动改了 configmap | 配置纳入版本控制,发布时比对 diff |
| 新旧版本互相打回 | 缓存或状态格式不兼容 | 版本前缀化、schema 兼容 |
| 放量后指标"看起来正常" | 采样或聚合掩盖问题 | 同时看实例级与业务指标 |
| 回滚后仍错误 | 数据已按新格式写入 | 数据回滚方案必须先写 |
| 门禁没拦住 | 基线取自全量而非旧版本 | 修正对比口径 |
第一条在 Spring Boot 项目里极常见:两个版本共用同一个 HikariCP 上限配置,但灰度实例数量少、单实例流量集中,看起来"没问题",直到池被打满。容量评估要按"每实例"算,不是按"整体"算。
10. 边界:灰度替代不了的事
| 场景 | 为什么不适用 | 替代 |
|---|---|---|
| 一次性数据订正 | 数据不能"回滚一部分" | 备份 + 幂等脚本 + 对账 |
| 不可逆协议变更 | 新旧无法共存 | 双协议过渡期 |
| 强一致性的分布式变更 | 半量会出现中间态 | 停机窗口或两阶段方案 |
| 第三方配额/DNS/证书 | 不在你的流量控制内 | 变更前确认、预留回退窗口 |
一句话:**灰度控制的是"暴露量",控制不了"不可逆性"。**凡是不可逆的变更,需要的是备份与演练,不是流量比例。
11. 自测
- 按实例比例灰度与按用户分桶,在会话一致性上有什么差别?
- 为什么观察基线必须是"同一时刻的旧版本",而不是昨天的全局数据?
- 三种回滚形态里哪一种最快,为什么事故现场应该优先用它?
- 缓存格式变更若不加版本前缀,回滚后会看到什么现象?
- 数据库"扩展 → 迁移 → 收缩"中,为什么不能让一次发布跨两段?
- 灰度期间为什么容量要按每实例估算?共享连接池会带来什么后果?