订单提交成功,通知任务还没发出去

采购订单落库后,系统要通知采购员和供应商。先提交数据库再发送消息,进程可能恰在两步之间崩溃:订单存在,没有待发送任务。先发消息再提交订单也不对:数据库回滚了,消费者却可能处理了不存在的订单。单库事务不能包住外部网络副作用。要消除的是“业务已经提交,但没有任何可恢复的通知意图”这个双写缺口,而不是声称数据库与消息系统拥有一个神奇的共同提交点。

累计工程的 ProcurementUseCases.order 在一个受管事务中写订单、将申请转为 ORDERED 并通过 JdbcRequestStore.enqueueOrderCreated 写 notification_outbox;PostgreSQL 16.15 的建表脚本是 examples/javaee-enterprise/db/migrations/002-notification-outbox.sql。这里全部是 JDBC SQL;若换成 JPA 持久化上下文,资源参与、flush 与提交的边界应先读 JPA 07:本地事务、JTA 与冲突,不能把 JDBC 对同库的观察直接套到 provider 与跨资源事务上。OutboxStubPublisher 和 JdbcOutboxStubStore 只记录尝试次数,任务仍为 PENDING,不发网络消息;本章以它验证数据库事务与失败重试,真实 Liberty 内嵌消息引擎在第 30 篇接入。stub 返回与 broker 接收均不是邮件送达,跨资源 XA 留给选修 E05。

同库原子性:写订单时留下要发送的事实

已执行的迁移 002-notification-outbox.sql 核心结构(隔离库实测建表;以下省略外键与检查约束,完整定义以源码为准):

1
2
3
4
5
6
7
8
9
10
11
12
13
CREATE TABLE notification_outbox (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
request_id bigint NOT NULL REFERENCES purchase_request(id),
tenant_id varchar(255) NOT NULL,
order_id bigint NOT NULL REFERENCES purchase_order(id),
event_type varchar(40) NOT NULL CHECK (event_type='ORDER_CREATED'),
status varchar(20) NOT NULL DEFAULT 'PENDING',
attempts integer NOT NULL DEFAULT 0,
published_at timestamptz,
CONSTRAINT one_order_event UNIQUE(request_id, event_type)
);
CREATE INDEX idx_notification_outbox_ready ON notification_outbox(status,id)
WHERE status='PENDING';

实际 order() 已在同一个 @Transactional 用例和同一受管库里取得/创建订单、条件推进申请为 ORDERED,然后插入 notification_outbox。任务业务键是 (request_id, event_type),而不是示例中尚不存在的 event_key 列。如果订单插入或状态迁移失败,任务也回滚;如果任务插入失败,订单与状态应回滚,后一项仍须专门做故障注入实验。重试已 ORDERED 的申请返回原订单,不重复插任务;库级唯一约束是最后一道防线。当前任务只存申请 ID、租户、订单 ID 与固定事件类型,不存价格/联系人快照;后续加入载荷版本时须决定哪些数据可复制及访问权限。

提交时只承诺任务在数据库中,不承诺远端已收到。JDBC DAO 要通过参与同一受管事务的 DataSource 执行 INSERT;不能在订单事务外另借非受管连接把 outbox 单独提交。RETURNING id 的实际使用要与现有 DAO 的订单冲突回查逻辑对齐:若已下单则读取同一个 order id,若事务内是首次下单则创建任务;不能让“存在订单但缺任务”的遗留数据在迁移后被无声忽略,需扫描和补录策略,是否补发由业务审核。

隔离实验 examples/javaee-enterprise/scenarios/23-outbox-stub.sh 经容器顺序下单并重试后,从另一个 PostgreSQL 连接读到同一订单对应 1|PENDING|0;一次 stub 尝试后计数为 1,注入“计数更新后抛错”返回 500、计数仍为 1,再次尝试计数为 2、任务仍为 PENDING。实际命令、失败输出、构建/部署 WAR 摘要与退出码归档在 writing-plans/javaee-enterprise/verification/20261004T-outbox-stub/。这些结果证明本机单个顺序故事和回滚后可重试,不证明订单—任务之间的专项故障注入、多实例抢占、消息送达或邮件完成。

后续发布器的游标/租约,不等于分布式原子提交

当前 stub 在短事务里用 FOR UPDATE SKIP LOCKED 选取单行并增加 attempts,结束后不标发布完成,也没有租约。未来多实例发布器若要在锁释放后避免相同任务被立即并发发送,可另外迁移 lease_token、lease_until 等列,用短事务认领后再发;该租约协议未实现、未运行。序号 id 用于扫描顺序,不表示全局严格业务顺序;SKIP LOCKED 跳过锁住的行,不提供普通查询的全局一致视图。

