元数据变更后的缓存失效
当 PM 在 BACK 保存变更(给表单加列、更新权限、注册新模块)时,框架必须丢弃对旧元数据的缓存解释。缓存清理由 BACK 进程内的 Spring @CacheEvict 同步完成,不是 JMS 扇出。代码里另有一条名字相近的 JMS 路径,但用途不同(基础数据合并);两者很容易混淆,本页专门拆开说明。
一个触发点,两条路径:差异在哪里
flowchart TB
classDef ok fill:#e6f4ea,stroke:#34a853
classDef notcache fill:#fce8e6,stroke:#ea4335
PM[PM 在 BACK 点击保存]:::ok
SAVE["BusinessBaseServiceImpl<br/>add/update/deleteBusinessData"]
EVICT["BusinessCleanRedisData.delCleanRedisData<br/>→ CleanRedisServiceImpl<br/>gdsmodule 相关 18 个 cache region"]:::ok
REDIS[("Redis<br/>(跨节点共享)")]:::ok
DB[("MySQL<br/>写入行")]:::ok
PM --> SAVE
SAVE --> DB
SAVE -- "同步执行,<br/>同一事务路径" --> EVICT
EVICT --> REDIS
REDIS -. "任意节点下次读取<br/>都能看到新值" .-> ANY[其他节点]:::ok
SAVE -. "发布 'gds module changed'" .-> AMQ([ActiveMQ])
AMQ --> CGM["ConsumerChangeGdsModuleThread<br/>(xlyErpJmsConsumer)"]:::notcache
CGM -- "调用<br/>PRO_ERPMERGEBASEGDSMODULE" --> DB2[("MySQL<br/>基础数据合并<br/>不是缓存")]:::notcache
classDef title font-weight:bold
绿色路径才是每次“修改元数据后刷新页面”实际依赖的路径。红色路径因为队列名(CHANGE_GDS_MODULE)和 consumer thread 名字,很容易被误认为缓存失效;但它不是。它通过存储过程做每租户 → base 数据合并。两条路径互不依赖。
真实缓存失效路径(同步、进程内)
PM 在 BACK 保存
│
▼
BACK controller(如 /business/addUpdateDelBusinessData)调用
BusinessBaseServiceImpl.addBusinessData / updateBusinessData / deleteBusinessData
│
▼
保存 service 调用 businessCleanRedisData.delCleanRedisData(...)
(如 BusinessBaseServiceImpl.java:1122、1224、1375、1441、1597、1677)
│
▼
BusinessCleanRedisDataImpl.delCleanRedisDataByTableName(<sTable>, ...)
按表名分发到 CleanRedisServiceImpl 上的某个清理方法
│
▼
CleanRedisServiceImpl.cleanRedisByTableNameGdsModle()(或类似方法)
对固定的一组 cache region 触发 @CacheEvict
│
▼
Spring CacheManager 清理命名条目
│
▼
下一次 /business/getModelBysId 调用重新从 DB 读取并回填缓存
清理方法位于 xlyBusinessService/src/main/java/com/xly/service/impl/CleanRedisServiceImpl.java。一个代表性方法(gdsmodule 行变更时调用)会一次性清理 18 个 cache region:
@CacheEvict(value = {
"getGdsmoduleTree", "getGdsmoduleList", "getModuleTreePro",
"getSysjurisdictionTreePro", "getsDisplayTypeAll",
"businessBaseServiceGetMenuList", "getBuMenu", "getMenu",
"getsAuthsId", "businessCommonServicegetModulelistAll",
"gdsmoduleById", "getSaveProName", "businessParameterGetParameter",
"getPrcName", "getKpiModelByUser", "getUserByFromId",
"getUserByActionId", "getModuleTreeProAll"
}, allEntries = true)
public void cleanRedisByTableNameGdsModle() { … }
同一个类上还有其他按表命名的 cleaner,分别清理与 gdsconfigformmaster、gdsconfigformslave、gdsconfigtbmaster、gdsformconst、gdsjurisdiction、gdsconfigcharmaster、登录信息、billnosetting、kpimaster、SysSystemSettings 等相关的缓存区域。
JMS 的 CHANGE_GDS_MODULE 实际做什么(不是清缓存)
框架中确实有 P2pQueue.ERP_JMS_ACTIVEMQ_CHANGE_GDS_MODULE 队列和 ConsumerChangeGdsModuleThread consumer thread,名字看上去像是做缓存失效,但事实不是。
ConsumerChangeGdsModuleThread.run() 解析 changeGdsModuleService bean(ChangeGdsModuleServiceImpl),调用 changeTableData(sGdsModuleId, sJobId),进而调用存储过程 PRO_ERPMERGEBASEGDSMODULE(通过 proDao.proErpMergeBaseGdsModule,映射在 ProMapper.xml)。该存储过程把每租户 gdsmodule 行合并进扁平“base”查询表。这是基础数据合并作业,不是缓存清理。对 xlyErpJmsConsumer/ grep @CacheEvict 或 cleanRedis* 结果为 0;consumer 侧不会清 Redis。
P2pQueue.java 中另外 23 个 ERP_JMS_ACTIVEMQ_* 队列也一样:每个队列驱动一个领域特定的基础数据合并或扇出工作项,而不是缓存失效。
跨节点缓存一致性:Redis 支撑,已确认
EntryApplicationBoot.java:22 和 ApiApplicationBoot.java:24 上有 @EnableCaching。范围内源码中没有自定义 CacheManager bean(没有 RedisCacheManager、没有返回 CacheManager 的 @Bean 方法、没有 implements CacheManager、任何 application*.yml 中也没有 spring.cache.* 属性)。在存在 spring-boot-starter-data-redis 且 classpath 中没有其他缓存提供方(无 Caffeine、EhCache、Hazelcast、JCache;xlyFlow 中的 shiro-ehcache jar 是 Shiro 自己的 session cache,不是 Spring Cache)的情况下,Spring Boot 2.2.5 会自动配置 RedisCacheManager。
已在实时 dev Redis 118.178.19.35:16379(database 0)验证:267 个 key 中有 233 个使用 Spring Cache 默认的 <cacheName>::<key> 分隔符。与 BusinessGdsconfigformsServiceImpl.java:189-190 中 getFormconstData 的 @Cacheable 注解(默认 key 来自所有参数)匹配的 key 形状示例:
businessGdsconfigformsServiceGetFormconstData::{sLanguage=sChinese, sModelsId=…, sSubsidiaryId=1111111111, sUserId=…, sBrandsId=1111111111}
gdsmoduleById::gdsmoduleById_<sBrandsId>_<sSubsidiaryId>_<sLanguage>
因此,任一节点上的 @CacheEvict 会清理共享 Redis 存储,其他节点下一次读取时也会看到失效。跨节点一致性来自 Redis,不来自 JMS。
直接用 SQL 修改元数据时
通过 MyBatis 或 BACK 完成的 insert / update 会触发 businessCleanRedisData.delCleanRedisData*。直接在 DB 执行 UPDATE gdsmodule SET … 不会触发任何 cleaner。缓存会继续提供旧元数据,直到:
- 缓存 TTL 过期(实际 TTL 看缓存配置)。
- 重启应用服务器(由于缓存由 Redis 支撑且共享,重启一次即可,见上文)。
- 从应用内部调用某个
BusinessCleanRedisDataImpl.delCleanRedisDataByTableName(<table>, …)方法;任意节点调用一次即可,因为它清的是共享 Redis 存储。
这个设计的代价
保存时同步 @CacheEvict 的模型运维上简单;在 Redis 支撑下,它也确实具备跨节点一致性。但它仍有几个脆弱点需要明确:
-
两套名字很像的系统容易混淆。 JMS 路径
CHANGE_GDS_MODULE+ConsumerChangeGdsModuleThread听起来像缓存失效,实际不是。这页存在的一部分原因,就是这种混淆反复造成 bug 和阅读误判。如果能把队列和过程重命名为类似MERGE_BASE_GDS_MODULE,会更清楚,但改名成本不低。 - 驱逐和写入在同一条事务路径上。 如果保存期间 Redis 调用失败,数据库行可能已经提交,但缓存仍旧是旧值。框架不会检测或自动恢复这种情况;保存时 Redis 短暂故障会让受影响行在 TTL 到期前一直读到旧缓存。
-
驱逐粒度是按 cache region 全量清。
CleanRedisServiceImpl上大多数@CacheEvict都使用allEntries=true,清掉整个 cache region,而不是只清受影响 key。元数据保存吞吐较高时,后续会出现一波 cache miss;小元数据缓存可以接受,但如果 region 有几千项就会变贵。 -
没有驱逐预算或批处理。 批量元数据变更(例如一次改 100 个字段)会触发 100 次
@CacheEvict,每次都往返 Redis。没有把多次驱逐合并成一批的机制。 -
直接 DB 写入会绕过全部机制。 任何绕开
BusinessBaseServiceImpl的工具都会留下陈旧缓存,包括 DBA 脚本、通过mysql命令应用的script/客户/覆盖、以及通道 2 的 SQL 替换。对 xly 实际采用的部署模式来说,这是实打实的运维风险。
常见 bug:问题其实是缓存
当现象像是“我改了但页面还是旧值”时,按顺序检查:
- 变更是否实际提交?(对 DB 执行
SELECT确认。) - 变更是否经过会调用
BusinessCleanRedisData的路径?(直接 DB 写入或绕过BusinessBaseServiceImpl的 controller 不会。) - 保存提交时 Redis 是否可达?驱逐失败不会回滚保存。
- 这个变更所在表是否映射到了会被清理的 cache region?
CleanRedisServiceImpl按写入表映射到具体 region;未映射的表不会让对应读取缓存失效。
getModelBysId 在切片 1 中返回的五键复合体会从缓存重跑;定位 bug 的关键是理解哪一层陈旧。