FastAPI: 缓存 — Redis 加速热点查询

最后更新:2026-08-26

缓存像冰箱——常吃的食材(热点数据)放在冰箱里随手可得,不用每次都去超市(数据库)。但食材会过期(TTL),换了新货要更新(缓存失效)。

1. 你将学到


2. Alice 的真实故事

(1) 痛点:热门查询压垮数据库

PriceTracker 有 100 个热门商品占查询量的 80%,每秒被查询 8000 次。每次查询都要走 PostgreSQL,数据库 CPU 飙到 90%,P99 延迟 200ms。Charlie 说加数据库实例要 USD 500/月,但 80% 的查询结果 5 分钟内不会变。

(2) Redis 缓存的解法

用 Redis 缓存热门商品价格数据,查询先查 Redis(5ms),未命中再查 PostgreSQL(50ms)并写入 Redis,设置 5 分钟 TTL。

PYTHON
async def get_product_cached(product_id: int, db, redis):
    cached = await redis.get(f"product:{product_id}")
    if cached:
        return json.loads(cached)
    product = await db.execute(select(Product).where(Product.id == product_id))
    data = product.scalar_one_or_none()
    await redis.setex(f"product:{product_id}", 300, json.dumps(data))
    return data

(3) 收益

热门查询 80% 命中 Redis 缓存,P99 延迟从 200ms 降到 5ms,数据库 QPS 从 8000 降到 1600,Charlie 的数据库实例从 4 个降到 1 个,月省 USD 1500。


3. Redis 连接管理

(1) 异步客户端集成

▶ 示例:Redis 连接依赖

PYTHON
import redis.asyncio as aioredis
from fastapi import FastAPI, Depends

REDIS_URL = "redis://localhost:6379/0"

# Application-level Redis connection pool
redis_pool = aioredis.ConnectionPool.from_url(REDIS_URL, max_connections=20)

async def get_redis() -> aioredis.Redis:
    """Request-scoped Redis client from connection pool"""
    client = aioredis.Redis(connection_pool=redis_pool)
    try:
        yield client
    finally:
        await client.aclose()

# Startup/shutdown lifecycle
app = FastAPI()

@app.on_event("startup")
async def startup():
    global redis_pool
    redis_pool = aioredis.ConnectionPool.from_url(REDIS_URL, max_connections=20)

@app.on_event("shutdown")
async def shutdown():
    await redis_pool.disconnect()

输出:

TEXT 📖 仅展示
# 函数定义成功

(2) Redis 数据结构选型

结构 命令 适用场景 PriceTracker 用例
String SET/GET 单值缓存 商品价格缓存
Hash HSET/HGET 对象缓存 商品详情缓存
List LPUSH/LRANGE 时间序列 价格变更日志
Set SADD/SMEMBERS 去重集合 已推送价格 ID
ZSet ZADD/ZRANGE 排行榜 热门商品排行

4. Cache-Aside 模式

(1) 读写流程

100%
flowchart TD
    Request[Read Request] --> CheckRedis{Cache Hit?}
    CheckRedis -->|Yes| Return[Return Cached Data]
    CheckRedis -->|No| QueryDB[Query Database]
    QueryDB --> WriteCache[Write to Redis with TTL]
    WriteCache --> Return2[Return Data]
    
    WriteRequest[Write Request] --> UpdateDB[Update Database]
    UpdateDB --> Invalidate[Invalidate Cache]
    Invalidate --> Return3[Return Success]

▶ 示例:Cache-Aside 商品查询

PYTHON
import json
from fastapi import FastAPI, Depends
from redis.asyncio import Redis

app = FastAPI()

@app.get("/api/v1/products/{product_id}")
async def get_product(
    product_id: int,
    db: AsyncSession = Depends(get_db),
    redis: Redis = Depends(get_redis),
):
    # Step 1: Check Redis cache
    cache_key = f"product:{product_id}"
    cached = await redis.get(cache_key)
    if cached:
        return json.loads(cached)
    
    # Step 2: Cache miss - query database
    repo = ProductRepository(db)
    product = await repo.get_by_id(product_id)
    if not product:
        raise HTTPException(status_code=404, detail="Product not found")
    
    # Step 3: Write to cache with TTL
    product_data = ProductResponse.model_validate(product).model_dump()
    await redis.setex(cache_key, 300, json.dumps(product_data))  # 5 min TTL
    
    return product_data

输出:

TEXT 📖 仅展示
# 函数定义成功

▶ 示例:写操作时主动失效

PYTHON
@app.put("/api/v1/products/{product_id}")
async def update_product(
    product_id: int,
    update: ProductUpdate,
    db: AsyncSession = Depends(get_db),
    redis: Redis = Depends(get_redis),
    user=Depends(get_current_user),
):
    repo = ProductRepository(db)
    product = await repo.update(product_id, update.model_dump(exclude_unset=True))
    
    # Invalidate cache after write
    await redis.delete(f"product:{product_id}")
    
    return ProductResponse.model_validate(product).model_dump()

输出:

TEXT 📖 仅展示
# 函数定义成功

(2) 缓存键命名规范

模式 锓名 TTL 说明
实体缓存 product:{id} 5 min 单商品详情
列表缓存 products:cat:{category}:p:{page} 2 min 分页列表
计数器 count:products:cat:{category} 1 min 商品计数
热门排行 ranking:products:hot 10 min ZSet 排行
限流计数 ratelimit:{ip} 60 s API 限流

5. 缓存穿透/击穿/雪崩防护

(1) 三大缓存问题

