Python日志记录最佳实践:从Logging组件到生产环境排障
凌晨两点被值班电话吵醒起因是服务突然开始疯狂报错。等我爬起床登录服务器打开日志文件发现里面全是 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另一种优雅的上下文传递方式除了 Filterlogging 还提供了 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( levellogging.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 日志生产环境如果不需要就调到 WARNINGSQLAlchemy 的引擎日志默认输出 SQL 语句除非你要慢查询分析否则也建议调到 WARNING。把第三方库的日志处理好能大幅减少噪音。3.3 日志轮转参数怎么设计才算真正靠谱日志文件不轮转早晚会撑爆磁盘。Python 内置的 RotatingFileHandler 按文件大小切分TimedRotatingFileHandler 按时间切分。大部分项目里我推荐优先用按大小轮转因为它的行为可预期单文件大小达到阈值就切分不依赖当前时间点。参数设计上maxBytes我通常设为 100MBbackupCount设为 5也就是最多保留 500MB 的日志。考虑到业务规模波动错误日志可以用更大的 backupCount比如 10因为错误日志量小留更多历史便于追溯。encodingutf-8必须显式加上否则 Windows 环境会出现 ANSI 编码问题。TimedRotatingFileHandler 有几个细节坑when参数支持 S、M、H、D、midnight 等取值。但要注意它切分文件时根据的是文件修改时间如果在日志量很少的情况下它会在每个周期结束时强制轮转即使文件没写多少内容。另外backupCount 对不同时间单位的解析逻辑不同用 S 时 backCount 代表秒数而不是保留多少个文件这个设计很容易让人踩坑。如果是 Docker 容器部署容器内一般只保留当前日志文件容器日志通过 stdout 交给宿主机或日志采集代理。此时不需要在应用里配置轮转统一输出到 stdout 即可采集层负责切分和清理。所以轮转方案的取舍本质上是“应用自治”和“平台统一治理”之间的选择。3.4 结构化日志给日志平台喂更易解析的数据当日志从“给人看”变成“给系统分析”JSON 格式的优势就体现出来了。采集工具如 Filebeat、Fluentd 对多行文本解析时的分隔符问题在一次 JSON 字符串日志面前几乎不存在。你可以直接在 Formatter 里输出 JSONimport 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_asciiFalse)注意record.getMessage()拿到的是日志消息本身但如果 log 参数中有自定义 extra 字段需要在 format 里手动把它们读出来。一个常见做法是在 formatter 中遍历record.__dict__过滤掉内部字段后其余全部输出这样任何 Filter 注入的 request_id、user_id 都能自动出现在 JSON 里不用每个字段都硬编码。结构化日志的核心价值是让字段可以被搜索、被聚合成指标。比如你的日志平台是 ELK 或者 Loki你可以在整个平台里直接按levelERROR和user_idxxx的组合查询远比人肉翻一个文本文件效率高。如果你的项目还不打算引入日志平台纯文本日志也足够但一旦有跨服务排查需求JSON 格式就变成了必需品。3.5 多进程与多线程日志安全写的正确姿势Python 的 logging 模块内部是线程安全的多个线程同时写同一个文件不会出现交错混乱。但多进程场景就不一样了多个进程同时 open 同一个文件并写入会因为各自都有独立的文件偏移量而互相覆盖导致日志丢失或错乱。这个问题在预发环境里很常见用 gunicorn 或 uvicorn 启动多 worker每个 worker 都是一个独立进程都在写同一个日志文件。解决思路有三种。最推荐的做法是程序中只写 stdout由外部日志采集器统一收集这种方案对容器化部署尤其友好。第二种是使用 QueueHandler 和 QueueListener在父进程中开一个独立线程统一消费队列并写文件子进程把日志记录放进内存队列即可这是 Python 官方文档推荐的 pattern简洁且稳定。第三种是使用concurrent-log-handler这类第三方库基于文件锁实现多进程安全写文件但性能比前两种差适合进程数不多的小规模部署。我实际项目中最稳的组合是生产环境用容器部署应用把所有日志输出到 stdoutFilebeat 负责采集到 Loki开发环境则挂 StreamHandler 输出到控制台。这样开发者本地就能直接看到日志同时不会因为多进程写日志文件而困扰。4. 踩坑实录那些年我们遇到的日志问题经过多年实践我积累了一些日志调试经验。这里把最常见的问题整理成速查表方便你遇到时直接对照排查。4.1 日志重复输出最常见的三个原因日志重复出现十有八九是 handler 重复挂了或者 propagate 没关。第一种情况是模块被重复导入导致addHandler被反复调用每个 Handler 都挂到同一个 logger 上日志自然重复输出。解决办法是在添加 handler 前检查logger.handlers是否为空。第二种情况是子 logger 与 root logger 同时挂 handler 导致的重复。前面 1.2 提到过子 logger 默认 propagate 为 True事件会向上传。如果子 logger 自己加了 console handlerroot logger 也有 console handler那么同一条日志会被两个 handler 各打印一遍。修复办法要么让子 logger 的 propagate 为 False要么在子 logger 上不添加 handler只让 root 统一输出。第三种情况在 Web 框架里特别常见框架自身的日志系统和应用日志系统各自初始化。比如 uvicorn 和 FastAPI 各自管理日志如果你用logging-getLogger(uvicorn)手动添加了 handler又通过uvicorn.run(..., log_configNone)触发了新的日志配置双重配置叠加自然重复。这种情况最好始终让应用层管理日志框架相关 logger 只调整级别。4.2 中文乱码源于编码问题而不是中文字符本身日志里的中文变成乱码通常不是 Python 字符串的问题而是 handler 写入文件时的编码不对。默认情况下StreamHandler 在 Windows 控制台可能使用 GBKFileHandler 默认编码是 Python 默认的 locale 编码。解决办法是在 FileHandler 创建时显式设置encodingutf-8。如果用 dictConfig在 handler 配置里加上encoding: utf-8。另外日志采集端也可能有编码问题。比如 Filebeat 默认按 UTF-8 读取文件如果应用写入时用的是 GBK采集到的内容就会乱码。所以应用层面输出统一 UTF-8 是基本纪律不仅是日志整个项目的文本文件都建议统一编码。4.3 日志成为性能瓶颈高并发下如何降损日志写得越多系统越慢这是没有任何争议的。但成为瓶颈的原因往往不是日志本身而是你在记录日志时做了多余的工作。最常见的低效写法是 f-string 拼接复杂对象比如logger.info(frequest data: {json.dumps(payload)})哪怕当前日志级别是 WARNINGjson.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_LEVELDEBUG 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( levellogging.INFO, event_levellogging.ERROR ) sentry_sdk.init(dsnhttps://xxxsentry.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 仔细扫一遍确认每个关键分支和异常分支都有唯一可搜索的日志。这个过程通常能提前暴露很多诡异问题远比上线后靠日志去反查节约时间。