15-消息可靠性幂等与Outbox

O泡李华 4

消息为什么会丢、会重?可靠投递、消费幂等与 Outbox

订单已经提交,消息没有发出;消息已经处理,确认却丢了。这两个窗口分别造成遗漏与重复。可靠消息设计要覆盖整条链路,而不是只打开 Broker 的一个开关。

Outbox 的原子范围

1. 三段链路需要不同确认

生产、路由、持久保存和消费提交不是一个成功信号。

三段链路各自确认什么?

业务数据库 → 生产者 → Broker → 消费者 → 消费业务数据库
                   confirm          ack

Publisher confirm 表示 Broker 对发布承担了相应责任;consumer ack 表示消费者允许 Broker 按语义处理已交付消息。两者没有自动形成“业务端到端事务”。

如果生产者在数据库提交后、发送之前退出,消息还没进入 Broker,任何 Broker 持久化配置都无法保存这条未发出的消息。

发布确认与路由返回是不同问题

消息被交换机接受,但没有合适队列承接时,也可能收到发布确认。需要感知未路由消息时,可使用 mandatory 与 return 机制,并核对业务要求的目标拓扑。RabbitMQ confirms

confirm 超时也不一定意味着未保存,可能只是确认响应丢失。重发应保留稳定的事件 id,消费者必须能够处理重复。

Broker 可靠保存需要哪些条件?

重要消息需要相应持久队列、消息持久化与确认策略。仅设置 durable,不等于每条消息都按期待方式保存;仅有多节点,也不等于每种队列的数据都复制到所有节点。

quorum queue、classic queue 与 Stream 具有不同复制与交付行为,容量、故障与配置应按具体类型分析。不要把旧版本镜像队列配置直接套到新版本。可靠性指南

消费提交之后再确认

接收消息并校验
开始业务数据库事务
执行幂等检查与业务写入
提交数据库事务
再向 Broker 确认这次交付

提交前 ack,进程随后退出,可能留下没有完成的业务。提交后 ack 失败,则可能重投;幂等用于让这种重复不产生重复效果。

deliveryTag 属于对应 Channel,不能拿另一个 Channel 的标签确认。批量 ack 会覆盖一组交付,处理并发时要避免确认尚未完成的消息。

RocketMQ 的对应边界

RocketMQ 的发送结果、消费重试和事务消息有自身协议与版本行为,不能直接套用 RabbitMQ 的 mandatory、deliveryTag 名称。

事务消息协调本地事务状态与消息可见性,还需要回查逻辑;它不让消费者数据库自动加入生产者事务,也不能取消消费幂等。RocketMQ 事务消息

2. 消费幂等必须与业务一起提交

标记和业务变更分离,会产生新的失败窗口。

消息 id 与业务操作 id 不一定相同

同一订单付款事件被重新包装,可能产生新的消息 id,但仍是同一次“发放积分”操作。只按消息 id 去重,可能重复奖励。

可选择业务键 order:42:paid,并加上消费者业务类型。积分消费者与短信消费者可以各自处理同一事件,不应共用一个全局“已经处理”标记。

如果同一业务键对应的用户或金额不同,不能盲目当成重复成功,需要核对载荷并记录冲突。

先写标记还是先写业务?

关键不在两条 SQL 谁先执行,而在它们是否属于同一个可靠事务。

先单独提交标记、再更新业务,后者失败会留下错误完成记录;先单独更新业务、再保存标记,进程退出又会在重投时重复执行。

本文把标记与积分更新放在同一个 InnoDB 数据库事务中。任何一步失败,整体回滚;提交后收到重投,唯一键阻止重复效果。

完整建表与第一次处理

CREATE DATABASE blog_idempotency_lab;
USE blog_idempotency_lab;
CREATE TABLE points_account (
 user_id BIGINT PRIMARY KEY,
 points BIGINT NOT NULL
) ENGINE=InnoDB;
CREATE TABLE consumed_operation (
 consumer_name VARCHAR(64) NOT NULL,
 business_key VARCHAR(128) NOT NULL,
 event_id VARCHAR(64) NOT NULL,
 user_id BIGINT NOT NULL,
 amount INT NOT NULL,
 PRIMARY KEY(consumer_name,business_key)
) ENGINE=InnoDB;
INSERT INTO points_account VALUES(7,0);

