Python · logging 日志体系

Logger 是一棵树: 记录沿层级冒泡到 root, Handler/Formatter/Filter 决定去哪长啥样; 生产日志的三条命 — 轮转、多进程、结构化

propagate=True 冒泡 propagate=True 冒泡 app.api logger.info("order paid") 级别未设 → 沿祖先找 找到第一个设了的为准 SyslogHandler 发往远端采集器 Formatter: JSON 远端 ELK / Loki 机器可读 JSON app propagate=True (默认) 记录继续向上冒泡 propagate=False → 截断 StreamHandler stderr 控制台 Formatter: 人类可读 控制台 (人看) 单行彩色 root getLogger() 不带名就是它 basicConfig 配的是它 全未配置 → WARNING 起 RotatingFileHandler app.log 100MB × 5 备份 防磁盘打满 logs/app.log 轮转文件 .1 ... .5 一条 log.info("order paid") → 三路扇出: 远端 JSON + 控制台 + 文件 —— propagate 是整棵树的"总闸" 有效级别判定 (两级闸门) 第一闸 logger: 从自身沿祖先找第一个设置的级别, 记录低于它 → 出不了 logger 第二闸 handler: 过了 logger 的记录, 每个 handler 还有自己的 level 再滤一次 三条生产命脉 ① 轮转 — 磁盘有上限 RotatingFileHandler( maxBytes=100MB, backupCount=5) 磁盘占用封顶 600MB 多进程轮转不安全 → 命脉② ② 队列 — 多进程汇聚 QueueHandler → queue.Queue QueueListener → 真 handler 业务线程只入队, 不被拖住 多 worker 汇到一个 listener ③ JSON — 机器可读 固定字段名 + UTC ISO8601 request_id / trace_id 贯穿 采集端能 grep 能建索引 告警/排障不靠人眼扫文本 getLogger(__name__) 惯例 模块名即 logger 名 → 树自然长成 配置可按模块精确调级/关噪 Legend Logger 树节点 Handler / 输出 机制说明 文件 / 结构化 警示

Logger 是树, 不是列表

  • • "app.api" 按点分层, 记录沿 propagate 向上冒泡
  • • 每级挂自己的 Handler: 一条记录可多路扇出
  • • __name__ 命名让树随模块结构自然生长

两级闸门决定"能不能出去"

  • • logger 有效级别: 沿祖先找到第一个设置的
  • • handler 级别是第二道滤网, 别只改一头
  • • 全链未配置 = root 的 WARNING 起, INFO 全丢

生产三条命: 轮转/队列/结构化

  • • 不轮转 → 磁盘迟早被日志打满
  • • 多进程不排队 → 同文件轮转互相踩
  • • 不结构化 → ELK/Loki 里没法检索

💡 一句话理解

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 生效
propagate
默认 True, 记录从子 logger 冒泡到祖先, 沿途每级 handler 都处理; 设 False 即截断 —— "一条日志打两遍"的第一嫌疑人。
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")                 # → 输出: 第一闸放行
basicConfig 的局限
仅当 root 无 handler 时生效(库先配置过就被静默跳过), 无轮转无结构化 —— 只适合脚本, 不适合服务; 库代码永远别调它。
import some_lib                 # 库内部先调了 basicConfig
logging.basicConfig(level=logging.INFO)   # 静默跳过:
                                          # root 已有 handler
logging.basicConfig(level=logging.INFO, force=True)  # 应急掀翻
# 服务正解: 入口统一 dictConfig
dictConfig
声明式整树配置: formatters/handlers/loggers/root 一张 dict 说清, disable_existing_loggers=False 保住库的 logger —— 生产标配。
dictConfig({
    "version": 1,
    "disable_existing_loggers": False,  # 关键: 保住库的 logger
    "root": {"level": "INFO",
             "handlers": ["console", "file"]},
})  # formatters/handlers/loggers 一张 dict 说清
分级语义
DEBUG 排障细节 / INFO 业务生命周期 / WARNING 可自愈异常 / ERROR 失败操作 / CRITICAL 服务濒死。生产纪律: 默认 INFO, DEBUG 只按模块临时开。
log.debug("sql=%s", q)      # 排障细节: 生产默认关
log.info("order paid")      # 业务生命周期: 默认级
log.warning("retry 1/3")    # 可自愈异常
log.error("charge failed")  # 失败操作
log.critical("db down")     # 服务濒死
exc_info / stack_info
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)
# 关键: 多进程同文件轮转必乱 → 交给队列方案
QueueListener 异步化
handler 换成 QueueHandler(只入队, 不阻塞业务线程), 由 listener 线程消费真正的 handler —— 网络类 handler 卡顿的解药, 也是多进程汇聚的标准姿势。
q = queue.Queue()
qh = QueueHandler(q)              # 业务线程只入队即返回
listener = QueueListener(q, real_handler)
listener.start()                  # 独立线程消费真 handler
log.handlers = [qh]               # 网络 handler 卡顿不再拖业务
框架收编一句
uvicorn/gunicorn 的 access/error 日志也只是 logger(uvicorn.access/gunicorn.error), dictConfig 里一并接管即可 —— 部署页有完整配置。
LOGGING["loggers"] = {
    "uvicorn.access": {"level": "WARNING",
                       "propagate": False},
    "gunicorn.error": {"level": "INFO"},
}
dictConfig(LOGGING)       # 关键: 框架日志也是 logger, 一并接管
Filter 语义
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())

