# 元数据变更后的缓存失效 当 PM 在 BACK 保存变更(给表单加列、更新权限、注册新模块)时,框架必须丢弃对旧元数据的缓存解释。**缓存清理由 BACK 进程内的 Spring `@CacheEvict` 同步完成**,不是 JMS 扇出。代码里另有一条名字相近的 JMS 路径,但用途不同(基础数据合并);两者很容易混淆,本页专门拆开说明。 ## 一个触发点,两条路径:差异在哪里 ```mermaid flowchart TB classDef ok fill:#e6f4ea,stroke:#34a853 classDef notcache fill:#fce8e6,stroke:#ea4335 PM[PM 在 BACK 点击保存]:::ok SAVE["BusinessBaseServiceImpl
add/update/deleteBusinessData"] EVICT["BusinessCleanRedisData.delCleanRedisData
→ CleanRedisServiceImpl
gdsmodule 相关 18 个 cache region"]:::ok REDIS[("Redis
(跨节点共享)")]:::ok DB[("MySQL
写入行")]:::ok PM --> SAVE SAVE --> DB SAVE -- "同步执行,
同一事务路径" --> EVICT EVICT --> REDIS REDIS -. "任意节点下次读取
都能看到新值" .-> ANY[其他节点]:::ok SAVE -. "发布 'gds module changed'" .-> AMQ([ActiveMQ]) AMQ --> CGM["ConsumerChangeGdsModuleThread
(xlyErpJmsConsumer)"]:::notcache CGM -- "调用
PRO_ERPMERGEBASEGDSMODULE" --> DB2[("MySQL
基础数据合并
不是缓存")]:::notcache classDef title font-weight:bold ``` **绿色路径**才是每次“修改元数据后刷新页面”实际依赖的路径。**红色路径**因为队列名(`CHANGE_GDS_MODULE`)和 consumer thread 名字,很容易被误认为缓存失效;但它不是。它通过存储过程做每租户 → base 数据合并。**两条路径互不依赖。** ## 真实缓存失效路径(同步、进程内) ```text PM 在 BACK 保存 │ ▼ BACK controller(如 /business/addUpdateDelBusinessData)调用 BusinessBaseServiceImpl.addBusinessData / updateBusinessData / deleteBusinessData │ ▼ 保存 service 调用 businessCleanRedisData.delCleanRedisData(...) (如 BusinessBaseServiceImpl.java:1122、1224、1375、1441、1597、1677) │ ▼ BusinessCleanRedisDataImpl.delCleanRedisDataByTableName(, ...) 按表名分发到 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: ```java @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`](../../api-reference/messaging.md) 中另外 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 默认的 `::` 分隔符。与 `BusinessGdsconfigformsServiceImpl.java:189-190` 中 `getFormconstData` 的 `@Cacheable` 注解(默认 key 来自所有参数)匹配的 key 形状示例: ```text businessGdsconfigformsServiceGetFormconstData::{sLanguage=sChinese, sModelsId=…, sSubsidiaryId=1111111111, sUserId=…, sBrandsId=1111111111} gdsmoduleById::gdsmoduleById___ ``` 因此,任一节点上的 `@CacheEvict` 会清理共享 Redis 存储,其他节点下一次读取时也会看到失效。跨节点一致性来自 Redis,不来自 JMS。 ## 直接用 SQL 修改元数据时 通过 MyBatis 或 BACK 完成的 insert / update 会触发 `businessCleanRedisData.delCleanRedisData*`。直接在 DB 执行 `UPDATE gdsmodule SET …` 不会触发任何 cleaner。缓存会继续提供旧元数据,直到: 1. 缓存 TTL 过期(实际 TTL 看缓存配置)。 2. 重启应用服务器(由于缓存由 Redis 支撑且共享,重启一次即可,见上文)。 3. 从应用内部调用某个 `BusinessCleanRedisDataImpl.delCleanRedisDataByTableName(, …)` 方法;任意节点调用一次即可,因为它清的是共享 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:问题其实是缓存 当现象像是“我改了但页面还是旧值”时,按顺序检查: 1. 变更是否实际提交?(对 DB 执行 `SELECT` 确认。) 2. 变更是否经过会调用 `BusinessCleanRedisData` 的路径?(直接 DB 写入或绕过 `BusinessBaseServiceImpl` 的 controller 不会。) 3. 保存提交时 Redis 是否可达?驱逐失败不会回滚保存。 4. 这个变更所在表是否映射到了会被清理的 cache region?`CleanRedisServiceImpl` 按写入表映射到具体 region;未映射的表不会让对应读取缓存失效。 [`getModelBysId` 在切片 1](../../slices/01-hello-world.md) 中返回的五键复合体会从缓存重跑;定位 bug 的关键是理解哪一层陈旧。