START TRANSACTION;
INSERT INTO consumed_operation
VALUES('points-award','order:42:paid','event-001',7,10);
UPDATE points_account SET points=points+10 WHERE user_id=7;
COMMIT;
SELECT points FROM points_account WHERE user_id=7;

预期积分为 10。应用还要检查 UPDATE 是否找到应有账户;找不到时应回滚,而不是提交一个没有业务效果的完成标记。

重复处理与并发处理

START TRANSACTION;
INSERT INTO consumed_operation
VALUES('points-award','order:42:paid','event-002',7,10);
ROLLBACK;

重复 INSERT 预期触发唯一键错误。应用在明确回滚后查询已有操作,核对 user_id 和 amount,一致时可认定同一业务已完成,再确认消息;不一致时进入冲突处理。

MySQL 客户端发生错误后,后面的语句是否自动继续取决于使用方式;这里的 ROLLBACK 是明确要求,不应依赖工具默认行为。

两个事务同时插入相同业务键,会由数据库约束协调,可能等待。获胜事务回滚时,另一个有机会继续;获胜事务提交时,另一个不能再以同一键成功完成。不能用普通 SELECT 查重替代这个并发约束。

为什么 Redis SETNX 不够?

Redis 标记与数据库业务写入通常不共享同一事务。先 SETNX 后数据库失败,会拦住后续补救;先写库后 SETNX 又可能重复。

Redis 可以作为快速过滤,但最终规则仍应落到相应业务存储。去重标记 TTL 到期后,旧消息可能重新通过,保留期限要覆盖重试和重放范围。

数据库事务之外的副作用

调用支付、发送短信、写另一个数据库,都不是这张本地表事务自动包含的效果。可以把外部动作写成另一个可靠任务,由具有自身幂等键的执行器处理。

对于覆盖式状态更新,也要防止旧事件覆盖新状态。幂等处理重复事件,版本或状态迁移规则处理乱序,它们是不同问题。

3. Outbox 让业务意图持久化

本地事务先保证可恢复意图,再推进消息投递。

outbox 保证哪一段原子性?

它保证同一个数据库事务内,业务数据与待发送记录一起成功或一起失败。它不保证 Broker 发布和 outbox 标记之间没有重复窗口。

发布成功、标记之前退出,恢复后会再次发送。因此稳定事件 id 和消费者幂等仍然必要。Transactional Outbox 模式

完整业务与消息表

CREATE DATABASE blog_outbox_lab;
USE blog_outbox_lab;
CREATE TABLE purchase_order (
 id BIGINT PRIMARY KEY,
 status VARCHAR(16) NOT NULL
) ENGINE=InnoDB;
CREATE TABLE outbox_event (
 event_id VARCHAR(64) PRIMARY KEY,
 aggregate_id BIGINT NOT NULL,
 event_type VARCHAR(64) NOT NULL,
 payload JSON NOT NULL,
 status VARCHAR(16) NOT NULL,
 attempts INT NOT NULL DEFAULT 0,
 available_at DATETIME(6) NOT NULL,
 lease_token VARCHAR(64) NULL,
 locked_until DATETIME(6) NULL,
 created_at DATETIME(6) NOT NULL,
 KEY idx_pending(status,available_at,event_id)
) ENGINE=InnoDB;

START TRANSACTION;
INSERT INTO purchase_order VALUES(42,'NEW');
INSERT INTO outbox_event
 (event_id,aggregate_id,event_type,payload,status,available_at,created_at)
VALUES
 ('order-42-created-v1',42,'OrderCreated',
  JSON_OBJECT('orderId',42,'version',1),'NEW',NOW(6),NOW(6));
COMMIT;

预期业务与事件同时保存。event_id 在重发时保持不变,不能每次扫描都重新生成。

多个转发器如何领取任务?

