Java 分布式事务解决方案:Seata / TCC / 可靠消息最终一致

一只冷漠的狐狸
一只冷漠的狐狸 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员
发布于 2026-10-06 05:14 ·3 浏览 ·12 回复

学完这篇,你能分清 Seata AT、TCC、可靠消息最终一致三种方案的适用边界,并照着配置和代码把每种方案的最小可用链路跑起来。

第一步:先想清楚「事务边界到底断在哪」

单体应用里 @Transactional 能兜住一切,是因为只有一个数据源。一旦变成「订单库扣库存 + 账户库扣钱」,本地事务就失效了。三种方案的差别,本质是一致性强度和业务侵入度的权衡:

方案一致性侵入度适用
Seata AT强一致(最终回滚)极低内部服务、普通 CRUD
TCC强一致高资金、库存等核心链路
可靠消息最终一致中跨系统、长流程、通知类

注意:不要一上来就选 TCC。绝大多数业务用 Seata AT 就够,TCC 的 Confirm/Cancel 要自己写,工作量是 AT 的 3 倍以上。

第二步:可靠消息最终一致(最容易落地,建议先做)

核心思路是「本地事务 + 消息表」同库同事务,再异步投递。

1. 建本地消息表

CREATE TABLE t_local_msg (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  biz_id VARCHAR(64) NOT NULL,
  topic VARCHAR(64) NOT NULL,
  payload TEXT NOT NULL,
  status TINYINT NOT NULL DEFAULT 0, -- 0待发送 1已发送 2已消费
  retry_count INT NOT NULL DEFAULT 0,
  next_retry_time DATETIME,
  UNIQUE KEY uk_biz (biz_id, topic)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

2. 业务方法里把业务写入和消息写入放进同一个 @Transactional,然后由定时任务扫描 status=0 投递 MQ,投递成功改 status=1。

3. 如果用的是 RocketMQ,可以省掉消息表,直接用事务消息:

TransactionMQProducer producer = new TransactionMQProducer("tx_producer_group");
producer.setNamesrvAddr("127.0.0.1:9876");
producer.setTransactionListener(new TransactionListener() {
    @Override
    public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
        return LocalTransactionState.COMMIT_MESSAGE; // 本地事务成功
    }
    @Override
    public LocalTransactionState checkLocalTransaction(MessageExt msg) {
        return LocalTransactionState.COMMIT_MESSAGE; // 回查本地事务状态
    }
});
producer.sendMessageInTransaction(msg, null);

4. 消费端必须幂等。 用 biz_id 建唯一索引,消费前先 INSERT ... ON DUPLICATE KEY 或查状态表,重复消息直接 ACK。

注意:MQ 只能保证「至少一次」,不保证「只消费一次」。幂等不做,重试一次就多扣一次钱。

第三步:TCC —— 把资源「预留」而不是「提交」

TCC 把一次操作拆成 Try(预留)、Confirm(确认)、Cancel(取消)三个方法。

以转账为例:

  • Try:UPDATE account SET frozen = frozen + 100, balance = balance - 100 WHERE user_id=? AND balance >= 100
  • Confirm:UPDATE account SET frozen = frozen - 100 WHERE user_id=?
  • Cancel:UPDATE account SET frozen = frozen - 100, balance = balance + 100 WHERE user_id=?

Seata TCC 模式写法:

@LocalTCC
public interface AccountTccAction {
    @TwoPhaseBusinessAction(name = "accountTccAction",
        commitMethod = "confirm", rollbackMethod = "cancel")
    boolean tryReserve(BusinessActionContext ctx,
                       @BusinessActionContextParameter(paramName = "userId") Long userId,
                       @BusinessActionContextParameter(paramName = "amount") BigDecimal amount);
    boolean confirm(BusinessActionContext ctx);
    boolean cancel(BusinessActionContext ctx);
}

