en un clic
async-task-pattern
// 当 API/任务可能执行超过 10 秒(批量数据处理、远程 API 批量调用、全表扫描、跨租户聚合)时触发。防止同步接口被网关 30s 超时切断、用户重复点击触发并发、状态缓存内存泄漏等问题。提供异步任务状态机标准模板。
// 当 API/任务可能执行超过 10 秒(批量数据处理、远程 API 批量调用、全表扫描、跨租户聚合)时触发。防止同步接口被网关 30s 超时切断、用户重复点击触发并发、状态缓存内存泄漏等问题。提供异步任务状态机标准模板。
一键安装 cc-use-exp 配置体系到 Codex CLI
结构化 Codex 配置与任务状态检查工作流,适用于显式 status、配置诊断、同步结果核对或任务盘点场景;聚焦 Codex 配置与项目内 .codex 任务状态。
当设计或修改 REST API 响应结构、处理 API 返回值,或生成 Excel/CSV/PDF/对账文件等下游产物时触发。防止 API 设计缺陷导致的字段错位、类型歧义,以及生成产物时关键字段缺失但静默成功的问题。
Bash 脚本与系统命令规范。禁止行尾注释,强制使用 tee/heredoc 写入,提升命令执行的可维护性。
当编写新模块、设计接口、重构代码或代码审查时触发。提供经典模块化六原则检查清单(大小适中/调用深度/扇入扇出/边界清晰/作用域内聚/可预测性),适用于 PR/Review/新模块设计场景。
当重构涉及字段映射(dataIndex、枚举映射、类型转换)时触发。防止字段名推测错误,确保字段映射的正确性。
| name | async-task-pattern |
| description | 当 API/任务可能执行超过 10 秒(批量数据处理、远程 API 批量调用、全表扫描、跨租户聚合)时触发。防止同步接口被网关 30s 超时切断、用户重复点击触发并发、状态缓存内存泄漏等问题。提供异步任务状态机标准模板。 |
当一个操作可能跑超过 10 秒,必须改成 triggerAsync() + getStatus() 的异步状态机模式。
不要让用户盯着一个会超时的 loading 转圈,更不要让他点第二下。
与
query-performance-safety配合:先用后者把同步性能压到能跑完的范围;只有"再优化也压不到 10s 以内"时,才走本规范。
只要满足任一条件,就属于本 skill 的覆盖范围:
场景: 6000 条商品查重,同步接口 30s 超时;前端无回执,用户重复点击。
// ❌ HTTP 请求线程内串行跑 N 次远程调用 + JPA 写库
@PostMapping("/duplicates/check")
public ApiResponse<Map<String, List<ProductDTO>>> checkDuplicates() {
return ApiResponse.success(productService.findDuplicateProducts());
// 6000 条 × 4 次隐藏查询 = 24000 次 SQL,必然 > 30s
}
后果:
@Service
public class DuplicateChecker {
private static final long FINISHED_STATE_TTL_MINUTES = 60;
private final Executor executor;
private final ProductService productService;
// 关键:状态缓存按租户隔离,永远不要让它无界增长
private final Map<Long, CheckState> tenantStates = new ConcurrentHashMap<>();
public CheckState triggerAsync() {
evictExpiredStates();
Long tenantId = TenantContext.getTenantId();
// 同租户已 RUNNING:返回当前状态,不重复启动
CheckState existing = tenantStates.get(tenantId);
if (existing != null && existing.getStatus() == Status.RUNNING) {
return existing.snapshot();
}
CheckState state = new CheckState();
state.setStatus(Status.RUNNING);
state.setStartedAt(LocalDateTime.now());
tenantStates.put(tenantId, state);
executor.execute(() -> {
try {
TenantContext.setTenantId(tenantId); // 子线程必须恢复上下文
run(tenantId, state);
} finally {
TenantContext.clear();
}
});
return state.snapshot();
}
public CheckState getStatus() {
evictExpiredStates();
Long tenantId = TenantContext.getTenantId();
CheckState state = tenantStates.get(tenantId);
if (state == null) {
CheckState idle = new CheckState();
idle.setStatus(Status.IDLE);
return idle;
}
return state.snapshot();
}
private void evictExpiredStates() {
LocalDateTime threshold = LocalDateTime.now().minusMinutes(FINISHED_STATE_TTL_MINUTES);
tenantStates.entrySet().removeIf(entry -> {
CheckState s = entry.getValue();
return s.getStatus() != Status.RUNNING
&& s.getFinishedAt() != null
&& s.getFinishedAt().isBefore(threshold);
});
}
public enum Status { IDLE, RUNNING, SUCCESS, FAILED }
}
// Controller 立即返回
@PostMapping("/duplicates/check")
public ApiResponse<CheckState> trigger() {
return ApiResponse.success("已触发", duplicateChecker.triggerAsync());
}
@GetMapping("/duplicates/check/status")
public ApiResponse<CheckState> status() {
return ApiResponse.success(duplicateChecker.getStatus());
}
根因:ConcurrentHashMap<Long, State> 作为长生命周期缓存,每个进入过的租户都留下一条记录,FINISHED 后也不清理。
// ❌ 错误:永远不清理
private final Map<Long, RefreshState> tenantStates = new ConcurrentHashMap<>();
// 多租户 SaaS 跑 3 个月后:1000 个租户 × 平均 2KB state = 内存只增不减
// 若 state 里还带着完整结果集(duplicateGroups Map<String,List>)→ 单租户就可能 MB 级
任选其一(推荐第 1 个,最简单):
| 策略 | 实现 | 适用 |
|---|---|---|
| 入口清理 + TTL(推荐) | 每次 triggerAsync/getStatus 时遍历清理 finishedAt < now - 1h 的非 RUNNING 状态 | 状态数量 < 1000,访问频率高 |
| 定时任务清理 | @Scheduled(fixedRate = 600000) 每 10 分钟扫描 | 状态数量大,访问频率低 |
| LRU 限容 | Caffeine.newBuilder().maximumSize(500).expireAfterWrite(1, HOURS) | 状态超大(含完整结果) |
Map<X, State> 作为成员变量时,是否有 TTL/LRU 策略异步执行后 ThreadLocal 默认不传递。如果业务依赖 TenantContext,子线程拿不到 → 跨租户数据串、空指针。
// ❌ 子线程拿不到 tenantId
executor.execute(() -> run(state));
// ✅ 在子线程入口手动恢复
executor.execute(() -> {
try {
TenantContext.setTenantId(tenantId);
run(tenantId, state);
} finally {
TenantContext.clear();
}
});
同样适用:
SecurityContextHolder、MDC(日志上下文)、自定义请求级 ThreadLocal。
用户点了"刷新"按钮没反应,再点一下 → 后端起了两个并发任务,互相覆盖状态。
public State triggerAsync() {
State existing = states.get(key);
if (existing != null && existing.getStatus() == Status.RUNNING) {
return existing.snapshot(); // 关键:直接返回,不要重新启动
}
// ... 启动新任务
}
前端配合:按钮在 RUNNING 状态下 disabled,文案变 "回填中 N%",让任何用户进来都看见同一任务在跑。
mount 时用 setTimeout 递归 polling,状态变 RUNNING 时又起 setInterval —— 两套并存 → 每 3s 触发 2 次接口;状态变化让第二个 effect 重新创建 interval → 可能爆出 N×N 个定时器。
只能有一套 polling 机制:mount 时只做一次初始 fetch,polling 完全由依赖 status 的 effect 接管。
完整错误/正确代码示例见
frontend-devskill - 陷阱 #3「useEffect 双轮询冲突」
| 项 | 规范 |
|---|---|
| 触发按钮 | 立即变 loading={true} disabled,文案 "回填中 N%" |
| 进度可见 | 顶部加 <Alert> + <Progress>,显示 processed / total |
| 任意用户进来都能看见 | 进入页面先 getStatus(),RUNNING 时自动启动 polling |
| 切换页面再回来 | polling 依赖 status,重新 mount 自动接续 |
| RUNNING → 完成 | message.success/error + 自动 loadData() 刷新主列表 |
| FAILED 显示原因 | state.errorMessage 透传到 message |
| 关闭浏览器再开 | 后端任务不受影响(在 Executor 里跑),下次进来仍能查 |
新增异步任务时挨条核:
errorMessage 透传给前端log.info 便于排查(开始 / 进度 / 结束)| 反模式 | 为什么不行 |
|---|---|
启动新线程而非 Executor | 不受池化管理,OOM 风险 |
| 把结果塞进 HTTP Session | 多实例部署直接失效 |
| 把状态写文件 | 多实例部署、容器重启都丢 |
用 @Async 但不管返回值 | 异常被吞,用户不知道失败 |
前端轮询用 setInterval 但不 cleanup | 路由切换后还在跑,反复触发 setState 报警 |
同步接口加 @Async 注解(self-invocation) | 不走代理,注解不生效 → 见 java-dev skill 「Spring self-invocation 陷阱」 |
任务规模继续增长时的演进方向:
| 当前规模 | 推荐方案 |
|---|---|
| 单租户单进程,< 1 小时 | 本 skill 描述的 ConcurrentHashMap 状态机 |
| 多实例部署,需共享状态 | 状态存 Redis(TTL 1h)+ 分布式锁 |
| 长任务(> 1 小时)或需要重试 | 上专门的任务队列(Spring Batch / XXL-Job / RocketMQ) |
| 工作流编排 | Temporal / Activiti |
不要过早上重型方案。ConcurrentHashMap + Executor 能覆盖 80% 的"同步太慢"场景。
> 📋 本回复遵循:`async-task-pattern` - [章节]