在 MySQL 8.0 的适用事务场景中,可用 SKIP LOCKED 避免多个扫描器等待同一批任务。下面领取一条,便于展示状态条件:

START TRANSACTION;
SELECT event_id FROM outbox_event
WHERE status='NEW' AND available_at<=NOW(6)
ORDER BY available_at,event_id
LIMIT 1 FOR UPDATE SKIP LOCKED;

UPDATE outbox_event
SET status='SENDING',lease_token='worker-A-claim-001',
    locked_until=DATE_ADD(NOW(6),INTERVAL 30 SECOND),attempts=attempts+1
WHERE event_id='order-42-created-v1' AND status='NEW';
COMMIT;

实际程序必须使用 SELECT 返回的 id,并检查更新行数。lease_token 是每次领取唯一的令牌,不是永久固定的 worker 名。

领取事务尽快结束,Broker 网络调用在它之外执行,避免长时间持有数据库锁。

确认与超时分别怎样处理?

满足目标路由与发布确认要求后,使用当前领取令牌标记完成:

UPDATE outbox_event
SET status='SENT',lease_token=NULL,locked_until=NULL
WHERE event_id='order-42-created-v1'
  AND status='SENDING' AND lease_token='worker-A-claim-001';

更新 0 行说明领取状态已变化,旧 worker 不能覆盖新 worker 的结果。没有确认或结果未知时,应按重试策略处理,不能任意标记 SENT。

扫描超时领取:

UPDATE outbox_event
SET status='NEW',available_at=DATE_ADD(NOW(6),INTERVAL 10 SECOND),
    lease_token=NULL,locked_until=NULL
WHERE status='SENDING' AND locked_until<NOW(6);

这是最小补偿示例,正式扫描还应限制批量、处理重试上限与死任务。旧 worker 在租约失效后仍可能发送,所以领取令牌保护数据库标记,不自动消除 Broker 重复发布。

同一订单事件顺序怎么办?

多个 worker 并发发送,created 与 paid 事件可能交错。数据库按时间排序扫描,不等于 Broker 和消费者最终顺序一致。

可按聚合 id 分区并保证适当发送顺序,或者让消费者按版本与业务状态处理。方案取决于是否必须处理每一步、能否跳过旧版本,以及消息系统的顺序能力。

补偿之外为什么还要对账?

监控最老待发送事件、重试次数、发送延迟和冲突,不只监控消息数量。对账还应检查已提交业务是否有相应事件,以及下游是否完成应该发生的效果。

SENT 只表达转发器完成了相应发布交接,不代表下游业务已完成。需要端到端状态时,应设计下游结果或业务对账,而不是扩大这个字段的含义。

4. 设计判断与适用边界

整体链路按至少一次处理来设计时,每一处重试都要可接受。生产超时可能已入 Broker,消费超时可能已提交业务,转发器崩溃可能已发送但未记录。相同业务标识与持久结果让恢复能够回答之前发生了什么。

Outbox 不等于全链路严格一次。外部副作用、事件顺序与积压仍需约束,对账用于发现补偿链路也可能遗漏的状态。先定义消息对应的业务事实,再决定确认、重试和死信如何影响用户。

AWS 的幂等 API 文章可用于理解同一调用意图与重复尝试的区别。本文将这一问题落实到自己的数据库事务和执行记录。 AWS:Making retries safe with idempotent APIs。

5. 问答

问:confirm 成功意味着消费者处理成功吗?

不是,发布与消费确认分别对应不同交接点。

问:超时后重发为什么要保留事件 id?

原消息可能已经保存,稳定 id 有助于关联与幂等。

问:幂等就是记住所有消息 id 吗?

不一定,重复业务可能具有新消息 id,应按实际操作定义键。

问:同事务内先插入处理记录安全吗?

需要业务写入也在同一事务,且失败时整体回滚,提交才代表完成。

问:outbox 能保证消息只发送一次吗?

不能,发布成功但标记失败仍会重发。

问:为什么领取后先提交,再调用 Broker?

缩短数据库锁持有时间,同时依靠领取状态与租约恢复任务。

参考资料