FastAPI高并发实战:缓存、分布式限流、链路日志三位一体架构

  接口上线后,最先被业务压垮的往往不是业务逻辑本身,而是三类横切能力:重复读打穿数据库恶意刷接口拖垮限流阈值日志散落导致线上问题无法复盘 。缓存负责把热点读请求挡在Redis前。限流负责按IP或用户Token控制调用频率。结构化日志与请求链路ID负责在故障发生后,用同一条request_id把整条调用链串起来。

一、整体架构,缓存、限流、日志如何叠在请求链上

  一次请求进入FastAPI后,大致顺序是:

  RequestContextMiddleware最外层,尽早生成X-Request-ID,写入contextvars,后续所有日志自动带上。SlowAPIMiddleware在进入路由前做限流计数,超限直接429。路由内部的读接口通过装饰器查Redis,未命中再回源。异常无论是业务HTTPException还是未捕获的RuntimeError,都会进入统一处理器或中间件兜底,写入带request_id的错误日志。

工程目录按职责拆分:

bash 复制代码
demo/
├── logs/app.log              # 按天切割的结构化日志
├── app/
│   ├── core/config.py        # Redis URL、限流默认值、日志目录
│   ├── core/logging_setup.py # JSON格式化、TimedRotatingFileHandler
│   ├── core/lifespan.py      # 启动时init Redis
│   ├── cache/redis_client.py # redis.asyncio/fakeredis
│   ├── cache/decorators.py   # 结果缓存装饰器
│   ├── rate_limit/limiter.py # slowapi Limiter、IP/Token key_func
│   ├── middleware/request_context.py
│   ├── exceptions/handlers.py
│   └── api/routes.py
├── run.py
└── scripts/smoke_test.py

二、异步Redis集成(redis.asyncio)

2.1 为什么用redis.asyncio而不是同步redis

  旧版独立包aioredis已合并进官方redis库的redis.asyncio命名空间。在async def路由里若使用同步redis.Redis().get(),会阻塞事件循环,与上一篇同步SQLAlchemy的危害同一类问题。异步客户端每次IO都await,多请求才能真正并发。

2.2 初始化与关闭

python 复制代码
async def init_redis() -> Any:
    if settings.use_fake_redis:
        from fakeredis import aioredis as fake_aioredis
        _redis = fake_aioredis.FakeRedis(decode_responses=True)
        await _redis.ping()
        return _redis

    from redis import asyncio as aioredis
    _redis = aioredis.from_url(
        settings.redis_url,
        encoding="utf-8",
        decode_responses=True,
        socket_connect_timeout=3,
        socket_timeout=3,
    )
    await _redis.ping()
    return _redis

  这段代码在lifespan启动阶段只执行一次,把客户端挂到进程级单例。decode_responses=Trueget直接返回str而不是bytes,后续json.loads更方便。ping()用于启动失败早暴露(连不上Redis时进程直接起不来,比运行时才500更好排查)。PS:我创建了一个假的redis使用的是内存。

2.3 缓存读写封装

python 复制代码
def cache_key(*parts: str) -> str:
    return ":".join([settings.cache_key_prefix, *[str(p) for p in parts]])

async def cache_get(key: str) -> Any | None:
    raw = await get_redis().get(key)
    if raw is None:
        return None
    return json.loads(raw)

async def cache_set(key: str, value: Any, ttl: int | None = None) -> None:
    ttl = ttl if ttl is not None else settings.cache_default_ttl
    await get_redis().set(key, json.dumps(value, ensure_ascii=False, default=str), ex=ttl)

  cache_key强制带上业务前缀,避免多项目共用一个Redis DB时键冲突,这是分布式缓存 落地时最容易忽略的细节。值统一JSON序列化,TTL用ex=秒级过期,防止热点键永久占用内存。所有操作都是await,不会堵事件循环。

三、接口结果缓存

3.1 显式键装饰器

python 复制代码
def product_cache_key(product_id: int) -> str:
    return cache_key("cache", "product", str(product_id))

@cached_by_key(product_cache_key, ttl=30)
async def get_product_cached(product_id: int) -> dict[str, Any]:
    return await _load_product_from_db(product_id)

  装饰器内部逻辑,先cache_get,命中则把source标成cache并直接返回。未命中则执行原函数,再cache_set写入30秒TTL。键形如 demo:cache:product:1可读、可手动删除,比把整个args哈希成digest更利于运维。

python 复制代码
async def wrapper(*args, **kwargs):
    key = key_builder(*args, **kwargs)
    hit = await cache_get(key)
    if hit is not None:
        hit = {**hit, "source": "cache"}
        return hit
    result = await func(*args, **kwargs)
    await cache_set(key, result, ttl=ttl)
    return result

  路由侧只负责HTTP:

