dctc_青龙 dctc_青龙 当前离线 积分5874 273主题1回帖159银子 超级版主 积分5874 关注TA 发消息 发表于 2026-11-11 07:02 | 显示全部楼层 |阅读模式 线上告警就一句话:`Agent-Reach request timeout after 30000ms`。任务没跑完,日志里连个堆栈都没有。重试吧,有时候过去了,有时候照样超时——这种"偶发"最磨人,也最费时间。下面按现象、归因、处置的顺序,把这条链路拆一遍。 ## 一、先搞清楚超时卡在哪一层 建连阶段超时,和数据读取阶段超时,处置方向是反的。搞错方向,改半天也白改。 最省事的办法:用带 `-w` 的 curl,把一次请求的耗时切成四段看。 ```bash curl -o /dev/null -sS -w 'dns=%{time_namelookup} tcp=%{time_connect} \ tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n' \ --max-time 35 https://reach.internal:8443/v1/ping ``` 对着这四项读数,基本就能定层了: - `time_connect` 差不多等于 `time_total`,那是卡在 TCP 握手,要么丢包,要么对端 backlog 满了; - `time_appconnect` 明显抬高,TLS 握手慢。证书链太长、OCSP 拉取超时、MTU 黑洞,都这个表现; - 只有 `time_starttransfer`(TTFB)高,前面几项都正常,说明服务端在处理阶段慢,锅在 Agent 的下游依赖; - 四项都不高,业务侧还喊超时——八成是客户端把超时值配短了,或者这条请求走的是 SSE 长连接。 Agent-Reach 的典型调用就是流式的(SSE / chunked)。很多人把 HTTP 库的 `timeout` 当成"整个请求的总时长"来填,30 秒一到,流还在正常吐 token,客户端自己先掐断了。这类"假故障"在偶发超时里出现频率最高。 有个粗略的判据挺好用:超时如果整齐地出现在**第 30 秒**,几乎就是客户端配的绝对值;要是 60、65、120、300 这种数字,那更像是中间设备的空闲回收阈值在动手。 ## 二、三种"超时"根本不是一个东西 不少排查绕远路,就是因为把三个语义完全不同的参数,塞进了一个值里。 | 超时类型 | 管的是什么 | 典型误配后果 | |---|---|---| | connect timeout | 建连(TCP 三次握手 + TLS) | 配太小,网络抖一下就判死 | | read / idle timeout | 两次数据到达之间的**间隔** | 配太小,长输出被拦腰截断 | | total deadline | 整个请求的总时长 | 配太大,卡死的连接长期占着池子 | SSE 场景下真正该被约束的是第二项。服务端在 20 秒里陆续发出 200 个 token,中间最长停顿可能就 300ms,总耗时却过了半分钟。拿一个 30 秒的总时长去卡它,等于给正常业务埋了个倒计时炸弹。 顺带把 SSE 的帧格式说清楚,理解"卡住"长什么样会容易些。它就是纯文本流,每条事件以 `data:` 开头,可以是若干行,最后一个空行收尾。以 `:` 开头的注释行常被拿来当心跳。**心跳不是可选项**。没有它,服务端在长思考阶段(比如模型正在跑工具调用)会长时间一声不吭,中间任何一层都可能判定这条连接已经死了。 ## 三、可能的原因,按遇到的多寡排 **1. 空闲超时和总时长超时混着用。** 连接池里的空闲连接被中间设备在 60–120 秒静默收走,客户端还以为活得好好的,复用时直接超时。症状特别典型:长时间没请求,第一次调用必失败,第二次就正常。第一次撞上的是条"半死连接"——对端早就关了,甚至 FIN 挥手都走完了,本端内核却毫不知情。数据写进去,要么吃个 RST,要么石沉大海,只能干等到超时。 云厂商的默认值可以拿来参考。AWS 的 NAT Gateway、VPC Endpoint、Network Load Balancer,对 TCP 流的固定空闲超时是 **350 秒**(2024 年 9 月起 NLB 的 TCP 空闲超时已经支持配置了)。350 秒听着很宽松,可 Linux 的 `tcp_keepalive_time` 默认是 **7200 秒**。也就是说,内核参数不动,你的 keepalive 探针根本赶不上在中间设备动手之前发出第一包。这个错位,正是很多"凌晨第一次调用必挂"的根子。 **2. 代理和直连混着走。** 容器里 `HTTP_PROXY` 指向内网代理,`NO_PROXY` 偏偏漏了 `reach.internal`,请求绕一大圈远路再回来,超时。这种问题在本地开发机上永远复现不了,一上集群就冒头——开发机的 shell 里压根没导出代理变量。 **3. 连接池被打满。** 并发一上来,连接全被长请求占住,新请求在队列里排队,排到自己就超时了。这种情况看 `ss` 比翻应用日志快得多: ```bash ss -tnp state established dst :8443 | wc -l ss -s ``` established 数量贴着 `max_connections`,同时冒出一堆 `CLOSE_WAIT`,那基本可以定性了:应用没正确关闭流,连接泄漏,跟网络没关系。`CLOSE_WAIT` 堆高,说明对端已经发了 FIN,本端迟迟不调 `close()`。SSE 客户端在异常分支里忘了释放 response 对象,一晚上攒几千条很轻松。 **4. DNS 抖动。** 内网 CoreDNS 偶尔卡个 5 秒,多数 HTTP 客户端默认又不做 DNS 级重试,这段时间直接算进总开销。表现是"平时 TTFB 80ms,偶尔 5 秒起步",而你去翻服务端日志,压根看不到这次请求。 **5. 反向代理缓冲。** nginx 的 `proxy_read_timeout` 默认 **60 秒**,`proxy_buffering` 默认开着。拿带缓冲的代理去转 SSE,事件全攒在 nginx 里不发,客户端看到的就是长时间"没数据",等缓冲一刷,又一股脑涌进来。客户端觉得卡死了,服务端觉得一切正常。流式接口上最经典的一类配置型假故障,说的就是它。 **6. 对端限流返回 429/503,被当成网络错误去重试。** 重试把流量放大,越重试越慢,最后上游被自己人打挂。 ## 四、两个场景复盘 **场景 A:批量任务凌晨首调失败。** 每天 02:00 触发的批处理,第一笔请求必超时,重试一次就过。查下来,连接池 22:00 之后就彻底静默了,中间 LB 按空闲策略把连接收了,池里留着 12 条僵尸。把 `keepalive_idle_ms` 调到 45 秒(远小于任何一层的回收阈值),取连接时再加一次 `isHealthy()` 探测,问题当天就没了。 **场景 B:长文档生成中途断流。** 客户端日志写着"读取 4.2 万字符后超时",服务端日志显示生成还在继续。定位到 nginx 的 `proxy_read_timeout 60s` 加默认缓冲。改法就三行: ```nginx location /v1/stream { proxy_pass http://reach_backend; proxy_http_version 1.1; proxy_set_header Connection ''; proxy_buffering off; proxy_cache off; proxy_read_timeout 300s; } ``` 注意 `proxy_read_timeout` 在这里约束的恰好是"两次读到数据之间的间隔"。配上服务端每 15 秒一个 `: ping` 心跳,300 秒的窗口足够盖住任何正常的思考停顿。硬件方案商把 Agent 网关塞进园区机房那种场景,前置代理一层叠一层,同一个参数往往每层都得改一遍,漏一层照样断。 ## 五、动手改的话,按这个顺序 **第一步,把超时拆成四个独立参数。** 别拿一个 30 秒从头套到尾。 ```yaml reach: connect_timeout_ms: 3000 # TCP + TLS read_timeout_ms: 15000 # 单次读间隔,不是总时长 total_deadline_ms: 120000 # 整体上限,留给流式输出 keepalive_idle_ms: 45000 # 小于中间设备回收阈值 max_idle_conns: 32 heartbeat_interval_ms: 15000 # 服务端 SSE 注释心跳 ``` 关键就在 `read_timeout` 只管"两次数据到达之间",不管整个响应。SSE 不这么设,长输出必然被误杀。 **第二步,显式声明绕过代理。** ```bash export NO_PROXY="reach.internal,10.0.0.0/8,127.0.0.1,localhost" ``` 排查期再拿 `curl --noproxy '*'` 对照跑一次。结果有差异,就说明代理路径确实在捣鬼。 **第三步,抓一次握手现场。** 超时窗口太短,别指望事后从日志里还原。 ```bash timeout 40 tcpdump -i any -nn 'host 10.20.3.11 and port 8443' -w /tmp/reach.pcap tshark -r /tmp/reach.pcap -Y 'tcp.flags.syn==1 or tcp.analysis.retransmission' ``` SYN 重传,说明对端没回或者中间丢包。握手成了但重传密集,那是 MTU / 链路质量问题,试着把 MSS 压到 1360 再测。TLS 阶段停住,用 `openssl s_client -connect reach.internal:8443 -servername reach.internal -state` 看清楚卡在哪一步。 **第四步,给重试加条件。** 只对幂等请求重试,且错误得是 `connect timeout / 502 / 503` 这类,指数退避,最多 3 次。读超时和 429 别盲重试。前者通常意味着对端是真的慢,后者需要退避,不是加压。 **第五步,把观测补齐。** 至少埋三个指标:`connect_duration`、`ttfb`、`stream_gap_max`。第三个是流式专属信号,能在用户报障之前就把"输出卡住"暴露出来。再补一个 `conn_pool_wait_ms`——连接池排队时间一抬头,说明池子已经不够用了。 ## 六、怎么验证真修好了 改完配置,别只看"今天没告警"就收工。做一轮对照压测:并发 20,每请求输出 3000 token,跑 10 分钟。重点盯三个数——`stream_gap_max` 是不是稳稳落在心跳间隔的 2 倍以内、`conn_pool_wait_ms` 的 P99 是不是接近 0、压测结束后 60 秒内 `CLOSE_WAIT` 计数有没有归零。 三项都过了,才算真修好;不然只是把故障推迟到下一次流量高峰。 ## 七、写在最后 Agent-Reach 的连接超时,很少是"网络不好"这么含糊的东西。八成落在两处:超时语义没拆开,把流式读超时当总时长用了;再就是代理、连接池、反向代理缓冲这些看不见的隐式路径。 先用带 `-w` 的 curl 把耗时切成四段定层,再用 `ss` 和 pcap 收口。改配置的时候,connect / read / total 三个值分开写,`read_timeout` 按数据间隔算而不是按总时长算,这类告警能掉一大半。 剩下的,交给心跳和 `stream_gap_max` 盯着就行。你们线上那层代理,`proxy_read_timeout` 现在是配的多少? 快速回复 回复 使用道具 举报