11.Redis与Cache-Aside旁路缓存策略

一、 核心概念:什么是旁路缓存 (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. 【终极一句话速记】

"查询先去探缓存,没货再找数据库;查完别忘填缓存,过期时间要牢记!"

相关推荐
catino16 分钟前
spring-Bean
java·后端·spring
深入云栈28 分钟前
Netty 4.2.x 源码深度解析 (十一):EventExecutor 业务线程池 —— 防止 IO 线程阻塞的并发模型
java·后端
柠檬味拥抱36 分钟前
鸿蒙 AI 对话实战:用蓝耘 MaaS 给礼物 App 装上「多轮对话选礼大脑」
后端
唐青枫42 分钟前
别再把 build.zig 当配置文件:Zig 构建系统从零到实战
后端
Naylor43 分钟前
只借不占:Rust 的引用与借用
后端·rust
大勇前进1 小时前
Python装饰器、生成器、上下文管理器:3个必会的"魔法"机制
后端
Go_error1 小时前
Fyne:让 Go 开发者也能玩转 GUI
后端·go
用户0795675043141 小时前
Python + OpenCV 识别 GIF 中旋转最快的图形:基于时间周期的动态识别
后端
长大19881 小时前
Python+Flask 1小时搭建个人博客:比WordPress轻量10倍
后端