python 复制代码
@router.get("/api/v1/products/{product_id}")
@limiter.limit("30/minute")
async def get_product(request: Request, product_id: int) -> ProductOut:
    data = await get_product_cached(product_id)
    return ProductOut.model_validate(data)

  第一次请求source=db且耗时约200ms+;第二次source=cache且毫秒级返回。DELETE /api/v1/products/{id}/cache调用invalidate_product删键,下一次再次回源,这是缓存更新策略里最简单的主动失效。

sequenceDiagram participant C as 客户端 participant API as 路由 participant Cache as Redis participant DB as 模拟 DB C->>API: GET /products/1 API->>Cache: GET key Cache-->>API: miss API->>DB: await sleep(0.2) 查库 DB-->>API: product API->>Cache: SET key EX 30 API-->>C: source=db C->>API: GET /products/1 API->>Cache: GET key Cache-->>API: hit API-->>C: source=cache

3.2 分布式缓存的含义

  分布式在这里指,多个FastAPI worker/多台机器共享同一Redis ,缓存结果全局可见。单机内存dict只在当前进程生效,多worker会各自击穿DB。把存储换成Redis URL后,任意实例写入的键,其他实例立刻可读。限流同理,下文slowapi在真实Redis模式下用同一URL作storage,多实例共享计数。

四、slowapi限流:IP与用户Token

4.1 Limiter初始化

python 复制代码
limiter = Limiter(
    key_func=get_remote_address,
    default_limits=[settings.default_rate_limit],  # 默认 100/minute
    storage_uri=_storage_uri(),
)

def _storage_uri() -> str:
    if settings.use_fake_redis:
        return "memory://"
    return settings.redis_url

  Limiter是slowapi的核心。default_limits作用于未单独声明限流的接口。storage_uri决定计数存在哪,单机demo用memory://,多worker生产用Redis,这样限流也是分布式的key_func默认按客户端IP取键。

必须在应用上挂载:

python 复制代码
app.state.limiter = limiter
app.add_middleware(SlowAPIMiddleware)

  被 @limiter.limit(...) 装饰的路由必须 声明 request: Request 参数,slowapi 才能从中取 IP / Header。

4.2 按IP限流

python 复制代码
def _ip_key(request: Request) -> str:
    host = request.client.host if request.client else "unknown"
    forwarded = request.headers.get("X-Forwarded-For")
    if forwarded:
        host = forwarded.split(",")[0].strip()
    return f"ip:{host}"

@router.get("/api/v1/ip-limited")
@limiter.limit("5/minute", key_func=_ip_key)
async def ip_limited(request: Request) -> dict[str, str]:
    return {"message": "IP限流接口通过", ...}

  5/minute表示同一key每分钟最多5次。生产环境在反代后必须解析X-Forwarded-For,否则所有人看起来都是Nginx的IP,限流会误伤整站。前缀ip: 只是为了和Token限流的键空间隔离,避免碰撞。 连续第6次请求返回429,响应体带request_id,并建议客户端看Retry-After

4.3 按用户Token限流

python 复制代码
def get_user_token_or_ip(request: Request) -> str:
    auth = request.headers.get("Authorization", "")
    if auth.lower().startswith("bearer "):
        token = auth[7:].strip()
        if token:
            return f"token:{token}"
    return f"ip:{get_client_ip(request)}"

@router.get("/api/v1/user-limited")
@limiter.limit("3/minute", key_func=get_user_token_or_ip)
async def user_limited(request: Request, token: str = Depends(_require_token)):
    return {"message": "用户 Token 限流接口通过", "token": token}

  登录用户用Bearer Token作为限流维度:同一办公网出口IP下的不同用户互不影响。未登录则回退到 IP。业务上先校验Token是否合法(401),再计数是否超限(429),顺序不能反,否则攻击者可用无效Token刷爆限流桶。

五、结构化日志与请求链路ID

5.1 contextvars传递request_id

python 复制代码
request_id_ctx: ContextVar[str] = ContextVar("request_id", default="-")
client_ip_ctx: ContextVar[str] = ContextVar("client_ip", default="-")

  ContextVar是asyncio友好的请求级线程局部变量。中间件在入口set,出口reset,同一请求内任意深度的logger.info都能读到当前request_id,无需把id当作参数层层传递。

5.2 JSON格式化与按天切割

python 复制代码
class JsonFormatter(logging.Formatter):
    def format(self, record: logging.LogRecord) -> str:
        payload = {
            "ts": datetime.now(timezone.utc).isoformat(),
            "level": record.levelname,
            "logger": record.name,
            "msg": record.getMessage(),
            "request_id": request_id_ctx.get(),
            "client_ip": client_ip_ctx.get(),
        }
        ...
        return json.dumps(payload, ensure_ascii=False)

