JVxeTable 子表字典回显请求优化:前端请求合并 + Redis 短缓存兜底

一、问题现象

QMS 质量管理系统中,检验任务表单(来料 / 制程 / 成品 / 出货检验等)的子表基于 j-vxe-table(vxe-table 4.x)渲染,多个列使用 selectDictSearch 表字典搜索下拉组件(检验规范、工序、检验站、量具等),单元格存储的是字典表主键 id,展示时需要实时翻译成文字。

实际运行中发现:

  • 表格每滚动一次,每个有值的单元格都会发起一次字典翻译 HTTP 请求(如 /sys/dict/loadDictItemNotFilter/specification_p,expression,id?key=xxx),且每次都穿透到数据库;
  • 以 50 行数据、3 个字典列为例,一次打开 / 滚动到底即产生 约 150 个后端请求;行数更多时,虚拟滚动回收单元格还会导致请求反复爆发;
  • 大量并发请求造成后端接口与数据库压力陡增,页面打开伴随明显的请求风暴。

二、问题原因

逐层排查后定位为四个叠加因素:

  1. 渲染机制触发重复查询:单元格组件通过 watch(innerValue, { immediate: true }) 在挂载时发起翻译请求。JVxeTable 默认开启纵向虚拟滚动(scrollY: { enabled: true },行数超过阈值后生效),滚动过程中单元格被回收 / 重建,每次重新挂载都会再次发起请求;即使未触发虚拟滚动,首次渲染也会一次性发出”行数 × 列数”个请求。
  2. 组件自带的缓存处于失效状态:组件内原本设计了 LabelMap 内存缓存用于防重复查询,但写入逻辑因历史 bug 被注释停用,缓存永远为空,导致每次挂载必然发请求。
  3. 历史 bug 的根因(缓存被停用的原因):
    • 后端翻译接口在 key 查不到数据时,会把 key 本身(即 id)当作文本返回(delNotExist=false 语义)。旧的缓存写入未过滤这种”污染数据”,text=id 被写入模块级缓存后会话期内永不过期,表现为”单元格一直显示 id 而不是文字”;
    • 缓存 key 只用了值本身、未区分字典来源,不同字典表的相同 id 会互相污染;
    • 防抖用的 requestId 是模块级全局变量,所有单元格共享,并发搜索时互相作废对方响应,进一步造成”显示原始 id”的假象。
  4. 后端无任何缓存:表字典翻译接口(queryTableDictByKeys 系列)每次直查数据库,代码注释声称有 Redis 缓存但实际未实现。

三、实现方案

整体思路:前端把”N 行 × M 列”的碎片化请求合并为”每列一个”的批量请求,并用带 TTL 的内存缓存消除重复;后端加按 key 的 Redis 短缓存吸收残余并发,两层均严格规避”缓存污染”问题。

3.1 前端:批量合并 + 缓存重建(组件层统一解决)

改动集中在 JVxeSelectDictSearchCell.ts 一个文件,全部 53 个使用该组件的页面自动受益,业务表单零改动:

  • 微任务批量合并:同一渲染波次挂载的单元格不再立即发请求,而是按字典定义分组进入批量队列,延迟到下一个宏任务统一冲刷——每个字典列合并为一个请求(后端接口原生支持逗号分隔多 key、按 key 顺序返回),单批超过 100 个 key 自动分片防止 URL 超长;
  • in-flight 请求去重:进行中的相同 dict+value 请求直接复用 Promise,相邻渲染波次不会重复发起;
  • 缓存重建(根治历史 bug):
    • 缓存 key 改为 dict::value 完整格式,不同字典定义(含 where 条件、筛选条件)彻底隔离;
    • 建立”三不缓存”规则:后端查不到回显的 key(text === value)、空文本、多值一律不写入缓存,从机制上杜绝”id 显示被固化”的问题复现;
    • 缓存条目增加 10 秒 TTL,过期读取时就地删除并自动走批量链路重查(有合并兜底,过期瞬间也不会爆发请求),兼顾请求量与数据新鲜度;
    • 下拉搜索结果同步写入缓存,用户选过的值后续回显零请求;
  • 竞态修复:requestId 由模块级改为组件实例级,消除多单元格并发搜索互相作废响应的问题。

