☰
Python日志记录最佳实践:从Logging组件到生产环境排障
2026/9/25 21:22:08 网站建设 项目流程

凌晨两点被值班电话吵醒,起因是服务突然开始疯狂报错。等我爬起床登录服务器,打开日志文件,发现里面全是 INFO 级别的心跳信息、第三方 SDK 的调试输出,以及一串串格式乱七八糟的打印,真正的异常堆栈早就被淹没了。那一刻我意识到,日志记录不是 print 的替代品,而是一套需要认真设计的基础设施,尤其是当你在写生产级 Python 服务的时候。这篇文章不打算给你讲教科书上的概念,而是想聊聊我在多个项目里沉淀下来的 Python 日志记录(Logging)最佳实践,从核心组件的理解到配置落地,再到排障技巧,希望能帮你少踩点坑。

1. 想清楚日志系统要解决什么问题,再动手写代码

很多人刚开始接触 logging 时,觉得它就是把 print 换了个写法。实际上,logging 模块解决的是三个 print 永远无法回答的问题:日志要不要分级、日志输出到哪里、日志格式如何统一。这三个问题不解决,你写的日志就只是心理安慰。

1.1 日志级别不是摆设,而是过滤噪音的第一道防线

Python logging 默认定义了 DEBUG、INFO、WARNING、ERROR、CRITICAL 五个级别,顺序上 DEBUG 最低,CRITICAL 最高。理解级别最简单的方式是把它们当成阀门:程序运行时设定一个阈值,低于阈值的日志直接丢弃,高于阈值的才被放行。开发环境把阀门调到 DEBUG,生产环境调到 INFO 或 WARNING,这是最常见的切换方式。

实际项目中级别怎么选,我通常遵循这样的经验:DEBUG 记录变量中间值、函数入口出口、耗时超过阈值的环节,目标是帮开发者在本地复现问题;INFO 记录业务流程的关键节点,比如订单创建成功、任务开始执行、外部接口调用完成,目标是运维同学能通过 INFO 日志还原一次完整请求;WARNING 记录可以继续跑但值得关注的情况,比如重试了三次才成功、缓存命中率异常下降;ERROR 记录功能不可用但进程未退出,比如调用数据库失败但已经被异常捕获;CRITICAL 留给整个应用不可用的场景。

有个细节值得注意:日志级别判断是有开销的。如果你写的是logger.debug(f"用户数据: {expensive_function()}"),哪怕当前日志级别是 INFO,这个 f-string 里的expensive_function()仍然会被执行,因为函数调用发生在 logger.debug 之前。正确做法是先判断再格式化:if logger.isEnabledFor(logging.DEBUG): logger.debug("用户数据: %s", expensive_function()),或者干脆把耗时操作放进延迟计算的函数里。这个坑几乎每个人都踩过。

1.2 Logger 命名是门学问,层级结构决定管理粒度

Python logging 的核心是 Logger 对象,它们之间不是平级关系,而是树状层级结构。最顶层的叫 root logger,其他 logger 通过名字挂在它下面。比如你执行logger = logging.getLogger("shop.order"),这个 logger 的父级就是shop,再往上就是 root。子 logger 默认会把日志向上传播(propagate),最终交到父 logger 的 handler 手里。

理解了这棵树,你就能明白为什么定义日志应该用logging.getLogger(__name__)而不是随便取一个名字。__name__是当前模块的完整导入路径,比如services.order_processor,它天然和代码结构一一对应。好处有两点:第一,你在日志里看到services.order_processor,立刻知道是哪段代码输出的;第二,配置时可以用 logger 名的前缀做精细化控制,比如给services下面的所有 logger 单独设级别,而第三方库的日志走另一套配置。

层级传播带来的常见困惑是日志重复输出。很多人创建了一个子公司 logger 并给它添加 handler,结果日志被打印两次:一次是子 logger 自己的 handler 输出的,另一次是传播到 root logger 时,root 的 handler 又输出了一遍。这不是 Python 的 bug,而是传播机制导致的。解决方案也很简单:要么只给 root 配 handler,所有子 logger 只负责记录事件;要么在子 logger 上显式设置propagate = False。

1.3 一套日志体系应有的设计目标

