Naixxfl
← 全部笔记

写完库再发消息,这一步根本没有事务

微服务分布式事务消息队列MySQL

先看这段代码,它大概是分布式系统里出现频率最高的 bug:

@Transactional
public void createOrder(OrderCmd cmd) {
    orderRepo.save(order);          // 有事务
    mq.send(new OrderCreated(...)); // 没有事务
}

问题一句话说完:数据库事务管不到消息中间件。

这两行之间任何一点意外,进程挂了、MQ 连不上、网络抖了,数据就分家。而代码看上去毫无破绽,@Transactional 明晃晃写在那儿,谁看都觉得挺稳。

三种绕不过去的写法

先发消息再写库。 消息出去了,写库失败回滚。下游收到 OrderCreated,兴冲冲去扣库存,拿订单号一查,没有这笔单。

先写库再发消息,也就是上面那段。库写成功,发消息时进程挂了,下游永远收不到。还有更隐蔽的另一半:mq.send 抛异常会让事务回滚,可消息可能已经发出去了 —— broker 收到了,只是 ack 在回来的路上丢了。订单没了,消息在外面飘着。

用 @TransactionalEventListener(AFTER_COMMIT)。 这个比前两种强,至少保证了事务提交成功才发。但提交完成到消息发出之间仍然有一个窗口,进程刚好死在这中间,消息照样丢。

它把窗口变小了,没有消掉。做系统的时候,"变小了"和"没有了"是两件完全不同的事。前者的意思是你会在某个凌晨被它叫醒。

Outbox:把"要发什么"也写进同一个事务

思路特别朴素。消息先别发,先当成一行数据写进业务库,和业务数据共用一个事务。

CREATE TABLE outbox_message (
  id           BIGINT PRIMARY KEY AUTO_INCREMENT,
  aggregate_id VARCHAR(64)  NOT NULL,   -- 聚合根 id,同一实体的消息靠它保序
  topic        VARCHAR(128) NOT NULL,
  payload      JSON         NOT NULL,
  status       VARCHAR(16)  NOT NULL,   -- PENDING / SENT
  retry_count  INT          NOT NULL DEFAULT 0,
  created_at   DATETIME(3)  NOT NULL,
  sent_at      DATETIME(3)  NULL,
  KEY idx_status_id (status, id)
);
@Transactional
public void createOrder(OrderCmd cmd) {
    orderRepo.save(order);
    outboxRepo.save(OutboxMessage.of("order.created", order.getId(), payload));
}

一个事务,两行数据,要么都在要么都不在。中间那个窗口没了。

让业务代码看不见 outbox

上面那段写法有个小毛病:领域服务里混进了一行基础设施代码。用 BEFORE_COMMIT 可以把它挪走。

// 领域服务只管发事件
orderRepo.save(order);
events.publishEvent(new OrderCreated(order.getId(), ...));

// 基础设施层统一落 outbox
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void onDomainEvent(DomainEvent e) {
    outboxRepo.save(OutboxMessage.from(e));
}

关键在 BEFORE_COMMIT 这个相位。它仍在原事务内执行,写进去的 outbox 行和业务数据一起提交。要是写成 AFTER_COMMIT,事务已经结束,那行根本落不了库,就又回到了开头那个问题。

投递器:轮询还是 CDC

剩下的交给一个独立的发送器,把 PENDING 捞出来投递,成功了标记 SENT。

投递方式有两种。轮询表实现简单,延迟就是轮询间隔,几百毫秒完全够用;订阅 binlog 延迟更低、也不给业务库加读压力,代价是多运维一套组件。Debezium 甚至内置了 io.debezium.transforms.outbox.EventRouter 这个 SMT,专门把 outbox 表的行转成目标 topic 的消息,不用自己写转换。

我的建议是先轮询。为一个还没出现的性能问题,提前引入一个必须高可用的组件,不划算。

多实例一起轮询会重复捞

服务是多副本的,两个实例同时 SELECT ... WHERE status = 'PENDING',会捞到同一批行,然后各发一遍。

MySQL 8.0 有现成的解法:

SELECT * FROM outbox_message
 WHERE status = 'PENDING'
 ORDER BY id
 LIMIT 100
 FOR UPDATE SKIP LOCKED;

SKIP LOCKED 的语义是「别人锁住的行我直接跳过,不排队」。于是两个实例各自拿到不相交的一批,天然分片,不需要任何协调服务。

