工程提速,数据字典的缓存设计
2026-10-25 13:32:40

最近在开源项目(nest-admin)里做了一次数据字典缓存的改造,起因很简单:后台每个用到字典的页面,打开都要各自请求一次字典接口。

页面越拆越多,字典请求也跟着越积越多,服务端反反复复查同一批数据,纯属浪费。

借着这次改造,把整套缓存设计的思路整理下来,算是给以后接手代码的同学留个说明书,也是一次优化的经验记录。

正文

问题总览

改造前的主要解决的问题很简单,数据字典每次获取都要调用接口,这很消耗服务器资源。

这个资源实际上可以一次性拉取缓存,以现代浏览器的性能,完全足够处理这种小规模的数据。

实现思路也很简单,字典数据只在登录或刷新页面时校验一次版本号:

  • 版本没变 → 直接用浏览器本地缓存,页面不再请求字典接口;
  • 版本变了 → 全量拉取一次,覆盖本地缓存;
  • 少数必须实时的场景 → 调用时传 fresh: true,绕过所有缓存。

改造前后对比

对比项 改造前 改造后
打开业务页面 每个用到字典的页面各请求一次 0 次字典请求,直接用缓存
登录 / 刷新页面 各页面各自请求 1 次版本号请求;版本变化时再加 1 次全量请求
服务端压力 请求分散、反复查库 全量接口优先读 Redis,一次返回所有字典
编辑字典后 需手动刷新页面才能看到 保存后自动失效并重新加载,立即生效
偶发实时需求 无法区分 传 fresh: true 单独处理

三层缓存结构

层级 存放位置 生命周期 作用
内存缓存 Pinia useDictStore 当前页面会话 组件之间共享,同一字典只请求一次
持久缓存 localStorage nest-admin:dict-cache 跨刷新、跨标签页 打开后台不用等接口,首屏即可渲染
服务端缓存 Redis sys_dict:* 跨用户、跨设备 不必每次都查 MySQL

读取顺序是一层层往下掉的:

1
2
3
4
5
6
7
8
9
10
11
组件 useDict()
↓ 命中即返回
Pinia 内存缓存
↓ 启动时从 localStorage 灌入
浏览器 localStorage
↓ 缺失或版本不一致
GET /system/dict/data/all
↓
Redis 缓存
↓ 未命中
MySQL

localStorage 里保存的不只是字典内容,还保存了 serverVersion(上次校验通过的版本号)和 schema(缓存结构版本)。

schema 用于以后缓存结构升级时自动丢弃旧格式,避免读到脏数据。

核心机制

用版本号判断缓存是否过期

版本号是什么

版本号 = 字典类型表、字典数据表中最后一次修改的时间,格式为 YYYY-MM-DD HH:mm:ss,例如 2026-10-22 04:03:43。

后端的生成规则(getDictVersion()):

  1. 分别取 sys_dict_type、sys_dict_data 中最大的 update_time(为空时取 create_time);
  2. 两个时间再取较新的一个,格式化成可读时间;
  3. 两张表都为空时返回 1970-01-01 00:00:00。

为什么用最后修改时间,而不是哈希或自增号

  • 不需要新建表、加字段,也不用维护额外的计数器;
  • 人能直接看懂,排查问题时一眼就知道缓存是什么时候的数据;
  • 新增、修改、删除字典都会刷新 update_time,版本号自然跟着变,不需要额外埋点。

启动时的校验流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
用户登录 / 刷新页面
│
▼
GET /system/dict/data/version ← 只查两个 MAX(),开销很小
│
├── 与 localStorage 中的 serverVersion 一致
│ └── 结束:直接使用本地缓存
│
└── 不一致(或本地无缓存)
│
▼
GET /system/dict/data/all ← 一次拿回所有字典
│
▼
覆盖 Pinia + localStorage,并保存新版本号

改造后实测的请求时序:

场景 实际请求
首次登录(无缓存) 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
2
3
4
5
// 前端:跳过 Pinia 和 localStorage,直接请求
const { sys_user_sex } = useDict("sys_user_sex", { fresh: true });

// 或者直接操作 store
dictStore.loadDict("sys_user_sex", { fresh: true });
1
2
GET /system/dict/data/type/sys_user_sex?fresh=true   // 跳过 Redis,直接查库
GET /system/dict/data/all?fresh=true // 跳过 Redis,并从数据库重建缓存

这个开关设计上有意做成前后端都绕过:

  • 只绕前端 → 后端 Redis 可能还是旧值;
  • 只绕后端 → 前端仍会用 localStorage 里的旧值;
  • 两端都绕,才能真正拿到数据库里的最新数据。

注意 fresh 会放大数据库压力,只作为个别场景的开关,不建议在列表页、循环里频繁使用。

业务代码怎么用

统一入口在 src/composables/useDict.ts,业务页面里长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<script setup lang="ts">
import { useDict } from "@/composables/useDict";

const { sys_normal_disable, getDictLabel, getDictTagType } =
useDict("sys_normal_disable");
</script>

<template>
<el-select v-model="form.status">
<el-option
v-for="item in sys_normal_disable"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>

<el-tag :type="getDictTagType('sys_normal_disable', row.status)">
{{ getDictLabel("sys_normal_disable", row.status) }}
</el-tag>
</template>

使用要点:

  • **不需要 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 发现缺失后单独拉取该类型

已知的取舍

有几个点是有意为之的,写下来免得以后自己都要怀疑人生。

  1. 不做跨标签页实时推送。另一个管理员改了字典,已经打开的页面不会主动变化,需刷新后生效。用”窗口聚焦 + 节流校验版本”可以补,但项目使用人数少、改动收益低,暂不实现。
  2. 版本号精度到秒。同一秒内连续两次修改,理论上版本号可能不变。实际概率极低,且下一次修改会纠正,不影响最终一致性。
  3. 全量一次加载。适合中小型系统。如果字典项达到数万条,应改回按类型加载,避免单次响应过大。
  4. localStorage 可被用户清空。清空后只是下次启动多一次全量请求,不影响正确性。

结语

验证这套设计是否生效很简单:

  1. 登录后打开 Network 过滤 dict,应该只看到一次 data/version;
  2. 刷新页面依然只有 data/version;打开任意业务页面,不应出现字典请求;
  3. 去字典管理页改一条数据并保存,才会出现 data/all 和 data/version。

缓存设计这东西,最难的不是加一层缓存,而是想清楚什么时候该失效。这次用”最后修改时间”当版本号,把失效判断省成了零成本,算是整场改造里最满意的一笔。

如果后面项目规模上去了,字典条目改成按类型懒加载,应该会是下一个要还的债。

另外,感谢qoder的免费提供的deepseek-flash4.1(1017),这个原本我上半年预计要改半个月的内容,基本上一天就改完了,效果真是出乎意料的好。

参考链接

本篇正文由 Qoder 的 AI 助手协助整理编辑(记录与技术方案源于本人的实际项目改造经验,AI 负责结构梳理与文稿润色)。