一、为什么需要全链路可观测性

线上服务就像一个大型小区,里面有很多住户。当用户报修说“我家的灯不亮了”,你不可能直接冲进每一户去查。你要拿着一张图纸,找到用户对应的那栋楼、那个单元、那间房。这个图纸就是我们的日志系统。但很遗憾,过去的日志往往是一行行文字,杂乱无章。比如某条日志写着“用户访问失败”,另一条写着“数据库连接超时”。当多个用户同时访问时,这些日志混在一起,我们根本分不清哪条日志属于哪个用户。这就像整个小区的住户都跑到楼下喊“我家出事了”,你根本听不出谁是谁。

要解决这个问题,我们需要做两件事:第一,让日志本身变得规整,也就是结构化;第二,给每个请求一个独一无二的编号,也就是请求ID。有了这两样,我们把整个调用链看清楚就有了基础。

实际上,可观测性这个概念并不神秘。它包含三个支柱:日志、指标、链路追踪。本文聚焦在日志和请求ID的配合上。当我们把日志从一行行自由文本变成一条条带字段的JSON,再给每次请求分配ID,然后让这条请求产生的所有日志都带上这个ID,那么排查问题时,只需要按ID一搜,就能把散落在各处的信息串成一条完整的线。这就是用低成本换来清晰追踪效果的办法。

下面我们动手实现。

二、准备工作:搭建一个最简单的FastAPI应用

2.1 技术栈说明

本文使用FastAPI框架,配合Python标准库的logging模块和python-json-logger库来生成结构化日志。所有示例代码基于Python 3.10环境。如果你有基本的Python开发经验,应该能很快跟上。

2.2 创建项目文件

我们先写一个最简单的FastAPI应用。创建一个main.py文件,内容如下:

# 技术栈:Python 3.10 + FastAPI + python-json-logger
# 文件:main.py

from fastapi import FastAPI

# 创建FastAPI实例,这是整个服务的入口
app = FastAPI()

# 定义根路径接口,用户访问时返回一句问候
@app.get("/")
def read_root():
    return {"message": "hello world"}

# 定义带参数的接口,模拟真正的业务逻辑
@app.get("/items/{item_id}")
def read_item(item_id: int, q: str = None):
    return {"item_id": item_id, "q": q}

# 直接使用uvicorn启动服务,这样我们不需要额外敲命令
if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8000)

这里有两个接口。现在运行这个文件,服务会启动,但日志还是默认的格式。为了让日志结构化,我们需要对日志系统进行配置。

2.3 项目结构建议

随着代码变多,建议把日志配置独立到一个文件里,比如logger_config.py。这样主程序保持干净,日志逻辑可以单独维护。接下来我们就来写这个配置。

三、结构化日志:让日志变成一列列数据

3.1 为什么要结构化

普通文本日志像手写的便签,虽然记了内容,但很难批量处理。JSON格式的日志像一份表格,每一列都有字段名,机器能读,人也能读。它能被日志平台直接解析,也方便在命令行里用jq等工具查询。

3.2 安装python-json-logger

在本文的示例中,我们需要用到一个第三方库python-json-logger。它能把标准库logging输出的日志格式化成JSON。你可以在终端中用pip安装它,这里就不展示安装命令了,毕竟那只依赖你的Python环境。

3.3 创建日志配置

在logger_config.py文件中,我们定义一个安装日志的函数。先看代码:

# 文件:logger_config.py
# 技术栈:Python 3.10 + FastAPI + python-json-logger

import logging
import sys
from pythonjsonlogger.json import JsonFormatter

def setup_logging():
    # 创建一个控制台处理器,把日志输出到标准输出
    handler = logging.StreamHandler(sys.stdout)

    # 使用JSON格式器,并且把标准字段名转换成更友好的名字
    formatter = JsonFormatter(
        fmt="%(asctime)s %(levelname)s %(name)s %(message)s",
        rename_fields={
            "asctime": "time",      # 时间字段改为time
            "levelname": "level",   # 级别字段改为level
        },
    )
    handler.setFormatter(formatter)

    # 获取根日志器,挂上处理器并设置日志级别
    root_logger = logging.getLogger()
    root_logger.addHandler(handler)
    root_logger.setLevel(logging.INFO)

