Node.js 日志:从打印到光看就知道哪里出问题了
凌晨三点被叫醒的人,都欠日志一句道歉。
那个让你想砸键盘的凌晨三点
手机震了。
你从梦里被拉出来,眯着眼看屏幕。运维在群里 @你:"用户服务挂了,帮忙看一下。"
你从床上爬起来,掀开笔记本,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.log 和 console.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 调用都要:
- 打开文件
- 把内容写到末尾
- 关闭文件
你这个接口一秒被调了 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 方式(重命名):
- 把
app.log重命名为app-2026-03-10.log - 创建新
app.log
问题来了:进程还抓着原来的 inode,继续往 app-2026-03-10.log 里写。新的 app.log 收不到数据。
解决:重命名之后给进程发个信号——比如 SIGUSR2——让它关闭旧文件、打开新文件。
copytruncate 方式(拷贝后清空):
- 把
app.log的内容拷贝到app-2026-03-10.log - 把原
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 随便打几行,日志级别也不分,全糊在一块——半年后某个凌晨三点,你对着满屏的无意义字符串,从床上折腾到天亮。
区别就是在那个凌晨三点。
别等到需要它的时候,才发现自己这半年录的全是雪花屏。
日志就是你的监控录像。平时没人看,出事的时候,就是你能抓住的最后一根稻草。把录像拍好。你会感谢现在的自己。
读者来信
暂无来信,期待你的分享。