适用场景与现象
适用于 Python 3.10 及以上的命令行程序、后台任务和自有服务。业务代码只调用一次 logger.info(),同一进程的原始输出却出现两行;或者应用工厂每执行一次,同一事件就多输出一遍。
先分清重复发生在哪一层:若进程的 stderr 只有一条,而日志平台出现两条,优先检查采集路径是否同时包含容器输出和文件。若两条记录的 process_id 不同,还应检查多个 Worker 是否都执行了同一任务。本篇处理同一进程里,一次日志调用经过多个输出路径的问题,不把日志去重当作业务幂等。
一、在独立进程里复现两种重复
把下面内容保存为 duplicate_demo.py,执行 python duplicate_demo.py。使用内存流,结果不会依赖终端或采集器。
import io
import logging
def make_handler(stream: io.StringIO) -> logging.StreamHandler:
"""创建独立输出器,用于复现相同目标上的重复写入。"""
handler = logging.StreamHandler(stream)
handler.setFormatter(logging.Formatter("%(name)s %(message)s"))
return handler
def main() -> None:
"""隔离两种故障,避免把传播重复和注册重复混在一起。"""
stream = io.StringIO()
root = logging.getLogger()
logger = logging.getLogger("demo.orders")
logger.setLevel(logging.INFO)
logger.propagate = True
logging.basicConfig(handlers=[make_handler(stream)], force=True)
child_handler = make_handler(stream)
logger.addHandler(child_handler)
logger.info("订单校验完成")
print("父子同时输出行数:", len(stream.getvalue().splitlines()))
logger.removeHandler(child_handler)
child_handler.close()
stream.seek(0)
stream.truncate(0)
logger.propagate = False
first = make_handler(stream)
second = make_handler(stream)
logger.addHandler(first)
logger.addHandler(second)
logger.info("订单校验完成")
print("重复注册输出行数:", len(stream.getvalue().splitlines()))
for handler in (first, second):
logger.removeHandler(handler)
handler.close()
for handler in list(root.handlers):
root.removeHandler(handler)
handler.close()
if __name__ == "__main__":
main()
两次都应显示 2。第一种是子 logger 与根 logger 各写一次;第二种没有向父级传播,两个不同 handler 仍各写一次。反复调用 getLogger("demo.orders") 本身不会创建新的 logger,问题通常出在每次初始化都创建新的 handler。Python logging API
二、查看真实传播路径,不只看 handler 数量
在发生重复的进程内运行下面的诊断函数。另开 Python 进程只能看到新进程的配置,不能诊断正在运行的服务。
import json
import logging
def inspect_logging_path(logger_name: str) -> None:
"""仅输出配置元数据,避免暴露日志文件路径和业务内容。"""
current: logging.Logger | None = logging.getLogger(logger_name)
while current is not None:
print(json.dumps({
"logger_name": current.name,
"configured_level": logging.getLevelName(current.level),
"effective_level": logging.getLevelName(current.getEffectiveLevel()),
"is_disabled": current.disabled,
"should_propagate": current.propagate,
"handlers": [
{
"handler_id": id(handler),
"handler_type": type(handler).__name__,
"handler_level": logging.getLevelName(handler.level),
}
for handler in current.handlers
],
}, ensure_ascii=False))
if not current.propagate:
break
current = current.parent
inspect_logging_path("service.orders")
诊断时记录一次冷启动、一次应用工厂调用以及再次调用后的结果:
| 观察结果 | 优先检查 | 修复方向 |
|---|---|---|
| 同一 logger 的 handler 数量持续增加 | 工厂、构造函数或初始化钩子中的 addHandler |
把配置移到进程入口 |
| 子 logger 和祖先都有输出 handler | propagate=True 的传播链 |
明确由哪一级负责输出 |
| 同一个 handler_id 出现在链上多处 | 同一对象被挂到父子两级 | 只保留一个挂载位置 |
| handler 数量稳定,原始输出仍重复 | 业务调用次数、Worker、采集链路 | 按进程与事件来源继续取证 |
不同 handler_id 不代表不同目标:两个 StreamHandler 可能都写 stderr。表中是定位线索,还要在受控调试环境里核对实际输出目标。
两个常见误判:
hasHandlers()会沿有效传播链查找祖先,不能用它判断“当前 logger 是否已安装自己的 handler”。- 提高 root 的 logger 级别不一定阻止显式设为 INFO 的子 logger 记录;传播阶段仍要看接收 handler 的级别。不要靠调高级别掩盖重复。Python logging API
三、方案 A:自有程序由入口统一配置
对于自己控制生命周期的程序,选择 root 统一输出,业务模块只获取 logger。保存为 logging_setup.py:
import json
import logging
import sys
from typing import TextIO
class JsonFormatter(logging.Formatter):
"""输出固定字段,第三方日志不提供 event 时使用默认值。"""
def format(self, record: logging.LogRecord) -> str:
payload = {
"level": record.levelname,
"logger_name": record.name,
"process_id": record.process,
"event": getattr(record, "event", "application_log"),
"message": record.getMessage(),
}
if record.exc_info:
payload["exception"] = self.formatException(record.exc_info)
return json.dumps(payload, ensure_ascii=False)
def configure_logging(stream: TextIO | None = None) -> None:
"""仅用于自有程序启动阶段,替换由该入口管理的根输出配置。"""
handler = logging.StreamHandler(sys.stderr if stream is None else stream)
handler.setLevel(logging.INFO)
handler.setFormatter(JsonFormatter())
logging.basicConfig(level=logging.INFO, handlers=[handler], force=True)
在入口调用一次 configure_logging(),然后启动线程或创建应用。业务模块使用 logging.getLogger(__name__),通过 logger.info("订单校验完成", extra={"event": "order_validated"}) 写入日志,不安装 StreamHandler。
force=True 会移除并关闭 root 上原有的 handler;它不会清理子 logger 的 handler,也不是框架配置的通用补丁。Python basicConfig
迁移已有程序时,先搜索配置来源:
rg -n 'addHandler|basicConfig|dictConfig|StreamHandler|FileHandler|propagate' .
删除业务模块和工厂中的输出器注册代码,再重启验证。在启动阶段确需清理历史实例时,只处理已确认归本应用管理的 handler,先 removeHandler(),再 close();共享 handler 要从全部挂载位置卸载后再关闭。不要遍历所有第三方 logger 批量清空。
这里的 JSON 格式器只提供诊断字段,不负责自动脱敏。不要把令牌、Cookie、原始请求体或敏感身份数据放入 message、extra 或异常文本;异常可能携带输入内容,应在业务边界处理后再记录。
四、方案 B:框架托管时在已有配置中明确归属
若 Django、ASGI 服务或任务框架负责配置,沿用它的启动配置入口。先明确框架日志与业务日志各自的路由,再合并配置。下面是独立业务命名空间的配置片段,应并入已有配置,不能覆盖完整框架配置:
BUSINESS_LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"json": {"()": "logging_setup.JsonFormatter"},
},
"handlers": {
"business_console": {
"class": "logging.StreamHandler",
"level": "INFO",
"formatter": "json",
"stream": "ext://sys.stderr",
},
},
"loggers": {
"service": {
"level": "INFO",
"handlers": ["business_console"],
"propagate": False,
},
"service.orders": {
"level": "NOTSET",
"handlers": [],
"propagate": True,
},
},
}
在这种设计中,service.orders 传给 service,后者输出后停止向 root 传播。其他业务子 logger 也应移除各自的输出 handler 并传播到 service。若实际名称是 myapp.orders,要按真实命名空间改配置;__name__ 不会自动落在 service 下。
disable_existing_loggers=False 用于避免未列出的现有非根 logger 因配置而被禁用;它不承诺现有 handler 都被移除。最终仍要执行路径检查。Python logging.config
直接把所有 logger 的 propagate 改成 False 很危险:没有自身输出 handler 的 logger 可能失去 INFO 输出路径。必须同时明确它自己的输出归属。
五、用行数、字段和丢失路径做回归验证
把以下代码保存为 test_logging_setup.py,与 logging_setup.py 放在同一目录。测试应在独立进程运行,因为它会替换 root 配置。
import io
import json
import logging
import unittest
from logging_setup import configure_logging
class LoggingSetupTests(unittest.TestCase):
"""验证重复配置、层级传播与级别边界,防止修复后丢日志。"""
def setUp(self) -> None:
self.stream = io.StringIO()
self.logger = logging.getLogger("practice_logging.orders")
self.logger.setLevel(logging.INFO)
self.logger.disabled = False
self.logger.propagate = True
configure_logging(self.stream)
def tearDown(self) -> None:
for logger in (self.logger, logging.getLogger()):
for handler in list(logger.handlers):
logger.removeHandler(handler)
handler.close()
self.stream.close()
def test_single_event_has_one_record(self) -> None:
self.logger.info("订单校验完成", extra={"event": "order_validated"})
lines = self.stream.getvalue().splitlines()
self.assertEqual(len(lines), 1)
record = json.loads(lines[0])
self.assertEqual(record["event"], "order_validated")
self.assertEqual(record["message"], "订单校验完成")
def test_repeated_configuration_does_not_accumulate(self) -> None:
configure_logging(self.stream)
configure_logging(self.stream)
self.logger.info("入口初始化完成")
self.assertEqual(len(logging.getLogger().handlers), 1)
self.assertEqual(len(self.stream.getvalue().splitlines()), 1)
def test_handler_level_filters_debug(self) -> None:
self.logger.setLevel(logging.DEBUG)
self.logger.debug("调试校验完成")
self.logger.info("业务校验完成")
self.assertEqual(len(self.stream.getvalue().splitlines()), 1)
def test_root_force_does_not_remove_child_handler(self) -> None:
self.logger.addHandler(logging.StreamHandler(self.stream))
configure_logging(self.stream)
self.logger.info("重复路径复现")
self.assertEqual(len(self.stream.getvalue().splitlines()), 2)
def test_stopping_propagation_without_handler_loses_info(self) -> None:
self.logger.propagate = False
self.logger.info("无输出路径的事件")
self.assertEqual(self.stream.getvalue(), "")
def test_exception_record_keeps_error_context(self) -> None:
try:
raise ValueError("订单状态不合法")
except ValueError:
self.logger.exception("订单校验失败")
lines = self.stream.getvalue().splitlines()
self.assertEqual(len(lines), 1)
record = json.loads(lines[0])
self.assertIn("ValueError", record["exception"])
self.assertEqual(record["level"], "ERROR")
if __name__ == "__main__":
unittest.main()
执行:
python -m unittest -v test_logging_setup.py
应通过六项测试。第四项刻意保留错误路径,证明只给 root 加 force=True 仍会重复;第五项证明只关传播可能丢日志。第三项则确保 INFO 输出器不会因为子 logger 显式开启 DEBUG 而泄漏调试记录。
上线验收还要覆盖:冷启动输出一次、实际框架初始化后输出一次、原始 stderr 与日志平台数量一致、框架访问日志仍存在。测试中的重配置是回归验证,生产入口依然应在启动时配置一次,不在请求处理中修改全局日志配置。
六、预防与回滚
代码评审时把日志配置视为进程级资源管理:库模块使用命名 logger,必要时安装 NullHandler;输出路径由应用入口决定。Python Logging HOWTO
将初始化后的 handler 数量和归属作为启动验收内容。不要在每个类构造函数里“保证日志可用”,也不要以降低告警阈值或平台去重掩盖配置增长。
迁移先在单个实例验证。若出现业务日志缺失或框架访问日志消失,恢复上一份启动配置并重启该实例;保留本次传播路径快照,用它确认哪个输出归属被错误覆盖。不要在运行中继续叠加 handler 试图修复。
总结
先确认一条业务事件是否在同一进程的原始输出中重复,再查看 logger 到祖先的有效传播路径。修复后同时验证“只输出一次”和“需要的日志仍能输出”,才能避免把重复日志问题变成日志丢失问题。
Discussion
评论