程序日志设计指南:如何让日志真正帮助定位问题
程序出现故障时,开发人员最常做的一件事就是“查看日志”。然而,很多系统虽然每天产生大量日志,真正排查问题时却仍然找不到有用信息。
常见情况包括:
操作失败
请求异常
系统发生错误
进入方法
执行完成
这些日志看起来记录了程序行为,却没有说明哪个请求失败、操作了什么数据、失败发生在哪个阶段,更没有保留异常原因。
日志的价值不取决于数量,而取决于它能否回答排障过程中真正重要的问题。
一、日志需要回答哪些问题
一条有用的日志,至少应该尽可能回答以下问题:
- 什么时候发生的;
- 在哪个服务和实例中发生;
- 哪个请求触发了问题;
- 哪个用户或业务对象受到影响;
- 程序正在执行什么操作;
- 操作执行到了哪个阶段;
- 最终结果是什么;
- 如果失败,失败原因是什么;
- 是否可以重试;
- 问题影响了多少请求。
例如,下面的日志几乎没有排障价值:
下单失败
更有价值的日志可以写成:
创建订单失败 request_id=req_8f21 user_id=1024
product_id=3005 quantity=2 stage=reserve_stock
error=insufficient_stock
看到这条日志后,我们能够立即知道:
- 哪个请求失败;
- 哪个用户发起请求;
- 购买了什么商品;
- 失败发生在库存预占阶段;
- 失败原因是库存不足。
好的日志应该减少猜测,而不是制造新的疑问。
二、正确使用日志级别
常见日志级别包括TRACE、DEBUG、INFO、WARN和ERROR。
TRACE
TRACE用于记录非常细粒度的执行过程,例如循环中的每次迭代、协议解析细节和算法内部状态。
这类日志数量通常很大,一般只在特殊排查场景中临时启用。
DEBUG
DEBUG用于开发和调试阶段,记录能够帮助理解程序执行路径的信息,例如:
- 命中了哪个缓存分支;
- 使用了哪个调度策略;
- 查询返回了多少条数据;
- 某个中间计算结果是什么;
- 某项配置最终解析成了什么值。
生产环境可以保留部分调试能力,但通常不应长期输出大量DEBUG日志。
INFO
INFO用于记录正常但重要的业务事件或系统状态变化,例如:
- 服务启动完成;
- 配置加载成功;
- 订单创建成功;
- 定时任务执行完成;
- 模型加载完成;
- 主从角色发生切换。
并不是每次函数进入和退出都值得记录为INFO。如果每个方法都输出一条开始和结束日志,真正重要的信息会被大量噪声淹没。
WARN
WARN表示出现了异常情况,但系统仍然能够继续工作,例如:
- 第一次调用失败,重试后可能恢复;
- 缓存不可用,已经降级到数据库;
- 队列长度接近上限;
- 请求参数接近系统限制;
- 某项配置已废弃但暂时仍能使用。
WARN应该提示潜在风险。如果某种情况每天正常发生数万次,就需要考虑它究竟是不是警告。
ERROR
ERROR表示当前操作失败,或者系统进入了需要关注的异常状态,例如:
- 数据写入失败;
- 任务重试多次后仍未完成;
- 必要依赖不可用;
- 数据状态不一致;
- 程序捕获到未预期异常。
不是每个异常都应该记录为ERROR。例如,用户密码输入错误属于正常业务结果,通常不应被视为系统故障。
三、区分业务失败和系统异常
业务失败是程序预期内的结果,例如:
- 用户余额不足;
- 商品库存不足;
- 验证码错误;
- 优惠券已经使用;
- 请求超过规定次数。
系统异常则表示程序无法按照预期完成工作,例如:
- 数据库连接中断;
- 文件读取失败;
- 数据格式与程序约定不一致;
- 空指针异常;
- 内存不足;
- 依赖服务返回无法解析的数据。
如果把所有业务失败都记成ERROR,错误日志中会充满正常用户行为,真正的系统故障反而不容易被发现。
比较合理的做法是:
业务失败:记录结果和必要的业务字段
系统异常:记录上下文、错误类型和完整异常栈
业务失败不等于系统错误,系统错误也不应该只记录一句“操作失败”。
四、为什么需要请求标识
一次用户请求可能经过多个模块:
网关
→ 用户服务
→ 订单服务
→ 库存服务
→ 支付服务
如果每个服务只记录自己的日志,就很难判断这些日志是否属于同一次请求。
解决方法是为每次请求生成唯一的请求标识,例如:
request_id=req_8f21
这个标识应该在服务调用过程中持续传递。每个服务输出日志时都附带相同的request_id:
gateway request accepted request_id=req_8f21
order service creating order request_id=req_8f21
inventory reserved request_id=req_8f21
payment completed request_id=req_8f21
这样,只需要搜索一次请求标识,就能还原整个调用过程。
如果系统使用链路追踪,还可以进一步记录:
trace_id:标识完整调用链;span_id:标识调用链中的某个具体操作;parent_span_id:标识当前操作的上一级调用。
五、为什么结构化日志更适合生产环境
传统日志经常是一段自由文本:
用户1024创建订单失败,商品3005,数量2,原因为库存不足
人可以读懂,但程序很难稳定提取字段。如果以后修改文字顺序,原有分析规则可能失效。
结构化日志会将每项信息保存为明确字段:
{
"timestamp": "2026-09-14T14:30:25.183Z",
"level": "WARN",
"service": "order-service",
"request_id": "req_8f21",
"event": "order_create_failed",
"user_id": 1024,
"product_id": 3005,
"quantity": 2,
"reason": "insufficient_stock"
}
结构化日志便于:
- 按用户编号查询;
- 按错误类型统计;
- 按服务筛选;
- 计算失败率;
- 查找特定时间范围;
- 建立告警规则;
- 制作运行状态面板。
即使日志最终仍以文本形式保存,也应尽量保持字段名称和格式稳定。
六、错误日志为什么要包含异常栈
下面的代码只记录异常消息:
try {
processOrder();
} catch (Exception e) {
log.error("订单处理失败:{}", e.getMessage());
}
这种写法可能只输出:
订单处理失败:null
因为部分异常没有详细消息。即使有消息,也无法知道异常发生在哪一行、经过了哪些方法调用。
更合适的方式是记录异常对象:
try {
processOrder();
} catch (Exception e) {
log.error(
"订单处理失败 order_id={} stage={}",
orderId,
"reserve_stock",
e
);
}
这样日志中能够保留完整调用栈,有助于定位具体代码位置。
不过,也不应在每一层捕获同一个异常、记录一次日志后再继续抛出。否则同一个错误可能在控制器、业务层、数据层分别打印,造成大量重复信息。
通常应选择最了解业务上下文、同时能够统一处理异常的一层记录完整错误。
七、记录输入参数时要注意什么
输入参数能够帮助复现问题,但不能不加选择地全部写入日志。
下面这些信息通常不应直接记录:
- 密码;
- 身份验证令牌;
- 私钥;
- 会话凭据;
- 银行卡完整号码;
- 身份证完整号码;
- 手机验证码;
- 用户上传的敏感文本;
- 数据库连接密码。
即使日志系统访问受限,敏感数据仍可能因为日志备份、导出、截图或权限配置错误而泄露。
必要时可以进行脱敏:
phone=138****5678
card_number=6222********1234
user_token=[REDACTED]
还需要避免直接记录完整请求头和请求体。调试时这样做很方便,但在生产环境中容易把凭据和隐私数据一起写入日志。
八、日志信息应该描述结果,而不是代码动作
下面的日志过于接近代码执行过程:
进入create方法
执行if分支
调用save方法
离开create方法
它告诉我们代码执行了,却没有告诉我们业务上发生了什么。
更有价值的写法是:
订单创建成功 order_id=90021 user_id=1024
amount=199.00 payment_status=pending
日志应优先描述业务事件和系统状态,而不是机械记录每一行代码的执行轨迹。
方法级执行过程可以在调试时通过DEBUG或性能分析工具观察,不应默认占据生产日志的大部分空间。
九、为关键操作记录耗时
系统变慢时,仅知道操作成功并不够,还需要知道它花了多长时间。
例如:
database_query_completed
table=orders
operation=find_pending
duration_ms=842
result_count=120
通过记录耗时,可以进一步统计:
- 平均耗时;
- 中位数;
- P95耗时;
- P99耗时;
- 超时次数;
- 慢操作占比。
耗时记录应使用单调时钟进行计算,避免系统时间调整导致结果出现负数或异常跳变。
需要监控的关键阶段通常包括:
- 数据库查询;
- 外部服务调用;
- 文件读写;
- 消息处理;
- 模型推理;
- 序列化和反序列化;
- 完整请求处理。
十、避免在循环中输出大量日志
下面的代码可能在短时间内产生海量日志:
for item in items:
logger.info("processing item: %s", item.id)
process(item)
如果一次处理几十万个对象,日志写入本身就可能成为性能瓶颈。
更合适的做法是记录整体进度和汇总结果:
batch_processing_started batch_id=batch_102 total=200000
batch_progress batch_id=batch_102 completed=50000 failed=12
batch_processing_completed batch_id=batch_102
total=200000 success=199950 failed=50 duration_ms=182000
对于高频事件,可以使用:
- 日志采样;
- 按时间窗口聚合;
- 计数器指标;
- 只记录异常样本;
- 定期输出汇总数据。
日志适合保留离散事件,监控指标更适合描述大量重复行为。
十一、日志写入也可能拖慢程序
日志并不是免费的。
一条日志可能涉及:
- 字符串拼接;
- 对象序列化;
- 时间格式化;
- 异常栈生成;
- 文件写入;
- 磁盘同步;
- 网络传输;
- 日志采集与压缩。
在高并发程序中,大量同步日志可能直接阻塞业务线程。
使用异步日志能够降低业务线程等待时间,但异步队列同样需要容量限制。如果日志产生速度长期高于写入速度,队列会持续增长,最终占用大量内存。
队列满时需要提前确定策略:
- 阻塞业务线程;
- 丢弃低级别日志;
- 保留错误日志;
- 对重复日志进行采样;
- 将日志降级写入本地文件。
选择哪种策略取决于业务要求,但不应让日志系统在故障时进一步拖垮业务系统。
十二、日志文件必须有轮转和保留策略
如果日志不断写入同一个文件,磁盘空间迟早会被耗尽。
日志管理通常需要设置:
- 单个文件最大大小;
- 每日或每小时轮转;
- 压缩历史日志;
- 最大保留天数;
- 最大磁盘占用;
- 磁盘剩余空间告警。
当磁盘接近写满时,系统可能出现:
- 数据库无法写入;
- 应用无法创建临时文件;
- 服务无法启动;
- 日志本身无法继续记录;
- 文件系统进入异常状态。
日志的目的是帮助系统稳定运行,不能让日志本身成为系统故障的原因。
十三、时间格式必须统一
分布式系统中的多个服务可能运行在不同机器或时区中。如果日志时间格式不统一,就很难还原事件顺序。
日志时间最好包含:
- 完整日期;
- 精确到毫秒或更高;
- 明确时区;
- 统一格式。
例如:
2026-09-14T14:30:25.183Z
统一使用协调世界时保存日志,展示时再转换为本地时间,通常更方便跨地区和跨系统分析。
还需要确保服务器时间能够可靠同步。即使每台机器只相差几秒,也可能让跨服务调用链看起来前后颠倒。
十四、不要用日志代替监控指标
日志可以告诉我们某次请求为什么失败,但不适合单独回答所有运行状态问题。
例如,想知道当前系统是否健康,更适合观察:
- 每秒请求数;
- 成功率;
- 错误率;
- 响应时间;
- CPU利用率;
- 内存使用量;
- 队列长度;
- 活跃线程数;
- 数据库连接数。
日志、指标和链路追踪各有作用:
| 工具 | 主要用途 |
|---|---|
| 日志 | 记录具体事件及其上下文 |
| 指标 | 观察系统整体趋势和异常变化 |
| 链路追踪 | 还原请求跨服务的执行过程 |
三者结合使用,才能既发现问题,又定位问题。
十五、一次完整故障应该留下什么
假设订单服务调用库存服务失败,一组较完整的信息可以包括:
{
"timestamp": "2026-09-14T14:30:25.183Z",
"level": "ERROR",
"service": "order-service",
"instance": "order-03",
"request_id": "req_8f21",
"event": "inventory_reservation_failed",
"order_id": "90021",
"product_id": "3005",
"quantity": 2,
"attempt": 3,
"duration_ms": 1502,
"retryable": false,
"error_type": "dependency_timeout"
}
如果还保留了异常栈和上下游调用信息,开发人员通常能够回答:
- 问题发生在哪个实例;
- 影响了哪个订单;
- 失败的是哪个处理阶段;
- 已经重试了多少次;
- 是否还能继续重试;
- 下游调用耗时多久;
- 最终为什么失败。
这才是日志应该提供的排障价值。
十六、常见日志设计误区
1. 日志越多越好
大量无意义日志会增加存储和查询成本,并掩盖真正重要的信息。
2. 只记录“失败了”
没有请求标识、业务对象、处理阶段和错误原因的失败日志,通常无法定位问题。
3. 所有异常都记录为错误
正常业务拒绝和系统异常应该区分,否则错误率会失真。
4. 捕获异常但不记录异常栈
只记录异常消息可能丢失最关键的代码位置和调用关系。
5. 每一层都重复记录同一个异常
重复日志会扩大噪声,并可能让一次错误被误统计为多次错误。
6. 将敏感信息完整写入日志
日志不是存放密码、令牌和个人隐私的安全位置。
7. 记录了日志却没有告警
如果关键错误只能等待人工偶然发现,那么日志无法帮助系统及时恢复。
8. 只记录错误,不记录关键成功事件
缺少成功日志时,很难区分“请求没有到达”和“请求已经成功处理”。
十七、实用日志检查清单
设计或审查日志时,可以检查以下问题:
- 每条关键日志是否有准确的时间;
- 是否包含日志级别;
- 是否标明服务和实例;
- 是否具有请求标识或链路标识;
- 是否记录关键业务对象;
- 是否说明当前操作和处理阶段;
- 失败时是否保留错误类型与异常栈;
- 是否区分业务失败和系统异常;
- 是否记录关键操作耗时;
- 是否避免泄露敏感数据;
- 是否存在高频循环日志;
- 是否配置日志采样、轮转和保留期限;
- 日志量突然增加时是否会影响业务;
- 关键错误是否能够触发告警;
- 能否通过一条请求标识还原完整处理过程。
结语
真正有价值的日志,不是把程序执行过的每一步全部写下来,而是在问题发生后提供足够的信息,让开发人员能够还原现场。
一套良好的日志系统应该做到:
- 用正确的级别表达事件严重程度;
- 用结构化字段记录稳定的上下文;
- 用请求标识串联完整调用过程;
- 为错误保留异常栈和处理阶段;
- 对敏感数据进行脱敏;
- 控制日志数量与写入成本;
- 与指标、告警和链路追踪配合使用。
日志设计的最终目标不是“证明程序运行过”,而是当系统在深夜发生故障时,能够清楚地告诉维护人员:哪里出了问题、为什么出问题,以及应该从哪里开始修复。
- 点赞
- 收藏
- 关注作者
评论(0)