python(71)这个系列写到这一篇,讲的是打印日志,而且标题里特意强调“醒目”两个字。说实话,日志打出来是给人看的,不是为了凑行数。我见过太多项目,日志拉出来一大片灰蒙蒙的文字,出错的时候根本找不到哪一行才是关键,最后只能 ctrl+F 搜 ERROR。花十分钟把打印日志这件事做漂亮,后面排查问题能省下几个小时,这笔账怎么算都划算。
这篇适合谁看?正在写 Python 脚本、后端服务、定时任务,或者维护老项目的同学都适用。核心就三件事:搞明白为什么用 logging 而不是 print,给日志加上颜色和结构化信息,再把控制台输出和文件落盘做成一套开工就能用的配置。
1. 先搞清楚:print 和 logging 到底差在哪
1.1 什么场景下日志必须“醒目”
你写一个爬虫脚本,每天凌晨跑一遍,抓完数据写入数据库。脚本跑完没报错,谁也看不出问题。等到某天数据没更新,你打开日志文件,面对的是几百行没有任何级别标识、没有任何时间戳的 print 输出,你甚至分不清哪一行是“抓取完成”,哪一行是“异常跳过”。这种时候你就知道,日志不够醒目,本质上等于没有日志。
服务端排查问题更典型。一个接口出错了,你希望日志里第一眼能看到:什么时间、哪个模块、什么级别、什么错误信息、堆栈有没有。而不是在一堆毫无层次感的文字里大海捞针。所谓“醒目”,核心就是这四点:级别标得清楚、关键信息靠前、颜色区分明显、重要上下文不丢。
还有一类场景是命令行工具。一个部署脚本,执行到一半卡住了,用户盯着终端看,最需要的是那种“一眼扫过去就知道当前走到哪一步”的感觉。比如“正在拉取依赖”“配置已写入”“启动成功”,这种阶段性输出如果全是一个颜色、一个格式,用户在终端前等着,心里完全没底。把阶段信息刷成高亮色,体验完全不一样。
1.2 logging 相比 print 的核心优点
print 解决的是“把字符串输出到屏幕”,而 logging 解决的是“把程序运行状态记录下来,在需要的时候能看、能筛、能转存”。这不是一回事。
logging 自带五个级别:DEBUG、INFO、WARNING、ERROR、CRITICAL。这意味着你可以控制“开发时多看细节,生产时只关心异常”。print 做不到这一点,要么全打出来,要么全删掉,没有中间状态。
logging 可以把同一条日志同时送到控制台、文件、远程日志服务,每个目标各自独立配置。print 想做到这个,得自己写文件操作、自己拼格式、自己管异常,工作量不小。
logging 的格式化能力很强,可以自动带上时间、进程号、线程名、模块名、行号。print 想要这些,每个输出点都得手动拼一遍,拼着拼着就有人偷懒不写了。
还有一点经常被忽略:logging 的格式化是惰性求值。log.debug("value = %s", expensive_func()) 只有在这条日志真正需要输出时才去调用 expensive_func(),而 f-string 写法 log.debug(f"value = {expensive_func()}") 是无论级别达不达标都会执行。高并发程序里,这个差异会被放大得很明显。
| 对比项 | logging | |
|---|---|---|
| 输出级别控制 | 不支持 | 支持 DEBUG 到 CRITICAL |
| 同时输出到多个目标 | 需要自己写逻辑 | Handler 天然支持 |
| 自动附带时间/位置信息 | 不支持,全靠手写 | Formatter 内置 |
| 惰性格式化 | 不支持 | 支持 %s 风格 |
| 线程安全 | 不保证 | 标准库实现线程安全 |
| 第三方库适配 | 无法拦截 | 可以统一接管第三方日志 |
结论很简单:正式项目里日志输出一律用 logging,print 只适合临时调试或者写一次性小脚本。
2. 给日志上颜色:核心实现与底层原理
2.1 ANSI 转义序列:终端颜色的底层机制
终端能显示颜色,靠的是 ANSI 转义序列。这个东西说穿了就是一个特殊的字符序列:\033[ 开头,后面跟数字和字母,最后以 m 结尾。比如 \033[32m 表示把当前输出改成绿色,\033[0m 表示重置回默认颜色。
所谓“给日志上色”,本质就是在日志文本的前后拼上对应的转义序列。输出到控制台时,终端看到这些序列就解析成颜色;写入文件时,这些序列反而会成为噪音,所以文件日志一般不用颜色。
常用颜色值并不复杂,前景色从 30 到 37,背景色从 40 到 47,加粗是 1,关闭所有属性是 0。常见的做法是:
- 前景 31:红色,适合 ERROR
- 前景 32:绿色,适合 INFO 或成功提示
- 前景 33:黄色,适合 WARNING
- 前景 36:青色,适合 DEBUG 或次要信息
- 前景 35:紫色,适合 CRITICAL
这里有个细节:不同终端的实际显示效果不完全一样,有的终端默认背景是深色,有的是浅色,所以颜色选择要尽量保证在两种背景上都能看清。黄色和深红在浅色背景下表现会差一些,白色背景终端建议直接把 ERROR 的整行背景刷成红色。
2.2 按日志级别配色的完整实现
实现思路很简单:写一个继承自 logging.Formatter 的子类,在 format 方法里根据 levelname 给消息加上颜色。
import logging class ColoredFormatter(logging.Formatter): COLORS = { "DEBUG": "\033[36m", # 青色 "INFO": "\033[32m", # 绿色 "WARNING": "\033[33m", # 黄色 "ERROR": "\033[31m", # 红色 "CRITICAL": "\033[35m", # 紫色 } RESET = "\033[0m" def format(self, record): color = self.COLORS.get(record.levelname, "") message = super().format(record) if color: return f"{color}{message}{self.RESET}" return message用法也简单,先创建 handler,把这个 formatter 挂上去,再看情况绑定到 logger。
handler = logging.StreamHandler() handler.setFormatter(ColoredFormatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s")) logging.basicConfig(level=logging.INFO, handlers=[handler])这样设置之后,INFO 日志整行是绿色,ERROR 日志整行是红色。整行着色比只给级别加颜色更醒目,特别是在日志量很大的时候,扫一眼颜色就能定位问题区间。
如果你只想要级别字段带颜色,那就别在 format 里给整个消息加颜色,改成在 format 方法里先拿到原始消息,再单独匹配 levelname 做替换,这种局部着色虽然细节上更“克制”,但实际排查时效果不如整行着色直观。我个人的习惯是:控制台日志整行按级别着色,文件日志不带颜色但保留级别文字。
3. 实战:一套开箱即用的“醒目日志”配置
3.1 单文件脚本最快的上手版本
如果你只是写一个跑完就结束的脚本,不需要复杂的包结构,那直接配置 root logger 就够了。下面这段是我日常写脚本最常用的一套,包含了时间、级别、模块名、行号和消息内容,并且按级别着色。
import logging import sys def setup_console_logger(): fmt = "%(asctime)s | %(levelname)-8s | %(name)s:%(lineno)d | %(message)s" handler = logging.StreamHandler(sys.stdout) handler.setFormatter(ColoredFormatter(fmt)) logging.basicConfig(level=logging.INFO, handlers=[handler], force=True) setup_console_logger() logger = logging.getLogger(__name__) logger.info("开始处理任务") try: 1 / 0 except ZeroDivisionError: logger.error("计算过程出现除零错误", exc_info=True)输出效果大致是:
2025-01-10 22:31:08 | INFO | __main__:13 | 开始处理任务 2025-01-10 22:31:08 | ERROR | __main__:16 | 计算过程出现除零错误 Traceback (most recent call last): File "<stdin>", line 15, in <module> ZeroDivisionError: division by zero注意 basicConfig 里的 force=True。这个参数会把 root logger 上已有的 handler 清掉再重新配置,避免在交互式环境或者多次执行时重复打印。Python 3.8 开始支持这个参数,如果你的环境比较老,可以先手动清理:for h in logging.root.handlers[:]: logging.root.removeHandler(h)。
还有一个容易忽略的小点:StreamHandler 默认输出到 stderr,我上面显式指定了 sys.stdout。脚本里把日志打到 stdout 有个好处,就是可以把正常日志和错误输出分流,在 shell 里用 2>/dev/null 就能把错误日志单独过滤出来,用 1>/dev/null 屏蔽普通日志只看错误。
3.2 模块化项目版:控制台与文件分开配置
到了真实的项目里,光有控制台输出远远不够。日志要有“留痕”,出了问题要能追到昨天、前天的日志。控制台和文件的需求不一样:控制台要颜色、要简洁,文件要干净、要详细、要持久化。所以我的做法是配两个 handler,各用各的 formatter。
def setup_logging(log_file="app.log", level=logging.INFO): fmt_console = "%(asctime)s %(levelname)-8s %(name)s: %(message)s" fmt_file = "%(asctime)s %(levelname)-8s %(name)s:%(lineno)d [%(process)d:%(thread)d] %(message)s" console_handler = logging.StreamHandler(sys.stdout) console_handler.setFormatter(ColoredFormatter(fmt_console)) file_handler = logging.FileHandler(log_file, encoding="utf-8") file_handler.setFormatter(logging.Formatter(fmt_file)) logging.basicConfig(level=level, handlers=[console_handler, file_handler], force=True)文件 formatter 特意加了 %(lineno)d、%(process)d、%(thread)d。遇到多进程或多线程的 bug,没有这些字段基本上没法定位。控制台就不需要这么啰嗦,因为人在终端前看,可以按需切换详细级别。
为什么文件日志不带颜色?因为颜色转义序列是终端专用协议,写进纯文本文件里只会变成 \033[31m 这种乱码。如果哪天想用 grep、awk 处理日志,这些转义序列会严重影响结果。所以统一原则:控制台上颜色,文件里干净文本。
3.3 打印启动横幅,让日志一开门就醒目
日志不止是排错工具,也可以是程序的门面。每次服务启动或者脚本开始执行,打出一个醒目的横幅,既方便确认版本,也给日志加了一层“仪式感”。我见过不少项目这么干,效果确实好。
def print_banner(logger, app_name, version): logger.info("=" * 60) logger.info(" %s v%s", app_name, version) logger.info(" 启动时间: %s", time.strftime("%Y-%m-%d %H:%M:%S")) logger.info(" Python: %s", sys.version.split()[0]) logger.info(" 平台: %s", sys.platform) logger.info("=" * 60)可能有人会问,这不就是多打了几行 INFO 吗?有什么值得说的。关键在“约定”。统一了启动横幅格式之后,任何人打开日志第一屏就能确认:这确实是我们要找的那个进程、版本对不对、什么时候启动的。多服务部署的时候尤其实用,否则打开日志看到满屏 INFO,还得想半天这是哪个服务的。
横幅用 logger 而不是 print,是为了让这段输出也走统一格式,带时间带级别,写入文件时能完整保留。如果你希望横幅更抢眼,可以临时用 CRITICAL 级别打一个边框,但那会污染监控告警,不建议在生产环境这么干。
4. 第三方库日志整合与输出细节调优
4.1 管理 requests、urllib3 等第三方库的日志噪音
Python 生态里很多库都基于 logging 输出日志,最典型的是 urllib3,包括 requests 底层就是用它。默认情况下 urllib3 的日志级别是 WARNING,也就是只有警告和错误级别才会打印。但某些场景下,你想看到 HTTP 请求的 URL、状态码、耗时,就可以把它调成 INFO。
logging.getLogger("urllib3").setLevel(logging.INFO) logging.getLogger("urllib3.connectionpool").setLevel(logging.INFO)反过来,有些库非常啰嗦,DEBUG 级别下可能每个响应体都会打一遍。项目里接的第三方 SDK 多了以后,日志会被刷得完全没法看。处理办法就是给这些库单独设置一个更高的级别,甚至直接禁止它们向 root logger 冒泡。
noisy = logging.getLogger("some_sdk") noisy.setLevel(logging.CRITICAL) noisy.propagate = False这里 propagate = False 是个关键。子 logger 默认会把 record 向上传递给祖辈 logger,最后流到 root 的 handler 那里。如果你只setLevel 不关 propagate,第三方库的日志还是能通过 root 的 handler 打到你的终端上。只有把 propagate 关掉,才算真正“物理静音”。
4.2 文件日志的编码与轮转
文件日志最常见的坑之一就是中文乱码。Python 的 FileHandler 默认编码是系统区域设置,Windows 下很容易是 gbk 或者 cp936,到时候日志里的中文全变成问号。所以创建 FileHandler 时务必写上 encoding="utf-8"。
另一个坑是日志文件越滚越大,磁盘被撑爆。日志文件动辄几个 GB,打开都费劲,排查问题更是灾难。解决办法是用 RotatingFileHandler 或者 TimedRotatingFileHandler。
from logging.handlers import RotatingFileHandler file_handler = RotatingFileHandler( "app.log", maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8", )maxBytes=10485760 表示单个日志文件超过 10MB 就轮转,backupCount=5 表示保留最近 5 个轮转文件。也就是说磁盘上最多出现 app.log、app.log.1、app.log.2……总共大约 60MB 的日志。这种“总量可控、文件可追溯”的状态,对长期运行的服务非常省心。
4.3 异常堆栈的格式化与保留
日志最核心的价值之一就是记录异常堆栈。logger.exception(msg) 等价于 logger.error(msg, exc_info=True),它在输出错误消息的同时把当前异常堆栈完整打出来。没有堆栈的错误日志是半个残废,只能告诉你“出错了”,却没法告诉你在哪出错的。
有一种情况很多人忽略:在 try/except 里手动组装异常信息时,只记录了错误消息,没记录堆栈。比如:
try: result = api_call() except Exception as e: logger.error(f"调用失败: {e}")这样写,等真出了问题时,日志里只有一句话“调用失败: 超时”。到底在哪一行超时、调用链是什么样的,一概不知。正确做法是:
import traceback try: result = api_call() except Exception: logger.error("调用失败:\n%s", traceback.format_exc())traceback.format_exc() 会把当前线程的异常堆栈格式化成字符串,这样日志里可以完整保留堆栈。还有一种场景是想把堆栈单独存到一个追溯文件,方便后续分析,也可以用它配合 FileHandler 实现。
在异步或者多线程环境里,最好在 except 块里第一时间把 exc_info 捕住,否则经过几层封装后,原始堆栈可能就丢了。我自己写装饰器做统一异常捕获时,都会在装饰器最外层记录 exc_info=True,保证任何异常都能留下完整的现场。
5. 常见问题与坑:我从实际项目里踩过的
5.1 Windows 终端颜色失效
在同一套 ColoredFormatter 代码跑在 Linux 终端上完全没有问题,换到 Windows 的 cmd 或者 PowerShell 里,颜色序列直接原样打印出来,变成一堆 \033[32m 之类的乱码。原因很简单:老版本 Windows 控制台默认不支持 ANSI 转义序列。
解决办法是启动时对 Windows 做一次适配,手动开启终端虚拟终端处理能力:
import os import sys if os.name == "nt": import ctypes kernel32 = ctypes.windll.kernel32 handle = kernel32.GetStdHandle(-11) # STD_OUTPUT_HANDLE mode = ctypes.c_uint32() kernel32.GetConsoleMode(handle, ctypes.byref(mode)) kernel32.SetConsoleMode(handle, mode.value | 0x0004) # ENABLE_VIRTUAL_TERMINAL_PROCESSING这段代码在脚本开头执行一次,之后 ANSI 转义序列就能在 Windows 10 及以上的终端里正常显示了。如果你不想搞这么复杂,也可以检测环境变量,比如 NO_COLOR(约定俗成的禁用颜色标志)、CI(持续集成环境中一般不要颜色),不满足条件就直接用无色 formatter。这属于细节习惯,但生产环境里处理好了会让同事省心很多。
5.2 Handler 重复输出
一个非常经典的坑:代码里多次调用 logging.basicConfig 或者手动 addHandler,日志突然打了两遍甚至三遍。原因就是 logger 对象是全局单例,root logger 上可能已经挂了好几个 handler,你又加了一个,重复打印就出现了。
排查方法很简单,打印 logging.getLogger().handlers,看看 root logger 上挂了多少 handler。处理方式前面提过,最省事的是 basicConfig(force=True),它会清掉之前的 handlers。如果你用的是自定义 logger 而不是 root,可以手动 removeHandler:
logger = logging.getLogger(__name__) for handler in logger.handlers[:]: logger.removeHandler(handler)还有一个相关问题是子 logger 传播导致父 logger 再打一遍。默认情况下子 logger 会把日志冒泡到 root,而 root 的 handler 会再次处理。项目里很多人习惯 getLogger(name),但忘了配 root,结果发现日志不输出,就是因为子 logger 自己的 handler 没配,而上层的 logger 级别又太高。这类问题的排查思路是:先看 logger.getEffectiveLevel(),再看 logger.handlers 和 logger.propagate,把这三个状态弄明白,重复打印和“不打印”的问题基本能一次定位。
5.3 别在日志里用 f-string 强拼接
我知道 f-string 写起来舒服,但日志场景里一定要用 %s 惰性格式化。比如:
logger.debug("用户信息: %s", expensive_json_serialize(user))当日志级别高于 DEBUG 时,这行代码根本不执行 expensive_json_serialize,完全没有性能损耗。但如果写成:
logger.debug(f"用户信息: {expensive_json_serialize(user)}")即使日志级别是 INFO,f-string 也会先执行序列化,再判断要不要输出。假设这个方法一次要几十毫秒,日志量大时服务性能直接被打爆。这条不是玄学,是真实发生过的高并发事故。
有些项目为了提高日志可读性,会在参数特别多时故意用 f-string,我能理解,但建议用一个中间变量保存计算结果,再传给 logger,至少要保证不会在没人看日志的情况下白算一遍。
5.4 日志级别失效?先查 root 的 handler
设置了 logger.setLevel(logging.DEBUG),却发现 DEBUG 日志还是不出来,这是新手最爱踩的坑。原因其实不复杂:logger.setLevel 只是给这个 logger 设了门槛,但日志最终要经过 handler,如果 handler 自己的 level 比 logger 级别还高,照样被过滤掉。
比如你给 root logger 的 handler 设了 level=logging.INFO,然后某个模块里 logger.setLevel(logging.DEBUG),结果 DEBUG 日志永远看不到。正确做法是同时检查 logger 和 handler 两个 level,谁的限制高,日志就得达到谁的级别。用 basicConfig(level=...) 时,它会同时设置 root logger 和默认 handler 的级别,所以平时感觉不到这个问题。一旦自定义 handler,就容易踩。
5.5 多线程日志的顺序与线程安全
logging 模块本身是线程安全的,标准库在 Handler.emit 上做了锁保护,所以多个线程同时写日志不会串行输出或互相覆盖。但有一个现象需要注意:多线程下日志的“时间序”和“代码序”不一定完全一致。因为不同线程竞争同一把锁,先 getLogger 的不一定先拿到锁输出。如果你需要在日志里严格还原操作顺序,就得自己在线程内部记录一个序号,或者把关键操作收敛到单线程执行。大多数项目其实不用做到这一步,但要心里有数,别看到日志顺序乱了就以为程序逻辑错了。
还有一点是多进程场景。每个进程有自己独立的 logging 配置,如果多个进程同时写同一个文件,用普通 FileHandler 偶尔会出现日志交错甚至内容覆盖。标准做法是使用 QueueHandler + QueueListener,或者干脆让每个进程写各自的文件。涉及分布式日志收集的话,再往 HTTP/远端服务上对接也不迟。
6. 额外心得:日志这块还能怎么玩
6.1 给日志加个“请求 ID”上下文
服务端排查问题,最头疼的场景是日志混杂了多个请求。用户报了一个 bug,你根本分不清日志里的哪几行是他那次请求产生的。这就要给每条日志附加一个请求 ID。最简单的办法是借助 logging 的 filter 或 LoggerAdapter 给 record 注入额外字段。
class ContextFilter(logging.Filter): def filter(self, record): record.request_id = getattr(threading.local(), "request_id", "-") return True然后把 filter 挂到 handler 上,格式化串里加一个 %(request_id)s。每次请求进来时,在入口处给 thread local 设置一个 UUID。这样所有日志都会自动带上当前请求的 ID,排查的时候 grep 一下就能还原一条完整的请求链路。
这个方案的好处是不需要引入任何第三方库,纯粹的 threading.local 和 logging filter 就能搞定。更复杂的情况可以用 contextvars 配合异步框架,原理是一样的。
6.2 把整套配置封装成一个 setup_logging
如果你在多个项目里反复做同样的日志配置,不如把它封装成一个函数,放到公共工具包里。每次新项目直接调用,省得重新踩一遍上面说的那些坑。
def setup_logging( console_level=logging.INFO, file_level=logging.DEBUG, log_file="app.log", max_bytes=10 * 1024 * 1024, backup_count=5, ): if os.name == "nt": try: import ctypes kernel32 = ctypes.windll.kernel32 handle = kernel32.GetStdHandle(-11) mode = ctypes.c_uint32() kernel32.GetConsoleMode(handle, ctypes.byref(mode)) kernel32.SetConsoleMode(handle, mode.value | 0x0004) except Exception: pass fmt_console = "%(asctime)s %(levelname)-8s %(name)s: %(message)s" fmt_file = "%(asctime)s %(levelname)-8s %(name)s:%(lineno)d [%(process)d:%(thread)d] %(message)s" console = logging.StreamHandler(sys.stdout) console.setLevel(console_level) console.setFormatter(ColoredFormatter(fmt_console)) file_handler = RotatingFileHandler( log_file, maxBytes=max_bytes, backupCount=backup_count, encoding="utf-8" ) file_handler.setLevel(file_level) file_handler.setFormatter(logging.Formatter(fmt_file)) logging.basicConfig(level=min(console_level, file_level), handlers=[console, file_handler], force=True)这里有个细节:root logger 的 level 要取 console_level 和 file_level 的较低值,否则文件里想记录 DEBUG,但控制台只显示 INFO,root 的级别却是 INFO,那 DEBUG 日志连 handler 都到不了。这个坑我踩过一次,花了一个多小时才反应过来,写在这里给后来人排雷。
6.3 关于日志颜色与可读性的个人偏好
最后说一点纯粹个人的经验。颜色不是越花哨越好,五颜六色的日志看久了眼睛容易累,而且会削弱真正关键信息的辨识度。我一般只在 DEBUG、INFO、WARNING、ERROR、CRITICAL 五个级别上各用一个颜色,INFO 用绿色但不要太鲜艳,WARNING 用黄色,ERROR 用红色,CRITICAL 加粗再加背景色。日常开发环境里日志级别开 DEBUG,能看到很多细节;生产环境只开到 INFO 甚至 WARNING,减少噪音。
另一个建议是统一项目里所有模块的 logger 命名。强烈推荐每个模块开头写 logger = logging.getLogger(name),这样日志里天然带模块路径,不用自己手动拼接模块名。配合行号字段 %(lineno)d,定位代码位置基本是秒级。养成这个习惯之后,你会发现自己排查 bug 的速度会上一个台阶。
日志这件事,做的时候花不了多少时间,但回报在深夜排查线上问题时体现得淋漓尽致。下次写项目,别再用 print 凑合了,花十分钟把 logging 配好,值得的。