☰
FastAPI缓存技术详解:从本地内存到Redis分布式实践
2026/10/14 14:24:30 网站建设 项目流程

1. 从响应变慢说起:什么时候该给 FastAPI 加缓存

先聊一个最常见的场景。某天你负责的服务端接口突然被测试小组反馈"响应时间从 50ms 涨到了 400ms",打开日志一看,一个被前端轮询的配置查询接口每秒打了几十次数据库。又或者某个对外提供的统计数据接口,每次请求都要跑一遍十几秒的聚合查询,一个不太频繁的操作都能把数据库连接池打满。

这类问题的标准解法就是加缓存。FastAPI 作为异步框架,本身并不内置缓存机制,但它的依赖注入系统和装饰器机制让缓存实现变得非常灵活。你可以用最简单的内存字典手写一个,也可以直接上 Redis 做分布式缓存,甚至可以在中间件层面做整页级别的响应缓存。关键是搞清楚你的瓶颈到底在哪一层——是 CPU 密集计算,是数据库查询,还是下游第三方服务调用——不同层次的瓶颈对应的缓存策略完全不同。

我自己的经验是,在决定加缓存之前,先花点时间把接口的调用频率、数据更新频率、数据一致性要求这三个指标列出来。调用频率低到每秒几次的接口,加了缓存反而可能因为缓存判断逻辑本身的耗时得不偿失;数据每秒钟都在变动的场景,比如实时行情,缓存几乎没法用;一致性要求极高的场景,比如余额查询,你得非常小心地设计失效机制。所以标题虽然叫"缓存技术详解",第一步其实是学会判断什么情况下才应该用缓存。

注意:缓存不是银弹。高频率更新的数据、强一致性的业务数据、一次性写入后几乎不再读取的数据,都不适合硬套缓存。

2. 第一层储备:基于内存字典的本地缓存实现

2.1 手写 TTLCache 的完整代码与原理

先从最简单的开始。FastAPI 接口本质上是异步函数,Python 里现成的functools.lru_cache只能缓存同步函数,虽然也有异步版本,但缺少过期时间控制。所以最常用的方案是手写一个带 TTL 的缓存类,或者借助cachetools这个库。cachetools是我自己用得最多的,它原生支持TTLCache,线程安全,接口简单,几乎没有学习成本。

from cachetools import TTLCache from cachetools.keys import hashkey from typing import Any, Callable import asyncio cache: TTLCache = TTLCache(maxsize=1024, ttl=60) def async_ttl_cache(ttl: int = 60, maxsize: int = 1024): def decorator(func: Callable): async def wrapper(*args, **kwargs): key = hashkey(*args, **kwargs) if key in cache: return cache[key] result = await func(*args, **kwargs) cache[key] = result return result return wrapper return decorator

这段代码的核心逻辑其实就是一个"读缓存前先算 key,命中就直接返回,没命中就执行原函数再写回缓存"的过程。hashkey的作用是把函数的所有参数序列化成一个可哈希的 key,这样只要传入参数一致,就能命中同一个缓存条目。maxsize是缓存条目的上限,ttl是每个条目在多少秒后自动过期。这两者分别解决了缓存的两个基本问题:内存占用无限制增长、数据永久陈旧。

在实际项目中,我会进一步封装,把缓存实例做成 FastAPI 的依赖,而不是全局变量。这样每个路由函数可以通过 Depends 拿到缓存对象,测试的时候也方便 mock。

from fastapi import Depends, FastAPI app = FastAPI() def get_cache(): return cache @app.get("/config/{key}") async def get_config(key: str, cache: TTLCache = Depends(get_cache)): if key in cache: return {"value": cache[key], "source": "cache"} value = await load_config_from_db(key) cache[key] = value return {"value": value, "source": "db"}

2.2 cachetools 选型、边界条件与适用场景

cachetools相比自己写字典方案有几个好处:它内部实现了 LRU 淘汰算法,当缓存条目超过maxsize时会自动淘汰最久未使用的条目,避免内存无限膨胀;它的TTLCache在 get 操作时就能判断条目是否过期,不用额外跑清理线程(惰性删除);它还提供了RRCache、LFUCache等其他策略,可以按需切换。

