我不会起名字322 · 后端 / 算法 / 数据库
文章目录
- [微服务链路超时怎么设:逐层收敛的 4 条规则与 1 个反例](#微服务链路超时怎么设:逐层收敛的 4 条规则与 1 个反例)
-
- 一、先看一个反例:超时"倒挂"
- [二、规则 1:超时必须逐层收敛](#二、规则 1:超时必须逐层收敛)
- [三、规则 2:所有重试共享同一个总 deadline](#三、规则 2:所有重试共享同一个总 deadline)
- [四、规则 3:每一跳都要设超时,包括数据库](#四、规则 3:每一跳都要设超时,包括数据库)
- [五、规则 4:超时值要基于实测,不是拍脑袋](#五、规则 4:超时值要基于实测,不是拍脑袋)
- 六、超时之外:还要有熔断和降级
- 七、还有一条:预留"取消传播"
- 八、带走物
微服务链路超时怎么设:逐层收敛的 4 条规则与 1 个反例
一、先看一个反例:超时"倒挂"
这是一个真实存在的错误配置:
text
用户端超时 :3s
网关超时 :30s
服务 A → B 超时 :10s
服务 B → DB 超时 :无(连库不设超时)
问题在哪:
- 用户 3 秒就放弃了,但网关还在等 30 秒 → 用户看到失败,服务端还在跑,白白消耗资源;
- B 查 DB 没有超时 → 慢查询会把 B 的 goroutine 全部挂住;
- 整体是**"外层小、内层大"的倒挂结构**------越靠内层的超时越大,甚至没有。
后果就是:499 满屏、线程池打满、雪崩。 用户的每次放弃,都留下一串还在工作的调用。
二、规则 1:超时必须逐层收敛
正确结构是越靠内层越小:
text
用户端 10s
CDN / SLB 12s
网关 15s
服务 A 12s(它留给下游的时间)
→ 服务 B 8s
→ 服务 C 5s
→ 数据库 3s
→ Redis 1s
原则:每一层的超时,必须小于"它的调用方给它的时间",并且大于"它所有下游串行调用的时间之和"。
数学表达:
text
T(本层) < T(上游) - 网络开销 - 余量
T(本层) > Σ T(下游串行调用) + 本层计算时间
如果第二条算不过来(下游串行加起来超过上游给的时间),说明这条链路的设计有问题,需要:
- 把串行调用改成并行;
- 或者缩短某个下游的超时;
- 或者重新评估上游的超时是否合理。
三、规则 2:所有重试共享同一个总 deadline
这是最容易违反的一条。看这段"看起来很合理"的代码:
go
// ❌ 错误:每次重试都用新的 3 秒,总耗时可能 9 秒
for i := 0; i < 3; i++ {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
resp, err := client.GetOrder(ctx, req)
cancel()
if err == nil {
return resp, nil
}
time.Sleep(backoff)
}
问题 :上游给的总时间如果是 5 秒,这段代码最多会用 9 秒。上游早就超时放弃了,你还在重试。
正确写法:所有重试都用同一个 ctx(从上游传下来的那个):
go
// ✅ 正确:ctx 带着上游的总 deadline,重试不会延长它
for i := 0; i < 3; i++ {
resp, err := client.GetOrder(ctx, req) // 同一个 ctx
if err == nil {
return resp, nil
}
if !isRetryable(err) {
return nil, err
}
select {
case <-time.After(backoff):
case <-ctx.Done():
return nil, ctx.Err() // 总 deadline 到了,立刻放弃
}
}
return nil, err
select 里监听 ctx.Done() 是必须的------否则退避期间上游已经放弃,你还在傻等。
四、规则 3:每一跳都要设超时,包括数据库
"漏设超时"比"设错超时"更常见,也更危险。
Go 里最容易漏的几处:
go
// ❌ 没有超时:慢查询会把连接和 goroutine 全部挂住
db.Query("SELECT ...")
rdb.Get(ctx, key) // 这个有 ctx,但 ctx 可能没有 deadline
http.Get(url) // http.Client 默认无超时!
// ✅ 都要带超时
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
db.QueryContext(ctx, sql)
rdb := redis.NewClient(&redis.Options{
DialTimeout: 2 * time.Second,
ReadTimeout: 1 * time.Second,
WriteTimeout: 1 * time.Second,
})
client := &http.Client{Timeout: 8 * time.Second}
http.Client 的零值没有超时,这是 Go 里最经典的坑。一个慢的下游能把整个服务的 goroutine 吃完。
检查清单:
bash
# 找出所有没有超时保护的调用
grep -rn "http.Get\|http.Post\|db.Query(\|db.Exec(\|http.DefaultClient" --include="*.go" ./
五、规则 4:超时值要基于实测,不是拍脑袋
正确做法是先量出每个下游的耗时分布,再定超时:
promql
# 下游调用的 P99 耗时
histogram_quantile(0.99,
sum by (le) (rate(grpc_client_handling_seconds_bucket[7d]))
)
定值的经验规则:
text
超时 ≈ P99 × 1.5 ~ 2
- 太小 → 正常的慢请求被误杀(P99 本来就是"1% 的请求");
- 太大 → 故障时恢复太慢。
并且要监控"超时率":如果超时率超过 1%,说明超时设得太紧或者下游真的有问题。
promql
# 超时率
sum(rate(grpc_client_handling_seconds_count{code="DeadlineExceeded"}[5m]))
/ sum(rate(grpc_client_handling_seconds_count[5m]))
六、超时之外:还要有熔断和降级
超时只能防止"单次调用无限等待",防止不了"持续打一个已经死掉的下游"。
如果下游全挂,每个请求都要等到超时(比如 8 秒)才失败------8 秒内所有请求都堆积在等待,服务照样被打满。
所以必须配熔断器:连续失败到一定比例就快速失败,不再调用下游。
go
cb := gobreaker.NewCircuitBreaker(gobreaker.Settings{
Name: "order-service",
Timeout: 30 * time.Second, // 打开后 30s 进半开
ReadyToTrip: func(c gobreaker.Counts) bool {
return c.Requests >= 20 &&
float64(c.TotalFailures)/float64(c.Requests) >= 0.5
},
})
resp, err := cb.Execute(func() (interface{}, error) {
return client.GetOrder(ctx, req)
})
熔断 + 超时 + 重试的正确关系:
text
重试:处理偶发失败(单次)
熔断:处理持续失败(批量)
超时:处理单次慢(防挂死)
三者必须一起配,缺一个都会出问题:
- 只有重试没有熔断 → 下游死透时疯狂重试,放大故障
- 只有超时没有熔断 → 每个请求都要等满超时,照样堆积
- 只有熔断没有超时 → 单次慢调用能挂死线程
再加上降级:熔断打开时返回兜底数据(缓存、默认值),而不是直接报错。
七、还有一条:预留"取消传播"
超时生效的前提是取消信号能被传下去。Go 里这是靠 ctx:
go
// ✅ ctx 一路传到底,上游取消时所有下游调用都会被取消
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() // 客户端断开时会被取消
order, err := svc.GetOrder(ctx, id) // 传下去
...
}
func (s *Svc) GetOrder(ctx context.Context, id string) (*Order, error) {
return s.repo.FindByID(ctx, id) // 继续传
}
func (r *Repo) FindByID(ctx context.Context, id string) (*Order, error) {
return r.db.QueryRowContext(ctx, ...) // 驱动会中止查询
}
中间任何一层断开(用了 context.Background()),取消就传不下去了,超时只能"放弃结果"而不能"中止工作"。
八、带走物
一份完整的超时配置示例:
text
用户端 10s
网关 15s ( > 用户端 )
服务 A 12s ( < 网关 )
├─ 服务 B 8s
├─ 服务 C 5s
├─ 数据库 3s
└─ Redis 1s
重试 共享 A 的 12s 总 deadline,不延长
熔断 失败率 >50% 打开,30s 后半开
降级 熔断时返回缓存/默认值
四条规则回顾:
text
1. 逐层收敛:内层 < 外层,且内层 > 所有下游串行之和
2. 重试共享同一个 ctx,不延长总 deadline
3. 每一跳都设超时,尤其是 DB、Redis、HTTP(Go 的 http.Client 默认没有)
4. 超时值 = 实测 P99 × 1.5~2,并监控超时率
外加一个"反例自检":如果你的网关超时比用户端还大,或者数据库没有超时,那就是倒挂,先修这个。
一句话结论:超时不是"给每个调用随便配一个数",而是一条从外到内逐级收敛的链路;任何一处倒挂(外层小于内层,或内层没有超时),都会让上游放弃后下游还在白干。