GV

GeezerVison

The World Wide Web, restored to 800 pixels of glory.

Home|About|Gaming cabinet|Archives|How it works|Starter sites

On this site

  • Sign in
  • ActionsCI/CD and automation
  • CodespacesCloud development environments
  • All Features
  • Documentation
  • Training
  • Certifications
  • Build with Copilot
  • Customize Copilot
  • Integrations
  • Trending repos
  • GitHub Sponsors
  • Developer articles
  • Enterprises

This view

  • Original page
  • New address

GeezerVison is showing github.com — fix: Groovy 脚本缓存增加跨实例失效同步与可插拔缓存策略 (8.x/springboot-starter-script) · codingapi/springboot-framework@679874a

File tree

Expand file treeCollapse file tree
Open diff view settings
Filter options
  • docs/capabilities/springboot-starter-script
    • groovy-runtime.md
  • springboot-starter-script/src
    • main/java/com/codingapi/springboot/script
      • GroovyScript.java
      • cache
        • DefaultGroovyScriptCache.java
        • GroovyScriptCache.java
        • GroovyScriptCacheContext.java
        • GroovyScriptCacheEvent.java
        • GroovyScriptCacheSync.java
        • GroovyScriptCacheSyncContext.java
        • LocalGroovyScriptCacheSync.java
    • test/java/com/codingapi/springboot/script/cache
      • GroovyScriptCacheEventBridgeTest.java
      • GroovyScriptCacheSyncContextTest.java
Expand file treeCollapse file tree
Open diff view settings
Collapse file

‎docs/capabilities/springboot-starter-script/groovy-runtime.md‎

Copy file name to clipboardExpand all lines: docs/capabilities/springboot-starter-script/groovy-runtime.md
+177-2Lines changed: 177 additions & 2 deletions
  • Display the source diff
  • Display the rich diff
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ symbols:
1010
- GroovyScriptRuntime
1111
- GroovyScript
1212
- GroovyScriptCacheContext
13+
- GroovyScriptCache
14+
- GroovyScriptCacheSync
15+
- GroovyScriptCacheSyncContext
16+
- GroovyScriptCacheEvent
17+
- LocalGroovyScriptCacheSync
1318
- GroovyScriptRuntimeContext
1419
- GroovyScriptController
1520
content_hash: deddb103b5a58509ca7c08ace4195760e618f5b07782cfcb857fc1326d4ae526
@@ -40,7 +45,10 @@ content_hash: deddb103b5a58509ca7c08ace4195760e618f5b07782cfcb857fc1326d4ae526
4045
| `GroovyScriptRuntime` | 底层执行引擎:GroovyShell 封装、LRU 编译缓存、invoke/run 方法、事务控制 |
4146
| `GroovyScriptRuntimeContext` | Runtime 的单例上下文,从配置读取 `shellMaxCacheSize` 初始化 |
4247
| `GroovyScript` | 脚本领域对象:封装 key/script/method/returnType/binds 等元信息,提供 compile/run/invoke 快捷方法 |
43-
| `GroovyScriptCacheContext` | 脚本对象的 LRU 缓存(最大 10240 条),三级查找:缓存 → 临时存储 → Repository |
48+
| `GroovyScriptCacheContext` | 脚本对象的 LRU 缓存(最大 10240 条),三级查找:缓存 → 临时存储 → Repository;缓存容器可通过 `setCache` 替换 |
49+
| `GroovyScriptCache` / `DefaultGroovyScriptCache` | 缓存容器接口与默认进程内 LRU 实现,多实例部署时可替换为 Redis 等共享缓存 |
50+
| `GroovyScriptCacheEvent` | 脚本缓存变更事件(SAVE / REMOVE),实现 `ISyncEvent`,可被进程内 `IHandler` 监听 |
51+
| `GroovyScriptCacheSync` / `GroovyScriptCacheSyncContext` | 缓存变更跨实例同步 SPI 与上下文:写路径发布事件,远端实例接收后失效本地缓存 |
4452
| `GroovyScriptController` | REST 端点 `/api/groovy-script/*`,提供 compile/getScript/getMetadata/save 接口 |
4553

