企业应用架构17:Request-Reply关联与超时恢复
两个合同同时等待预留结果,第二个请求先收到答复,第一个请求在等待期限以后仍没有答复。按队列接收顺序配对,会把设备预留结果交给错误合同;把超时直接写成失败,则可能在设备已经预留以后再次发起预留。
本篇保留两条尚未完成的请求,使用真实消息通道逆序返回结果,并在请求进程退出后恢复关联状态。服务端已经提交而回复迟到的场景,通过查询持久化结果恢复,超时本身不承担判断业务成功与否的职责。
三种标识对应三种问题
Request-Reply 以一条请求和对应答复组织交互,Correlation Identifier 则让接收者判断答复属于哪次请求。这不要求答复按请求发送顺序到达,也不要求调用方始终占用一个线程等待。Request-Reply;Correlation Identifier
实验的合同编号为 R1、R2,请求关联编号为 C1、C2,发送消息编号为 M1、M2。合同编号用于业务查询,关联编号区分一次请求,消息编号识别一次业务报文。三者在这个最小样本里一一对应,却不应因此合并:同一合同可能经历多次查询或不同阶段的请求。
JMSCorrelationID 携带 C1 或 C2,消息正文携带合同编号。接收答复时,程序先查关联编号,再比较正文合同号,避免一个格式正确但指向错误业务的答复更新其他合同。这个检查是应用语义,broker 不会替应用理解合同关系。
sequenceDiagram
participant Q as 请求进程
participant D as 持久化请求表
participant M as ActiveMQ
participant S as 服务进程
Q->>D: 保存C1对应R1与C2对应R2
Q->>M: 发出两条请求后退出
S->>M: 接收C1与C2
S->>D: 保存两份权威结果
S->>M: 先回复C2 暂不回复C1
Note over Q,D: 新进程恢复等待记录
Q->>D: 到期标记UNKNOWN
Q->>D: 查询C1已提交结果
Q->>M: 接收C2再接收C1
样本为了方便复跑,将请求表和服务结果表放在同一个 H2 文件中,由不同 JVM 分阶段访问。它验证持久化关联和恢复算法,并没有建立两个独立数据库之间的查询接口。真实跨系统查询需要经服务接口授权,不能直接复制本例的数据库访问方式。
两条未完成请求比串行等待更有区分度
初始化进程先写入两条 PENDING 记录,保存各自截止时间,再向 reservation.requests 发送请求。发送完成以后断言未完成请求数为二,然后进程结束。服务处理开始前,两条请求已经同时处于等待中。
这里的并发指多个请求同时未完成,实验没有启动两个发送线程做竞态测试。这个安排已经足以暴露按回复顺序配对的错误;线程安全、多个请求节点竞争更新等问题,则需要另一组并发场景证明。
服务进程接收 C1、C2 两条真实消息,却先处理第二条。R2 的结果保存为 RESERVED-E2 后立即发布答复,R1 的结果保存为 RESERVED-E1 后故意不发布。日志明确记录“结果持久化但答复延迟”,与数据库中的结果行相互核对。
这是一种受控的回复丢失窗口:业务结果已提交,调用方还没有收到。它没有真实占用仓库设备,预留结果是合成业务值,因此证据限于消息交互与结果记录。避免把实验字符串误写成生产库存的实际变动,才能准确判断后续补偿还缺哪些证据。
超时之后保存未知状态
运行器等待期限经过,再启动独立超时进程。SQL 只更新仍为 PENDING 且 deadline 已到的请求,将状态改为 UNKNOWN,受影响行数必须为二。此时 C2 的回复已经在 broker 中,但请求端尚未消费,因此它同样进入未知状态。
UNKNOWN 描述请求方的知识状态:在约定时间内还不能确认结果。它不表示服务失败,也不表示消息一定丢失。请求尚在队列、服务已提交、答复已排队或请求方没有及时调度,都可能产生相同的等待超时。
stateDiagram-v2
[*] --> PENDING
PENDING --> UNKNOWN: 等待期限已到
PENDING --> CONFIRMED: 匹配的确定答复
UNKNOWN --> CONFIRMED: 迟到答复或权威查询结果
CONFIRMED --> CONFIRMED: 相同重复结果
独立负例在超时之后要求存在两条 FAILED 记录,实际为零,断言失败并退出一。这个负例表达的是错误推论受到阻止,而不是超时操作应该抛异常。正常路径继续运行查询和答复处理,最终验证两个合同各自得到正确结果。
接口可以向调用方返回“处理中,稍后查询”,但必须提供能再次定位同一请求的编号。若每次页面刷新都创建一个全新请求,持久化未知状态就失去作用。是否允许用户撤销,也应基于服务端业务状态决定,不能只因为前端等待结束就释放或重做资源。
恢复关联关系不依赖旧线程
恢复进程根据 C1 查询服务结果表,取得合同 R1 与已提交的 RESERVED-E1,然后发布恢复答复。它另外发布一份相同 C1 结果和一份未知 C9 结果,用于检查重复与无法关联的分支。恢复期间没有重新执行预留业务。
接收进程实际读取四条答复,先是早已排队的 C2,再是查询恢复的 C1、C1 重复件和 C9。C2 更新 R2,C1 更新 R1;最后读回分别为 RESERVED-E2 和 RESERVED-E1,逆序交付没有交换业务结果。
已经 CONFIRMED 的 C1 再收到答复时,程序比较结果是否一致。一致才视为重复。样本只运行了相同结果的重复件,没有运行相同关联编号却返回另一台设备的冲突场景;代码中的一致性断言不能替代一次未执行的故障注入。
未知 C9 被送入 reservation.quarantine,并由检查步骤实际读出。它不会创造新的请求记录,也不会任选一个尚未完成请求接收。若系统允许服务主动通知新业务,应定义独立事件类型,不宜将未知答复顺便解释成创建命令。
查询、重试和关联的保留期限
恢复查询与重发请求有不同后果。查询读取已有结果,重发则可能再次触发业务动作。只有服务端支持稳定操作编号和幂等处理时,重发同一请求才具备明确语义。本篇没有再次投递原始预留请求,因此不能从答复去重推导服务操作本身可安全重试。
关联记录需要覆盖最长答复延迟和人工处理窗口。若请求刚超时就删除 C1,后续正确答复也会变成未知消息。保存期限过长又会增加历史查询成本,应在业务恢复要求确定后设计归档与清理,而不是仅按页面超时时间设置数据库保留期。
答复通道使用固定名字,适合观察请求进程退出后由新进程继续读取。如果改用跟随连接销毁的临时队列,就需要重新设计离线期间的答复去向。返回地址、关联标识和请求结果存储分别解决交付位置、配对和恢复问题,缺少其中任一项都可能影响重启后的行为。
本例的数据库提交与 JMS 提交仍是两个本地事务。若要保证已保存结果最终一定有答复,可在服务端使用 Outbox;若要避免请求端在确认窗口重复执行下游动作,可将业务更新与 Inbox 记录放在同一事务中。关联正确是这些措施的前提,却不能代替提交协议。
进一步扩大部署时,同一关联记录可能被超时任务和答复消费者同时修改。合理实现通常需要有条件更新或版本检查,防止已确认结果被迟到的超时任务覆盖。样本通过分阶段独立进程排除这个竞争,只验证恢复顺序,不声称多节点调度已经安全。
复跑和证据入口
未知状态还会影响监控的分母。把所有超时计入业务失败率,会混合服务拒绝和结果尚未确认两种事实;只统计最终确认,又可能隐藏过长的等待。更合适的指标是分别观察请求等待时间、未知请求存量以及恢复后的确定结果,并保留从关联编号追查原始日志的入口。
查询结果本身也需要权威来源。实验直接读取服务已经提交的结果表,因此可以明确说明恢复依据。若生产接口读取的是延迟同步的缓存,查询暂时返回不存在仍不能证明原操作未执行。查询协议应说明结果可见性、操作保留期限以及何时能够确定返回“没有发生”,避免恢复逻辑再次引入同样的未知窗口。
合成场景只有成功预留值,真实协议还应区分确定拒绝、处理中和无法确认。把所有答复都存成同一个成功字符串,会让关联正确却业务解释错误。请求状态与业务结果最好分别建模,使一个正确关联的拒绝答复也能终止等待,而不被误当作传输失败继续重试。
下载 实验包,在 Java 21 环境运行入口。ActiveMQ Classic 固定为 5.18.6,H2 固定为 2.1.214,依赖摘要复用第15篇的锁定清单。
1 | |
运行器启动真实本机 broker,依次创建请求、处理服务结果、执行超时、查询恢复并接收答复。正常退出零;追加 negative 则重新建立场景,在“超时等于确定失败”的错误断言处退出一。两种运行的数据库 SQL 快照与日志分开保存。
requests 表说明请求方当前知道什么,service_results 表说明服务端已经记录什么。核对这两张表,再查看消息接收中的关联编号,才能解释结果如何从未知变为确认。只有最终成功标签,无法证明逆序、进程退出和延迟答复这些中间条件实际发生过。






