4. 日志格式化:Formatter详解、自定义日志格式、添加时间/进程/线程信息

日志记录这件事,说白了就是把程序运行时发生了什么给记下来。但记什么、怎么记,这里面的门道可不少。我见过不少新手,日志打了一堆,真出问题的时候翻半天找不到关键信息——这就是格式化没做好。

今天咱们就聊聊 Formatter,这个 Python logging 模块里负责「排版」的家伙。

4.1 Formatter 是什么?

Formatter 的作用很简单:把日志记录(LogRecord)转换成字符串。你可以把它理解成一个模板引擎,它定义了每条日志最终长什么样。

默认情况下,Python 的日志格式是这样的:

WARNING:root:磁盘空间不足

嗯,说实话,这格式在生产环境基本不够用。谁打的?什么时候打的?哪个进程?全都没有。

所以我们需要自定义。

4.2 基本用法

先看一个最简单的例子:

import logging

formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')

handler = logging.StreamHandler()
handler.setFormatter(formatter)

logger = logging.getLogger('my_app')
logger.addHandler(handler)
logger.setLevel(logging.DEBUG)

logger.info('服务启动成功')

输出:

2025-01-15 10:30:45,123 - my_app - INFO - 服务启动成功

你看,加上时间戳和日志器名称后,信息一下子就清晰了。

小提示: %(asctime)s 默认格式是 "YYYY-MM-DD HH:MM:SS,mmm"。如果你想要更精确的时间,可以配合 datefmt 参数调整。

4.3 常用格式化字段

Python 提供了很多内置字段,我挑几个最常用的列出来:

字段 含义 示例
%(asctime)s 时间戳 2025-01-15 10:30:45,123
%(name)s 日志器名称 my_app
%(levelname)s 日志级别 INFO
%(message)s 日志消息 服务启动成功
%(filename)s 文件名 server.py
%(lineno)d 行号 42
%(funcName)s 函数名 start_server
%(process)d 进程ID 12345
%(thread)d 线程ID 140735202312448
%(threadName)s 线程名 MainThread
重点: 生产环境中,我强烈建议至少包含 asctimelevelnamenamemessage 这四个字段。如果涉及多进程/多线程,processthreadName 也必不可少。

4.4 自定义时间格式

我个人习惯用 ISO 8601 格式,不带毫秒:

formatter = logging.Formatter(
    '%(asctime)s [%(levelname)s] %(name)s: %(message)s',
    datefmt='%Y-%m-%dT%H:%M:%S'
)

输出:

2025-01-15T10:30:45 [INFO] my_app: 服务启动成功

为什么用 ISO 格式?因为很多日志分析工具(比如 ELK、Splunk)默认就能解析它,省去你额外配置的麻烦。

4.5 添加进程和线程信息

我在项目中遇到过一个问题:一个 Web 服务同时处理几百个请求,日志里全是 "用户登录成功",根本分不清是哪个请求。后来加上进程和线程信息,问题迎刃而解。

formatter = logging.Formatter(
    '%(asctime)s [%(levelname)s] [PID:%(process)d] [TID:%(thread)d] %(name)s: %(message)s'
)

输出:

2025-01-15T10:30:45 [INFO] [PID:12345] [TID:140735202312448] my_app: 用户登录成功

你想想看,如果用的是多进程架构(比如 Gunicorn 开多个 worker),%(process)d 能帮你快速定位是哪个 worker 出了问题。而 %(thread)d 在排查并发问题时更是利器。

注意: 线程ID是一个长整型数字,可读性一般。如果你更关心线程名称,可以用 %(threadName)s。不过线程名可能重复(比如多个线程池都用 "Thread-1"),所以建议两者结合使用。

4.6 自定义格式化器

有时候内置字段不够用,比如我想在日志里加上主机名。这时候可以继承 logging.Formatter

import logging
import socket

class CustomFormatter(logging.Formatter):
    def __init__(self, fmt=None, datefmt=None):
        super().__init__(fmt, datefmt)
        self.hostname = socket.gethostname()

    def format(self, record):
        record.hostname = self.hostname
        return super().format(record)

fmt = '%(asctime)s [%(hostname)s] [%(levelname)s] %(message)s'
formatter = CustomFormatter(fmt)

输出:

2025-01-15T10:30:45 [web-server-01] [INFO] 服务启动成功

嗯,这里要注意:自定义字段必须通过 record.xxx = xxx 的方式注入,否则格式化时会报错。

4.7 实战:生产环境日志格式

最后分享一个我目前在用的生产环境格式,供你参考:

formatter = logging.Formatter(
    '%(asctime)s | %(levelname)-8s | %(name)s:%(funcName)s:%(lineno)d | '
    '[PID:%(process)d] [TID:%(thread)d] | %(message)s',
    datefmt='%Y-%m-%d %H:%M:%S'
)

输出:

2025-01-15 10:30:45 | INFO     | my_app:start_server:42 | [PID:12345] [TID:140735202312448] | 服务启动成功

这个格式有几个好处:

  • 用竖线分隔字段,方便用 awkcut 做命令行分析
  • %(levelname)-8s 让级别左对齐,日志看起来整齐
  • 包含文件名、函数名、行号,出问题能直接定位代码位置
避坑指南: 我曾经在生产环境遇到过一个问题——日志格式里加了太多字段,导致单条日志超过 10KB,结果日志文件一天涨了 20GB。所以字段不是越多越好,够用就行。

好了,关于 Formatter 的内容就这些。记住一句话:日志格式的设计,决定了你排查问题的效率。花 10 分钟把格式配好,能省下未来 10 小时的排查时间。