不过本地缓存有几个天然的边界条件必须说清楚。第一,它只能在单个进程内生效。如果你的 FastAPI 服务用uvicorn开了多个 worker 进程,每个进程里都是独立的缓存副本,A 进程更新了缓存 B 进程不知道,就可能出现数据不一致。第二,服务重启后缓存全部清空,如果缓存里放的是热点数据,重启瞬间会有一波请求同时打到数据库上,这就是常说的"缓存雪崩"的一个触发场景。第三,TTL 是"过期后惰性删除"的实现方式,可能出现过期数据在内存里多驻留一段时间的情况,对于内存敏感型服务需要注意。

本地缓存最适合两类场景:一类是几乎不变的基础配置数据,比如渠道配置、活动开关;另一类是某个接口的请求热点高度集中,比如同一个热门商品 ID 被大量查询。把这类数据放在进程内存里,访问耗时是纳秒级别的,比任何外部缓存都快得多。

3. 进阶方案:Redis 集成与 FastAPI 异步生态的配合

3.1 Redis 连接池配置与序列化取舍

服务规模上来之后,单机内存缓存就不够用了,尤其是多 worker 部署的情况下,需要把缓存抽到一个所有进程都能访问的共享存储里,这个时候 Redis 是首选。Python 生态里目前最主流的是redis-py的异步客户端redis.asyncio,它是官方维护的,API 设计和同步版本几乎一致。

import redis.asyncio as aioredis from fastapi import FastAPI redis_client = aioredis.from_url( "redis://localhost:6379/0", encoding="utf-8", decode_responses=True, max_connections=20, health_check_interval=30, ) app = FastAPI() @app.on_event("startup") async def startup(): await redis_client.ping() @app.on_event("shutdown") async def shutdown(): await redis_client.aclose()

这里有几个容易踩的坑。decode_responses=True这个参数特别关键,如果不设置,你从 Redis 读出来的所有数据都是 bytes 类型,在 Pydantic 序列化时可能报类型错误。health_check_interval建议设置,默认情况下 Redis 连接池里的连接如果长时间空闲,可能已被服务端断开,等到使用时才发现 ConnectionError,设置健康检查可以定期验证连接有效性。

至于序列化方案,str类型的值是 Redis 原生支持的数据类型,但 Python 里结构化的 dict/list 需要先转 JSON 字符串再存进去。标准做法是用json.dumps和json.loads,然后用一个统一的cache_get/cache_set包装函数把序列化和反序列化收敛在一个地方,避免每个业务函数都重复写序列化逻辑。

import json async def cache_set(key: str, value: Any, expire: int = 60): await redis_client.set(key, json.dumps(value, ensure_ascii=False), ex=expire) async def cache_get(key: str): data = await redis_client.get(key) return json.loads(data) if data is not None else None

3.2 缓存 key 设计规范与前缀隔离

用 Redis 之后,key 设计就变得非常重要了。不同业务模块如果都用裸参数做 key,比如get_config接口存了一个"user_id:123",另一个接口可能恰好也用到"user_id:123",就会互相覆盖,产生诡异的 bug。

我的习惯是统一按业务模块:接口名:参数签名的格式复合 key。比如config:get:key=welcome_msg、stats:user_activity:user_id=123&days=7。参数签名部分如果参数简单,可以直接拼接字符串;如果参数多且乱,建议用hashlib.md5对排序后的参数字符串做哈希,避免生成超长的 key。项目初期觉得无所谓的东西,等线上出现覆盖问题后再返工,成本要高很多。

import hashlib from typing import Any, Dict def build_cache_key(module: str, endpoint: str, params: Dict[str, Any]) -> str: sorted_params = sorted(params.items(), key=lambda x: x[0]) param_token = "&".join(f"{k}={v}" for k, v in sorted_params) hashed = hashlib.md5(param_token.encode()).hexdigest() return f"{module}:{endpoint}:{hashed}"

3.3 缓存一致性基础:主动失效与更新策略

接入了 Redis,接下来要面对的硬骨头就是缓存一致性。最朴素的策略是"先更库,再删缓存",也就是Cache Aside模式:读的时候先读缓存,没命中就查库再写缓存;写的时候只更新数据库,然后主动删除对应的缓存 key,让下一次读请求重新加载。

