分布式事务ABC

我们知道一个普通的事务意味着支持 begin commit rollback 三种操作, 其中 rollback 可能在出错后主动触发, 但也可能是被动触发, 例如会话断开数据库对事务回滚.

现在我们有两个数据库, db1 和 db2, 考虑转账场景, 用户 a 的账号在 db1, 用户 b 的账号在 db2.

作为客户端, 其活动为

db1.begin()
db2.begin()
a.balance -= 100
b.balance += 100
db1.commit()
db2.commit()

现在问题来了,如果 db1.commit() 成功了,而 db2.commit() 失败了,这时 db1 的 commit 操作就需要回滚,否则 a 的钱就会凭空消失。

下面的代码似乎可以处理这个问题:

try:
  db1.begin()
  db2.begin()
  a.balance -= 100
  b.balance += 100
  db1.commit()
  db2.commit()
catch:
  db1.rollback()
  db2.rollback()

但是, 万一在执行完 db1.commit() 后进程崩溃了错误就无法挽回了. try-catch 也只是运行在 JVM 内存中的一个结构, 如果进程死了 catch 也不会执行.

我们都听说过二阶段提交,下面就是二阶段提交了。

怎么个二阶段呢?

prepare:
    db1.begin()
    db2.begin()
    a.balance -= 100
    b.balance += 100
commit:
    db1.commit()
    db2.commit()
rollback:
    db1.rollback()
    db2.rollback()

这就是二阶段?这个方案显然不行,同样的问题依然存在,它无法应对进程崩溃。

这其实是对两阶段的一个误解。两阶段的 prepare 阶段的精髓在于 Transaction Manager 的日志。

xid = new Xid()
prepare:
    db1.begin(xid)
    db2.begin(xid)
    a.balance -= 100
    b.balance += 100
    db1.prepare(xid) 
    db2.prepare(xid)       
commit:
    db1.commit(xid)        
    db2.commit(xid)        
rollback:
    db1.rollback(xid)
    db2.rollback(xid)

二阶段的 TM 在 prepare(xid) 和 commit(xid) 后, 都会记录下 xid 对应在 db1 db2 的事务状态。

现在假如 db1.commit(xid) 后,客户端崩溃,当进程重启后,TM 会读取日志继续执行 db2.commit(xid)。进程崩溃了事务不是自动回滚吗?这是 XA 机制的特点,这种 XA 事务不会因会话结束自动回滚。

显然,XA 机制需要数据库支持。数据库怎么实现的可以以情理度之,无非就是为 Xid 生成了一段独立的 binlog 之类的。

Grok3 给的伪代码:

import javax.transaction.xa.*;
import java.io.*;

public class SelfManagedXATransactionWithLog {
    private static final String LOG_FILE = "transaction_log.txt";

    public static void main(String[] args) throws Exception {
        XAResource xaRes1 = getXAResource1();
        XAResource xaRes2 = getXAResource2();
        Xid xid = new MyXidImpl();

        // 检查是否有未完成的事务
        recoverFromLog(xaRes1, xaRes2);

        try {
            log("START", xid, "xaRes1, xaRes2");
            xaRes1.start(xid, XAResource.TMNOFLAGS);
            xaRes2.start(xid, XAResource.TMNOFLAGS);

            // 执行操作...

            log("PREPARING", xid, "xaRes1, xaRes2");
            xaRes1.prepare(xid);
            xaRes2.prepare(xid);
            log("PREPARED", xid, "xaRes1, xaRes2");

            log("COMMITTING xaRes1", xid, "xaRes1");
            xaRes1.commit(xid, false);
            log("COMMITTED xaRes1", xid, "xaRes1");

            log("COMMITTING xaRes2", xid, "xaRes2");
            xaRes2.commit(xid, false);
            log("COMMITTED xaRes2", xid, "xaRes2");

        } catch (XAException e) {
            log("ROLLBACK", xid, "xaRes1, xaRes2");
            xaRes1.rollback(xid);
            xaRes2.rollback(xid);
            throw e;
        }
    }