🏭 生产实战 real world

场景 1 · dictConfig 一份配置管全服务: 控制台人读 + 文件 JSON

服务要同时满足"运维 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)         # 入口处一次调用, 全服务生效

场景 2 · logger.exception: except 块里一行带全栈

支付失败排障时, 只有 "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 一屏全齐。

场景 3 · 灰度只拉一个模块到 DEBUG

计费对账差一分钱, 要看 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

场景 4 · RotatingFileHandler 给磁盘上保险丝

裸 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

场景 5 · QueueHandler + QueueListener: 脱离主线程、多 worker 汇聚

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 尾部抖动同步消失。

场景 6 · 结构化 JSON: extra 字段 + request_id 贯穿

排障要按请求串联所有日志, 人读格式没法 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 条日志按序全出

场景 7 · 高频日志 1/100 采样降噪

健康检查探活每秒 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 不采样, 异常一条不丢

场景 8 · 第三方库噪音治理

一开 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%, 真正的业务信号不再被基建噪音稀释。

场景 9 · 日志脱敏 Filter: 密码/token 进日志前洗掉

合规审计发现手机号和 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())

场景 10 · 聚合到 ELK/Loki 的字段约定

五个服务各写各的格式, 平台没法统一索引; 约定四个字段全公司对齐:

# 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
# 字段不齐 = 检索失效: "格式即接口", 日志也逃不掉

⚠️ 编码注意与常见坑 pitfalls

坑 1 · basicConfig 被静默跳过 — 你调的 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
坑 2 · 改了 logger.setLevel 忘 handler.setLevel — logger 拉到 DEBUG, DEBUG 还是看不到。原因: 记录要过两道闸, handler 自己的 level 还在 WARNING 拦着。正解: 调级时两处一起改; dictConfig 里给 handler 显式 level。
log.setLevel(logging.DEBUG)   # 错: 只开了第一闸,
                              #     handler 还在 WARNING 拦着
log.setLevel(logging.DEBUG)   # 对: 两道闸一起开
h.setLevel(logging.DEBUG)
log.addHandler(h)
坑 3 · propagate 没关, 一条日志打两三遍 — app.api 有 handler, 记录又冒泡到 app/root 的 handler 再打一次。原因: 默认 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 不挂
坑 4 · 多进程写同一文件轮转错乱 — 4 个 worker 同时到 100MB, 各自 rename 互相踩: 丢日志/重复/Windows 上报 PermissionError。原因: 轮转的 check-rename- reopen 不是原子操作。正解: 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
坑 5 · 异常日志只 str(e) 丢栈 — 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)
坑 6 · f-string 惰性求值差异 — 热路径 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
坑 7 · import 时被 basicConfig 锁死级别 — 某模块顶层写了 basicConfig(level=DEBUG), 谁 import 它整个进程就 DEBUG, 生产配置改不动。原因: 配置时机错位 + 全局单例。正解: 模块里只 getLogger, 配置只在进程入口做一次。
# my_module.py 顶层
logging.basicConfig(level=logging.DEBUG)  # 错: 谁 import 它
                                          #     整个进程就 DEBUG
log = logging.getLogger(__name__)         # 对: 模块只 getLogger
# 配置只在进程入口 dictConfig 一次
坑 8 · TimedRotatingFileHandler 跨时区/进程竞态 — 半夜轮转出的文件名日期差 8 小时, 或多进程同时 rename 丢一天日志。原因: 后缀按本地时区/utc 算, rename 无锁。正解: utc=True 对齐口径; 多进程换队列方案, 单文件单写者。
TimedRotatingFileHandler("app.log", when="midnight")
                              # 错: 本地时区, 文件名日期差 8h;
                              #     多进程 rename 竞态丢日志
