日志里应该记录什么,不应该记录什么?生产级日志设计指南
日志不是“多打印几行以备排查”,而是系统对已经发生事件的可检索记录。好的日志能回答谁在什么时间、对哪个对象、执行了什么操作、结果如何;坏的日志要么只有一句“操作失败”,要么把请求体、令牌和用户隐私完整写入索引,既不能定位问题,又制造安全与成本风险。
1. 先定义日志要回答的问题
生产日志通常服务四类目标:
| 目标 | 典型问题 | 需要的关键信息 |
|---|---|---|
| 故障诊断 | 为什么这次请求失败? | traceId、错误类型、依赖、耗时、关键状态 |
| 业务审计 | 谁修改了订单地址? | 操作者、动作、资源、前后状态摘要、结果 |
| 安全调查 | 是否存在异常登录? | 主体、来源、认证结果、风险信号 |
| 运行分析 | 哪类请求持续变慢? | 路由模板、状态码、耗时、实例、版本 |
日志不能替代指标和链路追踪。QPS、错误率、P99 延迟更适合指标;跨服务调用关系更适合 Trace;日志负责保存离散事件和诊断上下文。如果每个请求都打印十几行再靠全文检索计算 P99,成本和准确性都很差。
2. 记录事件,而不是记录代码执行轨迹
下面的日志看似很多,却几乎没有信息:
log.info("进入 createOrder 方法");
log.info("开始校验参数");
log.info("校验完成");
log.info("开始保存");
log.info("保存完成");
方法入口、出口和每个 if 分支会快速制造噪声。更合适的是在业务边界记录一个结构完整的结果事件:
log.info("order_created orderId={} userId={} itemCount={} amount={} durationMs={}",
order.getId(), command.userId(), command.items().size(),
order.getTotalAmount(), elapsedMillis);
对于失败,记录失败发生在哪个业务动作、失败分类以及用于关联的标识:
log.warn("payment_rejected orderId={} channel={} reasonCode={} durationMs={}",
orderId, channel, result.reasonCode(), elapsedMillis);
事件名称应稳定,字段应具有明确含义。不要把自然语言句子当作唯一接口,否则稍微修改文案就会破坏告警和查询。
3. 结构化日志是机器接口
推荐输出 JSON 或由日志采集器解析的键值字段。日志 Schema 是跨团队接口,应版本化并验证字段类型;同一个字段不能在部分事件中是数字、另一些事件中是对象,否则可能造成索引映射冲突:
{
"timestamp": "2026-07-12T10:30:00.123+08:00",
"level": "WARN",
"service": "order-service",
"env": "prod",
"version": "2026.07.12-1",
"event": "payment_rejected",
"traceId": "4f8c...",
"spanId": "a312...",
"orderId": "202607120001",
"channel": "wechat",
"reasonCode": "BALANCE_INSUFFICIENT",
"durationMs": 184
}
基础字段应由统一组件注入:时间、级别、服务、环境、实例、版本、线程、traceId 和 spanId。业务代码只负责事件特有字段。字段命名需要治理,例如全公司统一使用 userId,避免同时出现 uid、user_id、memberNo。
数值要按数值写入,布尔值按布尔值写入,不要全部拼成字符串。HTTP 路由应记录模板 /orders/{id},而不是把真实 ID 嵌入路径字段,否则会产生高基数。
4. 应该记录哪些内容
4.1 系统边界的结果
在 HTTP、RPC、消息消费、定时任务等入口,记录结果摘要:
- 稳定的操作或路由名称。
- 请求关联 ID、调用方和租户。
- 状态分类、错误码和耗时。
- 载荷大小、批量数量等容量信息。
- 当前应用版本与实例。
入口日志应由 Filter、Interceptor 或消息中间件统一产生,避免每个 Controller 各自实现。
4.2 重要业务状态变化
创建、支付、取消、授权、退款等关键状态变化值得记录,但应记录业务标识和状态摘要,而不是整个实体:
log.info("order_status_changed orderId={} from={} to={} operatorType={} operatorId={}",
orderId, oldStatus, newStatus, operatorType, maskedOperatorId);
需要满足合规审计时,应使用独立、不可抵赖、访问受控的审计日志。普通应用日志可能被轮转、采样或由开发人员访问,不能天然承担审计证据职责。
4.3 外部依赖的异常摘要
调用数据库、缓存、支付渠道或第三方 API 失败时,记录依赖名称、操作、超时阶段、重试次数和对端错误码。不要记录数据库密码、Authorization Header 或完整响应体。
5. 不应该记录什么
以下信息默认禁止明文进入日志:
- 密码、验证码、私钥、Access Token、Refresh Token、Session ID。
Authorization、Cookie、签名密钥和数据库连接密码。- 完整身份证号、银行卡号、手机号、地址、医疗数据等个人信息。
- 文件内容、完整请求体和不受控的第三方响应。
- 可被直接执行的 SQL 拼接文本或来自用户的未转义换行内容。
脱敏不是简单保留前后几位。Token 即使只泄露一部分也可能帮助攻击者识别账户或验证猜测;密码和密钥应完全删除。个人信息是否允许部分展示,需要依据业务合规规则,而不是开发人员自行决定。
Spring MVC 统一日志中应采用字段白名单:
Map<String, Object> safe = Map.of(
"orderId", request.orderId(),
"itemCount", request.items().size(),
"channel", request.channel()
);
白名单比维护一份不断遗漏的敏感字段黑名单可靠。
6. 防止日志注入
用户可控文本可能包含换行和伪造前缀:
normal-user\nERROR admin login failed
若直接拼接到纯文本日志,攻击者可以伪造新日志行,干扰调查。结构化编码器应正确转义控制字符;展示层也应进行安全渲染。对用户名、User-Agent 等字段限制长度,避免一个请求制造数 MB 日志。
不要使用:
log.info("login user=" + username);
优先使用参数化日志并由可靠编码器输出:
log.info("login_attempt userId={} result={}", safeUserId, result);
参数化还避免日志级别关闭时提前构造昂贵字符串。
7. 日志级别如何选择
| 级别 | 使用场景 | 常见误用 |
|---|---|---|
| ERROR | 当前操作失败,通常需要关注或触发告警 | 所有可预期业务拒绝都记 ERROR |
| WARN | 可恢复异常、降级、风险信号 | 用于普通成功请求 |
| INFO | 关键生命周期和重要业务事件 | 每个方法入口、循环内逐条打印 |
| DEBUG | 开发诊断细节,可动态开启 | 在生产长期输出大量对象 |
| TRACE | 极细粒度临时分析 | 当作默认调试日志 |
用户余额不足、优惠券过期属于可预期业务结果,通常不应打印异常堆栈和 ERROR。数据库不可用导致支付状态无法保存,则是系统错误。
同一个异常不要在每一层重复打印:Repository 打一次、Service 再打一次、Controller 又打一次,会让一个故障变成三条错误。通常在能够补充完整业务上下文且异常即将越过边界的位置记录一次。
8. 堆栈应该何时打印
异常堆栈对未知缺陷有价值,但体积很大。以下代码只打印消息,丢失根因类型和调用栈:
log.error("create order failed: {}", e.getMessage());
正确写法把异常作为最后一个参数:
log.error("create_order_failed orderId={} errorCode={}", orderId, "DB_WRITE_FAILED", e);
对于大量重复发生的同类异常,可以对完整堆栈采样,其余只记录错误指纹和计数;但必须保证至少有代表性样本,并用指标统计真实发生次数,不能因为采样让告警低估故障。
9. MDC 与异步上下文传播
Servlet 线程中可将 traceId、tenantId 放入 MDC:
try (MDC.MDCCloseable ignored = MDC.putCloseable("tenantId", tenantId)) {
chain.doFilter(request, response);
}
线程池、CompletableFuture 和响应式流中,ThreadLocal 上下文不会自动可靠传播。任务包装器应在提交时捕获、执行时恢复,并在 finally 中清理:
Map<String, String> context = MDC.getCopyOfContextMap();
executor.execute(() -> {
Map<String, String> previous = MDC.getCopyOfContextMap();
try {
if (context != null) MDC.setContextMap(context);
task.run();
} finally {
if (previous == null) MDC.clear();
else MDC.setContextMap(previous);
}
});
只 set 不 clear 会在线程池复用时串租户、串请求。优先使用 OpenTelemetry 等统一上下文传播机制,避免每个团队自制不兼容的 Trace ID。
10. 成本、采样和保留期
日志成本近似由“每秒条数 × 平均大小 × 保留时间 × 索引副本”决定。一次上线把大对象加入 INFO,可能让存储和检索费用数倍增长。
治理措施包括:
- 对成功访问日志按规则采样;错误和安全事件也应按风险分级保留,而不是无上限全量写入。攻击者可主动制造海量 4xx 或认证失败拖垮日志管道,安全日志需要限速、聚合、独立告警与受控的原始证据留存。
- 大字段截断,并记录
truncated=true和原始长度。 - DEBUG 动态开启应有范围和自动过期时间。
- 热索引保存短期可检索数据,长期审计进入低成本受控存储。
- 对日志吞吐、丢弃数、采集延迟和单事件大小设置预算。
采样决策应尽量基于 trace,使同一条链路的相关日志一起保留或丢弃,避免只留下半条调用链。
11. 一份上线检查清单
- 每条日志是否对应清晰、稳定的事件?
- 能否通过 traceId、业务 ID 和版本关联现场?
- 是否使用结构化字段而非解析自然语言?
- 是否采用敏感字段白名单并限制用户输入长度?
- 是否错误地记录 Token、Cookie、请求体或完整实体?
- 异常是否只在合适边界记录一次?
- 业务拒绝与系统故障的级别是否区分?
- 异步任务是否正确传播并清理上下文?
- 日志量、索引基数、保留期和采样是否有预算?
- 审计日志是否与普通诊断日志分离并控制访问?
日志设计本质上是一套数据产品设计:有 Schema、有消费者、有成本、有隐私边界,也需要版本治理。记录得恰到好处,比“能打印的都打印”更考验工程能力。