Python自带的logging模块功能其实非常强大,之所以让人觉得复杂,主要是因为它的配置看起来有点繁琐。只要理清几个关键概念,再配合一个小封装,你就能彻底告别满屏的print,让调试和问题排查的效率大幅提升。
就像我们之前探讨技术选型一样,让我们从一个真实的开发场景开始。
先别急上手,问一句:你现在还在到处用 print 吗?
我曾经帮一位同事排查一个线上问题,他的代码是典型的“print调试法”,到处充斥着这样的语句:
print("enter handle_request", request_id)
print("user:", user_id)
print("result:", result)
在本地单进程运行时,这确实能立刻看到输出,非常直观。然而,一旦服务部署到 Docker 容器,并且通过 gunicorn 以多进程模式运行,日志输出就会变成混乱的“弹幕”。你根本无法区分哪一行日志属于哪一个具体的用户请求。
更麻烦的是,当问题出现在线上环境时,你会发现:
- 本地调试与线上运行逻辑脱节:本地用的是
print,线上则需要查看日志文件,两套不同的输出方式。
- 缺乏请求关联性:想为整个请求链路添加一个唯一的
trace_id,需要手动在代码各处传递和打印。
- 日志管理缺失:想要实现日志按天切割、限制单个文件大小等功能,还得自己编写额外的日志轮转脚本。
这时,你再回头去看logging模块那厚厚的文档,面对Handler、Formatter、Logger等一堆名词,很可能就打退堂鼓了:“算了,还是先用print顶着吧。”
logging 真正让人头疼的,其实就这三件事
别被它众多的类名吓到。大多数开发者被劝退,其实主要卡在三个地方:
- 配置繁琐:每次都需要写一大段配置代码,经常需要复制粘贴。
- 多输出目标配置复杂:想同时将日志输出到控制台和文件,需要分别设置
handler、formatter和setLevel。
- 多模块日志管理混乱:不同模块希望拥有独立的
logger,但又担心配置会相互干扰或重复。
只要理清这三个核心痛点,logging的使用就会变得简单很多。它的核心模型其实只有两句话:
- Logger:决定“记录什么”,即定义日志级别(
debug/info/warning/error)。
- Handler + Formatter:决定“记录到哪里”以及“记录成什么样子”,例如输出到控制台、文件、或进行轮转,以及日志的格式(如 JSON)。
我们的目标,就是为这套强大的机制包裹一层简便的外衣,让其用起来像print一样简单直观。关于Python的高级用法和最佳实践,你可以在云栈社区的Python板块找到更多深入的讨论和资源。
先写一个最小可用版:10 行代码搞定基础配置
首先,来看一个你今晚就能投入使用的精简版本,不包含任何花哨的功能:
# logging_setup.py
import logging
from logging.handlers import RotatingFileHandler
from pathlib import Path
def setup_logger(
name: str = "app",
level: int = logging.INFO,
log_file: str = "app.log",
max_bytes: int = 10 * 1024 * 1024, # 10MB
backup_count: int = 5,
) -> logging.Logger:
logger = logging.getLogger(name)
logger.setLevel(level)
logger.propagate = False # 不往 root 传递,避免重复输出
if logger.handlers:
# 已经配置过了,直接返回
return logger
fmt = logging.Formatter(
fmt="%(asctime)s [%(levelname)s] [%(name)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
# 控制台输出
ch = logging.StreamHandler()
ch.setFormatter(fmt)
ch.setLevel(level)
logger.addHandler(ch)
# 文件输出 + 按大小滚动
Path(log_file).parent.mkdir(parents=True, exist_ok=True)
fh = RotatingFileHandler(log_file, maxBytes=max_bytes, backupCount=backup_count, encoding="utf-8")
fh.setFormatter(fmt)
fh.setLevel(level)
logger.addHandler(fh)
return logger
在业务代码中,你可以这样使用它:
# main.py
from logging_setup import setup_logger
logger = setup_logger("order-service", level=10) # 10 = logging.DEBUG
def create_order(user_id: int, amount: float):
logger.info("start create order user_id=%s amount=%.2f", user_id, amount)
try:
# 这里是你的业务逻辑
1 / 0
except Exception:
logger.exception("create order failed user_id=%s", user_id)
if __name__ == "__main__":
create_order(123, 99.9)
这个简单的版本已经比满屏的print要强大得多:
- 本地开发:可以在控制台实时看到结构化的日志。
- 线上环境:日志会同时写入文件,并自动按设定的文件大小进行轮转切割。
- 异常追踪:使用
logger.exception可以自动记录完整的异常堆栈信息。
再进阶一点:为每一条日志自动添加“请求 ID”
在实际项目中,最令人头疼的是那些链路冗长的 Bug。一个 HTTP 请求可能经过多个服务、消息队列和异步任务,查看日志就像在玩拼图游戏。
此时,你最需要的是一个能将所有相关日志串联起来的关联标识。
下面介绍一个非常实用的技巧:为logging添加一个“上下文过滤器”,自动为同一次请求的所有日志打上trace_id,无需手动传递和打印。
# context_logger.py
import logging
import contextvars
from logging.handlers import RotatingFileHandler
_current_trace_id = contextvars.ContextVar("trace_id", default="-")
class TraceIdFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
record.trace_id = _current_trace_id.get()
return True
def set_trace_id(trace_id: str):
_current_trace_id.set(trace_id)
def get_logger(name: str = "app") -> logging.Logger:
logger = logging.getLogger(name)
if logger.handlers:
return logger
logger.setLevel(logging.INFO)
logger.propagate = False
fmt = logging.Formatter(
fmt="%(asctime)s [%(levelname)s] [trace_id=%(trace_id)s] [%(name)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
fh = RotatingFileHandler("logs/app.log", maxBytes=5 * 1024 * 1024, backupCount=3, encoding="utf-8")
fh.setFormatter(fmt)
fh.addFilter(TraceIdFilter())
logger.addHandler(fh)
ch = logging.StreamHandler()
ch.setFormatter(fmt)
ch.addFilter(TraceIdFilter())
logger.addHandler(ch)
return logger
结合 FastAPI 或 Flask 这样的 Web 框架,你可以在请求开始时轻松设置trace_id:
# web_example.py
import uuid
from fastapi import FastAPI, Request
from context_logger import set_trace_id, get_logger
app = FastAPI()
logger = get_logger("web")
@app.middleware("http")
async def add_trace_id(request: Request, call_next):
trace_id = request.headers.get("X-Trace-Id") or uuid.uuid4().hex[:16]
set_trace_id(trace_id)
logger.info("incoming request %s %s", request.method, request.url.path)
try:
response = await call_next(request)
logger.info("request done status=%s", response.status_code)
return response
finally:
# 请求结束,可以清理 trace_id(根据习惯选择是否清理)
set_trace_id("-")
@app.get("/hello")
async def hello():
logger.debug("handling /hello logic")
return {"msg": "ok"}
现在,当你在日志系统中搜索某个特定的trace_id时,与该请求相关的所有日志都会被筛选出来,整个请求链路一目了然,排查问题的体验直接提升数个等级。
调试时,究竟应该记录哪些日志?
许多人对日志记录有一个误解:只要将日志级别设为DEBUG,然后把所有信息都打出来就行了。结果往往是日志文件体积暴增,真正有价值的信息反而被海量冗余数据所淹没。
我个人的使用习惯大致如下(这里说的是习惯而非“规范”,更灵活一些):
logger.debug:仅用于开发或排查阶段需要查看的细节,例如函数内部参数、中间变量值等。
logger.info:记录关键的业务节点,例如“订单创建成功”、“开始同步用户数据”。
logger.warning:记录非预期但仍可继续运行的情况,例如“第三方接口调用超时,已启用备用缓存数据”。
logger.error:记录真正的错误,但系统整体仍能响应。
logger.exception:在except代码块中使用,等价于logger.error并自动附加异常堆栈信息。
来看一个具体的例子,感受会更直观:
import time
from context_logger import get_logger
logger = get_logger("payment")
def pay(user_id: int, amount: float):
start = time.time()
logger.info("pay start user_id=%s amount=%.2f", user_id, amount)
try:
if amount <= 0:
logger.warning("invalid amount user_id=%s amount=%.2f", user_id, amount)
return False
# 调用第三方支付网关
logger.debug("calling payment gateway...")
# 模拟一个HTTP调用
raise TimeoutError("gateway timeout")
except TimeoutError:
logger.exception("pay timeout user_id=%s amount=%.2f", user_id, amount)
return False
finally:
cost = (time.time() - start) * 1000
logger.info("pay end user_id=%s cost=%.1fms", user_id, cost)
当某天你需要排查“为什么支付流程变慢了”时,只需按name=payment进行过滤,并查看cost字段,问题很可能就清晰了。这类性能监控和日志分析是保障系统稳定性的重要环节,更多相关经验可以在运维 & 测试板块交流学习。
为日志添加一点提升“可读性”的调味剂
这部分属于体验优化,没有它也能正常工作,但有了它会让你的日常工作更舒适。
例如,在命令行查看日志时,很多人喜欢有颜色的输出,这样WARNING和ERROR可以一眼扫到。你可以使用colorlog这样的第三方库,也可以自己实现一个轻量级的包装:
# color_formatter.py
import logging
import sys
RESET = "\033[0m"
LEVEL_COLORS = {
logging.DEBUG: "\033[37m", # 灰色
logging.INFO: "\033[36m", # 青色
logging.WARNING: "\033[33m", # 黄色
logging.ERROR: "\033[31m", # 红色
logging.CRITICAL: "\033[41m" # 红底白字
}
class ColorFormatter(logging.Formatter):
def format(self, record):
color = LEVEL_COLORS.get(record.levelno, "")
message = super().format(record)
if not sys.stderr.isatty() and not sys.stdout.isatty():
# 非终端环境(例如重定向到文件)不加颜色
return message
return f"{color}{message}{RESET}"
然后,在前面的get_logger函数中,将控制台handler的formatter替换成这个彩色版本即可:
from color_formatter import ColorFormatter
# ...
ch = logging.StreamHandler()
ch.setFormatter(ColorFormatter(fmt._fmt, fmt.datefmt))
ch.addFilter(TraceIdFilter())
logger.addHandler(ch)
这样,当你在本地或测试环境使用tail -f命令跟踪日志时,错误信息会以醒目的红色标识出来,大大提升了视觉辨识度。
何时该将日志配置升级为“生产环境”版本?
有些同学会问:开发环境和生产环境是否需要两套不同的日志配置?
一个简单且有效的方法是,使用环境变量来控制日志级别和输出目标。例如:
# config_aware_logger.py
import logging
import os
from logging.handlers import RotatingFileHandler
def get_logger(name: str = "app") -> logging.Logger:
logger = logging.getLogger(name)
if logger.handlers:
return logger
level_name = os.getenv("APP_LOG_LEVEL", "INFO")
level = getattr(logging, level_name.upper(), logging.INFO)
log_to_file = os.getenv("APP_LOG_TO_FILE", "true").lower() == "true"
log_file = os.getenv("APP_LOG_FILE", "logs/app.log")
logger.setLevel(level)
logger.propagate = False
fmt = logging.Formatter(
fmt="%(asctime)s [%(levelname)s] [%(name)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
ch = logging.StreamHandler()
ch.setFormatter(fmt)
ch.setLevel(level)
logger.addHandler(ch)
if log_to_file:
fh = RotatingFileHandler(log_file, maxBytes=20 * 1024 * 1024, backupCount=10, encoding="utf-8")
fh.setFormatter(fmt)
fh.setLevel(level)
logger.addHandler(fh)
return logger
在开发时,你可以这样设置环境变量:
export APP_LOG_LEVEL=DEBUG
export APP_LOG_TO_FILE=false
而在生产环境部署时,则使用:
APP_LOG_LEVEL=INFO
APP_LOG_TO_FILE=true
APP_LOG_FILE=/var/log/myapp/app.log
代码本身完全无需改动,日志行为就已经根据环境自动切换到了最适合的模式。
实际上,Python的日志模块并不“玄学”,其大部分复杂性都集中在初始配置的几十行代码里。只要你静下心来,为自己的项目创建一个统一的logger工具模块,并约定所有代码都从这个模块获取logger实例,坚持使用一两周,你就再也回不去那个到处是print的“原始时代”了。
一旦你习惯了将关键节点和信息记录到日志中,许多以往需要反复打断点调试的场景,现在只需翻看日志就能理清整个事件脉络。这种感觉,确实会让人上瘾。