From 1d97442505f123930814c30714a062fc1bfc1279 Mon Sep 17 00:00:00 2001 From: lorne <1991wangliang@gmail.com> Date: Thu, 6 Aug 2026 21:23:06 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=B8=B4=E6=97=B6?= =?UTF-8?q?=E8=84=9A=E6=9C=AC=E7=BA=BF=E7=A8=8B=E6=B3=84=E6=BC=8F=EF=BC=88?= =?UTF-8?q?OOM=20=E6=A0=B9=E5=9B=A0=EF=BC=89=E5=B9=B6=E5=8D=87=E7=BA=A7?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E8=87=B3=203.4.55?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TempGroovyScriptContext 改用共享 daemon 调度线程,根治每脚本 new Timer() 的原生线程泄漏 - 覆盖/删除/清空/cache-miss 时 cancel 定时任务,ConcurrentHashMap 防并发 - 修正绝对到期时间戳误传为相对延迟的清理 bug(原实现约 55 年后才触发,自动清理从未生效) - 原子化 remove(key, value) 防止过期任务误删被刷新脚本 - 新增回归测试 TempGroovyScriptContextTest(线程数不增长 + 到期自动清理) - 版本号 3.4.54 → 3.4.55(全部 pom + CLAUDE.md) Co-Authored-By: Claude --- CLAUDE.md | 2 +- .../example-app-cmd-domain/pom.xml | 2 +- .../example-app/example-app-cmd-meta/pom.xml | 2 +- example/example-app/example-app-query/pom.xml | 2 +- example/example-app/pom.xml | 2 +- .../example-domain-leave/pom.xml | 2 +- .../example-domain-user/pom.xml | 2 +- example/example-domain/pom.xml | 2 +- .../example-infra/example-infra-flow/pom.xml | 2 +- .../example-infra/example-infra-jpa/pom.xml | 2 +- .../example-infra-security/pom.xml | 2 +- example/example-infra/pom.xml | 2 +- example/example-interface/pom.xml | 2 +- example/example-server/pom.xml | 2 +- example/pom.xml | 4 +- pom.xml | 2 +- springboot-starter-data-authorization/pom.xml | 2 +- springboot-starter-data-fast/pom.xml | 2 +- springboot-starter-flow/pom.xml | 2 +- springboot-starter-script/pom.xml | 2 +- .../script/temp/TempGroovyScriptContext.java | 89 +++++++++++++++---- .../temp/TempGroovyScriptContextTest.java | 84 +++++++++++++++++ springboot-starter-security/pom.xml | 2 +- springboot-starter/pom.xml | 2 +- 24 files changed, 178 insertions(+), 41 deletions(-) create mode 100644 springboot-starter-script/src/test/java/com/codingapi/springboot/script/temp/TempGroovyScriptContextTest.java diff --git a/CLAUDE.md b/CLAUDE.md index 551e1950..55d2c51b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## 项目概述 -springboot-framework 是一个基于 Spring Boot 3.3.5(Java 17)的多模块企业级开发框架,由 CodingApi 团队维护。核心定位是为领域驱动设计(DDD)与事件风暴提供开箱即用的基础设施,当前版本为 `3.4.54`。 +springboot-framework 是一个基于 Spring Boot 3.3.5(Java 17)的多模块企业级开发框架,由 CodingApi 团队维护。核心定位是为领域驱动设计(DDD)与事件风暴提供开箱即用的基础设施,当前版本为 `3.4.55`。 **回复语言**:请使用中文进行回答。 diff --git a/example/example-app/example-app-cmd-domain/pom.xml b/example/example-app/example-app-cmd-domain/pom.xml index fb233989..6fd7afd2 100644 --- a/example/example-app/example-app-cmd-domain/pom.xml +++ b/example/example-app/example-app-cmd-domain/pom.xml @@ -6,7 +6,7 @@ com.codingapi.springboot example-app - 3.4.54 + 3.4.55 ../pom.xml diff --git a/example/example-app/example-app-cmd-meta/pom.xml b/example/example-app/example-app-cmd-meta/pom.xml index 9ab44d9a..dc6b15fc 100644 --- a/example/example-app/example-app-cmd-meta/pom.xml +++ b/example/example-app/example-app-cmd-meta/pom.xml @@ -6,7 +6,7 @@ com.codingapi.springboot example-app - 3.4.54 + 3.4.55 ../pom.xml diff --git a/example/example-app/example-app-query/pom.xml b/example/example-app/example-app-query/pom.xml index f8f02e4d..982facb8 100644 --- a/example/example-app/example-app-query/pom.xml +++ b/example/example-app/example-app-query/pom.xml @@ -6,7 +6,7 @@ com.codingapi.springboot example-app - 3.4.54 + 3.4.55 ../pom.xml diff --git a/example/example-app/pom.xml b/example/example-app/pom.xml index 95f11dce..111b8a50 100644 --- a/example/example-app/pom.xml +++ b/example/example-app/pom.xml @@ -6,7 +6,7 @@ com.codingapi.springboot springboot-example - 3.4.54 + 3.4.55 ../pom.xml pom diff --git a/example/example-domain/example-domain-leave/pom.xml b/example/example-domain/example-domain-leave/pom.xml index f5c1734e..6717974d 100644 --- a/example/example-domain/example-domain-leave/pom.xml +++ b/example/example-domain/example-domain-leave/pom.xml @@ -6,7 +6,7 @@ com.codingapi.springboot example-domain - 3.4.54 + 3.4.55 ../pom.xml diff --git a/example/example-domain/example-domain-user/pom.xml b/example/example-domain/example-domain-user/pom.xml index 2477be54..cd68cdab 100644 --- a/example/example-domain/example-domain-user/pom.xml +++ b/example/example-domain/example-domain-user/pom.xml @@ -6,7 +6,7 @@ com.codingapi.springboot example-domain - 3.4.54 + 3.4.55 ../pom.xml diff --git a/example/example-domain/pom.xml b/example/example-domain/pom.xml index c47712d3..085db923 100644 --- a/example/example-domain/pom.xml +++ b/example/example-domain/pom.xml @@ -5,7 +5,7 @@ com.codingapi.springboot springboot-example - 3.4.54 + 3.4.55 ../pom.xml 4.0.0 diff --git a/example/example-infra/example-infra-flow/pom.xml b/example/example-infra/example-infra-flow/pom.xml index da43affa..6ca6db43 100644 --- a/example/example-infra/example-infra-flow/pom.xml +++ b/example/example-infra/example-infra-flow/pom.xml @@ -5,7 +5,7 @@ com.codingapi.springboot example-infra - 3.4.54 + 3.4.55 ../pom.xml diff --git a/example/example-infra/example-infra-jpa/pom.xml b/example/example-infra/example-infra-jpa/pom.xml index f5f1c4e2..3b0fe0c4 100644 --- a/example/example-infra/example-infra-jpa/pom.xml +++ b/example/example-infra/example-infra-jpa/pom.xml @@ -5,7 +5,7 @@ com.codingapi.springboot example-infra - 3.4.54 + 3.4.55 ../pom.xml diff --git a/example/example-infra/example-infra-security/pom.xml b/example/example-infra/example-infra-security/pom.xml index d0fdab13..6d13113e 100644 --- a/example/example-infra/example-infra-security/pom.xml +++ b/example/example-infra/example-infra-security/pom.xml @@ -6,7 +6,7 @@ com.codingapi.springboot example-infra - 3.4.54 + 3.4.55 ../pom.xml diff --git a/example/example-infra/pom.xml b/example/example-infra/pom.xml index 637e53ca..138f14a8 100644 --- a/example/example-infra/pom.xml +++ b/example/example-infra/pom.xml @@ -6,7 +6,7 @@ com.codingapi.springboot springboot-example - 3.4.54 + 3.4.55 ../pom.xml pom diff --git a/example/example-interface/pom.xml b/example/example-interface/pom.xml index 5eafea99..9c99fbb0 100644 --- a/example/example-interface/pom.xml +++ b/example/example-interface/pom.xml @@ -6,7 +6,7 @@ com.codingapi.springboot springboot-example - 3.4.54 + 3.4.55 example-interface diff --git a/example/example-server/pom.xml b/example/example-server/pom.xml index b5c8954c..73e58ece 100644 --- a/example/example-server/pom.xml +++ b/example/example-server/pom.xml @@ -5,7 +5,7 @@ springboot-example com.codingapi.springboot - 3.4.54 + 3.4.55 4.0.0 diff --git a/example/pom.xml b/example/pom.xml index f1281550..ea1257c2 100644 --- a/example/pom.xml +++ b/example/pom.xml @@ -14,12 +14,12 @@ com.codingapi.springboot springboot-parent - 3.3.5 + 3.4.55 ../pom.xml springboot-example - 3.4.54 + 3.4.55 springboot-example springboot-example project for Spring Boot diff --git a/pom.xml b/pom.xml index 82a3b015..a2d03bc9 100644 --- a/pom.xml +++ b/pom.xml @@ -12,7 +12,7 @@ com.codingapi.springboot springboot-parent - 3.4.54 + 3.4.55 https://github.com/codingapi/springboot-framewrok springboot-parent diff --git a/springboot-starter-data-authorization/pom.xml b/springboot-starter-data-authorization/pom.xml index d673b106..97370691 100644 --- a/springboot-starter-data-authorization/pom.xml +++ b/springboot-starter-data-authorization/pom.xml @@ -6,7 +6,7 @@ com.codingapi.springboot springboot-parent - 3.4.54 + 3.4.55 springboot-starter-data-authorization diff --git a/springboot-starter-data-fast/pom.xml b/springboot-starter-data-fast/pom.xml index e1e9666d..e94c4e69 100644 --- a/springboot-starter-data-fast/pom.xml +++ b/springboot-starter-data-fast/pom.xml @@ -5,7 +5,7 @@ springboot-parent com.codingapi.springboot - 3.4.54 + 3.4.55 4.0.0 diff --git a/springboot-starter-flow/pom.xml b/springboot-starter-flow/pom.xml index ba4a583f..0b58df48 100644 --- a/springboot-starter-flow/pom.xml +++ b/springboot-starter-flow/pom.xml @@ -6,7 +6,7 @@ springboot-parent com.codingapi.springboot - 3.4.54 + 3.4.55 springboot-starter-flow diff --git a/springboot-starter-script/pom.xml b/springboot-starter-script/pom.xml index d9c4853b..07954d4e 100644 --- a/springboot-starter-script/pom.xml +++ b/springboot-starter-script/pom.xml @@ -6,7 +6,7 @@ com.codingapi.springboot springboot-parent - 3.4.54 + 3.4.55 springboot-starter-script diff --git a/springboot-starter-script/src/main/java/com/codingapi/springboot/script/temp/TempGroovyScriptContext.java b/springboot-starter-script/src/main/java/com/codingapi/springboot/script/temp/TempGroovyScriptContext.java index 4cad69aa..e12ca04c 100644 --- a/springboot-starter-script/src/main/java/com/codingapi/springboot/script/temp/TempGroovyScriptContext.java +++ b/springboot-starter-script/src/main/java/com/codingapi/springboot/script/temp/TempGroovyScriptContext.java @@ -5,7 +5,13 @@ import com.codingapi.springboot.script.repository.TempGroovyScriptRepositoryContext; import lombok.Getter; -import java.util.*; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; /** * 临时脚本数据上下文管理对象 @@ -15,10 +21,20 @@ public class TempGroovyScriptContext { @Getter private final static TempGroovyScriptContext instance = new TempGroovyScriptContext(); + /** + * 共享的清理调度线程(daemon) + * 所有临时脚本共用一个调度线程,避免每个脚本创建原生线程导致线程泄漏 + */ + private final static ScheduledExecutorService SCHEDULER = Executors.newSingleThreadScheduledExecutor(r -> { + Thread thread = new Thread(r, "temp-groovy-script-clear"); + thread.setDaemon(true); + return thread; + }); + private final Map cache; private TempGroovyScriptContext() { - this.cache = new HashMap<>(); + this.cache = new ConcurrentHashMap<>(); } private static class ClearJob { @@ -26,30 +42,37 @@ private static class ClearJob { @Getter private final TempGroovyScript tempGroovyScript; - private final Timer timer; + private final ScheduledFuture future; public ClearJob(TempGroovyScript groovyScript) { this.tempGroovyScript = groovyScript; - this.timer = new Timer(); - this.initTimer(); + // clearTime 为绝对到期时间戳,需换算为相对延迟 + long delay = groovyScript.getClearTime() - System.currentTimeMillis(); + this.future = SCHEDULER.schedule(this::expire, delay, TimeUnit.MILLISECONDS); } public GroovyScript getGroovyScript() { return this.tempGroovyScript.getGroovyScript(); } - private void initTimer() { - this.timer.schedule(new TimerTask() { - @Override - public void run() { - TempGroovyScriptContext.getInstance().remove(getKey()); - } - }, tempGroovyScript.getClearTime()); - } - public String getKey() { return tempGroovyScript.getKey(); } + + /** + * 到期清理 + * 仅当缓存中仍是当前任务时才删除,避免过期任务误删已被刷新的脚本 + */ + private void expire() { + TempGroovyScriptContext.getInstance().removeIfCurrent(getKey(), this); + } + + /** + * 取消定时清理任务 + */ + private void cancel() { + this.future.cancel(false); + } } /** @@ -60,7 +83,17 @@ public String getKey() { public void save(GroovyScript script) { if (script != null) { long tempValidTime = PropertiesContext.getInstance().getTempValidTime(); - this.cache.put(script.getKey(), new ClearJob(new TempGroovyScript(script, tempValidTime + System.currentTimeMillis()))); + this.put(script.getKey(), new ClearJob(new TempGroovyScript(script, tempValidTime + System.currentTimeMillis()))); + } + } + + /** + * 写入缓存,覆盖时取消旧任务的定时清理 + */ + private void put(String key, ClearJob job) { + ClearJob previous = this.cache.put(key, job); + if (previous != null) { + previous.cancel(); } } @@ -73,7 +106,7 @@ public void loadAll(List groovyScripts) { if (groovyScript.isExpired()) { this.remove(groovyScript.getKey()); } else { - this.cache.put(groovyScript.getKey(), new ClearJob(groovyScript)); + this.put(groovyScript.getKey(), new ClearJob(groovyScript)); } } } @@ -93,10 +126,23 @@ public List findAll() { * @param key 脚本key */ public void remove(String key) { - this.cache.remove(key); + ClearJob job = this.cache.remove(key); + if (job != null) { + job.cancel(); + } TempGroovyScriptRepositoryContext.getInstance().delete(key); } + /** + * 仅当缓存中的任务仍为当前任务时删除,防止过期任务误删已被刷新的脚本。 + * 使用 ConcurrentHashMap.remove(key, value) 原子判断 + 删除,避免 check-then-act 竞态 + */ + private void removeIfCurrent(String key, ClearJob job) { + if (this.cache.remove(key, job)) { + job.cancel(); + TempGroovyScriptRepositoryContext.getInstance().delete(key); + } + } /** * 脚本总数量 @@ -118,7 +164,11 @@ public GroovyScript getGroovyScript(String key) { if (job == null) { TempGroovyScript groovyScript = TempGroovyScriptRepositoryContext.getInstance().get(key); if (groovyScript != null) { - this.cache.put(key, new ClearJob(groovyScript)); + if (groovyScript.isExpired()) { + this.remove(key); + return null; + } + this.put(key, new ClearJob(groovyScript)); return groovyScript.getGroovyScript(); } return null; @@ -131,6 +181,9 @@ public GroovyScript getGroovyScript(String key) { * 清空脚本数据 */ public void clear() { + for (ClearJob job : this.cache.values()) { + job.cancel(); + } this.cache.clear(); } } diff --git a/springboot-starter-script/src/test/java/com/codingapi/springboot/script/temp/TempGroovyScriptContextTest.java b/springboot-starter-script/src/test/java/com/codingapi/springboot/script/temp/TempGroovyScriptContextTest.java new file mode 100644 index 00000000..d7fbd7c9 --- /dev/null +++ b/springboot-starter-script/src/test/java/com/codingapi/springboot/script/temp/TempGroovyScriptContextTest.java @@ -0,0 +1,84 @@ +package com.codingapi.springboot.script.temp; + +import com.codingapi.springboot.script.GroovyScript; +import com.codingapi.springboot.script.properties.GroovyScriptProperties; +import com.codingapi.springboot.script.properties.PropertiesContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * 回归测试:临时脚本不得为每个脚本创建原生线程(OOM 根因), + * 且脚本到期后应被自动清理。 + */ +class TempGroovyScriptContextTest { + + private TempGroovyScriptContext context; + + @BeforeEach + void setUp() { + context = TempGroovyScriptContext.getInstance(); + // 清理上一用例残留的定时任务 + context.clear(); + // 预热共享调度线程,避免首个任务创建线程影响基线统计 + context.save(script("__warmup__")); + context.remove("__warmup__"); + } + + @AfterEach + void tearDown() { + // 清理定时任务并恢复默认配置,避免泄漏到同 JVM 的其他测试 + context.clear(); + PropertiesContext.getInstance().setProperties(new GroovyScriptProperties()); + } + + private GroovyScript script(String key) { + return GroovyScript.builder(key).script("return 1;").build(); + } + + @Test + void saveManyScriptsShouldNotCreateThreads() { + int threadsBefore = Thread.getAllStackTraces().size(); + + for (int i = 0; i < 200; i++) { + context.save(script("key-" + i)); + } + + assertEquals(200, context.count()); + assertEquals(threadsBefore, Thread.getAllStackTraces().size(), + "注册 200 个临时脚本不应创建任何新线程"); + } + + @Test + void overwriteRemoveClearShouldNotCreateThreads() { + int threadsBefore = Thread.getAllStackTraces().size(); + + for (int i = 0; i < 100; i++) { + context.save(script("key-" + i)); + context.save(script("key-" + i)); // 覆盖刷新 + } + for (int i = 0; i < 100; i++) { + context.remove("key-" + i); + } + context.clear(); + + assertEquals(0, context.count()); + assertEquals(threadsBefore, Thread.getAllStackTraces().size(), + "覆盖/删除/清空不应创建新线程"); + } + + @Test + void expiredScriptShouldBeRemovedAutomatically() throws InterruptedException { + GroovyScriptProperties properties = new GroovyScriptProperties(); + properties.setTempValidTime(300); + PropertiesContext.getInstance().setProperties(properties); + + context.save(script("expire-key")); + assertEquals(1, context.count()); + + Thread.sleep(1500); + assertEquals(0, context.count(), "脚本到期后应被自动清理"); + } +} \ No newline at end of file diff --git a/springboot-starter-security/pom.xml b/springboot-starter-security/pom.xml index ba60d6d0..596f4df7 100644 --- a/springboot-starter-security/pom.xml +++ b/springboot-starter-security/pom.xml @@ -6,7 +6,7 @@ springboot-parent com.codingapi.springboot - 3.4.54 + 3.4.55 springboot-starter-security diff --git a/springboot-starter/pom.xml b/springboot-starter/pom.xml index 8e3937d6..4aed573e 100644 --- a/springboot-starter/pom.xml +++ b/springboot-starter/pom.xml @@ -5,7 +5,7 @@ com.codingapi.springboot springboot-parent - 3.4.54 + 3.4.55 springboot-starter