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
flowchart TB
classDef ok fill:#e6f4ea,stroke:#34a853
classDef notcache fill:#fce8e6,stroke:#ea4335
PM[PM clicks 保存 in BACK]:::ok
SAVE["BusinessBaseServiceImpl<br/>add/update/deleteBusinessData"]
EVICT["BusinessCleanRedisData.delCleanRedisData<br/>→ CleanRedisServiceImpl<br/>17 @CacheEvict methods"]:::ok
REDIS[("Redis<br/>(shared across nodes)")]:::ok
DB[("MySQL<br/>row written")]:::ok
PM --> SAVE
SAVE --> DB
SAVE -- "synchronous,<br/>same transaction" --> EVICT
EVICT --> REDIS
REDIS -. "next read on<br/>any node sees fresh" .-> ANY[Other nodes]:::ok
SAVE -. "publishes 'gds module changed'" .-> AMQ([ActiveMQ])
AMQ --> CGM["ConsumerChangeGdsModuleThread<br/>(xlyErpJmsConsumer)"]:::notcache
CGM -- "calls<br/>PRO_ERPMERGEBASEGDSMODULE" --> DB2[("MySQL<br/>base-data merge<br/>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(<sTable>, ...)
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: 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 <cacheName>::<key> 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_<sBrandsId>_<sSubsidiaryId>_<sLanguage>
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:
- The cache TTL expires (check the cache config for the actual TTL).
- A bounce of the application servers (one bounce suffices since the cache is Redis-backed and shared — see above).
- A manual call to one of the
BusinessCleanRedisDataImpl.delCleanRedisDataByTableName(<table>, …)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+ConsumerChangeGdsModuleThreadsounds 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
@CacheEvictannotations onCleanRedisServiceImpluseallEntries=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
@CacheEvictfires, 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 viamysqlcommand 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):
- Did the change actually commit? (Confirm with
SELECTagainst the DB.) - Did the change go through a path that invokes
BusinessCleanRedisData? (Direct DB writes or controllers that bypassBusinessBaseServiceImplwon't.) - Was Redis reachable when the save committed? A failed eviction does not roll back the save.
- Is the change in a cache region that's evicted by the table that
was written?
CleanRedisServiceImplmaps writes to specific regions; an unmapped table will not invalidate its readers.
The five-key composite returned by
getModelBysId in Slice 1
re-runs from the cache; understanding which layer is stale is the key
to the bug.