成为全栈·Node 后端篇·结构化日志与请求链路追踪
开发时我们习惯了 console.log('到了这里')、console.log(user) 这种"打印大法"。它管用,因为你就一个人、一个断点、一个终端。可一旦项目上线、流量进来、用户开始报"我刚才那条请求怎么没了"------你会发现 console.log 像沙滩上的字,潮水一冲啥都不剩。
这一篇聊日志与链路追踪。它不性感,但决定了你半夜被叫起来时,是十分钟定位问题、还是通宵瞎猜。

一、为什么需要 traceId(请求链路 ID)
想象一个请求进来的生命周期:路由校验 → 调 service → 查三次数据库 → 调一次存储 → 返回。期间可能打了十几条日志,分布在五六个文件里。现在用户说"下午三点那次提交失败了",你要在浩如烟海的日志里,把属于那一次请求的十几条日志挑出来------靠什么挑?靠时间?同一秒可能有上千请求。靠"提交失败"关键字?那只能捞出失败的那一条,前面成功的步骤全丢了。
答案是给每次请求发一个唯一 ID ,它贯穿这次请求的所有日志。我们的信封里已经埋好了这个 ID------回忆 M1-08 讲过的 requestId:
ts
const envelope = <T>(code: number, message: string, data: T | null): Envelope<T> => ({
code, message, data,
requestId: requestId(), // 每次响应即时生成
timestamp: new Date().toISOString(),
});
requestId 就是天然的 traceId(链路追踪 ID) 。用户报障时把响应里的 requestId 甩给你,你拿它去日志系统一搜,这次请求从进来到崩溃的完整轨迹全出来了。这就是"快递单号"的价值------没有它,包裹在仓库里就是一团乱麻。
二、日志分级:不是所有日志都一样重要
console.log 把所有信息混为一谈。生产环境我们需要分级,常见的四档:
- debug:开发调试细节,生产默认关闭(量太大、含敏感风险)。
- info:正常业务节点("用户 7 发布了文章 123"),生产可保留关键路径。
- warn:不该发生但不致命("缓存未命中,降级查库")。
- error:出错了(异常、外部调用失败),必须告警。
我们目前代码里最显眼的一处错误级日志,就在 src/middleware/error.ts 的兜底分支:
ts
// 兜底:未知异常不应向客户端泄露堆栈
console.error('[unhandled]', err);
return failResponse(ErrCode.INTERNAL, 500);
未知异常来临时,它把完整错误打到服务端(注意:只打服务端、不返回客户端,呼应 M1-09 的"堆栈不泄露")。这是错误级日志的典范------它存在的唯一目的,就是出事时让你能顺着它查到根因。
分级的意义在于:生产环境可以把级别调到 info 或 warn,关掉 debug 的噪音,既省存储又让真正的异常不被淹没。级别是可调的旋钮,不是写死的。
三、结构化日志:让机器也能读懂
console.log('用户 ' + u.id + ' 登录了') 这种字符串拼接,人看着还行,机器处理起来是灾难------你想统计"今天登录失败多少次",得用正则去扒文本,脆弱又低效。
结构化日志的做法是:每条日志是一个 JSON 对象,字段固定、值规范:
json
{ "level": "info", "ts": "2026-08-27T15:04:00Z", "msg": "user_login", "userId": 7, "traceId": "a1b2c3" }
好处是立竿见影的:
- 可检索 :
level:error AND userId:7一条查询就能定位,不用正则。 - 可聚合:统计、画仪表盘、设告警都建立在结构化字段上。
- 可演进:加字段不影响旧解析,字段命名有契约可依。
举个具体对比就懂差距了。字符串日志时代,你想查"用户 7 的所有登录失败",得写 grep "登录" app.log | grep "失败" | grep "userId=7"------可一旦某同事日志里写成 uid:7 或 user 7,你的 grep 瞬间漏查。结构化日志时代,一条查询 level:error AND event:login_failed AND userId:7 精准命中,因为字段名稳定、值规范,不受文案怎么写影响。当日志量涨到每天千万条,这种"可检索性"的差距,就是"十分钟查到"和"查死也查不到"的区别。
我们信封里的 requestId / timestamp 其实就是"结构化思维"在响应层的体现------既然响应都结构化了,日志也没理由退回字符串。项目目前用 console.error 起步,但向生产演进时,第一件事就是换成结构化 logger(如 pino / winston),把字符串改成 JSON 对象。
四、请求链路串联:一条线串起所有日志
把第一、三节合起来,就是"链路追踪"的完整画面:
- 请求进入,生成
requestId(我们的信封每次响应都生成,handler 内部也可复用同一个)。 - 这次请求里每一条日志都带上
traceId: requestId。 - 排查时,按
traceId把分散在各文件、各函数的日志按时间排序,还原出完整时间线。
举个排查场景:用户说"我发布的文章没显示"。你拿到 requestId,一搜发现这条链路里:info 接收创建请求 → info 写入 articles 表成功(id=123) → warn 标签关联 article_tags 插入失败,已降级 → info 返回 200。一眼就看出:文章存成功了,但标签关联挂了导致前端以为没发布成功。定位从"通宵"变成"十分钟"。
这正是《工程公约》里"日志要规范"的初衷 工程公约------规范的日志不是给写代码的人看的,是给三个月后凌晨两点的自己看的。