    private static void log(String state, Xid xid, String rms) throws IOException {
        try (FileWriter fw = new FileWriter(LOG_FILE, true)) {
            fw.write(String.format("XID: %s, State: %s, RMs: %s%n", xid.toString(), state, rms));
        }
    }

    private static void recoverFromLog(XAResource xaRes1, XAResource xaRes2) throws Exception {
        // 读取日志,恢复未完成的事务
        // 示例:如果发现 xaRes2 未提交,则调用 xaRes2.commit(xid)
    }
}

在这个程序里,客户端程序自己担任 Transaction Manager。它自己管理事务的状态,自己记录事务的日志,自己恢复未完成的事务。

我们可以使用嵌入的 Atomikos 实现上述逻辑。

上述逻辑是在一个进程进行的,也可以搭建一个事务管理服务器,例如 WebSphere、JBoss(现在叫 WildFly)。这样一来数据库连接就扔在 TM 服务器管理,客户端通过 TM 服务器获得数据库连接,TM 服务器负责维护上面的事务状态日志以及重启后继续事务。

可以看出,XA 协议实际上对业务端的影响不深,只有几个 READY PREPARE COMMITTED 之类的状态约束,和数据库的 WAL 日志是两码事。

上面我有意在同一个程序里分析二阶段而不是微服务, 这是因为微服务的问题在单进程同样存在, 微服务无非跨了一下进程, 但是由于在逻辑上有调用顺序, 问题实质与单进程并无区别.

在分布式环境下,事务有时会分布在多个微服务进程之间。如果继续采用 XA,可以通过以下方法实现事务:

  1. 发起者创建 Xid,其它参与者共用这个 Xid,全流程都共用该 Xid。
    1. 不需要独立的事务管理器,任何一个发起者都可以充任事务管理器。
    2. 采用事务管理器,所有事务都集中到事务管理器
  2. 所有微服务都把自己搞成 RM,连接到同一个 TM。

可以看出,XA模式可以直接用于微服务,但这个模式的缺点是慢,对于同一个客户端来说还是OK的,在微服务场景里,这意味着一个请求要等待所有微服务都完成事务,这可能导致数据库锁长时间不释放。想想看一个有奖竞猜的微服务宕机了,其它微服务的数据库包括账号等等都被它锁死,这是很危险的。

目前微服务完成分布式事务主要有 saga 模式和 TCC 模式。

我们只看 saga 模式。

service a:
    process:
        db1.begin()
        a.balance -= 100
        db1.commit()
    rollback:
        db1.begin()
        a.balance += 100
        db1.commit()
service b:
    process:
        db2.begin()
        b.balance += 100
        db2.commit()
    rollback:
        db2.begin()
        b.balance -= 100
        db2.commit()
service transfer:
    process:
        if(a.process()){
            if(b.process()){
                return true
            } else {
                a.rollback()
            }
        } else {
            a.rollback()
        }

可以看到,这个模式完全没有 TM,也没有 XA 协议。其在正常进行时效率无可挑剔,但是在进行补偿操作时风险较高。最大的风险就是重复执行,试想上面 a.rollback() 如反复执行,a 就中大奖了。这就是所谓的幂等性的要求。

什么是幂等?确保已经执行的 mutable 动作不再修改数据。

另外,saga 事务不符合 ACID,会短暂的出现 a 的账户已经扣款而 b 的账户没有到账的情形。

现在我们仔细思考幂等问题。

saga 模式严重依赖幂等。如何实现幂等呢?执行后需要记录个 flag,执行前检查一下 flag,这就可以了吧?

也就是说我们在客户端加一个写盘操作,以 TID:A, TID:B 这样的局部 Transaction ID 作为 KEY 记录状态,像二阶段一样保存状态 TID:B -> SUCCESS,这样就可以避免重做了。这种状态可以记录在 redis 之类的存储。

没错,但要注意,记录这个活动本身就可能失败!

