当前位置:首页 > 文章列表 > 文章 > python教程 > Python logging 怎样用 contextvars 注入请求标识

Python logging 怎样用 contextvars 注入请求标识

来源:17golang原创 2026-10-14 13:41:19 0浏览 收藏

在异步服务里,最实用的做法是把请求标识放进 ContextVar,在请求入口调用 set(),再让日志处理器上的 Filter 从当前上下文取值并写入 LogRecord。这样业务函数不用层层传递 request_id,同一事件循环中的并发任务也不会因为共享全局变量而串号。

官方文档:https://docs.python.org/3/library/contextvars.html

下面的示例使用标准库完成这件事,关键是三点:请求开始时保存 set() 返回的 token,请求结束时在 finally 中恢复;把注入逻辑放到 Handler 的过滤器上;为请求外日志准备一个安全的默认值。

请求标识从 ContextVar 进入 asyncio Task、logging Filter 和 LogRecord 的结构说明图

图1:请求标识从请求边界进入上下文,再由日志过滤器写入记录字段的原创结构说明图。

先把请求范围和日志记录分开

ContextVar 保存的是当前上下文中的值,不是一个供所有协程读写的普通全局变量。Python 文档说明它适合上下文局部状态,并且原生支持 asyncio。因此可以让请求入口负责设置值,业务层只负责记录事件,日志层再负责把值补到记录中。

变量应该声明一次,并给出默认值。默认值不是用来掩盖调用错误,而是为了让启动日志、定时任务和异常处理日志在没有请求上下文时仍然能够被格式化。

import logging
from contextvars import ContextVar

# 请求外产生的日志使用短占位符,避免 Formatter 找不到字段。
request_id_var = ContextVar("request_id", default="-")

logger = logging.getLogger("demo")
logger.setLevel(logging.INFO)

用 Handler Filter 统一补上 request_id

过滤器不一定只做丢弃判断,也可以修改传给 Handler 的 LogRecord。把过滤器安装到 Handler 上,来自子 logger 的记录也能在输出前经过同一层注入;如果只把过滤器挂到某个父 Logger 上,子 Logger 产生的记录未必会经过那个 Logger 的过滤器。

class RequestContextFilter(logging.Filter):
    def filter(self, record: logging.LogRecord) -> bool:
        # 由日志层读取当前请求上下文,不要求每个调用点传 extra。
        record.request_id = request_id_var.get()
        return True


handler = logging.StreamHandler()
# 格式化器始终能拿到 request_id,包括脱离请求的启动日志。
handler.setFormatter(logging.Formatter(
    "%(levelname)s [request_id=%(request_id)s] %(message)s"
))
handler.addFilter(RequestContextFilter())
logger.addHandler(handler)

这里的过滤器只负责添加字段,不改变日志级别,也不把请求对象本身塞进记录。记录中保留字符串形式的标识,通常更适合文本日志、集中式采集和按字段检索。

在请求入口 set,在 finally 中 reset

set() 返回的 token 代表设置前的状态。保存它并在 finally 中调用 reset(),可以保证正常返回和异常退出都恢复上下文。不要只写 set() 而省略恢复,否则复用执行资源的后续任务可能读到上一个请求的标识。

import asyncio


async def load_profile(user_id: int) -> dict:
    # 模拟会继续记录日志的下游协程。
    logger.info("加载用户资料 user_id=%s", user_id)
    await asyncio.sleep(0)
    return {"user_id": user_id}


async def handle_request(request_id: str, user_id: int) -> None:
    # token 让本次请求可以精确恢复进入前的上下文。
    token = request_id_var.set(request_id)
    try:
        logger.info("请求开始")
        await load_profile(user_id)
        logger.info("请求完成")
    except Exception:
        # 记录异常时仍在请求上下文内,便于按 request_id 检索。
        logger.exception("请求失败")
        raise
    finally:
        # 无论成功还是失败,都不能把标识留给后续工作。
        request_id_var.reset(token)


async def main() -> None:
    # 每个 Task 在创建时取得当前上下文的副本;这里分别设置请求标识。
    await asyncio.gather(
        handle_request("R-2048", 7),
        handle_request("R-2051", 8),
    )


if __name__ == "__main__":
    asyncio.run(main())

并发执行时,两条请求日志应分别带有 R-2048 和 R-2051。这里没有把 request_id 放到模块级可变字符串里,因为那种写法会让任务之间互相覆盖。

create_task 的继承和后台任务边界

asyncio 的 Task 默认会复制创建时的当前上下文,所以在请求入口设置标识后创建的子任务通常可以读到同一个值。这个行为适合一个请求内部的并发工作,例如同时查缓存和数据库。

但“能继承”不等于“生命周期自动正确”。如果创建了会在请求返回后继续运行的后台任务,就要明确它是否仍属于原请求:属于原请求时可以等待它结束;不属于原请求时应显式建立新的上下文,或者给它单独的任务标识,避免把已结束请求的标识带到长期任务里。

