最近在开源项目(nest-admin)里做了一次数据字典缓存的改造,起因很简单:后台每个用到字典的页面,打开都要各自请求一次字典接口。
页面越拆越多,字典请求也跟着越积越多,服务端反反复复查同一批数据,纯属浪费。
借着这次改造,把整套缓存设计的思路整理下来,算是给以后接手代码的同学留个说明书,也是一次优化的经验记录。
正文
问题总览
改造前的主要解决的问题很简单,数据字典每次获取都要调用接口,这很消耗服务器资源。
这个资源实际上可以一次性拉取缓存,以现代浏览器的性能,完全足够处理这种小规模的数据。
实现思路也很简单,字典数据只在登录或刷新页面时校验一次版本号:
- 版本没变 → 直接用浏览器本地缓存,页面不再请求字典接口;
- 版本变了 → 全量拉取一次,覆盖本地缓存;
- 少数必须实时的场景 → 调用时传
fresh: true,绕过所有缓存。
改造前后对比
| 对比项 | 改造前 | 改造后 |
|---|---|---|
| 打开业务页面 | 每个用到字典的页面各请求一次 | 0 次字典请求,直接用缓存 |
| 登录 / 刷新页面 | 各页面各自请求 | 1 次版本号请求;版本变化时再加 1 次全量请求 |
| 服务端压力 | 请求分散、反复查库 | 全量接口优先读 Redis,一次返回所有字典 |
| 编辑字典后 | 需手动刷新页面才能看到 | 保存后自动失效并重新加载,立即生效 |
| 偶发实时需求 | 无法区分 | 传 fresh: true 单独处理 |
三层缓存结构
| 层级 | 存放位置 | 生命周期 | 作用 |
|---|---|---|---|
| 内存缓存 | Pinia useDictStore |
当前页面会话 | 组件之间共享,同一字典只请求一次 |
| 持久缓存 | localStorage nest-admin:dict-cache |
跨刷新、跨标签页 | 打开后台不用等接口,首屏即可渲染 |
| 服务端缓存 | Redis sys_dict:* |
跨用户、跨设备 | 不必每次都查 MySQL |
读取顺序是一层层往下掉的:
1 | 组件 useDict() |
localStorage 里保存的不只是字典内容,还保存了
serverVersion(上次校验通过的版本号)和schema(缓存结构版本)。
schema用于以后缓存结构升级时自动丢弃旧格式,避免读到脏数据。
核心机制
用版本号判断缓存是否过期
版本号是什么
版本号 = 字典类型表、字典数据表中最后一次修改的时间,格式为 YYYY-MM-DD HH:mm:ss,例如 2026-10-22 04:03:43。
后端的生成规则(getDictVersion()):
- 分别取
sys_dict_type、sys_dict_data中最大的update_time(为空时取create_time); - 两个时间再取较新的一个,格式化成可读时间;
- 两张表都为空时返回
1970-01-01 00:00:00。
为什么用最后修改时间,而不是哈希或自增号
- 不需要新建表、加字段,也不用维护额外的计数器;
- 人能直接看懂,排查问题时一眼就知道缓存是什么时候的数据;
- 新增、修改、删除字典都会刷新
update_time,版本号自然跟着变,不需要额外埋点。
启动时的校验流程
1 | 用户登录 / 刷新页面 |
改造后实测的请求时序:
| 场景 | 实际请求 |
|---|---|
| 首次登录(无缓存) | login → getInfo → /data/version → /data/all → getRouters |
| 普通刷新(版本未变) | getInfo → /data/version → getRouters |
| 任意业务页面(如用户管理) | 无字典请求 |
| 版本已变化后刷新 | getInfo → /data/version → /data/all → getRouters |
缓存什么时候失效
| 触发场景 | 后端行为 | 前端行为 |
|---|---|---|
| 新增 / 修改 / 删除字典类型或字典数据 | 清空 Redis 中 sys_dict:* 以及完整性标记,下次访问全量接口时自动重建 |
下次登录/刷新校验版本时发现变化,重新拉全量 |
| 字典管理页编辑、删除 | 同上 | 页面同时调用 initDict({ force: true }),当前页面立即生效 |
| 点击”刷新缓存”按钮 | 立即从数据库重建 Redis 缓存 | 前端强制全量重拉,并更新本地版本号 |
| 用户手动清空浏览器缓存 | 无感知 | 下次启动检测不到本地缓存,全量拉取一次 |
因为版本号本身就是”最后修改时间”,所以任何写操作都会让它变化,不需要额外维护失效逻辑。这一点是整个设计里最省心的地方。
实时获取的逃生口:fresh
绝大多数场景用缓存就够了,但个别必须读到最新值的场景(比如某些配置类字典),调用时加个参数就行:
1 | // 前端:跳过 Pinia 和 localStorage,直接请求 |
1 | GET /system/dict/data/type/sys_user_sex?fresh=true // 跳过 Redis,直接查库 |
这个开关设计上有意做成前后端都绕过:
- 只绕前端 → 后端 Redis 可能还是旧值;
- 只绕后端 → 前端仍会用 localStorage 里的旧值;
- 两端都绕,才能真正拿到数据库里的最新数据。
注意 fresh 会放大数据库压力,只作为个别场景的开关,不建议在列表页、循环里频繁使用。
业务代码怎么用
统一入口在 src/composables/useDict.ts,业务页面里长这样:
1 | <script setup lang="ts"> |
使用要点:
- **不需要
await**:有缓存立即渲染,没有缓存时异步补上并自动更新视图; - 默认不会额外发请求(缓存未命中除外),页面可以放心多处调用;
- 常用辅助方法:
getDictLabel(值转标签)、getDictTagType(el-tag 样式)、isDictLoaded(是否已加载)。
Store 的常用 API:
| 方法 | 用途 |
|---|---|
getDict(type) |
读取某个字典 |
setDict(type, list) |
写入某个字典 |
loadDict(type, { fresh }) |
加载单个字典(带并发去重) |
initDict({ force, fresh }) |
初始化 / 强制全量刷新 |
removeDict(type) / cleanDict() |
删除单个 / 清空全部缓存 |
异常与边界处理
| 情况 | 处理方式 |
|---|---|
| localStorage 内容损坏或结构版本不匹配 | 自动丢弃并重建,不影响使用 |
| 版本号接口失败 | 已有本地缓存就继续用,不阻塞登录;无缓存才走全量拉取 |
| 全量接口失败 | 回退方案:先取字典类型列表,再逐个类型加载 |
| 多个组件同时请求同一字典 | pendingTypeLoads 复用同一个 Promise,避免重复请求 |
并发多次调用 initDict |
initPromise 复用同一次初始化 |
| Redis 只写入了一部分数据 | 用完整性标记 sys_dict_meta:complete 判断,不完整时重建 |
| 本地缓存中没有某个新字典类型 | useDict 发现缺失后单独拉取该类型 |
已知的取舍
有几个点是有意为之的,写下来免得以后自己都要怀疑人生。
- 不做跨标签页实时推送。另一个管理员改了字典,已经打开的页面不会主动变化,需刷新后生效。用”窗口聚焦 + 节流校验版本”可以补,但项目使用人数少、改动收益低,暂不实现。
- 版本号精度到秒。同一秒内连续两次修改,理论上版本号可能不变。实际概率极低,且下一次修改会纠正,不影响最终一致性。
- 全量一次加载。适合中小型系统。如果字典项达到数万条,应改回按类型加载,避免单次响应过大。
- localStorage 可被用户清空。清空后只是下次启动多一次全量请求,不影响正确性。
结语
验证这套设计是否生效很简单:
- 登录后打开 Network 过滤
dict,应该只看到一次data/version; - 刷新页面依然只有
data/version;打开任意业务页面,不应出现字典请求; - 去字典管理页改一条数据并保存,才会出现
data/all和data/version。
缓存设计这东西,最难的不是加一层缓存,而是想清楚什么时候该失效。这次用”最后修改时间”当版本号,把失效判断省成了零成本,算是整场改造里最满意的一笔。
如果后面项目规模上去了,字典条目改成按类型懒加载,应该会是下一个要还的债。
另外,感谢qoder的免费提供的deepseek-flash4.1(1017),这个原本我上半年预计要改半个月的内容,基本上一天就改完了,效果真是出乎意料的好。
参考链接
本篇正文由 Qoder 的 AI 助手协助整理编辑(记录与技术方案源于本人的实际项目改造经验,AI 负责结构梳理与文稿润色)。