# 缓存架构设计 本章详细描述 的缓存架构设计,包括两级缓存策略(进程内缓存 + Redis 缓存)、缓存应用场景、失效策略和代码实现。 ## 两级缓存架构 :::tip 缓存分层设计 采用两级缓存架构:第一级为进程内字典缓存(`dict_util`),第二级为 Redis 分布式缓存。两级缓存配合使用,在保证数据一致性的同时最大化查询性能。 ::: ``` ┌─────────────────────────────────────────────────────────────────┐ │ 请求处理流程 │ │ │ │ ┌──────────┐ 命中 ┌──────────┐ 命中 ┌───────────┐ │ │ │ 业务代码 │ ─────────→ │ L1 缓存 │ ────────→ │ 返回结果 │ │ │ │ │ │ (进程内) │ │ │ │ │ └──────────┘ └──────────┘ └───────────┘ │ │ │ │ 未命中 │ │ │ ▼ │ │ │ ┌──────────┐ 命中 ┌───────────┐ │ │ │ │ L2 缓存 │ ────────→ │ 写回 L1 │ │ │ │ │ (Redis) │ │ 返回结果 │ │ │ │ └──────────┘ └───────────┘ │ │ │ │ 未命中 │ │ │ ▼ │ │ │ ┌──────────┐ ┌───────────┐ │ │ │ │ 数据库 │ ────────→ │ 写回 L1 │ │ │ │ │ (MySQL) │ │ 写回 L2 │ │ │ │ └──────────┘ └───────────┘ │ │ │ │ │ ┌──────────┐ │ │ │ 缓存失效 │ → 清除 L1 + 删除 L2 │ │ └──────────┘ │ └─────────────────────────────────────────────────────────────────┘ ``` ### 缓存层级对比 | 维度 | L1 进程内缓存 | L2 Redis 缓存 | |:---|:---|:---| | **存储位置** | Python 进程内存 | Redis 服务器 | | **访问速度** | 纳秒级(字典查找) | 毫秒级(网络 I/O) | | **数据共享** | 仅当前进程 | 所有进程/实例共享 | | **持久化** | 进程重启丢失 | 持久化可配置 | | **容量限制** | 受进程内存限制 | 受 Redis 内存限制 | | **适用场景** | 高频读取、低变更数据 | 跨进程共享、分布式场景 | ## L1 缓存:进程内字典缓存 ### 实现原理 `dict_util` 模块实现了带 TTL 的进程内字典缓存。数据首次查询时从 Redis 加载到进程内存,后续查询直接从内存读取,TTL 过期后自动重新加载。 ```python # src/utils/dict_util.py import time import json from typing import Dict # 进程内缓存:{cache_key: (data, expire_timestamp)} _cache: Dict[str, tuple] = {} CACHE_TTL = 300 # 默认 5 分钟 def get_dict_map(dict_code: str) -> Dict[str, str]: """获取数据字典的 {value: label} 映射(进程内缓存 + Redis 二级缓存)""" cache_key = f"dict:{dict_code}" now = time.time() # L1: 进程内缓存命中 if cache_key in _cache: data, expire_at = _cache[cache_key] if now < expire_at: return data del _cache[cache_key] # L2: Redis 缓存 import asyncio from core.redis_client import get_redis redis = get_redis() cached = asyncio.get_event_loop().run_until_complete(redis.get(cache_key)) if cached: data = json.loads(cached) _cache[cache_key] = (data, now + CACHE_TTL) return data # 数据库查询(L3 回源) from modules.dictionary.dict_item.repository import dict_item_repo items = dict_item_repo.get_all(dict_code=dict_code) data = {str(item.value): item.label for item in items} # 写回两级缓存 _cache[cache_key] = (data, now + CACHE_TTL) asyncio.get_event_loop().run_until_complete( redis.set(cache_key, json.dumps(data, ensure_ascii=False), ex=CACHE_TTL) ) return data def invalidate_dict_cache(dict_code: str): """失效指定字典的缓存(L1 + L2)""" cache_key = f"dict:{dict_code}" # 清除 L1(进程内缓存) _cache.pop(cache_key, None) # 清除 L2(Redis 缓存) from core.redis_client import get_redis import asyncio redis = get_redis() asyncio.get_event_loop().run_until_complete(redis.delete(cache_key)) ``` ### 使用场景 | 场景 | 调用方式 | 说明 | |:---|:---|:---| | 枚举显示名 | `get_dict_map('status_type')` | 序列化时将值转为显示名 | | 表单下拉 | `get_dict_map('gender')` | 前端获取字典项列表 | | 业务校验 | `value in get_dict_map('xxx')` | 校验值是否在字典范围内 | ### 缓存失效 字典数据变更时(新增/编辑/删除字典项),主动清除两级缓存: ```python # src/modules/dictionary/dict_item/service.py class DictItemService(BaseService[DictItem]): def add(self, request, data) -> R: result = super().add(request, data) if result.code == 0: invalidate_dict_cache(data.dict_code) # 新增成功后失效缓存 return result def update(self, request, data) -> R: result = super().update(request, data) if result.code == 0: invalidate_dict_cache(data.dict_code) # 更新成功后失效缓存 return result def delete(self, ids) -> R: # 删除前先获取 dict_code(删除后无法从 data 中获取) item = self.repo.get_by_id(ids) dict_code = item.dict_code if item else None result = super().delete(ids) if result.code == 0 and dict_code: invalidate_dict_cache(dict_code) # 删除成功后失效缓存 return result ``` ## L2 缓存:Redis 缓存 ### Redis 连接管理 ```python # src/core/redis_client.py from contextvars import ContextVar from redis.asyncio import Redis _redis_ctx: ContextVar[Redis] = ContextVar('_redis_ctx') def get_redis() -> Redis: """获取当前请求的 Redis 客户端""" return _redis_ctx.get() ``` - 每个请求通过中间件注入独立的 Redis 客户端到 `contextvars` - 连接池在应用启动时创建,所有请求共享 - 异步客户端(`redis.asyncio`)与 FastAPI 异步模型无缝集成 ### 缓存应用场景 | 应用场景 | 数据结构 | Key 格式 | TTL | 说明 | |:---|:---|:---|:---|:---| | **JWT 黑名单** | String | `jwt:blacklist:{token_hash}` | Token 剩余有效期 | 登出时将 Token 加入黑名单 | | **登录失败锁** | String | `login:lock:{username}` | 600 秒 | 连续 5 次失败后锁定 | | **登录失败计数** | String (Lua) | `login:fail:{username}` | 300 秒 | 原子递增 + 首次设置 TTL | | **滑动窗口限流** | String (Lua) | `rate:{ip}:{window_index}` | 2 倍窗口时长 | 滑动窗口计数器 | | **数据字典缓存** | String (JSON) | `dict:{dict_code}` | 300 秒 | 字典项 {value: label} 映射 | | **权限列表缓存** | String (JSON) | `perm:user:{user_id}` | 变更时失效 | 用户权限字符串列表 | | **在线用户** | String | `online:user:{user_id}` | Token 有效期 | 在线用户 Token 存储 | | **验证码** | String | `captcha:{uuid}` | 120 秒 | 图形验证码文本 | ### Lua 脚本 #### 原子递增 + 过期 ```python # src/core/redis_client.py _INCR_EXPIRE_LUA = """ local count = redis.call('incr', KEYS[1]) if count == 1 then redis.call('expire', KEYS[1], ARGV[1]) end return count """ async def incr_with_expire(redis, key: str, seconds: int) -> int: """原子执行 incr,并在 key 首次创建时一并设置过期时间""" return await redis.eval(_INCR_EXPIRE_LUA, 1, key, seconds) ``` :::tip 为什么需要 Lua 脚本? `incr` 和 `expire` 分开执行时,两步之间若进程崩溃,key 会以无 TTL 的状态永久残留,导致基于 Redis 的限流/登录锁定永久封禁。Lua 脚本保证原子性,消除此风险。 ::: #### 滑动窗口限流 ```python _SLIDING_WINDOW_LUA = """ local cur = redis.call('incr', KEYS[1]) if cur == 1 then redis.call('expire', KEYS[1], ARGV[1]) end local prev = tonumber(redis.call('get', KEYS[2]) or '0') local elapsed = tonumber(ARGV[3]) - tonumber(ARGV[2]) * tonumber(ARGV[1]) local f = elapsed / tonumber(ARGV[1]) if f < 0 then f = 0 elseif f > 1 then f = 1 end local effective = prev * (1 - f) + cur return effective """ ``` 滑动窗口限流通过当前窗口与上一窗口的计数按时间比例加权,消除固定窗口边界处的 2x 突发问题。 ## 缓存策略对比 | 策略 | 说明 | 适用场景 | 应用 | |:---|:---|:---|:---| | **Cache-Aside** | 先查缓存,未命中查 DB,写回缓存 | 读多写少 | 数据字典、权限列表 | | **Write-Through** | 写操作同时更新缓存 | 数据一致性要求高 | 字典项变更时主动失效 | | **Write-Behind** | 写操作只更新缓存,异步写 DB | 写入性能要求高 | 未使用 | | **TTL 过期** | 缓存自动过期 | 允许短暂不一致 | 登录锁、限流计数 | | **主动失效** | 数据变更时主动清除缓存 | 数据一致性要求高 | 字典缓存、权限缓存 | ## 缓存失效策略 ### 主动失效 数据变更时主动清除相关缓存,保证数据一致性: ```python # 字典项变更 → 清除字典缓存 invalidate_dict_cache(dict_code) # 角色菜单变更 → 清除该角色下所有用户的权限缓存 invalidate_perm_cache(user_id) # 用户角色变更 → 清除该用户的权限缓存 invalidate_perm_cache(user_id) ``` ### TTL 自动过期 对于允许短暂不一致的数据,使用 TTL 自动过期: ``` 数据字典缓存:TTL 300 秒(5 分钟) 登录失败计数:TTL 300 秒(5 分钟) 滑动窗口限流:TTL 2 倍窗口时长 验证码:TTL 120 秒(2 分钟) ``` ### 缓存穿透防护 :::warning 缓存穿透 当查询一个不存在的数据时,缓存和数据库都不会命中,导致每次请求都穿透到数据库。对于字典查询,如果 `dict_code` 不存在,应缓存空结果(空字典),避免反复查询数据库。 ::: ```python def get_dict_map(dict_code: str) -> Dict[str, str]: # ... 缓存查询逻辑 ... items = dict_item_repo.get_all(dict_code=dict_code) data = {str(item.value): item.label for item in items} # 即使 data 为空字典,也写入缓存,防止穿透 _cache[cache_key] = (data, now + CACHE_TTL) return data ``` ## 缓存监控 Redis 缓存的运行状态可通过以下方式监控: 1. **Redis INFO**:连接数、内存使用、命中率 2. **应用日志**:缓存命中/未命中日志(DEBUG 级别) 3. **Redis TTL**:检查 key 的剩余有效期 ```bash # 查看 Redis 缓存统计 redis-cli INFO stats | grep -E "keyspace_hits|keyspace_misses" # 查看指定 key 的 TTL redis-cli TTL "dict:status_type" # 查看所有字典缓存 key redis-cli KEYS "dict:*" ``` ## 总结 采用进程内缓存(L1)+ Redis 缓存(L2)的两级缓存架构。L1 缓存提供纳秒级访问速度,适合高频读取的字典数据;L2 缓存提供跨进程数据共享,适合分布式场景。缓存失效采用主动失效 + TTL 过期的混合策略,数据变更时主动清除缓存保证一致性,允许短暂不一致的场景使用 TTL 自动过期。Lua 脚本保证 Redis 操作的原子性,防止进程崩溃导致的缓存异常。