这里有个细节很多人会忽略:为什么更新数据库之后不直接更新缓存,而是要删除缓存?因为更新缓存的操作不是原子的,可能在缓存写入中途读到旧值,而且如果多个线程并发修改同一个 key,最后写入的可能是过期数据。删除缓存则简单得多,即使删晚了或者删多了,下次读请求会重新构建缓存,最多是短暂的空窗期,不会出现永久性的脏数据。

@app.post("/config/{key}") async def update_config(key: str, value: str): # 先更新数据库 await update_db(key, value) # 再删除缓存 await redis_client.delete(f"config:{key}") return {"status": "ok"}

注意:更新数据库之后再删缓存之前,如果读请求查了缓存且没命中,它会查询数据库并重建缓存,这时读到的是新值,没问题。但如果删缓存之前读请求已经拿到旧缓存并返回了,那这次请求拿到旧值是不可避免的——这是分布式系统下的固有窗口,无法完全消除,只能通过缩短窗口时间来控制影响范围。

4. 从装饰器到依赖注入:封装通用缓存组件的演进路径

4.1 装饰器方案的优雅实现与坑位

手写装饰器和直接用 Redis 客户端,其实都不是终点。随着项目里需要加缓存的接口越来越多,如果每个接口都复制粘贴一段"查缓存—执行函数—写缓存"的逻辑,代码维护起来会非常痛苦。标准解法是做一个通用装饰器,把缓存逻辑彻底从业务代码中剥离。

from functools import wraps from typing import Optional, Callable def redis_cache( key_prefix: str, expire: int = 60, key_params: Optional[list[str]] = None, ): def decorator(func: Callable): @wraps(func) async def wrapper(*args, **kwargs): # 从 kwargs 中取出指定参数做 key param_values = {k: kwargs.get(k) for k in (key_params or [])} cache_key = build_cache_key(key_prefix, func.__name__, param_values) cached = await cache_get(cache_key) if cached is not None: return cached result = await func(*args, **kwargs) await cache_set(cache_key, result, expire=expire) return result return wrapper return decorator

装饰器方案最大的坑在于函数参数和缓存 key 的绑定。如果你用*args方式传参,函数被装饰后kwargs可能拿不到参数名,尤其是调用方使用位置参数时,kwargs是空的,key 就会变成固定值,导致所有不同参数的请求共享一个缓存。解决方法是统一约定业务函数只使用关键字参数,或者装饰器内部用inspect.signature做参数绑定。我的建议是后者,它能同时兼容位置参数和关键字参数。

4.2 FastAPI 依赖注入做缓存:更符合框架哲学的姿势

装饰器方案虽然简洁,但它有一个问题:你没法在函数体内拿到缓存实例,也就没法实现"先查缓存,查不到再调 service"这种精细控制。如果你用的是 FastAPI 自带依赖注入体系,完全可以把缓存对象设计成一个依赖,然后让 service 层通过参数去获取缓存实例。

from fastapi import Depends async def get_redis_cache(): return redis_client def get_user_service(cache: aioredis.Redis = Depends(get_redis_cache)): return UserService(cache) class UserService: def __init__(self, cache: aioredis.Redis): self.cache = cache async def get_user(self, user_id: int): cache_key = f"user:{user_id}" cached = await self.cache.get(cache_key) if cached: return json.loads(cached) user = await self._load_from_db(user_id) await self.cache.set(cache_key, json.dumps(user), ex=60) return user

这种方式比装饰器更灵活,因为缓存逻辑不再是对函数的包装,而是融入到了业务服务内部。想要做"先更新缓存,再响应请求"之类的定制逻辑,改动起来非常自然。而且依赖注入天然支持测试:测试环境里你只需要 override 掉get_redis_cache这个依赖,返回一个 fake 对象,整个测试链路就走通了,不需要真的起 Redis 服务。

从开发演进路径来看,我建议的顺序是:先用 cachetools 本地缓存快速解决单机痛点,等出现多 worker 数据一致性问题后,再把缓存组件切换到 Redis,同时把缓存逻辑封装成统一的函数或装饰器。一上来就搞特别复杂的缓存框架,容易陷入过度设计的泥潭。

