Max
搜索
返回故事会

Node.js 日志:从打印到光看就知道哪里出问题了

45 分钟阅读0Max ZhangBackend
Node.js日志监控ELK

凌晨三点被叫醒的人,都欠日志一句道歉。

那个让你想砸键盘的凌晨三点

手机震了。

你从梦里被拉出来,眯着眼看屏幕。运维在群里 @你:"用户服务挂了,帮忙看一下。"

你从床上爬起来,掀开笔记本,SSH 进服务器。tail -f app.log

屏幕开始疯狂滚动。每秒几百行。

"用户登录成功"。 "订单创建成功"。 "数据库连接正常"。

没问题啊。

继续滚。

"库存查询成功"。 "支付回调处理中"。 "用户退出登录"。

还是没问题啊。

你已经看了十分钟了。眼睛开始发酸。屏幕上每一行都他妈的长得差不多。你知道出错了——不然运维不会半夜叫你——但你完全看不出错在哪里。

于是你开始 grep。先搜 "error"——出来 47 条,大部分是"邮箱格式不正确"。不是这个。

再搜 "fail"——出来 200 多条,大部分是"登录失败"的业务日志,跟宕机无关。

再搜 "timeout"。

找到了。

一个下游 API 的超时参数被设成了 100 毫秒。不是 100 秒。是 100 毫秒。

改回去,重启,好了。

你关掉电脑。躺在黑暗里。脑子里只有一个念头:卧槽,如果日志写得够好,这条信息应该在五秒内出现在我眼前。不是十分钟。

这就是这篇文章要讲的事。

不是"日志是什么"——你早就知道了。是"日志怎么不让你在凌晨三点想砸键盘"。是怎么让日志从一个让你头疼的东西,变成你最好的搭档。


一、日志不是"打印",它是你的监控录像

先说清楚一件事。

console.log("hello") 不是日志。

那个叫"打印"。跟你在便利贴上随手写个字一样。写完就丢了。下次想看?找不到。

真正的日志是有结构的、持久化的、可被检索的。它不是你顺手敲的几行字,是你部署在应用里的一套基础设施。

打个比方。

你开了一家 24 小时便利店。有一天盘账发现少了 500 块钱。

你怎么查?

如果店里装了监控摄像头,你调出那天的录像,十分钟就知道是谁、什么时间、怎么拿的钱。

如果没装摄像头呢?你只能对着账本干瞪眼。

日志就是你的应用程序的监控录像。它什么都不干,就在那录着。平时像个费电的铁疙瘩蹲在角落里。出事了,它就是祖宗。

但这里有个关键问题——很多人没想过。

录像的质量决定了你能多快找到问题。

你装的是 4K 高清夜视摄像头,还是路边 30 块钱买的 480P?你的录像保留一个月还是三天就覆盖了?出事的时候你能按时间检索,还是得从头看到尾?

这些东西,都是日志系统要回答的问题。不是"有没有日志"的问题——是"日志好不好用"的问题。

日志能干什么,不能干什么

很多新手觉得日志万能。出错了看日志,用户行为分析看日志,性能优化也看日志。

日志确实能干不少事,但它有明确的边界。

它能干的:

  • 记录错误发生的现场快照——谁在什么时候、调了什么接口、传了什么参数、返了什么错误
  • 还原一次请求的完整路径——前提是你加了 requestId
  • 统计某个错误出现了多少次——前提是你用了结构化日志
  • 审计追踪——"这个用户在过去 24 小时内干了什么"

它不能干的:

  • 告诉你为什么会出错。日志记录的是事实,不是分析。你得自己从事实里推出结论。
  • 主动通知你出错了。日志是被动的。错误发生了,你如果不主动去看,你永远不知道。这是告警系统的事。
  • 分析性能瓶颈。"为什么这个 API 慢了 200 毫秒?"——日志能告诉你哪些环节被调用了,但不会告诉你瓶颈在哪。这是链路追踪的事。
  • 发现缓慢的内存泄漏。日志看起来一切正常,但系统正在慢慢走向 OOM。这是指标监控的事。

一句话:日志给你原材料,但不会替你做菜


二、Node.js 里日志怎么落地——从 console 到文件

好了,道理讲完了。现在说说代码。

stdout 和 stderr 是两条路

在 Node.js 里,console.logconsole.error 走的是两条完全不同的管道。

console.log 输出到 stdout(标准输出流)。 console.error 输出到 stderr(标准错误流)。

这是什么意思?你可以在终端里把它们分开处理:

# stdout 存一个文件,stderr 存另一个
node app.js > normal.log 2> error.log

# 只把 stdout 管道传给 grep,stderr 仍然打印到屏幕
node app.js 2>/dev/null | grep "success"