4654
### 直接执行脚本
@@ -107,6 +115,173 @@ script.save();
107115
| GET | `/api/groovy-script/getMetadata?key=` | 获取脚本元数据 |
108116
| POST | `/api/groovy-script/save` | 保存脚本(编译+持久化) |
109117

118+
## 多实例部署(重要)
119+
120+
脚本对象缓存(`GroovyScriptCacheContext`)与临时脚本缓存都是**进程内**的。多实例部署(水平扩容、双端部署、多副本灰度)时,实例 A 修改脚本后,实例 B 的缓存不会自动感知——如果不做任何配置,B 将永久返回旧脚本(见 issue #214)。
121+
122+
### 部署前提
123+
124+
多实例部署**必须同时满足**以下两点,缺一不可:
125+
126+
1. **共享仓储**:通过 `GroovyScriptRepositoryContext.getInstance().setRepository(...)` 注入基于数据库(或 Redis)的仓储实现。模块默认的 `DefaultGroovyScriptRepository` 是进程内 HashMap,不提供跨实例数据共享;
127+
2. **跨实例失效同步**:实现 `GroovyScriptCacheSync` SPI 广播缓存变更事件,远端实例收到事件后自动失效本地缓存,下次读取回源仓储加载新版本。
128+
129+
单实例部署无需任何配置,默认 `LocalGroovyScriptCacheSync` 保持原有行为。
130+
131+
### Redis pub/sub 失效同步实现示例(推荐方案)
132+
133+
数据正文保存在共享数据库中,Redis 仅承担"失效通知"的广播通道,失效后各实例自动回源数据库加载新版本。该方案实现简单、无需处理脚本内容的序列化兼容问题。
134+
135+
依赖:`spring-boot-starter-data-redis`(版本由 Spring Boot BOM 管理)+ `com.alibaba.fastjson2:fastjson2`。
136+
137+
```java
138+
/**
139+
* 基于 Redis pub/sub 的脚本缓存跨实例同步实现
140+
* 广播通道:脚本保存/删除事件 -> 其他实例失效本地缓存并回源仓储
141+
*/
142+
@Component
143+
public class RedisGroovyScriptCacheSync implements GroovyScriptCacheSync {
144+
145+
public static final String CHANNEL = "groovy-script-cache-sync";
146+
147+
private final StringRedisTemplate redisTemplate;
148+
149+
public RedisGroovyScriptCacheSync(StringRedisTemplate redisTemplate) {
150+
this.redisTemplate = redisTemplate;
151+
}
152+
153+
@Override
154+
public void publish(GroovyScriptCacheEvent event) {
155+
// 事件包含 source(来源实例标识),远端实例 receive 时会自动过滤自身事件避免回环
156+
redisTemplate.convertAndSend(CHANNEL, JSON.toJSONString(event));
157+
}
158+
159+
/**
160+
* 订阅回调:收到远端事件后失效本地缓存
161+
* 由下方 RedisMessageListenerContainer 配置调用
162+
*/
163+
public void onMessage(String payload) {
164+
GroovyScriptCacheEvent event = JSON.parseObject(payload, GroovyScriptCacheEvent.class);
165+
// receive 内部:过滤本实例事件 -> 按事件类型 remove 本地缓存 key
166+
GroovyScriptCacheSyncContext.getInstance().receive(event);
167+
}
168+
}
169+
```
170+
171+
```java
172+
/**
173+
* 注册 Redis 频道订阅
174+
*/
175+
@Configuration
176+
public class GroovyScriptCacheSyncConfiguration {
177+
178+
@Bean
179+
public RedisMessageListenerContainer groovyScriptCacheSyncListenerContainer(
180+
RedisConnectionFactory connectionFactory,
181+
RedisGroovyScriptCacheSync sync) {
182+
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
183+
container.setConnectionFactory(connectionFactory);
184+
container.addMessageListener((message, pattern) -> {
185+
String payload = new String(message.getBody(), StandardCharsets.UTF_8);
186+
sync.onMessage(payload);
187+
}, new ChannelTopic(RedisGroovyScriptCacheSync.CHANNEL));
188+
return container;
189+
}
190+
}
191+
```
192+
193+
> 说明:若事件序列化使用 JSON,`GroovyScriptCacheEvent` 的字段(type/key/source/updateTime)均为可序列化类型;`GroovyScriptCacheEvent` 同时实现了框架的 `ISyncEvent`,进程内业务可通过 `IHandler<GroovyScriptCacheEvent>` 监听脚本变更,与跨实例同步互不影响。
194+
195+
### Redis 共享缓存容器实现示例(可选)
196+
197+
如果希望连脚本对象的读取也走 Redis(例如仓储回源成本高的场景),可以实现 `GroovyScriptCache` 接口替换默认 LRU 容器:
198+
199+
```java
200+
/**
201+
* 基于 Redis 的脚本缓存容器
202+
* 注意:GroovyScript 含 Class<?> 类型字段(returnType/binds/requests),
203+
* 序列化时需记录类名、反序列化时按类名反射加载,此处仅示意骨架
204+
*/
205+
public class RedisGroovyScriptCache implements GroovyScriptCache {
206+
207+
private final StringRedisTemplate redisTemplate;
208+
private final Duration ttl = Duration.ofMinutes(30);
209+
210+
public RedisGroovyScriptCache(StringRedisTemplate redisTemplate) {
211+
this.redisTemplate = redisTemplate;
212+
}
213+
214+
private String cacheKey(String key) {
215+
return "groovy-script:cache:" + key;
216+
}
217+
218+
@Override
219+
public void save(GroovyScript script) {
220+
if (script != null) {
221+
redisTemplate.opsForValue().set(cacheKey(script.getKey()), serialize(script), ttl);
222+
}
223+
}
224+
225+
@Override
226+
public GroovyScript get(String key) {
227+
String payload = redisTemplate.opsForValue().get(cacheKey(key));
228+
return payload == null ? null : deserialize(payload);
229+
}
230+
231+
@Override
232+
public void remove(String key) {
233+
redisTemplate.delete(cacheKey(key));
234+
}
235+
236+
@Override
237+
public List<String> keys() {
238+
// 生产环境建议使用 SCAN 遍历,避免 KEYS 阻塞
239+
List<String> keys = new ArrayList<String>();
240+
Set<String> cachedKeys = redisTemplate.keys("groovy-script:cache:*");
241+
if (cachedKeys != null) {
242+
for (String k : cachedKeys) {
243+
keys.add(k.substring("groovy-script:cache:".length()));
244+
}
245+
}
246+
return keys;
247+
}
248+
249+
@Override
250+
public int count() {
251+
return keys().size();
252+
}
253+
254+
@Override
255+
public void clear() {
256+
for (String key : keys()) {
257+
redisTemplate.delete("groovy-script:cache:" + key);
258+
}
259+
}
260+
261+
// serialize/deserialize 需处理 Class<?> 字段(记录类名 -> 反射加载),此处从略
262+
}
263+
```
264+
265+
```java
266+
// 启用时替换默认缓存容器(通常在配置类中执行一次)
267+
GroovyScriptCacheContext.getInstance().setCache(new RedisGroovyScriptCache(redisTemplate));
268+
```
269+
270+
**方案选择建议**:
271+
272+
| 方案 | 适用场景 | 复杂度 |
273+
|------|---------|--------|
274+
| Redis pub/sub 失效通知(推荐) | 脚本正文在共享数据库中,改动频率低、读取频率高 | 低 |
275+
| Redis 共享缓存容器 + pub/sub | 仓储回源成本高,或数据库无法承载读取压力 | 中(需处理 `Class<?>` 字段序列化) |
276+
277+
### 行为说明与注意事项
278+
279+
- 收到远端 `SAVE`/`REMOVE` 事件后,本实例仅失效对应 key 的缓存,**下次读取自动回源仓储**,无需主动拉取脚本内容;
280+
- 已编译 Script 的运行时缓存(`GroovyScriptRuntime`)以脚本内容 SHA256 为 key,内容变化后自动使用新 key 编译,无需额外失效;
281+
- `GroovyScriptCacheEvent.source` 为各实例的 UUID 标识(`GroovyScriptCacheSyncContext.getId()`),`receive` 会忽略本实例的事件,避免广播回环;
282+
- 临时脚本(`temp()`)与默认临时仓储均为进程内设计,临时编辑结果对其他实例不可见,属于预期行为;
283+
- 默认 LRU 容器为进程内实现,容量上限 10240 条;多实例下即使不替换为 Redis 缓存容器,只要接入了失效同步,各实例也会在收到事件后回源加载最新脚本。
284+
110285
## 使用实例
111286

112287
以下示例展示一个完整的动态折扣计算场景:
@@ -160,4 +335,4 @@ cached.save();
160335
// 下次 invoke 自动使用新版本
161336
```
162337

163-
缓存机制说明:`GroovyScriptRuntime` 内部以脚本内容的 SHA256 作为缓存 key,相同内容的脚本不会重复编译。当脚本内容变更后,SHA256 值改变,自动触发重新编译。LRU 策略确保内存占用可控,默认上限可通过配置项调整。
338+
缓存机制说明:`GroovyScriptRuntime` 内部以脚本内容的 SHA256 作为缓存 key,相同内容的脚本不会重复编译。当脚本内容变更后,SHA256 值改变,自动触发重新编译。LRU 策略确保内存占用可控,默认上限可通过配置项调整。脚本对象缓存的跨实例失效机制见上文[多实例部署](#多实例部署重要)章节(issue #214)。
Collapse file

‎springboot-starter-script/src/main/java/com/codingapi/springboot/script/GroovyScript.java‎

Copy file name to clipboardExpand all lines: springboot-starter-script/src/main/java/com/codingapi/springboot/script/GroovyScript.java
+15Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
package com.codingapi.springboot.script;
22

3+
import com.codingapi.springboot.framework.event.EventPusher;
4+
import com.codingapi.springboot.script.cache.GroovyScriptCacheEvent;
5+
import com.codingapi.springboot.script.cache.GroovyScriptCacheSyncContext;
36
import com.codingapi.springboot.script.temp.TempGroovyScriptContext;
47
import com.codingapi.springboot.script.meta.GroovyMetadata;
58
import com.codingapi.springboot.script.repository.GroovyScriptRepositoryContext;
@@ -161,21 +164,33 @@ public void temp() {
161164

162165
/**
163166
* 保存对象
167+
* 保存后发布缓存变更事件,其他实例收到事件后失效本地缓存
164168
*/
165169
public void save() {
166170
this.updateTime = System.currentTimeMillis();
167171
GroovyScriptRepositoryContext.getInstance().save(this);
168172
// 清理临时数据
169173
TempGroovyScriptContext.getInstance().remove(this.getKey());
174+
// 发布脚本缓存变更事件:跨进程广播由 SPI 实现,进程内监听由框架事件分发
175+
GroovyScriptCacheEvent event = GroovyScriptCacheEvent.save(this.getKey(),
176+
GroovyScriptCacheSyncContext.getInstance().getId(), this.updateTime);
177+
GroovyScriptCacheSyncContext.getInstance().publish(event);
178+
EventPusher.push(event);
170179
}
171180

172181
/**
173182
* 删除对象
183+
* 删除后发布缓存变更事件,其他实例收到事件后失效本地缓存
174184
*/
175185
public void remove() {
176186
GroovyScriptRepositoryContext.getInstance().delete(this.getKey());
177187
// 清理临时数据
178188
TempGroovyScriptContext.getInstance().remove(this.getKey());
189+
// 发布脚本缓存变更事件:跨进程广播由 SPI 实现,进程内监听由框架事件分发
190+
GroovyScriptCacheEvent event = GroovyScriptCacheEvent.remove(this.getKey(),
191+
GroovyScriptCacheSyncContext.getInstance().getId());
192+
GroovyScriptCacheSyncContext.getInstance().publish(event);
193+
EventPusher.push(event);
179194
}
180195

181196
/**
Collapse file

‎springboot-starter-script/src/main/java/com/codingapi/springboot/script/cache/DefaultGroovyScriptCache.java‎

Copy file name to clipboard
+55Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package com.codingapi.springboot.script.cache;
2+
3+
import com.codingapi.springboot.script.GroovyScript;
4+
5+
import java.util.ArrayList;
6+
import java.util.LinkedHashMap;
7+
import java.util.List;
8+
import java.util.Map;
9+
10+
/**
11+
* 默认脚本缓存容器实现:进程内 LRU 缓存
12+
*/
13+
public class DefaultGroovyScriptCache implements GroovyScriptCache {
14+
15+
private final static int MAX_CACHE_SIZE = 10 * 1024;
16+
17+
private final Map<String, GroovyScript> cache = new LinkedHashMap<String, GroovyScript>(16, 0.75f, true) {
18+
@Override
19+
protected boolean removeEldestEntry(Map.Entry<String, GroovyScript> eldest) {
20+
return size() > MAX_CACHE_SIZE;
21+
}
22+
};
23+
24+
@Override
25+
public void save(GroovyScript script) {
26+
if (script != null) {
27+
this.cache.put(script.getKey(), script);
28+
}
29+
}
30+
31+
@Override
32+
public GroovyScript get(String key) {
33+
return this.cache.get(key);
34+
}
35+
36+
@Override
37+
public void remove(String key) {
38+
this.cache.remove(key);
39+
}
40+
41+
@Override
42+
public List<String> keys() {
43+
return new ArrayList<String>(this.cache.keySet());
44+
}
45+
46+
@Override
47+
public int count() {
48+
return this.cache.size();
49+
}
50+
51+
@Override
52+
public void clear() {
53+
this.cache.clear();
54+
}
55+
}
Collapse file

‎springboot-starter-script/src/main/java/com/codingapi/springboot/script/cache/GroovyScriptCache.java‎

Copy file name to clipboard
+54Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package com.codingapi.springboot.script.cache;
2+
3+
import com.codingapi.springboot.script.GroovyScript;
4+
5+
import java.util.List;
6+
7+
/**
8+
* 脚本缓存容器接口
9+
* 默认实现为进程内 LRU 缓存({@link DefaultGroovyScriptCache}),
10+
* 多实例部署时可替换为共享存储实现(如 Redis 缓存),解决跨进程数据一致性问题
11+
*/
12+
public interface GroovyScriptCache {
13+
14+
/**
15+
* 保存脚本对象
16+
*
17+
* @param script 脚本对象
18+
*/
19+
void save(GroovyScript script);
20+
21+
/**
22+
* 获取脚本对象
23+
*
24+
* @param key 脚本key
25+
* @return 脚本对象,不存在时返回 null
26+
*/
27+
GroovyScript get(String key);
28+
29+
/**
30+
* 移除脚本对象
31+
*
32+
* @param key 脚本key
33+
*/
34+
void remove(String key);
35+
36+
/**
37+
* 获取脚本keys
38+
*
39+
* @return 脚本key列表
40+
*/
41+
List<String> keys();
42+
43+
/**
44+
* 脚本总数量
45+
*
46+
* @return 总数量
47+
*/
48+
int count();
49+
50+
/**
51+
* 清空缓存
52+
*/
53+
void clear();
54+
}

0 commit comments

Comments
0 (0)
You are visitor 01402503 — GeezerVison does not store the pages you look up.
Best viewed with Netscape Navigator 4.7 or Internet Explorer 6 at 800×600. No cookies required.