ContextVar 使用 set 和 reset 管理请求范围并隔离两个异步任务的原创说明图

图2:set/reset 控制请求范围,Task A 和 Task B 各自携带标识;这是上下文关系说明图,不是运行截图。

Filter、LoggerAdapter 和 LogRecord 工厂怎么选

如果目标是让一组 Handler 的输出都带上当前请求标识,Handler Filter 通常最直接:它靠近输出边界,不要求业务函数修改调用方式。若只有少量业务入口需要固定附加字段,可以使用 LoggerAdapter;但它更像调用侧的包装,第三方库发出的日志不会自动经过你的 adapter。

全局 logging.setLogRecordFactory() 也能注入字段,不过它会影响进程内所有日志记录,适合有明确统一规范的基础设施层。只为一个服务或一个 Handler 添加 request_id 时,先用过滤器更容易控制影响范围。

常见坑点

  • 不要在 Formatter 里直接假设字段一定存在;请求外日志会导致 KeyError,应在过滤器中写入默认值。
  • 不要把过滤器只挂到父 Logger,然后认为所有后代记录都会自动被它修改;输出 Handler 是更稳定的注入边界。
  • 不要在请求结束时只清空字符串;使用对应 token 的 reset() 才能恢复嵌套上下文。
  • 不要把密码、完整 Authorization 头或身份证明等敏感信息放入 request_id;标识应短小、可检索且不可承载秘密。

小结

Python logging 搭配 contextvars 的最小配方是:用 ContextVar(default="-") 保存请求标识,在请求边界 set(),把返回的 token 放进 finally,再由 Handler Filter 将 get() 的结果写到 LogRecord。这样既保留了业务日志调用的简洁性,也能适应 asyncio 的并发上下文。

相关问题

contextvars 能替代所有全局配置吗? 不能。它适合请求、任务或调用链范围的临时上下文,不适合承载需要跨请求共享的配置和连接池。

为什么不用 threading.local? 线程本地变量只按线程隔离;多个 asyncio Task 可以在同一线程交替运行,ContextVar 更符合任务上下文的边界。

为什么日志里出现 request_id=-? 说明记录产生时没有进入请求上下文,或者调用发生在 reset 之后。先判断这是不是启动任务、定时任务或真正丢失了请求边界。

版本声明
本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
Python typing.Protocol 怎样描述可替换依赖Python typing.Protocol 怎样描述可替换依赖
上一篇
Python typing.Protocol 怎样描述可替换依赖
Python sqlite3 serialize 与 deserialize 的内存数据库迁移方法
下一篇
Python sqlite3 serialize 与 deserialize 的内存数据库迁移方法
查看更多
最新文章
查看更多
课程推荐
  • 前端进阶之JavaScript设计模式
    前端进阶之JavaScript设计模式
    设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
    543次学习
  • GO语言核心编程课程
    GO语言核心编程课程
    本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
    516次学习
  • 简单聊聊mysql8与网络通信
    简单聊聊mysql8与网络通信
    如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
    500次学习
  • JavaScript正则表达式基础与实战
    JavaScript正则表达式基础与实战
    在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
    487次学习
  • 从零制作响应式网站—Grid布局
    从零制作响应式网站—Grid布局
    本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
    485次学习
查看更多
AI推荐
  • PubMedQA数据集详解:生物医学问答基准、功能与应用指南
    PubMedQA
    深入了解PubMedQA生物医学问答数据集,涵盖其核心功能、使用方法及在临床决策、药物研发等场景的应用,助力提升NLP模型性能。
    471次使用
  • H2O EvalGPT:开源LLM大模型评估与排行榜工具
    H2O EvalGPT
    H2O EvalGPT是H2O.ai推出的开源LLM评估平台,提供详细的大模型性能排行榜、行业特定基准测试及A/B测试功能,助您快速选择最适合项目的高性能大语言模型。
    530次使用
  • LMArena是什么?伯克利AI模型评估平台使用指南与功能解析
    LMArena
    LMArena是加州大学伯克利分校推出的AI模型匿名评测平台。通过盲测投票机制,用户可对比不同大模型回答并生成实时排行榜,助力开发者优化模型及用户选择最佳AI工具。
    549次使用
  • 斯坦福HELM:大语言模型Holistic Evaluation整体评估框架详解
    HELM
    深入了解斯坦福推出的HELM(Holistic Evaluation of Language Models)大模型评测体系。本文解析其核心功能、安装配置步骤及应用场景,涵盖准确性、公平性、鲁棒性等多维度指标,助力开发者全面优化语言模型性能。
    499次使用
  • MMBench详解:多模态大模型基准测试、功能特点与使用指南
    MMBench
    MMBench是由上海人工智能实验室等机构联合推出的多模态基准测试平台,提供细粒度能力评估、大规模数据集及VLMEvalKit工具。本文详细介绍其核心功能、安装使用方法及应用场景,助力开发者全面评估多模态模型性能。
    317次使用