服务端日志与监控:结构化日志、指标与告警体系
本文目录
日志和监控不是"打点 console.log 再装个面板",而是两套分工明确又互相配合的系统:日志回答"这一次请求到底发生了什么",监控回答"服务现在的整体状态是否正常"。这篇文章按生产环境的真实顺序展开:先写结构化日志,再建立指标,最后设计告警,每一步都给出可以直接落地的规则。
先分清三件事:日志、指标、链路追踪
很多团队把这三个词混着用,结果系统建成了三不像。它们的边界先划清楚:
| 维度 | 日志 | 指标 | 链路追踪 |
|---|---|---|---|
| 回答的问题 | 这一次请求发生了什么 | 服务整体现在健康吗 | 慢在调用链的哪一环 |
| 数据形态 | 离散事件(一条条) | 时间序列(按时间聚合) | 带 span 树的一次请求轨迹 |
| 典型查询 | 按 trace_id 取上下文 | 看错误率、P99 延迟的曲线 | 看 span 耗时瀑布图 |
| 存储成本 | 高,需要保留策略 | 低,天然聚合 | 中高,通常采样 |
三者共享同一个请求标识(trace_id)。监控发现问题(错误率升高)→ 追踪定位环节(支付回调慢)→ 日志还原细节(那一条请求的参数和错误栈)。任何一环缺失,排障就会断在半路。小团队可以先做好日志 + 指标,链路追踪在服务拆分出三个以上、跨服务排障开始变疼时再上,不要为了"可观测性"三个字一次性全铺开。
日志三原则:结构化、级别语义、trace_id 关联
原则一:结构化 JSON,日志是给机器读的
人读的日志格式([2026-10-22 10:00:00] user login ok)在单机时代没问题,在集中收集、要按字段过滤的年代就是灾难。规则:每条日志是一个 JSON 对象,字段名是契约。
// 用 pino 风格的结构化日志,字段名统一蛇形命名
const pino = require('pino');
const logger = pino({
level: process.env.LOG_LEVEL ?? 'info',
base: { service: 'blog-api', env: process.env.NODE_ENV ?? 'dev' }, // 每条都带服务名
timestamp: pino.stdTimeFunctions.isoTime, // ISO 8601,带时区,不用相对秒
});
logger.info({ user_id: 42, article_id: 108, ms: 35 }, 'article_view');
// 输出:{"level":30,"time":"2026-10-22T02:00:00.000Z","service":"blog-api",
// "env":"prod","user_id":42,"article_id":108,"ms":35,"msg":"article_view"}
字段规范要先定再写代码:user_id 不能有的地方叫 userId;时间戳统一 UTC 存储、展示层再转时区;错误对象的 err 字段用序列化器展开成 err.stack、err.message。这些约定写进团队的日志字段字典,新人照抄,禁止各写各的。
原则二:每个级别有明确的动作,不是心情
| 级别 | 语义 | 触发条件 | 对应动作 |
|---|---|---|---|
| error | 影响请求成功或数据正确 | 未捕获异常、写库失败、外部依赖不可用 | 告警(配合条件) |
| warn | 可自动恢复但要人知道 | 请求被限流、重试后成功、降级生效 | 观察,看板聚合 |
| info | 关键业务动作 | 登录、发布文章、支付回调 | 排查时按字段过滤 |
| debug | 排障细节 | 请求参数、中间状态 | 生产默认关闭 |
最常见的失败是级别通胀:写代码的人拿不准,全部 logger.error,最后 error 一天几十万条,谁也不敢配告警。规则是反向的:error 必须配告警条件,配不了告警的降级为 warn。debug 级别在生产环境通过环境变量关闭,需要排障时临时打开、定期回收。
原则三:trace_id 串起一次请求
一次请求会跨越中间件、业务服务、数据库客户端、外部 HTTP 调用。没有 trace_id,排障时要在十几个服务的日志里靠时间戳猜。用 Node.js 的 AsyncLocalStorage 在请求入口生成一次、全程透传:
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';
const als = new AsyncLocalStorage<{ traceId: string }>();
// 中间件:入口生成 trace_id,写入响应头,供前端反馈问题时附带
export function tracingMiddleware(req: Request, res: Response, next: NextFunction) {
const traceId = (req.headers['x-trace-id'] as string) ?? randomUUID();
res.setHeader('x-trace-id', traceId);
als.run({ traceId }, () => next());
}
// logger 包装:每条日志自动带 trace_id,不需要每处手写
export const tracedLogger = {
info: (obj: object, msg: string) =>
logger.info({ ...als.getStore(), ...obj }, msg),
};
配合结构化输出,排障流程变成:用户报障 → 从响应头拿到 trace_id → 日志平台 trace_id:9f2c7e21 一条查询看到全程。这个流程是否能在 30 秒内走完,是日志体系是否合格的验收标准。
集中收集:从单机文件到日志平台
单机日志在排障和审计上都不够用:机器一回收日志就没了,跨机器排障要逐台登录。标准链路是应用写本地文件(或 stdout),采集器抽取转发,集中存储后按保留期限滚动删除:
| 环节 | 常见选择 | 关键约束 |
|---|---|---|
| 应用输出 | 本地滚动文件 / 容器 stdout | 单进程单文件,滚动交给 logrotate 或驱动 |
| 采集 | filebeat、fluentd/fluent-bit | 只追加读取、断点续传、背压 |
| 传输与存储 | Kafka 缓冲 + 搜索存储 | 采集端挂掉不丢本地文件,可重放 |
| 消费 | 日志查询、错误聚合 | 按 service/env/level 建索引字段 |
以 filebeat 为例,采集器的核心职责是"文件追上新写的行,并记住读到哪了":
# filebeat 采集一个滚动日志文件的最小配置思路
filebeat.inputs:
- type: filestream
id: blog-api-prod # id 稳定,注册表才知道续传位置
paths:
- /var/log/blog/api.log
parsers:
- ndjson: # 行就是 JSON,按字段解析
add_error_key: true # 解析失败的行打标,静默丢行是排障黑洞
output.kafka:
hosts: ["log-kafka.internal:9092"]
topic: "logs-%{[service]}" # 按服务名分 topic,下游按服务消费
fluentd/fluent-bit 是同类思路:输入插件盯文件或容器,filter 做解析与字段裁剪,output 插件发往远端。选哪个不是重点,重点是三条纪律:采集器只追加不删除源文件;解析失败要有计数,不能静默丢;应用崩溃时本地文件兜底,采集恢复后能补读。
日志量直接决定成本,分级控制:info 保留 7–30 天,warn 保留 60 天,error 保留 180 天以上;量最大的 debug 生产默认不采。把日志平台当无成本的水池,账单和查询延迟会一起教你做人。
日志分级使用:error 告警、warn 观察、info 排查
有了级别语义,使用规则随之确定,不能反过来"级别随便打、告警再挑":
- error 才配告警:告警条件挂在 error 上,且必须带持续时间或次数(如 5 分钟内 ≥10 条),单条 error 就打电话只会把值班同学培养成告警盲。
- warn 进看板不下告警:限流命中数、降级触发数做成曲线,每天巡检。warn 曲线抬升是"即将出问题"的最早信号,比告警更有价值。
- info 排查用,不做告警源:info 是还原现场的材料。排障第一步永远是从 error 那条日志的 trace_id 出发,向前后各拉一段 info。
在日志平台里的典型查询是围绕 trace_id 与级别的组合:
# 假设日志落在单机文件,等价思路也适用于日志平台的过滤语法
# 1. 先看 error 的分布:哪个服务、哪个接口在报
grep '"level":50' /var/log/blog/api.log | grep -o '"route":"[^"]*"' | sort | uniq -c
# 2. 取一条报障的 trace_id,还原完整请求链路
grep 'trace_id":"a3f9c2' /var/log/blog/api.log
# 3. warn 聚合:最近一小时降级触发的量级
grep '"level":40' /var/log/blog/api.log | grep -c 'degraded'
指标体系:先 RED,再看资源
日志看个例,指标看整体。服务侧指标用 RED 方法起步,它覆盖了"用户感知"的全部三个维度:
| RED | 含义 | 典型问题 |
|---|---|---|
| Rate 请求速率 | 每秒请求数,按接口分维度 | 流量突增、某接口被刷 |
| Errors 错误率 | 5xx 与超时占比 | 依赖故障、自身 bug |
| Duration 延迟 | 分桶的响应时间 | 慢查询、GC、资源竞争 |
资源指标是"原因层",回答"为什么变慢":CPU 使用率、内存 RSS、事件循环延迟(Node.js 特有)、文件句柄数、数据库连接池使用率、磁盘空间与 inode。资源指标通常不直接告警用户可感知的问题——CPU 高不高不重要,慢不慢、错不错才重要。正确的用法是资源指标进告警的条件里做归因参考("错误率升高且连接池耗尽")。
实现上,Prometheus 的 counter 模型天然适合 RED:请求数、错误数单调递增,速率用 rate() 在查询时算,不用在应用里做减法。
指标类型:counter、gauge、histogram 与分位数误区
| 类型 | 语义 | 例子 | 聚合方式 |
|---|---|---|---|
| counter | 只增不减 | 请求总数、错误总数 | 查询时 rate() / increase() |
| gauge | 可升可降的瞬时值 | 连接池占用、内存 RSS | 直接取值、max_over_time |
| histogram | 分桶计数 | 响应延迟桶 | 查询时算分位数 |
| summary | 客户端预计算分位数 | 带分位数的延迟 | 只能按实例看,不可再聚合 |
生产上最常见的坑是 summary 和 histogram 的分位数不能比,且 summary 没法跨实例聚合:10 台机器各自的 P99 无法平均出集群 P99,数学上不成立。正确姿势是 histogram 分桶 + 服务端聚合:
# 集群维度的 P99 延迟:所有实例的桶加起来再算分位数
histogram_quantile(0.99,
sum by (le) (rate(http_request_duration_seconds_bucket[5m]))
)
# 错误率:error 计数除以总计数,乘 100 得百分比
100 * sum(rate(http_requests_total{status=~"5.."}[5m]))
/ sum(rate(http_requests_total[5m]))
两个必须知道的边界:其一,histogram_quantile 是桶间插值估计,桶边界设得太宽(比如只有 1s 和 +Inf)时 P99 会严重失真,桶要按接口的真实延迟分布设计;其二,直方图的桶会翻倍指标基数,接口多的服务先算好存储成本再上。另外"平均延迟正常"没有诊断意义——平均 50ms 可能是 95% 的 20ms 加 5% 的 800ms,用户抱怨的正是后 5%,分位数和错误率才是用户感知的代理。
告警设计:症状优先、分级明确、防疲劳
告警是监控的最后一环,也是最容易被做烂的一环。
症状告警优先于原因告警。症状是用户正在感受到的问题,原因是内部状态:
| 类型 | 例子 | 是否直接告警 |
|---|---|---|
| 症状 | 错误率 > 1% 持续 5 分钟、P99 > 800ms、支付成功率下跌 | 是,值班必须响应 |
| 原因 | CPU 85%、某依赖 5xx 增多、磁盘 80% | 归因参考,通常只进看板 |
原因告警的传统问题是"响了但用户没事":CPU 高但延迟正常,就不该在凌晨叫醒人。把原因指标放进症状告警的描述里做归因,而不是各自独立告警。
告警分级决定响应方式:
| 级别 | 判定 | 响应 |
|---|---|---|
| P0 | 用户可感知的核心功能不可用:API 整体 5xx、登录全挂 | 电话 + 短信,立即处理 |
| P1 | 部分功能异常:某接口错误率超阈值、延迟超标 | IM 群 @值班,30 分钟内响应 |
| P2 | 容量与趋势:磁盘 75%、慢查询数量上涨 | 工作时间内处理 |
防疲劳三条手段,按落地顺序:
- 条件加持续时间或窗口:
for: 5m让瞬时抖动不触发。 - 静默(silence):发布窗口、计划内维护,提前静默对应服务的告警,事后必须解除,静默要有过期时间。
- 收敛与抑制:同一服务同一错误的重复告警聚合成一条;数据库挂导致的连锁 API 告警,用依赖关系抑制,只报根因那条。
验收标准同样可以量化:每条告警要么可执行(有 runbook),要么删掉;每周告警复盘一次,误报超过三成的告警规则必须改。做不到这两条,值班同学会在一个月内把告警全部免打扰。
健康检查:liveness 与 readiness 是两件事
容器和负载均衡都依赖健康检查,但两种探针的语义经常被混用,后果是"数据库抖一下,整个服务被反复重启":
| 探针 | 回答的问题 | 失败后果 | 检查内容 |
|---|---|---|---|
| liveness | 进程是否死锁/僵死 | 重启容器 | 进程内自检:事件循环是否响应 |
| readiness | 是否准备好接流量 | 摘除流量,不重启 | 依赖可用性:数据库连通、缓存就绪 |
| startup | 初始化是否完成 | 推迟上面两个探针 | 大型应用慢启动场景 |
实现上最容易犯的错是把数据库连接检查放进 liveness:数据库短暂抖动 → liveness 失败 → 所有实例被重启 → 重启风暴。数据库检查放 readiness(效果只是暂时摘流量),liveness 只做进程级自检:
// liveness:进程还活着就行,不碰外部依赖
app.get('/healthz', (_req, res) => res.status(200).end());
// readiness:检查关键依赖的轻量连通性,探活超时必须短于探针的超时
app.get('/readyz', async (_req, res) => {
const probe = prisma.$queryRaw`SELECT 1`; // 轻量探活,别执行重查询
try {
await Promise.race([
probe,
new Promise((_, reject) =>
setTimeout(() => reject(new Error('db probe timeout')), 1500).unref()),
]);
res.status(200).end();
} catch {
res.status(503).end(); // 503:暂时不接流量,LB 会摘除本实例
}
});
另外两条实践:readiness 失败期间不要发新的外部调用(收到 SIGTERM 后应主动返回 503,配合优雅退出);健康检查端点必须免鉴权、轻量、可缓存,被监控打爆健康检查是真实发生过的事故。
注意事项与边界
- 日志里禁止出现密码、token、手机号明文;脱敏放在 logger 层统一做,靠每处代码自觉必然漏。
- trace_id 只串本系统边界,出系统调第三方时通过
x-trace-id头透传与否取决于对方支持,排障时不要假设能跨到外部服务。 - 集中式日志的查询不是搜索引擎:没有索引的字段做全文扫描会拖垮集群,先规划索引字段再上量。
- 指标基数(cardinality)失控是隐形成本:把 user_id、trace_id 当 label 打进指标,一个高基数字段就能让存储膨胀几个量级,高维度细节留在日志里。
- 分位数看分布、平均数看总量:所有"P99 正常但用户说慢"的案例,最后多半是分桶粒度或维度没切对(比如没按接口拆)。
- 健康检查通过不等于服务健康:探针只验证"路径上检查的那一两个依赖",覆盖不全的依赖故障仍要靠指标和告警兜底。
- 告警规则也是代码:进版本库、走评审、有负责人;无 runbook 的告警在复盘时按无效告警处理。
总结
日志与监控体系的落地顺序是固定的:结构化日志打地基(JSON + 级别语义 + trace_id)→ 集中收集控成本(采集器 + 分级保留)→ 指标用 RED 对齐用户感知(histogram 分桶,警惕分位数聚合陷阱)→ 告警只报症状且可执行(分级 + 静默 + 收敛)→ 健康检查分清 liveness 与 readiness。每一步都有可验收的标准,验收不过就退回重做,这套体系才撑得起生产环境的深夜告警。
参考
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。