# 元数据变更后的缓存失效

当 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<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 数据合并。**两条路径互不依赖。**

## 真实缓存失效路径（同步、进程内）

```text
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：

```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 默认的 `<cacheName>::<key>` 分隔符。与 `BusinessGdsconfigformsServiceImpl.java:189-190` 中 `getFormconstData` 的 `@Cacheable` 注解（默认 key 来自所有参数）匹配的 key 形状示例：

```text
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。缓存会继续提供旧元数据，直到：

1. 缓存 TTL 过期（实际 TTL 看缓存配置）。
2. 重启应用服务器（由于缓存由 Redis 支撑且共享，重启一次即可，见上文）。
3. 从应用内部调用某个 `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：问题其实是缓存

当现象像是“我改了但页面还是旧值”时，按顺序检查：

1. 变更是否实际提交？（对 DB 执行 `SELECT` 确认。）
2. 变更是否经过会调用 `BusinessCleanRedisData` 的路径？（直接 DB 写入或绕过 `BusinessBaseServiceImpl` 的 controller 不会。）
3. 保存提交时 Redis 是否可达？驱逐失败不会回滚保存。
4. 这个变更所在表是否映射到了会被清理的 cache region？`CleanRedisServiceImpl` 按写入表映射到具体 region；未映射的表不会让对应读取缓存失效。

[`getModelBysId` 在切片 1](../../slices/01-hello-world.md) 中返回的五键复合体会从缓存重跑；定位 bug 的关键是理解哪一层陈旧。