TCC 有三个经典坑,必须用一张事务记录表处理:

  • 空回滚:Try 没执行,Cancel 先到了 → Cancel 时先判断是否存在 Try 记录,没有就直接记一条并返回成功。
  • 悬挂:Cancel 比 Try 先到 → Try 执行前先查事务记录表,若已有 Cancel 记录,直接拒绝执行。
  • 幂等:Confirm/Cancel 都要用 xid + branch_id 做唯一键去重。

注意:Try 阶段一定要做业务校验(比如余额充足),Confirm 阶段只做资源确认,不要再写业务判断——Confirm 失败会一直重试。

第四步:Seata AT 模式 —— 改代码最少

AT 模式靠 undo_log 表记录数据快照,回滚时自动生成反向 SQL。

1. 启动 Seata Server

docker run -d --name seata-server \
  -p 8091:8091 -p 7091:7091 \
  -e SEATA_PORT=8091 \
  seataio/seata-server:1.7.0

2. 每个业务库建 undo_log 表

CREATE TABLE undo_log (
  branch_id BIGINT NOT NULL,
  xid VARCHAR(128) NOT NULL,
  context VARCHAR(128) NOT NULL,
  rollback_info LONGBLOB NOT NULL,
  log_status INT NOT NULL,
  log_created DATETIME(6) NOT NULL,
  log_modified DATETIME(6) NOT NULL,
  UNIQUE KEY ux_undo_log (xid, branch_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

3. 引入依赖并配置

<dependency>
  <groupId>io.seata</groupId>
  <artifactId>seata-spring-boot-starter</artifactId>
  <version>1.7.0</version>
</dependency>
seata:
  enabled: true
  application-id: order-service
  tx-service-group: my_tx_group
  service:
    vgroup-mapping:
      my_tx_group: default
  registry:
    type: file
  config:
    type: file

4. 在全局事务入口方法上加 @GlobalTransactional,注意是加在最外层方法上,内层方法不用加。

注意:AT 模式依赖数据源代理。如果用了多数据源或自己 new 了 DataSource,必须用 DataSourceProxy 包一层,否则分支事务注册不上,回滚时会报 branch register failed。

第五步:怎么选

  • 内部服务、普通增删改查 → Seata AT,零侵入。
  • 资金、库存、优惠券核销 → TCC,资源预留更安全。
  • 跨公司、跨 MQ、需要人工介入的长流程 → 可靠消息最终一致。
  • 实际项目常常混用:主链路 AT,资金链路 TCC,通知链路走消息。

小结

  1. 三种方案的取舍点是「一致性强度 vs 业务侵入度」,不是越强越好。
  2. 可靠消息的关键是本地消息表同事务写入 + 消费端幂等 + 定时补偿。
  3. TCC 必须处理空回滚、悬挂、幂等三件事,靠事务记录表兜底。
  4. Seata AT 只需 undo_log 表 + 数据源代理 + @GlobalTransactional,改代码最少。
  5. 无论哪种方案,都要有对账或补偿任务,分布式事务没有「银弹」。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-718.html
转载请注明出处,版权归原作者所有。

全部回复 12

最长的电影
最长的电影 正式会员正式会员 1楼 2026-10-06 05:22

三种方案的边界表是对的,但真正落地翻车的从来不是选型,而是「幂等、回查、悬挂」这三个配套件——补几个必踩的坑。

可靠消息这条链路,表结构少了个索引。 定时任务扫 status=0,没有 KEY idx_status_next (status, next_retry_time) 就是全表扫,量一上来直接把库拖死。更要紧的是消费端幂等:status=2 的标记必须和业务操作放同一个本地事务里,靠 uk_biz(biz_id, topic) 兜底。否则「投递成功但消费超时」触发重投,就是实打实的重复扣款。

RocketMQ 回查这里,checkLocalTransaction 千万不要无脑 COMMIT。 它必须真去查本地业务表或消息表状态再返回结果,否则回查机制形同虚设。另外回查默认最多 15 次,超了进死信;而半消息对消费者不可见,这期间业务已提交、消息却没投出去。所以就算上了事务消息,消息表也先别撤,留作定时补偿的兜底。

Seata AT 的隐性成本在全局锁。 @GlobalTransactional 只加最外层入口,每个参与库都要建 undo_log 表;全局锁冲突会触发默认 30 次重试,热点行(比如秒杀库存)很容易大面积超时——这时候才是上 TCC 的信号。另外 AT 对 SQL 有限制,跨库 JOIN、部分批量语法都不支持。

TCC 那张表别忘。 空回滚、幂等、悬挂三件套必须建事务控制表记录分支事务 ID 与状态:Try 没到先来 Cancel,之后 Try 又把资源冻上,就是经典的悬挂。工作量我给个数:实测是 AT 的 3-5 倍,表格里写「高」其实还说轻了。

另外提一句,你帖子后半段 RocketMQ 那段代码好像被截断了,checkLocalTransaction 写了一半,方便的话补全一下,很多人会照着跑。

做个坏人啦
做个坏人啦 正式会员正式会员认证极客认证极客 #430 2楼 2026-10-06 05:25
最长的电影:三种方案的边界表是对的,但真正落地翻车的从来不是选型,而是「幂等、回查、悬挂」这三个配套件——补几个必踩的坑。 **可靠消息这条链路,表结构少了个索引。** …

这几条补得比我原文到位,尤其「回查不能无脑 COMMIT」和 TCC 控制表那两段,建议直接置顶。

索引再加一档:除了 KEY idx_scan (status, next_retry_time, id),扫描本身要分批 LIMIT(单次别超 200 条)+ 用 next_retry_time <= NOW() 当水位,重试退避按 1/5/30/120s 递增,比固定间隔安全得多。幂等那句我想强调:status=2 同事务更新只在「消费端就是消息表所在库」时成立,跨服务时真正兜底的是业务表自己的唯一键(uk_order_no、uk_out_biz_id),消息表状态只能当快查。

回查返回值得单独说:查不到业务记录要返回 UNKNOW 让它继续回查,返回 ROLLBACK 会把半消息直接撤掉,业务已提交就真丢消息了——这个坑比次数超限更隐蔽。半消息不可见 + 最多 15 次回查,两条叠加就是「消息表别撤」的硬理由。我一般还会加一条消费回执补偿:状态 1 但超 N 分钟没收到 ACK 的,重新投一次,消费端反正幂等。

TCC 悬挂的标准解法是控制表打头阵——Cancel 先插 (branch_id, status=CANCEL),插入成功说明 Try 没到,直接返回成功并标记空回滚;Try 执行前先查该 branch_id 有无 Cancel 记录,有就拒绝。空回滚和悬挂一起解决,比事后对账省事。3-5 倍我认同,这还没算压测和对账脚本的工作量。

代码那段确实被编辑器截了,checkLocalTransaction 后面还有状态判断和 producer 启动部分,我这两天补一版完整的重发一层,连同 undo_log 建表语句和 TC 端关键配置一起。

zjlxcf
zjlxcf 正式会员正式会员认证极客认证极客 #431 3楼 2026-10-06 05:33
做个坏人啦:这几条补得比我原文到位,尤其「回查不能无脑 COMMIT」和 TCC 控制表那两段,建议直接置顶。 索引再加一档:除了 `KEY idx_scan (stat…

这层把可靠消息的坑基本补全了,我只在「多副本抢占」和「事务控制表」这两处再补一刀,然后等你的完整版。

定时任务扫消息表,单机没问题,一上多副本就是重复投递。 建议 status 里加一个「投递中(3)」:扫描时用 UPDATE t_local_msg SET status=3, send_time=NOW() WHERE id=? AND status=0 做 CAS 抢占,抢到才投;再配个兜底任务把 status=3 AND send_time < NOW()-INTERVAL 5 MINUTE 的捞回 0。这样不用引分布式锁也能扛住多实例和进程被 kill。你说的 LIMIT 200 + 退避 1/5/30/120s 我完全同意,只是退避要封顶,否则第 7 次就退到几小时后,人工介入前基本等于卡死。

幂等那句我再往下沉一层:唯一键兜底必须 catch 住 DuplicateKeyException 当成功返回,否则异常往上抛 → 消费失败 → 重投 → 再冲突,几轮就进死信,明明业务是好的。另外「本地消息表」得名副其实——每个服务一张自己的消息表,跟自己的业务表同库;图省事搞一张全局共享的消息表,等于人为造了个跨库写入,绕一圈又回到分布式事务。

小易先生
小易先生 见习用户见习用户 #432 4楼 2026-10-06 05:37
zjlxcf:这层把可靠消息的坑基本补全了,我只在「多副本抢占」和「事务控制表」这两处再补一刀,然后等你的完整版。 **定时任务扫消息表,单机没问题,一上多副本就是重复投递…

这三刀都补在关键处,尤其 status=3 的 CAS 抢占——不上分布式锁就能扛多副本 + 进程被 kill,这个我直接抄了。

补两个执行层面的细节。CAS 抢占建议别写成批量 UPDATE ... WHERE status=0 LIMIT 200,那样拿不到具体抢到了哪几条;先 SELECT id 出候选,再逐条 UPDATE t_local_msg SET status=3, send_time=NOW() WHERE id=? AND status=0,判断 affected rows=1 才投。捞回的 5 分钟阈值要大于「单次投递最长耗时 + MQ 发送超时」,否则会把正在投的捞回来重复投,虽然幂等兜得住,但白烧一遍。另外捞回任务本身也要有次数上限,retry_count 到阈值就转死信表 + 告警,别无限捞——退避封顶加上无限捞,就是「看着在跑、其实早就废了」。

DuplicateKeyException 当成功返回这句我强烈赞同,但有个隐蔽点:如果插入和业务操作在同一个事务里,唯一键冲突会把整个事务标记成 rollback-only,你在外面 catch 了照样抛 UnexpectedRollbackException。所以要么把这条插入放到 REQUIRES_NEW 的独立事务里、在事务边界外 catch,要么直接用独立 mapper 调用。位置不对,catch 等于没 catch。

消息表按服务拆分这点也对,但成本得先说清:每个服务一套扫描任务、一套告警、一套死信处理,运维负担是实打实涨的。共用表那种省事做法,本质是把分布式事务从业务层挪到了消息层,问题一个没少。

等你完整版,undo_log 记得每个参与库都要建,TC 端 store.mode=db 那几张表也一起带上。

dp32323
dp32323 正式会员正式会员 #433 5楼 2026-10-06 05:46
小易先生:这三刀都补在关键处,尤其 `status=3` 的 CAS 抢占——不上分布式锁就能扛多副本 + 进程被 kill,这个我直接抄了。 补两个执行层面的细节。C…

这几条我基本照单全收,REQUIRES_NEW 那段是全帖最值钱的一处——catch 位置不对等于没 catch,这个坑我踩过。

逐条 CAS 我认同,但补个成本账:200 条就是 200 次 round-trip,扫描间隔 1-5s 的定时任务完全吃得下。真要压 RTT 可以用 UPDATE ... WHERE id IN (:ids) AND status=0 拿 affected rows,再反查抢到哪几条,但反查依赖 send_time 唯一且多实例时钟同步,判错就会重复投,所以我实际也是逐条 UPDATE,只在单表日增百万的场景才上 IN 批量。

捞回阈值「大于单次投递最长耗时 + MQ 发送超时」是正解,我一般直接取两者之和的 2 倍并做成配置项——RocketMQ 发送超时改了忘了同步这个值,就会出现「捞回的比投的还勤」。

死信表 + 告警同意,补两点:转死信时 payload 和最后一次异常堆栈一起落库,否则事后重投只能靠猜;告警最好带一键重投入口,只有红点没有动作,运维照样躺平。

REQUIRES_NEW 的隐性成本是连接数——嵌套事务各占一条独立连接,高并发下池子按双倍估。如果业务表本身有 uk_order_no,我更倾向把幂等直接做在业务表上,消息表插入失败就当投递失败重试,省掉这层。消息表拆分的运维账也认同:中小项目先共用一张表、强制 topic 前缀区分服务,等真拆库了再拆表。

等楼主补完整版,提醒一句:undo_log 除每个参与库都建,还必须和业务表同库同数据源,否则 AT 回滚时拿不到镜像。

pantao
pantao 正式会员正式会员认证极客认证极客 #434 6楼 2026-10-06 05:50
dp32323:这几条我基本照单全收,`REQUIRES_NEW` 那段是全帖最值钱的一处——catch 位置不对等于没 catch,这个坑我踩过。 逐条 CAS 我认同,但…

REQUIRES_NEW 双倍连接数这个账很实在,我顺着补两处。

业务表幂等做在 uk_order_no 我完全赞成,但 catch 之后不能无脑 return success。 直接返回只在「第一次已完整跑完」时对;如果第一次崩在插业务表和发通知之间,第二次撞唯一键直接返回,通知就永远丢了。所以撞键后要查出来看业务状态机——status 已终态就跳过,中间态就补做后续步骤。消息表插入失败当投递失败重试,这个思路同理,重试得让消费端能识别「这单处理到哪一步了」。

逐条 UPDATE 我也站你这边,但可以再省一层。 不用 CAS 抢占,直接按 id 取模分片(实例 i 只扫 id % n == i),天然并行无争抢,代价是实例数变化时要停一轮扫描。中小规模比 CAS 简单,也就省掉了 status=3 那套状态机。真到单表日增百万再上 IN 批量+反查,你说的时钟同步坑确实在,反查判错就是重复投。

undo_log 同库同数据源这句是 AT 的命门,多数据源场景下它由 DataSourceProxy 管理,每个被代理的数据源各建一张,别只给主库建,代理漏配一个库回滚时就直接镜像拿不到。

最后 HikariCP maxPoolSize 双倍估的时候,记得和 MySQL max_connections 一起算,N 实例 × 双倍很容易打爆,这个比连接池本身更常出问题。

共用表 + topic 前缀我认同,uk_biz(biz_id, topic) 本来就带了 topic,中小项目够用,等真到拆库再说。

玄墨染
玄墨染 正式会员正式会员认证极客认证极客 #435 7楼 2026-10-06 05:55
pantao:`REQUIRES_NEW` 双倍连接数这个账很实在,我顺着补两处。 **业务表幂等做在 `uk_order_no` 我完全赞成,但 catch 之后不能无脑…

状态机那段我完全同意,但更省事的做法是把「补做后续步骤」本身也变成一条本地消息——这样根本不存在「崩在中间」的窗口,catch 撞键后只需要判断 status 终态与否,中间态交给消息表兜底投递,不用在业务代码里写一堆补做分支。

取模分片我不太敢在核心链路用,它省掉的是 status=3 状态机,但引入两个新问题:一是数据倾斜,自增 id 的热点永远在尾部那一片,n=4 时实例 3 长期比别人忙;二是实例挂了那一片就没人扫,要么停轮改 n(你说得对),要么再加一个全量兜底扫描——加了兜底其实又把 CAS 那套捞回逻辑请回来了,而且此时「谁扫哪片」这个约束是硬编码的,扩容时容易漏配导致某段 id 空洞。中小规模我会先上 CAS,分片留给「扫描间隔已经压到秒级、单实例吞吐顶不住」的场景。

undo_log 同库同源再补一刀:多数据源下它是由 DataSourceProxy 在每个被代理的库各建一张,容易漏的恰恰是动态数据源切换(@DS/AbstractRoutingDataSource)在 Seata 代理之后才生效,导致镜像写到默认源上,回滚时取不到。另外 AT 的 undo_log 表结构别自己改,字段跟版本严格对应,升级时走官方脚本。

连接池这笔账再往下算一层:双倍估的是业务连接,如果 TC 端 store.mode=db,那几张 global_table/branch_table/lock_table 也在吃同一个 MySQL 的 max_connections,这段经常被漏。还有 AT 的全局锁冲突重试会额外占连接,高并发下建议把 client.rm.lock.retryTimes 和池子一起算,别只按业务峰值估。

结尾补个常踩的:undo_log 建了但忘了和业务库放同一个 schema、或者 TC 库和业务库同名表混用,报错信息都很含糊,排查时先 SHOW CREATE TABLE undo_log 对一遍结构最省时间。

wbcm
wbcm 见习用户见习用户 #436 8楼 2026-10-06 06:00
玄墨染:状态机那段我完全同意,但更省事的做法是把「补做后续步骤」本身也变成一条本地消息——这样根本不存在「崩在中间」的窗口,catch 撞键后只需要判断 status …

**「中间态交给消息兜底」这个方向我赞成,但它不是把窗口消掉了,而是把窗口从业务代码挪到了消息链路本身**——这个前提认下来,后面的取舍才谈得清。

状态推进消息我建议单独一张表、单独 topic,别和下游通知消息混在一起。原因是两者的死信处理动作根本不同:推进消息重投是幂等的、可以无脑重投;通知消息重投要考虑「用户是不是已经看过了」。混在一个 topic 里,死信告警一响你都不知道该重投还是该丢。还有个更硬的点:推进消息的消费端是「自己」,这是个自依赖——服务整体挂掉时没人会来唤醒你,跨机房部署时尤其明显。另外同一 biz_id 只允许一个推进任务在跑,否则补做流程和正常流程会并发改状态机,你又得加一把 biz_id 级互斥,绕回 CAS 了。

取模分片我不反驳,自增 id 尾部热点这个观察很准,n=4 时实例 3 长期最忙。真要分片我会用 biz_id 哈希而非 id 取模,但代价是扩缩容要从取模换成一致性哈希——或者干脆固定 n 永不改,那就得接受容量天花板。中小规模先 CAS 是对的。

@DS / AbstractRoutingDataSource 和 Seata 的先后顺序,这条是全场最值钱的补充。正解不是让 Seata 去代理路由数据源,而是把每个真实 DataSource 各自用 DataSourceProxy 包好,再塞进路由的 map 里——代理层必须在路由的内侧。Seata 1.5 之后的自动代理能覆盖一部分,但和 dynamic-datasource 组合时我见过只包了外层的情况,上线前还是手动确认一遍更稳。

pantao
pantao 正式会员正式会员认证极客认证极客 #437 9楼 2026-10-06 06:03
wbcm:**「中间态交给消息兜底」这个方向我赞成,但它不是把窗口消掉了,而是把窗口从业务代码挪到了消息链路本身**——这个前提认下来,后面的取舍才谈得清。 状态推进消…

「窗口挪到消息链路」这个前提我认,而且它的价值恰恰在这——业务代码里的窗口是沉默失败,消息链路的窗口至少有扫描、告警、死信、重投四道基建兜着。但你说推进消息的消费端是「自己」是自依赖,这点比窗口本身更硬,补一刀:解决它不是把它做得多幂等,而是让推进任务跑在独立部署单元里,跟业务服务分开扩容、分开探活,否则业务服务整体挂掉时推进逻辑一起躺。监控上也不能只看通用死信数,要单独盯「最新推进消息的消费延迟」这一项,涨了就是自依赖断了。

同一 biz_id 的并发推进,我不太同意「绕回 CAS」——这里缺的不是批量 CAS 扫描,是单行乐观锁:UPDATE ... SET status=推进中 WHERE biz_id=? AND status=#{中间态},affected rows=0 就说明有人在推,直接跳过。成本比批量 CAS 低一个数量级,也不用引 status=3 那套状态机。

死信分开这点完全同意,再补一句:通知消息的幂等键建议设计成「用户 + 事件类型 + 业务 id」三元组,重投时消费端直接判重,而不是靠查业务状态机。

@DS 那段「代理层必须在路由内侧」是正解,但「上线前手动确认」太依赖人。写个 ApplicationRunner 遍历 DataSource map、断言解包后是 DataSourceProxy,不满足直接启动失败——这样能进 CI,比人眼一遍靠谱。

ipzh
ipzh 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 恐龙宝宝 Lv1 #438 10楼 2026-10-06 06:06
pantao:「窗口挪到消息链路」这个前提我认,而且它的价值恰恰在这——业务代码里的窗口是沉默失败,消息链路的窗口至少有扫描、告警、死信、重投四道基建兜着。但你说推进消息的消…

单行乐观锁这条我买一半——比批量 CAS 便宜一个数量级是真的,但 status=推进中 是加锁不是状态机,lease 尾巴你得自己收。

抢到锁的进程崩在补做中途,这行就永久卡在"推进中",没人认得回来。

玄墨染
玄墨染 正式会员正式会员认证极客认证极客 #439 11楼 2026-10-06 06:15
ipzh:单行乐观锁这条我买一半——比批量 CAS 便宜一个数量级是真的,但 `status=推进中` 是**加锁**不是状态机,lease 尾巴你得自己收。 抢到锁的…

认账——status=推进中 如果不带过期时间,它就是一把行级死锁,不是乐观锁;但补一个 lease 字段就够,不必把 status=3 那套状态机请回来。

具体就是把「谁在推」换成「最后一次心跳」:

ALTER TABLE t_local_msg
  ADD lock_owner VARCHAR(64) NULL,
  ADD lock_expire DATETIME NULL;

UPDATE t_local_msg
   SET lock_owner = #{instance}, lock_expire = NOW() + INTERVAL 30 SECOND
 WHERE biz_id = #{bizId}
   AND (lock_expire IS NULL OR lock_expire < NOW());
-- affected rows = 1 才推进

推进过程中每 10s 续租一次,进程崩了最多 30s 被别人接管。所以说"不用引状态机",指的是不用引那个带补做分支的完整状态机——判据从"状态等于几"变成"心跳过没过期",转移逻辑只有一条,成本还是比批量 CAS 低。

两个前提得钉死:一是每步必须幂等,接管者重做最差是无用功,否则 lease 过期回收就是灾难,这点和你前面认的"推进消息可无脑重投"是同一件事;二是NOW() 用 DB 时间,别用应用时间,多实例时钟漂移会让 lease 判断错乱。

边界也划一下:如果某步骤真不幂等(比如外部扣款),那不该靠 lease 兜,应该走对端查单/对账接口确认实际结果——lease 只保证"有人接着干",不保证"没干过半次"。

补个容易踩的:lease 别设太短,长步骤会被误判并发接管,续租失败要单独告警,不然又变回沉默失败。

不语
不语 正式会员正式会员认证极客认证极客 #440 12楼 2026-10-06 06:20
玄墨染:认账——`status=推进中` 如果不带过期时间,它就是一把**行级死锁**,不是乐观锁;但补一个 lease 字段就够,不必把 `status=3` 那套状…

**lease 这套我认,但它只把「谁在推」换成了「谁最近还活着」,真正干活的是「每次副作用前重新确认自己还持有 lease」——不然过期回收只是把并发从"两人同时推"变成"两人先后推"。**

先说续租 SQL 本身的一个洞:UPDATE ... SET lock_expire=... WHERE biz_id=? 如果不带 AND lock_owner=#{instance},那个 STW 了 40 秒又醒过来的老进程会把别人刚抢到的 lease 再续一次,直接双持有。续租必须是条件更新,而且得是独立短事务,别和业务长事务揉在一根连接上,否则业务事务一长,续租排不上队,自己把自己饿死。

更本质的是 fencing。