正常信息走前门,错误信息走后门。这不是 Node.js 发明的,是 Unix 几十年前就定下来的规矩。你懂了它,就能在命令行里玩出花来。

但大部分 Node.js 开发者这辈子都没用过这个特性——他们只知道 console.log,所有东西都往里灌,然后奇怪为什么日志看起来很乱。

console 藏了多少好东西

说实话,我以前也只用 console.log。直到有一次调试一个嵌套了七层的回调地狱,我才发现 console 这个对象里藏了不少能救你头发的东西。

打印变量名和值一起——ES6 对象简写最实用的场景之一:

const userId = 'u_123'
const orderId = 'ord_456'
const amount = 99.9

// 你以前的写法:
console.log('userId:', userId, 'orderId:', orderId, 'amount:', amount)

// 现在的写法:
console.log({ userId, orderId, amount })
// { userId: 'u_123', orderId: 'ord_456', amount: 99.9 }

少打了几十个字符,还不用对齐冒号。这种小东西用习惯了以后,再看老代码里那一串串的字符串拼接,你会有一种"我他妈的以前在干什么"的感慨。

表格打印——看数组数据的神器。

有一次我需要排查一个批量导入的问题,几百行用户数据,类型混乱。用 console.log 打印出来就是一堆 JSON 糊在屏幕上:

console.table([
  { name: '小明', role: '管理员', status: 'active' },
  { name: '小红', role: '普通用户', status: 'inactive' },
  { name: '小刚', role: '普通用户', status: 'active' },
])

终端直接给你画一张对齐的表格。行是行,列是列。一眼就能看到小红的状态不对。

调用栈追踪——console.trace()

function validateOrder(order) {
  if (!order.items) {
    console.trace('谁他妈的传了个空订单进来?')
    throw new Error('订单为空')
  }
}

function processCheckout(data) {
  validateOrder(data.order)
}

function handleRequest(req) {
  processCheckout(req.body)
}

trace 会打印出完整的调用链——handleRequest → processCheckout → validateOrder。你在一个被嵌了七层的函数里加个 trace,立刻就知道是谁在调你。不用翻几十个文件的 import 关系。

性能计时——console.time()console.timeEnd()

console.time('数据库查询')
const users = await db.query('SELECT * FROM users WHERE status = ?', ['active'])
console.timeEnd('数据库查询')
// 数据库查询: 234.56ms

不用自己手算 Date.now() 的差值。而且可以嵌套——你在一个大的 time('整个请求') 里嵌几个小的 time('查用户')time('查订单'),清清楚楚。

条件断言——console.assert()

const userCount = await db.query('SELECT COUNT(*) FROM users')
console.assert(userCount > 0, '用户表是空的,这不对劲')
// 只有当 userCount > 0 是 false 的时候才打印

这个东西在开发阶段特别好用——你假设某个条件一定成立,如果它有天不成立了,你第一时间就知道。

分层分组——console.group()console.groupEnd()

console.group('数据库操作')
console.log('查询用户表...')
console.group('SQL 详情')
console.log('SELECT * FROM users WHERE id = ?')
console.groupEnd()
console.log('返回 1 条记录')
console.groupEnd()

终端会显示缩进的层级结构。开发时可以展开看细节,平时折叠起来不占地方。

对象深度查看——console.dir()

const deepObj = {
  level1: { level2: { level3: { value: '到底了' } } },
}

// console.log 可能只显示到第 2 层
console.dir(deepObj, { depth: null, colors: true })
// 指定 depth: null ——全给我展开,别藏着

这些 API 不是为了让你在生产环境里用的——它们是给你开发时快速定位问题用的。但关键是,很多人连它们的存在都不知道。知道和不知道,调试效率差三倍。

往文件里写日志——流 vs 暴力 append

开发时用 console 就够了。但生产环境里,日志必须持久化到文件。不然服务器一重启,你昨晚的 bug 现场就没了。

新手最容易犯的错误是这个:

import fs from 'node:fs'

function log(message) {
  fs.appendFile('app.log', message + '\n', () => {})
}

// 业务代码里疯狂调用
app.post('/order', async (req, res) => {
  log('开始创建订单')
  const order = await createOrder(req.body)
  log('订单创建成功:' + order.id)
  res.json(order)
})

你看,每次 appendFile 调用都要:

  1. 打开文件
  2. 把内容写到末尾
  3. 关闭文件

你这个接口一秒被调了 1000 次,就是 1000 次开-写-关。操作系统烦死了。而且系统对同时打开的文件数量有限制——你可能会遇到"打开文件过多"的报错。

正确做法是用可写流

import fs from 'node:fs'

const logStream = fs.createWriteStream('app.log', { flags: 'a' })