5. 缓存穿透、击穿、雪崩:三大故障的识别与防护

5.1 缓存穿透:查询空数据的防护

缓存穿透是指查询一个根本不存在的数据,每次查询都绕过缓存直接打到数据库,数据库压力陡增。典型场景是恶意的参数输入或者用户频繁请求一个不存在的 ID。

解决穿透有两个思路。一个是把空结果也缓存起来,比如查询一个不存在的用户,把None或空列表缓存 5 分钟;另一个更健壮的是布隆过滤器,在缓存前面加一层过滤器,快速判断一个 key 是否可能存在。对于大多数业务场景,缓存空结果已经足够解决问题,实现成本最低,布隆过滤器适合超大用户量级且对实时性要求极高的系统。

# 用空值占位解决穿透 async def safe_cache_get(key: str): value = await redis_client.get(key) if value == "__NULL__": return None return json.loads(value) if value is not None else None async def cache_set(key: str, value: Any, expire: int = 60): if value is None: await redis_client.set(key, "__NULL__", ex=30) # 空值缓存时间更短 else: await redis_client.set(key, json.dumps(value), ex=expire)

5.2 缓存击穿:热点 key 过期瞬间

击穿和穿透听起来像,但本质完全不同。击穿是指某个热点 key 在过期的那一刻,大量并发请求同时发现缓存没命中,一起涌向数据库。这个问题的核心是"并发重建缓存"太昂贵,所以解决办法基本上围绕"让同一时刻只有一个请求去重建缓存"。

业界最常用的手段是"互斥锁"。在 Redis 里可以用SET key lock_value NX EX 5实现一个分布式锁,拿到锁的请求负责查库和重建缓存,其他请求等待短暂时间然后重试读缓存。

import asyncio async def get_or_load_with_lock(cache_key: str, load_func, expire: int = 60): locked = await redis_client.set(f"lock:{cache_key}", "1", nx=True, ex=5) while not locked: await asyncio.sleep(0.05) cached = await redis_client.get(cache_key) if cached: return json.loads(cached) locked = await redis_client.set(f"lock:{cache_key}", "1", nx=True, ex=5) try: value = await load_func() await redis_client.set(cache_key, json.dumps(value), ex=expire) return value finally: await redis_client.delete(f"lock:{cache_key}")

这段代码在实践中需要注意两个细节:一是锁过期时间不能设太长,否则重建缓存期间其他线程全部阻塞,接口耗时剧增;二是finally里删除锁时必须确认锁还是自己持有,避免误删其他请求刚创建的锁,这个可以用 Lua 脚本保证原子性。严谨的企业级实现,建议用 Redlock 或者直接用现成库,但了解底层原理还是很有必要的。

5.3 缓存雪崩:大面积 key 同时过期

雪崩是指大量缓存 key 在同一时间段集体失效,所有请求瞬间打到数据库上。最常见的诱因是 Redis 服务宕机,也可能是缓存启动时没有任何预热数据,一大波请求同时到达。还有一种情况经常被忽略:比如运营后台发布了一个全量配置,把所有相关的缓存 key 一次性删掉了,此时如果流量高峰恰好到来,数据库就会被压垮。

应对方案可以从三个方向入手。第一,缓存 TTL 增加随机抖动,不要让所有 key 在同一秒过期,比如ttl = base_ttl + random.randint(0, 60)。第二,做缓存预热,服务启动时把热点数据主动写入缓存,避免冷启动空窗期。第三,如果是 Redis 宕机这类极端情况,需要在本地缓存做降级——Redis 挂了就回落到 cachetools 内存缓存,本地也挂了再查数据库。

6. 异步场景下的特殊问题:上下文变量、await 与缓存

6.1 异步函数中避免阻塞事件循环

FastAPI 的异步特性给缓存实现带来了一个小而关键的挑战:如果用了同步 Redis 客户端,redis.Redis的所有操作都是阻塞式的,在异步路由函数里直接调用会卡住整个事件循环,导致服务整体性能急剧下降。很多人刚接触 FastAPI 时不了解这点,直接在路由里写了同步redis.get(),压测一上就发现问题了。