在应用启动时调用setup_logging(),日志就会变成JSON格式。比如我们在一个接口里打印一条消息:

import logging

# 记录一条INFO级别的测试日志
logging.info("这是一条测试日志")

输出的日志会类似下面这样:

{"time": "2025-03-21 10:00:00,123", "level": "INFO", "name": "logger_config", "message": "这是一条测试日志"}

注意,这里的输出里包含了time、level、name、message四个字段。这就是结构化日志的最小形态。在此基础上,我们还可以继续添加更多自定义字段。

四、请求ID:给每个请求发一个唯一编号

4.1 什么是请求ID

请求ID就是一次HTTP请求的唯一标识。用户可以不了解,但后端必须知道。当用户遇到问题时,我们把响应头里的X-Request-ID告诉用户,然后运维在日志系统里一搜,所有相关日志都出来了。

4.2 如何生成请求ID

FastAPI中可以使用中间件来拦截请求。中间件可以理解成一道安检门,请求进来时先过一遍,响应出去时再过一遍。我们在这道门里生成一个UUID,把它存到Python的上下文变量中。

4.3 上下文变量的作用

Python标准库的contextvars模块提供了一种在异步任务中传递状态的机制。它类似于给每个任务发一个小口袋,任务内部任何地方都能掏出这个口袋里的东西,任务之间互不干扰。这正好用来存放请求ID。

我们来看生成请求ID的中间件代码:

# 文件:request_id.py
# 技术栈:Python 3.10 + FastAPI

import uuid
from contextvars import ContextVar
from starlette.middleware.base import BaseHTTPMiddleware

# 声明一个上下文变量,默认值是短横线,表示还没有请求ID
request_id_var: ContextVar[str] = ContextVar("request_id", default="-")

class RequestIDMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request, call_next):
        # 为当前请求生成一个全局唯一的ID
        request_id = str(uuid.uuid4())
        # 把ID放入上下文变量
        request_id_var.set(request_id)
        # 继续执行后续的路由处理
        response = await call_next(request)
        # 把ID放进响应头,方便前端或用户回传给我们
        response.headers["X-Request-ID"] = request_id
        return response

4.4 把中间件注册到应用

在main.py中添加中间件注册:

# 在main.py中导入并注册中间件
from request_id import RequestIDMiddleware

app.add_middleware(RequestIDMiddleware)

五、让请求ID自动出现在所有日志中

5.1 日志过滤器

logging模块提供了Filter类,用来在日志输出前对日志记录做手脚。我们可以创建一个过滤器,从上下文变量中取出请求ID,把它塞进日志记录里,这样JSON格式器就能把它输出出来。

代码:

# 文件:logger_config.py(在原有基础上增加Filter)
# 技术栈:Python 3.10 + FastAPI + python-json-logger

import logging
import sys
from pythonjsonlogger.json import JsonFormatter
from request_id import request_id_var

class RequestIDFilter(logging.Filter):
    """把请求ID加到每一条日志上"""
    def filter(self, record: logging.LogRecord) -> bool:
        # 从上下文变量里取出当前请求ID,存到日志记录里
        record.request_id = request_id_var.get()
        # 返回True表示日志继续正常处理
        return True

def setup_logging():
    handler = logging.StreamHandler(sys.stdout)

    formatter = JsonFormatter(
        fmt="%(asctime)s %(levelname)s %(name)s %(message)s",
        rename_fields={
            "asctime": "time",
            "levelname": "level",
        },
    )
    handler.setFormatter(formatter)

    # 把这个过滤器添加到处理器上
    handler.addFilter(RequestIDFilter())

    root_logger = logging.getLogger()
    root_logger.addHandler(handler)
    root_logger.setLevel(logging.INFO)