function log(message) {
  logStream.write(message + '\n')
}

// 程序退出时优雅关闭
process.on('SIGINT', () => {
  logStream.end()
  process.exit(0)
})

流就像一条水管。文件在管子那头一直开着,你往这头倒水,水先积在缓冲区里,攒够一批再一起冲到磁盘。不用每次倒一杯水都重新接一次管子。

还有一个细节:write() 返回 false 的时候,说明内部缓冲区满了。高并发场景下你需要等 drain 事件再继续写:

function safeWrite(stream, data) {
  if (!stream.write(data)) {
    stream.once('drain', () => stream.write(data))
  }
}

但说实话,大部分 Node.js 应用的日志量还没到需要关心这个的程度。你知道有这么回事就行。

一个让你困惑的概念:inode

聊完了写文件,有一个 Linux 层面的事情必须讲清楚——inode。这件事跟你日志切割的体验直接相关。

Linux 里每个文件都有一个 inode 号。

ls -i app.log
# 1234567 app.log

inode 存着文件的元数据(权限、大小、时间戳)和指向磁盘数据块的指针。文件名只是一个标签,真正的身份标识是 inode 号。

打个比方:你家是一栋房子,inode 就是你家的门牌编号。文件名是你在外卖软件上随便写的"小红家"。你把"小红家"改成"快递放门口",门牌号不变,外卖小哥还是能找到。

对日志来说这意味着什么?

当你运行 rm app.log 删除一个正在被进程写入的日志文件时——文件名从目录里消失了,但 inode 还在。进程抓着文件描述符,持续往里写数据。磁盘空间不会被释放,直到进程关闭那个文件描述符。

这解释了为什么日志切割不能用"删除重建"的方式——你得把 app.log 重命名为 app-2026-03-10.log,让进程继续往原来的 inode 写,然后通知进程重新打开一个新文件。

我们后面讲日志切割的时候还会回来谈 inode。


三、生产环境的日志应该长什么样

请求日志:少一个字段都等于白记

每个 HTTP 请求,不管返回 200 还是 500,都必须留一条日志。这是底线。

但记录什么?

很多人的请求日志长这样:

GET /api/users/123 200

这跟摄像头拍到一团黑影差不多——你知道有人来过了,不知道是谁,不知道待了多久,不知道干了什么。

一条合格的请求日志至少应该包含:

timestamp   — 什么时间
method      — GET/POST/PUT/DELETE
url         — 请求的完整路径
status      — 返回了什么状态码
duration    — 花了多长时间
ip          — 谁在请求
userId      — 哪个用户在操作
traceId     — 这次请求的全局追踪 ID

代码不复杂:

app.use((req, res, next) => {
  const start = Date.now()

  res.on('finish', () => {
    console.log(
      JSON.stringify({
        timestamp: new Date().toISOString(),
        method: req.method,
        url: req.originalUrl,
        status: res.statusCode,
        duration: Date.now() - start,
        ip: req.ip,
        userId: req.user?.id,
        traceId: req.headers['x-trace-id'],
      }),
    )
  })

  next()
})

注意这行:res.on('finish', ...)。不要在 next() 之后直接 log。因为那个时候你的业务逻辑可能还没执行完,status code 还没确定,duration 也算不准。finish 事件是在响应完全发送出去之后才触发的。

外部调用日志:你的服务不是孤岛

你的 Node.js 应用大概率会调用其他后端服务。

调谁了、传了什么参数、花了多长时间、返回了什么——这些你都得记。不然哪天用户跟你说"网站好慢",你只能对着代码猜是哪一环慢了。

async function callBackend(url, params) {
  const start = Date.now()
  const requestId = crypto.randomUUID()

  try {
    const res = await axios.post(url, params, {
      headers: { 'X-Request-ID': requestId },
      timeout: 5000,
    })

    const duration = Date.now() - start

    console.log(
      JSON.stringify({
        type: 'backend_call',
        requestId,
        service: url,
        duration,
        status: res.status,
        timestamp: new Date().toISOString(),
      }),
    )

    return res.data
  } catch (err) {
    const duration = Date.now() - start

    console.error(
      JSON.stringify({
        type: 'backend_error',
        requestId,
        service: url,
        duration,
        error: err.message,
        timestamp: new Date().toISOString(),
      }),
    )

    throw err
  }
}

这里有一个容易被忽视的设计选择:为什么要在成功和失败两条日志里都记 duration?因为有时候失败不是因为超时,而是下游服务一调就立刻返回了错误——这时候 duration 很短,你能快速排除"网络问题",把矛头指向业务逻辑。

业务日志:记关键节点的状态变化

请求日志告诉你"有人调了这个接口",业务日志告诉你"这个人到底干了什么"。

