读完这篇,你能做什么
能用至少十二条事件序列验证六类状态,覆盖重复确认、非法事件及取消后的行为。
学习理由、准备要求与知识点
为什么学它
将流程规则变成可回放的普通代码,能在真实模型参与前验证恢复、重复事件和终态行为。
先读这些内容
知识点清单
- 状态和事件区分
- 状态转换表
- 纯函数迁移
- 业务不变量
- 终态与取消规则
- 版本条件更新
- 重复与乱序事件
- 检查点 Schema 演进
运行前先确认完整算法使用 Java 17 标准库;JDK 源码基线为 OpenJDK 17 GA。示例是内存中的机制模型,外部服务接入需要另外配置和验证。
01先弄懂它是什么
任务现在处于什么情况,和发生了什么事,要分开记录。例如 RUNNING 表示正在运行,是状态;CANCEL 表示收到取消要求,是事件。把它们都写成没有约束的字符串,就很难检查下一步是否合法。可以用纯函数 transition(state,event) 根据当前状态和事件计算新状态,把外部读写放在外层,方便重复验证同样输入是否得到同样结果。
转换表要说明每种状态下允许哪些事件,也要考虑任务结束后收到重复或迟到通知。例如完成后再次收到完成通知,可以保持完成;取消后才收到旧结果,则不能重新变成成功。检查这些规则时,不只看代码会不会抛异常,还要核对状态变化是否符合业务必须遵守的条件。
模型或工作进程升级后,可以把参考状态机和新实现放在同样事件序列下比较。本篇寻找使两者可观察状态第一次不同的最短事件序列,为回归测试提供小而清楚的反例。它不要求真实模型在线,但前提是两个状态机已经被正确抽象为有限模型。
文中这几个词,先认一认
- 确定性状态机
- 给定当前状态和事件,就能确定下一状态的规则系统。本篇用完整转换表列出这些规则。
- 可观察输出
- 用户或系统实际看见并关心的状态标签。内部编号不同,不一定代表外部行为不同。
- 乘积状态
- 把参考状态机和被测状态机的当前状态放在一起形成一对,便于逐步比较两边是否一致。
02看看它怎么工作
从两台状态机的起点组成初始二元组。若输出标签已经不同,空事件序列就是反例;否则对每个事件同时推进两台机器,得到新的二元组。广度优先搜索按事件数递增探索,因此第一次遇到输出不同的二元组给出最短反例。
例如参考机在RUNNING收到DONE进入完成,被测机却留在运行,单个DONE事件就能区分。若只有先START再DONE才触发差异,搜索会返回两步序列。内部状态编号不要求一致,比较的是已定义输出标签,避免把实现细节差异误判为行为错误。
每个二元组只访问一次,防止自环或重复路径无限探索。保存前驱二元组和进入事件,发现差异后反向回溯再翻转得到事件序列。没有找到差异只证明这两个有限确定模型在给定事件字母表下输出等价,不证明抽象之外的数据库与并发行为正确。
正在加载流程图…
查看流程图源码(Mermaid)
flowchart LR
R["参考状态机"] --> P["起点二元组"]
N["新状态机"] --> P
P --> B["按事件做乘积图 BFS"]
B --> C{"输出是否不同"}
C -->|"是"| W["恢复最短反例"]
C -->|"否"| B
W --> T["加入状态回归测试"]按顺序看,每一步做了什么
- 写出状态、事件和可观察输出,定义终态与重复政策。
- 将参考规则和被测实现整理成完整有限转换表。
- 构建起点二元组,按事件长度做广度优先探索。
- 发现输出差异后恢复最短事件反例。
- 把反例加入回归,再单独测试版本冲突与乱序外部事件。
03对照实现代码
OpenJDK 17 GA,jdk-17+35 · EnumMap.get / put
下方为原创 Java 教学片段,调用已核验的 JDK API 说明局部机制;不是该类原样源码,也不实现神经网络。完整程序见算法模块。
EnumMap<Event, State> transitions = new EnumMap<>(Event.class);
transitions.put(Event.CANCEL, State.CANCELLED); // 事件集合由枚举限制。
State next = transitions.get(event);
if (next == null) throw new IllegalArgumentException("unsupported transition");
// EnumMap不自动定义状态机规则,缺失事件策略需要应用明确处理。EnumMap针对同一枚举类型的键,用枚举次序定位内部数组,适合有限事件表。它约束键类型,但不会自动检查某状态是否配置全部事件。
枚举声明顺序可帮助稳定遍历,不能把ordinal直接作为长期持久协议值;新增或重排枚举可能改变编号,存储应采用稳定名称或显式代码。
本篇完整算法使用整数索引转换表,便于构造乘积图。源码片段展示生产Java中如何把事件集合限制为明确类型,两者共享有限事件契约。
容器正确不代表转换规则正确。测试要覆盖重复完成、取消后迟到结果和非法路由,并比较业务输出,而不是只确认Map里存在一个值。
代码先调用谁,再调用谁
- 定义有限事件枚举
- 按状态构建事件到后继映射
- 校验缺失和非法转移
- 对同一事件推进两个模型
- 比较可观察输出并恢复反例
04动手写一个例子
最短状态机差异序列
输入两个确定性有限状态机,起点均为0,事件用相同整数列表示;返回最短输出差异事件序列,没有差异返回空Optional。空列表表示起点已不同。Java17 main运行自测。
- 输入 1
参考收到事件1进入DONE,被测仍RUNNING- 输出 1
[1]
- 输入 2
起点输出已不同- 输出 2
[]作为有效反例
乘积图节点数至多A乘B,每条事件边同时推进两个状态。输出不一致的乘积节点就是可区分状态,搜索无需比较内部编号相等。
BFS首次发现的差异具有最少事件数,因为队列按距离层次展开。事件按整数升序扫描,也使同长度反例在固定模型下稳定。
前驱数组保存首次到达路径,回溯时收集对应事件并逆序。预先限制乘积规模,避免教学程序因巨大状态组合申请失控内存,这个限制是资源边界而非算法正确性条件。
为什么这样写是对的已访问二元组各保存一条从起点到达的最短事件路径,队列按路径长度非递减;首次弹出的输出差异节点提供最短区分序列。
A和B为两机状态数,Σ为事件数;最多访问AB个二元组,每个检查Σ条转移,前驱、事件和队列均按AB规模分配。
保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。
展开收起代码 · Main.java · 完整可运行程序
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
import java.util.*;
public class Main {
record Machine(int[][] next,String[] output){}
static int validate(Machine m){
if(m.next().length==0||m.output().length!=m.next().length)throw new IllegalArgumentException("shape");
int events=m.next()[0].length;
for(int[] row:m.next()){if(row.length!=events)throw new IllegalArgumentException("alphabet");for(int s:row)if(s<0||s>=m.next().length)throw new IllegalArgumentException("target");}
for(String out:m.output())Objects.requireNonNull(out);return events;
}
static Optional<List<Integer>> difference(Machine a,Machine b){
int events=validate(a);if(validate(b)!=events)throw new IllegalArgumentException("alphabet mismatch");
int nb=b.next().length,total=Math.multiplyExact(a.next().length,nb);if(total>1000000)throw new IllegalArgumentException("state budget");
int[] prev=new int[total],via=new int[total];Arrays.fill(prev,-2);prev[0]=-1;
Deque<Integer> q=new ArrayDeque<>();q.add(0);
while(!q.isEmpty()){
int pair=q.removeFirst(),x=pair/nb,y=pair%nb;
if(!a.output()[x].equals(b.output()[y])){
List<Integer> path=new ArrayList<>();for(int p=pair;prev[p]!=-1;p=prev[p])path.add(via[p]);
Collections.reverse(path);return Optional.of(path); // 空路径也是起点差异的有效证据。
}
for(int e=0;e<events;e++){
int next=a.next()[x][e]*nb+b.next()[y][e];
if(prev[next]==-2){prev[next]=pair;via[next]=e;q.addLast(next);} // 每个二元组只入队一次。
}
}
return Optional.empty(); // 只证明给定有限抽象和事件集合下无差异。
}
static void eq(Object e,Object a){if(!e.equals(a))throw new AssertionError(a);}
public static void main(String[] args){
Machine a=new Machine(new int[][]{{0,1},{1,1}},new String[]{"R","D"});
Machine b=new Machine(new int[][]{{0,0},{1,1}},new String[]{"R","D"});
eq(Optional.of(List.of(1)),difference(a,b));eq(Optional.empty(),difference(a,a));
eq(Optional.of(List.of()),difference(new Machine(new int[][]{{0}},new String[]{"A"}),new Machine(new int[][]{{0}},new String[]{"B"})));
Machine c=new Machine(new int[][]{{1,0},{1,2},{2,2}},new String[]{"R","R","D"});
Machine d=new Machine(new int[][]{{1,0},{1,1},{2,2}},new String[]{"R","R","D"});
eq(Optional.of(List.of(0,1)),difference(c,d));
try{difference(new Machine(new int[][]{{2}},new String[]{"R"}),a);throw new AssertionError();}catch(IllegalArgumentException expected){}
System.out.println("PASS 5 tests");
}
}这些特殊情况也要试一试
- 空Optional表示无反例,包含空列表的Optional表示起点已不同。
- 内部状态编号不同不一定是行为差异,比较输出标签。
- 状态组合可能爆炸,必须限制模型规模。
进阶选读 · 后续再读:并发事件与线性化历史
场景多个工作进程同时完成、取消和恢复同一任务。
做法记录事件版本与提交顺序,使用条件更新保障合法转移;对并发历史检查是否存在符合顺序规范的解释,保留最小反例。
复杂度一般并发历史搜索可能组合爆炸;本题确定模型的O(ABΣ)不能直接推广到任意并发程序验证。
好处与代价抽象越细可发现更多错误但状态越大,需明确忽略哪些字段,并保留真实数据库故障测试。
05项目里怎么用
提交新状态时,带上自己读到的旧版本,只在 version 仍相等时更新。若影响行数为零,可能已经有人修改了记录,应重新读取并判断,不能直接覆盖。重复收到同一个事件,与事件先后顺序错乱,是不同问题;设计了幂等,也不代表所有迟到事件都能无条件忽略。
修改保存任务进度的数据格式时,也要准备把旧记录转换成新格式的迁移函数,并保留旧样本。例如枚举的 ordinal 只是它在声明中的位置:若把这个数字存进检查点,后来调整枚举顺序,同一个数字就可能指向另一状态。默认值变化、某个旧状态被删除,也可能让恢复走错路。这些旧数据问题不会自动变成编译错误,因此要把真实旧记录作为固定测试输入,检查升级后还能不能正确恢复。
测试结果应说明模型范围。有限转换表等价是强于单次示例的证据,但不包含时间、网络和未建模数据。将图算法反例转成应用单测,再做受控集成测试,逐层建立证据而不夸大结论。
容易踩的坑
- 混淆状态与事件,路由依赖任意字符串。
- 终态收到迟到结果后被重新打开。
- 把枚举ordinal直接持久化为长期协议值。
- 有限抽象等价被宣传成整个并发系统无错。
几种做法,怎么选
表格有四列,窄屏可左右滑动查看代价和适用条件。
| 方案 | 优势 | 代价与限制 | 适用条件 |
|---|---|---|---|
| 纯函数迁移 | 容易穷举与复现 | 外部副作用需单独组织 | 状态核心逻辑 |
| 乘积图比较 | 自动生成最短反例 | 状态组合空间增大 | 版本升级与参考实现对照 |
| 真实并发测试 | 覆盖数据库和调度行为 | 结果空间难完全穷举 | 持久任务与多工作进程 |
06检验是否读懂了
先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。
先用自己的话解释
01状态与事件为何区分?
展开参考答案与推导
状态描述当前事实,事件是转移输入;区分后才能定义同一事件在不同状态的合法行为。
02BFS为什么给最短反例?
展开参考答案与推导
每条事件边长度为一,队列按事件数层次探索,首次差异的路径不可能比尚未探索层更长。
03没有反例能证明什么?
展开参考答案与推导
只证明给定有限确定模型和事件集合下可观察输出等价,抽象之外的外部行为仍需验证。
换个情况,还会处理吗
01取消后迟到完成把任务改成功,怎样修复?
展开参考答案与推导
在转换表明确CANCELLED对完成事件的政策,并通过版本条件更新防止覆盖;加入取消后完成的事件序列回归。
02升级枚举后旧任务恢复到错状态,查什么?
展开参考答案与推导
检查是否存ordinal或依赖声明顺序,改用稳定代码并迁移旧数据,回放历史检查点。
03两台机器编号不同,比较会误报吗?
展开参考答案与推导
本算法比较可观察输出并同步推进,不要求编号相同;输出定义必须与业务行为一致。
写代码试一试
01起点不同的反例多长?
- 输入 / 条件
起点输出A与B- 输出 / 预期
零个事件
提示 空列表也能是反例。
展开参考答案与推导
在扩展任何边之前先比较输出,返回Optional.of(emptyList),不能与Optional.empty混淆。
02如何恢复事件顺序?
- 输入 / 条件
前驱链从终点回到起点- 输出 / 预期
反转收集结果
提示 回溯顺序与执行相反。
展开参考答案与推导
每个节点记录进入事件,逆向收集后Collections.reverse得到正向事件序列。
03为什么只访问一次二元组?
- 输入 / 条件
循环多次回到同状态对- 输出 / 预期
后续行为完全相同
提示 确定性模型无隐藏状态。
展开参考答案与推导
在同一状态对下所有后续事件行为相同,重复到达不会产生更短新信息;有隐藏状态时抽象本身需修正。
读完之后,独立完成
课后作业:自己做一遍
本篇共 12 道作业,先完成 9 道基础与综合任务,再按前置知识挑战进阶题。其中包含 2 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。
能用至少十二条事件序列验证六类状态,覆盖重复确认、非法事件及取消后的行为。
本篇重点加练
业务不变量需经受重复、乱序与组合事件,重点检验测试是否只覆盖了预想路径。
- 业务不变量
- 重复与乱序事件
重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。
先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。
基础必做
- 作业 01原理问答
为“排队、运行、等待确认、成功、失败、取消”定义状态与事件,制作转换表并解释哪些名字不适合同时充当两者。
- 提交内容
- 一份独立书面回答或标注图,包含自造例子、概念边界及判断依据。
- 怎样算完成
- 逐项回应题目要求,区分事实、假设与个人判断。
- 能用自己的例子解释适用条件,记录尚未弄懂的点。
练到的知识点状态和事件区分 · 状态转换表
- 作业 02代码推演
设初始版本4,给出运行完成、取消和重复完成的事件序列,手工推演纯迁移函数,记录每一步业务不变量与是否接受。
- 提交内容
- 手工推演记录,写明输入约定、中间状态、最终判断;可运行时附程序对照记录。
- 怎样算完成
- 推演包含中间过程,数据形状、单位、顺序或状态前后一致。
- 主动检查至少一个边界或异常条件,能解释手算与运行差异。
练到的知识点纯函数迁移 · 业务不变量
- 作业 03原理问答
为事件序列定义至少两项可以在每一步检查的不变量,并说明哪些检查仅看最终状态无法完成。
- 提交内容
- 概念或合同说明、关系图与自造案例,明确事实、假设和适用条件。
- 怎样算完成
- 关键概念有明确边界,能用案例检验声明。
- 未获证据支持的假设单独标注。
练到的知识点业务不变量 · 重复与乱序事件
综合应用
- 作业 04面试场景
两个工作进程同时读取版本7,一个提交成功,一个提交取消。设计条件更新与终态规则,说明如何保存冲突证据。
- 提交内容
- 场景分析与决策记录,包含信息缺口、约束、备选方案和验证计划。
- 怎样算完成
- 方案回应给定约束,列出至少一种替代方案及代价。
- 每项关键结论说明所需证据,不能核实的部分明确标注。
练到的知识点终态与取消规则 · 版本条件更新
- 作业 05编程实践
实现事件回放测试器,注入重复、乱序和旧版检查点;记录迁移、拒绝与升级过程,比较同一事件集不同到达顺序下的行为。
- 提交内容
- 可复现的最小程序或实验材料、自写测试、环境说明与运行记录;题目允许纸面实验时提交完整案例表。
- 怎样算完成
- 提交物按说明可以重现;测试覆盖正常、边界与失败场景。
- 运行证据与断言能核验声明的行为,注明模拟与真实执行范围。
练到的知识点重复与乱序事件 · 检查点 Schema 演进
- 作业 06排错实验
故意允许一次终态继续运行,编写能稳定暴露问题的最小测试,再扩大为自动生成事件序列检查不变量。
- 提交内容
- 最小故障输入、复现步骤、观测记录、定位依据及回归用例。
- 怎样算完成
- 其他人可按记录复现故障,并区分现象与已验证原因。
- 定位过程有观测依据,回归用例可以再次暴露原问题。
练到的知识点状态转换表 · 业务不变量 · 终态与取消规则
- 作业 07编程实践
实现有界事件序列枚举器,对两个完成事件、一次取消和一次重试构造不同次序;每步检查不变量并保存最短失败前缀。
- 提交内容
- 可复现的小程序或题目允许的离线实验、自写测试、输入和运行记录。
- 怎样算完成
- 输入、环境和运行过程可重现,测试覆盖声明的重点条件。
- 验证目标由合同确定,模拟结果不冒充真实服务证据。
练到的知识点业务不变量 · 重复与乱序事件
- 作业 08排错实验
给迁移函数人为加入一个“重复事件仍追加一次计数”的缺陷,检查已有测试与新增序列测试各自能否发现它,记录差异原因。
- 提交内容
- 最小故障序列、观测记录、原因核对与可再次触发原问题的测试。
- 怎样算完成
- 故障可以稳定重现,现象与已验证原因分开。
- 观测记录足以判断声明的合同是否被违反。
练到的知识点业务不变量 · 重复与乱序事件
- 作业 09面试场景
业务允许某些事件交换次序,但另一些不能。为三对事件声明可交换条件,设计正常与违反条件的回放案例。
- 提交内容
- 场景评审记录,包含约束、备选方案、未决信息和验证计划。
- 怎样算完成
- 方案回应题目约束,取舍能对应到可核验的事实。
- 给出失败或条件变化时的检查方式。
练到的知识点业务不变量 · 重复与乱序事件
进阶挑战 · 掌握前置后选做
- 作业 10设计评审
为状态Schema新增字段并设计兼容迁移,分别测试老检查点恢复、新旧工作进程并存和升级中断。
- 提交内容
- 设计评审文档或对照实验报告,包含前置条件、方案取舍、验收证据与尚未验证的范围。
- 怎样算完成
- 比较使用一致条件与事先声明的标准,记录失败与未知结果。
- 说明方案的适用前提、未覆盖风险及后续验证方法。
练到的知识点纯函数迁移 · 版本条件更新 · 检查点 Schema 演进
思维拓展
先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。
- 作业 11思维拓展 · 反例构造
构造“所有合法单步迁移都保持字段类型正确,但组合后业务不变量失败”的序列,明确类型检查无法替代的条件。
- 提交内容
- 具体反例、原主张的适用前提、复现或逻辑证明及结论边界。
- 怎样算完成
- 反例满足原主张所声称的前提,确实针对其结论。
- 说明一个反例能推翻什么、不能进一步证明什么。
练到的知识点业务不变量 · 重复与乱序事件
- 作业 12思维拓展 · 跨域迁移
将事件序列测试迁移到库存预留或文件上传流程的纸面模型,选一项新不变量并设计重复与乱序事件实验。
- 提交内容
- 原场景与目标场景的对应表、可迁移的机制、不能迁移的前提及验证设计。
- 怎样算完成
- 逐项核对两种场景的前提,不只比较相似术语。
- 至少一个不能直接迁移的边界有具体检验方法。
练到的知识点业务不变量 · 重复与乱序事件
完成后,再检验一次
给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。
继续阅读
先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。
- 必读参考文档英文
LangGraph:Graph API overview (新标签页)↗
重点阅读 State、Nodes、Edges 和更新规则,将业务转换表映射到图结构并控制循环。
- 实践参考文档英文
LangGraph:Persistence (新标签页)↗
阅读 checkpointer 与 store 的职责、会话作用域和内存存储限制,验证重启后是否真的可恢复。
- 深入实践指南英文
LangGraph:Interrupts (新标签页)↗
阅读暂停恢复和节点重新执行的限制,把确认与副作用分开,并用故障注入检查幂等性。
- 深入参考文档英文
OpenJDK 17 EnumMap 源码 (新标签页)↗
按指定版本定位
EnumMap.get/ put;对照源码分析理解实际调用边界。