3.2 后端:按 key 的 Redis 兜底短缓存(TTL = 3 秒)

改动集中在 SysDictServiceImpl 的两个表字典翻译方法(queryTableDictByKeys / queryTableDictByKeysNotFilter):

  • 缓存粒度为单个 key 而非整个请求:不同请求的 key 组合可互相命中,3 秒内任何用户、任何页面引用同一字典项均可复用;
  • 读取流程:先按 key 批量查 Redis → 仅未命中的 key 用一条 IN (...) 查库 → 按原有语义组装返回(保持入参顺序、delNotExist=false 时查不到回显 key 等行为完全不变);
  • 缓存 key 设计:sys:cache:dictTable::{table,text,code}[|where:...]::{字典项key},由字典三段 + where 条件唯一确定字典定义,且在 SQL 注入校验与转义之后构造;
  • 三不缓存 + 降级保护:查不到的 key 不缓存(防止”回显 id”被固化)、空文本不缓存、Redis 异常时 try-catch 自动降级为纯查库——兜底层只允许减性能,不允许减功能;
  • 3 秒 TTL 的定位是削峰而非持久缓存:自然过期、无需任何主动失效逻辑,对字典表的增删改零侵入,一致性成本约等于零。

3.3 兼容性保障

  • 4 段式字典(table,text,code,筛选条件)与 table where ... 风格均验证兼容:前端按完整 dict 字符串隔离缓存与批量队列;后端回显链路对筛选条件的处理保持既有行为;
  • 多值(含逗号)场景自动降级为原有单请求路径,不破坏 key↔text 的位置对应关系;
  • 接口的出入参契约、返回顺序、边界语义与优化前完全一致,属于纯性能优化,无行为变更。

四、最终效果对比

以 50 行数据 × 3 个字典列为例:

场景 优化前 优化后
首次打开表单请求数 约 150 个 3 个(每字典列 1 个批量请求)
滚动 / 来回滚动 请求反复爆发 0 个(命中内存缓存)
关闭弹窗后重开 再次约 150 个 0 个(10 秒缓存期内)
缓存过期后再次触发 仍是每行一个请求 自动合并为每列 1 个请求
3 秒内多人 / 多标签页重复打开 每次都查库 命中 Redis,0 次 DB 查询
数据库查询次数(首次打开) 约 150 次单行查询 3 次 IN(...) 批量查询
受益范围 — 全项目 53 个使用 selectDictSearch 的页面自动生效,后续新增页面免维护

五、技术亮点

  1. 系统性根因分析:从”滚动重复请求”表象出发,逐层定位到虚拟滚动单元格重建、缓存写入被停用、缓存污染(查不到回显 key)、全局请求 id 竞态四个叠加根因,并逐一修复;
  2. 请求合并模型设计:设计”批量队列 + 宏任务冲刷 + in-flight 去重 + 带 TTL 的防污染缓存”四层机制,将请求复杂度从 O(行数 × 列数) 降为 O(字典列数);
  3. 组件层收口:方案落在基础组件内部,相比逐表单挂钩子的方式,一次改动覆盖全部 53 个页面且对未来新增页面免疫,业务代码零侵入;
  4. 缓存安全性设计:前后端一致的”不缓存查不到的 key”原则,从机制上根治”缓存导致显示 id”的历史 bug,而不是简单绕过;
  5. 兜底层的健壮性:Redis 短缓存定位为削峰(TTL 3 秒、无主动失效、零侵入),配合异常降级,保证缓存层故障时功能完全不受影响。