以我多年写服务端代码的经验,真正可用的日志体系至少要满足四个目标。第一是可过滤,任何一条日志你都能在几秒钟内从日志平台里查出来,而不会被噪音淹没;第二是可追溯,一次用户请求产生的所有日志能通过 request_id 串成一条线;第三是低开销,日志模块不能成为性能瓶颈,尤其是高频调用的函数不能做大量字符串拼接;第四是可配置,日志级别、输出位置、格式都能在运行时调整,不需要改代码重新发布。这几个目标听起来简单,但需要从 Handler、Formatter、Filter 三个角度层层落实,也就是接下来要聊的内容。

2. 四大组件拆解:Handler、Formatter、Filter 的正确用法

Logger 本身只是事件入口,真正决定日志去向和长相的是 Handler、Formatter 和 Filter。我见过很多项目把日志写得一团糟,根本原因是这三个组件的职责没分清。

2.1 Handler 决定日志流向,多样性是它的价值所在

Handler 负责把日志记录写到某个目的地。最常见的是 StreamHandler(输出到控制台)和 FileHandler(写入文件),但 Python 还提供了很多实用的 handler,比如 RotatingFileHandler(按文件大小轮转)、TimedRotatingFileHandler(按时间轮转)、QueueHandler(配合多进程写入)、HTTPHandler(把日志发给远程服务)、SMTPHandler(错误时发邮件)。

设计上,一个 Logger 可以挂多个 Handler,这正好满足“既要看控制台、又要落文件、还要上日志平台”的常见诉求。开发环境通常只需要 StreamHandler,方便本地观察;生产环境一般同时挂两个 FileHandler:一个写 INFO 级别的全量日志,另一个只写 ERROR 级别以上的错误日志,方便告警和快速排查。注意每个 Handler 可以独立设置日志级别,这比在 Logger 上设置级别更灵活。

实际项目中我特别推荐使用logging.handlers.WatchedFileHandler,它监听文件是否被日志轮转工具重命名,如果被 mv 走了就自动重新打开新文件。配合系统的 logrotate 工具,可以安全避免 Python 进程一直写已经轮转掉的文件描述符。如果你直接在代码里用 RotatingFileHandler,它的触发条件是进程内的文件大小,多进程场景下会失效,因为每个进程都有自己的计数器。

2.2 Formatter 里藏着排障效率,别小看格式设计

Formatter 决定日志展示成什么样子。看似只是字符串拼接,但格式里有没有关键信息,直接影响你在几百行日志里找问题时的速度。

我最常用的格式是:%(asctime)s %(levelname)s [%(name)s:%(filename)s:%(lineno)d] %(message)s

分解一下每个字段的作用:asctime 是时间戳,但要注意它默认格式是2025-01-01 12:00:00,123,其中逗号后是毫秒。如果你要在日志平台里按时间精确查询,最好显式指定 datefmt,例如%(asctime)s配合datefmt="%Y-%m-%d %H:%M:%S",但这样会丢失毫秒,查询时间窗口较大的请求时就无法确定先后顺序了。所以我实际项目中会保留毫秒,但通过定制的 Formatter 把时区也带上,避免服务器时区和本地时区不一致导致误导。levelname 是级别,name 是 logger 名,filename 和 lineno 是输出日志的代码位置,少了这两个字段,你看到一条 ERROR 日志还要去搜索代码才能定位,效率特别低。

有一个字段容易被忽视:%(process)d进程号和%(thread)d线程号。多进程部署时,如果日志集中到一个平台,看到不同的 process 可以快速判断是哪个 worker 干的。%(threadName)s也能帮你定位是哪个后台线程出错,尤其是用线程池跑任务的服务。

生产环境我额外推荐一条规则:把异常堆栈统一格式化。默认情况下,logger.exception("xxx")会附带当前异常栈,但如果你只想记录异常而不打算吞掉它,logger.exception会隐式捕获sys.exc_info(),这种情况下输出的堆栈只有被调用的那一层,之后外层捕获时再拍一条,信息是重复的。我的做法是设计一个自定义 Formatter,把 exc_info 单独处理成多行,而不是直接拼在 message 后面,方便日志平台按行解析。

2.3 Filter 是日志的安检门,也是注入上下文的通道