TimedRotatingFileHandler("app.log", when="midnight",
                         utc=True, backupCount=7)  # 对: 对齐口径
坑 9 · 日志泄敏感字段 — 打印整个 request body, Authorization/手机号原文落盘被审计逮个正着。原因: log.info("req=%s", body) 图省事。正解: 统一脱敏 Filter 在源头洗; 敏感字段白名单外不打。
log.info("req=%s", body)        # 错: Authorization/手机号
                                #     原文落盘
log.info("login user=%s", uid)  # 对: 只打白名单字段
#     + 统一脱敏 Filter 在源头洗
坑 10 · except 里 error 又 raise, 双重上报刷屏 — 底层记一遍, 中间层记一遍, 顶层再记一遍, 一次错误 40 行日志。原因: 每层都想"记一下保险"。正解: 边界层记一次(最终处理处), 传递层只 raise; 想带上下文用 raise ... from e。
except Exception as e:
    log.error("failed: %s", e); raise  # 错: 每层都记+抛
except Exception as e:
    raise PaymentError(oid) from e     # 对: 只 raise 带上下文,
                                      #     边界层记一次
坑 11 · getLogger 动态名字撑爆注册表 — 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
坑 12 · 慢 handler 阻塞业务线程 — SocketHandler/HTTP handler 网络抖动, 所有业务线程排队等写日志, 接口 P99 一起飙。原因: 默认 handler 在调用线程里同步做 I/O。正解: QueueHandler + QueueListener 异步化; 网络 handler 加 timeout。
log.handlers = [SocketHandler("log.local", 9000)]
                              # 错: 网络抖动 → 业务线程排队等
qh = QueueHandler(queue.Queue())      # 对: 入队即返回
listener = QueueListener(qh.queue, sock_h)
listener.start()                      #     listener 线程慢慢写
坑 13 · root 无配置, INFO 全部静默丢 — 代码里一堆 log.info 上线后一条不见, 也不报错。原因: root 默认 WARNING 起步, INFO 过不了第一道闸。正解: 入口无条件 dictConfig; 启动时打一条 INFO 自检确认通路。
log = logging.getLogger("app")
log.info("started")            # 错: root 默认 WARNING,
                               #     INFO 全静默丢, 无报错
dictConfig(LOGGING)            # 对: 入口无条件配置
log.info("started")            #     启动自检: 必须出现
坑 14 · 容器里日志进文件没打 stdout — 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"}  # 对: 永远保留
坑 15 · 格式不一致无法检索 — 有的带 asctime 有的没有, 时而 JSON 时而裸文本, 平台字段解析一半失败。原因: 各服务/各脚本自行 addHandler。正解: 公司级 Formatter 约定 + 禁止业务代码直接 addHandler, 配置收敛到 dictConfig。
# 服务 A 手动 addHandler 带 asctime, 服务 B 裸文本
# 错: 平台字段解析一半失败, 检索失效
FMT = {"format": "%(asctime)s %(levelname)s "
       "%(name)s %(message)s"}   # 对: 统一约定
# 配置收敛 dictConfig, 禁止业务代码直接 addHandler
坑 16 · filter 忘写 return, 全部日志蒸发 — 自定义 Filter 里只改了字段没 return, 上线后日志一条不出。原因: 返回 None 按 False 处理 = 丢弃。正解: filter() 必须显式 return True/False。
class F(logging.Filter):
    def filter(self, record):
        record.req_id = rid()   # 错: 没 return → None
        #     按 False 处理 → 日志全部蒸发
        return True             # 对: 必须显式 return
坑 17 · logging 与 print 混用行序错乱 — 文件里 print 的行和日志时间戳顺序对不上, 重现步骤都理不正。原因: print 走 stdout 块缓冲, logging 的 handler 独立缓冲且即时 flush。正解: 存量 print 用 contextlib.redirect_stdout 引到 logger; 新代码禁 print。
print("step1")               # 错: stdout 块缓冲,
log.info("step2")             #     与 handler 的 flush 错序
with contextlib.redirect_stdout(log_stream):  # 对: 收编 print
    legacy_code()              #     新代码禁 print
坑 18 · 重复 addHandler 越打越多遍 — 初始化函数每次调用都 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)
坑 19 · exception 用在非 except 块 — 普通流程里调 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())  # 或显式传
坑 20 · fileConfig 默认掐死已有 logger — 换用 fileConfig 后, 之前 import 的库日志全没了。原因: 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