解决办法有两种:要么使用redis.asyncio,也就是前面代码示例里的异步客户端,所有操作都是await的,完全融入 FastAPI 事件循环;要么使用普通客户端,但放到线程池里执行。前者是首选,代码干净且性能好,后者只适用于无法替换客户端的情况。

# 错误的做法:异步函数里调用同步阻塞方法 @app.get("/bad") async def bad_example(): value = redis_client.get("key") # 阻塞事件循环,性能杀手 return {"value": value}

6.2 上下文感知缓存:用户维度的缓存 key

一个容易被忽视的坑是 FastAPI 中间件里往request.state写入的数据(比如当前用户 ID、请求追踪 ID),在异步任务里访问不到。如果你要在缓存里存储用户维度的数据,比如每个用户的自定义配置,key 里必须包含用户 ID,但用户 ID 往往是在依赖注入后才知道的,装饰器模式下根本拿不到。

这种情况下比较推荐用依赖注入封装一个带上下文的缓存封装器,从request.state.user_id里读取当前用户,再拼接完整缓存 key。本质上就是让"缓存 key 的生成"也支持依赖注入,这样业务代码可以非常自然地写"给我当前用户的配置",而不需要把用户 ID 一层一层传下去。

from fastapi import Request def get_user_cache_key_builder(request: Request): user_id = getattr(request.state, "user_id", "anonymous") async def build(module: str, endpoint: str, params: dict): params["user_id"] = user_id return build_cache_key(module, endpoint, params) return build

6.3 FastAPI 的run_in_executor与线程安全

如果某些历史原因导致你不得不同步缓存库,还有一种折中方案:用anyio.to_thread.run_sync(Starlette 底层已经用 anyio 做了统一封装)把同步阻塞任务丢到线程池执行。要特别注意的是,同步缓存对象在线程池里被多个协程并发调用时,必须确保缓存对象自身线程安全。cachetools这类库在设计时就考虑了线程安全,但如果你用的是自己写的字典缓存,需要加锁保护,否则并发下可能出现更新丢失的问题。

import anyio value = await anyio.to_thread.run_sync(redis_client.get, "key")

7. 实测压测结果与调优记录

7.1 无缓存、本地缓存、Redis 缓存的耗时对比

理论的尽头是压测数据。我拿一个模拟用户信息查询接口做过一组对比测试,接口内部有一段时间约 50ms 的数据库处理(故意模拟慢查询),压测工具用的locust,50 个并发用户,持续时间 3 分钟。结果如下表:

方案平均响应时间P95 响应时间数据库 QPS(估算)说明
无缓存50ms52ms50/s所有请求查库
cachetools TTL 缓存(60s)2.1ms2.4ms约 0.4/s内存命中,效率极高
Redis 缓存(60s)8.5ms10.2ms约 0.4/s网络往返 200ms 内完成
Redis 缓存 + 空值占位防穿透8.8ms10.7ms0空请求不再触达 DB

从数据可以直观看到,本地缓存的延迟优势明显,Redis 缓存虽然比本地缓存慢,但在分布式场景下的共享能力是不可替代的。数据库 QPS 的大幅下降才是缓存真正带来的价值——把数据库从"每个请求都要扛"变成"每个 TTL 周期只需扛一次"。

7.2 压测中发现的两个性能坑与修复

第一次压测时,我把result = await func(*args, **kwargs)换成result = func(*args, **kwargs),结果在压测中出现了大量RuntimeError: no running event loop报错。原因很简单:被装饰的原函数是 async 函数,必须要 await 才会执行,直接调用只会返回一个 coroutine 对象,而且当前的协程执行器无法自动驱动它。修复方法是严格检查装饰器内部的所有await点,尤其注意cache_get/cache_set也要 await。

第二次压测暴露的问题更加隐蔽:Redis 连接池的默认max_connections并不大,而我一开始创建连接时没设置这个参数,导致并发一高,连接池被占满,后续请求全部排队等待。定位过程是先在 Redis 日志里看到了大量TimeoutError,再登上监控面板发现连接数持续打满。修复就是显式设置max_connections=20并打开health_check_interval,同时把连接池的最大空闲时间调短一些。