实际发布必须设置网络超时、并发上限与回收策略。未来标记发布成功只说明 broker 按指定语义接收消息;当前 stub 根本不会标记完成,不是消费者完成、更不是邮件送达。未来对超时或结果未知的发送须保留任务,待恢复条件满足后重试;过度快速重试会打爆下游,因而还需有界退避和人工处理长期失败项。未来多实例领取若引入租约则必须用 token 防止旧持有者把新一轮任务标成成功;消费者按稳定 event_key 建去重账本或把副作用本身做幂等。

下面的故障窗口揭示 outbox 的真实保证:

崩溃点 持久状态 恢复后的结果
订单事务提交之前 订单和 outbox 均不存在 从原业务操作重试,不能补发幽灵订单
订单事务提交之后、领取之前 订单和待发事件都存在 轮询领取,至少不因进程重启静默遗失任务
领取之后、真正发送之前 未标记 published,租约稍后过期 可能重试,无消息也可能补发
发送成功之后、标记 published 之前 下游可能已处理,outbox 仍待确认 会重复发布,依靠事件键去重
标记 published 之后 仅发布器确认本次发送 下游最终效果另由后续消息实验核查

如果先写 published_at 再发送,崩溃就会造成静默丢失,与 outbox 目标相反;当前单实例教学路径依赖显式重跑,尚无自动补扫、租约或报警,进程重启后的最终送达不能凭现有实验宣称完成。published_at 不是全局的“通知已送达”字段。第 30 篇用 Liberty 内嵌 Jakarta Messaging 记录消息重投与数据库收据;实际邮件或其他外部副作用尚未验收;本章保留任务到事件的映射。

正常路径和故障注入如何验收

回环隔离库已执行 002 迁移;按 examples/javaee-enterprise/README.md 部署后,先运行采购故事,再运行本章附带的 stub 场景:

1
2
3
4
5
cd examples/javaee-enterprise
JAVAEE_PORT=9085 JAVAEE_DEMO_MODE=true JAVAEE_LAB_USER=javaee_lab \
JAVAEE_LAB_PASSWORD="$JAVAEE_LAB_PASSWORD" bash scenarios/09-lab-procurement.sh
JAVAEE_PORT=9085 JAVAEE_DEMO_MODE=true JAVAEE_LAB_USER=javaee_lab \
JAVAEE_LAB_PASSWORD="$JAVAEE_LAB_PASSWORD" bash scenarios/23-outbox-stub.sh

迁移 002 执行后,可以从独立连接检查迁移后新订单的订单—任务配对,以及仍待发的任务;迁移前旧订单不应假定已自动补录:

1
2
3
4
psql -X -v ON_ERROR_STOP=1 -h 127.0.0.1 -U javaee_lab -d javaee_lab \
-c "SELECT o.id, COUNT(b.id) AS tasks FROM purchase_order o LEFT JOIN notification_outbox b ON b.order_id=o.id AND b.event_type='ORDER_CREATED' GROUP BY o.id ORDER BY o.id;"
psql -X -v ON_ERROR_STOP=1 -h 127.0.0.1 -U javaee_lab -d javaee_lab \
-c "SELECT id,tenant_id,attempts,status FROM notification_outbox WHERE status='PENDING' ORDER BY id;"

第一条在迁移后新订单应为每单恰好一条任务;迁移前旧订单不能直接套该断言。脚本已用另一个数据库连接实测一单一任务、同单重试及 stub 计数更新后抛错回滚;上面两条逐项审计 SQL 仍可在同一库单独复跑。订单—任务之间的专门抛错、多实例领取与自动补扫仍为 NOT_RUN;第 30 篇另有 broker 提交后、更新 published_at 前抛错的受控观察,不能冒充第 23 篇 stub 的发送成功。留存时间、哈希、命令、退出码、状态对照见发布与恢复实验卡。

迁移 002 已建表;还可以执行一个不依赖发布器的 SQL 失败对照:先在隔离库用新租户插入 APPROVED 测试申请并记录返回的 id,在独立 psql -X -h 127.0.0.1 -U javaee_lab -d javaee_lab 会话输入以下语句,把 123 换成测试申请 id。此处只验证单库订单和任务的共同回滚,并不模拟消息发送。

