@@ -10,6 +10,11 @@ symbols: |
10 | 10 | - GroovyScriptRuntime |
11 | 11 | - GroovyScript |
12 | 12 | - GroovyScriptCacheContext |
| 13 | + - GroovyScriptCache |
| 14 | + - GroovyScriptCacheSync |
| 15 | + - GroovyScriptCacheSyncContext |
| 16 | + - GroovyScriptCacheEvent |
| 17 | + - LocalGroovyScriptCacheSync |
13 | 18 | - GroovyScriptRuntimeContext |
14 | 19 | - GroovyScriptController |
15 | 20 | content_hash: deddb103b5a58509ca7c08ace4195760e618f5b07782cfcb857fc1326d4ae526 |
@@ -40,7 +45,10 @@ content_hash: deddb103b5a58509ca7c08ace4195760e618f5b07782cfcb857fc1326d4ae526 |
40 | 45 | | `GroovyScriptRuntime` | 底层执行引擎:GroovyShell 封装、LRU 编译缓存、invoke/run 方法、事务控制 | |
41 | 46 | | `GroovyScriptRuntimeContext` | Runtime 的单例上下文,从配置读取 `shellMaxCacheSize` 初始化 | |
42 | 47 | | `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 与上下文:写路径发布事件,远端实例接收后失效本地缓存 | |
44 | 52 | | `GroovyScriptController` | REST 端点 `/api/groovy-script/*`,提供 compile/getScript/getMetadata/save 接口 | |
45 | 53 |
|
46 | 54 | ### 直接执行脚本 |
@@ -107,6 +115,173 @@ script.save(); |
107 | 115 | | GET | `/api/groovy-script/getMetadata?key=` | 获取脚本元数据 | |
108 | 116 | | POST | `/api/groovy-script/save` | 保存脚本(编译+持久化) | |
109 | 117 |
|
| 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 | + |
110 | 285 | ## 使用实例 |
111 | 286 |
|
112 | 287 | 以下示例展示一个完整的动态折扣计算场景: |
@@ -160,4 +335,4 @@ cached.save(); |
160 | 335 | // 下次 invoke 自动使用新版本 |
161 | 336 | ``` |
162 | 337 |
|
163 | | -缓存机制说明:`GroovyScriptRuntime` 内部以脚本内容的 SHA256 作为缓存 key,相同内容的脚本不会重复编译。当脚本内容变更后,SHA256 值改变,自动触发重新编译。LRU 策略确保内存占用可控,默认上限可通过配置项调整。 |
| 338 | +缓存机制说明:`GroovyScriptRuntime` 内部以脚本内容的 SHA256 作为缓存 key,相同内容的脚本不会重复编译。当脚本内容变更后,SHA256 值改变,自动触发重新编译。LRU 策略确保内存占用可控,默认上限可通过配置项调整。脚本对象缓存的跨实例失效机制见上文[多实例部署](#多实例部署重要)章节(issue #214)。 |