2026 年 2 月底某个周一上午,平台组 SRE 在群里贴了一组奇怪的告警:"过去 24 小时 order-service 调 inventory-service 累计出现 1842 次 'connection reset by peer' 错误,每 5 分钟一波,持续 1-2 秒。但下游服务自身指标完全正常,没有重启、没有崩溃、没有 OOM。"这种"每 5 分钟一波"的规律性非常可疑——任何"周期性"现象在分布式系统里都不是巧合,通常背后有某个 timer 在作怪。
5 天后定位的根因让人哭笑不得:我们的 K8s 集群前面的云厂商负载均衡器(LB)默认 idle timeout 是 5 分钟,任何 5 分钟没流量的 TCP 连接都被 LB 主动 RST。order-service 用 gRPC 调 inventory-service,gRPC 默认会复用 HTTP/2 长连接;在低 QPS 时段(早 4 点到 7 点)调用稀疏,连接就会"空闲超过 5 分钟",LB 把它 RST 掉。但 gRPC client 没配 keepalive ping,不知道连接已死,继续用——下次发请求就拿到 connection reset。这篇是完整复盘,涵盖 gRPC HTTP/2 长连接的工作机制、keepalive 配置、LB / 防火墙 / NAT 的 idle timeout 现象、3 端协调配置的方法、以及落地的《gRPC 长连接稳定性纪律》。
背景:这个看起来普通的 gRPC 微服务调用
| 维度 | 数值 |
|---|---|
| 架构 | Go 微服务集群,内部全部用 gRPC HTTP/2 通信 |
| 规模 | 56 个微服务,日均 RPC 调用 200 亿次 |
| 关键路径 | order-service → inventory-service(查库存) |
| K8s | 1.28,16 Pod 一服务 |
| LB | 云厂商 NLB(L4)+ K8s Service,idle timeout 默认 5 分钟 |
| 事故现象 | 每 5 分钟一波 connection reset,集中在低 QPS 时段 |
| 影响 | 每天 1800+ 次单次请求失败,有重试机制所以业务无感但客服监控有数据 |
"业务无感"是这次问题被忽视很久的原因——重试一次就能成功,客户基本看不出来。但每天 1800 次 connection reset 意味着:
- 1800 次毫无意义的重试,放大下游负载
- 错误日志噪音,真正的 connection issue 被淹没
- P99.9 不稳定(碰上 reset + 重试的请求总有 ~ 100ms 额外延迟)
- 定时炸弹:如果某天 LB idle timeout 缩短 / gRPC 重试策略调整,可能直接演变成可见事故
事故时间线:从规律性告警到根因的 5 天
| 时刻 | 事件 |
|---|---|
| 02-23 周一上午 | SRE 注意到"每 5 分钟一波"的规律,我加入排查 |
| 02-23 下午 | 抓客户端和服务端的 tcpdump,看到 LB 端发起 FIN + RST,且时间精准在"上次流量后 5 分钟" |
| 02-24 | 查云厂商 NLB 文档,确认默认 idle timeout 350 秒(约 5 分 50 秒) |
| 02-25 | 研究 gRPC keepalive 机制 + HTTP/2 PING frame + Go grpc-go 客户端默认行为 |
| 02-26 | 设计三端协调方案:client keepalive 2 分钟 + server permit + LB 不变 |
| 02-27 | 预发测试 + 灰度上线 |
| 03-02 | 全量上线,7 天观察 connection reset 完全归零 |
第一反应:"是不是下游服务有 bug 偶发 reset"
当看到 "connection reset by peer" 时,所有人的本能反应是"对方(peer)出问题了"——下游服务崩溃、重启、内存溢出、网络问题。这个直觉在 90% 场景对,但在LB / NAT / 防火墙参与的链路里要警惕——这些中间设备也可能主动 RST 连接。
判断"reset 来自谁"的关键是 tcpdump。我们在 inventory-service Pod 抓包:
tcpdump -i any -nn -w inv.pcap port 50051
# 用 Wireshark 打开, 过滤 tcp.flags.reset==1
# 看到 RST 包的 source IP
# 出现 RST 的几种情况:
# 1. source IP = client Pod IP: 客户端发 RST
# 2. source IP = server Pod IP: 服务端发 RST
# 3. source IP = LB IP / VIP: LB 发 RST(就是我们的情况)
我们抓到的 RST 包源 IP 是 NLB 的 VIP,这直接锁定到 LB 是 "killer"——下游服务完全无辜。然后再看 RST 的精确时间,和该连接"上次有流量"的时间差,固定 350 秒——LB 的 idle timeout 现身了。
三层叠加的因果链:为什么"业务无感的小问题"其实是定时炸弹
这张图最关键的信息是三个因素互相放大:gRPC 默认不发 keepalive / LB 默认主动断空闲连接 / HTTP/2 多路复用让单连接故障影响 N 个并发请求。任何一个单独存在都不致命,叠加就让"看似无感的偶发错误"变成"每天 1800+ 次重试 + 错误日志噪音 + 定时炸弹"。我们后来内部叫这种问题"长连接中间设备反模式",任何一项 gRPC / 长连接事故复盘都强制画一张这种因果图,确保不会"看见错误能重试就放过它"——可重试不代表无成本,1800 次重试每天放大下游负载 + 淹没真正问题的日志噪音,长期看是不可承受的工程债。
真凶 1:云厂商 LB 的 idle timeout
所有主流云厂商的 LB 都有 "idle connection timeout"——TCP 连接空闲超过此时间自动 close 或 RST。默认值各家不同:
| 云厂商 / LB | 默认 idle timeout | 可配置范围 |
|---|---|---|
| AWS NLB | 350 秒 | 不可改(固定) |
| AWS ALB | 60 秒 | 1 ~ 4000 秒 |
| GCP TCP Load Balancer | 600 秒 | 1 ~ 28800 秒 |
| Azure Load Balancer | 4 分钟 | 4 ~ 30 分钟 |
| 阿里云 SLB | 900 秒(TCP) | 10 ~ 900 秒 |
| K8s kube-proxy iptables | 2 小时(conntrack) | 可改 |
看到 AWS NLB 的 350 秒固定值——这是我们用的产品,这就是事故根因之一。AWS 文档明确说 NLB idle timeout 不可改(这是产品设计),所以应用层必须配合做"保活"。
真凶 2:gRPC 默认不发 keepalive ping
gRPC 基于 HTTP/2,HTTP/2 协议本身支持 PING frame——可以在空闲时发 PING,保持连接活跃。但 gRPC 客户端默认不发 keepalive(除非显式配置):
| 语言 | 默认 keepalive |
|---|---|
| Go grpc-go | 默认禁用(必须显式配 KeepaliveParams) |
| Java grpc-java | 默认禁用 |
| Python grpcio | 默认禁用 |
| C++ grpc-cpp | 默认禁用 |
| Node.js @grpc/grpc-js | 默认禁用 |
"默认禁用"的设计哲学是"避免无意义的 PING 浪费带宽"——对短链接场景是合理的。但对长连接 + LB 场景,这是个隐藏的坑——客户端不知道 LB 会断连,服务跑起来才发现。
真凶 3:HTTP/2 长连接复用让问题放大
gRPC 默认会复用同一个 HTTP/2 连接发送多个并发 RPC(多路复用)。这是 HTTP/2 的核心优势——但是意味着一个连接断了影响所有正在用它的 RPC。在低 QPS 时段,所有 order-service 实例 to inventory-service 的流量可能只用 1-3 个 HTTP/2 连接,LB 一断,所有积压的请求一起失败。
HTTP/1.1 时代,每个请求一个连接(或最多 6 个池化连接),即使 LB 断了一个连接,其他还能用。HTTP/2 多路复用把"连接失败"的影响放大了 N 倍。
修法:三端协调 + 容错
修法 1:client 配 keepalive,2 分钟一次 ping
// Go grpc-go client
import "google.golang.org/grpc/keepalive"
kacp := keepalive.ClientParameters{
Time: 2 * time.Minute, // 2 分钟没收发就发 PING
Timeout: 20 * time.Second, // PING 20 秒没响应认为连接死
PermitWithoutStream: true, // 即使没活跃 stream 也发 PING
}
conn, err := grpc.NewClient(
target,
grpc.WithKeepaliveParams(kacp),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
关键参数:
Time: 2 分钟:比 LB 的 350 秒 idle timeout 短得多,保证连接不会被认为空闲PermitWithoutStream: true:即使当前没 RPC 进行,也发 PING。这个是关键!否则空闲连接(没 stream)不会被保活Timeout: 20s:PING 20 秒收不到响应认为连接死,主动关闭并重连
没有 PermitWithoutStream=true 的话,keepalive 形同虚设——连接闲着没 stream,client 不发 ping,LB 还是会断。这个默认值是 false(为了"礼貌"),实际生产环境必须改 true。
修法 2:server 端 permit keepalive(否则 GOAWAY)
gRPC server 默认会拒绝过于频繁的 keepalive ping——这是为了防止恶意客户端通过频繁 ping 发 DoS。如果 client 发 ping 太频繁,server 会发 GOAWAY 关连接。配套配置:
// Go grpc-go server
import "google.golang.org/grpc/keepalive"
kasp := keepalive.ServerParameters{
MaxConnectionIdle: 5 * time.Minute, // server 主动关闭超过 5 分钟空闲的连接
MaxConnectionAge: 30 * time.Minute, // 强制重连周期(避免连接老化)
MaxConnectionAgeGrace: 30 * time.Second, // 关闭前给 graceful period
Time: 3 * time.Minute, // server 自己也发 ping
Timeout: 20 * time.Second,
}
kep := keepalive.EnforcementPolicy{
MinTime: 1 * time.Minute, // 客户端最频繁 1 分钟一次 ping
PermitWithoutStream: true,
}
server := grpc.NewServer(
grpc.KeepaliveParams(kasp),
grpc.KeepaliveEnforcementPolicy(kep),
)
关键:server 端 EnforcementPolicy.MinTime 必须 ≤ client 端的 Time。我们 client 是 2 分钟一次 ping,server MinTime 设 1 分钟,留余量。
如果 MinTime 设得比 client Time 大,会出现"client 发 ping → server 认为太频繁 → 发 GOAWAY → 连接关闭"的灾难。这是经典的"双方配置不协调"问题。
修法 3:LB 配合(如果可改)
AWS NLB 不可改,但 ALB / GCP / 阿里云都能调长 idle timeout。建议:
| 层 | idle timeout | 说明 |
|---|---|---|
| client keepalive Time | 2 分钟 | 最短 |
| server MinTime | 1 分钟 | 允许 client 至少 1 分钟一次 |
| LB idle timeout | 5 ~ 10 分钟 | 比 client 长(LB 不该比 client 先动手) |
| conntrack timeout | 2 小时 | K8s 默认,通常不用改 |
核心原则:越靠近应用层的 timeout 越短,越靠近网络层的越长。这样应用层先主动管理连接,网络层不会"先动手"。
修法 4:容错重试
就算 keepalive 配好了,connection reset 仍可能因为网络抖动 / Pod 重启 / LB 维护偶发。所以应用层必须有重试:
// Go grpc-go 客户端 retry policy(通过 service config)
serviceConfig := `{
"methodConfig": [{
"name": [{"service": "inventory.InventoryService"}],
"retryPolicy": {
"MaxAttempts": 3,
"InitialBackoff": "0.05s",
"MaxBackoff": "1s",
"BackoffMultiplier": 2.0,
"RetryableStatusCodes": ["UNAVAILABLE"]
}
}]
}`
conn, _ := grpc.NewClient(target,
grpc.WithDefaultServiceConfig(serviceConfig),
grpc.WithKeepaliveParams(kacp),
)
注意只重试幂等的 RPC(GET 类查询),写操作要业务保证幂等才能重试。
修法 5:关键指标监控
修完之后还要把"连接健康"这件事变成可观测指标,否则下次坏了也是被动等业务方报。我们接入 prometheus,采的关键指标:
# gRPC 客户端关键指标(via grpc-go interceptor + prometheus)
# 1. connection reset 计数(按 service / target / error_code 拆维度)
grpc_client_connection_reset_total{service="inventory",target="..."}
# 2. keepalive ping 失败计数(ping 发了但没收到 ack)
grpc_client_keepalive_ping_failed_total{target="..."}
# 3. GOAWAY 接收计数(server 主动让我重连)
grpc_client_goaway_received_total{target="...",reason="..."}
# 4. 当前活跃连接数(grpc.ClientConn level)
grpc_client_active_connections{target="..."}
# 5. 重试触发计数(按 status code 拆)
grpc_client_retry_total{service="...",status_code="UNAVAILABLE"}
# Grafana 告警规则
# - connection_reset rate > 0.1/s 持续 5min: P3
# - keepalive_ping_failed rate > 0.05/s: P2
# - goaway_received rate > 0.5/s: P1
# - retry rate > 1% of total RPC: P3
这五条指标是 gRPC 长连接健康的"心电图",任何持续异常都说明 client / server / LB 三端配置不协调。建议把这套监控做成统一 dashboard,全公司 gRPC 服务共用。
验证:7 天观察
| 指标 | 修复前 | 修复后 |
|---|---|---|
| connection reset / 天 | 1842 次 | 0 次 |
| 低 QPS 时段 P99 | 偶发 200-500ms 尖峰 | 稳定 25ms |
| 重试触发次数 / 天 | 1800+ | 5-10(真实网络抖动) |
| 下游负载 | 有 5 分钟一波的小峰 | 平稳 |
| 错误日志噪音 | 1800+/天 | 0/天 |
5 天里被否决的方案
| 方案 | 看似可行 | 否决理由 |
|---|---|---|
| 把 gRPC 改回 HTTP/1.1 调用 避开 HTTP/2 多路复用问题 | HTTP/1.1 短连接 LB 断了影响小 一次失败只重试一个 RPC | 56 个微服务全部 gRPC 改回 HTTP/1.1 是 6 个月级重构 + 失去 HTTP/2 多路复用的性能优势 我们 P99 25ms 大部分来自 HTTP/2 改回去 P99 直接翻倍 ROI 极差 |
| 业务层定时发"健康检查 RPC"模拟流量 保活 | 1 天可上线 不动 gRPC 配置 | 每 2 分钟向 56 个下游各发一次 health RPC 多余 RPC 量约 40 万次/天 + 业务方代码侵入 + 还是治标 不如直接用 gRPC 内置 keepalive |
| 关掉 NLB 让 K8s Service ClusterIP 直连 | 消除中间 LB 环节 | NLB 是跨集群服务发现 + AZ 容灾 + 限流入口 关掉等于推倒整个流量入口架构 + ClusterIP 跨集群不通 工程量月级 不是这个事故能解决的 |
| 客户端每 5 分钟定时关闭再重建 gRPC 连接 | 暴力但简单 | 每次重建连接 TLS 握手 100-300ms 业务方接受不了 + 重建期间正在用该连接的并发 RPC 全部失败 + 反向放大问题 |
| 所有下游调用包一层重试中间件 错误都重试 N 次 | 对业务透明 错误隐藏 | 方向反了 错误不该被隐藏 否则真正的下游故障也被吞 + 重试放大下游负载 一旦下游真挂了重试雪崩 + 不解决根因 |
| 把 gRPC 换成 NATS / Kafka 异步消息 | 消息中间件天然有重连机制 | 同步 RPC 改异步消息是业务模型大改 order/inventory 查库存需要立即返回 异步不合适 + 团队对消息中间件运维不熟 工程量季度级 |
每条否决都让我们更清楚"真正要修什么"。最后选定的"client keepalive + server permit + 重试策略"既是技术最优,也是组织成本最低——所有改动都在 gRPC 配置层,基础设施不动、业务代码不动。后来产品和老板问"为什么不一次性换掉 NLB 一劳永逸",我们直接甩这张表 5 分钟说服全场。这种"否决记录"在长期来看比"选定方案"价值还大,新人入职第二周遇到类似问题翻一下表就有思路,不需要从头讨论。
决策树:新建一个 gRPC 服务该怎么配 keepalive
这棵决策树后来嵌进了平台组的 gRPC 服务接入 SOP:任何新建 gRPC 服务的 PR,作者必须在 description 里说清楚走了哪条分支。这个小改动让团队对"长连接稳定性"的纪律性提升一个量级——以前是"抄一份 grpc.NewClient(addr) 就 merge",现在是"先确认 LB 类型再选 keepalive 参数"。code review 也因此变得更有抓手,新人入职第二周就能跟着这棵树独立做配置评估,不再凭"我之前是这样写的"做决策。半年下来类似的"connection reset 噪音"工单从月均 6-8 起降到 0 起。
类似问题的其他场景
这类"中间设备 idle timeout 杀连接"问题不局限于 gRPC。在以下场景都会出现:
| 场景 | 表现 | 修法 |
|---|---|---|
| MySQL 长连接 + 阿里云 SLB 中间 | 偶发"Lost connection to MySQL server during query" | 客户端 wait_timeout < LB idle |
| Redis 长连接 + 防火墙 | 偶发"connection reset by peer" | tcp_keepalive_time 调小 |
| RabbitMQ 长连接 + 公网 | 偶发 frame_error 断连 | heartbeat 设 30s |
| WebSocket + Cloudflare | 100 秒后断连 | 客户端定期发 ping |
| SSH 长会话 + NAT 路由器 | 30 分钟空闲断 | ServerAliveInterval 60 |
都是同一个套路:中间设备杀连接,应用层不知道,下次用时失败。修法都是应用层主动发 keepalive。
立的《gRPC 长连接稳定性纪律》
- 所有 gRPC client 必须显式配 KeepaliveParams:Time=2min,Timeout=20s,PermitWithoutStream=true。
- 所有 gRPC server 必须配 KeepaliveEnforcementPolicy:MinTime=1min,PermitWithoutStream=true。
- 必须了解 LB 的 idle timeout 值,LB 不可改的(如 AWS NLB)必须 client keepalive 配合;LB 可改的统一调到 10 分钟+。
- client keepalive Time < LB idle timeout < conntrack timeout,严格保证顺序。
- 所有 gRPC 调用必须有重试,UNAVAILABLE / DEADLINE_EXCEEDED 重试 3 次,指数退避。
- connection reset / GOAWAY 类错误必须监控,任何持续出现都是配置 bug。
- 新服务接入 gRPC 时必须 review:client / server / LB 三端配置协调。
- 慎用 HTTP/2 多路复用:虽然性能好,但单连接故障影响放大,在不稳定链路上考虑 HTTP/1.1 + 连接池。
给读者的几条自查清单
- 用 tcpdump 抓你某个 Pod 端口 30 分钟,看 RST 包的源 IP——如果不是 client 或 server,就是中间设备在杀。
- 查云厂商 LB 文档,确认 idle timeout。AWS NLB 不可改,GCP 可改,阿里云可改 max 15 分钟。
- 看你的 gRPC client 代码,是不是只 grpc.NewClient(addr) 然后就用?基本上没配 keepalive。
- 看错误日志,有没有"每 N 分钟规律性出现"的连接错误。"每 5 分钟"或"每 4 分钟"或"每 60 分钟"都是 timeout 嫌疑。
- 测试一下:把 client 启动后让它空闲 6 分钟(超过 NLB 5 分钟),然后发请求看是否成功。失败就是没配 keepalive。
- 所有 long-lived TCP 连接(DB / Cache / MQ / RPC)都问自己同样的问题。这是个通用模式。
- 对接公网 SaaS 服务(Slack / Twilio 等)的长连接,关注他们的 keepalive 要求(API 文档里通常有)。
这次事故让我再次确认一个分布式系统的铁律:"沉默的中间设备最危险"。LB、防火墙、NAT、proxy——这些设备不会发 error 信号给你,它们只是悄悄断连接。如果你的应用不主动检测,就只能在"下次用"时发现连接已死。设计稳定的长连接系统,本质是要"假设链路任意时刻都可能死",然后用 keepalive + 心跳 + 重连 + 重试构建韧性。
另一个心得:"配置协调"是分布式系统最容易被忽视的环节。client、server、LB、防火墙、NAT、conntrack——每个都有自己的 timeout 配置,而且默认值都是"为它自己的场景"优化的,合在一起经常打架。系统设计 / SRE 团队的一个重要责任就是把这些配置"通盘审视",建立一致的纪律(比如"应用层 timeout < 中间层 < 网络层"),避免相互矛盾。
整体效果 + 长期收益
| 维度 | 修复前 | 修复后 90 天 |
|---|---|---|
| connection reset / 天 | 1842 次 5 分钟规律一波 | 0 次 90 天累计 0 |
| 低 QPS 时段 P99 | 偶发 200-500ms 尖峰 | 稳定 25ms 无尖峰 |
| 下游重试触发 / 天 | 1800+ 次 全部 connection reset 引发 | 5-10 次 真实网络抖动 |
| 下游 inventory-service 负载 | 每 5 分钟一波 重试小峰 CPU 15-20 percent 上下波动 | 平稳 CPU 12 percent 无波动 |
| 错误日志噪音 | 1800+/天 真问题被淹没 | 0/天 任何错误都是真错误 |
| SRE 长连接相关工单 | 月均 6-8 起 重复值班 | 月均 0 起 |
| 新服务 gRPC 配置决策时间 | 15-30 分钟讨论 抄隔壁服务 | 5 分钟按决策树走 |
| 顺手扫到的同类隐患服务 | 0 | 主动扫到 11 个 gRPC 服务 + 4 个 MySQL 长连接 + 2 个 Redis 全部已配 keepalive |
| P99.9 抖动率 | 1.2 percent 与 LB idle 强相关 | 0.08 percent 与业务真实抖动一致 |
| 云成本(重试放大下游) | + 约 0.8 万元 / 月 多余 RPC | 0 多余成本 |
P99.9 从 1.2% 抖动率降到 0.08% 这一项是意外收获——原以为修复是"消除错误日志噪音",结果是"消除错误 + 显著改善长尾延迟"。原因是重试本身有 50-100ms 的额外延迟,1800 次重试/天分摊到所有请求上拉高了 P99.9。一次 5 天的深度调优换来"错误清零 + 延迟改善 + 下游负载平稳 + 同类隐患全清"四重收益,这种 ROI 在 SRE 项目里很难得。
认知更新:对 gRPC / 长连接 / 中间设备的 4 个新认知
- "沉默的中间设备"是分布式系统最危险的角色。LB、防火墙、NAT、proxy 这类设备的特点是"不会主动告诉你它做了什么"——它们悄悄断连接,客户端只有在"下次用"时才发现。和"应用层服务挂了立刻报 error"完全不同的故障模式,这种"延迟到下次操作才暴露"的特性让排查难度极高。任何依赖长连接的系统设计,都必须假设"链路任意时刻都可能死,但不告诉我",然后用 keepalive + 心跳 + 重连 + 重试构建韧性。这个认知没建立的团队,长期都会被"偶发 connection reset"困扰,而且查不到根因。
- HTTP/2 多路复用是双刃剑。性能上它把"每请求一连接"的开销消除,延迟和资源利用都好得多。但故障域上它把"一个连接断"放大成"所有用这个连接的并发 RPC 全失败",影响范围×N。这个权衡在稳定链路上是赚的(多路复用收益远大于偶发故障代价),在不稳定链路(公网 / 多层 NAT / idle timeout LB)上是亏的。选 gRPC 时这个 trade-off 要明确考虑,不是"HTTP/2 一定比 HTTP/1.1 好"——是"在你的链路上 HTTP/2 是否比 HTTP/1.1 好"。这条认知后来写进我们 gRPC 接入文档,新服务上线前必须确认。
- "默认配置"几乎从来不是你的最佳配置。gRPC client 默认不发 keepalive(避免无意义带宽)、server 默认拒绝高频 ping(防 DoS)、PermitWithoutStream 默认 false(礼貌)——每个默认值都是为"通用场景"优化,但你的具体场景可能完全不通用。任何长连接库都该先 audit 一遍它的默认值,问"在我的场景下这个默认是对的吗"。我们 audit 完 gRPC 后顺手 audit 了 MySQL driver / Redis client / Kafka consumer 的默认 keepalive 设置,挖出 7 个有类似隐患的服务——这种"配置 audit"半年做一次,投入产出比极高。
- "修这个事故"和"修这类事故"是两件事。原本我们计划改完 order-service → inventory-service 这条链路就收工,后来主动扫了公司所有 gRPC 服务的 keepalive 配置,挖出 11 个有类似隐患的服务,顺手又扫了 MySQL / Redis / Kafka 等长连接,又挖出 6 个。一次复盘的真正价值不是修当下,是把同类问题在它们爆雷前都摸出来。这种"主动扫雷"耗时大约是修一个 bug 的 4 倍,但避免 17 次类似事故 + 17 次值班半夜起夜——ROI 极其划算。我们后来在 SRE 团队设了固定流程,每次 P1 / P2 事故复盘后必须做"同类扫雷",这套流程半年下来主动避免了 19 次潜在事故,口碑提升非常明显。
第三个心得是关于"benchmark 的真实性"。gRPC 官方 benchmark 跑的是"持续高 QPS"场景,在这种场景下连接永远有 stream,LB idle timeout 根本不会触发——所以官方 benchmark 完美。但生产场景往往是"高 QPS + 低 QPS 时段交替"(凌晨 4-7 点几乎无流量),官方 benchmark 完全没覆盖这个模式。"官方 benchmark 漂亮"和"你的生产负载稳定"是两件事,选型 / 配置时必须用自己的真实流量模式(包括低谷期)跑一遍。这个习惯后来扩展到所有长连接组件——MySQL / Redis / Kafka 都先模拟"低流量 + LB idle"场景跑一遍,再做决策。半年下来挡掉了 3 次"看官方文档调成最优但生产爆"的坑。
最后再补一个工程文化层面的反思:这次事故触发前其实有过很多次小信号——SRE 同事注意过"凌晨告警有规律"、监控大盘上 connection reset 这个指标月增长肉眼可见、新人 onboarding 时问过"为什么错误日志这么多"、业务方偶尔抱怨过"凌晨第一笔订单有时候慢",每次大家都用"还能跑"、"是历史代码"、"重试能兜住"绕过去。所有大事故都有它的"预热信号",区别只在团队有没有把它当回事。我们后来在事故管理里加了"小信号月度复盘"机制——把过去 30 天的所有低优先级告警 + 业务抱怨 + 新人提的"为什么这样"问题集中拉一遍,挑出可能值得深挖的提前修。半年下来这个机制至少提前避免了 6 次类似量级的问题,投入产出比远超事后排查。希望读到这里的你也能在自己团队里建立类似的"小信号雷达",别再让一个看似无害的"connection reset 但能重试"把团队 6 个月后的某个忙碌下午毁掉。
下次有人在代码里写 grpc.NewClient(addr) 时,别只想着"反正能连上",顺手把 KeepaliveParams 也配上(Time=2min / Timeout=20s / PermitWithoutStream=true)、server 端也加上 EnforcementPolicy(MinTime=1min)、retry policy 也写好。这套配置花你 10 分钟,但能让你未来 6 个月不被"凌晨规律性 connection reset"工单叫起来。修完之后你会发现,同样的业务逻辑、同样的下游服务,链路突然就稳定到"再也没人吐槽过它"——其实代码没变多少,变的只是你终于让 gRPC 知道"中间有个 LB 会断连接,要主动保活"。这种"零业务侵入却带来 SLO 跃迁"的工程红利,在长连接场景里非常常见,值得每个团队投入一次彻底的复盘。如果你在自家 gRPC 链路上也做了类似的 keepalive 治理,欢迎在评论区分享你的 tcpdump 截图、最终的 connection reset 数据,以及踩到的其他长连接反模式——长连接稳定性这块,中文社区沉淀的实战经验还很稀缺,每一份数据都是后来者的灯塔,愿我们的 5 天踩坑能换你 30 分钟就内化成自己团队的工程默认值,把每一次 RPC 都用在真正的业务价值上,而不是浪费在本可以避免的"连接已死但客户端不知道"的应急重试里。
—— 别看了 · 2026