这样,当我们在路由函数中打印日志时,每一条日志都会带上request_id字段。比如我们在某个接口里写:

import logging

# 在某个接口里记录日志
logging.info("用户查询商品的请求已收到")

输出就会变成:

{"time": "2025-03-21 10:00:00,123", "level": "INFO", "name": "main", "message": "用户查询商品的请求已收到", "request_id": "6f9b2e5a-34d1-4f1a-9a5b-1c8e4d7f2a66"}

注意,这个request_id是从上下文变量中取出来的,和当前请求完全绑定。

下面我们把所有部分拼起来,得到一个完整的main.py:

# 文件:main.py(完整示例)
# 技术栈:Python 3.10 + FastAPI + python-json-logger

import logging
from fastapi import FastAPI
from logger_config import setup_logging
from request_id import RequestIDMiddleware

# 启动时配置日志
setup_logging()

app = FastAPI()

# 注册请求ID中间件
app.add_middleware(RequestIDMiddleware)

@app.get("/")
def read_root():
    # 打印一条普通日志
    logging.info("根接口被访问")
    return {"message": "hello world"}

@app.get("/items/{item_id}")
def read_item(item_id: int, q: str = None):
    # 打印一条业务日志
    logging.info("查询商品信息")
    return {"item_id": item_id, "q": q}

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8000)

5.3 为什么不用日志参数传递

有的同学可能想,直接在每行日志里把请求ID作为参数传进去,不就行了吗?那样虽然可行,但太麻烦。你每写一条日志都要手动带上ID,容易漏,也容易写错。用Filter自动注入,能确保所有日志都规规矩矩带上ID,而且不需要改动业务代码。这就是自动化带来的好处。

六、收集更多关键信息:路径、状态码、耗时

仅有请求ID还不够,我们经常还要知道这次请求访问了哪个路径、返回了什么状态码、花了多长时间。这些信息可以帮助我们快速定位性能问题和访问异常。

6.1 在中间件中记录耗时

我们可以在中间件里记录开始时间,在响应返回前计算耗时,然后把相关信息写进一条日志。注意,我们可以顺便把请求方法、路径、状态码都记录下来。

扩展RequestIDMiddleware如下:

# 文件:request_id.py(增强版)
# 技术栈:Python 3.10 + FastAPI + python-json-logger

import logging
import time
import uuid
from contextvars import ContextVar
from starlette.middleware.base import BaseHTTPMiddleware

request_id_var: ContextVar[str] = ContextVar("request_id", default="-")

class RequestIDMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request, call_next):
        # 生成请求ID
        request_id = str(uuid.uuid4())
        request_id_var.set(request_id)

        # 记录开始处理的时间
        start_time = time.perf_counter()

        try:
            response = await call_next(request)
        except Exception:
            # 出异常时也要记录日志,并带上请求ID
            logging.exception("请求处理异常")
            raise

        # 计算处理耗时(单位:毫秒)
        duration_ms = round((time.perf_counter() - start_time) * 1000, 2)

        # 把请求ID写入响应头
        response.headers["X-Request-ID"] = request_id

        # 记录一条访问日志,包含关键信息
        logging.info(
            "访问日志",
            extra={
                "method": request.method,
                "path": request.url.path,
                "status_code": response.status_code,
                "duration_ms": duration_ms,
            },
        )
        return response

这样,每次请求结束后,日志系统都会自动输出一条包含关键信息的JSON日志。请求ID由之前挂载的RequestIDFilter自动注入,不需要手动写。输出会类似:

{"time": "...", "level": "INFO", "name": "request_id", "message": "访问日志", "request_id": "abc-123", "method": "GET", "path": "/items/1", "status_code": 200, "duration_ms": 13.45}

6.2 业务日志中也可以包含更多字段

比如用户ID、订单号、商品ID等,都可以通过日志的extra参数来补充。但要注意,不要让日志字段无限膨胀,字段太多反而会增加维护成本。我们只需要记录对排查问题最有用的信息即可。