拿创建订单举例。你要记三个状态:

开始 — "有人要下单了"。订单 ID 是什么,买了什么,多少钱。

成功 — "下单成功了"。最终成交价格是多少。

失败 — "下单失败了"。原因是什么——库存不足?支付超时?还是参数校验没过?

三条日志用同一个订单 ID 串起来。出事了搜这个 ID,整个流程一目了然。

async function createOrder(data) {
  const orderId = generateOrderId()

  console.log(
    JSON.stringify({
      type: 'business',
      action: 'order_start',
      orderId,
      productId: data.productId,
      quantity: data.quantity,
      totalPrice: data.totalPrice,
    }),
  )

  try {
    const stockOk = await checkStock(data.productId, data.quantity)
    if (!stockOk) {
      console.log(
        JSON.stringify({
          type: 'business',
          action: 'order_failed',
          orderId,
          reason: 'stock_insufficient',
        }),
      )
      throw new Error('库存不足')
    }

    const order = await saveOrder(orderId, data)

    console.log(
      JSON.stringify({
        type: 'business',
        action: 'order_success',
        orderId,
        totalPrice: data.totalPrice,
      }),
    )

    return order
  } catch (err) {
    console.error(
      JSON.stringify({
        type: 'business',
        action: 'order_error',
        orderId,
        error: err.message,
      }),
    )
    throw err
  }
}

有始有终,有因有果。这样的日志,凌晨三点出问题的时候,你搜一下 orderId,故事线自己就展开了。

错误处理——try/catch 里面的日志才是最有价值的

有几个陷阱值得单独拿出来说。

陷阱一:Promise.reject 不会被同步 try/catch 捕获。

// 这他妈的不会生效
try {
  Promise.reject(new Error('出错了'))
} catch (err) {
  console.error(err) // 永远不会执行
}

// 必须 await 或 .catch()
await Promise.reject(new Error('出错了')) // try/catch 会生效
Promise.reject(new Error('出错了')).catch((err) => console.error(err))

陷阱二:你以为处理了,其实进程在裸奔。

最危险的不是你没写 try/catch——是你写了,但没包住。比如你在 Express 路由里写了 try/catch,但没放全局兜底。当某个没被包住的异步 reject 发生时,进程里 unhandledRejection 事件被触发,而你根本没监听这个事件——错误就"吃"掉了。

必须加全局兜底:

// 最后一层防线
process.on('unhandledRejection', (reason, promise) => {
  console.error(
    JSON.stringify({
      type: 'unhandled_rejection',
      message: reason?.message || reason,
      stack: reason?.stack,
      timestamp: new Date().toISOString(),
    }),
  )
  // 记录完日志后,建议让进程优雅退出
  // 因为此时程序状态可能已经不一致
  process.exit(1)
})

process.on('uncaughtException', (err) => {
  console.error(
    JSON.stringify({
      type: 'uncaught_exception',
      message: err.message,
      stack: err.stack,
      timestamp: new Date().toISOString(),
    }),
  )
  process.exit(1)
})

Node.js 官方文档里明确建议:捕获到 uncaught exception 后应该退出进程。因为此时程序的状态可能已经不可靠了。你能做的就是在退出之前把现场记录下来。

日志级别——凌晨三点这个判断救你的睡眠

很多人把所有日志都打成 info。或者全打成 error。这跟不打是一回事——分不出轻重缓急。

从低到高:

trace — 最细粒度的。追踪代码执行路径,比 debug 还细。基本不在生产环境开启。只有排查极端诡异的 bug 时才用。

debug — 开发时调试用。SELECT * FROM users WHERE id = ? 这种。上线后关掉。

info — 正常业务流程。订单创建成功、用户登录、文件上传完成。这些是 info。你日常看日志的主旋律。

warn — 不对劲但还没死。数据库连接池用了 85%。某个 API 的 P99 延迟到了 2 秒。值得关注,但不致命。

error — 真出错了。请求失败、异常被捕获、业务逻辑失败。需要你来修,但不至于立刻挂。

fatal — 马上要挂了。数据库彻底连不上了。内存溢出了。进程即将退出。这是你在这个进程里能看到的最后一条日志。

一个判断标准:凌晨三点收到这条日志,你要不要从床上爬起来?

级别行动
trace / debug继续睡
info翻个身继续睡
warn眯眼看一眼,大概率继续睡
error坐起来
fatal弹射起床

结构化日志:为什么 JSON

你这个周三下午搭了个小项目。一台服务器,日志用字符串打印,看着挺舒服:

[2026-03-10 10:00:00] INFO: 用户 user_123 登录成功

没问题。

