读完这篇,你能做什么
能实现运行和笔记的三个接口,用测试数据库核对数据归属、重复事件和版本冲突时的行为。
学习理由、准备要求与知识点
为什么学它
先独立测清接口、存储和数据迁移,再接模型,出了问题才容易判断是后端基础功能还是模型流程造成的。
先读这些内容
知识点清单
- Controller Service Adapter 分层
- 兼容版本与构建配置
- 请求和响应 DTO
- 运行状态与版本字段
- 事件顺序和唯一约束
- 动作幂等记录
- 测试数据库与模拟模型
- 真实请求及脱敏错误
运行前先确认完整算法使用 Java 17 标准库;JDK 源码基线为 OpenJDK 17 GA。示例是内存中的机制模型,外部服务接入需要另外配置和验证。
01先弄懂它是什么
把学习助手做成后端时,要把“一次聊天”拆成可以保存和查询的对象。会话记录归哪个用户,运行记录描述一次任务,步骤事件记录模型、检索和工具做了什么,资料记录保留来源与版本。只存最终答案,就难以恢复中断任务或追查问题;具体保存哪些文本、保存多久,也应按用途明确设计。
API 可以分别负责发起任务、查状态、取结果、取消任务和确认操作。长任务先返回运行标识,再用轮询或流式通道更新进度。客户端断线,不代表后台任务一定失败;客户端再次发送请求,也不一定是在要求创建新任务。需要结合请求幂等键和已保存的业务状态,决定复用原任务还是怎样处理重试。
存储层需要身份范围、唯一约束、版本字段和事务边界。数据库事务可以原子提交同库运行状态与事件,不能自然覆盖外部模型或邮件系统。外部操作要使用可识别操作键、结果查询与对账;本篇先建立可测试的API和存储契约,再接真实基础设施。
文中这几个词,先认一认
- 运行资源
- 代表一次具体任务的数据对象,保存所有者、当前状态、版本和结果。
- 乐观版本
- 更新时要求记录仍是自己读到的旧版本;若别人已更新,就拒绝直接覆盖并重新处理。
- 对账
- 把预期状态与实际可观察记录逐项比较,查找丢失、重复或互相冲突的部分。
02看看它怎么工作
本题把运行表快照与事件回放出的聚合快照按运行ID对账。两侧都严格按ID升序且ID唯一;相同ID比较版本与状态,不同ID报告只有运行行或只有事件聚合。它帮助发现数据链断裂,但不会擅自认定哪一侧正确或自动修复。
例如运行表A版本三为完成,事件聚合A版本二为执行中,输出状态冲突。若读取时系统仍在写入,这种差异可能只是两份快照时间不同,因此真实对账要使用一致快照、读取水位或重试确认,不能看到一次差异就回滚正确业务。
双指针按ID比较:较小ID在另一侧后续不可能出现,可以立即报告并前进;相等时核对字段后双方前进。完整校验排序和唯一性是必要前提,任意一侧重复ID都应拒绝,而不是让归并逻辑偶然丢掉一行。
正在加载流程图…
查看流程图源码(Mermaid)
flowchart TD
A["认证API与幂等键"] --> R["运行状态与版本"]
R --> T["同库事务"]
T --> S["状态表"]
T --> E["步骤事件"]
S --> C["共同水位对账"]
E --> C按顺序看,每一步做了什么
- 定义用户、会话、运行、步骤与资料实体及归属。
- 设计发起、查询、取消和确认API的状态契约。
- 以唯一键和版本条件更新守住存储边界。
- 使用桩模型与内存仓库验证服务流程,再测试真实适配器。
- 用一致快照或水位对账并保留修复依据。
03对照实现代码
OpenJDK 17 GA,jdk-17+35 · PreparedStatement.setString / setLong / executeUpdate
下方为原创 Java 教学片段,调用已核验的 JDK API 说明局部机制;不是该类原样源码,也不实现神经网络。完整程序见算法模块。
String sql = "UPDATE agent_run SET status=?, version=version+1 WHERE id=? AND owner_id=? AND version=?";
try (PreparedStatement ps = connection.prepareStatement(sql)) {
ps.setString(1, "SUCCEEDED"); ps.setString(2, runId); // 值使用参数绑定,不拼接到SQL。
ps.setString(3, authenticatedOwner); ps.setLong(4, expectedVersion); // 身份与旧版本共同约束写入。
if (ps.executeUpdate() != 1) throw new IllegalStateException("state conflict"); // 影响行数决定是否成功匹配。
}PreparedStatement是JDBC接口,源码定义参数绑定和executeUpdate的契约,实际SQL执行由驱动与数据库完成。Java片段不是数据库锁或事务内核实现。
参数绑定针对数据值,不用于表名或任意SQL语法。动态排序列等结构应使用应用白名单,不能因为用了一个占位符就认为整个查询没有注入面。
条件包含主键、可信所有者和期望版本,且成功时版本递增,因此目标状态与并发版本都明确。受影响行数不为一表示未匹配或异常,需按业务区分不存在、无权限和冲突,避免泄露资源存在性。
片段需要已有连接与外层事务。同库事件写入必须在同一事务中提交;网络调用不能放进长事务并假定数据库回滚能撤销外部副作用。
代码先调用谁,再调用谁
- 认证并解析命令
- 读取合法状态和期望版本
- 绑定条件更新参数
- 检查
executeUpdate影响行数 - 同事务追加事件并提交
04动手写一个例子
有序运行快照与事件聚合对账
输入两组严格按ID升序的运行状态快照,输出缺失与冲突清单。仅发现差异,不自动决定修复方向。Java17 Main.java自测。
- 输入 1
运行A3成功,事件A2执行中- 输出 1
CONFLICT:A
- 输入 2
运行有B,事件无B- 输出 2
ROW_ONLY:B
先验证每条记录的ID、非负版本与非空状态,再检查相邻ID严格递增。重复与乱序都违反双指针前提,应直接拒绝。
比较两个当前ID,小者在另一个有序列表后续不会再出现,因此可以准确报告单侧记录。相同ID只比较本题定义的版本与状态,不擅自推断事件正文是否完整。
结束后排空另一侧剩余元素,保持输出按ID排序。算法采用明确差异标签,后续人工或规则修复可以依据来源水位补充判断。
为什么这样写是对的每轮前两侧已越过的ID均被且仅被核对一次,未处理部分ID都不小于当前指针,输出保持按ID有序。
d是差异条数;若ID长度最多L,字符串比较的最坏字符成本O((n+m)L)。输入列表已存在,算法不额外排序或复制。
保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。
import java.util.*;
public class Main {
record Row(String id,long version,String status){}
static void validate(List<Row> rows){for(int i=0;i<rows.size();i++){Row r=rows.get(i);if(r.id()==null||r.id().isBlank()||r.version()<0||r.status()==null||r.status().isBlank()||i>0&&rows.get(i-1).id().compareTo(r.id())>=0)throw new IllegalArgumentException("rows");}} // 严格升序同时排除重复主键。
static List<String> reconcile(List<Row> rows,List<Row> events){
validate(rows);validate(events);int i=0,j=0;List<String> out=new ArrayList<>();
while(i<rows.size()||j<events.size()){
if(i==rows.size()){out.add("EVENT_ONLY:"+events.get(j++).id());continue;}
if(j==events.size()){out.add("ROW_ONLY:"+rows.get(i++).id());continue;}
Row a=rows.get(i),b=events.get(j);int c=a.id().compareTo(b.id());
if(c<0){out.add("ROW_ONLY:"+a.id());i++;} // 小ID不可能在另一侧后续出现。
else if(c>0){out.add("EVENT_ONLY:"+b.id());j++;}
else{if(a.version()!=b.version()||!a.status().equals(b.status()))out.add("CONFLICT:"+a.id());i++;j++;} // 相同ID比较聚合状态。
}
return out;
}
static void eq(List<String> e,List<String> a){if(!e.equals(a))throw new AssertionError(a);}
public static void main(String[] args){
Row a=new Row("A",1,"RUNNING");eq(List.of(),reconcile(List.of(a),List.of(a)));
eq(List.of("CONFLICT:A"),reconcile(List.of(new Row("A",3,"SUCCEEDED")),List.of(new Row("A",2,"RUNNING"))));
eq(List.of("ROW_ONLY:A","EVENT_ONLY:B"),reconcile(List.of(a),List.of(new Row("B",0,"PENDING"))));
eq(List.of(),reconcile(List.of(),List.of()));
eq(List.of("ROW_ONLY:A"),reconcile(List.of(a),List.of()));
try{reconcile(List.of(a,a),List.of());throw new AssertionError();}catch(IllegalArgumentException expected){}
try{reconcile(List.of(new Row("A",-1,"X")),List.of());throw new AssertionError();}catch(IllegalArgumentException expected){}
System.out.println("PASS 7 tests");
}
}这些特殊情况也要试一试
- 两份快照时间不一致可能产生暂态差异。
- 相同版本和状态不证明事件正文完全相同。
- 差异发现与修复决策必须分开。
进阶选读 · 后续再读:海量状态的分区对账
场景运行与事件达到千万级,不能每次全量载入。
做法按稳定ID区间和共同水位分页,先比较分区摘要再对异常分区逐行核对,保留版本与删除标记。
复杂度逐行精确核对仍为O(n+m)扫描,可分区并行;摘要只能筛选,哈希相同并非绝对无差异证明。
好处与代价降低扫描成本会增加水位、分页一致性和摘要维护复杂度,应先保证简单对账可解释。
05项目里怎么用
先用接口约定表写清状态码、业务错误、幂等键在哪个范围有效,以及响应包含哪些字段。查运行状态时始终核对当前用户;取消和确认操作还要检查版本与允许的状态变化。前端按钮有没有显示,不能代替后端权限判断,流式事件也要遵守相同的身份限制。
存储字段区分任务输入摘要、资料版本、工具操作键和最终结果,不在每个事件重复存整份提示。敏感数据建立删除与保留策略,事件日志不能变成无限存储私人资料的借口。索引与唯一约束应对应具体查询和不变量。
测试可以一层层接近真实运行:先让纯服务代码检查状态变化和预算;再检查存储实现是否遵守约定,例如唯一值能否防重复、事务能否一起提交、版本更新是否会覆盖别人;最后通过端到端测试,把登录身份、API 和外部服务接在一起验证。第二层通常叫仓库契约测试。只用内存仓库,可能看不出数据库并发读取和数据存取格式的差异,因此单元测试通过后,还需要在独立环境中留下集成运行的证据。
容易踩的坑
- 只保存最终答案而无法解释运行过程。
- 把外部调用放在数据库事务内就认为可整体回滚。
- 客户端重试直接创建重复运行。
- 快照水位不同却立即自动修复数据。
几种做法,怎么选
表格有四列,窄屏可左右滑动查看代价和适用条件。
| 方案 | 优势 | 代价与限制 | 适用条件 |
|---|---|---|---|
| 同步短请求 | 接口简单 | 容易受长模型等待影响 | 快速确定查询 |
| 异步运行资源 | 支持查询恢复和取消 | 增加状态与事件管理 | 长任务和工具循环 |
| 同库状态与事件事务 | 便于维护本地一致性 | 不能覆盖外部副作用 | 运行记录与本地审计 |
06检验是否读懂了
先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。
先用自己的话解释
01版本条件更新保护什么?
展开参考答案与推导
保护已读取版本与本次写入之间的并发冲突,不能替代业务状态合法性或外部幂等。
02对账为何不自动修复?
展开参考答案与推导
差异可能来自读水位不一致或其中一侧故障,需要证据判断权威来源和修复方向。
03PreparedStatement是数据库内核吗?
展开参考答案与推导
不是,它定义Java客户端执行与绑定契约,锁和事务由驱动及数据库实现。
换个情况,还会处理吗
01任务完成后客户端断线重试,如何避免重复?
展开参考答案与推导
使用作用域明确的幂等键定位原运行,返回已有状态;请求内容冲突需拒绝。
02用户猜到另一运行ID能读吗?
展开参考答案与推导
不能,所有查询和流式订阅必须同时校验运行所有者与当前身份。
03事件比状态慢一个版本,先做什么?
展开参考答案与推导
检查两侧事务边界、读取副本与水位,在一致条件下复核,再决定是否补偿。
写代码试一试
01相同ID不同版本输出什么?
- 输入 / 条件
A3与A2- 输出 / 预期
CONFLICT:A
提示 比较字段。
展开参考答案与推导
报告冲突并保留两侧版本,不自行选择较大者覆盖数据库。
02重复ID能继续归并吗?
- 输入 / 条件
A,A- 输出 / 预期
拒绝输入
提示 严格升序。
展开参考答案与推导
重复破坏一对一对账语义,必须先修复输入或明确聚合政策。
03ID很长复杂度如何写?
- 输入 / 条件
n+m行,最长ID为L- 输出 / 预期
O((n+m)L)字符比较上界
提示 比较字符串不总是常数。
展开参考答案与推导
双指针比较次数线性,但每次可能扫描长公共前缀,因此补充字符成本。
读完之后,独立完成
课后作业:自己做一遍
本篇共 12 道作业,先完成 9 道基础与综合任务,再按前置知识挑战进阶题。其中包含 2 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。
能实现运行和笔记的三个接口,用测试数据库核对数据归属、重复事件和版本冲突时的行为。
本篇重点加练
运行版本与动作幂等记录需要协同更新,重点检验存储边界和并发请求的组合行为。
- 运行状态与版本字段
- 动作幂等记录
重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。
先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。
基础必做
- 作业 01原理问答
为学习助手画Controller、Service与模型Adapter依赖图,列出JDK、框架和依赖兼容性核对表,说明怎样复现构建环境。
- 提交内容
- 一份独立书面回答或标注图,包含自造例子、概念边界及判断依据。
- 怎样算完成
- 逐项回应题目要求,区分事实、假设与个人判断。
- 能用自己的例子解释适用条件,记录尚未弄懂的点。
练到的知识点Controller Service Adapter 分层 · 兼容版本与构建配置
- 作业 02代码推演
定义启动任务与查询任务的DTO及运行状态版本字段,对一次合法更新和一次旧版本更新手工推演接口与数据库记录。
- 提交内容
- 手工推演记录,写明输入约定、中间状态、最终判断;可运行时附程序对照记录。
- 怎样算完成
- 推演包含中间过程,数据形状、单位、顺序或状态前后一致。
- 主动检查至少一个边界或异常条件,能解释手算与运行差异。
练到的知识点请求和响应 DTO · 运行状态与版本字段
- 作业 03原理问答
为一个计划变更定义运行版本、动作标识和结果记录的关系,说明同一动作重试与基于新版本的新动作如何区分。
- 提交内容
- 概念或合同说明、关系图与自造案例,明确事实、假设和适用条件。
- 怎样算完成
- 关键概念有明确边界,能用案例检验声明。
- 未获证据支持的假设单独标注。
练到的知识点运行状态与版本字段 · 动作幂等记录
综合应用
- 作业 04面试场景
同一动作被重复提交且事件乱序到达。设计事件顺序、唯一约束和幂等记录,分别说明应用检查与数据库约束负责什么。
- 提交内容
- 场景分析与决策记录,包含信息缺口、约束、备选方案和验证计划。
- 怎样算完成
- 方案回应给定约束,列出至少一种替代方案及代价。
- 每项关键结论说明所需证据,不能核实的部分明确标注。
练到的知识点事件顺序和唯一约束 · 动作幂等记录
- 作业 05编程实践
使用测试数据库与假模型跑通真实HTTP请求,覆盖成功、参数非法、模型失败和无权限场景,保存脱敏响应与数据变化。
- 提交内容
- 可复现的最小程序或实验材料、自写测试、环境说明与运行记录;题目允许纸面实验时提交完整案例表。
- 怎样算完成
- 提交物按说明可以重现;测试覆盖正常、边界与失败场景。
- 运行证据与断言能核验声明的行为,注明模拟与真实执行范围。
练到的知识点测试数据库与模拟模型 · 真实请求及脱敏错误
- 作业 06排错实验
故意让Controller绕过Service直接写入,构造一个暴露不变量破坏的接口用例,提交分层修复前后的验证记录。
- 提交内容
- 最小故障输入、复现步骤、观测记录、定位依据及回归用例。
- 怎样算完成
- 其他人可按记录复现故障,并区分现象与已验证原因。
- 定位过程有观测依据,回归用例可以再次暴露原问题。
练到的知识点Controller Service Adapter 分层 · 请求和响应 DTO · 事件顺序和唯一约束
- 作业 07编程实践
使用本地测试库实现两请求交错实验,分别覆盖相同动作同参数、相同动作不同参数与旧版本新动作,记录事务结果及最终行状态。
- 提交内容
- 可复现的小程序或题目允许的离线实验、自写测试、输入和运行记录。
- 怎样算完成
- 输入、环境和运行过程可重现,测试覆盖声明的重点条件。
- 验证目标由合同确定,模拟结果不冒充真实服务证据。
练到的知识点运行状态与版本字段 · 动作幂等记录
- 作业 08排错实验
在运行表更新成功后、动作结果写入前制造受控失败,用回放请求检查数据库约束与应用逻辑各自提供了什么保证。
- 提交内容
- 最小故障序列、观测记录、原因核对与可再次触发原问题的测试。
- 怎样算完成
- 故障可以稳定重现,现象与已验证原因分开。
- 观测记录足以判断声明的合同是否被违反。
练到的知识点运行状态与版本字段 · 动作幂等记录
- 作业 09面试场景
客户端希望在页面刷新后继续查看旧动作,但同时提交了新计划版本。设计查询与提交接口合同,防止把不同版本的运行结果混为一次动作。
- 提交内容
- 场景评审记录,包含约束、备选方案、未决信息和验证计划。
- 怎样算完成
- 方案回应题目约束,取舍能对应到可核验的事实。
- 给出失败或条件变化时的检查方式。
练到的知识点运行状态与版本字段 · 动作幂等记录
进阶挑战 · 掌握前置后选做
- 作业 10设计评审
在掌握数据库迁移后,演练运行表新增字段与幂等记录升级,测试旧请求重放、迁移中断和回滚限制。
- 提交内容
- 设计评审文档或对照实验报告,包含前置条件、方案取舍、验收证据与尚未验证的范围。
- 怎样算完成
- 比较使用一致条件与事先声明的标准,记录失败与未知结果。
- 说明方案的适用前提、未覆盖风险及后续验证方法。
练到的知识点兼容版本与构建配置 · 运行状态与版本字段 · 动作幂等记录
思维拓展
先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。
- 作业 11思维拓展 · 反例构造
构造“动作表有唯一键且运行表有版本字段,就自动实现完整幂等恢复”的反例,明确事务范围和失败时点。
- 提交内容
- 具体反例、原主张的适用前提、复现或逻辑证明及结论边界。
- 怎样算完成
- 反例满足原主张所声称的前提,确实针对其结论。
- 说明一个反例能推翻什么、不能进一步证明什么。
练到的知识点运行状态与版本字段 · 动作幂等记录
- 作业 12思维拓展 · 跨域迁移
把运行版本与幂等动作记录迁移到本地批量导入任务,设计文件版本、行身份和重试结果的映射,说明与交互式工具调用不同的边界。
- 提交内容
- 原场景与目标场景的对应表、可迁移的机制、不能迁移的前提及验证设计。
- 怎样算完成
- 逐项核对两种场景的前提,不只比较相似术语。
- 至少一个不能直接迁移的边界有具体检验方法。
练到的知识点运行状态与版本字段 · 动作幂等记录
完成后,再检验一次
给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。
继续阅读
先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。
- 必读教程英文
Spring:Building a RESTful Web Service (新标签页)↗
跟随最小 REST 服务示例建立请求与 DTO 返回,再扩展认证后身份、状态查询和错误响应。
- 实践参考文档英文
Spring Boot:Testing (新标签页)↗
阅读测试支持和测试切片,再分别验证控制器、服务、数据库及真实模型适配器。
- 深入参考文档英文
PostgreSQL:数据约束 (新标签页)↗
阅读主键、唯一约束、外键与检查约束,用数据库约束验证重复事件和幂等记录。
- 深入参考文档英文
Spring AI:ChatClient (新标签页)↗
按所选 Spring AI 版本阅读请求构造、完整响应、实体映射及流式响应;保留元数据用于验收。
- 深入参考文档英文
OpenJDK 17 PreparedStatement 源码 (新标签页)↗
按指定版本定位
PreparedStatement.setString/setLong/executeUpdate;对照源码分析理解实际调用边界。