cache-invalidation.md 9.34 KB

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/>18 cache regions for gdsmodule"]:::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 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: 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 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_<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:

  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(<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 + 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 re-runs from the cache; understanding which layer is stale is the key to the bug.