Filter 相比 Handler 和 Formatter 容易被忽略,但它是整个 logging 体系里最灵活,也最容易被低估的组件。它的作用有两方面:过滤日志,以及在日志记录对象中加入自定义字段。

过滤功能很好理解,比如接口的访问日志 96% 都是 GET 请求,你想让某个 logger 只记录 POST 请求的错误,就可以写一个 Filter 检查 record 的请求方法属性,不满足条件就直接返回 False。注意 Filter 可以同时挂在 Logger 和 Handler 上,挂在 Logger 上意味着这条日志根本不生成,挂在 Handler 上意味着 Logger 照常工作,只是某个输出目的地不收。

更妙的是给 LogRecord 注入上下文。比如你在 Web 框架的中间件里拿到 request_id,想让它出现在所有日志里,但是 logger 本身没有这个字段。这时可以写一个 Filter,在filter(record)方法里执行record.request_id = request_id,后续 Formatter 的格式串里加上[%(request_id)s]就能输出。只要 Filter 挂在 root logger 或全局 handler 上,所有日志都会自动带上 request_id 字段。这类侵入性极低的上下文注入方案,是我在项目里最常用的技巧之一。

2.4 LoggerAdapter:另一种优雅的上下文传递方式

除了 Filter,logging 还提供了 LoggerAdapter 类来携带额外上下文。它的思路是包装一个 Logger,在把日志事件交给真正的 logger 之前,自动把固定字段塞进日志记录的 extra 字典里。

比如你要在每个日志里加上环境变量和部署机房信息,可以这样写:

class ContextLoggerAdapter(logging.LoggerAdapter): def process(self, msg, kwargs): kwargs["extra"] = { **self.extra, **(kwargs.get("extra") or {}) } return msg, kwargs logger = ContextLoggerAdapter(logging.getLogger("app"), {"env": "prod"})

用 Filter 还是 LoggerAdapter,我的习惯是这样:Filter 适合全局性的上下文,比如从 Web 中间件读取当前请求的 request_id、用户 ID,这类信息每个请求都在变化,不能写死;LoggerAdapter 适合固定的上下文,比如实例 IP、部署环境,这类信息进程启动后就不变了,用 Adapter 包装后不用再传。两者并不冲突,我在同一个服务里经常同时用。

3. 完整配置落地:从 basicConfig 到 dictConfig 的进阶之路

理解了组件,接下来就是怎么配。很多人觉得配置 logging 很麻烦,于是先用 basicConfig 凑合,结果越写越乱。这里我按推荐程度从低到高讲三套方案,每套方案适用的场景和注意事项都不一样。

3.1 basicConfig 适合小工具和单文件脚本,但别指望它管理复杂项目

logging.basicConfig是 Python 内置的便捷函数,传入 level、format、filename 等参数,它会自动创建一个 StreamHandler 并挂到 root logger 上。如果你的代码只有一个文件,这个方案确实够用:

import logging logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", datefmt="%Y-%m-%d %H:%M:%S" ) logging.info("system started")

但 basicConfig 有个隐蔽的坑:它在 root logger 上首次执行时才生效,如果某个第三方库在导入阶段就调用了 logging 且写入了日志,basicConfig 就不会再次生效。更麻烦的是,当你创建了自己的 Logger 并添加 Handler 后,basicConfig 的默认 Handler 仍然挂在 root 上,两种输出叠加导致重复。所以我的建议是:单文件脚本、几十行以内的工具脚本可以用 basicConfig,一旦项目超过三个模块,立刻切换到显式配置。

3.2 生产环境推荐 dictConfig,把配置收拢到一个文件里

logging.config.dictConfig 是官方提供的字典式配置入口,核心价值是把所有 Logger、Handler、Formatter 的定义集中在一个数据结构里,既可以是 Python 字典,也可以来自 YAML 或 JSON 文件。我的习惯是写在logging.yaml里,作为项目配置文件的一部分,这样日志相关调整不用改代码,改完重启即可生效。

一个典型的生产配置长这样:

LOGGING_CONFIG = { "version": 1, "disable_existing_loggers": False, "formatters": { "default": { "format": "%(asctime)s %(levelname)s [%(name)s:%(filename)s:%(lineno)d] %(message)s", "datefmt": "%Y-%m-%d %H:%M:%S" }, "error_format": { "format": "%(asctime)s %(levelname)s [%(name)s] %(pathname)s:%(lineno)d\n%(message)s" } }, "handlers": { "console": { "class": "logging.StreamHandler", "level": "INFO", "formatter": "default", "stream": "ext://sys.stdout" }, "file_info": { "class": "logging.handlers.RotatingFileHandler", "level": "INFO", "formatter": "default", "filename": "logs/app.log", "maxBytes": 104857600, "backupCount": 5, "encoding": "utf-8" }, "file_error": { "class": "logging.handlers.RotatingFileHandler", "level": "ERROR", "formatter": "error_format", "filename": "logs/error.log", "maxBytes": 104857600, "backupCount": 10, "encoding": "utf-8" } }, "root": { "handlers": ["console", "file_info", "file_error"], "level": "INFO" }, "loggers": { "uvicorn": {"level": "WARNING", "handlers": ["console"], "propagate": False}, "sqlalchemy.engine": {"level": "WARNING", "handlers": ["console"], "propagate": False} } }

配置里的disable_existing_loggers: False是一个容易忽略但很重要的字段。dictConfig 默认会销毁所有已经存在的 logger,如果你在导入阶段已经创建了logger = logging.getLogger("app"),再执行 dictConfig 会把这个 logger 禁用掉。显式设为 False,可以防止这个意外。

第三方库日志级别的调整经常在 dictConfig 里一次搞定。比如 FastAPI 底层的 uvicorn 日志默认很啰嗦,每个请求都会打一条 INFO 日志,生产环境如果不需要就调到 WARNING;SQLAlchemy 的引擎日志默认输出 SQL 语句,除非你要慢查询分析,否则也建议调到 WARNING。把第三方库的日志处理好,能大幅减少噪音。

3.3 日志轮转参数怎么设计,才算真正靠谱

日志文件不轮转,早晚会撑爆磁盘。Python 内置的 RotatingFileHandler 按文件大小切分,TimedRotatingFileHandler 按时间切分。大部分项目里,我推荐优先用按大小轮转,因为它的行为可预期:单文件大小达到阈值就切分,不依赖当前时间点。

参数设计上,maxBytes我通常设为 100MB,backupCount设为 5,也就是最多保留 500MB 的日志。考虑到业务规模波动,错误日志可以用更大的 backupCount,比如 10,因为错误日志量小,留更多历史便于追溯。encoding="utf-8"必须显式加上,否则 Windows 环境会出现 ANSI 编码问题。

TimedRotatingFileHandler 有几个细节坑:when参数支持 "S"、"M"、"H"、"D"、"midnight" 等取值。但要注意,它切分文件时根据的是文件修改时间,如果在日志量很少的情况下,它会在每个周期结束时强制轮转,即使文件没写多少内容。另外,backupCount 对不同时间单位的解析逻辑不同,用 "S" 时 backCount 代表秒数,而不是保留多少个文件,这个设计很容易让人踩坑。

如果是 Docker 容器部署,容器内一般只保留当前日志文件,容器日志通过 stdout 交给宿主机或日志采集代理。此时不需要在应用里配置轮转,统一输出到 stdout 即可,采集层负责切分和清理。所以轮转方案的取舍,本质上是“应用自治”和“平台统一治理”之间的选择。

3.4 结构化日志:给日志平台喂更易解析的数据

当日志从“给人看”变成“给系统分析”,JSON 格式的优势就体现出来了。采集工具如 Filebeat、Fluentd 对多行文本解析时的分隔符问题,在一次 JSON 字符串日志面前几乎不存在。你可以直接在 Formatter 里输出 JSON:

import json class JSONFormatter(logging.Formatter): def format(self, record): log_entry = { "timestamp": self.formatTime(record, self.datefmt), "level": record.levelname, "logger": record.name, "module": record.module, "function": record.funcName, "line": record.lineno, "message": record.getMessage() } if record.exc_info: log_entry["exc_info"] = self.formatException(record.exc_info) return json.dumps(log_entry, ensure_ascii=False)

注意record.getMessage()拿到的是日志消息本身,但如果 log 参数中有自定义 extra 字段,需要在 format 里手动把它们读出来。一个常见做法是在 formatter 中遍历record.__dict__,过滤掉内部字段后其余全部输出,这样任何 Filter 注入的 request_id、user_id 都能自动出现在 JSON 里,不用每个字段都硬编码。

结构化日志的核心价值是让字段可以被搜索、被聚合成指标。比如你的日志平台是 ELK 或者 Loki,你可以在整个平台里直接按level=ERROR和user_id=xxx的组合查询,远比人肉翻一个文本文件效率高。如果你的项目还不打算引入日志平台,纯文本日志也足够,但一旦有跨服务排查需求,JSON 格式就变成了必需品。

3.5 多进程与多线程:日志安全写的正确姿势

Python 的 logging 模块内部是线程安全的,多个线程同时写同一个文件不会出现交错混乱。但多进程场景就不一样了,多个进程同时 open 同一个文件并写入,会因为各自都有独立的文件偏移量而互相覆盖,导致日志丢失或错乱。这个问题在预发环境里很常见:用 gunicorn 或 uvicorn 启动多 worker,每个 worker 都是一个独立进程,都在写同一个日志文件。

解决思路有三种。最推荐的做法是程序中只写 stdout,由外部日志采集器统一收集,这种方案对容器化部署尤其友好。第二种是使用 QueueHandler 和 QueueListener,在父进程中开一个独立线程统一消费队列并写文件,子进程把日志记录放进内存队列即可,这是 Python 官方文档推荐的 pattern,简洁且稳定。第三种是使用concurrent-log-handler这类第三方库,基于文件锁实现多进程安全写文件,但性能比前两种差,适合进程数不多的小规模部署。

我实际项目中最稳的组合是:生产环境用容器部署,应用把所有日志输出到 stdout,Filebeat 负责采集到 Loki;开发环境则挂 StreamHandler 输出到控制台。这样开发者本地就能直接看到日志,同时不会因为多进程写日志文件而困扰。

4. 踩坑实录:那些年我们遇到的日志问题

经过多年实践,我积累了一些日志调试经验。这里把最常见的问题整理成速查表,方便你遇到时直接对照排查。

4.1 日志重复输出,最常见的三个原因

日志重复出现,十有八九是 handler 重复挂了或者 propagate 没关。第一种情况是模块被重复导入,导致addHandler被反复调用,每个 Handler 都挂到同一个 logger 上,日志自然重复输出。解决办法是在添加 handler 前检查logger.handlers是否为空。

第二种情况是子 logger 与 root logger 同时挂 handler 导致的重复。前面 1.2 提到过,子 logger 默认 propagate 为 True,事件会向上传。如果子 logger 自己加了 console handler,root logger 也有 console handler,那么同一条日志会被两个 handler 各打印一遍。修复办法要么让子 logger 的 propagate 为 False,要么在子 logger 上不添加 handler,只让 root 统一输出。

第三种情况在 Web 框架里特别常见:框架自身的日志系统和应用日志系统各自初始化。比如 uvicorn 和 FastAPI 各自管理日志,如果你用logging-getLogger("uvicorn")手动添加了 handler,又通过uvicorn.run(..., log_config=None)触发了新的日志配置,双重配置叠加自然重复。这种情况最好始终让应用层管理日志,框架相关 logger 只调整级别。

4.2 中文乱码,源于编码问题而不是中文字符本身

日志里的中文变成乱码,通常不是 Python 字符串的问题,而是 handler 写入文件时的编码不对。默认情况下,StreamHandler 在 Windows 控制台可能使用 GBK,FileHandler 默认编码是 Python 默认的 locale 编码。解决办法是在 FileHandler 创建时显式设置encoding="utf-8"。如果用 dictConfig,在 handler 配置里加上"encoding": "utf-8"。

另外,日志采集端也可能有编码问题。比如 Filebeat 默认按 UTF-8 读取文件,如果应用写入时用的是 GBK,采集到的内容就会乱码。所以应用层面输出统一 UTF-8 是基本纪律,不仅是日志,整个项目的文本文件都建议统一编码。

4.3 日志成为性能瓶颈,高并发下如何降损

日志写得越多,系统越慢,这是没有任何争议的。但成为瓶颈的原因往往不是日志本身,而是你在记录日志时做了多余的工作。最常见的低效写法是 f-string 拼接复杂对象,比如logger.info(f"request data: {json.dumps(payload)}"),哪怕当前日志级别是 WARNING,json.dumps 也会执行。这个坑在 1.1 提过,这里再强调一遍:所有日志消息应当使用%s占位符风格,并且调用前先检查logger.isEnabledFor(level)。

另一个容易忽视的性能点是使用logging.queue.QueueHandler时,队列的大小和消费速度。如果队列满了,put 操作会阻塞业务线程,所以 QueueListener 消费线程要足够快,或者队列采用有界队列并配合降级策略,而不是无限增长。

如果你在一个极热路径上,比如每秒调用上万次的内部函数,哪怕只是if logger.isEnabledFor(logging.DEBUG)判断也有些开销。更进一步的方案是在该函数里完全不调用 logger,而是通过采样器或计数器把统计指标交给专门的模块记录,这与日志的职责分离,也符合可观测性设计里 metrics 和 logs 分开的原则。

4.4 日志莫名丢失:异步消费者、缓冲区与异常场景

日志“该出现的没出现”比重复日志更让人头疼。我遇到过几次原因各不相同的丢失事件。第一是使用 QueueHandler 时,业务进程崩溃或退出太匆忙,队列里未消费的日志直接丢掉了。解决方案是给进程注册atexit钩子,退出前调用QueueListener.stop()把剩余日志清空。

第二是某些请求库或异步框架在内部捕获异常时不会自动记录日志,导致异常被吞掉。这种情况不是 logging 的问题,而是代码里缺少一个全局异常钩子。我建议你的框架中间件统一捕获未被处理的异常,执行logger.exception("unhandled exception"),如果框架本身没有这个能力,用装饰器包一层也行。

第三是日志文件被外部删除或轮转后,文件描述符失效,写日志时静默失败。使用 WatchedFileHandler 可以有效缓解,因为它会在每次写入前检查文件是否被改名,察觉已变化就重新打开文件。如果是容器化部署,注意检查日志采集任务是否对文件有权限,以及 mount 到宿主机的目录容量是否足够。

4.5 一次真实排障:日志平台里查不到关键请求

有一次排查线上支付订单问题,日志平台里搜订单号只有一条 INFO,没有后续的 ERROR,也没有成功日志。一开始怀疑是日志丢失,后来发现是我们团队有人在代码里用了logger.warning(f"处理失败: {order_id}"),而且是在一个try/except块内先打印了堆栈,随后又抛出了一个新的异常,导致最终记录的 ERROR 消息里没有了订单号。这类问题本质上不是因为 logging 配置出错,而是没有遵循“每条日志都要有足够上下文标识”的原则。

从那以后我一直坚持:任何与业务实体相关的日志,消息里必须带上业务主键(订单号、用户 ID、请求 ID),而且这些字段要放在日志记录的自定义 extra 里,通过 Filter 自动注入,而不是手动拼字符串。这样才能保证日志平台上的搜索字段统一。

5. 更进阶一点:生产环境值得做的四个日志增强

基础配置跑起来了,但离真正好用还有一段距离。下面这四个增强手段,每个都是我亲手在生产环境验证过的,投入产出比极高。

5.1 运行时动态调整日志级别,不用改代码

生产环境排查问题时,最常见的需求是“让我临时看看 DEBUG 日志”。如果日志级别是写死在配置里的,就需要改配置、重启服务,重启本身可能影响在线流量,这种操作很多时候不可接受。

办法是用环境变量控制级别。比如在代码里读取LOG_LEVEL环境变量,默认值是 INFO,启动脚本里可以临时LOG_LEVEL=DEBUG python main.py。另一种更优雅的方式可以用信号处理:监听 SIGUSR1 信号,收到后就把 root logger 的级别切换到 DEBUG,过一段时间或再收一次信号切回 INFO。这在长驻进程里很实用,不需要重启。注意如果你用 systemd 管理服务,默认单位文件可能不允许 SIGUSR1 直通,需要单独配置信号处理,这个细节容易踩到。

5.2 用 contextvars 实现请求级链路追踪

当你从一个 Web 请求出发,经过业务逻辑、SQL 查询、外部 RPC 调用,最终返回响应,中间涉及的所有函数都会打日志。问题是日志平台里这些日志的时间戳分散,你很难把它们归拢成一条链路。业界通用解决方案是 request_id 链路追踪:请求进来时生成一个唯一 ID,在这条请求处理路径上的所有日志都带着这个 ID,日志平台按 ID 聚合即可。

Python 3.7 之后,contextvars提供了完美的实现方式。请求中间件里往 ContextVar 写入 request_id,同一个协程或线程内部所有 logging 日志读取这个变量并注入到日志记录。核心代码大致是这样:

import contextvars import logging request_id_var: contextvars.ContextVar[str] = contextvars.ContextVar("request_id", default="-") class TraceIDFilter(logging.Filter): def filter(self, record): record.request_id = request_id_var.get() return True

在请求入口处执行request_id_var.set(uuid4().hex),这个值就会自动出现在当前执行上下文及其所有子协程、子任务中。和手动传参相比,这种方式对业务代码零侵入,所有日志只需挂上这个 Filter 就自动带上 request_id 字段。日志格式串里加上[%(request_id)s],再配日志平台按字段聚合,基本就实现了单服务内的链路追踪。

跨服务时,一般通过 HTTP header 透传这个 request_id。比如服务 A 调用服务 B 时,在 outbound 请求里带上当前 request_id,服务 B 的中间件把它读出来后contextvars继续向下传递。这样整条调用链上的每个服务日志都能用同一个 ID 串起来。

5.3 错误日志自动关联告警,别靠人肉盯日志

如果 ERROR 日志只是静静地写进文件,没有被任何人看到,那它和没写并没有区别。生产环境里,我会让 ERROR 日志和告警通道打通。方案有轻有重:轻量做法是在应用里用一个专门的 ERROR handler,当日志级别为 ERROR 时把消息同时发给飞书/钉钉/企业微信的 webhook;重量做法是接 Sentry 等 APM 产品,直接把异常堆栈和上下文推到平台上。

接入 Sentry 其实非常简单,它的 handler 是现成的:

import sentry_sdk from sentry_sdk.integrations.logging import LoggingIntegration sentry_logging = LoggingIntegration( level=logging.INFO, event_level=logging.ERROR ) sentry_sdk.init(dsn="https://xxx@sentry.example.com/1", integrations=[sentry_logging])

注意接入 Sentry 之后,normal 的 ERROR 日志和 Sentry 事件并不意味着重复。Sentry 更擅长记录异常上下文、堆栈和用户影响范围,而本地日志平台负责存储全量历史。两者各司其职,没有冲突。我还建议在关键 ERROR 日志里附带关联的 request_id,方便从告警跳到日志平台看完整链路。

5.4 不同环境用不同的日志策略

开发环境、测试环境、预发环境、生产环境对日志的要求完全不同。开发环境可以全量 DEBUG,怎么啰嗦都行;测试环境需要明确记录测试用例执行过程;预发环境建议和生产一样严格;生产环境则要平衡磁盘占用与排障效率。我的做法是在同一个配置模板里留占位符,通过环境变量控制几个关键参数:日志级别、控制台是否输出、是否启用 JSON 格式化、日志保留天数或文件轮转大小。

一个值得提倡的实践是把日志配置模板化,例如用一个logging_config_factory(env)函数返回不同环境对应的 dictConfig,而不是复制粘贴多份配置文件。环境变量 SPREAD 出一份配置,所有服务统一沿用,能避免每个服务各自为政导致的管理混乱。日志配置是基础设施,基础设施的变更理应走统一的 CI/CD 流程,不要让日志配置成为一个随意修改的孤儿文件。

6. 我的一点个人心得

写了这么多年 Python,我越来越觉得 logging 不是一门“会用 API”的手艺,而是一种系统设计能力。你的系统复杂度越高,日志策略就越需要提前规划。你现在偷懒用的 print,将来都会变成凌晨被叫醒的代价。如果这篇文章只能留下一句话,那就是:从项目第一天就按生产级别的日志标准来写,即使你的项目现在还很小。因为你永远不知道它哪一天会突然长成需要 debug 一整晚的庞然大物。

最后分享一个我养成的小习惯:写完一段新功能后,我不看功能是否能跑通,先跑一遍关键路径,把日志输出从 INFO 调到 DEBUG 仔细扫一遍,确认每个关键分支和异常分支都有唯一可搜索的日志。这个过程通常能提前暴露很多诡异问题,远比上线后靠日志去反查节约时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询