前言

这两天在练手项目 go-chat-api 里,表面上只是想做一件很小的事:给每个请求补一个 requestId,方便看日志、方便查问题。

但实际做下来以后,问题并没有停留在“加个 header”这么简单。中间一路牵出了几个很典型、也很适合复盘的点:

  • requestId 到底放在 request、response,还是两边都放
  • context.WithValue 应该基于谁来扩展
  • 为什么明明写了 header,却看不到 x-request-id
  • 为什么改完 context 以后接口开始报 500
  • 为什么报错信息里会出现 invalid character '<'
  • 最后为什么能确认不是代码问题,而是外部 LLM API 返回了 502

这篇文章一方面是记录这次排查过程,另一方面也顺手整理成一篇以后自己查阅时能直接拿来用的笔记。

先说结论

这次最终确认了两件事:

  1. requestId 链路已经完整打通了
  2. 后面的 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
2
ctx := context.WithValue(r.Context(), "requestId", requestId)
r = r.WithContext(ctx)

重点有两个:

  1. 不是新起一个孤立的 context,而是基于 r.Context() 扩展
  2. 扩展完以后,要把新的 context 挂回到 r 上继续传递

这是不是意味着以后不能用 r *http.Request 了?

不是。

这一点我当时也有疑问:

如果用了 context,是不是所有函数都不能再用 r *http.Request 接参数了?

答案是完全不用这么想。

r *http.Request 还是照常传,context 只是 request 身上的一部分。正确理解应该是:

  • 业务上继续通过 r 拿请求信息
  • 链路数据通过 r.Context() 往下传

这两者不是替代关系,而是配合关系。


四、这次 requestId 链路是怎么打通的

从结构上看,这次比较合理的链路是:

  1. middleware 生成 requestId
  2. 写入 response header
  3. 放进 request context
  4. handler / service 从 context 读取
  5. 日志里统一打印 requestId
  6. 出错时响应体里也可以带上 request_id

可以把它理解成这样一条链:

1
2
3
4
5
6
7
请求进入
→ middleware生成UUID
→ 写入 X-Request-ID 响应头
→ context.WithValue 注入 requestId
→ r.WithContext(ctx) 继续传递
→ handler/service 读取并打印日志
→ 出错响应携带 request_id

这样做完以后,基本就具备了最小可用的链路追踪能力。


五、为什么改完以后开始报 500

这一步是最容易误判的。

因为时间上看,问题是在我改完 context 以后出现的,所以直觉上会觉得:

是不是我把 context 改坏了?

这其实是排错时很典型的误区:最后改过的地方,不一定就是根因。

后来拿到实际报错后,返回内容是这样的:

1
2
3
4
5
{
"code": 500,
"message": "invalid character '<' looking for beginning of value",
"request_id": "fa8a869a-ac41-4d1c-9caa-7b785e52189d"
}

当时第一反应是“格式问题”,这个判断方向其实是对的,但还不够具体。


六、invalid character '<' 到底说明了什么

这个报错在 Go 里很有辨识度。

它通常意味着:

  • 程序本来准备按 JSON 去解析响应体
  • 但实际拿到的内容不是 JSON
  • 并且响应内容是以 < 开头的

最常见的情况有三种:

  1. 上游返回了一段 HTML 错误页
  2. 网关返回了非 JSON 的错误页面
  3. 某个中转服务返回了纯文本或异常内容

也就是说,问题不在 json.Unmarshal 本身,而在于:

上游接口根本没有按预期返回 JSON。

这时候如果还一直盯着本地 handler、middleware、context 去改,方向就偏了。


七、怎么把问题继续定位下去

既然怀疑是上游响应格式不对,那下一步就不是猜,而是直接看响应原文。

于是我在调用 LLM API 的地方加了调试日志:

1
log.Printf("[LLM DEBUG] status=%d body=%s", resp.StatusCode, string(bodyBytes))

然后重新发请求,服务端日志很快给出了关键信息:

1
2
[LLM DEBUG] status=502 body=error code: 502
[LLM ERROR] 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
2
ctx := context.WithValue(r.Context(), "requestId", requestId)
r = r.WithContext(ctx)

结论 3:看到 invalid character '<',优先检查上游返回是不是 HTML 或网关错误

不要急着先怀疑本地 JSON 结构。

结论 4:当怀疑外部依赖时,可以先用 mock 或临时固定返回验证本地逻辑

这是很实用的隔离思路。


总结

这两天最大的收获,其实不只是“把 requestId 做出来了”。

更重要的是,我对下面几件事的理解比之前清楚了很多:

  • requestId 为什么值得做
  • context 在 HTTP 请求里应该怎么正确传递
  • 为什么链路追踪和日志是排错的基础设施
  • 一个看起来像本地代码导致的 500,最后也可能是外部服务 502

如果只看结果,这次完成的是一个功能点:requestId 链路打通。

但如果看过程,这次更像是做了一次完整的后端排错演练。

而且这种演练很有价值,因为以后再碰到类似问题时,脑子里就不会只剩下“继续改代码试试”,而是会更自然地想到:

  • 先看链路
  • 先看日志
  • 先看原始响应
  • 先区分本地问题和外部问题

从这个角度看,这次收获到的其实是两样东西:

  • 一个能工作的 requestId 链路
  • 一套更清晰的排错思路

后者往往比前者更值钱。