K 的一隅

Python FastAPI 后端实战

日志要能追:结构化日志直觉

请求级 request_id、JSON 结构化字段、标准 logging 与业务 logger 分工;如何用日志串起一次调用链。

8 分钟阅读 更新于 2026-07-28
本文目录

线上报错时用户只给订单号,你在 Kibana 里搜 ERROR 跳出上万行——同名函数、不同请求缠在一起,根本对不上是哪次 HTTP 调用。日志 的价值不在「多打几行 print」,而在**事后能 追踪**:同一请求从进 middleware 到 Service 到 SQL,共享同一 request_id;字段机器可读,才方便按 用户、路径、耗时过滤。这就是 结构化 logging 要解决的,与换不换 loguru 无关,核心是字段设计。

好的 日志 像飞行黑匣子:平时不显山露水,出事时能还原时间线。FastAPI 项目请求短、并发高,更需要约定字段,而不是依赖开发者临场发挥写 f-string。

非结构化 vs 结构化

python
logger.info(f"created order {order_id} for user {user_id}")

人眼能读,机器难稳定解析:user= 还是 user_id:结构化 用固定键:

python
logger.info(
    "order_created",
    extra={"order_id": order_id, "user_id": user_id, "amount": total},
)

若 formatter 输出 JSON,一行即一条记录,ELK、Loki、CloudWatch 都能按 order_id 精确查。追踪 依赖这些键,而不是 grep 子字符串。

消息与字段分离

结构化 实践里,message 宜短且稳定(如 order_created),变量放 extra 字段。这样在仪表盘里按 message 聚合不会 explosion;按 order_id 过滤仍精确。中文描述可以放在 detail 字段,但键名保持英文 snake_case,方便跨团队脚本。

标准库 logging 足够起步

不必一上来引入 heavy 依赖。在 lifespan 启动 时配置 root logging

python
import json
import logging
from datetime import UTC, datetime


class JsonFormatter(logging.Formatter):
    def format(self, record: logging.LogRecord) -> dict[str, object]:
        payload: dict[str, object] = {
            "ts": datetime.now(UTC).isoformat(),
            "level": record.levelname,
            "logger": record.name,
            "message": record.getMessage(),
        }
        if hasattr(record, "request_id"):
            payload["request_id"] = record.request_id
        for key in ("user_id", "path", "duration_ms"):
            if hasattr(record, key):
                payload[key] = getattr(record, key)
        return json.dumps(payload, ensure_ascii=False)


def setup_logging(level: str = "INFO") -> None:
    handler = logging.StreamHandler()
    handler.setFormatter(JsonFormatter())
    root = logging.getLogger()
    root.handlers.clear()
    root.addHandler(handler)
    root.setLevel(level)

业务模块:

python
logger = logging.getLogger(__name__)

第三方库默认 logger 名通常是包名;你可以把 uvicorn.access 的 handler 也挂到同一 JsonFormatter,避免 access 行与应用行格式分裂。loguru 等库若团队已统一,也可输出 JSON 行,原则相同:启动 时一次配置,业务只打字段。

request_id:串起一次请求

中间件(或纯 ASGI 中间件)在入口生成 UUID,写入 contextvars 与响应 Header:

python
import contextvars
import uuid
from collections.abc import Callable

from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response

request_id_ctx: contextvars.ContextVar[str] = contextvars.ContextVar("request_id", default="")


class RequestIdMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next: Callable) -> Response:
        rid = request.headers.get("X-Request-Id") or str(uuid.uuid4())
        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)


def log_extra(**fields: object) -> dict[str, object]:
    extra: dict[str, object] = {"request_id": request_id_ctx.get()}
    extra.update(fields)
    return extra

Service 里:

python
logger.info("inventory_reserved", extra=log_extra(sku=sku, qty=qty))

同一 HTTP 请求内所有 日志 带相同 request_id,排障时按 id 拉全链。追踪 再上一级可对接 OpenTelemetry trace_id,与 request_id 互填字段即可。

在中间件里记录 access 摘要

除业务 日志 外,可在 middleware 出口打一行 access 摘要:methodpathstatus_codeduration_ms,同样带 request_id。用户报「刚才下单失败」时,用 request_id(响应 Header 可回传)串起 access 行与 Service 内 order_created / domain_error 行,比只看 Uvicorn 默认 access 更齐。

contextvars 与 async

contextvarsasync def 路由里随 Task 传播,子 Task 默认继承 request_id。若用 asyncio.create_task fire-and-forget 后台作业,应显式传入 request_id 或在任务开头 request_id_ctx.set(...),否则后台 日志 会丢链。

记什么、不记什么

建议记录避免
请求 method、path、status、duration_ms完整 Authorization Header
业务 id:order_id、payment_ref密码、完整 PAN
异常类型与 message(非完整堆栈到 INFO)巨型 body

logging 级别:生产默认 INFO;DEBUG 仅短期开。Uvicorn access 日志 可与应用 日志 同一 JSON 格式,避免两套解析规则。

采样与体量

高 QPS 下全量 DEBUG 会拖垮磁盘与采集成本。可以对健康检查路径降级或不打 body;对重复成功的读接口只打 access 一行。ERROR 与 WARNING 尽量全量保留,并带 request_id 与关键 业务 id。

与异常处理配合

统一异常处理器里打 结构化 错误 日志,再返回统一 JSON 体:

python
@app.exception_handler(DomainError)
async def domain_error_handler(request: Request, exc: DomainError) -> JSONResponse:
    logger.warning(
        "domain_error",
        extra=log_extra(code=exc.code, detail=str(exc), path=request.url.path),
    )
    return JSONResponse(status_code=400, content={"code": exc.code, "detail": str(exc)})

客户端只看到 detail;你在后台用 request_id 关联 warning 行与 access 日志

未捕获的 500 在全局 handler 里 logger.exception(...),自动带 stack trace;返回给客户端的 body 仍应脱敏,不把内部路径或 SQL 透出。追踪 靠服务端 日志 字段,不靠把堆栈塞进 JSON 响应。

开发环境可读性

本地可把 JsonFormatter 换成彩色 console format;通过 Settings.debug 切换。原则不变:字段键名与生产一致,避免「本地能搜、上线不能搜」。

也可以在开发环境 human-readable、生产 JSON,但 extra 键集合保持一致。新人本地读彩色行,上线后用同一套键名写查询,迁移成本更低。

常见误解

误解 1:print 调试够用了。无级别、无 request_id、无集中采集,多进程下 stdout 交错更难 追踪

误解 2:每条 日志 打完整 stack。ERROR 带 traceback 合理;INFO 应短。堆栈交给 logger.exception 在 except 块用。

误解 3日志 在 import 时配置。应在 lifespan 启动setup_logging(),并读 Settingslog_level,与配置篇一致。

排障故事:三键定位

假设 用户 反馈「支付后订单仍 pending」。你手里有支付时间与大略路径 /orders/123/pay。操作顺序可以是:

  1. 日志 系统按 path:/orders/123/pay 与时间窗筛 access 行,取出 request_id
  2. 用同一 request_iddomain_errorpayment_callbackorder_status_changed结构化 message。
  3. 若缺上下文,再按 order_id:123request_id 关联回调与首次下单。

若没有 request_id 与固定 message 键,同一订单的多步操作会散在几十万行里,追踪 变成人工猜。日志 设计要在写代码时就想好「出事按什么键搜」。

中间件记录的 duration_ms 还能区分慢在外部支付还是慢在 SQL——配合 事务 篇看连接持有,常能发现 边界 画错导致的隐性长 事务

与中间件、异常处理的字段一致

访问 日志pathmethod 应与异常 handler 里 log_extra(path=...) 使用同一键名。Service 层打 业务 日志 时优先带 order_iduser_id 等业务主键,而不是只打中文句子——中文留给 detail 字段 optional。这样 追踪 链路从 edge 到 core 字段可对齐,仪表盘一条 JSON 解析规则吃全站。

loguru 与标准库并存

团队若已用 loguru,可用 logger.bind(request_id=...).info("event", key=value) 达到同类 结构化 效果;关键是 启动 时统一 sink 到 stdout JSON,而不是默认彩色 human format 上线。logging 标准库与 loguru 选型不冲突,冲突的是「有无固定字段、有无 request_id」——先统一字段契约,再谈库。

性能:异步路径少打

async def 路由里同步写盘 日志 会阻塞 event loop;高 QPS 时 日志 handler 应是非阻塞或批量 flush。开发环境同步 stdout 足够;生产 JSON 行到 stdout 由采集 agent 负责,应用侧避免在 hot path 打 DEBUG 字符串拼接。

关联 request_id用户

认证 通过后,middleware 或 get_current_user 下游可把 user_id bind 进 日志 context(与 request_id 并列)。排障「某 用户 连续失败」时可按 user_id 聚合,而不只靠 request_id 单条链。注意隐私合规:日志保留周期与脱敏策略由公司政策定,技术上是字段设计问题。

采样 trace 与 DEBUG

短期排查可在 Settings 里对特定 用户 或路径开 DEBUG,仍走 结构化 输出。全站长期 DEBUG 不可持续;与 配置 篇联动,用 feature flag 控制 verbose 日志,避免改代码重启。

correlation 与下游调用

Service 调外部 HTTP 时,把 request_id 放进 outbound Header(如 X-Request-Id),下游 服务 日志 可对齐全链路。这是 追踪 的延伸,不依赖特定 APM 厂商;与 导购 调外部 服务 的场景直接相关。

日志 时养成习惯:message 用英文 snake_case 事件名,业务 id 放 extra,一条一行 JSON。几个月后你会感谢现在的自己——结构化 logging 是运维友好的最低成本投资。

access 中间件一行、业务 事件一行、异常 handler 一行——同一 request_id 三条记录,足以还原绝大多数请求故事。先把这三处打通,再考虑引入 trace 系统也不迟。

日志 级别约定:生产 INFO 起;WARN 表 业务 可恢复异常;ERROR 表需人介入。统一语义后,告警规则才好配。

middleware 里记录的 status_code 与异常 handler 的 level 应对齐:用户 输入错误多用 WARN,未捕获异常用 ERROR 并 logger.exception

上线前用一次 staging 故障注入(故意抛 DomainError 与 500),确认 日志 系统能按 request_id 拉齐 access 与 业务 行——比写多少条规范更有说服力。 日志 是运行时可观测性的地基,值得在 启动 时优先配置,再打开数据库连接池,避免两套 日志 格式并存。Uvicorn 与应用共用 formatter 即可,运维只解析一种 JSON 行。

小结

日志 要服务排障:结构化 字段(JSON 行)+ request_id 追踪 一次调用;logging启动 时统一配置,业务层用 extra= 传键值。能按订单号、用户 id、request_id 三键定位,日志 才算「能追」。