GeezerVison is showing github.com — GroovyScriptCacheContext 的脚本编译缓存没有跨进程失效机制,多实例部署下修改脚本后其他实例会永久返回旧脚本。 · Issue #214 · codingapi/springboot-framework
GroovyScriptCacheContext 的脚本编译缓存没有跨进程失效机制,多实例部署下修改脚本后其他实例会永久返回旧脚本。 #214
Description
环境:com.codingapi.springboot:springboot-starter-script:17.3.1,Spring Boot 3.x、JDK 17。部署形态为同一套代码运行两个独立进程(admin 8080 与 mobile 8081),两个进程连接同一个数据库,脚本正文存在 t_groovy_script 表里。
现象:在 admin 实例(进程 A)的页面上编辑并保存 Groovy 校验脚本后,进程 A 再次执行该脚本,得到的是新逻辑的结果;同一个 scriptKey 在进程 B(mobile)执行时,返回的却始终是改动之前的判定结果,等待多久都不会恢复,只有重启进程 B 才正常。数据库中该 key 的脚本正文已经是新版,说明落库没问题,问题出在进程内缓存。
复现方式:进程 A 通过 GroovyScriptCacheContext.getInstance().getGroovyScript(key) 拿到脚本后 setScript 新正文、compile(true)、save(),此时 A 的缓存同步更新、库里正文也已更新;进程 B 之前已经 getGroovyScript 过同一个 key,此后继续 invoke,拿到的永远是旧的编译结果,不会因为库里正文变化而失效。这与业务代码的写法无关,与项目里 ScriptGroovyFactoryContext.save 的“先 remove(key) 再 compile(true)、save()”完全一致,而 remove 只清当前 JVM。
根因:GroovyScriptCacheContext 是按 key 懒加载的进程内静态缓存,首次 getGroovyScript 之后编译结果常驻内存,没有 TTL、没有版本校验、也不会回查存储,只有显式 remove 或 clear 才会失效。GroovyScript 的 save() 只负责写存储,不通知其他进程;remove(key) 也只作用于调用方 JVM。于是缓存失效只发生在写入的那个进程,其他节点永久处于脏读状态,而且没有任何途径能感知脚本已经被改过。
影响面:任何多实例部署都会遇到这个问题,包括水平扩容、PC 与 H5 双端、多副本灰度,只要脚本的读取方和写入方不是同一个进程就一定会发生;表现是脚本修改后不同节点行为不一致,且只能靠重启非写入节点恢复。
Activity
xlorne commented on Nov 9, 2026
处理方式
- 首先可以补充数据更新相关的事件,从而运行更新同步其他进程的数据能力。
- cache机制可以允许提供其他的实现策略,例如可以redis实现策略,通过共享redis的数据的方式解决数据共享问题。
xlorne commented on Nov 10, 2026
问题定位
感谢反馈,问题已定位,确认属实。
根因(以 springboot-starter-script 17.x 源码为准,类名为 GroovyScriptCacheContext,issue 中提到的 ScriptGroovyFactoryContext 实际对应 GroovyScriptController.save):
GroovyScriptCacheContext 是进程内静态单例 LRU 缓存(LinkedHashMap accessOrder),getGroovyScript 命中缓存后直接返回,没有 TTL、没有版本校验、不会回查存储,只有显式 remove/clear 才失效。
GroovyScript.save() 只调用 GroovyScriptRepositoryContext.save(this) 写存储并清理本进程临时数据,不携带任何跨进程通知;remove() 同理。
GroovyScriptController.save 的流程是"取出缓存对象 → setScript → compile(true) → save()",整条链路的缓存失效只发生在写入方 JVM 内,其他实例感知不到脚本已变更,且编译缓存以脚本内容 SHA256 为 key(内容变了会重新编译),但对象缓存不失效就永远拿不到新内容,因此表现与 issue 描述一致。
影响版本线:17.x(issue 环境 springboot-starter-script:17.3.1,Spring Boot 3.x / JDK 17)。8.x 的同名模块存在相同机制,如需同步修复请在下方确认。
处理方案(对应上述两条方向):
- 新增脚本缓存变更事件机制:
GroovyScript.save() / remove() 后发布 GroovyScriptCacheEvent(SAVE / REMOVE),由可替换的 GroovyScriptCacheSync SPI 负责广播;其他实例接收事件后本地失效对应缓存,下次读取自动回源仓储加载新版本。默认提供单进程实现(行为等价于现状),多实例部署时由使用方接入 Redis pub/sub 等实现广播。
- 缓存策略可插拔:将
GroovyScriptCacheContext 内部的 LRU 容器抽象为 GroovyScriptCache 接口 + 默认 LRU 实现,支持替换为 Redis 等共享存储实现(与现有 GroovyScriptRepositoryContext.setRepository 的注入方式一致)。
交付方式:基于 17.3.x 创建特性分支 feature/issue-214-groovy-cache-sync-17x,测试先行,完成后推送该分支,是否合并由维护者决定。
xlorne commented on Nov 10, 2026
修复完成
特性分支已推送:feature/issue-214-groovy-cache-sync-17x(基线 17.3.x,提交 0355e566)
PR 创建入口(目标分支 17.3.x,是否合并由维护者决定):https://github.com/codingapi/springboot-framework/pull/new/feature/issue-214-groovy-cache-sync-17x
最终方案(与定位回复的差异:按评审意见增加了框架事件桥接)
-
跨实例失效同步(SPI)
- 新增
GroovyScriptCacheEvent(含 SAVE/REMOVE 类型、key、来源实例标识、更新时间)与 GroovyScriptCacheSync SPI;
GroovyScript.save() / remove() 写路径发布事件;默认实现 LocalGroovyScriptCacheSync 保持单进程行为(保存/删除后本进程缓存同步失效);
- 多实例部署时使用方实现
GroovyScriptCacheSync 广播事件(如 Redis pub/sub),远端实例收到后调用 GroovyScriptCacheSyncContext.receive(event) 失效本地缓存(按来源实例标识过滤回环),下次 getGroovyScript 自动回源仓储加载新版本。
-
进程内事件桥接
GroovyScriptCacheEvent 实现 ISyncEvent,写路径同时经 EventPusher.push 进程内分发(无 Spring 上下文时为安全 no-op);
- 业务代码实现
IHandler<GroovyScriptCacheEvent> 即可监听脚本保存/删除变更。
-
缓存策略可插拔
- LRU 容器抽象为
GroovyScriptCache 接口(默认 DefaultGroovyScriptCache,行为与原实现一致);
GroovyScriptCacheContext.setCache(...) 可替换为 Redis 等共享缓存实现(注入方式与 GroovyScriptRepositoryContext.setRepository 一致);
GroovyScriptCacheContext.remove(key) 调整为纯本地失效(原实现会先回源再删除,净效果不变)。
验证结果
- 模块测试:
springboot-starter-script 96/96 通过(新增 9 个同步机制单元测试 + 2 个框架事件桥接 Spring 集成测试),JDK 17.0.17;
- 全量构建:
./mvnw clean test -P travis 全部 5 个模块 456 个测试通过,BUILD SUCCESS。
其他
- 8.x 版本线存在相同机制,如需同步修复请确认,将按
8.2.x 基线另行处理(Ref #214)。
xlorne commented on Nov 10, 2026
补充说明:已在特性分支追加提交 e5a67f08,完善能力文档 docs/capabilities/springboot-starter-script/groovy-runtime.md:
- 新增「多实例部署」章节,明确部署前提:① 注入共享仓储(默认仓储为进程内 HashMap,不提供跨实例共享)② 实现
GroovyScriptCacheSync 跨实例失效同步;
- 提供 Redis pub/sub 失效同步完整实现示例(推荐方案:Redis 仅作失效通知通道,脚本正文存共享数据库,远端实例失效后自动回源);
- 提供 Redis 共享缓存容器实现示例(可选方案,需处理
Class<?> 字段序列化);
- 补充行为说明(回环过滤、SHA256 编译缓存自愈、临时脚本进程内语义)。
另经全仓库排查,其余模块(security 的 JWT/Redis 两种认证模式、data-authorization 的 SQL 解析与权限规则、starter 核心的消息与注册表类)均不存在同类多实例脏读问题,本次仅需调整 script 模块。
xlorne commented on Nov 10, 2026
8.x 版本线修复完成
特性分支已推送:feature/issue-214-groovy-cache-sync-8x(基线 8.2.x,提交 679874a6)
PR 创建入口(目标分支 8.2.x,是否合并由维护者决定):https://github.com/codingapi/springboot-framework/pull/new/feature/issue-214-groovy-cache-sync-8x
按仓库规范做了 JDK 8 代际适配(未直接 cherry-pick):匿名内部类显式泛型、new ArrayList<>() 代替 stream().toList(),提交信息使用 Ref #214(8.x 合入 8.2.x 不会自动关闭 issue)。代码与文档(多实例部署章节 + Redis 实现示例)与 17.x 保持一致。
验证结果(JDK 1.8.0_462 / Corretto 8,字节码 major version 52)
springboot-starter:108/108 通过;springboot-starter-script:96/96 通过(含新增 9 个同步机制单元测试 + 2 个框架事件桥接集成测试)。
⚠️ 需要维护者关注的基线问题(与本修复无关)
./mvnw clean test -P travis 在 JDK 8 下无法完成全量构建:8.2.x 基线根 pom 声明 jsqlparser.version=5.0(字节码版本 55.0 = Java 11),导致 springboot-starter-data-authorization 在 JDK 8 下编译失败(无法访问 net.sf.jsqlparser.expression.Expression,类文件具有错误的版本 55.0, 应为 52.0)。该问题在本次改动之前即存在(本分支未触碰该模块与根 pom),建议另开 issue 评估降级到 JDK 8 兼容版本(如 4.x)及 API 兼容性。
Metadata
Metadata
Development
No branches or pull requestsIssue actions