Java EE 企业应用 30:从 Outbox 到真实消息队列
订单提交后,消息到底在哪里
采购单变为 ORDERED 时,通知供应商不能挡住数据库提交。若在同一个方法里先提交订单、再调用消息服务,进程恰好在两步之间退出,订单已经存在,通知却没有任何可恢复的记录;先发消息再提交,则消费者可能看到一张最后回滚的订单。现有工程已经让第 23 篇的订单与 outbox 在单库事务内写入,由本地 stub 增加尝试次数;本篇接上 Open Liberty 26.0.0.5 的内嵌持久消息引擎,通过受控 HTTP 实验入口发送和同步接收。第 33 篇再把一次发送提交给受管执行器。这个教学闭环并没有给真实供应商发邮件,也没有自动运行的 MDB。
从真实代码进入:采购用例的 order 方法、JDBC 存储实现与002 outbox 迁移负责订单与任务;003 收据迁移记录本地消费。消息配置定义内嵌引擎、jms/ProcurementCF 与 jms/ProcurementNotifications;BrokerLabResource以演示入口完成收发。这些是本地源码入口;外部 master blob 是否已同步到当前工作树版本,仍需发布前核对。
三个不同的提交点
实际表为 notification_outbox(id, request_id, tenant_id, order_id, event_type, status, attempts, created_at, published_at);(request_id, event_type) 唯一,状态仅 PENDING、PUBLISHED。本地 stub锁定一条待发布行并累计尝试,不把它标成已送达。BrokerLabResource在 send-next 读取某租户第一条 PENDING 记录,用 JMSContext.SESSION_TRANSACTED 发送内含十进制事件 id 的 TextMessage,设置 DeliveryMode.PERSISTENT 并提交消息事务,然后另行把 outbox 标为 PUBLISHED。notification_receipt(event_id PRIMARY KEY, tenant_id, received_at) 则在同步 receive-one 中以 ON CONFLICT(event_id) DO NOTHING 记录一次本地收据。消息体只有事件 ID,没有金额、订单快照、payload_version 或外部送达结果;数据库提交、JMS 提交、收据入库是不同的边界。
1 | |
Queue 的一次投递面向一个竞争消费者;topic 可面向多个订阅方,但离线期间能否收到此前消息取决于订阅类型与配置。这里的 server-messaging.xml 配置 Liberty 内嵌引擎,通过只绑定回环的消息端口 7277 提供服务,JNDI 名为 jms/ProcurementCF 和 jms/ProcurementNotifications。BrokerLabResource 在同一 Liberty 服务器内取用它们,不是连接独立的外部 broker,也不是 MDB 容器回调。JMS API 属于 jakarta.jms,JDBC DataSource 仍在 javax.sql。换 Web Profile 或另一款 broker 时不能把此 Liberty 配置当作规范要求。
关键断点在 JMS 提交之后、PUBLISHED 入库之前。send-next?abortAfterSend=true 在这里抛异常,HTTP 500、SQL 仍为 PENDING,再次调用会发同一个 event ID 的另一条消息。receive-one?abortAfterRecord=true 先把收据写进数据库,再回滚 JMS 本地事务;下一次接收显示 newReceipt=false。两个数据库更新与 JMS 操作没有一起配置 XA,所以不能声称端到端恰好一次。当前实验注入的是抛异常、回滚和受控重启,不是进程在任意指令处真正崩溃;第 30 篇的收据只代表实验消费,不代表真实通知完成。
FUTURE:并发发布需要跨请求领取
当前 BrokerLabResource.nextPending() 只用 ORDER BY id LIMIT 1 查询,不领取租约;sendNext() 对同一 ID 的“先读、发送、标记”没有跨请求互斥。仅在隔离环境单路调用做过测试,不能据此部署双实例发布者。两个并发请求可能读到同一条 PENDING、均发送成功,其中一个 markPublished() 因状态已改变而失败。稳定 event ID 与收据主键可挡住重复的本地记账,但不证明外部效果至多一次。
FUTURE/NOT_RUN:若要多实例调度,可在新迁移增加 lease_until、lease_token、next_attempt_at 等字段,以数据库时间、短事务与 FOR UPDATE SKIP LOCKED 抢占到期任务,发送完成后校验 token 再条件回写;租约过期后的重复投递仍需要幂等消费。现有 002-notification-outbox.sql 没有这些列,不能在当前库执行租约 SQL;也不能误用现有 idx_notification_outbox_ready(status, id) 充当租约索引。
由于现有 purchase_order(request_id) 有唯一约束,JdbcRequestStore.insertOrder用 ON CONFLICT(request_id) DO NOTHING 处理重复下单;002 则用 (request_id,event_type) 唯一约束限定一张订单的一项通知任务。实验覆盖演示下单后任务仍为 PENDING 的正常路径;迁移之前的历史订单是否要补通知,仍须单独对账,不能凭现有唯一键推断已经自动补齐。
broker 确认与消息内容的边界
代码使用 DeliveryMode.PERSISTENT,显式提交本地 JMS 会话;验证目录 20261004T-broker-lab 还记录了服务器重启后再次请求 /receive-one 返回 HTTP 200、{"newReceipt":false,"eventId":5}:内嵌消息队列在此次重启中仍能重放,该 event 的收据没有增加。但未做断电、磁盘损坏、外部 broker 或连接工厂跨运行时实验;一个成功重启窗口不是生产持久性 SLA。发送出现未知结果时保留任务 ID 对账,不以 HTTP 200 或 send() 的返回作为外部送达证明。
当前 TextMessage 只携带十进制 event ID;收据 SQL 从 notification_outbox 取 tenant_id,不用消息体宣称已经验过真实用户身份。FUTURE/NOT_RUN:若扩展成采购事件快照,需补 payload_version、业务身份/租户映射验证、金额和必要字段的兼容策略;现有表及消息均无 payload_version 或货币字段,不可用尚未实现的字段做 SQL 验收。部署新旧版本时应先验证消费者能处理双方都存在的载荷版本。
queue 的竞争消费者语义适合“一个处理者完成一次通知”;如果同时要审计和供应商回执,不能让两个彼此独立的业务消费者共享同一个 queue 后误以为两边都收到。应设计两个独立任务,或按 topic 配置不同订阅并验证各自的保留策略。消息顺序也不能由单队列名称自然推出:并行消费者、重投与不同订单的任务会交错。采购操作必须以订单状态和稳定任务 ID 判定,不把“先抵达的消息”当作业务状态机的权威。
实际故障实验仅有 JMS commit 后抛错(SQL 仍 PENDING)、记录收据后回滚 JMS(SQL 收据为 1)、有界窗口内再次消费相同 event ID,以及一次服务器重启后消费队列中尚存的相同 event。发送侧本来就制造了两条携带相同 ID 的独立消息,实验没有记录 JMSMessageID:后续读到的究竟是回滚消息重新投递,还是第二条消息首次到达,不能凭 eventId 或 newReceipt=false 区分。真正随机断电、双实例租约领取、消息持久卷损坏、外部服务效果均 FUTURE/NOT_RUN。任务已经标 PUBLISHED 但内嵌队列若损坏,单数据库标记不能重造消息,仍需独立对账与运维方案。
可复查的现状和部署门槛
复跑前核对实际加载的是 deploy/server-messaging.xml 而非无消息资源的 deploy/server.xml,按工程说明使用独立库和回环端口;新库先按 001 → 002 → 003 各迁移一次,不在已有表重复执行非幂等建表。然后构建、复制 WAR 并以消息配置启动服务器。以下只读命令检查工作树配置和隔离库;JAVAEE_LAB_PASSWORD 须从本机安全环境提供,不能复用 JPA 库。
1 | |
新库迁移的可执行形式是 PGPASSWORD="$JAVAEE_LAB_PASSWORD" psql -X -1 -v ON_ERROR_STOP=1 -h 127.0.0.1 -U javaee_lab -d javaee_lab -f db/migrations/001-initial.sql;替换文件名依次执行 002-notification-outbox.sql、003-notification-receipt.sql。必须先人工核实目标是专用空库;脚本不会替你创建角色/数据库。构建前末行 WAR 可能不存在;to_regclass 返回空值说明本机尚未应用相应迁移,不是“消息队列不可用”。新实验证据保留源码 SHA、实际部署 WAR SHA、场景退出码、消息配置和服务器日志,不能用当前工作树源码反推另一轮已部署二进制。已保存的两组原始运行记录对应不同构建摘要,重跑仍要核对自身部署物。
实验合同:内嵌队列的真实记录
在隔离环境已经用 Open Liberty 26.0.0.5、JDK 21、PostgreSQL 16.15 和 server-messaging.xml 跑过实际场景脚本。verification/20261004T-broker-lab/ 中构建和部署 WAR 的 SHA-256 同为 137bedf79212aec318ab0f4778facc4dd14efd13f236431793c7ce96c397e068,场景退出码为 0。日志记载 event 5:PENDING|0 → 发送提交后注入 HTTP 500 仍 PENDING|0 → 重发并标记 PUBLISHED|0 → 写收据后回滚 JMS、HTTP 500 仍 PUBLISHED|1 → 再次消费同 ID 时 newReceipt=false、行数仍 1。随后停止/重启服务器,重启后的原始响应记录 {"newReceipt":false,"eventId":5}、HTTP 200。注意这只是一次独立线程、受控故障及重启窗口的证据,不是 MDB 或对外送达测试。
可按现有脚本复跑(前提:JDK 21、Maven wrapper、独立 PostgreSQL javaee_lab、迁移 001–003、已安装并用 server-messaging.xml 部署 Open Liberty、jq、专用本地口令、JAVAEE_DEMO_MODE=true;部署说明见工程说明):
1 | |
场景脚本断言两次 HTTP 500 的受控中断点、有界接收同一 event ID、newReceipt=false,并在回滚后核对收据数为 1。旧脚本末尾只打印 receipt_unchanged,没有对最后一次收据计数执行失败断言;现在改为再用独立 SQL 查询并要求 <event_id>|PUBLISHED|1,避免“退出 0 却忽略最后状态”。2026-10-05 修订版在与第 33 篇相同 SHA 的 WAR 上实跑,event 9,脚本退出 0、末尾为 9|PUBLISHED|1;代码摘要、原始输出和退出码见 writing-plans/javaee-enterprise/verification/20261005T-final-check/。这次不是重新构建内嵌消息服务代码,而是补强验收脚本;两次发送同一事件可能生成不同 JMS 物理消息,收据保持一行也不能证明具体哪条消息被重投。异常后 PENDING 消失、响应 event ID 不一致、十次接收都未见同 ID 或终态行数不为一时脚本非零退出。服务器重启后再次接收由旧证据目录的另一条独立 curl 记录,不能算进此次脚本退出码。故障注入的 HTTP 500 是预期屏障,不是进程崩溃;生产邮件、MDB、XA 和外部网络 broker 均 NOT_RUN。详细条件见 消息实验合同。
如何辨别进度卡住与确实丢失
现有状态字段叫 status,值只有 PENDING 和 PUBLISHED:新任务仍待发布可能是未调用实验入口;已发布而无收据可能是队列尚未消费。当前没有 CLAIMED、租约、到期时间、死信归档或自动调度。重试前必须分别核对 event ID、消息存储与本地收据;用 SQL 批量将所有 PUBLISHED 改回 PENDING 不是可接受的补救。HTTP 脚本只验证自己生成的隔离事件,不证明所有积压任务已处理。
操作排查时先按请求/租户确定 outbox 的 id,再用该 ID 查收据:PENDING 加零收据可能是尚未发、也可能是 JMS 已接受而回写丢失;再请求一次会产生重复消息,不能把它当无副作用的“安全补发”。PUBLISHED 加零收据不一定是丢失,还可能是消费者从未被触发;只有先检查服务器是否用消息配置启动、消费者是否真的发出 HTTP 请求,才能继续定位。PUBLISHED 加一收据意味着这一次本地实验记过账,仍无法推出队列已空、邮件送达或供应商接受订单。把三种读数都标成“成功”或都标成“应重发”,会把恢复策略与事故源头混在一起。
当前 markPublished() 只在 id=? AND status='PENDING' 时更新一行;双请求并发时,后来者可能在消息已提交后得到零行并抛错。这一条检查能暴露进度竞争,却不能撤销后来者已发出的消息。要补多实例领取、重试退避与发送结果对账,应另建迁移和隔离用例;生产对账还需实际消息服务的管理数据,不能将 notification_receipt 表视为 broker 内所有消息的快照。
旧实例停机后,新实例虽能读到 PENDING,但当前查询只有租户和状态条件,两个并发实例都可能发送同一行。收据主键只能限制到达本地收据层的重复;若消息存储损坏或发往错误目的地,需要事件 ID 与消息管理数据对账,收据为零不能证明消息从未发出。多实例/存储损坏组合尚未运行。
版本交叉检查要以实际加载的 server-messaging.xml 为准:jakartaee-11.0 之外还需显式配置 messagingEngine、wasJmsEndpoint、JNDI 资源。server.xml 仍是无消息资源的另一份配置,不可一概断言“运行时已连队列”。内嵌引擎接受一条可重投消息与“独立外部 broker 已安装”是两件事;更换地址、持久化存储或部署形态都须重新收集原始证据。第 31 篇的 MDB 和死信能力仍未实测。
两道练习
练习一:同一订单的 HTTP 客户端重试两次,发布器也因超时重试两次;消息数应当等于一吗?解:不一定。唯一约束保证同一业务事件只有一个 outbox 行,内嵌队列可能收到多次相同 event_id 的消息。当前以 event ID 唯一收据限制本地记录行数,不控制尚未实现的供应商副作用;以消息条数代替通知结果,会错把可恢复的重复投递当作两张订单。
练习二:队列已确认发送成功,但发布器在数据库 UPDATE 前被杀;能否改成先 UPDATE 再发送以避免重复?解:不能。先标完成会制造不可恢复的漏发窗口;保留待发布并接受重投,随后用收据去重。若要求真正的跨资源原子性,须另行配置并验证 XA 与崩溃恢复,不能靠调整两行代码的次序得到。
并行部署新旧发布器时,要防止旧版本把新 payload_version 当旧消息发送。先冻结消费者支持的 schema 集合,生产者升级前以一条只在隔离环境出现的测试任务检查解析与收据;升级失败要能停发新版本、继续处理旧任务。回滚应用代码不会自动把数据库里已经写入的新格式任务变回旧格式,数据库兼容窗口与消息版本兼容窗口必须一同设计。
边界与版本资料
本章只验证内嵌持久队列、同步 HTTP 收发、故障注入后同 event ID 的重复消费/收据与一次重启后队列尚存消息;第 31 篇的 MDB/死信、第 33 篇的受管执行器分别核算。订单及 broker 实验接口仍未认证,X-Lab-Tenant 不是受信身份,send-next 的 tenant 查询参数也不是授权边界;只可在回环隔离环境使用。版本表的先前 broker 待冻结描述与本地后续运行记录有时间差,不拿旧表取代 verification/20261004T-broker-lab/ 原始证据。
规范:Jakarta Messaging 3.1、Jakarta Connectors 2.1、Jakarta EE 11 Platform、PostgreSQL 16 行锁与 SKIP LOCKED(未来多实例设计);实现入口:Open Liberty 26.0.0.5 Jakarta EE 11 功能与Messaging 3.1 功能。规范、厂商配置与本地运行记录各自回答不同的问题。






