Go 服务背压设计:拒绝请求比拖垮全链路更负责
一、服务不能无限接请求
Go 后端很容易写出高并发服务:一个请求一个 goroutine,看起来吞吐很高。但下游数据库、模型服务、队列和第三方接口都有容量上限。入口无限接,内部排队无限长,最后用户等到超时,服务也被拖垮。
背压的本质,是在系统还有理智时说不。
我经历过一次典型事故:数据库连接池设置了 50 个连接,但业务高峰期网关没做限流,每秒进来 300 个请求。每个请求都要查数据库,连接池瞬间满,后续请求都在等连接。等连接的请求把 goroutine 占满了,内存飙升,最终 OOM killed。如果入口做了背压,在连接池快满时就对额外请求返回 429,数据库和业务服务都不会挂。那次之后我们给所有入口都加了 inflight 限制,宁可拒绝 5% 的请求,也不让 100% 的请求等超时。
二、先找容量瓶颈
flowchart TD A[入口请求] --> B[HTTP Handler] B --> C[业务队列] C --> D[下游服务] D --> E[响应] C --> F{队列是否过载} F -->|是| G[拒绝或降级]背压要放在瓶颈前面。数据库连接池只有 50 个连接,入口却允许 5000 个请求进入业务队列,这不是高并发,是延迟炸弹。
backpressure_policy: max_inflight_requests: 800 queue_size: 200 queue_timeout_ms: 100 reject_status: 429队列长度和等待时间都要限制。只限制队列长度不够,队列没满但等待过久也应该拒绝。用户等 5 秒拿到结果和等 5 秒收到 429,后者体验更好,因为用户可以立刻重试或切换操作。
瓶颈分析不要靠猜。用压测找到下游每次调用平均耗时,然后反推入口需要的并发度。比如下游单次调用 50ms,要支撑 1000 QPS,大约需要 50 个并发。给 2-3 倍缓冲,入口 inflight 设置在 100-150 比较安全。超过这个数就应该背压。
三、用 channel 控制入口
type Limiter struct { sem chan struct{} } func (l *Limiter) TryAcquire() bool { select { case l.sem <- struct{}{}: return true default: return false } } func (l *Limiter) Release() { <-l.sem }这种 semaphore 方式简单直接,适合限制某段业务逻辑的并发。拿不到名额就快速失败,不要让请求一直挂着。
但 channel semaphore 只是最基础的实现。生产环境里你还需要:区分不同优先级请求的队列(高优先级单独分配名额)、可以动态调整的并发上限(流量突增时适当放宽,下游异常时收紧)、以及本地限制加全局限流的组合(单机 inflight + 全局限流)。
实际项目里,还要按接口或租户区分。低成本查询接口和高成本 AI 生成接口不能共用一个限额,否则重请求会挤掉轻请求。比如查询接口被 AI 生成接口拖慢,导致管理系统卡顿,这比背压本身更影响业务。
一个常见的错误是:全局背压设置了 1000 inflight,但没有给轻量查询保留最低保障。结果 AI 生成的 800 个请求占满了名额,查询请求全部排队。应该给查询这类 P0 接口预留最少并发,比如总是保留 100 个名额给查询,AI 生成最多用 700。
四、拒绝也要可观测
背压不是静默失败。每次拒绝都要记录原因、当前 inflight、队列长度、接口和租户。否则业务方只看到 429,不知道是容量不足还是限流策略太保守。
backpressure_metrics: inflight_requests: true queue_wait_ms: true reject_count_by_route: true downstream_latency_ms: true背压触发时,还可以返回Retry-After。这比让客户端盲目重试更友好。客户端重试也要带退避,否则拒绝会被重试风暴放大。
降级也是一种背压。比如 AI 接口可以在高负载时关闭重排、减少 topK、降低最大输出 token。不是每次过载都只能 429,但降级后的结果要标记清楚,让上层知道这不是完整质量的回答。
一个有意思的细节:背压发生后,不要立刻把所有拒绝请求的 429 都堆到监控大盘上,那样会触发连环告警。可以在短时间内对同接口、同原因做聚合减少告警风暴。
最后,压测要验证背压。把下游延迟调高,看入口是否及时拒绝,服务是否保持稳定。只测正常容量下的 QPS,看不出背压设计好不好。
背压参数也要能动态调整。模型服务、数据库和第三方接口的容量会随时间变化,固定阈值容易在高峰期过松、低峰期过紧。可以把阈值放进配置中心,但必须配合灰度和回滚。
dynamic_backpressure: config_hot_reload: true min_limit: 100 max_limit: 1200 change_audit: true还要防止多个服务层层排队。入口排一次、业务队列排一次、下游 SDK 再排一次,用户看到的就是长时间无响应。链路里最好只保留必要队列,并让超时预算向下传递。
五、总结
Go 服务背压要限制并发、队列、等待时间和下游容量,并把拒绝原因暴露出来。
拒绝请求不是不负责。比起把全链路拖垮,及时说不才是生产系统该有的边界感。能快速拒绝并让用户知道重试时间的系统,比默默排队 30 秒然后超时的系统靠谱得多。