file_handler = TimedRotatingFileHandler(
    filename=str(log_path),
    when="midnight",
    interval=1,
    backupCount=14,
    encoding="utf-8",
)
file_handler.suffix = "%Y-%m-%d"

  每行一条JSON,便于Filebeat/Loki/ELK采集。字段固定包含request_id,线上只要用户反馈一个ID,就能在日志平台精确过滤整条链路。TimedRotatingFileHandler在每天零点切割,保留14天,文件落在demo/logs/app.log,历史文件名带日期后缀。   控制台与文件使用同,Formatter,本地开发与服务器行为一致。

5.3 中间件写入访问日志

python 复制代码
class RequestContextMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request, call_next):
        request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4())
        ...
        response = await call_next(request)
        response.headers["X-Request-ID"] = request_id
        response.headers["X-Process-Time"] = f"{elapsed_ms:.2f}ms"
        logger.info("request completed", extra={
            "path": request.url.path,
            "method": request.method,
            "status_code": response.status_code,
            "duration_ms": round(elapsed_ms, 2),
        })
        return response

  客户端可传入自己的X-Request-ID(网关追踪场景)。未传则服务端生成UUID。响应头回传同一ID,方便前后端对齐。duration_ms写入日志后,慢请求可直接按字段过滤,不必再grep文本。

sequenceDiagram participant C as 客户端 participant M as RequestContext participant R as 路由 participant L as logs/app.log C->>M: 请求 (可选 X-Request-ID) M->>M: set contextvars M->>R: call_next R-->>M: Response M->>L: JSON 行含 request_id / duration_ms M-->>C: 响应头带 X-Request-ID

六、统一异常日志捕获

6.1 分类处理

python 复制代码
def register_exception_handlers(app: FastAPI) -> None:
    @app.exception_handler(RateLimitExceeded)
    async def rate_limit_handler(request, exc):
        logger.warning("rate limit exceeded", extra={...})
        return JSONResponse(status_code=429, content={
            "detail": "请求过于频繁,请稍后再试",
            "request_id": request_id_ctx.get(),
        })

    @app.exception_handler(HTTPException)
    async def http_exception_handler(request, exc):
        # 4xx info / 5xx error
        return JSONResponse(status_code=exc.status_code, content={
            "detail": exc.detail,
            "request_id": request_id_ctx.get(),
        })

    @app.exception_handler(Exception)
    async def unhandled_exception_handler(request, exc):
        logger.exception("unhandled exception", extra={...})
        return JSONResponse(status_code=500, content={
            "detail": "服务器内部错误",
            "request_id": request_id_ctx.get(),
        })

  限流、校验错误、业务HTTP异常、未知异常分别打不同级别日志,但响应里都带request_id,方便用户报障。logger.exception会自动附带堆栈,写入JSON的exc_info字段。

6.2 与BaseHTTPMiddleware的兼容

  Starlette的BaseHTTPMiddleware 有时会把路由里抛出的异常重新抛到外层,导致exception_handler(Exception) 捕不到。本demo在RequestContextMiddleware里对未处理异常做兜底:记录request failed日志并返回统一500 JSON。

七、请求全链路串联示意

总结

  高并发接口的三板斧可以落成三条工程原则:读多写少的结果进Redis,并带前缀与TTL限流键按业务选IP或Token,多实例时存储也必须是Redis每条日志都是带request_id的JSON,并按天落入文件demoredis.asyncioslowapicontextvars+TimedRotatingFileHandler把这三件事串在同一请求链上,默认fakeredis可离线演示,改环境变量即可上真Redis。

  排查线上问题的最短路径,往往是:用户给出响应头里的X-Request-ID、在日志平台按该字段过滤、看到访问耗时、是否 429、是否缓存命中、异常堆栈。把这套体系建好,比事后在服务器上grep散乱文本高效得多。

相关推荐
fengxinzi_zack6 小时前
年统计与最长连续天数:数据聚合可视化
后端
全堆鸿蒙6 小时前
aboutToAppear 生命周期:路由参数获取与数据初始化
后端
2501_931803756 小时前
Go 反射:原理、核心机制与实践指南
开发语言·后端·golang
程序员爱钓鱼6 小时前
Rust if let 与 while let 详解:简化模式匹配
前端·后端·rust
爸爸6196 小时前
UIAbility 生命周期在日记 App 中的实践
后端·华为·harmonyos·鸿蒙系统
java1234_小锋14 小时前
FastAPI python web开发- 集成SQLAlchemy ORM 操作数据库
fastapi
大阳光男孩15 小时前
Spring Boot 整合 Debezium 实现 MySQL 增量数据监听(嵌入式版)
spring boot·后端·mysql
皮皮林55116 小时前
ThreadLocal 不香了?ScopedValue才是王道?
后端
X-⃢_⃢-X16 小时前
一、第一阶段:认识 Spring Boot
java·spring boot·后端