分布式事务

什么是事务

事务是数据库的术语,表示事务范围内的操作不成功便成仁【要么成功,要么失败,没有中间状态】。单条sql的执行默认都是事务的,也即单条sql的执行要么成功,要么失败,不存在中间状态。对于多条sql的事务,则需要显式的开启和关闭事务。比如下单减库存的操作:

1
2
3
4
5
6
7
8
#开启事务
begin;
#新增订单
insert into order values (...);
#修改库存
update stock set amount = amount -1 where goods_name = 'xxoo';
#提交 或 根据业务逻辑主动失败回滚
commit; # rollback;

和事务密切关联的是数据库的隔离级别。数据库的隔离级别定义了不同事务中看到的数据的差异。Mysql有四种隔离级别:

隔离级别 脏读 不可重复读 幻读
读未提交
读已提交
可重复读 是(实际测试,发现此隔离级别下是解决了幻读问题的,测试mysql版本:5.7.34-log)
串行化

不同的隔离级别下,不同事务的数据一致性不一样。Mysql默认隔离级别是可重复读。可以通过【show variables like ‘%transaction%’;】查看默认隔离级别。此隔离级别下的语义是多次select的结果对其他事务的update屏蔽(可重复读),但是其他事务的insert、delete是可以看到的(幻读)。

因此对于一个涉及查询更新的多条sql事务,由于查询的结果对其他事务的update屏蔽,因此有可能更新后的结果并不是我们想要的。比如:业务逻辑:执行当库存大于0时减库存,如果按以下查询更新执行:

1
2
3
4
5
begin;
select * from stock where goods_name = 'xxoo' and amount > 0;
#业务逻辑判断select结果不为空,则递减amount
update stock set amount = amount -1 where goods_name = 'xxoo';
commit;

此时,如果amount字段类型可以为负数,则事务执行成功,但是由于其他事务也执行了相同的操作并且先一步递减了amount,则此时amount变成了负值,不符合我们的业务逻辑。

因此事务并不能保证多条sql语句的执行在业务逻辑上的正确性。

如果只是sql操作,可以通过sql语句层面的悲观锁和乐观锁来解决。

悲观锁:

1
2
3
4
5
begin;
select * from stock where goods_name = 'xxoo' and amount > 0 for update;
#业务逻辑判断select结果不为空,则递减amount
update stock set amount = amount -1 where goods_name = 'xxoo';
commit;

此时,其他事务的select for update操作会被阻塞直到此事务提交或回滚,由此可以保证业务逻辑的正确性。

乐观锁:旧值判断或者新增版本号、时间戳字段来判断

1
2
3
4
5
begin;
select * from stock where goods_name = 'xxoo' and amount > 0;
#业务逻辑判断select结果不为空,则递减amount
update stock set amount = amount -1 where goods_name = 'xxoo' and amount > 0;
commit;

此时,其他事务的update操作会被阻塞直到此事务提交或回滚,由此保证业务逻辑的正确性。

如果sql操作中间还有业务逻辑,比如下面的对Account.amount的累加操作放在业务代码里执行而不是sql语句中执行,则要另外加语言层面的锁保证多线程下的安全性。

Transactional注解

Transactional注解基于AOP,通过动态代理,生成代理类,在执行Transactional注解标注的方法前开启事务,在执行Transactional注解标注的方法后提交事务。并提供了方法执行过程中碰到何种异常执行回滚的属性rollbackFor,所以方法中如果有try-catch捕获异常会导致事务无法正确回滚。

问题:在Transactional注解标注的方法内加锁,导致锁失效的问题

