分布式事务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,可以通过以下方法实现事务:
- 发起者创建 Xid,其它参与者共用这个 Xid,全流程都共用该 Xid。
- 不需要独立的事务管理器,任何一个发起者都可以充任事务管理器。
- 采用事务管理器,所有事务都集中到事务管理器
- 所有微服务都把自己搞成 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 的协助。
显然补偿是很繁琐的。
我们可以思考一下如何较方便的实现补偿。
- 整行快照。利用 JSON 字段可以轻松的将整行 DUMP 作为快照,结合 TID 可以组织为 {TID: {snap}}。
- 变更快照。类似,但是只记录有变更的字段。
- 事件溯源(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()