压测、灰度发布与故障演练
本场景回答:一次高并发变更如何从基线开始,经过压测、影子验证、灰度和回滚,最终用故障演练和验收记录证明它不会把发布本身变成新故障。
1. 先定义发布前后的可比较基线
没有基线,“压测通过”“灰度正常”都只是主观判断。发布前先选择业务 SLO、正确性口径和容量余量,记录同一时间窗口、同一数据规模、同一依赖状态下的结果。
至少保留以下基线:
| 类别 | 指标示例 | 记录方式 |
|---|---|---|
| 业务 | 成功率、错误类型、端到端完成率 | 按接口和租户分组 |
| 性能 | P50、P95、P99、吞吐量、饱和点 | 固定负载模型和采样窗口 |
| 资源 | CPU、内存、连接数、线程池、磁盘 IO | 同实例规格和副本数 |
| 数据 | 读写延迟、复制差、锁等待、积压 | 分主库、消息和任务 |
| 容错 | 依赖超时率、熔断次数、降级命中 | 同一故障场景和采样周期 |
基线表应能回答“哪项指标变坏到什么程度就会停止发布”。不要把阈值写成普适常数,应由当前 SLO、容量模型和业务方共同确认。
指标:下单接口 P99 延迟
当前基线:以同一时间窗口的历史分位数填写
停止条件:超过已批准的 SLO 或相较基线超过批准比例
恢复条件:连续满足批准的观察窗口且无错误累积
证据:发布记录链接、监控面板、日志查询、对账任务2. 准备可复现的压测环境和数据
2.1 先隔离依赖,再放大流量
压测可能把数据库、第三方支付、短信、消息队列和对账服务一起打满。每次压测前明确三类依赖:
- 必须测真实依赖:连接池、SQL 执行计划、Broker 确认、缓存穿透;
- 可替换依赖:用等量数据或受控 stub 替代外部支付、短信和推送;
- 必须保护依赖:为生产共享服务设置独立账号、限流和熔断。
测试环境不应直接写入真实支付、短信或库存副作用。对必须验证链路的请求使用测试商户、测试手机号或明确的沙箱账号,并在报告中写清哪些链路没有经过真实第三方。
2.2 生产等量数据
只准备少量示例数据会掩盖索引、分片热点和缓存未命中问题。测试数据集至少覆盖:
- 与生产接近的总行数和分片分布;
- 热点用户、长尾用户和跨页查询;
- 正常订单、过期订单、退款订单和异常状态;
- 空缓存、过期缓存、缓存击穿和缓存不同步;
- 消息积压、死信重放和重复消费样本;
- schema 变更前后同时可读的过渡数据。
生成数据后用统计信息确认分布,而不是只看总行数:
SELECT
COUNT(*) AS row_count,
MIN(created_at) AS earliest,
MAX(created_at) AS latest,
COUNT(DISTINCT user_id) AS user_count
FROM orders;
EXPLAIN ANALYZE
SELECT order_no, status, created_at
FROM orders
WHERE user_id = 100001
AND created_at >= UTC_TIMESTAMP(6) - INTERVAL 7 DAY
ORDER BY created_at DESC
LIMIT 20;压测前保存 EXPLAIN ANALYZE 结果和统计信息版本,发布后在相同数据快照上重复执行。仅比较平均耗时会漏掉缓存命中变化和扫描行数变化。
2.3 让流量可识别、可清理
全链路压测应携带标记,并保证下游可以区分测试流量:
traceparent: W3C Trace Context
X-Load-Test: true
X-Release-Id: 2026.10.11-build123
X-Test-Data-Set: perf-20261011标记要贯穿 API、日志、消息和数据库记录。测试记录使用独立前缀或租户 ID,并在执行前确认清理任务只删除当前批次,不按“所有测试数据”粗暴删除。
CREATE TABLE release_test_runs (
run_id CHAR(36) NOT NULL,
release_id VARCHAR(64) NOT NULL,
dataset_name VARCHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL,
started_at DATETIME(6) NOT NULL,
finished_at DATETIME(6) NULL,
PRIMARY KEY (run_id),
INDEX idx_release_status (release_id, status)
) ENGINE=InnoDB;3. 执行全链路压测
3.1 先做单组件校准
全链路压测不应成为发现问题的唯一机会。按依赖逐层执行:
- 单接口验证参数、鉴权、超时和错误码;
- 数据库专项验证连接池、慢查询、锁等待和复制;
- 缓存专项验证空缓存、热点键和失效风暴;
- 消息专项验证生产确认、消费重试、积压与死信;
- 全链路组合验证排队、背压、降级和恢复。
每层先从低负载开始,逐步增加并发,找到第一个饱和点。记录吞吐量不再增长、P99 开始陡增、错误开始出现的负载,而不是只报告最大 QPS。
3.2 在同一负载模型下做对比
发布前与发布后使用相同的数据集、客户端实例数、请求分布和观察窗口。压测脚本至少能控制:
# 压测计划片段;具体工具由团队选择
scenario: checkout
dataset: perf-20261011
mix:
browse: 70
checkout: 25
query_order: 5
ramp:
- duration: 5m
target: 300
- duration: 10m
target: 900
stop_on:
- error_rate_above_approved_slo
- p99_above_approved_slo
- database_connections_saturated压测报告必须同时给出:
- 峰值吞吐量和稳定吞吐量;
- P50、P95、P99 和最大值;
- 每个依赖的超时率和错误率;
- 数据库连接池、等待线程和锁等待;
- 队列最深处、消费速率和恢复到基线的时间;
- 客户端与服务端时间戳,区分服务慢和测试机慢。
发现错误时先停止继续加压,保留现场:部署版本、配置版本、数据集、监控窗口、日志关联 ID、线程或连接池快照。否则复现时数据和流量已改变。
3.3 验证背压和优雅停机
流量超过系统能力时,预期行为应是拒绝、排队或降级,而不是让连接池耗尽并连锁失败。验证以下动作:
- 入口限流是否触发;
- 下游超时是否短于上游预算;
- 熔断打开后健康请求是否能恢复;
- 队列停止入队后消费者是否继续消化;
- 实例收到终止信号后是否停止接新请求并完成在途请求;
- 恢复流量后是否出现瞬时重试风暴。
4. 用影子流量验证新逻辑
影子流量只用于比较,不应产生真实副作用。适合用于搜索排序、推荐打分、只读查询和不调用外部系统的数据加工;不适合直接调用支付、发券、扣库存、发短信或写入正式数据库。
影子链路必须满足:
正式请求 ─┬─▶ 原版本 ─▶ 正式响应
└─▶ 新版本 ─▶ 影子结果 ─▶ 仅记录差异执行前逐项检查:
- 影子写入被重定向到专用表或完全禁用;
- 外部副作用被隔离或替换为可控 stub;
- 影子结果不返回给用户,也不参与后续业务分支;
- 影子失败不影响正式请求;
- 影子日志包含 release ID 和原请求关联 ID;
- 对比器能区分字段差异、顺序差异和允许的非确定性字段。
示例比较记录:
{
"request_id": "req-20261011-001",
"baseline_release": "build122",
"candidate_release": "build123",
"matched_fields": ["price", "stock", "status"],
"differences": [
{"field": "recommendation_rank", "baseline": 4, "candidate": 7}
],
"decision": "needs_review"
}影子流量占比和观察窗口根据流量、SLO 和差异率决定。出现资金、权限、状态或安全字段差异时,无论整体差异率多低都应停止放量并人工确认。
5. 分阶段灰度发布
5.1 确定灰度维度
优先按稳定主体维度分流,例如用户 ID 哈希、租户 ID 或单元;没有稳定主体的匿名请求可按请求 ID 或会话 ID 分流。不要按随机比例修改同一次业务请求的版本,否则重试可能在新旧逻辑间跳转。
def choose_release(user_id: int, enabled_builds: list[str]) -> str:
bucket = stable_hash64(f"{user_id}:checkout") % 10000
cursor = 0
for build in enabled_builds:
cursor += build.rollout_basis_points
if bucket < cursor:
return build.release_id
return enabled_builds[-1].release_id分流结果应记录在服务端日志或决策服务中。用户、版本、路由桶和请求关联 ID要能在同一条查询里对应起来。
5.2 先保证新旧版本兼容
灰度期间新旧实例可能同时处理同一数据,因此以下变更不能按普通版本直接放量:
- 数据库字段新增或删除;
- 消息 schema 改名或改变语义;
- 缓存 key 格式或序列化格式改变;
- 配置项从可选变为必填;
- ID 规则、状态枚举或错误码改变。
采用扩展—迁移—收缩三步:
- 新版本先读取新旧两种字段,写出新字段;
- 回填并验证旧记录,所有消费者都能读新字段;
- 经过一个完整观察窗口后移除旧字段或旧分支。
数据库变更向后兼容性要同时覆盖回滚路径。新增 NOT NULL 列必须有默认值策略,新增索引先在影子库或受控环境验证耗时,删除列和改名必须先停止读写旧路径。详细切换流程见应用平滑切换数据库。
5.3 设置自动停止条件
每个灰度阶段都预先定义停止条件,例如:
- 错误率超过 SLO;
- P99 超过批准预算;
- 数据差异、重复副作用或安全违规出现;
- 数据库连接池、复制延迟或消息积压超过保护线;
- 新版本健康实例数不足;
- 自动化回滚无法在目标时间内完成。
停止条件应由平台自动采集,而不是等值班人员“看到不太对”。每次放量前保存:
阶段:10% → 50%
新版本:build123
开始时间:
上一阶段稳定窗口:
关键指标对比:
数据差异与业务对账:
是否满足放量条件:
批准人:5.4 验证回滚能执行
发布前做一次小范围回滚演练,至少验证:
- 新实例停止接流,健康检查失效;
- 在途请求完成或按预算超时;
- 旧实例重新注册并恢复容量;
- 配置、路由和特性开关回到旧版本;
- 消息消费者、定时任务和 schema 回到兼容状态;
- 新版本产生的数据仍能被旧版本正确读取。
回滚不是“重新部署旧包”。如果新版本已经改变 schema、缓存 key、消息格式或生成数据,必须逐项列出兼容策略。无法向后兼容的变更应设计前滚修复方案,并由负责人签字。
6. 设计故障演练
故障演练验证的不是工具能注入错误,而是系统按预期隔离、降级、恢复并保留证据。
6.1 Pod 或实例故障
注入方式包括终止单个 Pod、使健康检查失败或停止实例。验证:
- 服务发现移除故障实例;
- 入口流量不再进入故障实例;
- 连接池和线程池没有泄露;
- 副本数恢复后新实例可接流量;
- 在途请求只有预期范围内失败。
6.2 依赖超时和熔断
对数据库、缓存或下游 RPC 注入超时和 5xx,验证:
- 连接等待不会超过上游预算;
- 熔断器按错误率和慢调用策略打开;
- 降级结果不改变关键业务状态;
- 恢复后半开、探测、全开过程正常;
- 熔断不会把所有请求重新压回故障依赖。
熔断阈值和恢复策略见熔断与降级。
6.3 消息积压与重试
暂停消费者或限制消费速率,验证:
- 生产者触发背压而不是无限堆积;
- 消费者扩容后积压下降;
- 重复消费不产生重复副作用;
- 重试超限进入死信并可审计;
- 恢复消费不会同时重放全部历史消息。
消息可靠性和故障注入步骤见消息可靠性闭环与消息系统高可用实战。
6.4 数据库复制延迟和切库
制造副本落后或模拟源库不可用,验证:
- 监控显示 GTID 差值和复制线程状态;
- 应用没有把未同步数据当作实时数据;
- 切换条件、冻结写入和冒烟验证能执行;
- 目标库已有业务写入时不会被错误回滚。
具体步骤见应用平滑切换数据库。区域级切流见异地多活与单元化。
6.5 网络分区
隔离两个服务实例或区域之间的一部分网络,验证:
- 连接失败是否在预算内返回;
- 租约、选主或连接是否产生脑裂;
- 重连是否带退避,不会形成重试风暴;
- 网络恢复后是否重复处理或丢弃任务;
- 单元路由不会把请求送到不可达地域。
7. 形成可审计的验收记录
一次发布至少留下以下证据:
变更单:CHG-20261011-023
代码提交与构建:
配置版本与 schema 版本:
压测数据集与计划:
发布前基线窗口:
影子差异报告:
灰度阶段与自动停止条件:
关键指标对比:
回滚演练结果:
故障演练编号:
业务对账结果:
最终结论与剩余风险:
复测日期:验收报告要把“事实、判断、待办”分开:
事实:P99 在放量阶段超过批准停止条件 1 次。
判断:根因是新增查询导致连接池等待,不属于客户端噪声。
待办:移除未命中索引的查询,修复后重新压测。没有监控、对账和复测日期的“灰度通过”,只能算观察过,不能算验证过。
8. 上线检查单
压测前
- 已记录基线、SLO 和停止条件;
- 数据规模与分布接近目标场景;
- 测试流量有标记和独立清理批次;
- 外部副作用已隔离,共享依赖有保护;
- 数据库、缓存、消息和任务已分别校准;
- 客户端与服务端时钟、关联 ID可用。
发布前
- schema、缓存 key、消息格式兼容新旧版本;
- 影子链路无真实副作用;
- 灰度按稳定主体分流;
- 自动停止与回滚条件已配置并验证;
- 回滚不会丢失新版本产生且旧版本无法读取的数据;
- 值班人员知道何时放量、何时停止、何时回滚。
发布后
- 关键指标与基线同口径比较;
- 影子差异、日志错误和业务对账已查看;
- 数据库连接、复制、锁等待和消息积压已检查;
- 故障演练项有证据和修复责任人;
- 未满足项进入复测,不以口头豁免替代记录;
- 完成一个稳定观察窗口后才清理灰度开关和测试数据。
9. 面试追问速答
Q1:全链路压测为什么不能直接压生产?
因为压测会放大数据库锁、连接池、消息重试和第三方配额风险,测试流量还可能触发真实支付、短信或库存。生产压测必须先隔离副作用、保护共享依赖、标记测试数据,并按批准的停止条件逐步放量;不能用“先压一下看看”替代风险评估。
Q2:灰度比例按什么分流最合理?
优先按稳定的用户、租户或会话哈希分流,让同一次业务请求和重试都落在同一版本。随机按请求分流会导致新旧逻辑交替处理同一实体;按地域分流则要先保证地域内数据归属和复制状态一致。比例只是控制影响面的参数,不能代替停止条件。
Q3:影子流量什么时候不能用?
凡是会产生副作用的链路都不能直接做影子,包括支付、扣库存、发券、短信、写正式数据库和更新全局状态。必须重定向到专用存储或替换为可控 stub;影子失败也不能影响正式请求。若无法证明无副作用,应改为只读接口或合成请求。
Q4:为什么数据库 schema 变更最难回滚?
删除列、改名、改变类型和约束会让旧版本读取或写入失败。扩展—迁移—收缩能保留回滚窗口:先兼容读写,再回填和验证,最后移除旧结构。发布前必须验证新版本写入的数据仍可被旧版本处理,否则回滚可能变成数据恢复。
Q5:故障演练只验证服务不崩吗?
不够。还要验证故障隔离、降级、重试、恢复和数据一致性,并记录告警到恢复的时间、错误范围、停止条件和修复项。单个 Pod 重启成功,不代表依赖超时、消息积压、数据库复制和网络分区也能按预期恢复。
Q6:压测结果很好,为什么发布后还是变慢?
常见原因是数据分布、缓存温度、配置、并发连接或依赖状态与压测不同。必须让压测和线上指标同口径比较,检查索引执行计划、连接池、GC、线程池和下游延迟,再通过小范围灰度复现。不要用平均值掩盖 P99 和尾部阻塞。
10. 相关章节
故障隔离和优雅停机见高可用设计,依赖超时后的降级见熔断与降级,消息链路的不丢、不重和死信验证见消息可靠性闭环,数据库切流和回滚见应用平滑切换数据库。