ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Node.js集中式日志实战:Pino+PM2+OpenSearch从采集到检索

Node.js集中式日志实战:Pino+PM2+OpenSearch从采集到检索 那次凌晨两点半的线上事故我到现在还记得几十个 Node.js 服务实例分别在各自的服务器上打印日志出问题时我靠着grep和tail -f一台一台翻最后定位到根因已经是三个小时后。事后团队决定把集中式日志体系真正落地上篇聊完了架构选型和整体设计这篇直接把代码铺开——Pino 负责把日志变成结构化的 JSON 行PM2 管住进程和日志生命周期OpenSearch 兜底存储与检索。如果你正被日志散落各处、排查靠问、监控靠猜折磨这篇文章就是给你的。1. 目标架构一条日志从产生到可检索的完整链路1.1 这套体系承载的三件事集中式日志体系说白了就干三件事采集、传输、检索。采集发生在应用进程内部也就是 Node.js 代码里这步决定日志的格式和内容传输负责把分散在每台机器上的日志文件安全地送到中心存储检索则是在 OpenSearch 里把海量日志变成能回答问题的东西——刚刚那个请求到底报了什么错这个 requestId 经历了哪几个服务。很多团队栽在第一步日志没有结构化就是一段拼出来的字符串到后面不管是 Filebeat 采集还是 OpenSearch 查询都会很难受。我们的做法是让应用只输出 NDJSONNewline Delimited JSON每行一条完整日志既是给人看的也是给机器看的。1.2 组件边界谁管格式谁管进程谁管存储我习惯用一句话划定边界Pino 管日志怎么产生PM2 管进程怎么活OpenSearch 管日志怎么存。PinoNode.js 生态里性能最好的日志库之一输出 JSON 行格式支持级别、序列化器、脱敏机制。PM2Node.js 进程守护和负载均衡负责多实例启动、崩溃重启、优雅退出保证日志文件在进程切换时也能连续写入。OpenSearch存储和检索引擎Elasticsearch 的开源分支兼容所有 ES 的 API 和生态用于做索引、全文搜索、聚合分析和告警。这个划分很关键。日志体系最容易犯的错就是职责混乱比如让 PM2 去处理日志格式或者让应用直接拼请求发 HTTP 给搜索服务。边界清晰出了问题才知道去哪一层排查。1.3 动手前的环境假设既然已经是下篇我默认你具备这些基础一个或多个 Node.js 服务Express/Koa/Fastify 均可PM2 已安装并能正常管理进程OpenSearch 集群已部署并可从应用所在网络访问。如果你的 OpenSearch 还没搭单机模式用 Docker 拉一个镜像先跑起来也够用。另外记住一个总原则应用层永远不要阻塞地去裸写日志文件也不要自己实现日志轮转。这些交给专门的机制处理后面你会看到这么做的好处。2. 选型复盘为什么锁死 Pino PM2 OpenSearch2.1 Pino 凭什么是生产级日志库选日志库我第一个看的是性能。Node.js 是单线程事件循环同步写日志会直接阻塞请求处理。console.log 在低并发下看不出问题一旦请求量上来打印一个嵌套对象引发的 JSON 序列化就能让吞吐量断崖式下跌。Pino 的核心技巧是把日志序列化压缩到极致配合 SonicBoom 实现近乎零开销的写入。官方基准测试里Pino 在很多场景下比 Winston 快出一个数量级。我实测过一个中等流量的 order-api 服务接入 Pino 后 p95 延迟变化可以忽略不计。更重要的一点Pino 默认输出就是 JSON 行。这意味着日志从诞生的那一刻就是结构化的不需要后面再用正则去拆。你可以后续接 Filebeat、Logstash、Fluentd或者直接写到 OpenSearch管道天然兼容。2.2 PM2 对日志体系的真实作用生产环境不可能裸跑node app.js崩了谁来拉起多核机器怎么利用PM2 解决的是进程层面的问题。对日志体系来说PM2 的价值在于两点一是多实例统一管理二是提供进程退出时的钩子保证日志在进程死亡前能完整落盘。有人说 PM2 太老K8s 才是正道。但现实是大量中大型项目仍在用 PM2 跑在云主机上它简单、可靠、生态成熟配合 pm2-logrotate 处理日志轮转也很顺手。这篇文章只讨论 PM2 场景K8s 的容器化日志是另一套思路不展开。2.3 OpenSearch 与 Elasticsearch 的取舍如果你在 2021 年以前问这个问题答案几乎是 Elasticsearch 一统天下。但后来 Elastic 修改了 license很多团队为了规避合规风险转向了 OpenSearch。OpenSearch 是从 Elasticsearch 7.10 分叉出来的API 高度兼容生态工具Filebeat、Kibana 对应的 OpenSearch Dashboards基本对齐。对我们这种规模的项目来说OpenSearch 在检索、聚合、生命周期管理上完全够用而且开源协议更友好。唯一要注意的是版本节奏OpenSearch 的演进比 ES 慢半拍某些插件生态需要额外适配但核心的日志检索场景不受影响。2.4 不引入 Kafka 和 Logstash 的理由很多集中式日志架构图里都会画上 Kafka 和 Logstash做个漂亮的管道出来。但我要泼一盆冷水如果你的日志量没有达到每秒几十万条级别Kafka 只会增加运维复杂度和故障点。Logstash 同理它是一台 JVM 机器吃内存大户配置繁琐。替代方案是轻量级的 FilebeatGo 写的单一二进制采集端部署成本极低。我们的链路是应用写 JSON 日志文件 → Filebeat 读文件 → OpenSearch。中间没有重量级组件日志延迟控制在秒级以内出问题也好排查。3. Pino 代码落地一条符合规范的 JSON 日志是怎么产出的3.1 先替换 console.log我接入 Pino 的第一步是把代码里的console.log、console.error全部换掉。不是靠人肉改而是用 ESLint 规则强制限制commit 前就拦住// .eslintrc.js module.exports { rules: { no-console: error, }, };如果你的团队里有大量历史代码可以先用一个过渡期的 wrapper把 console 方法重定向到 pino但最终目标还是消灭裸 console 调用。原因很简单console.log 的输出是字符串没有级别、没有时间戳格式化、没有上下文进了 OpenSearch 之后你根本没法按级别筛选。3.2 生产级配置level、redact、serializers、timestamp这是 Pino 配置的重头戏。下面这份配置我从生产环境里直接拿出来的逐个字段解释为什么这么写const pino require(pino); const { v4: uuidv4 } require(uuid); const logger pino({ level: process.env.LOG_LEVEL || info, base: { service: order-api, env: process.env.NODE_ENV || development, instance: uuidv4(), }, timestamp: () ,timestamp:${new Date(Date.now()).toISOString()}, redact: { paths: [ req.headers.authorization, req.headers.cookie, *.password, *.secret, ], censor: [REDACTED], }, serializers: { err: pino.stdSerializers.err, req: pino.stdSerializers.req, res: pino.stdSerializers.res, }, });逐个拆解base每条日志都会带上 service、env、instance 三个字段。service 用于区分应用env 区分测试/生产instance 是个随机 UUID用来标识这一轮启动的进程实例。注意这里没有默认的 pid 和 hostname因为 Pino 默认会带它们你可以在 base 里显式覆盖。timestamp这是最容易被忽略的一个点。Pino 默认的时间字段叫time值是一个 epoch 毫秒数字。但 OpenSearch 更习惯timestamp而且我们想要 ISO 8601 字符串。所以我覆盖了默认的 timestamp 函数生成timestamp字段。这在后面配索引模板时会省很多麻烦。redact日志最容易出事的地方是不小心把用户密码打出来。redact 支持路径匹配像*.password这样的通配符能覆盖任意层级的嵌套对象。censor 的默认值是[Redacted]我改成大写避免歧义。serializersPino 自带对 Error、Request、Response 的序列化。比如pino.stdSerializers.err会把 Error 对象转成{ type, message, stack }的结构而不是 JSON.stringify 出来的空对象。这个必须配否则日志里的错误信息会丢失。3.3 child logger 与 requestId 链路透传集中式日志体系里最重要的一件事叫关联性。一次请求会经过中间件、数据库查询、第三方 API 调用会打印十几条日志。没有关联 ID这些日志就是一堆孤立的点。在 Express 里我通常是写一个中间件const { v4: uuidv4 } require(uuid); app.use((req, res, next) { const requestId req.headers[x-request-id] || uuidv4(); res.setHeader(x-request-id, requestId); req.log logger.child({ requestId }); next(); });然后所有业务代码里都使用req.log.info(...)而不是全局 logger。这样的话同一次请求的所有日志都自动携带同一个 requestId。到了 OpenSearch 里我只要按 requestId 一查整个调用链就还原出来了。logger.child()底层是创建一个新的 logger 实例绑定额外的 context 字段开销很小可以在每请求粒度使用。如果你用的是 Fastify它内置了 req.log但生产环境我也会手动覆盖让 requestId 的取值逻辑可控。3.4 落盘还是落 stdoutdestination 的三种形态Pino 的日志去向由 destination 决定常见三种形态去向写法适用场景stdoutpino()不传参数开发、Docker 容器内采集文件pino.destination({ dest: ./logs/app.log, sync: false })传统 PM2 Filebeat网络pino.transport({ target: pino-socket })直连日志收集器我推荐生产环境走文件形态而且sync: false打开。Pino 的异步写入内部用 SonicBoom 维护一个缓冲区由后台线程负责刷盘应用主线程不会被 I/O 拖累。代价是进程突然被 kill -9 时可能丢最后几行日志这个风险可以通过优雅退出机制降到最低第 4 章会讲。const logDest pino.destination({ dest: /app/logs/order-api.log, sync: false, }); const logger pino({ /* 上面的配置 */ }, logDest);注意异步写入模式下进程退出前记得调用logger.flush()否则缓冲区里的日志会丢失。这条在进程管理器配置里要设计好。4. PM2 侧配置进程管理绕不开的日志细节4.1 让 PM2 闭嘴out_file 与 error_file 的设置很多人接入 PM2 后会发现一个诡异现象日志到了 PM2 的~/.pm2/logs/目录格式不再是我们预期的 JSON或者文件轮转不受控制。这是因为 PM2 默认会接管 stdout/stderr 并把内容写入自己的日志体系。如果应用自己通过 Pino 的 destination 写文件就应该让 PM2 完全不碰日志// ecosystem.config.js module.exports { apps: [ { name: order-api, script: ./src/app.js, instances: 2, exec_mode: cluster, out_file: /dev/null, error_file: /dev/null, merge_logs: true, kill_timeout: 5000, shutdown_with_message: true, }, ], };out_file和error_file设为/dev/nullPM2 就不会再创建自己的日志文件。有人问那 PM2 的日志难道一点都不留吗进程启动失败的输出还是会打到 stderr但因为你设了 /dev/null现场不好查。我的做法是PM2 日志文件路径保留到一个独立目录但只作为进程级排障用途不作为业务日志依赖。如果你刚上手可以先留着确认 Pino 落盘正常后再切 /dev/null。4.2 cluster 多实例写文件的原子性PM2 的 cluster 模式会启动多个 worker 进程共享同一个端口。如果每个 worker 都通过 pino.destination 写同一个文件会不会出现日志行互相穿插答案是Linux 下 O_APPEND 模式单次 write 不超过 PIPE_BUF通常 4096 字节时是原子的所以单行 JSON 不会交错。Pino 的 SonicBoom 写入正是追加模式。但这里有个隐患如果某条日志超过了缓冲区大小SonicBoom 会拆成多次 write原子性就没了。长日志主要出现在 stack trace 上。我的处理方案是二选一方案 A每个实例写独立文件路径带上 pid例如/app/logs/order-api-${process.pid}.logFilebeat 读整个目录。方案 B控制单条日志长度pino 不直接提供 maxSize但可以自定义 serializer 对长字段截断。我们最终选了方案 B 为主配合对 stack 的截断处理。因为在 OpenSearch 里按 pid 分文件的意义不大反正日志内容里有 pid 字段反而合并文件后查询更方便。但如果你发现有穿插马上切方案 A别犹豫。4.3 SIGTERM、kill_timeout 与日志落盘的最后一步shutdown_with_message: true配合kill_timeout: 5000的意思是PM2 在重启/停止进程时先给应用发一个 shutdown 消息等 5 秒超时再强制 SIGKILL。应用收到 shutdown 信号后要做的事包括但不限于停止接收新请求、logger.flush()、关闭数据库连接。// src/app.js process.on(message, async (msg) { if (msg.type shutdown) { server.close(async () { await logger.flush(); process.exit(0); }); } });这一步的价值在于异步写入模式下缓冲区里可能还有没刷盘的日志尤其是高并发瞬间。如果不做 flush重启后最后几秒的日志就丢了排查线上问题时这往往是关键信息。4.4 文件轮转pm2-logrotate 与 pino-roll 的分工日志文件无限增长是不可接受的。如果你让 PM2 管理日志文件pm2 install pm2-logrotate是标配。但当应用自己写日志时pm2-logrotate 管不到你的应用日志目录。我用的方案是 pino-roll直接在 Pino 层做轮转const pinoRoll require(pino-roll); const transport pinoRoll({ file: /app/logs/order-api.log, frequency: daily, dateFormat: yyyy-MM-dd, mkdir: true, limit: { size: 200MB }, }); const logger pino({ /* 配置 */ }, transport);pino-roll 是基于 pino 的 transport 机制实现的子线程处理文件轮转不影响主线程性能。按天 按大小双条件轮转文件名带上日期。Filebeat 的 filestream input 会自动感知新文件的出现继续采集。顺带提醒如果你用的是系统级 logrotate比如/etc/logrotate.d/配置注意 logrotate 默认会 rename 日志文件但应用仍然持有旧文件的 fd继续向旧 inode 写入。解决办法是配置copytruncate模式。既然有 pino-roll 这种应用内方案就别再去折腾系统 logrotate 了。5. 日志汇入 OpenSearchFilebeat 管道与索引模板设计5.1 Filebeat 采集配置filestream ndjson 解析Filebeat 7.10 推荐使用filestreaminput替代老旧的loginput。它是按文件来跟踪的新文件出现、文件轮转都能正确处理。下面是我更新后的配置# filebeat.yml filebeat.inputs: - type: filestream id: nodejs-json-logs enabled: true paths: - /app/logs/*.log parsers: - ndjson: target: overwrite_keys: true expand_keys: true fields: log_type: nodejs fields_under_root: true output.opensearch: hosts: [https://opensearch.internal:9200] username: ${OPENSEARCH_USER} password: ${OPENSEARCH_PASSWORD} indices: - index: app-logs-%{yyyy.MM.dd} when.equals: log_type: nodejs几个关键点parsers.ndjson会把每一行 JSON 解析后合并到事件根层面。这样 Pino 产出的timestamp、level、message字段都直接成为事件的顶层字段OpenSearch 里查询非常直观。overwrite_keys: true允许日志里的字段覆盖 Filebeat 默认字段比如log.level避免歧义。expand_keys: true会把嵌套 key 中的特殊字符展开比如a.b: 1变成a: { b: 1 }这个按需开启。fields_under_root: true把自定义的log_type放进事件根用于 downstream 的索引路由。如果某些行不是合法 JSONFilebeat 会报解析错误并把原始行放进message或者丢弃取决于target设置。第 7 章我会专门讲这个坑。5.2 索引模板把时间字段和 message 映射写正确OpenSearch 只靠动态映射也能跑但日志检索的高效性直接取决于索引模板。我现在用的模板长这样PUT /_index_template/app-logs-template { index_patterns: [app-logs-*], priority: 100, template: { settings: { number_of_shards: 3, number_of_replicas: 1, refresh_interval: 5s, index.mapping.total_fields.limit: 2000 }, mappings: { properties: { timestamp: { type: date }, level: { type: keyword }, message: { type: text }, service: { type: keyword }, env: { type: keyword }, pid: { type: long }, requestId: { type: keyword }, err.type: { type: keyword }, err.message: { type: text }, err.stack: { type: text }, hostname: { type: keyword } } } } }我要重点解释为什么level和requestId用keyword而不是text。keyword 类型用于精确匹配、聚合、排序text 类型用于全文搜索。requestId一定是精确查询你永远不想对一坨 UUID 做分词搜索。而message、err.stack是全文搜索的用武之地用 text。如果你把字段类型搞反了查询性能会差很多而且后面改 mapping 需要重建索引成本很高。refresh_interval我设成 5s 而不是默认的 1s这个是性能和实时性的权衡。日志场景延迟 5 秒完全可接受但写入吞吐能提升不少后面第 7 章有实测数据。5.3 ISM 生命周期不让日志索引无限膨胀日志索引是按天分的如果不管OpenSearch 集群会被老索引拖死。OpenSearch 的 ISMIndex State Management可以设定索引从 hot 到 warm 再到 delete 的流转策略。PUT /_plugins/ism/policies/log_retention_policy { policy: { description: 日志保留 14 天, default_state: hot, states: [ { name: hot, actions: [], transitions: [ { state_name: warm, conditions: { min_index_age: 7d } } ] }, { name: warm, actions: [ { replica_count: { number_of_replicas: 0 } } ], transitions: [ { state_name: delete, conditions: { min_index_age: 14d } } ] }, { name: delete, actions: [ { delete: {} } ], transitions: [] } ] } }然后把策略关联到索引模板的 settings 里settings: { index.plugins.index_state_management.policy_id: log_retention_policy }策略逻辑是前 7 天处于 hot 状态保持 1 个副本保证可用性第 7 天进入 warm副本降为 0 以节省磁盘warm 阶段的节点一般有冗余第 14 天直接删除索引。这样磁盘占用就是有界、可控的。5.4 不需要 Filebeat 的轻量直写方案如果你的日志量不大比如每秒几百条以内不想引入 Filebeat 这个额外组件Pino 生态里也有直写 OpenSearch 的方案。这类 transport 的底层就是调用 OpenSearch 的 bulk API把日志批量插入。// pino-opensearch 这类 transport const transport pino.transport({ target: pino-opensearch, options: { node: https://opensearch.internal:9200, index: app-logs, auth: { username: ..., password: ... }, bulk.size: 1000, flush.interval: 1000, }, });直写方案的优点是链路短、延迟低、少一个组件缺点是应用进程里多了一个 HTTP 依赖OpenSearch 短暂不可用时日志会滞留在 transport 的队列里甚至丢失。Filebeat 的优势恰恰是磁盘持久化 断点续传扛得住下游抖动。我的建议是有没有 Filebeat 取决于你多看重日志的可靠性。金融、交易类系统我会坚持用 Filebeat内部工具类小服务直写也行。6. 检索实战在 OpenSearch Dashboards 里用好这批日志6.1 单条 error 日志的现场还原日志进了 OpenSearch 后最基础的操作是筛选 error 级别。在 OpenSearch Dashboards 的 Discover 页面输入level: error时间范围选最近 15 分钟你就能看到这段时间内所有服务的报错。点开任意一条err.type告诉你异常类型err.stack告诉你调用栈service告诉你哪个服务hostname告诉你哪台机器。我见过很多团队停留在这一步有报错就翻 Redis 查这条日志。但这只是第一步更重要的是往下追问这个错误影响了谁是不是同一个来源6.2 requestId 串联一次完整请求集中式日志体系的王牌功能是链路还原。先在一条 error 日志里找到 requestId然后切换筛选器requestId: d72b9c8e-4f1a-4e0b-9b3e-1a2b3c4d5e6f回车之后你能看到这一次请求从进入网关到业务处理到最终报错的全过程。level字段里的 info、warn、error 按时间排序后就是这条请求的完整时间线谁调用了什么、在哪个环节变慢了、在哪个环节抛错了。如果跨服务调用在 A 服务请求 B 服务时把 requestId 传递到 HTTP headerx-request-id那么 B 服务的日志里也会有同一个 requestId。这样一次多服务链路就粘连起来了。传递逻辑很简单HTTP 客户端拦截器里把当前上下文的 requestId 塞进 header。6.3 高频查询模式与字段设计建议日志系统跑起来之后有几类查询会成为日常场景查询示例说明按服务查错误service: order-api and level: error快速定位是哪个服务在出问题慢请求排查service: order-api and duration: 1000前提是你在日志里打了 duration 字段用户维度问题userId: 10086 and level: warn客服反馈某用户操作异常上下游状态level: error and env: production区分环境有些错只在生产出现为了让这些查询跑得稳我强烈建议在日志字段设计上遵循两个规则所有用于过滤的分类字段都用 keyword 类型比如 service、env、userId、requestId所有用于全文搜索的字段用 text 类型比如 message、err.stack。还有一个配套建议在 Pino 的调用处养成打结构化字段的习惯例如logger.info({ durationMs: 123 }, query completed)而不是logger.info(query completed, took 123ms)。前者在 OpenSearch 里可以直接对 durationMs 做范围筛选和聚合后者只能当字符串看。6.4 时区与时间戳对齐的细节日志系统里时间不一致是经典坑。Pino 时间戳用 ISO 8601 带时区2024-06-01T02:30:00.123ZOpenSearch 里的timestamp字段会自动归一化为 UTC 存储Dashboards 展示时按浏览器时区转换。这个其实是好事——存储统一展示灵活。真正会出错的是如果你一开始没自定义 Pino 的 timestamp 函数默认输出 epoch 毫米数字OpenSearch 自动 mapping 会把它识别成long类型Dashboards 里简直没法按时间过滤。所以我在第 3 章的配置里强烈建议从一开始就把timestamp定义为 ISO 字符串这一步省下来的后期返工时间远超配置成本。另外如果你的日志里同时存在timePino 默认和timestamp自定义两个字段建议在序列化前就屏蔽默认的 time 字段。具体做法是配置base: { time: undefined }不优雅正确做法是像第 3 章那样直接用自定义 timestamp 覆盖默认的 time 字段不会共存。7. 上线后真实踩过的坑格式污染、积压、实时性7.1 一个隐藏换行符引发的 JSON 解析失败体系上线第二周我们发现 Filebeat 采集到的日志里某些记录莫名其妙缺失。排查后发现不是 Pino 的问题而是有人绕过 Pino 直接用了 console.log打的还是一段多行文本比如console.log(Received webhook payload:\n JSON.stringify(body, null, 2));console.log 会原样输出换行符一行 JSON 被拆成了三行。Filebeat 的 ndjson parser 处理多行 JSON 时会解析失败把后续行扔掉或者归并到错误事件里。应对策略有三层ESLintno-console规则堵住源头在代码评审里明确日志必须走 loggerFilebeat 侧如果可以接受加multilineparser 把不完整的行拼接回来。但第 3 层是治标不治本多行 JSON 的拼接策略写起来很恶心而且 pino 本身不会产生多行 JSONJSON.stringify 默认转义换行符为\n转义序列所以核心还是管住人。7.2 高流量下 Filebeat 内存队列的积压另一个坑发生在一次大促流量峰值。应用日志正常写入文件但 OpenSearch Dashboards 里出现了 10 分钟以上的查询延迟。看 Filebeat 日志发现queue.mem一直处于满状态新的文件事件读不进来。Filebeat 默认的queue.mem.events是 3200queue.mem.flush.min_events是 512。当 OpenSearch 写入变慢比如跨机房网络抖动队列打满后 Filebeat 会停止读取文件形成背压日志延迟就上来了。我的调优策略是queue.mem.events: 65536 queue.mem.flush.min_events: 2048 queue.mem.flush.timeout: 5s改完之后高峰期日志延迟稳定在 5 秒以内。如果你们的数据量再大一个量级Filebeat 8.x 还支持 disk queue把事件持久化到磁盘可靠性更强代价是要预留磁盘空间。我自己用内存队列 65536 已经够了日志系统不是交易系统可以允许秒级延迟。7.3 OpenSearch 分片与 refresh interval 的实时性权衡日志索引的 shard 数量和 refresh interval 是两件容易被忽略但影响很大的事。shard 数量决定写入和查询的分布并行度。索引模板里我设置了 3 个主分片。有人会问单机 OpenSearch 也设 3 个分片有意义吗有意义但不是因为并行写入而是为了后续水平扩容时无需重建索引即可分散数据。shard 过多也会带来问题每个分片都是有开销的如果一天只有几百 MB 日志拆成 10 个分片纯属浪费。日志类索引的分片大小最好控制在 5-30GB 之间按一天的日志量倒推分片数即可。refresh interval 我设 5s 而不是默认 1s是为了减少 refresh 带来的 I/O 和 CPU 消耗。这是实时性和性能的折中——日志晚几秒可见换来了更平稳的写入表现。如果你的业务想看到近乎实时的日志设 1s 也不是不行但集群规模小时要留意写入毛刺。实测下来同样是每秒 5000 条文档的写入refresh_interval 从 1s 调到 10s写入高峰期的 CPU 占用能降低约 30%对于我这种运维人力不足的小团队来说这个收益很值。最后说一个实操体会这套体系上线后最直观的变化不是日志更好查了而是团队解决问题的路径变了——以前是先猜后看日志现在直接打开 Dashboards 按 requestId 把现场还原出来省下的时间就是半夜少掉的揪头发时间。下一篇如果有机会我会聊聊基于这套日志体系做错误率告警和性能分析看板的实践。
返回列表