cache-invalidation.md 8.87 KB

元数据变更后的缓存失效

当 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,分别清理与 gdsconfigformmastergdsconfigformslavegdsconfigtbmastergdsformconstgdsjurisdictiongdsconfigcharmaster、登录信息、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 @CacheEvictcleanRedis* 结果为 0;consumer 侧不会清 Redis。

P2pQueue.java 中另外 23 个 ERP_JMS_ACTIVEMQ_* 队列也一样:每个队列驱动一个领域特定的基础数据合并或扇出工作项,而不是缓存失效。

跨节点缓存一致性:Redis 支撑,已确认

EntryApplicationBoot.java:22ApiApplicationBoot.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-190getFormconstData@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。缓存会继续提供旧元数据,直到:

  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 中返回的五键复合体会从缓存重跑;定位 bug 的关键是理解哪一层陈旧。