七、应用场景、优缺点分析

7.1 应用场景

最适合这种方案的应用场景有这些:

第一,面向用户的线上服务。当用户遇到问题,我们只需要拿到他的请求ID,就能把他那次请求的完整日志链捞出来,从入口到数据库,所有环节都能看到。

第二,微服务架构。虽然本文只演示了单个FastAPI服务,但请求ID的思想可以横向扩展。每个服务都生成自己的请求ID,或者在网关层生成一个统一的追踪ID,然后透传到下游服务,就能实现跨服务的完整链路追踪。

第三,数据分析与监控。结构化的JSON日志可以直接被日志平台解析,然后根据字段进行统计,比如按状态码统计请求量,按耗时计算P95等。

7.2 优点

这种做法的优点非常明显。首先,日志可读性和可查询性大大提升,人眼能看懂,脚本也能处理。其次,因为每条日志都有请求ID,排查问题时不再需要猜测日志之间的关联。再者,结构化日志是自动化的基础,后面接上日志采集、告警、可视化管理平台,都非常顺畅。

7.3 缺点

当然,任何方案都有代价。第一,日志量变大。JSON格式比纯文本长得多,存储成本会上升。第二,加中间件、加过滤器,多少会影响一点性能,尤其在高并发场景下。第三,如果团队没有统一的日志规范,每个人往日志里塞不同的字段,时间一长会变得混乱。所以我们在引入结构化日志时,最好提前定义好字段标准,并且控制日志输出量。

八、注意事项

8.1 中间件的执行顺序

在FastAPI中添加中间件时,注意它的执行顺序是后添加的先执行。如果我们有多个中间件,要确保请求ID中间件在最外层,这样请求ID才能覆盖内部所有环节。

8.2 异常情况下的日志输出

如果请求在处理过程中抛出了异常,中间件里的call_next会抛出异常,此时我们可能拿不到response。我们需要在中间件中捕获异常,并记录一条包含请求ID和异常信息的日志。否则异常日志容易丢失请求ID。增强版中间件里已经用try-except处理了这种情况,关键代码是这样的:

try:
    response = await call_next(request)
except Exception:
    logging.exception("请求处理异常")
    raise

注意这里的request_id依然会被自动注入,因为上下文变量在异常发生时还没有被清除。

8.3 日志脱敏

永远不要在日志中记录密码、Token、银行卡号等敏感信息。如果一定要记录某些用户数据,先做脱敏处理,比如手机号中间四位用星号代替。

8.4 不要过度记录

每个请求都打日志虽然方便,但如果日志量过大,会影响系统性能。可以根据需要调整日志级别。比如生产环境只记录WARNING及以上,或者采样记录一批请求。也可以使用异步日志处理器来减轻IO压力。

8.5 与分布式追踪系统的关系

请求ID是最朴素的追踪方案,但它只适合单服务或手工透传的场景。如果系统很复杂,建议使用专业的分布式追踪系统,例如OpenTelemetry。它会自动生成trace ID和span ID,并且能跨服务传递上下文。我们可以把请求ID当作trace ID的雏形,理解它的原理有助于后面学习更复杂的工具。

九、总结

回到最初的问题:日志杂乱无章,排查问题痛苦。我们通过两步来解决。第一步,用JSON格式器把日志变成结构化数据;第二步,用中间件为每次请求生成唯一ID,并通过上下文变量和日志过滤器,让这个ID自动出现在每条日志里。同时,我们还可以在中间件里补充路径、状态码、耗时等信息,让一条日志变得更有价值。

这套方案不需要引入额外的大件系统,只用了FastAPI和Python标准库,以及一个小小的python-json-logger。但它已经能解决大多数中小型服务的日志追踪问题。当你觉得服务越来越复杂、日志查询越来越难时,不妨先把这个基础打好,再考虑引入更完整的可观测性平台。希望这篇文章能给你带来启发,让你在排查问题时不再像无头苍蝇一样乱翻日志。