后端开发最头疼的事线上出了 bug日志里一堆信息却不知道哪条日志对应哪个用户、哪个请求。今天以“掌柜问数”项目为例手把手教你用 Loguru ContextVars 搭建生产级日志系统让每个请求的日志都能串联起来。一、为什么你的日志总是一团乱麻很多团队还在用 Python 自带的 logging 模块。不是说它不好而是配置起来太麻烦要写 Formatter、Handler、Filter还要处理日志轮转、压缩、异步写入……折腾半天往往还漏了关键信息——请求 ID。更麻烦的是现在大家都在用 async/await 异步协程。如果还用 threading.local 来存请求 ID多个协程并发时会互相覆盖日志里的 request_id 全乱套了。解决方案Loguru 负责简化日志输出和文件管理ContextVars 负责在异步环境下安全传递请求 ID。两者配合一行日志就能看到当前请求的唯一标识。二、Loguru 到底好在哪小白也能懂Loguru 是一个第三方 Python 日志库安装后直接用几乎不需要配置。开箱即用from loguru import logger然后 logger.info(hello)控制台立刻输出带颜色、带时间、带行号的日志。加文件输出只要一行logger.add(file.log, rotation500 MB, retention10 days) – 自动轮转和删除旧日志。异常信息自动带堆栈logger.exception(出错啦) 直接打印完整异常信息。完全兼容异步加上 enqueueTrue日志写入不会阻塞你的 async 代码。三、ContextVars 又是什么和 threading.local 有啥区别threading.local 可以在同一个线程的不同函数间共享变量但无法区分同一个线程里交替运行的多个协程。ContextVars 是 Python 3.7 引入的协程安全的上下文变量。同一个协程任务中无论你 await 多少次变量都自动传递不同协程之间互不干扰。简单说在 FastAPI / Sanic / 任何 asyncio 框架中用 ContextVars 存 request_id 万无一失。四、动手打造完整日志系统代码可运行下面以“掌柜问数”项目为例给出一个精简但完整的日志模块。4.1 项目结构data-agent/ ├── app/ │ ├── core/ │ │ ├── context.py # 存放 request_id 上下文变量 │ │ └── logging.py # 日志配置 │ └── middleware/ │ └── request_id.py # 为每个请求生成 request_id └── main.py # FastAPI 入口4.2 第一步创建 contextvars 上下文 (context.py)# app/core/context.py import contextvars import uuid # 定义一个协程安全的变量默认值为 None request_id_ctx_var contextvars.ContextVar(request_id, defaultNone) def set_request_id(req_id: str None) - str: 设置当前请求的 request_id如果没有传入则自动生成 if req_id is None: req_id str(uuid.uuid4()) request_id_ctx_var.set(req_id) return req_id def get_request_id() - str: 获取当前请求的 request_id return request_id_ctx_var.get()4.3 第二步配置 Loguru 日志 (logging.py)# app/core/logging.py import sys from pathlib import Path from loguru import logger from app.core.context import get_request_id # 1. 定义日志格式预留 extra[request_id] 占位符 log_format ( green{time:YYYY-MM-DD HH:mm:ss.SSS}/green | level{level: 8}/level | magentarequest_id{extra[request_id]}/magenta | cyan{name}/cyan:cyan{function}/cyan:cyan{line}/cyan - level{message}/level ) # 2. 移除 Loguru 默认的控制台输出准备重新配置 logger.remove() # 3. 定义 patched 函数在每条日志生成前从 ContextVars 中取出 request_id 填进去 def inject_request_id(record): rid get_request_id() if rid is None: rid N/A # 兜底避免 KeyError record[extra][request_id] rid # 应用 patch logger logger.patch(inject_request_id) # 4. 添加控制台输出开发环境用 logger.add( sys.stdout, levelDEBUG, formatlog_format, colorizeTrue, ) # 5. 添加文件输出生产环境用自动轮转和保留 log_path Path(./logs) log_path.mkdir(exist_okTrue) logger.add( log_path / app.log, levelINFO, formatlog_format, rotation1 day, # 每天轮转 retention30 days, # 保留 30 天 encodingutf-8, enqueueTrue, # 异步写入不阻塞主线程 ) if __name__ __main__: # 测试手动设置 request_id 再打日志 from app.core.context import set_request_id set_request_id(test-123) logger.info(测试日志应该包含 request_idtest-123)4.4 第三步FastAPI 中间件自动注入 request_id# app/middleware/request_id.py from starlette.middleware.base import BaseHTTPMiddleware from app.core.context import set_request_id class RequestIDMiddleware(BaseHTTPMiddleware): async def dispatch(self, request, call_next): # 每个请求进来时自动生成并绑定一个 request_id req_id set_request_id() response await call_next(request) # 还可以把 request_id 加到返回头里方便前端调试 response.headers[X-Request-ID] req_id return response4.5 第四步在 FastAPI 中启用中间件并验证# main.py from fastapi import FastAPI from app.middleware.request_id import RequestIDMiddleware from app.core.logging import logger # 导入配置好的 logger app FastAPI() app.add_middleware(RequestIDMiddleware) app.get(/) async def root(): logger.info(处理根路径请求) return {message: Hello, 日志系统} app.get(/error) async def error(): logger.error(这是一个错误日志示例) raise ValueError(故意出错)启动服务后访问 http://localhost:8000/ 和 http://localhost:8000/error控制台和日志文件里每一条日志都会带上不同的 request_id同一个请求的多个日志拥有相同 ID。五、这段代码到底解决了什么问题你随便找一个第三方 HTTP 客户端发两个并发请求观察控制台输出2025-01-15 10:00:01.123 | INFO | request_idabc-111 | ... - 处理根路径请求 2025-01-15 10:00:01.124 | INFO | request_iddef-222 | ... - 处理根路径请求两个请求的 request_id 清晰区分不会混淆。如果查询日志文件grep abc-111 app.log 就能拿到这个请求的完整调用链。六、避坑指南重要必须调用 logger.remove()Loguru 默认会向 stderr 输出日志不先移除会导致日志重复打印。一定要做 logger.patch(inject_request_id)这样才能在运行时动态从 ContextVars 拿 request_id而不是只在启动时绑定一次。文件输出要开 enqueueTrue在异步高并发场景写文件 I/O 可能阻塞事件循环开启队列写入能避免性能问题。ContextVars 在 create_task 时注意继承如果你手动创建新协程任务需要传递 contextcontextvars.copy_context()否则子任务拿不到 request_id。FastAPI 的 BackgroundTasks 已经处理好继承不用担心。七、总结记住这几点就够了问题解决方案日志格式混乱、配置麻烦用 Loguru一行logger.add()搞定所有异步协程中请求 ID 互相覆盖用 ContextVars 代替threading.local每条日志都想带上 request_idlogger.patch()动态注入文件日志太大、不轮转rotationretention自动管理写日志拖慢异步性能开启enqueueTrue异步写入一句话Loguru 让你优雅地打日志ContextVars 让你在异步世界里安心追踪请求。赶紧把这两招用起来告别线上查日志时的“大海捞针”