一、 核心概念:什么是旁路缓存 (Cache-Aside)?
- ● 一句话结论:应用程序主动充当"中间商",读取数据时先看缓存有没有,有就直接拿走;没有就去查数据库,查完后再顺手往缓存里存一份,从而帮数据库挡住绝大多数的重复流量。
1. 为什么需要它?(传统方案的致命痛点)
- 如果几万个用户同时刷新首页获取"新闻分类列表",每一个请求都会直接砸向底层关系型数据库(如 MySQL)。这会导致极其可怕的 I/O 拥堵,轻则响应极度缓慢,重则数据库当场宕机。
2. 底层机制与流向拆解
前端请求 ➡️ 拦截查缓存 (Hit/Miss) ➡️ (未命中) 查数据库 ➡️ 存入缓存 ➡️ 返回数据
- 第一步(拦截查询):请求先问 Redis(内存级速度)要数据。
- 第二步(缓存命中 Hit):如果 Redis 有数据,直接打包返回,这叫"缓存命中"。请求就此结束,数据库完全感受不到这次访问!
- 第三步(缓存穿透 Miss):如果 Redis 里是空的(比如刚重启或过期了),程序才老老实实去 MySQL 里执行真实的慢查询。
- 第四步(回写兜底):MySQL 查出数据后,程序不会马上跑路,而是先把这份崭新的数据序列化,并存进 Redis 里(通常设置过期时间),然后再返回给前端。下次再有人查,就会直接走第二步。
3. 核心魔力与收益
- 数据库卸压:将 99% 的读流量拦截在内存层,响应速度从百毫秒级缩短到个位数毫秒级,抗并发能力实现千倍飞跃。
二、 核心实战代码:FastAPI 优雅集成 Redis 的"三层解耦"架构
在我们的项目中,为了避免代码极度耦合,Redis 的接入严格遵循了工业级的"三层解耦"工作流:
1. 第一层:基础设施层(初始化全局连接池)
文件路径 :config/cache_conf.py
- 核心动作 :使用
redis.asyncio(绝对不能用同步库,否则会卡死 FastAPI)初始化一个全局的单例连接池redis_client。 - 避坑参数 :必须设置
decode_responses=True。这样从 Redis 读取出来的数据自动就是可以直接看的字符串(String),而不再是底层的字节流(Bytes),彻底告别满屏的.decode('utf-8')。
完整代码实现:
python
import redis.asyncio as redis
# 核心配置项
REDIS_HOST = "localhost"
REDIS_PORT = 6379
REDIS_DB = 0
REDIS_PASSWORD = None
# 神仙参数:自动将底层字节流解码为字符串
REDIS_DECODE_RESPONSES = True
# 创建全局 Redis 异步连接池单例
redis_client = redis.Redis(
host=REDIS_HOST,
port=REDIS_PORT,
db=REDIS_DB,
password=REDIS_PASSWORD,
decode_responses=REDIS_DECODE_RESPONSES, # 必须带上
max_connections=20,
retry_on_timeout=True,
socket_keepalive=True,
)
2. 第二层:缓存封装层(翻译官与脏活累活)
文件路径 :cache/news_cache.py
- 核心动作 :绝对禁止在路由里直接写
redis_client.set,必须在这里封装专属的业务方法(如set_categories_to_cache)。 - 序列化脏活 :因为 Redis 只能存纯文本,这里必须充当翻译官:
- 存入时 :先用
jsonable_encoder()把笨重的 ORM 对象洗成字典,再用json.dumps()压成纯文本,最后调用await redis_client.setex(key, ttl, value)存入 Redis 并设置过期时间。 - 读取时 :拿到文本后,立刻用
json.loads()解冻成 Python 字典再返回。
- 存入时 :先用
完整代码实现:
python
import json
from typing import Optional, Any
from fastapi.encoders import jsonable_encoder
from TouTiaoApp.config.cache_conf import redis_client, NEWS_CACHE_TTL
# 读缓存:解冻文本还原成字典
async def get_categories_from_cache() -> Optional[Any]:
try:
data = await redis_client.get("news:categories")
if data is None:
return None
return json.loads(data)
except Exception as e:
print(f"获取分类缓存失败: {e}")
return None
# 写缓存:ORM清洗,压制纯文本并兜底 TTL 过期时间
async def set_categories_to_cache(categories: Any) -> bool:
try:
data = jsonable_encoder(categories)
await redis_client.setex(
"news:categories",
NEWS_CACHE_TTL,
json.dumps(data, ensure_ascii=False)
)
return True
except Exception as e:
print(f"设置分类缓存失败: {e}")
return False
3. 第三层:业务路由层(完美旁路拦截)
文件路径 :routers/news.py 在这一层,前台业务代码根本不需要知道 Redis 的存在,直接无脑执行优雅的四步走:
完整代码实现:
python
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from TouTiaoApp.config.db_conf import post_dbs
from TouTiaoApp.curd import news
from TouTiaoApp.cache.news_cache import get_categories_from_cache, set_categories_to_cache
from TouTiaoApp.utils.response import success_response
router = APIRouter(prefix="/api/news", tags=["news"])
@router.get("/categories")
async def get_categories(db: AsyncSession = Depends(post_dbs), skip: int = 0, limit: int = 10):
# 1. 尝试从缓存拿数据
cached_categories = await get_categories_from_cache()
if cached_categories is not None:
return success_response(data=cached_categories) # 命中缓存,绝对不碰数据库!
# 2. 没命中,老老实实查 MySQL
categories = await news.get_categories(db, skip, limit)
# 3. 将新查出来的数据回写到缓存中兜底
await set_categories_to_cache(categories)
# 4. 返回数据
return success_response(data=categories)
4. 【核心细节与避坑指南】(灵魂模块)
- 细节 1(防雪崩必设 TTL) :在封装层调用
setex存数据时,必须设置过期时间(TTL)。如果不过期会导致内存撑爆;如果不加随机抖动时间,会导致同一时刻大批缓存同时失效,海量请求瞬间击穿到数据库,引发"雪崩"。 - 细节 2(数据一致性难题) :如果后台删除了某条新闻,但 Redis 里的旧缓存还没过期,前端就会看到"已删除的幽灵新闻"。因此在写操作(如更新/删除)时,必须主动去执行
await redis_client.delete(*keys)扫清旧缓存(也就是咱们项目里的clear_news_cache()函数)。
三、 全局大串联 (终极总结)
1. 全链路协同
读请求到达 ➡️ 查 Redis 内存字典 ➡️ 若有则秒回 ➡️ 若无则下沉至 MySQL 硬盘 ➡️ 取出后反哺 Redis ➡️ 返回客户端
2. 【终极一句话速记】
"查询先去探缓存,没货再找数据库;查完别忘填缓存,过期时间要牢记!"