五、落盘与脱敏:日志也会泄密
日志写哪、写什么,两件事都不能含糊。
落盘:开发时打终端就够了;生产要把日志写到文件或集中收集系统(如 Loki / ELK),保留一段时间供检索。终端日志进程一退就没了,等于没记。同时还要考虑日志轮转(按大小或日期切割)和保留期------无限增长会撑爆磁盘,保留太久又增加敏感信息的暴露面,两者都要有个度,这正是运维和开发要一起商定的事。
脱敏 :这是最容易被忽略的安全坑。日志里绝不能出现密码、令牌、完整身份证号这类敏感字段 。我见过有人为了"方便排查"把登录请求的整个 body 打进日志,结果密码明文躺在日志系统里半年,等于把用户密码库白送攻击者。console.error('[unhandled]', err) 之所以安全,是因为 err 是程序异常对象,通常不含用户输入的明文密码;但如果你手滑 console.log('login body', body),那就是事故。铁律:日志只记"能公开给外人看"的信息, userId、操作类型、traceId 这些够了,密码令牌一个字都不许进日志。
还有个容易被忽视的点(P-50):日志字段命名也要跟契约对齐,别让它悄悄 stale 。我们信封的 requestId / timestamp 是契约定义的字段,如果将来引入结构化日志,它的字段名(如 traceId 还是 requestId、ts 还是 timestamp)应当和契约词汇表保持一致,避免"代码里叫 traceId、契约里叫 requestId、文档里又叫 reqId"的三套名字漂移。注释和文档会随契约过时,日志字段名也是一样------凡是和契约沾边的命名,都以契约为准。
六、诚实交代:我们目前到哪一步
写到这里得说句实话,免得你照着文章去 src/shared/logger.ts 找文件------当前冻结版本里并没有独立的 logger 模块 。我们有的是:信封自带的 requestId(链路追踪的地基已就位)、error.ts 里的 console.error('[unhandled]') 兜底(错误级日志的雏形)。
这是刻意的"轻量起步":先让关键信息(requestId、错误兜底)就位,不为了"看起来专业"而过早引入重型日志框架。等你项目真上了量,再引入 pino 这类零开销结构化 logger、接上集中收集,成本是低的------因为 requestId 已经贯穿全程,接入时只要把"每条日志带 traceId"这一步补上即可。架构的节奏感,往往比一步到位更重要。
七、小结与前瞻
日志与链路追踪,是把"能跑"推进到"可观测"的关键:
- traceId 是命脉 :每次请求一个唯一 ID,串起它所有的日志;我们的
requestId已就位。 - 日志分级:debug/info/warn/error,生产可调级别、关掉 debug 噪音。
- 结构化优先:日志写 JSON 而非字符串,机器可检索、可聚合。
- 链路串联 :每条日志带
traceId,排查时按 ID 还原时间线。 - 落盘 + 脱敏:日志写文件/集中收集;密码令牌绝不进日志。
- P-50 :日志字段命名也跟契约对齐,别让
traceId/requestId这类名字三套漂移。 - 诚实现状 :当前用
requestId信封 +console.error兜底起步,logger 模块留待上量时引入------地基已稳,演进成本低。
下一篇({{LINK:M1-12}})我们聊认证方案:JWT 还是 Session?两者真实差异在哪、无状态意味着什么代价、以及我们为什么选"JWT 访问令牌 + 有状态刷新令牌"这条折中路线。
如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」:
🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html
📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer
