
做后端这几年我发现自己跟日志系统打交道的时间远比自己写业务逻辑的时间多。而Python的logging模块又是所有语言日志方案里最“反直觉”的一个接口简单到三行代码就能跑真到了生产环境日志重复、丢失、轮转失效、格式混乱这些问题全冒出来时你会觉得自己根本没用明白它。这篇东西我不打算讲文档里那些套话就按我实际在项目里踩坑、调优、最终沉淀下来的这套“Python日志记录Logging最佳实践”把原理、配置、坑点一起讲透。适用的人很明确Python刚入门但不想走弯路的同学以及已经在用logging但总觉得“哪里不对劲”的工程化开发同学。我会从核心组件拆起一步一步给出可直接抄走的配置模板最后附上我整理过的问题排查表。1. 为什么日志系统这么容易写歪先建立正确的认知框架我见过太多项目一开始用print调试上线后print全删出问题时两眼一抹黑。也见过一些人老老实实用了logging但只是调用logging.basicConfig(levellogging.INFO)运行一阵子发现日志文件巨大无比、磁盘被打满。这些问题的根源是没理解logging模块最核心的设计思想日志系统不是让你“打几句话”而是让你收集程序运行过程中所有值得记录的状态信号。1.1 logging模块的核心设计思路组件解耦Python logging模块的设计思路跟Java的Log4j、Node.js的winston是一脉相承的核心就一句话日志的产生、加工、输出完全分离。它由四个基本组件构成组件职责生活化类比Logger产生日志的入口负责记录消息公司里负责“汇报”的员工Handler决定日志写到哪比如控制台、文件“汇报”的传递渠道快递Formatter决定日志长什么样汇报的模板格式Filter决定哪些日志可以放行汇报的筛选人一个Logger产生一条日志消息后会被交给Handler处理Handler再根据Formatter规定的格式把消息渲染出来写走。这条链路里的任何一环都互不干扰你可以让同一个Logger把日志同时写到文件和控制台也可以让控制台输出简单格式、文件输出详细格式——只要配置不同的Handler就行。很多人一开始写日志时习惯用print我只能说print适合在交互式环境里临时看结果一旦程序变成服务、任务、脚本进程这些“无人值守”形态print基本等于自杀式调试没有等级控制没有时间戳没有源码位置输出到stdout后随着容器或进程销毁就再也找不回来。logging模块的另一个价值是它把“日志等级”这个概念制度化这直接决定了你在混乱的生产环境里能不能快速找到“真正的错误”。1.2 日志等级从DEBUG到CRITICAL别当摆设logging模块定义了五个标准等级从低到高依次是DEBUG、INFO、WARNING、ERROR、CRITICAL。每一条日志都有等级而Logger和Handler都有自己的最低受理门槛。意思很简单你给Logger设置levelINFO那DEBUG级别的日志在源头就被拒了根本不会往下游走。等级选择这件事我在团队里反复强调过一条原则DEBUG是给开发者的INFO是给运维者的WARNING是给值班者的ERROR和CRITICAL是给救火队的。这句口诀同样适合你从写第一行日志开始就想清楚不要把DEBUG当INFO用也不要把ERROR当INFO用。真实场景里最常见的反面案例是有人把所有关键信息全记成INFO一旦系统出了稳定复现的问题日志里全是流水账真正的异常信号被淹没在几十万行INFO日志里还有人反过来把“任务正常完成”之类的事记成ERROR导致告警误报值班同事半夜被无效告警骚扰。我个人建议每次调用logger.info()之前都问自己一句——这条消息对“排查问题”真的有用吗如果没有降级到DEBUG如果这是错误分支但程序还能继续运行用WARNING如果程序都跑不下去了那才轮到ERROR。这套判断标准能直接决定日志系统的可用性比任何花哨配置都重要。2. 工程化落地的关键细节配置方式、轮转与模块分层理解了组件和等级接下来要处理的是工程化问题。我见过不少项目日志代码写得到处都是配置却只靠一行logging.basicConfig()。这在小脚本里没问题一旦服务有多个模块、开始跑定时任务或部署到docker里这一行配置根本不够用。工程化日志的第一条硬规矩就是配置要与代码分离且要能根据环境切换。2.1 配置方式的选型basicConfig、fileConfig还是dictConfigPython官方提供了三种配置方式代码硬编码、fileConfig()读取INI配置文件、dictConfig()读取字典配置。我强烈建议一律用dictConfig理由有三条INI格式的配置文件表达能力有限比如要配多个Handler、给Handler设置过滤器、配置Logger层级INI写起来非常别扭而字典结构天然能表达嵌套关系和Python数据模型完全一致。dictConfig支持用()来实例化自定义对象比如自定义Formatter、自定义Filter甚至能用factory参数指定处理器类扩展性远胜INI。字典配置可以直接写在代码里也可以从YAML或JSON文件加载方便测试环境、生产环境切换配置源。下面是一个工程级的最小dictConfig骨架我先放出来后面会逐行解释import logging.config LOGGING_CONFIG { version: 1, disable_existing_loggers: False, formatters: { standard: { format: %(asctime)s [%(levelname)s] %(name)s: %(message)s }, detailed: { format: %(asctime)s [%(levelname)s] %(process)d %(threadName)s %(name)s:%(lineno)d: %(message)s } }, handlers: { console: { class: logging.StreamHandler, level: INFO, formatter: standard, stream: ext://sys.stdout }, file: { class: logging.handlers.RotatingFileHandler, level: DEBUG, formatter: detailed, filename: app.log, maxBytes: 10485760, backupCount: 5, encoding: utf-8 } }, loggers: { app: { handlers: [console, file], level: DEBUG, propagate: False } }, root: { handlers: [console], level: WARNING } } logging.config.dictConfig(LOGGING_CONFIG) logger logging.getLogger(app)2.2 核心参数详解maxBytes、backupCount、propagate先看文件Handler这里用的是RotatingFileHandler它会在文件大小达到阈值时自动轮转。maxBytes10485760就是10MB当文件超过10MB时当前文件会被改名为app.log.1新建的app.log继续写入backupCount5表示最多保留5个历史文件超出后最老的那个会被删掉。这样磁盘占用被严格限制在10MB * 6 60MB以内不会出现日志把磁盘写爆的惨剧。计算方式很简单预估一下单条日志平均大小一般几百字节估算高峰期每秒产生多少条日志再算一下你想保留多少天的日志就能反推maxBytes。比如每天产生200MB日志、想保留3天那就是600MB如果backupCount10maxBytes就得设为60MB。不要为了省事随手写一个1MB那样轮转太频繁日志文件数量多、分析也不方便。再看propagate这个参数这是90%重复日志问题的源头。Python的Logger遵循层级命名logging.getLogger(app.auth)是logger(app)的子Logger。默认情况下一条日志不仅会传给当前Logger的Handler还会一路向上传给父Logger的Handler直到root。这就是为什么你在自己的模块里配置了一个文件Handler结果日志在控制台打了两遍甚至三遍因为父Logger的Handler又处理了一遍。解决方式有两种一是给顶层的业务Logger设置propagateFalse阻断向上传播二是只给root配置Handler所有子Logger都不配Handler只设level。我推荐混合模式自己的业务模块内建独立Logger并设propagateFalseroot只保留一个兜底的控制台WARNING输出专门接住意外情况。2.3 按模块分LoggergetLogger(name)是底线我在所有项目的代码规范里定了一条硬性规定任何模块里都只能用logger logging.getLogger(__name__)来拿Logger。__name__在模块加载时会自动变成模块的完整路径名比如app.services.order。这样做的直接好处是日志里会自带%(name)s字段你能一眼看出这条日志是哪个模块打的同时你可以在配置里针对某个子模块单独调级比如对app.services.order开DEBUG、对其他模块保持INFO无需改代码。不少团队把“日志关注度”放在模块粒度上核心交易模块要求DEBUG级别第三方SDK调用模块只记WARNING这全靠模块级Logger实现。还有一个很容易踩的坑第三方库也会往root Logger上报日志。如果你不管requests、urllib这些库的INFO日志会源源不断刷进你的文件。所以root级别设成WARNING基本是生产环境的默认选择既能保留第三方库的警告和错误又不会让它们污染你的业务日志。3. 生产级配置模板、异常堆栈与结构化日志实操配置搞明白之后真正拉开差距的是“日志内容”本身。我见过太多系统Handler配得漂漂亮亮但每条日志就一句话“Error: something wrong”连堆栈都没有。这种日志拿去排查问题等于没有日志。这一节我给出我实际在用的生产级方案从Formatter格式到异常堆栈再到结构化日志。3.1 Formatter格式串的进阶写法基础格式串%(asctime)s [%(levelname)s] %(name)s: %(message)s够用但并不够好。我推荐生产环境用这条%(asctime)s %(levelname)-8s [%(process)d:%(threadName)s] %(name)s:%(lineno)d | %(message)s这里有几个值得注意的细节。%(levelname)-8s表示左对齐并补足8位这样不同长度的等级单词在日志里排列整齐扫描时特别舒服。%(process)d是进程ID当多进程服务收到一条异常日志时你能直接通过PID定位到是哪个worker进程出的问题。%(threadName)s是线程名在线程池并发场景下这个字段能帮你判断日志是否来自同一个请求线程。%(name)s:%(lineno)d组合起来就是“哪个模块的哪一行”任何报错都能精确跳转。还有一个经常被忽略的字段%(funcName)s会显示调用日志的函数名。在函数多、逻辑复杂的模块里这个字段帮助巨大。你可以根据团队习惯在格式串里取舍但至少name、lineno、process、threadName这四个字段我觉得是工程化日志的标配。3.2 异常堆栈记录logger.exception与exc_info的正确姿势异常日志这块我绝不提倡logger.error(f出错了{e})这种写法。当你只把异常对象格式化成字符串时异常的完整堆栈信息——哪个函数被调用、哪一行触发的链式调用——全丢了。排查线上问题时堆栈比异常信息本身值钱得多。正确姿势是try: result risky_operation() except Exception: logger.error(调用risky_operation失败, exc_infoTrue) raiseexc_infoTrue会把当前异常堆栈自动塞进日志。另一种更简短的写法是logger.exception(调用risky_operation失败)它等价于logger.error(..., exc_infoTrue)但只能在异常处理块里调用离开except块后堆栈信息就取不到了。这里有个小坑要提醒如果你在except里捕获了异常却想继续抛出去让上层处理千万别在捕获后不记日志直接raise这样上层捕获到异常时如果没记整个运行链路里这条错误就彻底消失了。正确做法是在捕获点记日志带exc_infoTrue然后再raise上层可再记或不记但至少保证关键错误有原始记录。另外绝对不要用logger.error(str(e))来记录异常因为异常对象转字符串时堆栈已经丢失排查会很痛苦。3.3 结构化日志从文本到JSON可检索性直接翻倍如果你所在的项目已经上了日志采集系统ELK、Loki、Splunk这类纯文本日志分析起来效率很低——正则匹配复杂、字段提取容易出错。这两年我自己的偏好是结构化日志简单说就是把每条日志输出成一个JSON对象字段就是键值对采集系统可以直接解析、聚合、查询。不引入额外依赖的话直接自定义一个Formatter就能实现import json import logging class JsonFormatter(logging.Formatter): def format(self, record): log_entry { time: self.formatTime(record, %Y-%m-%dT%H:%M:%S%z), 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) if hasattr(record, trace_id): log_entry[trace_id] record.trace_id return json.dumps(log_entry, ensure_asciiFalse)这只是一个起点生产上我一般还会往里加进程号、线程号、环境名、服务名甚至加一个trace_id贯穿整条请求链路这样关联查询时效率很高。往record上挂自定义字段也简单在请求入口处做一次logger logging.getLogger(__name__); logger logging.LoggerAdapter(logger, {trace_id: trace_id})之后通过这个Logger打的日志都会被适配器自动加上trace_id字段连同Filter里的extra参数一起落到JSON里。注意一个细节record.getMessage()只负责格式化%(message)s真正要让%(trace_id)s出现在格式串里你得保证record对象上真的有trace_id这个属性否则Formatter会报KeyError。结构化日志的组件搭配上用Filter比用适配器更干净我会在配一个ContextFilter从线程局部变量里读trace_id注入每条日志这样就不需要每个模块都包一层适配器了。4. 常见问题排查速查与独家避坑清单配置写完了、代码上线了、日志也打了接下来大概率会遇到一些经典问题。这些问题我基本都在项目里亲历过这里挑高频的排一张速查表再重点讲几个我印象最深的坑。4.1 高频问题速查表现象根因解法同一日志在控制台出现两遍logger配置了Handler且propagateTrue父Logger又处理了一次顶层业务Logger设propagateFalse或统一使用root Handler代码里logger.info打印不出来Logger级别或Handler级别高于INFO同时检查logger和handler的level两个关卡都要放行日志文件未生成或写入失败日志目录不存在、无写权限程序启动时用os.makedirs(os.path.dirname(log_path), exist_okTrue)确保目录存在RotatingFileHandler轮转失效文件被其他进程持续打开Windows下尤其常见检查是否有进程长期占用日志文件多进程场景用QueueHandler方案时间戳是本地时间但服务器在UTC默认%(asctime)s取的是本地时间容器内时区不统一重写formatTime()或传自定义时间Formatter统一转UTC中文日志乱码文件Handler没指定编码Handler加encodingutf-8就像我前面模板里写的日志里没有异常堆栈用了str(e)替代exc_infoTrue统一改用logger.exception()或exc_infoTrue日志打出来的对象是object object at 0x...%s格式化没有调用自定义__str__手动做序列化或者给对象实现__str__4.2 重复日志几乎人人都会踩的经典坑重复日志这个问题我几乎每次接手老项目都能撞见。最典型的场景是写了一个模块级Logger配了文件Handler然后模块内部每个子文件再getLogger(__name__)结果一条日志在文件里出现N次。原因就是前面说的传播机制——子Logger把日志交给自己的Handler输出一次又向上传播给父Logger父Logger自己的Handler再输出一次。排查这类问题有个简单的办法把所有Handler的propagate先设成False看问题是否消失如果消失了说明是传播叠加的问题。根治方式还是回到架构层面日志接收和日志输出解耦所有子Logger不配Handler只有顶层或root配Handler。我对团队的约定很明确模块内只getLogger不addHandler。Handler的注册全部放在配置阶段完成运行期代码只负责打日志。这个约定一旦建立重复日志问题几乎绝迹。4.3 日志丢失级别和路径的双重排查另一个高频问题是“日志怎么少了几条”。我刚才说了一条日志要经过两级关卡Logger自身级别和Handler级别。只要任何一级比日志等级高日志就会被拦住。排查时先看Logger的level再看对应Handler的level。还有一个藏在暗处的问题——logging.basicConfig()只在root Logger没有Handler时才生效。如果你中途某些模块自己addHandler了再调用basicConfig()就会无效导致部分模块的日志“消失”这个行为很多人不理解建议统一走dictConfig一开始就把所有Handler规划清楚。4.4 多进程写日志的性能与轮转异变最后一个大坑是Python日志在多进程下的行为。RotatingFileHandler在多进程场景下每个进程可能同时判断“文件超过maxBytes了”并执行轮转结果就是日志文件互相挤占、轮转错乱甚至日志丢失。如果服务是多进程起多个worker我有两个方案一是继承QueueHandlerQueueListener让所有进程把日志先推到内存队列由一个专用线程统一写文件这样无论多少进程写最终只有一个写入者完美规避文件锁和轮转问题二是干脆把日志输出到stdout由上层的采集程序docker logging driver、systemd-journal这类统一收集业务进程完全不落盘。第二种方案在容器化部署里越来越流行因为它天然规避了本地磁盘写满的问题。关于性能我还有一个切身经验日志收集打断业务逻辑的段子网上到处都是而Python的logging默认是同步写文件一旦磁盘IO慢比如网络盘、机械盘打日志本身就会拖慢接口。如果你所在项目的日志量特别大、或者写日志的介质不可靠上QueueHandler后台线程写盘几乎是必须的代价是进程崩溃时可能会有最后几秒日志没来得及写盘权衡时要想清楚。5. 多线程、子进程与统一入口的最佳实践补充除了前面说的工程化日志还有几个容易被忽略但时常制造麻烦的细节我把它们单独归拢在这一节里当作补充建议。5.1 多线程场景下的日志追加顺序Python的logging模块本身是线程安全的多线程打日志基本不会出现内容错乱因为StreamHandler和FileHandler底层都有锁保护。不过要注意锁的存在不代表“日志顺序绝对和时间挂钩”线程A先调用logger、线程B后调用最终落到文件的顺序可能受GIL调度影响导致先打的不一定先落盘。这个问题在排查“日志时间倒序”时容易被误解成bug。实际上这是多线程并发日志的正常现象一般不影响问题定位。如果你对顺序要求极其严格比如金融交易审计正确方案是在日志里带上单调递增的序号或者业务流水号而不是单纯依赖时间戳排序。有一类特殊场景我需要额外提醒当你在信号处理器signal handler里打日志时logging模块的锁机制可能和主进程的其他线程产生死锁因为信号处理器会打断任意代码执行点。生产环境的稳定做法是信号处理器里只设置标志位日志留给主循环打不要直接在信号处理器里用logger。5.2 子进程日志处理用multiprocessing或subprocess开子进程时子进程默认会继承父进程的Logger配置。看起来方便实则容易出问题如果父进程用的是RotatingFileHandler子进程和父进程会同时持有同一个日志文件的句柄之前说的轮转冲突就在这里出现多进程写同一文件还有可能交叠串行。子进程的日志最好也走“stdout集中收集”或“独立文件”方案不要共享父进程的文件Handler。5.3 统一入口的威力请求日志与上下文贯穿无论你是写Web服务、脚本还是数据处理任务我建议每个程序都提供一个统一的日志入口模块封装两件事一是初始化dictConfig的加载逻辑本地开发、测试、生产分别读不同配置文件二是提供一个get_request_logger(trace_idNone)工厂函数自动给Logger注入请求上下文。这个入口模块一旦建立整个团队的日志行为就收敛了后期做日志脱敏、加字段、切换JSON格式都只需要动一处而不是翻遍每个模块。脱敏这一块尤其值得提早规划。我见过有人把用户手机号、银行卡号直接打进日志等日志采集系统被外部审计时出了问题才追悔莫及。处理方式有两个层面一是在源头控制禁止将敏感字段拼进日志消息统一传上下文ID二是在Filter层做二次拦截检测特定正则模式并替换。前者的优先级高于后者因为Filter只对经过它的日志生效而日志消息已经格式化过了就来不及了。6. 我最终沉淀下来的实践经验踩了这么多坑之后我自己现在写Python日志的态度可以浓缩成几句话算是给这份最佳实践做个收尾。配置上我所有项目从第一天就用dictConfig文件里永远保留console、file两个Handlerfile永远用RotatingFileHandler并指定utf-8编码root级别永远是WARNING。代码里我要求所有模块只用getLogger(__name__)任何Handler的add都发生在配置阶段模块内禁止出现addHandler。内容上错误日志必须带exc_infoTrue或走logger.exception格式串里至少包含name、lineno、process、threadName四个字段日志量大的服务慢慢往JSON结构化上迁移。这套组合拳不是最花哨的方案但它是让我在多次故障排查里“有日志可查、查完能定位、定位能复现”的可靠保障。日志这件事真正的价值不在打得多而在打得准、留得住、查得清。如果你能把自己的项目从“print发泄式日志”逐步修到这一套工程化状态将来线上出问题时你会感谢当初认真写日志的自己。