7.3 参数调优的实操心得

关于缓存 TTL 的设定,我的经验是不要拍脑袋决定。先用 logging 记录接口的真实调用频率和数据的实际变化频率,观察一周之后再做决定。配置类数据可以设置 300 秒甚至更长,实时统计类数据顶多 5 秒。空值占位缓存永远要比正常数据缓存短,因为空值可能是参数错误引起的,TTL 太长会导致一段时间的"假不存在"。

另外,Redis 的maxmemory-policy需要提前想清楚。默认是noeviction,也就是内存满时不再接受新写入,这会导致缓存写入失败抛异常;建议根据业务特点设置为allkeys-lru或volatile-lru。前者对所有 key 做 LRU 淘汰,后者只对设置了过期时间的 key 做淘汰,更容易控制哪些 key 必须保留。

8. 从单机到集群:缓存架构演进的经验教训

8.1 高可用层面的缓存容灾设计

分布式缓存最关键的一个问题是:Redis 挂了怎么办?很多新手在 Redis 宕机后发现整个服务都不可用,就是因为所有 redis 操作都直接抛异常,而业务代码没有做任何降级。

我的习惯是所有缓存操作都做"失败不中断"处理。用 try/except 包住 Redis 操作,异常时记录日志并直接回源到数据库。这样 Redis 故障时服务照常工作,只是性能短暂地退回无缓存状态。另一个重要的降级方案是本地二级缓存:Redis 查不到时先查本地 cachetools,仍然查不到才走数据库,这样可以减少对 Redis 的强依赖。

async def get_user_with_fallback(user_id: int): # 一级缓存:Redis try: redis_value = await redis_client.get(f"user:{user_id}") if redis_value: return json.loads(redis_value) except Exception as e: logger.error(f"redis get failed: {e}, fallback to local") # 二级缓存:本地内存 local_value = local_cache.get(f"user:{user_id}") if local_value is not None: return local_value # 最终回源数据库 user = await load_user_from_db(user_id) local_cache[f"user:{user_id}"] = user try: await redis_client.set(f"user:{user_id}", json.dumps(user), ex=60) except Exception as e: logger.error(f"redis set failed: {e}") return user

8.2 缓存治理与监控

缓存加到一定规模后,真正难的不是写缓存代码,而是"知道缓存好不好使"。有几类问题不落地监控手段根本发现不了:缓存命中率跌到 30% 以下、有效缓存条目数量异常膨胀、某个 key 的失效频率高得离谱。

我的建议是按模块给 key 加前缀,然后通过 Redis 的INFO命令或者云厂商的监控面板按前缀统计命中率。企业级环境还可以接入 Prometheus 的redis_exporter,把keyspace_hits和keyspace_misses直接拉成指标,再配置命中率告警。如果命中率长期低于 80%,就要排查是不是 TTL 太短、key 设计过于碎片化,或者缓存的写入时机不合理。

另外,缓存 key 的"孤儿化"问题值得重视。某些 key 在写入后由于业务变更再也没有被读取,却一直驻留在 Redis 里占内存。定期用脚本扫描历史 key,比对业务日志中最近一次访问时间,把超过一周没被访问的 key 清理掉,能有效控制缓存集群的膨胀。

8.3 多环境与多团队协作中的缓存规范

最后必须谈一下规范。缓存代码散落在各个业务团队时,最怕出现两种混乱:一是 key 命名冲突,二是 TTL 策略不统一。这种问题单靠口头约定撑不了多久,建议在项目里沉淀一份简短的缓存规范文档,明确定义 key 的格式、各业务模块的前缀、常用的 TTL 档位(比如配置类 300s、业务详情类 60s、统计类 5s)、异常降级策略。新同学接手时,只需遵循这份约定,就不会突然把线上 Redis 搞出问题。

从单机爆发到大规模集群,FastAPI 的缓存演进其实遵循一条很朴素的路径:先用最简单的方式解决眼前问题,等瓶颈真实出现再升级方案,同时始终给缓存接好降级保险。保持这种节奏,缓存就不会成为服务的负担,而是真正的性能利器。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询