用 contextvars 在 FastAPI 中传递 request_id 到日志与响应头

举报
yd_250998817 发表于 2026/11/07 10:01:45 2026/11/07
【摘要】 并发请求下日志容易因共享全局变量而串号。本文用 contextvars 保存 request_id,在 FastAPI 中间件中设置并写入响应头,配合日志过滤器输出,附可运行最小示例、运行方法与线程/后台任务边界。

用 contextvars 在 FastAPI 中传递 request_id 到日志与响应头

问题场景

一个接口在并发时,日志需要带上同一个 request_id,方便把一次请求的多条日志串起来;响应头也应返回该值,便于客户端排查。常见做法是使用模块级变量:

current_request_id = None

请求入口赋值,日志里读取。单请求看似正常,但并发时多个协程会互相覆盖,日志出现串号;同步线程池中也可能读到别的请求值。根因是模块级变量是进程内共享状态,不随协程或任务隔离。

contextvars.ContextVar 提供上下文局部变量。每个异步任务有独立上下文,适合保存 request_id 这类请求级数据。下面给出不依赖全局字典的最小实现。

最小实现

保存为 main.py:

import asyncio
import logging
import uuid
from contextvars import ContextVar

from fastapi import FastAPI, Request
from starlette.middleware.base import BaseHTTPMiddleware

request_id_ctx: ContextVar[str] = ContextVar('request_id', default='-')

class RequestIdFilter(logging.Filter):
    def filter(self, record: logging.LogRecord) -> bool:
        record.request_id = request_id_ctx.get()
        return True

handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter(
    '%(asctime)s %(levelname)s request_id=%(request_id)s %(name)s %(message)s'
))
handler.addFilter(RequestIdFilter())
logger = logging.getLogger('demo')
logger.setLevel(logging.INFO)
logger.addHandler(handler)
logger.propagate = False

class RequestIdMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next):
        rid = request.headers.get('X-Request-ID') or uuid.uuid4().hex
        token = request_id_ctx.set(rid)
        try:
            response = await call_next(request)
            response.headers['X-Request-ID'] = rid
            return response
        finally:
            request_id_ctx.reset(token)

app = FastAPI()
app.add_middleware(RequestIdMiddleware)

@app.get('/hello')
async def hello():
    logger.info('handle hello')
    return {'ok': True}

@app.get('/slow')
async def slow():
    await asyncio.sleep(0.05)
    logger.info('handle slow')
    return {'ok': True}

关键点有三个。第一,中间件在请求进入时用 set 写入 request_id,并保存 token;在 finally 中 reset,避免影响后续请求。第二,日志过滤器从 ContextVar 读取值并写入 LogRecord.request_id,格式串再输出。第三,响应头在 call_next 返回后写入,客户端可直接看到同一个值。

运行与验证

安装依赖并启动:

python -m venv .venv
source .venv/bin/activate
pip install fastapi uvicorn
uvicorn main:app --port 8000

先验证单个请求:

curl -i -H 'X-Request-ID: req-a' http://127.0.0.1:8000/hello
curl -i http://127.0.0.1:8000/slow

第一条响应头应包含 X-Request-ID: req-a,日志中也出现 request_id=req-a。不传请求头时,中间件生成 UUID,响应头与日志保持一致。

再验证并发。以下脚本用标准库同时发 8 个请求,每个请求带不同 request_id:

python - <<'PY'
import concurrent.futures
import urllib.request

def call(i):
    req = urllib.request.Request(
        'http://127.0.0.1:8000/hello',
        headers={'X-Request-ID': f'req-{i}'},
    )
    with urllib.request.urlopen(req) as r:
        return r.headers.get('X-Request-ID')

with concurrent.futures.ThreadPoolExecutor(max_workers=8) as ex:
    print(list(ex.map(call, range(8))))
PY

输出应与传入值一致;查看 uvicorn 日志,每一行 request_id 应对应各自请求,不应出现多个请求共用同一值。这里验证的是日志与响应头的一致性,不涉及性能结论。

边界与限制

  1. 同步端点与自建线程:FastAPI 的同步端点可能在线程池中执行。不要假设所有运行方式和版本都会自动复制上下文;在自建线程、线程池任务中,应显式把 request_id 作为参数传入,或在任务创建时使用 contextvars.copy_context()。
  2. 后台任务与流式响应:响应返回后执行的后台任务、流式生成器后续片段,可能不在原请求上下文内。需要跨阶段使用时,显式传递 rid,避免只依赖 ContextVar。
  3. 日志配置范围:过滤器要挂在真正输出日志的 handler 上;第三方库若使用独立 logger/handler,需要单独配置,或统一走根 logger 的 handler。
  4. 外部请求头需校验:直接信任客户端传入的 X-Request-ID 有日志注入和超长头风险。生产环境应限制长度与字符集,不合法时重新生成。
  5. 不是分布式追踪替代品:本文只解决单进程内请求级日志关联。跨服务链路追踪需要专门的 trace 上下文方案,request_id 可作为辅助字段。
  6. 异常路径:若异常在中间件之外未被处理,响应头可能来不及写入;需要在异常处理器或更外层 ASGI 中间件统一补充。

按以上方式,request_id 的生命周期被限制在单次请求的上下文中,日志与响应头读取同一来源,能避免并发请求日志串号。

【版权声明】本文为华为云社区用户原创内容,未经允许不得转载,如需转载请自行联系原作者进行授权。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。