描述:有个递增更新amount字段的操作如下,为了保证递增操作在多线程下的安全,加了锁。但是测试的时候发现amount的更新值不对,锁住的代码块还是并发执行了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Transactional(rollbackFor = Throwable.class)
public boolean updateLoginInfo(String deviceString, String packageFlag,String version, AccountParam param) {
Account update = new Account();
update.setLoginpackageflag(packageFlag);
update.setPackageType(0);
update.setLastLoginTime( LocalDateTime.now().format( DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss")));
update.setLoginDevString(deviceString);
synchronized (this) {
Account oldAccount = this.accountMapper.getByUserName(deviceString);
int oldAmount = oldAccount.getAmount() == null ? 0 : oldAccount.getAmount();
update.setAccid(oldAmount + 1);
int count = this.accountMapper.updateByExampleSelective(update, param);
if (count == 0) {
return false;
}
}
return true;
}

分析:由于Transactional注解是基于Spring的AOP,会在执行方法之前开启事务,之后再加锁,当锁住的代码执行完成后,再提交事务,因此锁住的代码块执行是在事务之内执行的,因此在synchronized代码块执行完后,事务还未提交,锁已经被释放,此时其他线程可以开启新的事务并拿到锁之后执行锁住的代码块,导致并发问题。

解决:将锁放到Transactional标注的方法外面

1
2
3
4
5
6
7
8
9
10
11
public boolean updateLoginInfo(String deviceString, String packageFlag,String version, AccountParam param) {
Account update = new Account();
update.setLoginpackageflag(packageFlag);
update.setPackageType(0);
update.setLastLoginTime( LocalDateTime.now().format( DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss")));
update.setLoginDevString(deviceString);
synchronized (this) {
amountMapper.transactionalUpdate(deviceString, update, param);
}
return true;
}
1
2
3
4
5
6
7
8
9
10
11
@Transactional(rollbackFor = Throwable.class)
public boolean transactionalUpdate(String deviceString, Account update, AccountParam param) {
Account oldAccount = this.accountMapper.getByUserName ( deviceString );
int oldAcid = oldAccount.getAccid () == null ? 0 : oldAccount.getAccid ();
update.setAccid ( oldAcid + 1 );
int count = this.accountMapper.updateByExampleSelective ( update, param );
if (count == 0) {
throw new UpdateException();
}
return true;
}

分布式事务

分布式事务就是为了保证不同数据库的数据一致性。

分布式环境下的系统不可能同时满足C(一致性)、A(可用性)、P(分区容错性);而分布式环境下P是必然要保证的。 所以分布式系统要么侧重于AP,要么侧重于CP。

由于CAP理论对一致性、可用性的定义太精准,现实的系统不可能同时满足100%的一致性和可用性,又提出了BASE(基本可用、软状态、最终一致性)理论。

Mysql XA

mysql 5.7及以上提供对分布式事务的支持,实现了XA规范,XA规范定义了分布式事务:

RM:资源管理者,要具备commit/rollback的能力。

TM:事务管理者,要具备协调通知各个RM是执行commit还是rollback的能力。

一个全局事务是由若干个本地事务分支组成的,每个RM内部的操作就是一个本地事务分支。

TM通过XID来协调各个本地事务的操作。

TM和RMs之间使用两阶段提交协议来运行:

第一阶段:

TM向所有RMs发起prepare commit请求,每个RM根据自身情况回复TM是否可以commit,如果可以则预留本地事务需要的资源然后回复OK,如果不可以则回复FAIL

第二阶段:

TM根据回复情况,如果全部回复OK则向所有RMs发起commit请求,每个RM执行commit并回复TM操作结果。如果有一个回复FAIL则向所有RMs发起rollback请求,每个RM执行rollback并回复TM操作结果。

由于Mysql XA强依赖于数据库版本,所以实际上应用很少。更多是通过中间件来实现分布式事务。

两阶段提交2PC

定义来自维基百科:

第一阶段(提交请求阶段)
  1. 协调者节点向所有参与者节点询问是否可以执行提交操作,并开始等待各参与者节点的响应。
  2. 参与者节点执行询问发起为止的所有事务操作,并将Undo信息Redo信息写入日志。
  3. 各参与者节点响应协调者节点发起的询问。如果参与者节点的事务操作实际执行成功,则它返回一个"同意"消息;如果参与者节点的事务操作实际执行失败,则它返回一个"中止"消息。

有时候,第一阶段也被称作投票阶段,即各参与者投票是否要继续接下来的提交操作。

第二阶段(提交执行阶段)
成功

当协调者节点从所有参与者节点获得的响应消息都为"同意"时:

  1. 协调者节点向所有参与者节点发出"正式提交"的请求。
  2. 参与者节点正式执行提交,并释放在整个事务期间内占用的资源。
  3. 参与者节点向协调者节点发送"完成"消息。
  4. 协调者节点收到所有参与者节点反馈的"完成"消息后,完成事务。
失败

如果任一参与者节点在第一阶段返回的响应消息为"终止",或者协调者节点在第一阶段的询问超时之前无法获取所有参与者节点的响应消息时:

  1. 协调者节点向所有参与者节点发出"回滚操作"的请求。
  2. 参与者节点利用之前写入的Undo信息执行回滚,并释放在整个事务期间内占用的资源。
  3. 参与者节点向协调者节点发送"回滚完成"消息。
  4. 协调者节点收到所有参与者节点反馈的"回滚完成"消息后,取消事务。

有时候,第二阶段也被称作完成阶段,因为无论结果怎样,协调者都必须在此阶段结束当前事务。

缺点:

  1. 执行过程中第一阶段和第二阶段参与节点均处于阻塞状态。【解决:阿里Fescar的解决方案,直接在第一阶段执行commit并写undo log;然后在第二阶段:如果第一阶段commit成功,则异步删除相应的undo log即可;如果第一阶段commit失败,则根据undo log异步执行rollback并删除相应的undo log】
  2. 在第二阶段部分参与者执行失败时,数据会不一致。【解决:在协调者引入重试机制】
  3. 协调者单点失败问题。【解决:由第三方集群部署的中间件来充当协调者】
  4. 协调者崩溃后或者网络问题参与者和协调者失联后,导致参与者一直阻塞。【解决:在参与者引入重试机制】

TCC

TCC(Try-Confirm-Cancle)又叫补偿事务。相当于把2PC第二阶段的成功、失败两种情况拆分成Confirm和Cancle两个阶段。因此它存在的问题和2PC存在的问题一样。

RocketMQ分布式事务消息方案

在分布式环境下,多个服务之间通过消息队列进行异步调用,在这种环境下如何保证多个上下游消息调用之间的事务性(ACID)。由于异步解耦,通常会采用可靠消息最终一致性方案。

RocketMQ事务消息的原理图大致如下,RocketMQ充当了分布式事务中的协调者,并且在协调者中引入了重试机制,在消费者侧也引入了重试机制,所以整个分布式事务的一致性可用性最后就只取决于RocketMQ中间件的高可用性。另外由于引入了重试机制,需要服务提供的接口满足幂等性,避免重复调用引起的数据不一致。

参考

阿里开源分布式事务解决方案 Fescar 全解析

面试官:了解分布式事务?讲讲你理解的2PC和3PC原理

拜托,面试请不要再问我TCC分布式事务的实现原理

分布式事务之如何基于RocketMQ的事务消息特性实现分布式系统的最终一致性

分布式一致性事务看这篇就够了

-------------本文结束感谢您的阅读-------------
Good for you!