Logger 是一棵树: 记录沿层级冒泡到 root, Handler/Formatter/Filter 决定去哪长啥样; 生产日志的三条命 — 轮转、多进程、结构化
logging 的核心是一棵按名字点分的 Logger 树: 你调 logger.info() 只是造了一条 LogRecord, 它先过 logger 的有效级别这道闸, 然后从当前节点沿 propagate=True 一路向 root 冒泡 —— 沿途每一级的 Handler 都有机会接一手, 各自用各自的 Formatter 决定"去哪、长啥样"。所以"打两遍"是树的问题, "没打出来"是级别闸门的问题, "日志打架"是多个进程抢一个文件的问题 —— 三类生产事故都能在这张树上找到病根。
Logger 造记录(入口 API), Handler 决定去向(文件/控制台/远端), Formatter 决定长相, Filter 细粒度放行 —— 四个正交维度, 组合而非继承。 log = logging.getLogger("app") # Logger: 入口造记录 h = logging.StreamHandler() # Handler: 去向 h.setFormatter(logging.Formatter( "%(asctime)s %(levelname)s %(message)s")) # 长相 log.addFilter(ReqFilter()) # Filter: 放行
getLogger("app.api") 按点自动建父子; 模块里写 getLogger(__name__), logger 名 = 模块路径, 配置可按模块精确控制。 # app/services/payment.py 里: log = logging.getLogger(__name__) log.name # → 'app.services.payment' # 关键: 按点分层, 父是 app.services → 祖先 app → root logging.getLogger("app.services").setLevel(logging.DEBUG) # → payment 未设级别, 沿祖先找到这个 DEBUG 生效
log = logging.getLogger("app.api") log.addHandler(h) # 自己挂了 handler # root 也配了 handler 时: log.info("x") # → 打 2 遍(自己的 + 冒泡到 root 的) log.propagate = False # 关键: 截断冒泡, 只打 1 遍
isEnabledFor(lv) 从自身沿祖先找第一个显式设置的级别; 一路没设 = root 默认 WARNING。记录低于有效级别, 根本出不了 logger。 # root: WARNING; app / app.api 都未设 log = logging.getLogger("app.api") log.info("x") # → 不输出: 沿祖先找到 WARNING 拦下 log.setLevel(logging.INFO) # 自身显式设置 log.info("x") # → 输出: 第一闸放行
import some_lib # 库内部先调了 basicConfig logging.basicConfig(level=logging.INFO) # 静默跳过: # root 已有 handler logging.basicConfig(level=logging.INFO, force=True) # 应急掀翻 # 服务正解: 入口统一 dictConfig
disable_existing_loggers=False 保住库的 logger —— 生产标配。 dictConfig({
"version": 1,
"disable_existing_loggers": False, # 关键: 保住库的 logger
"root": {"level": "INFO",
"handlers": ["console", "file"]},
}) # formatters/handlers/loggers 一张 dict 说清log.debug("sql=%s", q) # 排障细节: 生产默认关 log.info("order paid") # 业务生命周期: 默认级 log.warning("retry 1/3") # 可自愈异常 log.error("charge failed") # 失败操作 log.critical("db down") # 服务濒死
logger.exception("x") 在 except 块里自动带完整栈(等价 exc_info=True); stack_info=True 另附当前调用栈 —— 排障证据链。 try: charge(oid) except Exception: log.exception("charge failed") # 自动带完整栈 # 等价 log.error(..., exc_info=True) log.info("state", stack_info=True) # 另附调用栈
logger.info("a=%s", a) 只有真要输出时才拼接; f-string 是立刻求值 —— 热路径上 INFO 关着也在白拼字符串。 log.debug("big=%s", p) # 对: 真输出才 format log.debug(f"big={json.dumps(p)}") # 错: 立刻求值 log.isEnabledFor(logging.DEBUG) # → False 时上面白算
RotatingFileHandler 按大小(100MB×5 封顶 600MB), TimedRotatingFileHandler 按时间(每天一个); 多进程同文件轮转必乱, 交给队列方案。 RotatingFileHandler(p, maxBytes=100*1024*1024, # 按大小 backupCount=5) # 封顶 600MB TimedRotatingFileHandler(p, when="midnight", # 按时间 backupCount=7, utc=True) # 关键: 多进程同文件轮转必乱 → 交给队列方案
QueueHandler(只入队, 不阻塞业务线程), 由 listener 线程消费真正的 handler —— 网络类 handler 卡顿的解药, 也是多进程汇聚的标准姿势。 q = queue.Queue() qh = QueueHandler(q) # 业务线程只入队即返回 listener = QueueListener(q, real_handler) listener.start() # 独立线程消费真 handler log.handlers = [qh] # 网络 handler 卡顿不再拖业务
uvicorn.access/gunicorn.error), dictConfig 里一并接管即可 —— 部署页有完整配置。 LOGGING["loggers"] = { "uvicorn.access": {"level": "WARNING", "propagate": False}, "gunicorn.error": {"level": "INFO"}, } dictConfig(LOGGING) # 关键: 框架日志也是 logger, 一并接管
filter(record) 返回 True 放行 / False 丢弃; 忘写 return(None 按 False)会静默丢光。还能往 record 上注字段(如 req_id)。 class ReqFilter(logging.Filter): def filter(self, record): record.req_id = get_req_id() # 顺手注字段 return True # 关键: None 按 False 丢光! log.addFilter(ReqFilter())
服务要同时满足"运维 tail 控制台"和"采集器吃 JSON 文件"两个消费方, 一张 dictConfig 声明整棵树:
LOGGING = {
"version": 1,
"disable_existing_loggers": False, # 不杀库先建的 logger
"formatters": {
"human": {"format": "%(asctime)s %(levelname)-7s %(name)s: %(message)s"},
"json": {"()": "app.JsonFormatter"}, # 文件侧机器可读
},
"handlers": {
"console": {"class": "logging.StreamHandler",
"stream": "ext://sys.stdout", "formatter": "human"},
"file": {"class": "logging.handlers.RotatingFileHandler",
"formatter": "json", "filename": "logs/app.log",
"maxBytes": 104857600, "backupCount": 5},
},
"root": {"handlers": ["console", "file"], "level": "INFO"},
}
logging.config.dictConfig(LOGGING) # 入口处一次调用, 全服务生效
支付失败排障时, 只有 "timeout" 两个字没有栈, 等于没日志; exception() 自动附带当前异常完整链:
def charge(order_id: str): try: gw = PaymentGateway() # 初始化也可能失败 return gw.charge(order_id) except TimeoutError: log.warning("gateway timeout, will retry: order=%s", order_id) raise # 瞬时错误: 上层有重试 except Exception: log.exception("charge failed order=%s", order_id) # 自动带 40 行栈 raise PaymentError(order_id) from None # str(e) 只有一行消息; exception() = 消息 + 完整调用栈 + cause 链
事故复盘时间从"猜半天"变成"看栈即知": 报错行、调用链、cause 一屏全齐。
计费对账差一分钱, 要看 SQL 明细但不能全服务 DEBUG(日志量 100 倍), 按模块精确调级:
# 发版前: dictConfig 里只给 app.billing 开 DEBUG LOGGING["loggers"] = { "app.billing": {"level": "DEBUG", "propagate": True}, "app": {"level": "INFO"}, } # 线上不停机临时开 (改内存, 不落盘): logging.getLogger("app.billing").setLevel(logging.DEBUG) logging.getLogger("app.billing.sqlalchemy.engine").setLevel(logging.INFO) # 排查完记得调回去 —— DEBUG 的 SQL 明细 1 分钟能写 2GB
裸 FileHandler 写了三周把 2GB 数据盘写满, 服务整个挂掉; 按大小轮转 + 备份数封顶:
from logging.handlers import RotatingFileHandler h = RotatingFileHandler( "logs/app.log", maxBytes=100 * 1024 * 1024, # 单文件 100MB 即轮转 backupCount=5, # app.log.1 ... app.log.5 encoding="utf-8", # 中文不乱码 ) log = logging.getLogger("app") log.setLevel(logging.INFO) # 两级闸门都要开 log.addHandler(h) # 磁盘占用上限 = 100MB × (1 + 5) = 600MB, 永远打不满 2GB 数据盘 # 注意: 多进程共用此 handler 会互相踩轮转 → 场景 5
gunicorn 4 worker 各自轮转同一文件, 互相 rename 踩脚丢单; 换成"worker 只入队, 主进程一个 listener 独家写文件":
from logging.handlers import QueueHandler, QueueListener import multiprocessing as mp q = mp.Manager().Queue() # 跨进程队列 (spawn/fork 都行) def worker_logger(name: str): lg = logging.getLogger(name) lg.handlers = [QueueHandler(q)] # 业务侧只入队, 不做慢 I/O lg.setLevel(logging.INFO) return lg listener = QueueListener(q, rotating_file_handler, console_handler, respect_handler_level=True) listener.start() # 一个进程独家轮转/格式化/落盘
副作用收益: 业务线程的日志调用从"同步写文件"变"入队即返回", P99 尾部抖动同步消失。
排障要按请求串联所有日志, 人读格式没法 grep; LoggerAdapter 把 req_id 注进每条记录:
class JsonFormatter(logging.Formatter): def format(self, r): d = {"ts": utc_iso(r.created), "lvl": r.levelname, "logger": r.name, "msg": r.getMessage(), "req_id": getattr(r, "req_id", None)} # extra 注入的字段 if r.exc_info: d["exc"] = self.formatException(r.exc_info) return json.dumps(d, ensure_ascii=False) log = logging.LoggerAdapter(logging.getLogger("app"), {"req_id": ctx.request_id}) log.info("order paid") # 同一请求的全部日志同 req_id # Loki/ES: req_id=abc123 一搜, 这单支付 17 条日志按序全出
健康检查探活每秒 2000 条 INFO, 告警平台被打爆; 用 Filter 采样, 只留百分之一:
class Sampler(logging.Filter): def __init__(self, rate=100): self.rate, self.n = rate, 0 def filter(self, record): # 必须显式 return bool self.n += 1 keep = self.n % self.rate == 0 # 100 条留 1 条 if keep: record.sampled = 1 # 标记: 知道这是采样 return keep logging.getLogger("app.health").addFilter(Sampler(100)) # 2000/s → 20/s; ERROR 级别走独立 logger 不采样, 异常一条不丢
一开 DEBUG, urllib3 连接池和 uvicorn.access 每秒几万条, 业务日志被淹; 库日志单独压级:
LOGGING["loggers"] = { "urllib3": {"level": "WARNING"}, # 连接池 DEBUG 刷屏 "uvicorn.access": {"level": "WARNING", "propagate": False}, "sqlalchemy.engine": {"level": "WARNING"}, "celery": {"level": "INFO"}, "app": {"level": "INFO"}, # 自己的业务不变 } # 纪律: 第三方默认 WARNING, 只有自己的代码吃 INFO/DEBUG 配额
治理后日志量降 85%, 真正的业务信号不再被基建噪音稀释。
合规审计发现手机号和 Authorization 原文进了日志文件; 在源头统一清洗, 而不是靠每个开发自觉:
import re SENSITIVE = ("password", "token", "authorization", "phone") class MaskFilter(logging.Filter): def filter(self, record): msg = record.msg if isinstance(msg, str): for k in SENSITIVE: # 匹配 JSON 形态与 k=v 形态 msg = re.sub(rf'("{k}"\s*:\s*")[^"]+', r'\1***', msg) msg = re.sub(rf'({k}=)[^\s,]+', r'\1***', msg) record.msg = msg # 源头改掉, Formatter 只见干净数据 return True # 别忘了放行 logging.getLogger().handlers[0].addFilter(MaskFilter())
五个服务各写各的格式, 平台没法统一索引; 约定四个字段全公司对齐:
# ts UTC ISO8601 带毫秒: 2026-09-26T08:01:02.123Z (不要本地时区!) # svc 服务名: payment / order ... 与部署单元对齐 # lvl 级别名: INFO / ERROR 统一大写 # trace_id W3C trace id: 跨服务串联 中间件注入 contextvar log.info("refund done", extra={"svc": "payment", "trace_id": tid}) # Loki 一条查询链穿三个服务: # {svc="payment"} |= "trace_id=9f3c..." → order → risk → db # 字段不齐 = 检索失效: "格式即接口", 日志也逃不掉
basicConfig(level=INFO) 没生效, 级别纹丝不动。原因: 某个库 import 时先调过 basicConfig, root 已有 handler, 它只对"root 无 handler"的场景生效。正解: 入口统一 dictConfig; 应急用 basicConfig(force=True) 掀翻重来。 import some_lib # 库 import 时先调了 basicConfig logging.basicConfig(level=logging.INFO) # 错: root 已有 handler, 静默跳过 logging.basicConfig(level=logging.INFO, force=True) # 对: 应急掀翻重来 # 正解: 服务入口统一 dictConfig
log.setLevel(logging.DEBUG) # 错: 只开了第一闸, # handler 还在 WARNING 拦着 log.setLevel(logging.DEBUG) # 对: 两道闸一起开 h.setLevel(logging.DEBUG) log.addHandler(h)
propagate=True, 沿途每级 handler 各处理一遍。正解: handler 集中放 root 一处; 或子 logger 设 propagate=False 截断。 log = getLogger("app.api") log.addHandler(h) # root 也挂了 h2 log.info("x") # 错: h、h2 各打一遍 → 2 条 log.propagate = False # 对: 截断冒泡, 只打 1 条 # 更优: handler 集中放 root, 子 logger 不挂
QueueHandler 汇聚到单进程写; 或 WatchedFileHandler 交给外部 logrotate。 # gunicorn -w 4 共用 RotatingFileHandler("app.log") # 错: 各自 rename 互相踩 → 丢日志/重复/PermissionError qh = QueueHandler(mp.Manager().Queue()) # 对: 只入队 listener = QueueListener(qh.queue, rotating_h) listener.start() # 单进程独家写; 或 WatchedFileHandler
log.error(f"fail: {e}") 只有一行消息, 报错位置全靠猜。原因: 字符串化丢掉 traceback。正解: except 块里 logger.exception() 或 exc_info=True。 except Exception as e: log.error(f"fail: {e}") # 错: 只有一行, 没栈 except Exception: log.exception("charge failed") # 对: 自动带完整栈 # 或 log.error(..., exc_info=True)
log.debug(f"big={json.dumps(payload)}"), INFO 级别下格式化已经发生, 白拼大字符串。原因: f-string 立刻求值, %s 参数化只在真输出时才 format。正解: 一律 log.debug("big=%s", payload)。 log.debug(f"big={json.dumps(p)}") # 错: INFO 级别下序列化 # 也已经发生, 白拼大串 log.debug("big=%s", p) # 对: 真输出时才 format
basicConfig(level=DEBUG), 谁 import 它整个进程就 DEBUG, 生产配置改不动。原因: 配置时机错位 + 全局单例。正解: 模块里只 getLogger, 配置只在进程入口做一次。 # my_module.py 顶层 logging.basicConfig(level=logging.DEBUG) # 错: 谁 import 它 # 整个进程就 DEBUG log = logging.getLogger(__name__) # 对: 模块只 getLogger # 配置只在进程入口 dictConfig 一次
utc=True 对齐口径; 多进程换队列方案, 单文件单写者。 TimedRotatingFileHandler("app.log", when="midnight") # 错: 本地时区, 文件名日期差 8h; # 多进程 rename 竞态丢日志 TimedRotatingFileHandler("app.log", when="midnight", utc=True, backupCount=7) # 对: 对齐口径
log.info("req=%s", body) 图省事。正解: 统一脱敏 Filter 在源头洗; 敏感字段白名单外不打。 log.info("req=%s", body) # 错: Authorization/手机号 # 原文落盘 log.info("login user=%s", uid) # 对: 只打白名单字段 # + 统一脱敏 Filter 在源头洗
raise ... from e。 except Exception as e: log.error("failed: %s", e); raise # 错: 每层都记+抛 except Exception as e: raise PaymentError(oid) from e # 对: 只 raise 带上下文, # 边界层记一次
getLogger(f"user-{uid}") 每个用户名造一个 Logger 对象, 全挂在全局 Manager 上永不释放, 万级用户即泄漏。原因: logger 是进程级单例字典。正解: 名字只用固定层级; 动态信息进 extra 字段。 log = logging.getLogger(f"user-{uid}") # 错: 每用户一个 # Logger, 永不释放 log = logging.getLogger("app.user") # 对: 固定层级名 log.info("login", extra={"uid": uid}) # 动态进 extra
log.handlers = [SocketHandler("log.local", 9000)] # 错: 网络抖动 → 业务线程排队等 qh = QueueHandler(queue.Queue()) # 对: 入队即返回 listener = QueueListener(qh.queue, sock_h) listener.start() # listener 线程慢慢写
log = logging.getLogger("app") log.info("started") # 错: root 默认 WARNING, # INFO 全静默丢, 无报错 dictConfig(LOGGING) # 对: 入口无条件配置 log.info("started") # 启动自检: 必须出现
kubectl logs 空空如也, 应用其实活得好好的。原因: 只配了 FileHandler, 容器收的是 stdout/stderr。正解: 永远保留一个 StreamHandler(ext://sys.stdout), 文件只是补充。 # 只配了 FileHandler("logs/app.log") 时: $ kubectl logs quote-api # 错: 空空如也 — 容器只收 # stdout/stderr "console": {"class": "logging.StreamHandler", "stream": "ext://sys.stdout"} # 对: 永远保留
# 服务 A 手动 addHandler 带 asctime, 服务 B 裸文本 # 错: 平台字段解析一半失败, 检索失效 FMT = {"format": "%(asctime)s %(levelname)s " "%(name)s %(message)s"} # 对: 统一约定 # 配置收敛 dictConfig, 禁止业务代码直接 addHandler
filter() 必须显式 return True/False。 class F(logging.Filter): def filter(self, record): record.req_id = rid() # 错: 没 return → None # 按 False 处理 → 日志全部蒸发 return True # 对: 必须显式 return
contextlib.redirect_stdout 引到 logger; 新代码禁 print。 print("step1") # 错: stdout 块缓冲, log.info("step2") # 与 handler 的 flush 错序 with contextlib.redirect_stdout(log_stream): # 对: 收编 print legacy_code() # 新代码禁 print
addHandler, 同一条日志打 1 遍/2 遍/3 遍递增。原因: handler 列表只增不减, logger 又是单例。正解: 配置只做一次; 会重复执行的 init 先 logger.handlers.clear()。 def setup(): log.addHandler(h) # 错: init 每跑一次多挂一个, # 同条日志打 1/2/3 遍递增 def setup(): log.handlers.clear() # 对: 先清再挂(或配置只做一次) log.addHandler(h)
logger.exception("x"), 输出 "NoneType: None" 噪音, 没有真栈。原因: 当前线程没有活动异常, exc_info 取不到东西。正解: 只在 except 里用; 或显式传 exc_info=sys.exc_info()。 def flow(): logger.exception("state") # 错: 无活动异常, # 输出 "NoneType: None" 噪音 except Exception: logger.exception("boom") # 对: 只在 except 里用 logger.error("st", exc_info=sys.exc_info()) # 或显式传
disable_existing_loggers 默认 True, 把已创建的非配置项 logger 全部 disable。正解: 配置文件里显式 disable_existing_loggers=False, 或迁到 dictConfig 并同样显式设 False。 logging.config.fileConfig("logging.conf") # 错: disable_existing_loggers 默认 True, # 库先建的 logger 全被 disable fileConfig("logging.conf", disable_existing_loggers=False) # 对: 显式 False # 迁 dictConfig 时同样显式设 False