如何做一个接近零停机的 HTTP 服务
Posted on 五 08 5月 2026 in Tech
| Abstract | 如何做一个接近零停机的 HTTP 服务 |
|---|---|
| Authors | Walter Fan |
| Category | Tech |
| Status | v1.0 |
| Updated | 2026-05-08 |
| License | CC-BY-NC-ND 4.0 |
短大纲
展开看看
- **核心观点**:零停机不是系统永不故障,而是故障发生时,用户尽量看不见。 - **基本架构**:两个集群 active-active,对外由 Global Edge / Gateway 统一接入。 - **止血手段**:短超时、每请求跨集群重试、被动故障检测、熔断和慢启动。 - **安全边界**:GET 可以自动重试,POST 必须靠 `Idempotency-Key` 兜底。 - **状态要求**:应用尽量无状态,会话、幂等记录、数据库写入和后台任务要跨集群设计。 - **落地清单**:给出默认参数、健康检查设计和上线前检查卡。正文
线上服务最尴尬的时刻,不是机器真的坏了。
机器坏了,至少事情很明确。真正让人头大的是:某个集群开始半死不活,偶尔超时,偶尔 502,监控刚抬头,客户已经截图发来了。你看着仪表盘,心里默念:“再给健康检查五秒钟,它应该能发现。”可用户不会等你的健康检查。
所以我对“零停机”的理解比较朴素:不是让系统永不失败,而是让失败尽量被挡在用户看见之前。
如果是 HTTP 服务,最实用的一套打法是:
active-active traffic
+ fast request timeout
+ retry to another cluster
+ circuit breaker
+ shared idempotency state
+ stateless app design
一句话,健康检查负责最终把坏集群摘掉,每请求 failover 负责把故障窗口藏起来,幂等负责让重试不会把业务做两遍。
这篇文章就讲一个具体场景:Cluster A 和 Cluster B 两套集群同时对外服务,边缘层按请求做重试和故障转移,目标是把用户可见错误降到最低。
先看架构:两套集群都在干活
不要一上来就做 active-passive。
active-passive 看起来简单:A 平时干活,B 平时待命。问题是,B 长期不吃真实流量,一到真出事,大家才发现它证书过期、缓存没热、配置少了一行、数据库权限不对。备胎系统最大的问题是,平时太像备胎,关键时刻也容易像备胎。
更实用的方式是 active-active:
┌──────────────┐
Client ───────▶ │ Global Edge │
│ LB / Gateway │
└──────┬───────┘
│
┌──────────────┴──────────────┐
▼ ▼
┌──────────────┐ ┌──────────────┐
│ Cluster A │ │ Cluster B │
│ Local LB/API │ │ Local LB/API │
└──────────────┘ └──────────────┘
正常状态:
Cluster A: 50%
Cluster B: 50%
Cluster A 不健康时:
Cluster A: 0%
Cluster B: 100%
这要求全局入口层具备几类能力:
| 能力 | 解决的问题 |
|---|---|
| Weighted load balancing | 正常状态下按权重分流,例如 50/50 |
| Active health check | 定期探测 /ready,判断集群是否能接流量 |
| Passive failure detection | 根据真实请求的超时、reset、5xx 判断异常 |
| Retry to alternate cluster | 当前请求失败时,尝试另一个集群 |
| Circuit breaker / outlier ejection | 某个集群连续异常后,临时摘除 |
| Request timeout control | 控制每次尝试和整体请求的时间预算 |
可选的边缘层很多,云厂商和自建网关都有成熟方案,例如 AWS Global Accelerator + ALB/NLB、Cloudflare Load Balancing、Akamai GTM、GCP Global External HTTP(S) LB、Azure Front Door、Envoy / Istio Gateway、HAProxy / NGINX Plus 等。具体选哪一个,看团队已有基础设施和运维能力,不必为了“高大上”重造一套轮子。
不要只相信健康检查
健康检查很有用,但它不是神仙。
一个常见配置是:
health check interval: 5s
unhealthy threshold: 3
这意味着最坏情况下,边缘层可能要 15 秒左右才确认某个集群不健康。15 秒在架构图上很短,在用户面前很长。一个登录接口卡 15 秒,用户不会说“你们的故障检测窗口设计合理”,他只会刷新、投诉,或者换产品。
所以要同时使用两种信号:
Active health check:
周期性访问 /health 或 /ready。
Passive health check:
边缘层观察真实请求的失败、超时、连接重置和 5xx 峰值。
一个更贴近生产的流程是:
Cluster A 开始超时:
1. 当前请求快速失败。
2. Edge 把符合条件的请求重试到 Cluster B。
3. Edge 增加 Cluster A 的失败分。
4. 达到阈值后,Cluster A 被临时摘除。
5. 健康检查继续探测,恢复后再逐步放流量。
这样,停机窗口不再完全取决于“健康检查间隔 × 阈值”,而是更接近“一次快速失败 + 一次跨集群重试”的时间。
超时要短,而且要分层
很多 failover 方案看起来没效果,罪魁祸首不是没有重试,而是第一次尝试等太久。
如果上游超时是 30 秒,客户端超时也是 30 秒,那边缘层即使有重试能力,也没有时间重试。就像你约了两辆出租车,第一辆迟到半小时才想起叫第二辆,面试早结束了。
更合理的设计是短超时、分层超时:
TCP connect timeout: 200-500ms
TLS handshake timeout: 500ms-1s
upstream request timeout: 1-2s for normal APIs
total request timeout: 3-5s
retry budget: 1 retry to another cluster
关键规则只有一句:
第一次尝试必须失败得足够快,第二次尝试才有机会成功。
举个例子:
client timeout: 5s
edge total timeout: 4s
attempt 1 to Cluster A:
timeout after 1s
retry to Cluster B:
allowed up to 2s
edge still has time to return a successful response
当然,不是所有 API 都能用 1 秒超时。报表导出、视频转码、批量任务提交,这类接口本来就不该被设计成同步等待到底。它们更适合异步任务模型:先返回 task id,再由客户端轮询或服务端推送结果。
零停机不是用网关掩盖所有慢接口。慢接口要从 API 设计上治。
重试不是越多越好
重试是稳定性工具,也是放大器。
用得好,它挡住一次短暂故障;用不好,它把一个小毛病放大成雪崩。尤其是跨集群 active-active 场景,最怕所有客户端、网关、服务内部都在重试,大家一起“热心帮忙”,最后把唯一健康的集群也打趴下。
我的默认建议是:
max retries: 1
retry target: different cluster only
retry backoff: 20-100ms jitter
retry budget: max 5-10% of total traffic
这里有两个要点。
第一个,只重试一次。如果一次跨集群重试还失败,多半不是靠第三次、第四次能救的。继续重试只会占用线程、连接和队列。
第二个,只重试到另一个集群。Cluster A 正在失败,你再打 Cluster A 一次,大概率只是把宝贵的时间窗口浪费掉。
哪些请求可以重试
重试策略的核心不是 HTTP method,而是业务语义。
可以用下面这张表作为默认规则:
| 请求类型 | 默认策略 |
|---|---|
GET / HEAD / OPTIONS |
可自动重试 |
PUT / DELETE |
只有 API 明确幂等时才重试 |
POST |
只有带 Idempotency-Key 时才重试 |
常见可重试失败:
- connection refused
- connection reset
- upstream timeout
- HTTP 502
- HTTP 503
- HTTP 504
通常不要重试:
- HTTP 400
- HTTP 401
- HTTP 403
- HTTP 404
- HTTP 409
- HTTP 422
- 已经完成但副作用未知的请求,除非有幂等保护
这里最危险的是 POST。比如创建订单、扣款、发券、开通权限,这些操作一旦执行两次,系统就不是零停机了,是零理智。
所以,凡是有业务副作用的接口,都要认真设计幂等。
幂等:让重试不变成重复扣款
对写请求来说,Idempotency-Key 是零停机方案里最不起眼、也最要命的一块。
客户端发起请求:
POST /api/orders
Idempotency-Key: 01J9Z7S3H5VZ9XK8FZ2M
Content-Type: application/json
服务端存一条幂等记录:
idempotency_key
request_hash
operation_name
tenant_id / user_id
status
response_code
response_body
created_at
expires_at
处理流程可以这样设计:
1. 收到带 Idempotency-Key 的请求。
2. 计算 request hash。
3. 尝试插入幂等记录,status=PROCESSING。
4. 如果插入成功:
执行业务操作。
保存最终响应。
返回响应。
5. 如果 key 已存在:
比较 request hash。
如果 hash 不同,返回 409 Conflict。
如果 status=SUCCESS/FAILED,返回已保存的响应。
如果 status=PROCESSING,短暂等待,或返回 409/425/202,取决于 API 语义。
一个简化版表结构大概长这样:
CREATE TABLE api_idempotency (
idempotency_key VARCHAR(128) NOT NULL,
tenant_id VARCHAR(64) NOT NULL,
operation_name VARCHAR(64) NOT NULL,
request_hash CHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL,
response_code INT,
response_body JSON,
created_at TIMESTAMP NOT NULL,
expires_at TIMESTAMP NOT NULL,
PRIMARY KEY (tenant_id, operation_name, idempotency_key)
);
注意,tenant_id 和 operation_name 通常要进入唯一键。否则不同租户、不同操作之间可能误伤。request_hash 也不是可有可无,它用来防止同一个 key 搭配不同请求体,被系统错误复用。
幂等能挡住几类真实问题:
- edge retry after timeout
- client retry
- duplicate POST from network failure
- cluster failover during write
但有一个硬要求:幂等存储必须跨集群共享。
可选方案有:
- globally replicated database table
- strongly consistent primary database
- Redis with cross-cluster replication, if consistency is acceptable
如果是支付、权限、安全配置这类高价值操作,我更倾向于数据库幂等表,而不是只靠最终一致的缓存。缓存可以快,钱和权限不能“差不多”。
状态设计:active-active 最怕“本地真相”
很多 active-active 方案最后失败,不是因为流量切不过去,而是状态切不过去。
要让两个集群都能接同一个请求,至少要满足这些条件:
- both clusters can serve the same hostname
- both clusters have valid TLS certs
- app instances are stateless
- sessions are shared or token-based
- idempotency records are shared
- database writes are safe under retry
- background jobs are leader-elected or partitioned
反过来,下面这些设计会让 failover 变得很脆:
- sticky sessions required for correctness
- cluster-local cache as source of truth
- cluster-local idempotency table
- duplicate scheduled jobs running in both clusters without locks
会话最好用 JWT 或其他无状态 token。确实需要服务端 session,也要放到跨集群共享的 session store 中,并明确一致性要求。
缓存只能是缓存,不能是事实来源。这个原则听起来像废话,但产线里很多事故就是从“我们以为缓存里一定有”开始的。缓存一旦成为事实来源,切流量时就会出现玄学问题:A 集群知道,B 集群不知道,用户夹在中间像参加猜谜节目。
后台任务也要特别小心。两个集群 active-active,不代表定时任务也能随便跑两份。清算、发邮件、发券、数据同步,都要用 leader election、分片、分布式锁或任务队列来约束。
健康检查:活着不等于能接流量
健康检查至少要拆成两个端点:
/live
Process is alive.
Used by local orchestrator.
/ready
Instance can serve traffic.
Used by load balancers.
/live 回答“进程是不是还活着”。/ready 回答“现在能不能接真实流量”。这两个问题不能混在一起。
/ready 可以检查:
- database connectivity
- required cache connectivity
- downstream critical services
- migration/version compatibility
- local app warm-up complete
但 readiness 也不能太脆。
如果一个非关键推荐服务挂了,就把整个订单服务摘掉,可能反而扩大故障。readiness 应该关注“没有它就无法正确服务”的依赖,例如:
/ready returns unhealthy if:
- DB unavailable
- app cannot authenticate requests
- required secrets/config missing
- local server is overloaded beyond threshold
一句话,readiness 不是全家桶体检报告,而是“我现在接真实流量会不会害人”的判断。
熔断和慢启动:摘掉坏的,温柔地放回来
边缘层要能根据真实请求快速摘除异常集群。
一个参考策略:
consecutive 5xx: 5
consecutive gateway failures: 3
success rate below: 80%
ejection time: 30s
max ejection percent: 100%
recovery: slow start over 1-5 minutes
流程大概是:
Cluster A starts failing
↓
Edge sees timeouts/502/503
↓
Retry eligible requests to Cluster B
↓
Cluster A gets temporarily ejected
↓
Health checks continue
↓
Cluster A recovers
↓
Traffic ramps back gradually
这里“慢启动”很重要。刚恢复的集群,不要立刻吃回 50% 流量。它可能缓存还是冷的,连接池还没建好,JIT 还没热,甚至某些依赖刚刚恢复。慢慢放量,就像病人刚出院,别马上拉去跑半马。
三个请求怎么走
正常请求:
Client → Global Edge → Cluster A → 200 OK
Cluster A 超时,但请求可重试:
Client → Global Edge → Cluster A
timeout after 1s
→ Global Edge retries → Cluster B → 200 OK
← Client receives 200 OK
带幂等键的 POST:
Client → POST /payment Idempotency-Key: abc123
→ Global Edge → Cluster A
timeout after 1s
→ retry → Cluster B
→ Cluster B checks idempotency table
→ returns stored result or safely completes operation
第三个场景最值得反复演练。因为读请求失败,最多用户刷新一下;写请求做错,可能要客服、财务、合规一起陪你过周末。
推荐默认参数
下面这份默认值不是圣旨,但可以作为第一版配置的起点:
Routing:
active-active 50/50
Health check:
interval: 5s
timeout: 1s
unhealthy threshold: 2-3
healthy threshold: 2
Retry:
max retries: 1
retry to alternate cluster only
retry on: connect failure, reset, 502, 503, 504
retry GET/HEAD by default
retry POST only with Idempotency-Key
Timeouts:
connect timeout: 300ms
upstream response timeout: 1-2s
total edge timeout: 4s
client timeout: 5s+
Circuit breaker:
eject after 3-5 consecutive gateway failures
ejection duration: 30s
slow start after recovery: 1-5min
真正上线前,要用压测和故障演练验证这些数字。不同业务的 P99 延迟、数据库写入时延、下游依赖情况都不一样,直接复制参数只能算“起步”,不能算“负责”。
常见坑
1. 只做双集群,不做跨集群重试
这叫“架构图高可用”,不是用户体验高可用。
健康检查摘除集群之前,用户仍然会撞到坏集群。没有每请求 failover,就只能等检测窗口过去。
2. POST 没有幂等,还敢自动重试
这是典型的稳定性方案把业务搞坏。重试不是免费的,写请求一定要先设计幂等。
3. 所有层都在重试
客户端重试、Edge 重试、Service A 重试、SDK 重试、数据库驱动还重试。每一层都觉得自己在救火,最后一起往火里倒汽油。
要有统一的 retry budget,明确谁重试、重试几次、哪些错误能重试。
4. Readiness 检查太重
/ready 每次都查十几个下游,任何一个小依赖抖一下就摘流量。这样不是健康检查,是故障制造机。
5. 恢复后立刻全量放流
坏集群刚好,马上打满流量,很容易二次故障。慢启动不是保守,是对系统恢复过程的尊重。
上线前自查卡
最后给一张可以直接抄走的检查卡。
| 检查项 | 问题 |
|---|---|
| 流量入口 | 是否有统一 Global Edge / Gateway?是否支持按集群权重分流? |
| 健康检查 | 是否区分 /live 和 /ready?readiness 是否只检查关键依赖? |
| 被动检测 | 是否根据真实请求的 timeout/reset/5xx 快速降权或摘除? |
| 超时预算 | 第一次尝试失败后,是否还留有足够时间重试另一个集群? |
| 重试策略 | 是否限制 max retries=1?是否只重试到另一个集群? |
| Retry budget | 重试流量是否有上限,避免雪崩? |
| 幂等设计 | POST / 写操作是否强制 Idempotency-Key? |
| 幂等存储 | 幂等表是否跨集群共享?是否校验 request hash? |
| 会话状态 | session 是否无状态或跨集群共享? |
| 数据一致性 | 数据库写入是否能承受超时后的重试和重复请求? |
| 后台任务 | 定时任务是否有 leader election、分片或锁? |
| 证书配置 | 两个集群是否都能服务同一个 hostname 和 TLS 证书? |
| 观测指标 | 是否按 cluster 维度观测 QPS、错误率、超时率、重试率、熔断次数? |
| 演练 | 是否做过单集群断网、5xx 注入、慢响应、数据库抖动演练? |
| 安全与隐私 | 日志和错误响应是否避免泄露 token、用户隐私和幂等请求体? |
明天就能做的三件事
如果现在还没有完整方案,不妨先做三件小事:
- 给所有关键 HTTP API 梳理一遍:哪些能重试,哪些必须加
Idempotency-Key。 - 在网关上把超时拆开:connect timeout、upstream timeout、total timeout,不要一个 30 秒打天下。
- 做一次小型演练:让 Cluster A 对某个接口连续超时,观察 Edge 能不能把请求重试到 Cluster B。
很多稳定性工程不是一口气建成罗马,而是先把最危险的洞补上。
零停机服务也是如此。它不是某个神奇组件,也不是一张漂亮架构图。它是一组朴素但严格的约定:流量能切,失败能快,重试有界,写入幂等,状态共享,恢复慢放。
无他,提前把失败当成正常路径设计。