出问题时别再翻一堆 print:vibe 项目的结构化日志实战
日志是产品的飞行记录仪,但前提是按规矩写。结构、级别、关联 ID、字段、脱敏、采样、留存:一个一人团队今天就能落地的七件事,出问题时才不会靠猜。

先说判断:日志不是给机器看的,是给出问题时那个凌晨三点的你看的

几乎每个 vibe 项目都会走到同一个夜晚:用户说“刚才支付失败了”,你打开日志,只看到满屏的 console.log('here')、console.log(data)、console.log('ok')。时间戳没有,哪个用户不知道,哪一步失败了不知道,失败之前发生了什么更不知道。你只好去猜,去复现,去问用户“你当时点了什么”。这个夜晚本可以不存在。
我的判断先摆出来:日志不是“打印点东西方便调试”,而是产品的一份“飞行记录仪”。飞机出事之后,调查员靠的不是飞行员的回忆,而是黑匣子里按时间顺序记下来的每一项数据。你的产品也一样:用户遇到问题时,你能拿出来的唯一可靠证据,就是事发当时系统自己写下来的记录。记录得越结构化,事后复原就越快;记录得越随意,排查就越像算命。
这篇文章讲的就是怎么把日志从“随手打印”升级成“飞行记录仪”。不讲大厂那套动辄几十万的日志平台,只讲一个一人团队今天就能落地的做法:结构、级别、关联 ID、字段、脱敏、采样、留存,七件事做完,日志就能在出问题时真正帮上忙。
以下例子里出现的产品“拾光账本”与人物、数字均为虚构,仅用于说明写法;方法本身是通用的。
第一步:把日志写成结构,而不是写成句子
先看两行日志,感受一下区别。第一行是大多数 vibe 项目一开始的样子:
2026-11-05 10:23:11 用户 123 支付失败了,金额 99,原因是余额不足
第二行是结构化之后的样子:
{"ts":"2026-11-05T10:23:11+08:00","level":"warn","event":"payment.failed","user_id":"u_123","amount_cents":9900,"reason":"insufficient_balance","request_id":"req_9f31"}
第一行是给人读的句子,第二行是给机器读的数据。区别在于:第二行可以被搜索、被统计、被报警。想查“过去一小时余额不足的支付失败有多少次”,第一行要靠肉眼一行行数,第二行只要一条查询。想对“支付失败”设一个报警阈值,第一行做不到,第二行是现成的。
结构化的核心就三条。第一,一行日志就是一条 JSON,字段名固定,值是机器可读的类型:金额用“分”不用“元”,时间用 ISO 8601 带时区,状态用枚举值不用自然语言。第二,每个事件有一个固定的名字,比如 payment.failed、auth.login、ai.generate,用点分隔的命名空间,别让每个人发明自己的叫法。第三,字段只记录事实,不记录情绪:写 reason":"insufficient_balance,别写“用户又没钱了”。
落地的方法很简单:不要直接调用 console.log,封装一个最小的 logger。以后所有日志都走这个封装,格式由封装保证,而不是靠每个人的自觉。一个最简单的封装只需要做三件事:自动加上时间戳和级别,自动带上当前请求的关联 ID,把对象序列化成一行 JSON。十几行代码,收益是之后每一次排查。
第二步:级别要有含义,不要全是 info
级别混乱是第二常见的毛病:所有日志都是 info,出问题时在一堆 info 里捞针;或者反过来,满屏都是 error,真正的错误反而淹没了。级别不是“严重程度的装饰”,而是“这件事需不需要人处理”的分工。
我的分法是四级,各管一摊。debug 只在开发环境开,记“为了理解代码行为而需要的信息”,比如某个函数的输入输出;上线之后默认关闭,出了问题再针对性打开。info 记“业务上正常发生、事后可能要查的事”,比如用户注册、订单创建、支付成功。warn 记“事情没按预期走,但系统自己处理了”,比如重试了一次才成功、用了降级方案、某个外部接口慢了。error 只记“需要人介入的事”,比如支付扣款后写库失败、AI 调用连续三次超时、后台任务崩溃。
这套分法的检验标准很简单:error 级别的日志出现时,应当有人收到通知;如果 error 每小时出现几百条还没人管,说明级别定错了——要么把不该是 error 的降级成 warn,要么把真正的问题修掉。反过来,如果一周都没有一条 error,也别高兴得太早:可能是错误都被 try-catch 吞掉、连日志都没写。定期翻一下 warn 的数量,warn 长期为零的系统,往往不是没问题,而是没记下来。
第三步:给每个请求一个关联 ID,让日志能串起来
一个用户操作在系统里往往要经过好几步:网关、鉴权、业务逻辑、数据库、外部 API、AI 模型。没有关联 ID,这几步的日志就是散落的珠子;有了关联 ID,它们才是一条线。关联 ID 的做法不复杂:请求进来时生成一个唯一 ID,比如 req_9f31,之后这个请求产生的所有日志都带上它,返回给用户的错误信息里也可以带上它。
关联 ID 的价值在排查时最直观。用户说“刚才支付失败了,错误码是 req_9f31”,你拿这个 ID 一查,十分钟内能看到这次请求的完整时间线:哪一步花了多久、哪一步重试了、哪一步返回了什么。没有这个 ID,你只能按时间加用户 ID 去猜,运气好五分钟,运气不好一下午。
关联 ID 还要往下传:调外部 API 时把它放进请求头,调 AI 模型时把它写进元数据,推后台任务时把它放进任务载荷。这样,一次操作从前端到后端、从同步到异步,全程都能用同一个 ID 串起来。后端任务尤其容易被忽视:很多任务失败之后只在任务系统里留一条孤零零的错误,没有人知道它属于哪个用户、哪次操作。任务载荷里带上关联 ID,这个问题就消失了。
第四步:字段优先于句子,记“能查的”,不记“好看的”
结构化日志最容易犯的错误,是把句子原样塞进 JSON:{"msg":"用户 123 支付 99 元失败,余额不足"}。格式是 JSON,内容还是句子,等于什么都没改。正确的做法是把句子拆成字段:谁、做了什么、结果如何、关键数字是多少。句子可以由字段在展示时拼出来,但存储时必须是字段。
哪些字段值得记?我的清单是:事件名、时间、级别、关联 ID、用户 ID、关键业务 ID(订单号、订阅号)、结果(成功/失败/重试)、耗时(毫秒)、外部依赖的名字和状态码。AI 调用要多记两项:模型名和 token 用量——这两项直接关系到成本,出问题时能立刻定位是“模型换了”还是“用量爆了”。
反过来,有三类东西不要记进日志。第一是密码、token、密钥、完整的信用卡号,任何情况下都不行。第二是用户的完整隐私内容,比如聊天记录全文、私信内容、身份证号。第三是超大对象,比如整个请求体、整张图片的 base64——日志是用来定位问题的,不是用来备份数据的。大对象只记摘要:长度、哈希、关键字段,而不是全文。
第五步:脱敏要在写日志之前做,不是在查日志的时候补
脱敏是结构化日志里最容易被“以后再说”的一步,也是出事之后代价最大的一步。正确的做法是在 logger 封装里做集中脱敏:维护一份敏感字段清单,比如 password、token、secret、api_key、card_number、id_card、phone,写日志之前先过一遍,命中清单的字段一律替换成 [REDACTED]。脱敏发生在数据落盘之前,而不是事后去清洗已经存下来的日志——已经落盘的敏感数据,删起来比不写进去难十倍。
有两类脱敏特别容易漏。第一类是嵌套对象里的敏感字段:请求体里套着用户信息,用户信息里套着手机号,只检查顶层字段是拦不住的。脱敏函数要递归处理,或者在序列化之前把敏感路径拍平检查。第二类是错误对象:很多错误会把请求体、响应体整个包进 message 或者 stack,日志一打就全泄了。记录错误时,只记错误的类型、消息和关键上下文,别把整个错误对象原样序列化。
还有一条纪律:日志的访问权限要和数据库一样对待。不是所有人都能看生产日志,看日志的人要知道什么能截图、什么不能外发。脱敏做得再好,也防不住一张把用户手机号截进去的截图。技术和纪律要同时到位。
第六步:采样和留存,控制成本而不是控制视力
日志的量一上来,成本立刻就成了问题。一个日活几千的产品,每天产生几百万行日志并不稀奇。控制成本的方法不是“少记”,而是“分级留存”:error 和 warn 全量保留,info 按业务重要性保留,debug 只在需要时临时打开。采样也要分情况:正常请求的 info 日志可以按比例采样,比如只留 10%;但失败请求的日志必须全量保留——出问题时最需要的,恰恰是失败的那几条。
留存时间同样要分级:error 至少保留 90 天,warn 保留 30 天,普通的 info 保留 7 到 14 天就够了。超过留存期的日志要自动清理,别让它一直堆着。清理之前可以先做聚合:把每天的错误按事件名和原因分组,记下来次数和趋势,原始日志删掉之后,趋势还在。
这里要避开一个坑:不要为了省钱把日志采样到“出问题时刚好没有”的程度。采样比例的检验标准是:拿最近三次真实故障复盘,看看当时的日志够不够定位根因。如果三次里有一次是因为“那条日志被采样掉了”,采样就太狠了,先把失败路径的采样关掉,再谈省钱。
三个排查场景:日志怎么写,决定了问题多久能定位
场景一:用户说支付扣款成功了,但订单还是未支付。没有结构化日志时,你要先问用户什么时候付的、多少钱,再去支付平台对账,再去数据库查订单状态,至少半小时。有结构化日志时,你按订单号查 payment.succeeded 和 order.paid 两个事件,看中间隔了什么:如果是回调没收到,日志里会有网关超时的记录;如果是回调处理时写库失败,error 日志里会有唯一键冲突或者连接池耗尽的记录。十分钟,根因就出来了。
场景二:AI 功能突然变慢,用户开始抱怨。没有日志时,你只能猜是模型慢了、还是网络慢了、还是自己的代码慢了。有结构化日志时,你按时间把 ai.generate 事件的耗时画出来,再按模型名分组:如果只是某个模型变慢,是上游的问题;如果所有模型都变慢,是自己的网络或者并发打满了。分组一出,责任方就清楚了,接下来是找上游还是修自己,不用再吵。
场景三:凌晨三点,后台任务静悄悄地停了。没有日志时,你早上才发现,丢了半夜的数据。有日志时,任务每次运行都记一条 job.finished,带上处理条数和耗时;任务系统再配一个最简单的“心跳”检查:如果超过预期时间没有新的 job.finished,就发一条告警。日志在这里的角色不是事后排查,而是事前发现:它让“停了”这件事本身变得可见。
日志的查询习惯:出问题时先查什么
日志写得再好,不会查也是白搭。出问题时的查询习惯,值得单独练一练。我的顺序是三步,而且每次都按这个顺序来,不凭感觉乱翻。
第一步,按关联 ID 查。如果用户给了错误信息里的请求 ID,直接拿它查,一次就能看到这次请求的完整时间线。这是最快的一条路,十次里有八次到这一步就够了。如果用户没给请求 ID,就按用户 ID 加时间窗口查:先圈定“出问题前后十分钟”,再按用户 ID 过滤,范围立刻就小了。
第二步,按事件名加时间窗口查。不知道具体是哪次请求时,就查事件名:比如支付失败,就查过去一小时所有 payment.failed 事件,按原因分组。如果某个原因突然暴涨,那就是根因的方向;如果分布均匀、没有突变,问题可能不在支付本身,而在上游,比如网关或者数据库。分组的习惯很重要:一条一条看日志是看不出趋势的,分组之后趋势自己会说话。
第三步,按错误分组查。把过去一天的 error 日志按事件名和错误类型分组,看哪一组数量最多、哪一组是新出现的。新出现的错误组往往对应最近一次上线引入的问题;数量突然翻倍的组,往往对应外部依赖的故障。这一步的价值在于,它能把“用户反馈的个案”变成“系统层面的模式”:一个用户说支付失败是个案,一百个用户在同一小时支付失败就是事故。
日志的价值不在写的时候,而在查的时候;查询习惯的价值不在平时,而在出问题那一刻。养成这三个查询习惯之后,排查就从“翻日志”变成了“问问题”:先问“这次请求发生了什么”,再问“这个事件最近有什么变化”,最后问“哪一类错误在变多”。问题问对了,答案往往就在眼前。
落地清单:今天就能做的七件事
第一,封装一个 logger,所有日志走它,不再直接调用 console.log;封装自动加时间戳、级别和关联 ID。第二,定一份事件名清单,统一命名,不再临场发明。第三,级别按“需不需要人处理”来定,error 出现就告警。第四,请求进来生成关联 ID,前后端、同步异步全程透传。第五,敏感字段清单加递归脱敏,在落盘之前做。第六,失败路径的日志全量保留,成功路径可以采样。第七,每月做一次故障复盘,复盘的第一个问题永远是:当时的日志够不够定位根因?不够,就补日志,而不是补猜测。
最后回到开头那句话:日志是给凌晨三点的你准备的。写日志的时候,多想一步“出问题时我需要看到什么”,排查的时候就能少花一小时。结构化日志不是什么高深技术,它只是把“随手记”换成“按规矩记”。规矩定下来之后,日志会从一堆需要翻找的文本,变成一份真正能用的飞行记录仪。而这份记录仪的价值,要到你第一次靠它在十分钟内定位根因的那天,才会真正显现出来。
那一天你会发现,之前花在封装日志器、定事件名、接关联标识上的每一个小时,都在那十分钟里连本带利地还回来了。日志写得好,排查就是查记录;日志写得差,排查就是凭运气。
评论 (0)
相关文章

预览是文件类产品的第一道门:三秒内确认“这是我要的文件”。按类型选渲染方式、安全边界、移动端、性能,按这个顺序做才不会返工。

Cognition 11 月 4 日发布 SWE-2 Flash:比 SWE-2 快 3 倍、便宜 5 倍,11 月 30 日前免费试用。真正的看点是新增的中期训练阶段,以及权重这次不公开的决定。

OpenAI 11 月 4 日发布 Images 2.6:ChatGPT 与 Codex 里直接生成 GIF,参考图可存下来反复用,API 分出 Flare 与 Sunburst 两档。速度是最不值得单独说的部分,真正改变用法的是记忆与动作。