三年后。这个项目变成了你的主业。50 台服务器。每天几亿条日志。你把它们灌进 ELK。

现在你要搜 user_123 的所有日志。你在 Kibana 里搜索 user_123

返回来一堆结果——有些是 userId 字段里的,有些是日志消息正文里偶然出现的,有些是 URL 里的 query param。

你搜不准。

这才是 JSON 登场的时候:

{
  "timestamp": "2026-03-10T10:00:00.000Z",
  "level": "info",
  "message": "用户登录成功",
  "userId": "user_123",
  "ip": "192.168.1.100",
  "requestId": "req_abc789"
}

在 Kibana 里搜 userId: "user_123"——精准命中。不会匹配到其他任何地方。

这就是结构化日志的威力。它让你能问问题,而不是猜谜语

三个铁律——它们救过很多人的命

一、日志函数不能抛异常。

日志是你应用的基础设施。基础设施不能自己塌了。

// 傻逼写法
function log(msg) {
  if (!msg) throw new Error('消息为空')
  console.log(msg)
}

// 你调 log 的时候如果没传参数,log 函数自己抛个异常把调用者炸了。这他妈的是帮倒忙。

正确做法:日志内部 try/catch 吞掉所有错误。日志失败了,业务必须继续。你可以把日志错误写到 stderr 兜底,但绝不能让它影响主流程。

二、日志不能有副作用。

你不能因为记日志就把对象改了几个字段。

// 错误
function logUser(user) {
  console.log(user)
  user.lastLogged = new Date() // 副作用!
  await db.save(user) // 你只是在记日志,为什么改数据库?
}

记日志和做业务是两件事。摄像头不能去挪便利店的货架。

三、敏感信息脱敏。

密码、手机号、身份证、银行卡、Token——这些东西在日志里出现的唯一形式是 ***

const SENSITIVE = ['password', 'token', 'secret', 'apiKey']

function sanitize(obj) {
  const clean = { ...obj }
  for (const key of SENSITIVE) {
    if (clean[key]) {
      clean[key] = clean[key].length > 10 ? clean[key].slice(0, 5) + '...' + clean[key].slice(-5) : '***'
    }
  }
  return clean
}

想象一个场景:你的 error 日志被某个初级运维复制粘贴到了公司大群里。群里 200 个人都看到了明文密码。这已经不是技术问题,是安全事故。


四、日志切割——别让日志吃掉你的硬盘

不切割的下场

app.log 一天涨 500MB。

一周 3.5GB。一个月 15GB。三个月 45GB。

某个周三的凌晨,磁盘写满了。Node.js 进程试图写日志,操作系统返回"No space left on device"。进程崩了。用户服务挂了。你的手机又震了。

日志切割就是把一个无限增长的大文件,按时间或大小拆成多个小文件,定期清理旧的,保住你的磁盘空间。

两种策略

按时间切。

app-2026-03-08.log
app-2026-03-09.log
app-2026-03-10.log
app.log   ← 正在往这写

每天午夜,把当前的 app.log 重命名成包含日期的文件名,然后新建一个 app.log 继续写。保留最近 30 天的。

适合日志量稳定的应用。

按大小切。

app-001.log  (100MB)
app-002.log  (100MB)
app-003.log  (80MB)   ← 正在往这写

文件一到 100MB 就切。保留最近的 50 个文件。

适合日志量忽大忽小的应用。

切割方式的坑

create 方式(重命名):

  1. app.log 重命名为 app-2026-03-10.log
  2. 创建新 app.log

问题来了:进程还抓着原来的 inode,继续往 app-2026-03-10.log 里写。新的 app.log 收不到数据。

解决:重命名之后给进程发个信号——比如 SIGUSR2——让它关闭旧文件、打开新文件。

copytruncate 方式(拷贝后清空):

  1. app.log 的内容拷贝到 app-2026-03-10.log
  2. 把原 app.log 内容清空(truncate)

优点是进程不需要知道发生了什么——它继续往同一个 inode 写就行。缺点是拷贝和清空之间有极短的间隙,正在写入的数据可能会丢失。

logrotate 配置

在 Linux 上,这个东西叫做 logrotate。大部分发行版已经自带了:

# /etc/logrotate.d/myapp

