go-chat-api 中 requestId 链路打通与 502 排查复盘
前言
这两天在练手项目 go-chat-api 里,表面上只是想做一件很小的事:给每个请求补一个 requestId,方便看日志、方便查问题。
但实际做下来以后,问题并没有停留在“加个 header”这么简单。中间一路牵出了几个很典型、也很适合复盘的点:
requestId到底放在 request、response,还是两边都放context.WithValue应该基于谁来扩展- 为什么明明写了 header,却看不到
x-request-id - 为什么改完 context 以后接口开始报 500
- 为什么报错信息里会出现
invalid character '<' - 最后为什么能确认不是代码问题,而是外部 LLM API 返回了 502
这篇文章一方面是记录这次排查过程,另一方面也顺手整理成一篇以后自己查阅时能直接拿来用的笔记。
先说结论
这次最终确认了两件事:
requestId链路已经完整打通了- 后面的 500 错误并不是我新增
requestId的代码导致的,而是外部 LLM API 实际返回了502 Bad Gateway
也就是说,这次表面上是在做链路追踪,实际上还顺手做了一次比较完整的接口排错。
一、requestId 到底要不要放到 response 里
一开始我最直接的疑问其实是:
我已经把 requestId 放到请求里了,还需要再放到 response 里吗?
答案不是“强制必须”,但非常建议放。
原因很简单:
- 客户端可以直接从响应头里拿到这个请求的唯一标识
- 出问题时,前端、后端、日志系统可以拿同一个 id 对齐
- 调试接口时,看到响应就能立刻知道这次请求在服务端对应哪条日志
这其实就是链路可观测的一个基础动作。
比较常见的做法是写到响应头里,比如:
1 | X-Request-ID: fa8a869a-ac41-4d1c-9caa-7b785e52189d |
如果只是把它放在请求内部使用,而不返回给客户端,那么服务端自己查日志当然也能用,但联调时会少一个很方便的锚点。
二、为什么“写了 response header 却看不到”
这一步也挺容易踩坑。
如果 X-Request-ID 没有出现在响应头里,常见原因通常不是“Go 不支持”,而是下面这几类:
1. 设置 header 的时机不对
在 Go 里,一旦响应已经开始写出,再去设置 header 往往就来不及了。
也就是说,应该先:
1 | w.Header().Set("X-Request-ID", requestID) |
再去写 body 或状态码。
2. 看错了位置
有时候是工具界面没展开响应头,或者只看了 body,误以为没有返回。
3. 中间包装层把信息藏起来了
如果自己封装了 ResponseWriter,或者通过统一响应函数返回数据,也要注意 header 有没有被覆盖、有没有在更早的地方已经写出。
这次排查下来,最终链路打通以后,requestId 是能在完整链路中看见的。
三、context.WithValue 的正确用法
这部分是这两天最大的知识点之一。
我一开始查资料时,看到的是这种写法:
1 | ctx := context.WithValue(context.Background(), "requestId", requestId) |
第一眼会觉得好像没问题,但放到 HTTP 请求处理中就不太对了。
为什么不应该直接用 context.Background()
因为 HTTP 请求本身已经带着一个 context:
1 | r.Context() |
这个 context 里可能已经有:
- 请求生命周期
- 取消信号
- 超时控制
- 上游 middleware 注入的值
如果你直接从 context.Background() 重新起一个,就相当于把原来请求自带的上下文链路切断了。
正确做法
应该在当前请求的 context 基础上扩展:
1 | ctx := context.WithValue(r.Context(), "requestId", requestId) |
重点有两个:
- 不是新起一个孤立的 context,而是基于
r.Context()扩展 - 扩展完以后,要把新的 context 挂回到
r上继续传递
这是不是意味着以后不能用 r *http.Request 了?
不是。
这一点我当时也有疑问:
如果用了 context,是不是所有函数都不能再用
r *http.Request接参数了?
答案是完全不用这么想。
r *http.Request 还是照常传,context 只是 request 身上的一部分。正确理解应该是:
- 业务上继续通过
r拿请求信息 - 链路数据通过
r.Context()往下传
这两者不是替代关系,而是配合关系。
四、这次 requestId 链路是怎么打通的
从结构上看,这次比较合理的链路是:
- middleware 生成
requestId - 写入 response header
- 放进 request context
- handler / service 从 context 读取
- 日志里统一打印
requestId - 出错时响应体里也可以带上
request_id
可以把它理解成这样一条链:
1 | 请求进入 |
这样做完以后,基本就具备了最小可用的链路追踪能力。
五、为什么改完以后开始报 500
这一步是最容易误判的。
因为时间上看,问题是在我改完 context 以后出现的,所以直觉上会觉得:
是不是我把 context 改坏了?
这其实是排错时很典型的误区:最后改过的地方,不一定就是根因。
后来拿到实际报错后,返回内容是这样的:
1 | { |
当时第一反应是“格式问题”,这个判断方向其实是对的,但还不够具体。
六、invalid character '<' 到底说明了什么
这个报错在 Go 里很有辨识度。
它通常意味着:
- 程序本来准备按 JSON 去解析响应体
- 但实际拿到的内容不是 JSON
- 并且响应内容是以
<开头的
最常见的情况有三种:
- 上游返回了一段 HTML 错误页
- 网关返回了非 JSON 的错误页面
- 某个中转服务返回了纯文本或异常内容
也就是说,问题不在 json.Unmarshal 本身,而在于:
上游接口根本没有按预期返回 JSON。
这时候如果还一直盯着本地 handler、middleware、context 去改,方向就偏了。
七、怎么把问题继续定位下去
既然怀疑是上游响应格式不对,那下一步就不是猜,而是直接看响应原文。
于是我在调用 LLM API 的地方加了调试日志:
1 | log.Printf("[LLM DEBUG] status=%d body=%s", resp.StatusCode, string(bodyBytes)) |
然后重新发请求,服务端日志很快给出了关键信息:
1 | [LLM DEBUG] status=502 body=error code: 502 |
到这里,问题就基本坐实了。
真正的根因
不是 requestId 代码有问题。
不是 context 传递有问题。
不是本地 JSON 结构定义错了。
而是:
外部 LLM API 返回了 502 Bad Gateway。
所以本地服务后面报 500,只是把上游错误继续传导出来了。
八、这一步为什么很重要
这次很值得记一笔的地方在于:
如果没有 requestId,其实也能查日志;但有了 requestId 以后,整个排错过程会更稳。
因为现在链路里已经有了统一标识:
- 客户端响应里能看到
request_id - 服务端日志里也能看到对应
requestId - 出错时能快速把某次请求的日志串起来
这就避免了“多次请求混在一起,分不清哪条日志对应哪次接口调用”的问题。
所以这次并不是“顺便加了个 requestId”,而是 requestId 真正参与了排错闭环。
九、为什么后来用模拟回复能证明代码没问题
在确认外部 API 挂了以后,为了继续验证本地接口逻辑,我临时把 service 改成直接返回一段模拟回复。
思路很简单:
- 如果绕过外部 LLM API 后,接口能正常返回 200
- 并且 response、requestId、日志、结构化返回都正常
- 那就说明本地代码主体链路没问题
这一步本质上是一种隔离法。
把“不稳定的外部依赖”先拿掉,单独验证自己的代码路径是否成立。
从结果上看,这一步也确实证明了:
- 本地请求处理链路正常
- requestId 传递正常
- 错误不在 middleware / handler / context 改动上
十、这次排查里几个值得记住的经验
1. 不要因为“刚改过这里”就认定问题一定出在这里
时间上的先后关系,不等于因果关系。
刚改完 context 就报 500,只能说明两件事发生得很接近,不能直接证明是 context 导致的。
2. invalid character '<' 基本可以优先怀疑上游返回了非 JSON
这是一个很有价值的经验型信号。
以后再看到类似错误,第一反应就应该是:
- 先看原始响应体
- 先看状态码
- 先确认是不是 HTML / 网关错误页
3. requestId 不只是“为了规范”,它真的能帮忙排错
平时总觉得链路追踪是“以后再说”的基础设施,但一旦接口出问题,它的价值会马上体现出来。
4. 排错要尽量把系统拆开验证
比如这次的做法就是:
- 先确认响应 header 是否正常
- 再确认 context 是否正常传递
- 再确认 JSON 解析报错位置
- 再确认上游原始响应
- 最后用 mock 隔离外部依赖
一层层拆下来,问题就很容易收敛。
十一、如果以后再做一遍,我会怎么做
如果下次再从头做类似功能,我会按下面这个顺序来:
第一步:先把 requestId 链路打通
- middleware 生成 id
- response header 返回 id
- context 注入 id
- 日志打印 id
第二步:先验证最小闭环
直接用一个最简单的接口确认:
- header 里能看到
X-Request-ID - 响应体里能看到
request_id - 服务端日志能查到同一个 id
第三步:再接外部依赖
这样一来,如果后面报错,就能很快区分:
- 是本地链路问题
- 还是外部 API 问题
第四步:调试日志只在定位问题时打开
像这种:
1 | log.Printf("[LLM DEBUG] status=%d body=%s", resp.StatusCode, string(bodyBytes)) |
在排查阶段非常好用,但问题确认以后最好回收,避免生产环境日志过多,或者把不该长期打印的响应内容写进日志。
十二、适合直接复用的几个结论
为了以后查阅方便,这里把这次最实用的结论单独列一下。
结论 1:requestId 建议同时出现在 response header 和日志里
这样客户端和服务端才能真正对齐。
结论 2:HTTP 请求里扩展 context,要基于 r.Context(),不要直接 context.Background()
正确写法:
1 | ctx := context.WithValue(r.Context(), "requestId", requestId) |
结论 3:看到 invalid character '<',优先检查上游返回是不是 HTML 或网关错误
不要急着先怀疑本地 JSON 结构。
结论 4:当怀疑外部依赖时,可以先用 mock 或临时固定返回验证本地逻辑
这是很实用的隔离思路。
总结
这两天最大的收获,其实不只是“把 requestId 做出来了”。
更重要的是,我对下面几件事的理解比之前清楚了很多:
- requestId 为什么值得做
- context 在 HTTP 请求里应该怎么正确传递
- 为什么链路追踪和日志是排错的基础设施
- 一个看起来像本地代码导致的 500,最后也可能是外部服务 502
如果只看结果,这次完成的是一个功能点:requestId 链路打通。
但如果看过程,这次更像是做了一次完整的后端排错演练。
而且这种演练很有价值,因为以后再碰到类似问题时,脑子里就不会只剩下“继续改代码试试”,而是会更自然地想到:
- 先看链路
- 先看日志
- 先看原始响应
- 先区分本地问题和外部问题
从这个角度看,这次收获到的其实是两样东西:
- 一个能工作的 requestId 链路
- 一套更清晰的排错思路
后者往往比前者更值钱。