这种日志失败和二阶段的日志失败是一回事?

不完全是,因为二阶段是天然幂等的,回滚再做一次也无所谓,因为事务有一个唯一的 undo log 供其 rollback,undo log 跑完就没了,再 rollback(xid) 也不会重复执行。

究其原因,这里引入了一个新的参与者,而这个参与者本身就可能失败,假如 TID:B -> PENDING 转为 SUCCESS 写盘失败,在进程重启后又会重做一次 rollback,可见,幂等性不能寄托在另一个参与者。

最可行的方法是在同一个数据库记录下上述局部的 TID 是否已执行,也就是说:

    conn.setAutoCommit(false);
    // 检查是否已执行
    if (db.exists(conn, "SELECT * FROM operations WHERE tid = ?", tid)) {
        conn.commit();
        return ResponseEntity.ok().build();
    }
    // 回滚
    db.execute(conn, "UPDATE accounts SET balance = balance + ? WHERE id = ?", amount, accountId);
    db.execute(conn, "INSERT INTO operations (tid, operation, status) VALUES (?, 'deduct', 'completed')", tid);
    conn.commit();

当然,对于支持数组的数据库,也可以记录在同一行的 operations[] 数组。

    updatae accounts set balance = balance + :amount, operations = operations || [:tid] where id=:account and operations @> [:tid] == false

这样一来,幂等所需的 flag 就和数据行处于同一个处境,二者必然同时成功和失败。

  • 幂等解决了重复执行的问题,如果进程崩溃时没有执行到呢?

这个可以通过状态机来做到,当任务进行到 PENDING 时总是会执行,任务成功了状态才会变为 SUCCESS,进程重启后对 PENDING 状态的任务再执行一次即可。

可见, saga 模式同样也需要事务 ID, 只不过其默认操作总是成功, 所以不再需要数据库 XA 的协助。

显然补偿是很繁琐的。

我们可以思考一下如何较方便的实现补偿。

  1. 整行快照。利用 JSON 字段可以轻松的将整行 DUMP 作为快照,结合 TID 可以组织为 {TID: {snap}}。
  2. 变更快照。类似,但是只记录有变更的字段。
  3. 事件溯源(Event Sourcing)。不直接修改数据库状态,而是记录所有操作作为事件流,状态通过事件重放生成。删除和补偿通过事件反向操作实现。有点像区块链的做法。

除了 saga,微服务常用的另外一种事务模式是 TCC,TCC 在转账的 try 环节会加一个冻结资金的动作, 这里就不展开了。

不管是 TCC 也好,saga 也好,都是和 XA 性质不同的应用层的事务实现。

从上面的分析可以看到,mongodb 实现 saga 要轻松的多。

分享一个很早之前思考过的一个问题:

db.begin()
sendSms(user)
db.writeSucess()
db.commit()

这里,假如 writeSuccess() 失败,短信已经下发,下次启动又会发送短信,如何解决?

有人说,我可以把待发送的短信记录在一个表 will_send_sms, sendSms 提交到这个表,另一个服务走这个表逐行下发,下发完毕就删除。

也有人说,kafka 支持事务,可以推到消息队列。的确 kafka 支持事务,甚至支持 XA 事务,可以确保能提交成功。尽管在架构形态上可能更漂亮,但和保存在同一个数据库的待发送短信表没有本质区别。

问题根源在于发送短信这个动作是一个不可撤销的,不可重复的,dirty 的 IO 活动,它是作用于现实世界的,而不是在计算机中,对此我们需要做到确保送达,而尽量做到幂等,也就是说短信必须送到,送重复了有影响但影响不大——听起来很像消息队列的特征。

所以我们在发送短信后写一个无事务的迅速完成的存储即可,如 rocksdb 之类,假如非要用数据库也不必上事务。

所以方案大概是这样:

sendSms(user)
rocksdb.writeSuccess()
posted @ 2025-02-27 15:55  Inshua  阅读(13)  评论(0)    收藏  举报