系列导读:本系列面向零基础学习者,从变量、循环一路讲到 Web 开发、测试、部署。本篇是第三十三篇,带你用 Python 标准库
logging搭建专业日志系统,让程序运行有迹可循、线上问题有据可查。
一、为什么不用 print
很多初学者调试代码就一个绝招:print()。但 print 在生产环境有几个致命痛点:
| 痛点 | 说明 |
|---|---|
| 无级别 | "正常信息"和"出错了"混在一起,没法区分 |
| 无文件 | 只输出到控制台,程序一关、终端一清就没了 |
| 无法关闭 | 上线后想关掉调试信息?只能一个个删 print |
| 无格式 | 没有时间、没文件名、没行号,出问题不知道哪来的 |
| 生产混乱 | 多线程/多进程同时 print,输出交错乱成一团 |
而 logging 模块解决了以上所有问题:
- 有级别:DEBUG / INFO / WARNING / ERROR / CRITICAL
- 可输出到文件:FileHandler
- 可随时开关:设置 level 即可
- 自带格式:时间、模块、行号自动加
- 线程安全:多线程并发写不会乱
一句话:print 是调试用的,logging 是工程用的。
1.1 一个真实场景对比
想象你写了一个电商下单接口,上线后偶尔有用户反馈"下单失败"。如果用 print:
def create_order(user_id, item_id):
print("开始下单")
user = get_user(user_id)
print("查到用户", user)
if not user:
print("用户不存在")
return None
stock = get_stock(item_id)
print("库存", stock)
if stock <= 0:
print("库存不足")
return None
order = save_order(user, item_id)
print("订单创建", order)
return order
线上出了问题,你去服务器看 print 输出------发现终端早就刷过去了,啥也找不到。而且 print 没有时间戳,分不清哪条是哪次请求的,多用户并发时全混在一起。
换成 logging 就不一样了:
import logging
logger = logging.getLogger(__name__)
def create_order(user_id, item_id):
logger.info("开始下单 user=%s item=%s", user_id, item_id)
user = get_user(user_id)
if not user:
logger.warning("用户不存在 user=%s", user_id)
return None
stock = get_stock(item_id)
if stock <= 0:
logger.warning("库存不足 item=%s stock=%s", item_id, stock)
return None
order = save_order(user, item_id)
logger.info("订单创建成功 order_id=%s", order.id)
return order
日志自动带时间、模块名、级别,写到文件里随时可查,还能按级别过滤。出问题时 grep "订单创建" 就能定位,这就是工程化日志的价值。
二、日志的五个级别
logging 定义了五个级别,从低到高:
| 级别 | 数值 | 何时使用 | 示例场景 |
|---|---|---|---|
| DEBUG | 10 | 最详细的信息,调试时才开 | 打印变量值、执行路径 |
| INFO | 20 | 确认一切按预期运行 | "用户X登录成功" |
| WARNING | 30 | 有潜在问题,但程序仍能运行 | "磁盘空间不足20%" |
| ERROR | 40 | 出错了,某功能无法完成 | "数据库连接失败" |
| CRITICAL | 50 | 严重错误,程序可能无法继续 | "系统磁盘满,无法写入" |
规则:设置某个级别后,低于该级别的日志不会输出。默认级别是 WARNING,所以默认只看到 WARNING 及以上。
2.1 级别选择的实战指南
很多新手不知道某条日志该用哪个级别,这里给一个判断口诀:
- DEBUG:开发时才看的信息,比如"进入函数 X,参数是 Y"。上线后关掉,排查问题时临时打开。
- INFO:证明程序在正常运转的关键节点,比如"服务启动""用户登录""订单完成"。生产环境默认开到这一级。
- WARNING:有点不对劲但还能跑,比如"配置项缺失,用了默认值""重试第2次""磁盘剩10%"。要关注但不报警。
- ERROR:某个操作失败了,但程序整体还活着,比如"发邮件失败""某个接口超时但已降级"。通常要配监控告警。
- CRITICAL:整个程序或核心功能挂了,比如"数据库连不上""磁盘满了无法写"。半夜也得爬起来处理的那种。
一个常见错误:把所有日志都写成 logger.error(),导致真正的错误被淹没。请严格按语义选级别,这直接影响运维能不能快速判断严重程度。
2.2 动态调整级别
线上排查问题时,不想重启服务又想看 DEBUG 日志,可以运行时改级别:
logging.getLogger("myapp").setLevel(logging.DEBUG)
很多框架(如 FastAPI)可以暴露一个管理接口,调用它就能动态调级别,不用重新发版,非常实用。
import logging
logging.debug("调试信息") # 不显示(默认级别 WARNING)
logging.info("普通信息") # 不显示
logging.warning("警告信息") # 显示
logging.error("错误信息") # 显示
logging.critical("严重错误") # 显示
运行结果:
WARNING:root:警告信息
ERROR:root:错误信息
CRITICAL:root:严重错误
三、logging 基本配置
3.1 basicConfig
最简单的配置方式:
import logging
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s - %(levelname)s - %(message)s",
filename="app.log",
encoding="utf-8",
)
logging.debug("程序启动")
logging.info("加载配置完成")
logging.warning("内存使用率70%")
logging.error("接口超时")
运行后 app.log 文件内容:
2026-10-28 10:00:01,234 - DEBUG - 程序启动
2026-10-28 10:00:01,235 - INFO - 加载配置完成
2026-10-28 10:00:01,235 - WARNING - 内存使用率70%
2026-10-28 10:00:01,236 - ERROR - 接口超时
3.2 格式化字段详解
format 里能用的字段非常多:
| 字段 | 含义 | 示例 |
|---|---|---|
%(asctime)s |
时间 | 2026-10-28 10:00:01,234 |
%(levelname)s |
级别名 | INFO |
%(levelno)s |
级别数值 | 20 |
%(name)s |
logger 名字 | myapp.user |
%(message)s |
日志消息 | 用户登录 |
%(module)s |
模块名 | user_service |
%(filename)s |
文件名 | user_service.py |
%(lineno)d |
行号 | 42 |
%(funcName)s |
函数名 | login |
%(process)d |
进程 ID | 12345 |
%(thread)d |
线程 ID | 140735 |
%(pathname)s |
完整路径 | /app/src/user.py |
一个生产级格式示例:
format="%(asctime)s | %(process)d:%(thread)d | %(levelname)-8s | %(name)s | %(filename)s:%(lineno)d | %(message)s"
输出:
2026-10-28 10:00:01 | 12345:140735 | INFO | myapp | user_service.py:42 | 用户登录成功
四、getLogger 模块化日志
basicConfig 是全局的,适合小脚本。真实项目里每个模块应该有自己的 logger:
# user_service.py
import logging
logger = logging.getLogger(__name__) # __name__ 是模块名 "user_service"
def login(username):
logger.info("用户 %s 尝试登录", username)
if username == "admin":
logger.warning("管理员登录,请注意安全")
return True
logger.error("用户 %s 不存在", username)
return False
用 __name__ 命名的好处:日志里能直接看出是哪个模块输出的,而且可以通过模块名精确控制各模块的日志级别:
logging.getLogger("user_service").setLevel(logging.DEBUG)
logging.getLogger("db").setLevel(logging.WARNING)
五、Handler 处理器
Handler 决定日志输出到哪里。一个 logger 可以挂多个 Handler,比如同时输出到控制台和文件。
5.1 StreamHandler(控制台)
import logging
logger = logging.getLogger("demo")
logger.setLevel(logging.DEBUG)
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
logger.addHandler(console_handler)
logger.info("输出到控制台")
5.2 FileHandler(文件)
file_handler = logging.FileHandler("app.log", encoding="utf-8")
file_handler.setLevel(logging.DEBUG)
logger.addHandler(file_handler)
5.3 同时输出到控制台和文件
import logging
logger = logging.getLogger("myapp")
logger.setLevel(logging.DEBUG)
fmt = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
console = logging.StreamHandler()
console.setLevel(logging.INFO)
console.setFormatter(fmt)
file_h = logging.FileHandler("app.log", encoding="utf-8")
file_h.setLevel(logging.DEBUG)
file_h.setFormatter(fmt)
logger.addHandler(console)
logger.addHandler(file_h)
logger.debug("只写文件")
logger.info("控制台和文件都有")
logger.error("两边都显示错误")
这样 DEBUG 只进文件(便于排查),INFO 以上同时进控制台(便于实时观察)。
5.3.1 Handler 级别和 Logger 级别的关系
新手常困惑:到底以 Logger 的 level 为准还是 Handler 的 level 为准?答案是两者都会过滤,取交集:
- 日志先过 Logger 的 level 关,低于 Logger level 的直接丢弃,连 Handler 都到不了。
- 通过的日志再分别到每个 Handler,每个 Handler 再用自己的 level 过滤一次。
所以可以玩这种组合:Logger 设 DEBUG(全放行),控制台 Handler 设 INFO(控制台只显示重要信息),文件 Handler 设 DEBUG(文件全记下来便于排查)。这是生产环境最常用的配置,前面 5.3 的例子就是这个思路。
5.4 NullHandler
库的开发者应该用 NullHandler,避免库的日志影响使用方:
# mylib/__init__.py
import logging
logging.getLogger(__name__).addHandler(logging.NullHandler())
六、Formatter 格式化器
Formatter 专门管"日志长什么样":
formatter = logging.Formatter(
fmt="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
handler.setFormatter(formatter)
datefmt 控制时间格式,和 time.strftime 用法一致。输出:
2026-10-28 10:00:01 [INFO] myapp: 用户登录
格式化器可以给不同 Handler 配不同格式------比如控制台用简洁格式(省得刷屏看不过来),文件用详细格式(带模块名行号便于追溯)。这种"同一份日志、不同目的地不同长相"的能力,是 print 完全做不到的。
七、日志切分
线上程序长期运行,日志文件会越来越大,需要切分。logging.handlers 提供了两个切分 Handler。
7.1 RotatingFileHandler(按大小切分)
from logging.handlers import RotatingFileHandler
handler = RotatingFileHandler(
"app.log",
maxBytes=1024 * 1024, # 单个文件最大 1MB
backupCount=5, # 最多保留 5 个备份
encoding="utf-8",
)
当 app.log 超过 1MB,会自动重命名为 app.log.1,新建 app.log 继续写。最多保留 app.log.1 ~ app.log.5,更老的自动删除。
7.2 TimedRotatingFileHandler(按时间切分)
from logging.handlers import TimedRotatingFileHandler
handler = TimedRotatingFileHandler(
"app.log",
when="midnight", # 每天午夜切
interval=1,
backupCount=30, # 保留 30 天
encoding="utf-8",
)
when 可选值:
| when | 含义 |
|---|---|
S |
秒 |
M |
分钟 |
H |
小时 |
D |
天 |
midnight |
每天凌晨 |
W0~W6 |
每周几 |
7.3 完整示例
import logging
from logging.handlers import RotatingFileHandler, TimedRotatingFileHandler
logger = logging.getLogger("myapp")
logger.setLevel(logging.DEBUG)
fmt = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
# 控制台
console = logging.StreamHandler()
console.setFormatter(fmt)
logger.addHandler(console)
# 按大小切
rotating = RotatingFileHandler("app.log", maxBytes=1_000_000, backupCount=3, encoding="utf-8")
rotating.setFormatter(fmt)
logger.addHandler(rotating)
# 按天切
daily = TimedRotatingFileHandler("daily.log", when="midnight", backupCount=30, encoding="utf-8")
daily.setFormatter(fmt)
logger.addHandler(daily)
for i in range(100):
logger.info(f"第 {i} 条日志")
八、日志层级与传播
logger 的名字用 . 分隔会形成父子关系:
parent = logging.getLogger("myapp")
child = logging.getLogger("myapp.user")
grand = logging.getLogger("myapp.user.service")
myapp.user 是 myapp 的子 logger,myapp.user.service 是 myapp.user 的子 logger。
默认情况下,子 logger 的日志会向上传播给父 logger 的 Handler。这意味着:只要在根 logger 配一个 FileHandler,所有子模块的日志都会写进去。
如果想关闭传播:
child.propagate = False
这样 child 的日志只走自己的 Handler,不再向上冒泡,避免重复输出。
九、dictConfig 配置
生产环境推荐用 dictConfig,把配置和代码分离:
import logging.config
LOGGING_CONFIG = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"standard": {
"format": "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
},
"simple": {
"format": "%(levelname)s: %(message)s"
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"level": "INFO",
"formatter": "simple",
},
"file": {
"class": "logging.handlers.RotatingFileHandler",
"level": "DEBUG",
"formatter": "standard",
"filename": "app.log",
"maxBytes": 1048576,
"backupCount": 5,
"encoding": "utf8",
},
},
"loggers": {
"myapp": {
"level": "DEBUG",
"handlers": ["console", "file"],
"propagate": False,
},
},
"root": {
"level": "WARNING",
"handlers": ["console"],
},
}
logging.config.dictConfig(LOGGING_CONFIG)
logger = logging.getLogger("myapp")
logger.info("dictConfig 配置完成")
更进一步,可以把这个 dict 写成 YAML/JSON 文件,运行时加载,改日志配置不用改代码:
import yaml, logging.config
with open("logging.yaml") as f:
cfg = yaml.safe_load(f)
logging.config.dictConfig(cfg)
十、异常日志 logger.exception
记录异常时,用 logger.exception() 会自动附带完整堆栈信息:
logger = logging.getLogger(__name__)
def divide(a, b):
try:
return a / b
except ZeroDivisionError:
logger.exception("除法失败: a=%s, b=%s", a, b)
raise
输出:
ERROR:__main__:除法失败: a=1, b=0
Traceback (most recent call last):
File "demo.py", line 5, in divide
return a / b
ZeroDivisionError: division by zero
注意:
logger.exception()只能在 except 块里调用,它等价于logger.error(..., exc_info=True)。
也可以手动传 exc_info:
try:
1 / 0
except Exception:
logger.error("出错了", exc_info=True)
十一、结构化日志
传统日志是给人看的文本,但现代运维(ELK、Grafana Loki)更喜欢结构化日志(JSON 格式),便于机器解析和检索。
11.1 手动输出 JSON
import json, logging, time
class JsonFormatter(logging.Formatter):
def format(self, record):
log = {
"time": self.formatTime(record),
"level": record.levelname,
"logger": record.name,
"msg": record.getMessage(),
"file": record.filename,
"line": record.lineno,
}
if record.exc_info:
log["traceback"] = self.formatException(record.exc_info)
return json.dumps(log, ensure_ascii=False)
handler = logging.StreamHandler()
handler.setFormatter(JsonFormatter())
logger = logging.getLogger("json")
logger.addHandler(handler)
logger.setLevel(logging.INFO)
logger.info("用户登录", )
输出:
{"time": "2026-10-28 10:00:01", "level": "INFO", "logger": "json", "msg": "用户登录", "file": "demo.py", "line": 25}
11.2 用 python-json-logger
pip install python-json-logger
from pythonjsonlogger import jsonlogger
import logging
handler = logging.StreamHandler()
handler.setFormatter(jsonlogger.JsonFormatter(
"%(asctime)s %(levelname)s %(name)s %(message)s"
))
logger = logging.getLogger("app")
logger.addHandler(handler)
logger.info("订单创建", extra={"order_id": 12345, "user": "陈"})
输出:
{"asctime": "2026-10-28 10:00:01", "levelname": "INFO", "name": "app", "message": "订单创建", "order_id": 12345, "user": "陈"}
extra 里的字段直接进 JSON,ELK 收集后可以按 order_id 精准检索。
十二、第三方库:loguru
loguru 是一个更简洁的第三方日志库,号称"Python logging made (stupidly) simple":
pip install loguru
from loguru import logger
logger.info("普通日志")
logger.warning("警告")
logger.error("错误")
# 自动带颜色、时间、文件、行号
# 2026-10-28 10:00:01.234 | INFO | __main__:<module>:3 - 普通日志
# 写文件,自动切分
logger.add("app_{time}.log", rotation="1 MB", retention="10 days", compression="zip")
# 异常堆栈
try:
1 / 0
except ZeroDivisionError:
logger.exception("出错了")
loguru 的优势:
- 一个
logger全局可用,不用getLogger - 默认就带漂亮的彩色输出
rotation/retention/compression一行搞定切分和压缩- 异常堆栈更详细(连局部变量值都打印)
缺点:不是标准库,需要安装;和用 logging 的第三方库混用时需要适配。新项目个人偏爱 loguru,团队/企业项目仍推荐标准 logging(统一规范、好治理)。
十三、实战案例:给 Web 应用配置完整日志系统
综合本篇所学,给一个 FastAPI 应用配上完整日志:
import logging
import logging.config
import time
from fastapi import FastAPI, Request
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"verbose": {
"format": "%(asctime)s | %(process)d | %(levelname)-8s | %(name)s | %(message)s"
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "verbose",
"level": "INFO",
},
"file": {
"class": "logging.handlers.RotatingFileHandler",
"formatter": "verbose",
"level": "DEBUG",
"filename": "app.log",
"maxBytes": 10485760,
"backupCount": 10,
"encoding": "utf8",
},
},
"loggers": {
"myapp": {"level": "DEBUG", "handlers": ["console", "file"], "propagate": False},
},
}
logging.config.dictConfig(LOGGING)
logger = logging.getLogger("myapp")
app = FastAPI()
@app.middleware("http")
async def log_requests(request: Request, call_next):
start = time.time()
response = await call_next(request)
duration = (time.time() - start) * 1000
logger.info("%s %s -> %d (%.2fms)",
request.method, request.url.path,
response.status_code, duration)
return response
@app.get("/")
def root():
logger.info("访问首页")
return {"msg": "ok"}
@app.get("/error")
def trigger_error():
try:
1 / 0
except ZeroDivisionError:
logger.exception("故意触发的错误")
from fastapi import HTTPException
raise HTTPException(500, "内部错误")
运行后:
- 每个请求的方法、路径、状态码、耗时都被记录
- 异常自动带完整堆栈
- DEBUG 以上写文件,INFO 以上同时输出控制台
- 日志文件超 10MB 自动切分,保留 10 个备份
日志输出示例:
2026-10-28 10:00:01 | 12345 | INFO | myapp | GET / -> 200 (3.21ms)
2026-10-28 10:00:02 | 12345 | INFO | myapp | 访问首页
2026-10-28 10:00:05 | 12345 | ERROR | myapp | 故意触发的错误
Traceback (most recent call last):
File "app.py", line 35, in trigger_error
1 / 0
ZeroDivisionError: division by zero
2026-10-28 10:00:05 | 12345 | INFO | myapp | GET /error -> 500 (5.67ms)
十四、最佳实践
- 绝不用 print 做生产日志:print 没级别、没文件、关不掉,一律换 logging。
- 每个模块用
getLogger(__name__):模块名自动成为 logger 名,便于定位和分级。 - 别在根 logger 加 Handler :根 logger 的 Handler 会接收所有库的日志,容易刷屏。给业务 logger 单独配,
propagate=False。 - 生产用 dictConfig / 配置文件:日志配置和代码分离,改配置不用发版。
- 日志要切分:用 RotatingFileHandler 或 TimedRotatingFileHandler,别让日志撑爆磁盘。
- 异常用
logger.exception:自动带堆栈,比logger.error(str(e))信息全得多。 - 不记录敏感信息 :密码、token、身份证号、手机号绝对不能进日志,必要时脱敏(如
138****0000)。 - 日志参数用 lazy 格 :用
logger.info("用户 %s 登录", username)而非logger.info(f"用户 {username} 登录"),前者在日志级别不达标时不会拼接字符串。 - 大量日志考虑异步 :用
QueueHandler+ 单独的监听线程写文件,避免 I/O 阻塞主业务。 - 结构化日志便于运维:JSON 格式 + ELK/Loki,比文本日志检索效率高几个数量级。
14.1 常见问题 FAQ
Q1:日志重复输出怎么办? A:通常是子 logger 的日志向上传播到了根 logger,两个 Handler 各输出一次。解决:给子 logger 设 propagate = False,或者只在根 logger 挂 Handler,子 logger 不挂。
Q2:basicConfig 不生效? A:basicConfig 只在第一次调用且根 logger 没有 Handler 时 生效。如果你之前别的库已经触发过日志(比如 requests),根 logger 就有 Handler 了,再调 basicConfig 会被忽略。解决:在程序最开头、import 别的库之前就调 basicConfig,或者改用 dictConfig。
Q3:中文日志乱码? A:FileHandler 默认编码可能不是 UTF-8。显式指定 encoding="utf-8" 即可:FileHandler("app.log", encoding="utf-8")。
Q4:多进程写同一个日志文件安全吗? A:不安全!多个进程同时写同一个文件会交错覆盖。解决:用 ConcurrentLogHandler 第三方库,或者每个进程写各自的文件再合并,或者直接输出到标准输出由容器/日志收集器统一处理(Docker/K8s 环境推荐做法)。
Q5:日志怎么和请求关联? A:Web 应用里一个请求可能产生多条日志,想串起来可以给每个请求生成一个 request_id,放进日志上下文(用 logging.LoggerAdapter 或 contextvars),所有日志都带上这个 id,排查时按 id 过滤就能看到一次请求的完整链路。