优化更新

This commit is contained in:
2026-09-14 08:34:30 +08:00
parent 23bd737b82
commit 44d21c9ff8
13 changed files with 383 additions and 251 deletions
+128 -85
View File
@@ -46,107 +46,142 @@
| **容量限制** | 受进程内存限制 | 受 Redis 内存限制 |
| **适用场景** | 高频读取、低变更数据 | 跨进程共享、分布式场景 |
## L1 缓存:进程内字典缓存
## 进程内字典缓存
### 设计决策
`dict_util` 采用**纯进程内 TTL 缓存**(无 Redis L2),原因:
1. 字典数据几乎不变,读写均落在同步 service 路径,无法使用请求级异步 Redis
2. 字典/字典项增删改时由 service 主动失效,TTL 仅作兜底
3. 多 worker 部署下各进程缓存最多在 TTL 内短暂不一致,属可接受范围
### 实现原理
`dict_util` 模块实现了带 TTL 的进程内字典缓存。数据首次查询时从 Redis 加载到进程内存,后续查询直接从内存读取,TTL 过期后自动重新加载。
```python
# src/utils/dict_util.py
import logging
import time
import json
from typing import Dict
# 进程内缓存:{cache_key: (data, expire_timestamp)}
_cache: Dict[str, tuple] = {}
from modules.dictionary.dict.models import Dict
from modules.dictionary.dict_item.models import DictItem
from modules.dictionary.dict.repository import dict_repo
from modules.dictionary.dict_item.repository import dictitem_repo
CACHE_TTL = 300 # 默认 5 分钟
logger = logging.getLogger(__name__)
# 字典缓存 TTL(秒):兜底过期时间,实际变更靠主动失效即时生效
_CACHE_TTL = 60
# 进程内字典缓存:{code: (过期时间戳, 字典项列表)}
_dict_cache: dict = {}
def get_dict_map(dict_code: str) -> Dict[str, str]:
"""获取数据字典的 {value: label} 映射(进程内缓存 + Redis 二级缓存)"""
cache_key = f"dict:{dict_code}"
def get_dict_items(code):
"""按字典编码读取字典项列表 [{label, value, sort}, ...],字典不存在返回空列表"""
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
hit = _dict_cache.get(code)
if hit and hit[0] > now:
return hit[1]
items = _load_dict_items(code)
_dict_cache[code] = (now + _CACHE_TTL, items)
return items
def invalidate_dict_cache(dict_code: str):
"""失效指定字典的缓存(L1 + L2)"""
cache_key = f"dict:{dict_code}"
def get_dict_map(code):
"""按字典编码读取 {value: label} 映射(value 为字典项存储值,字符串类型)"""
return {item['value']: item['label'] for item in get_dict_items(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))
def get_dict_label(code, value, default=None):
"""取单个字典项名称:value 自动转字符串匹配存储值,查不到返回 default"""
return get_dict_map(code).get(str(value), default)
def invalidate_dict(code):
"""清除指定字典编码的缓存(None/未缓存时安全无操作)"""
if code:
_dict_cache.pop(code, None)
def invalidate_dicts_by_ids(dict_ids):
"""按字典ID批量清除缓存:字典项变更后据 dict_id 反查 code 再失效"""
if not dict_ids:
return
try:
rows = dict_repo.filter(Dict.id.in_(dict_ids), Dict.is_delete == 0).all()
for row in rows:
invalidate_dict(row.code)
except Exception as e:
logger.warning("字典缓存失效失败(将按 TTL 自动过期): %s", e)
def _load_dict_items(code):
"""从数据库读取字典项(未删除、按 sort+id 升序),字典不存在返回空列表"""
dict_obj = dict_repo.get_one(code=code)
if not dict_obj:
return []
rows = dictitem_repo.filter_by(dict_id=dict_obj.id).all()
rows.sort(key=lambda item: (item.sort, item.id))
return [{'label': item.name, 'value': item.value, 'sort': item.sort} for item in rows]
```
### 公开接口
| 函数 | 签名 | 说明 |
|:---|:---|:---|
| `get_dict_items` | `(code) → [{label, value, sort}]` | 字典项列表(供前端下拉) |
| `get_dict_map` | `(code) → {value: label}` | 值→显示名映射(供后端翻译) |
| `get_dict_label` | `(code, value, default) → str` | 单值翻译 |
| `invalidate_dict` | `(code) → None` | 按字典编码清除缓存 |
| `invalidate_dicts_by_ids` | `(dict_ids) → None` | 按字典 ID 批量清除缓存 |
### 使用场景
| 场景 | 调用方式 | 说明 |
|:---|:---|:---|
| 枚举显示名 | `get_dict_map('status_type')` | 序列化时将值转为显示名 |
| 表单下拉 | `get_dict_map('gender')` | 前端获取字典项列表 |
| 表单下拉 | `get_dict_items('gender')` | 前端获取字典项列表 |
| 单值翻译 | `get_dict_label('status', 1)` | 将 1 转为 "正常" |
| 业务校验 | `value in get_dict_map('xxx')` | 校验值是否在字典范围内 |
### 缓存失效
字典数据变更时(新增/编辑/删除字典项),主动清除两级缓存:
字典数据变更时(新增/编辑/删除字典项),通过 `invalidate_dicts_by_ids` 按字典 ID 批量清除:
```python
# src/modules/dictionary/dict_item/service.py
class DictItemService(BaseService[DictItem]):
def add(self, request, data) -> R:
repo = dictitem_repo
model = DictItem
def add(self, request, data):
"""新增字典项,新增后失效所属字典的缓存"""
result = super().add(request, data)
if result.code == 0:
invalidate_dict_cache(data.dict_code) # 新增成功后失效缓存
if data.dictId:
invalidate_dicts_by_ids({data.dictId})
return result
def update(self, request, data) -> R:
def update(self, request, data):
"""更新字典项,更新后失效新旧所属字典的缓存"""
old = self.repo.get_by_id(data.id) if data.id else None
result = super().update(request, data)
if result.code == 0:
invalidate_dict_cache(data.dict_code) # 更新成功后失效缓存
dict_ids = {data.dictId} if data.dictId else set()
if old and old.dict_id:
dict_ids.add(old.dict_id)
invalidate_dicts_by_ids(dict_ids)
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
def delete(self, ids):
"""删除字典项,删除后失效所属字典的缓存"""
from utils.request import parse_id_list
id_list = parse_id_list(str(ids))
dict_ids = set()
if id_list:
for row in dictitem_repo.filter(DictItem.id.in_(id_list), DictItem.is_delete == 0).all():
if row.dict_id:
dict_ids.add(row.dict_id)
result = super().delete(ids)
if result.code == 0 and dict_code:
invalidate_dict_cache(dict_code) # 删除成功后失效缓存
invalidate_dicts_by_ids(dict_ids)
return result
```
@@ -178,11 +213,15 @@ def get_redis() -> Redis:
| **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 秒 | 图形验证码文本 |
| **滑动窗口限流** | String (Lua) | `rl:{ip}:{window_index}` | 2 倍窗口时长 | 滑动窗口计数器(前缀可配置) |
| **权限列表缓存** | String (JSON) | `perm:list:{user_id}:{version}` | 7200 秒 | 用户权限字符串列表(版本号策略) |
| **菜单缓存** | String (JSON) | `perm:menu:{user_id}:{version}` | 7200 秒 | 用户菜单树(版本号策略) |
| **用户有效状态** | String | `user:active:{user_id}` | 300 秒 | login_required 每请求校验 |
| **验证码** | String | `captcha:{uuid}` | 300 秒 | 图形验证码文本 |
:::tip 字典缓存不在 Redis 中
数据字典(`dict_util`)采用纯进程内 TTL 缓存,不经过 Redis。原因是字典读取落在同步 service 路径,无法使用请求级异步 Redis 客户端。详见上方「进程内字典缓存」章节。
:::
### Lua 脚本
@@ -243,14 +282,17 @@ return effective
数据变更时主动清除相关缓存,保证数据一致性:
```python
# 字典项变更 → 清除字典缓存
invalidate_dict_cache(dict_code)
# 字典项变更 → 按字典 ID 批量清除字典缓存
invalidate_dicts_by_ids(dict_ids)
# 菜单变更 → 递增全局版本号,所有用户权限缓存自然失效
await invalidate_menu_change()
# 用户角色变更 → 删除该用户的权限和菜单缓存
await invalidate_user_perms(user_id)
# 角色菜单变更 → 清除该角色下所有用户的权限缓存
invalidate_perm_cache(user_id)
# 用户角色变更 → 清除该用户的权限缓存
invalidate_perm_cache(user_id)
await invalidate_role_perms(role_id)
```
### TTL 自动过期
@@ -258,26 +300,27 @@ invalidate_perm_cache(user_id)
对于允许短暂不一致的数据,使用 TTL 自动过期:
```
数据字典缓存:TTL 300 秒(5 分钟)
数据字典缓存:TTL 60 秒(1 分钟,兜底过期,实际变更靠主动失效即时生效)
登录失败计数:TTL 300 秒(5 分钟)
滑动窗口限流:TTL 2 倍窗口时长
验证码:TTL 120 秒(2 分钟)
验证码:TTL 300 秒(5 分钟)
```
### 缓存穿透防护
:::warning 缓存穿透
当查询一个不存在的数据时,缓存和数据库都不会命中,导致每次请求都穿透到数据库。对于字典查询,如果 `dict_code` 不存在,应缓存空结果(空字典),避免反复查询数据库。
当查询一个不存在的字典编码时,缓存和数据库都不会命中,导致每次请求都穿透到数据库。`dict_util` 通过缓存空列表来防护:即使 `_load_dict_items` 返回空列表,也写入缓存,避免反复查询数据库。
:::
```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
def get_dict_items(code):
now = time.time()
hit = _dict_cache.get(code)
if hit and hit[0] > now:
return hit[1]
items = _load_dict_items(code) # 字典不存在时返回 []
_dict_cache[code] = (now + _CACHE_TTL, items) # 空列表也缓存,防止穿透
return items
```
## 缓存监控