1
2
3
4
5
6
7
8
9
\set request_id 123
BEGIN;
INSERT INTO purchase_order(request_id,tenant_id)
VALUES (:request_id,'outbox-lab-unique') RETURNING id \gset
INSERT INTO notification_outbox(request_id,tenant_id,order_id,event_type)
VALUES (:request_id,'outbox-lab-unique',:id,'ORDER_CREATED');
SELECT COUNT(*) FROM notification_outbox WHERE order_id=:id;
ROLLBACK;
SELECT COUNT(*) FROM purchase_order WHERE request_id=:request_id;

事务内任务计数预期 1,回滚后订单计数预期 0;再用新连接按已返回的订单 id 查询 outbox 应为 0。正常对照另建一条新的批准申请,重复前四条写入并把 ROLLBACK 改成 COMMIT,从第三连接预期订单与任务各 1 行。两个样本必须不同,否则订单唯一约束可能先阻止写入。这段手工 SQL 对照尚未运行(NOT_RUN);独立的 23-outbox-stub.sh 已运行,不是当前应用服务通过事务拦截器的证据。

拿到任务,和发出任务之间仍隔着一次网络调用

设计发布器时,应把“取任务”和“发送完成”分成两个可恢复阶段。若在数据库事务里 SELECT ... FOR UPDATE 后一直持有锁直到远端响应,网络抖动会长期占用连接和行锁,还可能拖慢新的订单事务;即使这样持锁,远端成功而本地事务提交前崩溃时仍可能重复发送,并没有得到两资源原子提交。短事务只负责领取:挑出尚未 published_at、旧租约已过期的任务,写入新的租约标识和截止时间,立刻提交。发送动作在事务外进行。只有收到约定的发送确认,才在另一个短事务里按租约标识条件标记完成。

下面是未来另行添加 lease_token、lease_until 和载荷列之后才可执行、现在 NOT_RUN 的 PG16 领取 SQL 草图;它不能复制到当前 002 表直接运行,当前可运行的 stub 实现以 JdbcOutboxStubStore.recordAttempt 为准:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
BEGIN;
WITH picked AS (
SELECT id FROM notification_outbox
WHERE published_at IS NULL
AND (lease_until IS NULL OR lease_until < now())
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 10
)
UPDATE notification_outbox AS outbox
SET lease_token = gen_random_uuid(),
lease_until = now() + interval '30 seconds',
attempts = attempts + 1
FROM picked
WHERE outbox.id = picked.id
RETURNING outbox.id, outbox.event_key, outbox.payload, outbox.lease_token;
COMMIT;

gen_random_uuid() 与 SKIP LOCKED 是这里冻结 PG16 的能力;换数据库必须重新核对。领取 0 行可能表示确实无待办,也可能表示当前行被别的事务锁住,不能把 0 行解释为全队列永久清空。领取记录中的 token 要随发送尝试传回 ACK:UPDATE notification_outbox SET published_at=now() WHERE id=:id AND lease_token=:token AND published_at IS NULL,检查更新 1 行才算本次认领者成功标记。旧认领者在租约过期后才收到发送确认,若任务已经被其他实例重新认领,它的旧 token 不应覆盖新结果;但外部消息可能已经发出,这再次说明 token 只约束数据库标记权,不约束远端副作用。

轮询频率和批量也不是无关的性能参数。持续 LIMIT 10 按 ID 取行,在故障任务反复租约过期时可能使后面的任务长时间得不到处理;应用需为失败项设有界退避、告警与人工排查策略。若无限增加 attempts 却不给值班人员指出订单、任务、错误类型和最后一次尝试时刻,就只是把丢通知改名叫“永远重试”。相反,任意把尝试超过三次的行直接标记 published_at,会把失败伪装成发送成功,形成静默缺口。归档或人工终止需另设清楚的失败状态与补偿审批,不能滥用发布成功时间戳。

事件身份与下游最终效果不是一个字段

订单任务的 (request_id,event_type) 在本库里由唯一约束限制为一条;这只保证创建通知意图时不重复写本地任务。发布器崩溃后同一条任务可能被发送两次,下游即使两次都回复“接收成功”,也可能实际调用了两次外部邮件网关。下游需要维护按稳定业务事件 ID 唯一的处理账本,并把“记录这个事件已经应用”和“修改本地下游业务状态”放在它自己的原子边界里。若外部邮件网关没有幂等接口,下游账本仍无法严格消除“邮件已发出但账本未提交”的重复送达窗口,只能用通知类型、幂等能力及补偿机制确定可接受风险。

