Python日志别只会print
Python日志别只会print
很多 Python 项目一开始都靠 print 排查问题。脚本跑不通,打一行;接口返回异常,打一行;定时任务没执行,也打一行。早期这样做没有太大问题,甚至很高效。问题出现在项目开始被更多人使用、任务开始长期运行、错误不再能在本地复现的时候。
我见过一个数据同步任务,失败时只打印了“sync error”。这行日志当然证明它错了,但没有告诉我们是哪一批数据、哪个外部接口、重试了几次、失败前已经写入多少。排查的人最后只能重新跑任务,结果第二次成功了,真正的问题被盖过去。两周后同样的问题又出现,还是没人能解释。
所以这篇文章不想把日志讲成“用 logging 替代 print”的简单替换。真正值得处理的是:日志能不能让你还原一次请求或一次任务的关键路径,能不能帮你区分业务问题和系统问题,能不能在报警后给出下一步排查方向。
这张图表达的是一个最小闭环:业务入口产生上下文,日志记录关键节点,TraceId 串起一次执行,最后在告警和复盘里回到同一组证据。
print 的问题不是低级,而是没有上下文
说 print 不好,容易显得像教条。实际上,在临时脚本、本地调试、一次性数据处理里,print 很有价值。它简单,直接,不需要配置。问题是它几乎不携带上下文,也不适合被系统化消费。
比如你在任务里写:
- print('request failed')
当它出现在终端里时,你也许知道自己刚才跑的是哪条命令。但当它出现在服务器日志、容器标准输出或一堆定时任务里,它就失去了意义。排查的人不知道失败的是哪个用户、哪个文件、哪个接口,也不知道这是第一次失败还是重试后的失败。
更糟的是,print 往往让日志变成流水账。开发阶段为了确认流程,会打印大量“start”“step1”“step2”“done”。上线后这些内容继续存在,真正出错时,关键日志被无关输出淹没。日志不是越多越安全,日志太杂同样会降低排查效率。
所以第一步不是把所有 print 批量替换成 logger.info。如果只是换 API,但内容仍然是“start”“error”,效果不会有本质变化。更重要的是先确定:这条日志未来要回答什么问题。
一条有用日志,至少回答四件事
我判断一条日志有没有价值,会先看它能不能回答四件事。
第一,发生在什么场景。是用户主动触发,还是定时任务;是同步主流程,还是补偿流程;是测试环境,还是线上灰度环境。没有场景,日志只能说明代码执行过。
第二,处理的是哪个对象。比如用户 id、订单 id、文件名、批次号、任务 id。这里要注意隐私和安全,不该把手机号、身份证、完整 token 直接打进日志。能用内部 id 就用内部 id,必要时脱敏。
第三,当时系统做了什么判断。比如为什么跳过一条数据,为什么进入降级,为什么没有重试。很多问题不是代码抛异常,而是系统按某个分支正常执行了。没有判断日志,排查时就只能猜条件。
第四,结果是什么。成功、失败、部分成功、进入重试、进入补偿,最好说清楚。如果失败,要带上错误类型和必要的上下文,而不是只把异常字符串丢出来。
这四件事不一定每条日志都写全,但关键路径上的日志应该覆盖它们。尤其是定时任务和异步消费,因为它们不像 HTTP 请求那样容易从入口看到完整上下文。
用 logging,不只是为了分级
Python 的 logging 模块最容易被介绍的是日志级别:debug、info、warning、error、critical。这当然重要,但我更看重它的三个能力:统一格式、统一输出位置、统一上下文。
下面是一个很朴素的配置示例:
- import logging
- logging.basicConfig(
- level=logging.INFO,
- format='%(asctime)s %(levelname)s %(name)s trace_id=%(trace_id)s message=%(message)s'
- )
- logger = logging.getLogger('sync.user_profile')
这段代码直接运行会有一个问题:普通日志记录里没有 trace_id 字段时会报错。因此真实项目里通常会用 LoggerAdapter、filter 或结构化日志库来补默认上下文。这个细节说明了一件事:日志上下文不是顺手加一个字段,而是要被工程化处理。
一个简单做法是用 LoggerAdapter:
- import logging
- import uuid
- base_logger = logging.getLogger('sync.user_profile')
- trace_id = uuid.uuid4().hex[:12]
- logger = logging.LoggerAdapter(base_logger, {'trace_id': trace_id})
- logger.info('sync started')
- logger.info('fetch remote profile success')
- logger.warning('skip invalid record')
这仍然不是完美方案,但它比散落的 print 好很多。至少同一次任务里的日志可以被同一个 trace_id 串起来。后续如果接入日志平台,也更容易检索。
结构化日志适合长期排查
当项目进入长期维护阶段,纯文本日志会慢慢吃力。你可能想统计某个错误码出现多少次,想筛选某个接口的慢请求,想按用户类型看失败分布。这个时候结构化日志更合适。
结构化日志不一定非要引入复杂系统。最简单的方式,就是输出 JSON 行:
- import json
- import logging
- logger = logging.getLogger('payment_callback')
- def log_event(event, **fields):
- logger.info(json.dumps({'event': event, **fields}, ensure_ascii=False))
- log_event(
- 'callback_received',
- trace_id='a13f9c02',
- order_id='O20260529001',
- channel='wechat',
- retry_count=1,
- )
这样做的好处是字段清楚,后续可以按 event、order_id、retry_count 查询。缺点是如果团队没有统一规范,字段名会越来越乱:有人叫 orderId,有人叫 order_id,有人叫 oid。所以结构化日志一旦开始用,就要维护一份轻量字段约定。
我不建议一上来就把所有日志都 JSON 化。可以先从关键链路开始:支付回调、数据同步、消息消费、任务调度、外部接口调用。这些地方最需要长期排查,也最值得付出一点格式成本。
日志级别要表达处理意图
很多项目里,日志级别用得很随意。成功就是 info,异常就是 error,参数不对就是 warning。看起来没问题,但报警接入后就会发现:error 太多,真正要处理的问题反而被冲淡。
我更喜欢按处理意图来定级别。
debug 用于开发和临时排查,不应该成为线上理解业务流程的主要依据。info 记录关键业务节点,比如任务开始、任务结束、外部接口返回关键结果。warning 表示系统还能继续,但已经出现需要关注的偏差,比如某条数据被跳过、某个下游进入降级。error 表示当前动作失败,并且需要人或补偿机制介入。
比如用户上传的某一行 CSV 格式不对,如果任务允许跳过并给出报告,这更像 warning;如果这行错误导致整个任务无法继续,才应该是 error。同一个异常,在不同业务语义下级别可能不同。
这也是日志设计里很容易被忽略的取舍:级别不是异常类型的直接映射,而是系统处理结果的表达。
不要把日志当数据库
日志能帮助排查,但不应该承担业务数据存储职责。我见过有人为了方便统计,把完整请求体、完整响应体、甚至用户敏感信息都打进日志。短期排查很爽,长期就是风险。
日志应该保留足够证据,但证据不等于全部数据。比如接口失败时,可以记录业务 id、错误码、耗时、下游名称、重试次数、脱敏后的摘要。完整 payload 如果确实需要保存,应该进入受控的数据存储,并设置权限和过期策略。
另一个问题是成本。日志量太大会增加存储和检索成本,也会让开发忽视日志质量。很多系统不是没有日志,而是日志多到没人愿意看。与其每个循环都打一行,不如在关键节点打清楚。
可以从三个改动开始
如果你现在的 Python 项目还在大量使用 print,不用一次性重构。可以先做三个小改动。
为关键入口加上 trace_id,并让后续日志都带上它。
把关键业务对象写进日志,比如任务 id、订单 id、文件批次号,但注意脱敏。
为失败日志补充处理结果:是否会重试,是否进入补偿,是否影响用户。
这三个动作不会让日志系统立刻高级,但能显著提高排查效率。等关键链路稳定后,再考虑结构化日志、统一字段、日志平台和告警规则。
Python 日志治理的重点,从来不是把 print 换成 logging。真正的变化是让日志从“我当时看一眼”变成“别人三天后还能根据它还原现场”。能做到这一点,日志才算开始为维护负责。