另一条路是用 ShedLock 给 @Scheduled 加分布式锁,让同一时刻只有一个实例在跑。实现更简单,代价是吞吐上不去,而且那个实例卡住的时候没人接手。

那个索引会慢慢失效

idx_status_id (status, id) 刚上线时很好用。跑上几个月,表里 99.99% 的行都是 SENT,索引的区分度就没了 —— 优化器看到 status 这一列几乎只有一个值,很可能直接放弃走索引改全表扫。

MySQL 没有部分索引,没法只给 PENDING 建。可用的办法是发完就把行删掉,需要留痕就先搬进归档表。表里常驻只有几十上百行待发消息,怎么查都快。

我见过有人用「发完把 status 置为 NULL」来模拟部分索引,这在 MySQL 上没用,NULL 一样进索引。

代价:你从此活在"至少一次"里

发送器在标记 SENT 之前挂掉,重启后会重发。这是 Outbox 换一致性要付的钱。

所以消费端必须幂等。这一步不做等于没做对,你只是把"丢消息"换成了"重复消费",而重复扣款比丢消息还难解释。

两种做法。去重表最通用:

CREATE TABLE consumed_message (
  message_id  VARCHAR(64) PRIMARY KEY,
  consumed_at DATETIME NOT NULL
);

消费时先插一行,撞唯一键就说明处理过,直接 ack 掉。注意插去重记录和业务处理必须在同一个事务里,否则"业务做了、去重记录没写上",下次照样重复。

业务状态是单向流转的话,直接用状态机更省事,连表都不用建:

UPDATE `order` SET status = 'PAID'
 WHERE id = ? AND status = 'PENDING';

看影响行数。返回 0 说明别人已经处理过了,直接确认。这个写法还顺带解决了并发,不用加锁。

顺序只保证该保证的那部分

同一个订单的「创建 → 支付 → 发货」乱序消费会出事,不同订单之间乱不乱无所谓。

按 aggregate_id 哈希分区,保证同一个聚合根的消息落在同一分区就够了。追求全局有序意味着单分区,吞吐直接锁死,为一个不需要的保证把性能赔进去很亏。

这里有个容易漏的地方:投递器如果用线程池并发发送,分区保序会被它自己破坏。 表里明明是按 id 排好的,并发发出去之后到达 broker 的顺序就不一定了。要么按 aggregate_id 取模分给固定线程,要么同一批里同一个聚合根的消息串行发。

payload 里的结构是冻在写入那一刻的

消息体一旦落库,格式就定死了。发送器可能几分钟后才发,消费者可能几周后才上线新版本。

所以 payload 不能直接序列化领域对象。领域对象随时会重构,字段改个名,躺在 outbox 里的老行就没人认得了。要单独定义一个事件 DTO,并且只做加字段这种向后兼容的改动。

同样的道理,别在 payload 里存"当前状态",存"发生了什么"。OrderPaid{orderId, amount, paidAt} 是安全的,OrderSnapshot{...整个订单...} 在消费的时候已经过期了。

那 Seata 呢

Seata 本身没问题,它解决的问题常常不是你现在这个问题。

AT 模式靠前后镜像加全局锁做自动回滚。全局锁会实实在在改变并发行为,热点行、批量更新、慢 SQL 这些场景下代价不小。另外还得多养一个 TC 组件,它挂了,所有参与全局事务的业务跟着停。

所以先问自己一句:这个业务能不能接受几百毫秒到几秒的不一致窗口?

能,Outbox 就够了,代价是两张表加一个发送器。不能,再去认真评估强一致方案,并且接受它在并发和运维上的成本。

我见过不少团队一上来就把 Seata 引进去,最后用到的只有"自动回滚"这一个特性,而那个特性用 Outbox 加一条反向补偿消息也能做到。

上线前至少盯两个指标

一个是积压量,status = 'PENDING' 的行数。

另一个更有用:最老一条未发送消息的年龄。

SELECT TIMESTAMPDIFF(SECOND, MIN(created_at), NOW())
  FROM outbox_message WHERE status = 'PENDING';

积压一百条可能只是流量高峰,没什么。但如果最老那条已经躺了十分钟,说明发送器要么死了,要么卡在某条毒消息上反复重试。光看积压数量看不出来这件事 —— 数量可能一直稳定在一百条,只是永远是同样那一百条。

配上重试上限和死信处理,别让一条烂消息堵死整条队列。