事件载荷也要预留演进边界。只存订单 ID 可以避免快照过期,但消费者读取订单时可能发现数据已变或调用时无权限;存全量快照则可能复制价格、联系人等敏感字段,还要定义字段版本与保留期限。主线的最低合同是带固定事件类型、稳定键、订单标识及必要的业务上下文,不把当前 HTTP 的不可信租户头原样拿去当异步身份。发布器脱离请求线程后,没有理由假设 CDI 请求范围、已登录用户或原事务自动跟随;第 24–26 篇要定义可信主体、租户上下文如何写入持久任务并由消费者重新验证。

一次可审计的故障演练怎样安排

先在测试库生成唯一订单操作标识,确保一次正常下单同时增加一个 ORDER_CREATED 任务;第三连接查询订单、申请状态与任务的 (request_id,event_type),这是正常基线。接着在订单插入后、任务插入前抛未捕获异常;预期两个写入连同状态迁移全部回滚。这组实验与现有第 18 篇“申请明细插入后抛错”不同,因为 outbox 尚未建表,不能直接拿旧日志证明新边界。

第二阶段使用确定性的 stub,而不是“随机杀进程看运气”。在发布器领取后、发送前设置阻断点,终止实例并记录此时未标记完成的任务;等待租约过期,由重启后的发布器补领,同一个 event_key 最终应出现在 stub 账本中。第三阶段让 stub 先记接收结果并返回成功,在 ACK 前阻断发布器;恢复后期待可见同键重复接收,下游去重账本仍只发生一次可验证业务处理。这比只检查 published_at 更有说服力,因为那一列仅代表发布器的确认,不代表消费者已完成动作。测试期间若数据库和 stub 分别使用不同的测试订单标识,不能把两个独立场景拼成一次成功链路。

多实例还需检查租约到期与旧实例复活:新实例取得新 token 后,旧实例再试 ACK 应更新 0 行;但旧实例若已向下游发送,记录可能包含两个相同 event_key。无法保证最终外部发送恰好一次不是代码缺陷被“容忍”,而是两系统无共同事务时必须显式面对的未知窗口。运维上应保留可查询任务与处理账本,能按订单追溯“业务提交—任务领取—发送确认—下游处理”每一环;缺任一环的记录就不要宣称已通知采购员。

发布游标不应只理解为“上次扫描到的最大 id”。如果发布器把游标越过了一条尚未确认的任务,再只查更大的 id,重启后那条任务将永远不再被访问。设计中的待发条件、租约到期和重复扫描正是为了补回这样的缺口:即使序号不连续、批次乱序,也能按任务是否真正确认来决定继续领取。若运营必须要求同一订单严格顺序发送多种事件,需要另立每订单的序号和消费者顺序合同;本篇只处理一类订单创建通知,不宣称全局顺序。

两道练习与答案

练习一: 把订单事务提交成功后,立刻调用发送 API;API 超时,于是直接把订单回滚。这能补双写缺口吗?

解: 不能。订单已经提交,后续 Java 异常无法回滚已完成的数据库事务;发送超时也未必表示下游没收到。订单与 outbox 记录应先同库提交,发送器随后根据持久任务重试并去重;若要撤销业务,则走独立补偿动作并明确订单状态,不伪装成原事务回滚。

练习二: broker 已接受任务 event_id=42 的消息,发布者还没更新 published_at 就崩溃。重启后仍有待发任务,这是违反“恰好一次”吗?

解: 本方案从未承诺端到端恰好一次。恢复后可能再次发送相同事件 ID;下游按该稳定 ID 去重,并独立核对最终业务副作用。若此时为避免重复而先标记成功,下一次“发送前崩溃”就可能丢失任务。选择可重试并可能重复,是为了避免不可恢复的静默缺口。

限制与版本资料

Outbox 是应用协议,不是 Jakarta Transactions 自动提供的功能;单库原子写入不保证网络发送原子性。当前工程有可重复尝试的 stub 与 Liberty 内嵌持久队列的有限本机证据,但没有可信身份、自动多实例租约、外部邮件送达或 XA 恢复证据。依据:Jakarta Transactions 2.0、PostgreSQL 16 SELECT ... FOR UPDATE SKIP LOCKED、PostgreSQL 16 Transaction Isolation、PostgreSQL 16 Constraints。对发布成功的解释应取决于后续冻结的 broker 与确认协议,不能预写成生产已验证。