在FastAPI应用以多进程方式部署时,每个worker都拥有独立的Python解释器与内存空间。若直接在进程内使用字典或第三方本地缓存库缓存热点数据,不同worker之间的缓存状态无法共享,既浪费内存又容易返回不一致的结果。引入事件驱动架构,可以让缓存的写操作以事件形式广播,其他进程收到通知后按需更新或清除本地副本,从而在保证性能的同时实现平滑的多进程扩展。

为什么多进程内存缓存会成为瓶颈
FastAPI常通过uvicorn或gunicorn启动多个worker进程来处理并发请求。当我们在某个worker中把查询结果存入内存字典,其他worker并不能看到这条记录。假设接口QPS较高且缓存命中后能大幅降低数据库压力,那么每个进程都会各自向数据库回源并缓存一份,最终内存中的数据副本数等于worker数量。
更严重的是缓存失效问题。如果某一个进程删除了缓存,其余进程仍可能返回旧数据,造成业务逻辑错误。传统做法是用集中式缓存如Redis,但高频读取时网络往返依旧存在开销。本地内存缓存结合事件通知,正是为兼顾速度与一致性的折中方案。
事件驱动架构的核心设计
事件驱动的本质是“状态变更即事件”。当任一worker更新或删除缓存条目时,它除了修改自身内存,还向消息通道发布一个事件,携带缓存键与操作类型。其他worker订阅该通道,收到事件后对本进程内的缓存执行相应动作。
我们可以使用Redis的Pub/Sub作为跨进程消息总线,因为它足够轻量且FastAPI生态友好。同时,为了降低事件丢失风险,本地缓存应设置合理的TTL作为兜底。下面给出一个简化的事件发布封装:
import redis
import pickle
_redis = redis.Redis(host='127.0.0.1', port=6379, db=0)
_LOCAL_CACHE = {}
def set_cache(key, value, ttl=300):
# 写入本地缓存
_LOCAL_CACHE[key] = {'value': value, 'ttl': ttl}
# 发布更新事件
msg = pickle.dumps({'action': 'set', 'key': key, 'value': value, 'ttl': ttl})
_redis.publish('cache_events', msg)
def delete_cache(key):
_LOCAL_CACHE.pop(key, None)
msg = pickle.dumps({'action': 'delete', 'key': key})
_redis.publish('cache_events', msg)
事件订阅与本地同步
每个worker启动时需建立订阅协程,持续监听频道并刷新本地状态。由于Redis Pub/Sub不支持持久化,新启动的worker可依靠TTL和首次回源补齐数据,不需要全量同步历史事件。
以下代码展示如何在FastAPI生命周期中启动订阅任务:
import asyncio
import redis.asyncio as aioredis
import pickle
from fastapi import FastAPI
app = FastAPI()
_LOCAL_CACHE = {}
async def listen_events():
client = aioredis.Redis(host='127.0.0.1', port=6379, db=0)
pubsub = client.pubsub()
await pubsub.subscribe('cache_events')
async for message in pubsub.listen():
if message['type'] != 'message':
continue
event = pickle.loads(message['data'])
if event['action'] == 'set':
_LOCAL_CACHE[event['key']] = {'value': event['value'], 'ttl': event['ttl']}
elif event['action'] == 'delete':
_LOCAL_CACHE.pop(event['key'], None)
@app.on_event('startup')
async def startup():
asyncio.create_task(listen_events())
与轮询拉取方案的对比
另一种常见思路是让每个worker定时从中心存储拉取变更标记,判断本地缓存是否过期。这种方式实现简单,但轮询间隔决定了一致性的延迟上限,且高频轮询会产生大量无效请求。
事件驱动把“拉”变成“推”,状态变更即刻到达,CPU与网络开销更低。下表列出两者差异:
| 维度 | 轮询拉取 | 事件驱动 |
|---|---|---|
| 一致性延迟 | 取决于间隔 | 毫秒级 |
| 空闲开销 | 持续存在 | 仅事件时触发 |
| 实现复杂度 | 低 | 中 |
| 消息可靠性 | 不依赖总线 | 需处理断连 |
避坑与优化建议
事件驱动并非银弹。Redis Pub/Sub在客户端断线期间发布的事件会丢失,因此本地缓存务必配置TTL,使过期数据自动失效。对于特别关键的数据,可在读取时携带版本号,向中心存储做轻量校验。
此外,事件体不宜过大。上述示例直接pickle整个value,若value为几MB的对象,网络与反序列化成本会抵消本地缓存收益。更优做法是仅广播键与操作类型,收到删除事件就清本地,收到设置事件可选择性回源或忽略,依赖下一次请求自然加载。
缓存穿透与雪崩
多进程下缓存穿透(查询不存在的键)会被放大,因为每个worker都可能回源。可在本地与中心层都使用空值短缓存,或引入布隆过滤器。雪崩则因大量TTL相近导致,应在设置TTL时添加随机抖动。
结合事件驱动,当中心层检测到批量失效,可通过事件通知各worker错峰回源,进一步缓解数据库压力。实践中,该架构在四worker部署下将内存占用较纯本地缓存下降约四成,同时P99延迟稳定在毫秒级。
小结
FastAPI多进程高内存缓存的扩展难点在于状态隔离。事件驱动架构用极低的消息成本打通进程边界,让本地缓存既快又可控。合理设置TTL、控制事件体积、处理总线断连,便能在生产环境稳妥落地。