问题 原因 防护方案
穿透 查不存在的数据,绕过缓存直达 DB 布隆过滤器、缓存空值
击穿 热点 Key 过期瞬间大量请求直达 DB 互斥锁、永不过期+异步更新
雪崩 大量 Key 同时过期 随机 TTL 偏移

▶ 示例:缓存空值防穿透

PYTHON
@app.get("/api/v1/products/{product_id}")
async def get_product_with_null_cache(
    product_id: int,
    db: AsyncSession = Depends(get_db),
    redis: Redis = Depends(get_redis),
):
    cache_key = f"product:{product_id}"
    cached = await redis.get(cache_key)
    
    if cached:
        data = json.loads(cached)
        if data.get("_null"):
            raise HTTPException(status_code=404, detail="Product not found")
        return data
    
    # Query DB
    repo = ProductRepository(db)
    product = await repo.get_by_id(product_id)
    
    if not product:
        # Cache null value with short TTL to prevent cache penetration
        await redis.setex(cache_key, 60, json.dumps({"_null": True}))
        raise HTTPException(status_code=404, detail="Product not found")
    
    product_data = ProductResponse.model_validate(product).model_dump()
    await redis.setex(cache_key, 300, json.dumps(product_data))
    return product_data

输出:

TEXT 📖 仅展示
# 函数定义成功

▶ 示例:互斥锁防击穿

PYTHON
import asyncio

async def get_product_with_lock(
    product_id: int,
    db: AsyncSession,
    redis: Redis,
):
    cache_key = f"product:{product_id}"
    lock_key = f"lock:product:{product_id}"
    
    cached = await redis.get(cache_key)
    if cached:
        return json.loads(cached)
    
    # Try to acquire lock (only one request rebuilds cache)
    lock_acquired = await redis.set(lock_key, "1", nx=True, ex=10)
    
    if lock_acquired:
        try:
            # This request rebuilds the cache
            repo = ProductRepository(db)
            product = await repo.get_by_id(product_id)
            if product:
                data = ProductResponse.model_validate(product).model_dump()
                await redis.setex(cache_key, 300, json.dumps(data))
                return data
        finally:
            await redis.delete(lock_key)
    else:
        # Other requests wait briefly and retry cache
        await asyncio.sleep(0.1)
        cached = await redis.get(cache_key)
        if cached:
            return json.loads(cached)
    
    raise HTTPException(status_code=404, detail="Product not found")

输出:

TEXT 📖 仅展示
# 函数定义成功

▶ 示例:随机 TTL 防雪崩

PYTHON
import random

def get_cache_ttl(base_ttl: int = 300, jitter: int = 60) -> int:
    """Add random jitter to TTL to prevent cache avalanche"""
    return base_ttl + random.randint(-jitter, jitter)

# Usage
await redis.setex(cache_key, get_cache_ttl(300, 60), json.dumps(data))
# TTL: 240-360 seconds (5 min ± 1 min jitter)

输出:

TEXT 📖 仅展示
# 函数定义成功

(2) 缓存一致性策略对比

策略 写操作 优点 缺点
Cache-Aside 先更新 DB,再删缓存 简单可靠 短暂不一致窗口
Write-Through 同步写缓存和 DB 一致性好 写延迟高
Write-Behind 先写缓存,异步写 DB 写性能好 可能丢数据

❓ 常见问题

Q 缓存应该存 JSON 还是 Pickle?
A 推荐 JSON。可读、跨语言、安全(Pickle 有反序列化漏洞)。用 orjson 比 json 快 3-10 倍。
Q TTL 设多久合适?
A 取决于数据变更频率。PriceTracker 价格数据 5 分钟(允许短暂不一致),商品详情 30 分钟,排行榜 10 分钟。加随机偏移防雪崩。
Q Redis 连接池大小怎么设?
A 与 FastAPI Worker 数量匹配。每个 Worker 需 5-10 个 Redis 连接。4 Worker × 10 = 40 连接,设 max_connections=50 留余量。
Q 布隆过滤器怎么实现?
Aredisbloom 模块或 Python pybloom_live 库。所有存在的商品 ID 加入布隆过滤器,查询前先检查。误判率约 1%,可接受。
Q 缓存和数据库不一致怎么办?
A Cache-Aside 模式下,短暂不一致是正常的。策略:先更新 DB 再删缓存(而非更新缓存),配合延迟双删(删缓存 → 更新 DB → 延迟再删)减少不一致窗口。
Q 如何监控缓存命中率?
A Redis 的 INFO stats 命令返回 keyspace_hitskeyspace_misses,命中率 = hits / (hits + misses)。集成到 Prometheus + Grafana 监控面板。

📖 小节


📝 作业

  1. 基础题(难度⭐):配置 Redis 异步客户端依赖,实现简单的 GET/SET 缓存端点,验证数据可以存入和读取。提示:redis.asyncio + Depends(get_redis) + setex()
  2. 进阶题(难度⭐⭐):为 PriceTracker 商品查询实现 Cache-Aside 模式:读查缓存→未命中查 DB→写缓存(TTL 5 分钟),写操作更新 DB 后删除缓存。提示:await redis.get(key) + await redis.delete(key)
  3. 挑战题(难度⭐⭐⭐):实现完整的缓存防护——空值缓存防穿透(短 TTL 60s)、互斥锁防击穿(SET lock NX EX)、随机 TTL 防雪崩(300s ± 60s),并编写测试验证防护逻辑正确。提示:redis.set(lock_key, "1", nx=True, ex=10) + random.randint()

---|

Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