检查点与状态持久化:服务重启之后,那封没点"采纳"的审批邮件去哪了
这是我"Java 转 AI 工程"系列的第 7 篇。前面把库存调拨的 Agent 图搭起来、能跑通人工审核了,这一篇处理一个更现实的问题:跑到一半的流程,进程没了怎么办。顺便把 PlantUML 接进来,让流程图不再靠截图维护。
一、事故:邮件发出去了,服务没了
先复盘一次我自己制造(也值得制造)的故障。
这条链路长这样:START →(saleRecordDataNode ∥ inventoryOrderDataNode 并行采集)→ predictNode → extractNode → sendEmailNode → humanApprovalNode →(条件边:采纳)createInventoryTransferNode → END。
关键在编译配置里这一行:
java
CompileConfig compileConfig = CompileConfig.builder()
.interruptBefore("humanApprovalNode")
.build();
interruptBefore 让图在人工审批节点之前停下来等外部指令,这正是第 3 篇里说的"能中途停下来让人看一眼"的落地形态------邮件发给管理员,他点"采纳"或"拒绝",流程再继续。
事故时间线我在日志里翻得清清楚楚:
text
2025-11-27 22:23:17.437 [http-nio-8876-exec-1] INFO c.c.a.t.nodes.ExtractNode - ExtractNode result=[{"sourceWarehouseId":3,"targetWarehouseId":1,...}]
2025-11-27 22:23:19.198 [http-nio-8876-exec-1] INFO c.c.a.t.s.impl.EmailServiceImpl - HTML email sent success
2025-11-27 22:23:49.424 [SpringApplicationShutdownHook] INFO o.s.boot.jdbc - Evicting Hikari connections
Process finished with exit code 130
模型分析完了、JSON 提取出来了、审批邮件也发出去了,三十秒后我停了进程。第二天管理员悠哉悠哉点开邮件里那个链接:
text
http://localhost:8876/saleProduct/approve?approval=true&threadId=551917bfda407973d3141d1bd1ab...
接口回我一句 Missing Checkpoint。调拨单没生成,业务链路断在半空。
原因不复杂:工作流的状态默认存在 JVM 内存里 。图停在 humanApprovalNode 之前,靠的是"会话 threadId → 那份状态快照"的映射;进程一没,映射跟着蒸发,重启后的新进程拿这个 threadId 去查,什么都查不到。这类故障的共同特征是:它不在你的代码路径上炸,而是在时间轴的另一端炸------你测的时候一切正常,因为没人真的会在审批邮件发出后 kill 进程。
二、检查点到底存了什么
Spring AI Alibaba Graph 内置了一层持久化机制,载体叫检查点(Checkpoint) :在每个超级步骤(super-step)结束时 保存一次图状态快照,用 StateSnapshot 对象表示,用途就是在稍后的某个时间点把会话恢复回来。
一个检查点里到底有什么,我按字段整理了一遍:
| 字段 | 含义 | 我排查时怎么用它 |
|---|---|---|
config |
关联的运行配置(含 threadId) |
会话隔离的依据,恢复时先拿它对上号 |
metadata |
元数据 | 看步骤、来源这类附加信息 |
values |
状态通道(State)的当前值 | 最值钱的一栏:各节点写进 State 的结果都在这 |
next |
下一个要执行的节点名称 | 判断"当时停在哪",一眼看出是不是卡在 humanApprovalNode |
tasks |
下一个任务的信息(PregelTask) |
恢复时要知道该调度谁,别自己猜 |
values + next 合起来就是"跑到一半"的完整描述:数据到哪一步了、下一步该谁干活。这两样能跨进程活下来,恢复就是水到渠成的事。
落到 Redis 里,RedisSaver 的键结构是这样(前缀直接抄自 com.alibaba.cloud.ai.graph.checkpoint.savers.RedisSaver):
| Key | 作用 | 说明 |
|---|---|---|
graph:checkpoint:content:{threadId} |
存检查点内容 | 值是 JSON,序列化器由 Redisson 的 JsonJacksonCodec 决定 |
graph:checkpoint:lock:{threadId} |
并发写锁 | 同一个会话的检查点写入互斥,避免两个请求互相踩 |
两个细节值得记住:
threadId是会话隔离的边界。 控制器里必须显式传RunnableConfig.builder().threadId(threadId).build(),不传就落到接口常量BaseCheckpointSaver.THREAD_ID_DEFAULT(即"$default")------所有会话挤在同一个 key 里,测试时看着"能恢复",上线必串数据。- 锁和内容是两个 key。 我一开始以为只有一个 hash,实际成对出现,Redis 里看到两个 key 是正常的。
三、把状态搬进 Redis:Redis Stack + 图配置改造
1. 为什么是 Redis Stack,以及它和 Redis 的区别
Redis Stack 是 Redis 的功能扩展版本,兼容 Redis 全部功能。两者差异压缩成一行:
- 数据结构 :基本结构 → 基本 + 复杂结构;全文搜索 :不支持 → 支持高效全文搜索;图形功能 :不支持 → 支持图形存储与查询;时间序列 :基本支持 → 专门优化;安装复杂度:较简单 → 较复杂(涉及多个组件)。
说实话,这一章我只用到"当 KV 存储"这一项,普通 Redis 也够;选 Stack 是因为后面 BI 项目要用全文搜索,而它端口和协议跟 Redis 一致,不用二次迁移。
Windows 上先装 Docker Desktop,然后一条命令起单机版:
bash
docker run --name redis-stack -d -p 6379:6379 redis/redis-stack-server
容器名撞了先停掉已有容器;生产环境记得配持久化方案。
2. 依赖:Redisson
xml
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.23.4</version>
</dependency>
RedisSaver 的构造方法要的是 RedissonClient,不是 RedisTemplate------Spring Data Redis 那套在这里用不上,这是最容易搞混的一点。
3. 配置:application.yml
yaml
server:
port: 8876
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/ai-transfer?useUnicode=true&characterEncoding=utf-8&useSSL=false
username: root
password: 123456
data:
redis:
host: localhost
port: 6379
database: 2
timeout: 5s
connect-timeout: 5s
database: 2 是我特意挑的------本机 Redis 已被别的项目占了 0 和 1,检查点混进去,清库时容易出事;独立库还有个附带好处:FLUSHDB 只影响检查点。
4. Redisson 客户端配置类
java
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
// 自定义 key-value 的序列化方式:JSON 可读性最好,排障时能直接看懂
config.setCodec(new JsonJacksonCodec());
config.useSingleServer()
.setAddress("redis://127.0.0.1:6379")
.setDatabase(2);
return Redisson.create(config);
}
}
主机、端口、库号我这里写死了,正式项目建议从 spring.data.redis.host / port / database 注入,配置只留一份。顺带一句:Config 要导的是 org.redisson.config.Config。序列化器我的经验是能选 JSON 就别用 Java 原生序列化,检查点是要人肉去 Redis 里看的,性能上 JSON 也优于原生和 XML。
5. 改造 GraphConfig:把 Saver 挂进编译阶段
持久化配置必须写在图的编译阶段,不是节点里,也不是 StateGraph 上:
java
@Configuration
@Slf4j
public class GraphConfig {
@Resource
private RedissonClient redissonClient;
@Bean
public CompiledGraph graph(ChatClient.Builder chatClientBuild) throws GraphStateException {
// ... 键策略、addNode、addEdge、addConditionalEdges 与前几篇一致,此处略 ...
// 1) 创建持久化配置
SaverConfig saverConfig = SaverConfig.builder()
.register(SaverEnum.REDIS.getValue(), new RedisSaver(redissonClient))
.build();
// 2) 定义编译配置:中断点 + 存储
CompileConfig compileConfig = CompileConfig.builder()
.interruptBefore("humanApprovalNode")
.saverConfig(saverConfig)
.build();
// 3) 编译
return stateGraph.compile(compileConfig);
}
}
CompileConfig 除了指定中断点,还承载保存配置;SaverEnum 五个值:DB("db")、REDIS("redis")、MEMORY("memory")、FILE("file")、NONE("none"),register(type, saver) 就是把存储类型和 BaseCheckpointSaver 实现类绑起来。
BaseCheckpointSaver 这个接口本身很薄,四个抽象方法:
java
public interface BaseCheckpointSaver {
String THREAD_ID_DEFAULT = "$default";
Collection<Checkpoint> list(RunnableConfig config);
Optional<Checkpoint> get(RunnableConfig config);
RunnableConfig put(RunnableConfig config, Checkpoint checkpoint) throws Exception;
boolean clear(RunnableConfig config);
}
框架在 1.0.0.4 里给了五个实现:MemorySaver、RedisSaver、FileSystemSaver、MongoSaver、VersionedMemorySaver。换存储只需要换一个实现类,业务代码一行不动。
6. 恢复侧:控制器怎么把流程"唤醒"
存下来只是第一步,让它续起来靠这段:
java
@RestController
@RequestMapping("/saleProduct")
public class SaleProductController {
@Resource
private CompiledGraph graph;
@GetMapping("/approve")
public R approve(@RequestParam("approval") Boolean approval,
@RequestParam(value = "threadId", required = false) String threadId) {
// threadId 用于会话隔离,让 Agent 知道当前处理哪个对话
RunnableConfig runnableConfig = RunnableConfig.builder().threadId(threadId).build();
try {
StateSnapshot stateSnapshot = graph.getState(runnableConfig);
OverAllState state = stateSnapshot.state();
log.info("stateSnapshot state=[{}]", state);
state.withResume(); // 通知工作流:我要激活了
Map<String, Object> map = new HashMap<>();
map.put("approval", approval);
state.withHumanFeedback(new OverAllState.HumanFeedback(map, "")); // 人工反馈写回 State
OverAllState overAllState = graph.call(state, runnableConfig);
return R.success(overAllState.data());
} catch (Exception e) {
log.error("error msg=[{}]", e.getMessage());
}
return R.success();
}
}
三步走:取快照 → 标记恢复 + 注入人工反馈 → 重新驱动图 。getState(runnableConfig) 就是之前报 Missing Checkpoint 的那个调用;withResume() 告诉框架"别当新流程跑,从断点续";HumanFeedback 把审批结果塞回 State,供后面的条件边(ApprovalEdge)决定走 createInventoryTransferNode 还是 END。
四、这条链路怎么测
"跑通了"这三个字我最不信。持久化链路的测试要覆盖时间维度:存进去、进程死了、活过来、还能续上。我把它拆成五个用例:
| 用例 | 操作 | 断言点 |
|---|---|---|
| TC1 基线(不重启) | GET /saleProduct/sale?productId=1 |
日志依次出现采集 → PredictNode → ExtractNode → HTML email sent success;停在 humanApprovalNode 之前;Redis DB2 出现 graph:checkpoint:content:{threadId} |
| TC2 宕机恢复 | kill 进程 → 重启 → 点 approve?approval=true&threadId=... |
不再报 Missing Checkpoint;日志出现 INSERT INTO bb_transfer_order(_item)、Updates: 1、事务 commit |
| TC3 否决分支 | 同上,approval=false |
不生成调拨单,图走 END |
| TC4 会话隔离 | 换另一个 threadId 调 approve |
恢复的是对应会话的快照;不传时能看出它落到 $default |
| TC5 可视化 | 启动阶段看日志 | 打印出完整 PlantUML 脚本(下一节) |
几个我认为有效的做法:
1)先看空库,再看有库。 启动前用 Another Redis Desktop Manager 连上去确认 DB2 是空的,跑完 TC1 再刷新。这一步是为了证明"数据真的是这次写进去的",而不是上次遗留。
2)断言落在三个地方,不只看 HTTP 状态。 日志(框架行为)、Redis(状态是否可恢复)、MySQL(业务是否闭环)。只调接口拿个 200 是测不出持久化问题的------业务单据在恢复之后才生成。
3)把恢复前后的数据串起来看。 TC2 重启前 bb_transfer_order 最新主单 ID 是 12,采纳之后主表新增 13,子表跟着插入 (transfer_order_id=13, product_id=1, transfer_quantity=80, remark='调拨以支持华北仓2026年Q1预期销售回升'):
text
==> Preparing: INSERT INTO bb_transfer_order_item ( transfer_order_id, product_id, transfer_quantity, remark ) VALUES ( ?, ?, ?, ? )
==> Parameters: 13(Long), 1(Long), 80(BigDecimal), 调拨以支持华北仓2026年Q1预期销售回升(String)
<== Updates: 1
Transaction synchronization committing SqlSession
"从华南仓调 80 件到华北仓"这个结论是模型给的,但80 这个数字真的落库了------这才是端到端。
4)宕机要"真宕"。 别用优雅停机测恢复,exit code 130 / -1 这种被强杀的场景才接近线上------优雅关闭时 shutdown hook 还会去 evict Hikari 连接,行为和直接 kill 不一样。
顺带两个坑
@Resource private RedissonClient redissonClient;注入不上。 不是写法问题,是依赖没进来:这个类来自 Redisson,pom 里少了 starter,要么找不到符号,要么找到了类但容器里没有 Bean。- IDEA 启动报
Command line is too long。 多模块 + Redisson + Thymeleaf 依赖一多就撞上 Windows 命令行长度限制,提示是 "Shorten the command line and rerun",在 Run Configuration 里把 shortening 方式改成 JAR manifest 即可。它和持久化无关,但一定会在你第一次加完依赖启动时出现,别像我一样先去查 Redis。
五、让领导一眼看懂你的流程:PlantUML 一键生成
这是我这一章最喜欢的部分。ToB 项目里,"你做的东西长什么样"往往比"你怎么实现的"更早被问。传统做法是在低代码平台上拖一张图、截图、贴进文档,它有两个死穴:流程一改(调个节点顺序、换个方案)图就过期;你得反复截图、更新、同步给每个人,维护成本最后全落在你头上。
PlantUML 的思路是用代码画图:图由脚本渲染,脚本跟代码一起进版本库,代码改了图就跟着改。Spring AI Alibaba 已经内置支持,能自动把工作流定义转成 PlantUML 脚本。
1. 改造编译方法
有个小门槛:不能直接 return stateGraph.compile(compileConfig) ,得先把 CompiledGraph 接住,才能对它做可视化处理。
java
// 编译
CompiledGraph compiledGraph = stateGraph.compile(compileConfig);
// 实现 PlantUML 可视化
GraphRepresentation transferGraph = compiledGraph.getGraph(GraphRepresentation.Type.PLANTUML,
"transferGraph", true);
log.info("plantUML code=[{}]", transferGraph.content());
// TODO: 保存到数据库中,后续通过接口查询与转换
return compiledGraph;
getGraph 最完整的重载是 getGraph(Type, String title, boolean printConditionalEdges);printConditionalEdges 传 true 才看得到条件分支------给开发看打开,给业务方看可以关掉。
GraphRepresentation 本身是个 record,里面就两个访问器:
java
public record GraphRepresentation(Type type, String content) {
public static enum Type {
PLANTUML(new PlantUMLGenerator()),
MERMAID(new MermaidGenerator());
final DiagramGenerator generator;
}
}
也就是说,把 Type.PLANTUML 换成 Type.MERMAID,同样的图会吐一份 Mermaid 脚本------掘金/公众号这类 Markdown 平台直接就能渲染,这是我最常用的取巧方式。
2. 框架吐出来的脚本长这样
这是 transferGraph.content() 打印出来的真实内容(头部 skinparam 省略若干),可以直接抄去改:
plantuml
@startuml
skinparam usecaseStereotypeFontSize 12
skinparam hexagonFontSize 14
skinparam hexagonStereotypeFontSize 12
title "transferGraph"
footer
powered by spring-ai-alibaba
end footer
circle start<<input>> as __START__
circle stop as __END__
usecase "saleRecordDataNode"<<Node>>
usecase "inventoryOrderDataNode"<<Node>>
usecase "predictNode"<<Node>>
usecase "extractNode"<<Node>>
usecase "sendEmailNode"<<Node>>
usecase "createInventoryTransferNode"<<Node>>
usecase "humanApprovalNode"<<Node>>
hexagon "check state" as condition1<<Condition>>
"__START__" -down-> "saleRecordDataNode"
"__START__" -down-> "inventoryOrderDataNode"
"saleRecordDataNode" -down-> "predictNode"
"inventoryOrderDataNode" -down-> "predictNode"
"predictNode" -down-> "extractNode"
"extractNode" -down-> "sendEmailNode"
"sendEmailNode" -down-> "humanApprovalNode"
"humanApprovalNode" .down.> "condition1"
"condition1" .down.> "createInventoryTransferNode"
"humanApprovalNode" .down.> "createInventoryTransferNode"
"condition1" .down.> "__END__"
"humanApprovalNode" .down.> "__END__"
"createInventoryTransferNode" -down-> "__END__"
@enduml
对着脚本读一遍图,几件事一目了然:
- 实线
-down->是固定边,虚线.down.>是条件边------printConditionalEdges=true的效果就是它。 hexagon "check state" as condition1是条件节点,ApprovalEdge的判断逻辑挂在这里。__START__有两条出边,说明两个采集节点是并行入口 ,都汇到predictNode。humanApprovalNode同时连着condition1、createInventoryTransferNode和__END__------这正是我之前调整过的结构:删掉人工节点直连创建节点的边、改走条件边,图才既清晰又不报错。
把这段脚本粘进在线编辑器(app.timelessq.com/office/plantuml-editor)就出图,不用装 Graphviz。
3. 更进一步:让接口直接返回图片
content() 是文本,前端要的是图。路子是拿 PlantUML 官方库把脚本渲染成图片:
java
static java.awt.Image plantUML2PNG(String code) throws IOException {
var reader = new SourceStringReader(code); // net.sourceforge.plantuml.SourceStringReader
try (var out = new java.io.ByteArrayOutputStream()) {
reader.outputImage(out, new FileFormatOption(FileFormat.PNG));
return javax.imageio.ImageIO.read(
new java.io.ByteArrayInputStream(out.toByteArray()));
}
}
// 从 GraphRepresentation 生成图像
static void displayDiagram(GraphRepresentation representation) {
display(plantUML2PNG(representation.content()));
}
往生产方向推,就是"脚本存进数据库表 + 一个 /graph/png?threadId= 接口",审批页面上直接展示流程走到哪。笔记里那句 // TODO: 保存列表中 就是留给这一步的。
六、写完这一章,我改了三个看法
1)"状态"从 JVM 里的成员变量,变成了一份可寻址的数据。 以前状态就是内存对象,进程没了就没了;现在它是 graph:checkpoint:content:{threadId} 这个 key 下的 JSON。状态一旦变成数据,就能被查询、被审计、被跨实例恢复,甚至被人肉打开看一眼当时卡在哪。
2)中断不是异常,是一等公民。 interruptBefore + withResume 把"等人"变成了流程里的正常一步,而 threadId 是这一切的锚点------会话隔离没做好,持久化做得再稳也是错的。
3)Redis 不是终点。 检查点全塞 Redis,数据量一大就是内存账单。所以 BaseCheckpointSaver 这个薄接口设计得很聪明:四个方法,换 MongoDB(MongoSaver,1.0.0.4 里唯一的数据库方案)、换 PostgreSQL/Oracle 都只是实现同一个接口,表里放检查点内容 + 线程 ID,关系库再补上事务管理。选型问题被接口吸收掉了,这是我喜欢的工程结构。
下一篇进入第 8 章:高并发场景下的微服务与 Kafka 改造。这一章解决的是"一个流程别丢状态",下一章要面对的是"一万个流程同时进来,别把下游压垮"------同步调用链在流量面前撑不住,得把节点之间的耦合交给消息队列。
本系列是我学习 Spring AI Alibaba Graph 的工程复盘,代码依据课程讲义与截图重建(spring-ai-alibaba 1.0.0.4 + spring-ai 1.0.3 + JDK 17 + Spring Boot 3.x + Redisson 3.23.4),未在最新版本上重新验证。若你的版本里 SaverConfig / SaverEnum 有出入,以 IDE 反编译的 BaseCheckpointSaver 实现类为准。