# Cache invalidation on metadata change When a PM saves a change in BACK — adds a column to a form, updates a permission, registers a new module — the framework drops the cached interpretation of the old metadata. **The cache-clear is synchronous in the BACK process via Spring's `@CacheEvict`**, NOT a JMS fan-out. A separate JMS path with similarly-named classes exists for a different purpose (base-data merge); the two are easy to confuse and this page calls them out explicitly. ## Two paths, one trigger — what's actually different ```mermaid flowchart TB classDef ok fill:#e6f4ea,stroke:#34a853 classDef notcache fill:#fce8e6,stroke:#ea4335 PM[PM clicks 保存 in BACK]:::ok SAVE["BusinessBaseServiceImpl
add/update/deleteBusinessData"] EVICT["BusinessCleanRedisData.delCleanRedisData
→ CleanRedisServiceImpl
18 cache regions for gdsmodule"]:::ok REDIS[("Redis
(shared across nodes)")]:::ok DB[("MySQL
row written")]:::ok PM --> SAVE SAVE --> DB SAVE -- "synchronous,
same transaction" --> EVICT EVICT --> REDIS REDIS -. "next read on
any node sees fresh" .-> ANY[Other nodes]:::ok SAVE -. "publishes 'gds module changed'" .-> AMQ([ActiveMQ]) AMQ --> CGM["ConsumerChangeGdsModuleThread
(xlyErpJmsConsumer)"]:::notcache CGM -- "calls
PRO_ERPMERGEBASEGDSMODULE" --> DB2[("MySQL
base-data merge
NOT cache")]:::notcache classDef title font-weight:bold ``` The **green path** is what every metadata-change-then-page-reload flow actually rides. The **red path** is what readers expect to be cache invalidation because of the queue's name (`CHANGE_GDS_MODULE`) and consumer-thread class — but it isn't. It does a per-tenant→base-data merge via stored procedure. **Neither path depends on the other.** ## The actual cache-invalidation path (synchronous, in-process) ``` PM saves in BACK │ ▼ BACK controller (e.g. /business/addUpdateDelBusinessData) calls BusinessBaseServiceImpl.addBusinessData / updateBusinessData / deleteBusinessData │ ▼ Save service calls businessCleanRedisData.delCleanRedisData(...) (e.g., BusinessBaseServiceImpl.java:1122, 1224, 1375, 1441, 1597, 1677) │ ▼ BusinessCleanRedisDataImpl.delCleanRedisDataByTableName(, ...) dispatches to one of the named cleaners on CleanRedisServiceImpl │ ▼ CleanRedisServiceImpl.cleanRedisByTableNameGdsModle() (or similar) fires @CacheEvict against a fixed list of named cache regions │ ▼ Spring CacheManager evicts the named entries │ ▼ Next /business/getModelBysId call re-reads from DB and re-populates the cache. ``` The cleaner methods are in `xlyBusinessService/src/main/java/com/xly/service/impl/CleanRedisServiceImpl.java`. A representative one — invoked when `gdsmodule` rows change — evicts 18 cache regions in a single call: ``` @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() { … } ``` Other table-named cleaners on the same class evict the regions relevant to `gdsconfigformmaster`, `gdsconfigformslave`, `gdsconfigtbmaster`, `gdsformconst`, `gdsjurisdiction`, `gdsconfigcharmaster`, login-info, billnosetting, kpimaster, `SysSystemSettings`, etc. ## What the JMS `CHANGE_GDS_MODULE` queue actually does (NOT cache-bust) The framework has a JMS queue `P2pQueue.ERP_JMS_ACTIVEMQ_CHANGE_GDS_MODULE` and a consumer thread `ConsumerChangeGdsModuleThread`, both of which sound like they should be doing cache invalidation — but they don't. `ConsumerChangeGdsModuleThread.run()` resolves a `changeGdsModuleService` bean (`ChangeGdsModuleServiceImpl`) and calls `changeTableData(sGdsModuleId, sJobId)`, which invokes the stored procedure `PRO_ERPMERGEBASEGDSMODULE` (via `proDao.proErpMergeBaseGdsModule`, mapped in `ProMapper.xml`). That proc consolidates per-tenant `gdsmodule` rows into a flattened "base" lookup table — a base-data merge job, not a cache evict. A `grep` of `xlyErpJmsConsumer/` for `@CacheEvict` or `cleanRedis*` returns zero hits — the consumer side clears nothing in Redis. The same goes for the other 23 `ERP_JMS_ACTIVEMQ_*` queues in [`P2pQueue.java`](../../api-reference/messaging.md): each one drives a domain-specific base-data merge or fan-out work item, not cache invalidation. ## Cross-node cache coherence — Redis-backed, confirmed `@EnableCaching` is on `EntryApplicationBoot.java:22` and `ApiApplicationBoot.java:24`. **No custom `CacheManager` bean is declared anywhere in the in-scope source** (no `RedisCacheManager`, no `@Bean CacheManager`-returning method, no `implements CacheManager`, no `spring.cache.*` property in any `application*.yml`). With `spring-boot-starter-data-redis` present and no other cache provider on the classpath (no Caffeine, EhCache, Hazelcast, JCache; the `shiro-ehcache` jar in `xlyFlow` is for Shiro's own session cache, not Spring Cache), Spring Boot 2.2.5 auto-configures **`RedisCacheManager`**. **Empirically verified** against the live dev Redis at `118.178.19.35:16379` (database 0): 233 of 267 keys use Spring Cache's default `::` separator. Sample key shape matching the `@Cacheable` annotation on `getFormconstData` at `BusinessGdsconfigformsServiceImpl.java:189-190` (default key derived from all params): ``` businessGdsconfigformsServiceGetFormconstData::{sLanguage=sChinese, sModelsId=…, sSubsidiaryId=1111111111, sUserId=…, sBrandsId=1111111111} gdsmoduleById::gdsmoduleById___ ``` So `@CacheEvict` on any node clears the shared Redis store and the next read on every node sees the eviction. Cross-node coherence works through Redis, not through JMS. ## When you change metadata directly via SQL Inserts/updates done through MyBatis or BACK trigger `businessCleanRedisData.delCleanRedisData*`. Raw `UPDATE gdsmodule SET …` against the DB does **not** trigger any cleaner. The cache will serve stale metadata until either: 1. The cache TTL expires (check the cache config for the actual TTL). 2. A bounce of the application servers (one bounce suffices since the cache is Redis-backed and shared — see above). 3. A manual call to one of the `BusinessCleanRedisDataImpl.delCleanRedisDataByTableName(, …)` methods is invoked from inside the application — once, on any node, since it clears the shared Redis store. ## Drawbacks of this design The synchronous `@CacheEvict`-during-save model is operationally simple and (with Redis backing) genuinely cross-node coherent. It is also fragile in ways worth naming: - **Two systems with confusingly similar names.** The JMS path `CHANGE_GDS_MODULE` + `ConsumerChangeGdsModuleThread` *sounds* like it should be cache invalidation but isn't. This page exists partly because that conflation is a recurring source of bugs and reader confusion. A renaming pass (proc and queue → e.g. `MERGE_BASE_GDS_MODULE`) would help, but isn't free. - **Eviction is in the same transaction as the write.** If the Redis call fails mid-save, the row commits but the cache stays stale. The framework does not detect or recover from this; a Redis outage during save silently corrupts the cache for affected rows until TTL expiry. - **Eviction is "all or nothing per cache region".** Most `@CacheEvict` annotations on `CleanRedisServiceImpl` use `allEntries=true`, which dumps the entire cache region rather than the affected key. Heavy save throughput causes high cache-miss rates immediately afterwards — fine for small metadata caches, expensive when dropping a region with thousands of entries. - **No invalidation budget / batching.** Bulk metadata changes (e.g., editing 100 form fields) trigger 100 `@CacheEvict` fires, each one round-tripping to Redis. There is no mechanism to coalesce evictions into one batch. - **Direct DB writes bypass everything.** Any tooling that touches the schema outside `BusinessBaseServiceImpl` — including database admin scripts, `script/客户/` overrides applied via `mysql` command line, and Channel-2 SQL replacements — leaves the cache stale until manually invalidated. This is a real operational hazard for the deployment pattern xly actually uses. ## Common bug: the cache is the bug When something looks like "I changed it but the page still shows the old value", check (in this order): 1. Did the change actually commit? (Confirm with `SELECT` against the DB.) 2. Did the change go through a path that invokes `BusinessCleanRedisData`? (Direct DB writes or controllers that bypass `BusinessBaseServiceImpl` won't.) 3. Was Redis reachable when the save committed? A failed eviction does not roll back the save. 4. Is the change in a cache region that's evicted by the table that was written? `CleanRedisServiceImpl` maps writes to specific regions; an unmapped table will not invalidate its readers. The five-key composite returned by [`getModelBysId` in Slice 1](../../slices/01-hello-world.md) re-runs from the cache; understanding which layer is stale is the key to the bug.