/var/log/myapp/*.log {
    daily           # 每天切
    rotate 30       # 保留 30 天
    compress        # 旧日志压缩,节省空间
    delaycompress   # 昨天的日志先不压(你可能会马上翻它)
    missingok       # 日志文件不存在也不报错
    notifempty      # 空的日志不切,省得生成一堆 0 字节文件
    create 0640 myapp myapp   # 新文件权限
    sharedscripts   # 多个日志文件共享一次 postrotate 脚本

    postrotate
        kill -USR2 $(cat /var/run/myapp.pid)
    endscript
}

postrotate 里的 kill -USR2 就是你通知 Node.js 进程重新打开日志文件的信号。你的 Node.js 进程里需要监听这个信号:

process.on('SIGUSR2', () => {
  logStream.end()
  logStream = fs.createWriteStream('app.log', { flags: 'a' })
})

五、当终端日志变得好玩——命令行美化

这一章跟生产环境关系不大。它是给你的开发体验加料的。但好的开发体验能让你更愿意记日志。

chalk:给你的终端上色

import chalk from 'chalk'

console.log(chalk.red('错误信息'))
console.log(chalk.green('操作成功'))
console.log(chalk.yellow('警告:内存使用率 85%'))
console.log(chalk.bold.blue('粗体蓝色'))

// 模板字符串
console.log(chalk`{red 失败} {green 成功} {yellow 注意}`)

开发时一堆白花花的文字从眼前滚过,你根本注意不到那条红色的错误。加上颜色,眼睛自动就能抓到。

progress:进度条

import ProgressBar from 'progress'

const bar = new ProgressBar('下载中 [:bar] :percent 剩余: :etas', {
  total: 100,
  width: 30,
})

const timer = setInterval(() => {
  bar.tick()
  if (bar.complete) {
    clearInterval(timer)
    console.log('\n下载完成')
  }
}, 100)

终端显示:

下载中 [███████████████] 52% 剩余: 4.8s

inquirer:交互式命令行

import inquirer from 'inquirer'

const answers = await inquirer.prompt([
  {
    type: 'input',
    name: 'name',
    message: '你叫什么名字?',
  },
  {
    type: 'list',
    name: 'env',
    message: '选一个环境:',
    choices: ['dev', 'staging', 'production'],
  },
  {
    type: 'confirm',
    name: 'confirm',
    message: '确定继续?',
    default: false,
  },
])

commander.js:命令行工具框架

import { Command } from 'commander'

const program = new Command()
program.name('myapp').description('我的工具').version('1.0.0')

program
  .command('serve')
  .description('启动服务')
  .option('-p, --port <number>', '端口', 3000)
  .action((opts) => console.log(`服务启动于端口 ${opts.port}`))

program.parse()

这三个库——chalk、inquirer、commander——组合起起来,你就是命令行艺术家了。哪怕是内部工具,好用和难用之间的差距就是你的同事是爱你还是恨你。


六、日志收集——当你有不止一台服务器

一个灵魂拷问

你有了 10 台服务器。每台上都有 app.log。

用户投诉:昨天下午 3 点下单失败。你怎么查?

一台一台 SSH 上去 grep?

等你 grep 完第三台,老板已经在群里 @你第五遍。十台 grep 完——半小时过去了。用户已经去用竞品了。

这就是为什么你需要日志集中管理。所有服务器的所有日志汇总到一个地方,你一个搜索框,秒出结果。

听起来是大厂才需要的东西,对吧?

错了。哪怕你只有两台服务器,也应该做集中管理。因为两台服务器出问题的时候,你要在两台之间来回切,已经够烦了。

ELK:三件套

ELK 是三个开源工具的缩写:

Elasticsearch — 存储和搜索。一个专门为文本搜索优化的分布式数据库。你往里面灌 JSON,它能以毫秒级速度检索几十亿条。

Logstash — 处理管道。一个数据加工厂,把 Filebeat 发来的原始日志做解析、过滤、格式转换。把时间戳解析成标准格式,把嵌套 JSON 展平,把敏感字段打码。

Filebeat — 收集器。装在你每台服务器上的小代理。轻得要命,吃不了多少 CPU。只干一件事:盯着日志文件的新增内容,读到数据就发给 Logstash。

还有一个不属于"ELK"缩写但不可或缺的:

Kibana — 可视化界面。你在浏览器里搜日志、画图表、搭仪表盘的地方。

数据流是这样的:

你的 Node.js 应用 → 写 JSON 到 app.log
                         ↓
                    Filebeat 监听到新内容
                         ↓
                    Logstash 解析 & 过滤
                         ↓
                    Elasticsearch 存储 & 索引
                         ↓
                    Kibana 展示 & 搜索

五分钟搭一套

用 Docker Compose,你本地五分钟就能跑起来:

git clone https://github.com/deviantony/docker-elk.git
cd docker-elk
docker-compose up -d
# 浏览器打开 http://localhost:5601

就这么简单。你的个人项目也能用上全套日志平台。别觉得"这东西离我还远"——它离你就是五分钟的事。

Node.js 端用什么库

两个选择摆在你面前:

Pino — 快。比 winston 快差不多 10 倍。默认输出 JSON,零配置。极简。如果你不需要花哨的功能——比如同时输出到五个不同地方——Pino 是你的首选。

Winston — 全。Transport 插件多到令人发指——文件、控制台、Elasticsearch、HTTP、MongoDB……你能想到的输出目标它都有对应的 transport。功能丰富,生态成熟。

import pino from 'pino'

const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
  base: { service: 'user-service' },
  timestamp: pino.stdTimeFunctions.isoTime,
})

logger.info({ userId: 'user_123' }, '用户登录')
// {"level":30,"time":"2026-03-10T10:00:00.000Z","pid":1234,"service":"user-service","userId":"user_123","msg":"用户登录"}

Pino 快不是因为用了什么黑科技。它只是做得少——序列化更高效,不搞花里胡哨的格式化。少即是快。

我的建议:新手从 Pino 开始。理解日志的本质以后再考虑要不要换成 Winston。

Kibana 怎么用

日志进了 Elasticsearch 之后,Kibana 就是你的操作面板。

第一步,创建"索引模式"——告诉 Kibana 你的日志在哪个 index 里。通常是 myapp-*,因为你按天建索引:myapp-2026.03.10

第二步,指定时间字段,一般是 @timestamp

第三步,搜索:

level: "error"                          → 所有错误
userId: "user_123"                      → 这个用户的所有日志
level: "error" AND userId: "user_123"   → 组合查询
@timestamp: [2026-03-09 TO 2026-03-10]  → 时间范围
method: "POST" AND NOT status: 200      → 所有失败的 POST 请求

比 grep 强一百倍的地方在于——grep 搜的是"包含这个字符串的行",Kibana 搜的是"这个字段等于这个值"。前者容易误匹配,后者精准定位。

你还可以建仪表盘:

  • 每小时错误数量的折线图
  • P50 / P90 / P99 延迟分布
  • Top 10 最常见的错误消息
  • 请求量的时间线

一张图挂在大屏上,比翻一屏幕日志直观多了。


七、Sentry——让错误主动来找你

日志是监控录像,Sentry 是报警器

日志是被动的。是监控录像。你不出事不去看它。

Sentry 是主动的。是报警器。门被人踹开了,警报直接响。

具体说,Sentry 在你代码里埋了一个 SDK。每当有未捕获的异常发生时,SDK 自动把错误现场打包——堆栈、用户信息、请求参数、浏览器信息、操作系统版本——然后发到 Sentry 服务端。服务端聚合去重后通过邮件、Slack、钉钉等方式通知你。

关键功能:

自动聚合。 1000 个用户遇到同一个 bug,不是 1000 条通知。Sentry 自动识别出"这是同一种错误",聚合成一个 Issue。你的手机不会变成震动棒。

Source Map。 生产环境的代码是压缩过的——变量名全变成 a、b、c,行号全挤在一行。Sentry 支持上传 Source Map,把错误堆栈映射回你源码里的真实文件名和行号。这个功能救过我太多次了。

上下文自动收集。 用户的 IP、浏览器、操作系统、当前 URL、请求参数——你不需要手动加。Sentry SDK 自己就把能拿到的信息全打包进去了。

接入就几行代码

npm install @sentry/node
import * as Sentry from '@sentry/node'

Sentry.init({
  dsn: 'https://your-dsn@sentry.io/project-id',
  environment: process.env.NODE_ENV || 'development',
  tracesSampleRate: process.env.NODE_ENV === 'production' ? 0.1 : 1.0,
})

// Express 里一行就够了
Sentry.setupExpressErrorHandler(app)

所有没被你的 try/catch 包住的异常,Express 的 error handler 会捞到,然后 Sentry SDK 自动上报。

手动上报也很简单:

try {
  await chargeUser(userId, amount)
} catch (err) {
  Sentry.captureException(err, {
    tags: { userId, action: 'charge' },
    extra: { amount, paymentMethod: 'alipay' },
  })
  throw err // 上报完了继续抛,让上层处理
}

自建还是用云

Sentry 有免费的云服务额度。也提供开源的自建方案。

云服务:零维护,马上能用。免费额度对于小项目够用。缺点是数据不在你自己的服务器上。

自建:数据完全自己掌控。但需要 8GB+ 内存,你得定期升级版本,得维护 PostgreSQL、Redis、Kafka 等一系列依赖。

大部分人的选择:先用云服务。等项目大到需要自建的时候,你已经很清楚自己需不需要了。


八、日志解决不了的问题——可观测性的三根柱子

我觉得写到这,必须把这个掰开说清楚。

日志是神吗?

不是。差远了。

有些问题,你翻遍日志也找不到答案。不是你没翻到位——是那个答案根本不在日志里。

日志擅长什么

  • 搜一个特定用户的某次操作
  • 还原一次请求的完整路径(前提是你加了 traceId)
  • 统计某个错误类型出现了多少次
  • 审计追踪——"谁在什么时候对这条数据做了什么操作"

日志不擅长什么

缓慢的性能退化。

上周你的 API 平均响应时间 50ms。这周变成了 80ms。然后下个月变成了 150ms。

这期间日志里没有任何报错。每一条都是 info。服务看起来一切正常。但用户已经感觉到慢了。

这种问题需要指标监控。每分钟采集一次 API 的 P99 延迟,画成折线图。曲线是往上翘还是平的,一目了然。

跨服务的瓶颈定位。

一个请求进来:网关 10ms → 用户服务 30ms → 订单服务 100ms → 支付服务 2000ms。

日志分散在四个服务里。你怎么知道 2000ms 是支付服务的锅?

需要链路追踪。一个 traceId 贯穿所有环节。Jaeger 或 SkyWalking 把每个阶段的耗时画成瀑布图,哪个环节最长,一眼看到。

内存泄漏。

GC 越来越频繁。堆内存慢慢上涨。单条日志看起来一切正常——没有 error,没有 stack trace。两个月后的某个凌晨,OOM 了。

需要指标监控持续盯着内存曲线。曲线持续上涨一个月——这就不对。

三大支柱

现代运维的可观测性有三根柱子。缺一根就是瘸腿。

柱子回答的问题用什么
日志 (Logs)"发生了什么?"ELK, Loki
指标 (Metrics)"系统健康吗?"Prometheus, Grafana
链路追踪 (Traces)"为什么这么慢?"Jaeger, SkyWalking

日志告诉你细节。指标告诉你趋势。链路告诉你瓶颈。

三者不是替代关系,是互补关系。就像你去看医生——日志是你的主观描述("我肚子疼"),指标是你的体检报告("血压 130,心率 90"),链路追踪是 CT 扫描("胃部有一段炎症")。


九、给自己一套判断框架

好了。到这里你应该已经知道日志是怎么回事了。但知道是一回事,下次在项目里用起来是另一回事。

我给你一套简单的问题。下次你加日志的时候,拿这些问题问自己。

问题一:这条日志,凌晨三点能帮到我吗?

凌晨三点,你被叫醒。就靠这条日志的信息,你能判断出问题出在哪一环吗?

如果不能——你漏了什么?用户 ID?请求参数?下游服务的响应?耗时?加进去。

问题二:这条日志我现在能搜到吗?

你在终端里 console.log 出来的东西,服务器重启之后就没了。如果这条日志第二天早上还需要用到,它必须持久化——要么写到文件,要么直接打到 ELK 里。

问题三:这条日志跟其他日志能串起来吗?

你的系统里一次请求可能经过 API 网关 → 用户服务 → 订单服务 → 支付服务。如果在订单服务里报了个错,你能通过 requestId 找到这个用户在用户服务里干了什么吗?能找到支付服务那一步发生了什么吗?

没加 requestId 的话,这条日志就是一座孤岛。

问题四:里面有明文敏感信息吗?

password、token、身份证号、银行卡号——看一眼。确认没有。养成习惯:每次 push 之前扫一遍日志输出。

问题五:日志级别对吗?

生产环境关了 debug 吗?不该是 error 的东西没写成 error 吗?不该是 info 的东西没写成 warn 吗?

问题六:我能多快知道出问题了?

日志不会主动告诉你。你有没有 Sentry 做实时告警?有没有 Grafana 看指标大盘?有没有接入链路追踪?

问题七:我真需要这么复杂的方案吗?

不是每个项目都需要 ELK + Sentry + Prometheus 全家桶。单机小项目,Pino + logrotate 就够了。10 台服务器的中型项目,加个 ELK。用户量大了、错误多了,再加 Sentry。等你开始做性能优化的时候,自然就知道该上 Prometheus 和 Jaeger 了。

别一口气全上。从最疼的那个点开始。


最后几句

日志这件事,说到底就是一个投资。

你现在花两小时把日志库换掉,用 Pino 输出 JSON,配好 logrotate,搭一个 Docker ELK——半年后某个凌晨三点,你十分钟内定位问题、修好、回去睡觉。

你现在图省事,console.log 随便打几行,日志级别也不分,全糊在一块——半年后某个凌晨三点,你对着满屏的无意义字符串,从床上折腾到天亮。

区别就是在那个凌晨三点。

别等到需要它的时候,才发现自己这半年录的全是雪花屏。

日志就是你的监控录像。平时没人看,出事的时候,就是你能抓住的最后一根稻草。把录像拍好。你会感谢现在的自己。

读者来信

0/1000

暂无来信,期待你的分享。