# 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
17 @CacheEvict methods"]:::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
17 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` SpEL spec from `BusinessGdsconfigformsServiceImpl.java:209-211`:
```
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.