从一笔交易说起,如何处理好数据的一致性问题

发表于:从一笔交易说起,如何处理好数据的一致性问题

什么是分布式事务?

互联网应用中,随着系统用户数量的增多,访问压力也不断增大,数据功能相互独立的模块拆分开来,对其进行集群部署。

比如完成一笔交易,分别需要在交易模块订单模块用户数据模块中进行处理,分别做一些数据的更新或者入库,当三个模块都处理完毕之后,才算完成了这笔交易的事务。在这种分布式部署的系统中,需要处理的数据分布在不同的物理节点上,怎么去保证能处理完一笔交易之后的数据完整性呢,这就是分布式事务考虑的事情。

分布式事务如何处理

如何用消息系统避免分布式事务?这里提到的方案也是可行的,但是更常见的场景是,我们对接了第三方的支付,需要调用第三方支付接口,而第三方支付的API一般我们不可能有那么高的控制权(第三方支付究竟打款成功没有,我们不可能立刻知道,也不可以在第三方支付那边加上应用消息确认表来防止重复投递的问题(因为第三方对接一般不会跟渠道做一些定制上的数据存储改变)。

不过类似的,我们可以根据第三方API来实现基于消息的分布式事务。具体过程如下:

  • 在需要与第三方系统交互的数据处理,使用编程式事务,在系统异常回滚的时候,取消请求第三方系统,如果第三方系统已经处理了相关数据,则通知其回滚(需要第三方支持该操作);
  • 在本地系统创建一个消息表,处理完一笔订单,则添加一条消息,用于第三方系统的消费,每次投递消息后,则设置消息为处理中;
  • 注意,为了只发送一次请求(如果第三方api有类似的去重机制,那么可以不用限制只发送一次请求,实际上消息去重第三方已经帮你实现了),不应该在处理前请求第三方接口判断是否已经处理,因为假如一个请求到了第三方系统,消息被阻塞了,而另一个查询执行状态的请求已经处理成功了,结果我们得到的还是未处理的状态,于是又尝试请求一次,这样,连续两次请求都提交到了第三方系统;更好的做法应该是提交请求之前就设置消息状态为处理中,间隔久一点的时间定期轮训请求结果,如果还是未执行,则产生报警并生成重发消息的表单之类的,双方确认没问题之后再重新让消息投递过去;不过如果是两个系统都是内部的,则可以很方便控制API来通过消息状态来防止消息重复投递。

关于本地系统线程重复请求的问题

为了防止并发多个线程重复执行,加入了分布式锁。防止重复点击两次,导致重复执行的情况。这样又影响了前端的体验,目前该功能都是财务人员使用的,如果类似的,我们需要让用户主动触发该功能,那么并发量就上来了,这个分布式锁则会引发性能问题,很明显,我们需要通过其他的可靠方式来取代这个分布式锁:

  • 通过数据库行锁的方式:根据支付订单,创建支付消息,根据支付消息发送打款请求,打款请求发出去之前,先通过数据库的行锁update更新支付消息为已提交支付请求,如果更新成功,则表明可以继续往下执行,如果更新失败,则表明已经被另一个线程处理掉了。

  • 根据乐观锁的方式:获取到乐观锁的优先update更新状态,并发送消息,那么下一个线程进来的时候就无法继续执行了,为了让系统更可靠,乐观锁也应该是要保证高可用的,可以通过Redis集群或者zk集群的方式提供分布式锁服务。

关于事务的四个特征

ACID

原子性:购买操作所有数据处理要么都失败,要么都成功;
一致性:一个事务的执行,不能破坏数据库的一致性;
隔离性:不同事务操作相同的数据,每个事务都有各自完整的数据空间,并发执行的各事务不能相互干扰;
持久性:事务提交之后,数据库中的变更是永久的。

怎么设置事务隔离级别

Read Uncommitted: 允许脏读,不可重复读,存在幻读
Read Committed: 没有脏读脏读,不可重复读(提交后可读),存在幻读
Repeatable Read: 没有有脏读,可重复度,存在幻读
Serializable: 没有脏读,可重复度,不存在幻读

Serializable串行执行,效率太低。

一般使用Repeatable Read,这类隔离级别可重复读,保证一个事物内数据处理的一致性,避免了脏读也防止了事务回滚造成的脏数据,更大程度上保证了事务的一致性,至于幻读,如果我们处理好具有竞争关系的数据库资源,则可以很好的避免数据不一致的问题,举个例子:

购买理财产品,可购买余额剩下1000块,A用户购买金额扣减成功了,B用户进来发现没得购买了,然后就提示已募集结束,A接下来执行的时候因为异常,事务回滚了,这个时候又恢复了可购买余额1000块,B用户或者C用户可以继续钱够这1000块,这种情况也是运行出现的,但是最终我们能够确保卖出去的钱不会超额。曾经就遇到过一个这样的系统,购买最后一笔可购买余额被多下了两次单,导致募集金额多了,这就是并发事务和具有竞争关系的资源没控制好的情况,怎么彻底避免这类问题呢?我们先来研究下数据库的隔离级别和事务的传播特性。

事务的隔离级别

假设我们程序里面设置的数据库隔离级别是使用数据库默认的,通过查询:

1
select @@global.tx_isolation;

可以看到,假设查出来的是REPEATABLE_READ,允许重复读。接下来做一组试验。

通过两个事务观察数据库的更新操作

线程1执行事务:

1
2
3
update t_audio set like_num=like_num-10 where id=1 and like_num>0;
// 休眠30秒
...

线程2执行事务:

1
2
3
update t_audio set like_num=like_num-10 where id=2 and like_num>0;
// 休眠30秒
...

可以看到由于这两个线程更新两条不一样的数据,所以都可以立刻更新成功;

而如果把线程2执行更新的记录id也改为1,可以发现线程2一直卡在那里,等地线程1的事务提交。

进一步测试,线程2不更新,只是查询线程1修改的记录:

1
select * from t_audio where id=1;

因为事务隔离级别是REPEATABLE_READ,而线程2执行的时候线程1事务还没有提交,所以看到的是修改前的值。

结论

我们可以看到,在一个事务中修改中的数据库记录是有行锁的,在事务提交之前该锁不会释放,直到事务提交之后才释放.

关于事务的传播特性

我们来假设一下有如下场景

1
2
3
transA(){  // PROPAGATION_REQUIRED
transB(); // PROPAGATION_REQUIRES_NEW
}

假设设置的事务隔离级别为Repeatable Read,transA方法的传播特性为PROPAGATION_REQUIRED,transB的传播特性为PROPAGATION_REQUIRES_NEW,假设这两个方法都同事执行了:

1
update t_audio set like_num=like_num-10 where id=1

也就是说,在两个事务里面,我们都对同一个记录进行了更新,我们执行一下,可以发现,两次改动都成功了,好像两次事务的改动都成功了的样子,PROPAGATION_REQUIRES_NEW是怎样的机制呢,我们先来分析下。

事务嵌套是个什么概念

其实我们所说的嵌套事务(nested transaction),对应的 spring的事务传播特性则是:

PROPAGATION_NESTED

这篇文章里面已经提到,该传播特性是通过设置savepoint的方式实现的,并不是在事务里面又开启了事务这样的嵌套。

我们有必要再次区分这两种传播特性:

  • PROPAGATION_NESTED:通过savepoint实现了子事务;

  • PROPAGATION_REQUIRES_NEW把当前事务挂起了,然后开启一个新的数据库连接并创建新的事务。说到事务挂起,就要顺便提一下MySQL的事务模型了,Mysql使用了平面事务模型(flat model, not nested model)。

平面事务模型和嵌入事务模型有什么区别呢?

在嵌入式事务模型中,如果你开启了一个事务,并且想在当前事务下继续开启一个新的事务,第一个事务依旧会保持正常的开启状态,也就是说,第二个事务会嵌套在第一个事务里面;而在平面式事务中,是不允许事务嵌套的,如果开启了一个事务之后,继续开启另一个事务,会自动先提交第一个事务。

MySQL使用了平面事务模型:嵌套的事务是不允许的,在连续开启第二个事务的时候,第一个事务自动提交了。

see more and here

为了更具体的说明MySQL不允许这种事务里面嵌套新的事务的情况,我可以做一个实验:

首先设置为不自动提交事务

set autocommit=0

设置事务隔离级别:

set tx_isolation=’REPEATABLE-READ’

1
2
3
4
5
6
7
8
9
START TRANSACTION;
# 外部事务更新记录
update gt_audio set like_num=like_num-10 where id=1;
START TRANSACTION;
# 内部事务更新同一条记录
update gt_audio set like_num=like_num-20 where id=1;
ROLLBACK; # 内部事务回滚
# 外部事务查找记录
COMMIT;

like_num的初始值是30,开启第二个事务之后,在另一个会话中查询,发现like_num的值已经变为了20,可以发现,执行第二个START TRANSACTION的时候,第一个事务的内容已经提交了,继续测试可以发现,无论后面怎么回滚或者提交,都无法撤销第一个事务的改动了。

很明显,Spring的PROPAGATION_REQUIRES_NEW是不可以通过这样简单粗暴的sql来实现的,唯一的一种实现方式就是开启第二个事务的时候,将第一个事务的数据库连接暂时存起来不使用,并开启一个新的数据库连接来创建新的事务,没错,Spring内部就是这样实现的,PROPAGATION_REQUIRES_NEW的挂起操作,带来了一个新的事务状态:Suspend。除了PROPAGATION_REQUIRES_NEW这种传播特性,PROPAGATION_NOT_SUPPORTED也会让事务进入Suspend状态。

什么时候应该使用PROPAGATION_NESTED

使用PROPAGATION_NESTED是有前提的,就是该嵌套事务可能需要做分支处理。像这样:

1
2
3
4
5
6
7
transactionA(){
try{
transactionB();
} catch(SomeException e){
// 处理其他数据
}
}

因为内部事务回滚实际上是返回到了内部事务之前的savepoint,所以外部事务的其他部分还可以继续执行,否则用PROPAGATION_REQUIRED就足够了。

什么时候应该使用PROPAGATION_REQUIRED_NEW

如果不管外部方法执行成功与否,某些内嵌的方法都要执行成功,即提交对数据的修改,那么就可以使用这种传播属性啦,常见的是交易系统中的日志记录,无论方法执行成功或者失败,都需要保存日志;当然了,如果我们有单独的日志系统,也可以通过发送异步消息的方式,在别的系统中保存日志,这样就无需开启这种事务了。

PROPAGATION_SUPPORTS的作用

对于前面使用REPEATABLE_READ的例子,两个事务相互隔离,一个事务对数据的改动,是不会让另一个事务读取到的,如果有这样一个方法:

1
2
3
4
5
6
void updateData(){
// 更新金额相关操作
...
// 读取金额的方法
long amount = readAmount();
}

如果我们设置readAmount()方法的事务传播属性为PROPAGATION_SUPPORTS,那么该方法就会使用updateData()方法的事务了,这样上面改动金额之和,readAmount也可以立刻读取到改动后的值了,因为是处于同一个事务中。

事务隔离级别最佳实践: 当为方法分配事务属性的时候,把类中对大部分方法最具限制性的属性作为类级别的默认属性, 然后再对有特殊需要的方法进行微调.如果默认的,我们的读方法不需要加事务,则可以配置为PROPAGATION_SUPPORTS方式,对于其他写的方法,则使用PROPAGATION_MANDATORY来确保需要事务,这样即使写方法里面又调用了读的方法,也会自动包含在这个事务里面了。

可以考虑如下配置

PROPAGATION_MANDATORY作为默认属性, 而对查询方法使用PROPAGATION_SUPPORTS属性。

这样所有的方法都默认需要事务了,不会因为编写的过程中忘了考虑事务而产生意向不到的错误,在不需要事务的地方在专门的配置,对于查询方法,使用Supports会避免上面更新金额的例子产生的问题。

设置为可重复读,对于非嵌套的两个事务,如果一个事务的改动没提交,另一个事务读取到的是改动前的值,另外,如果两个事务要改动同一条记录,那么其中一个事务一定会等另一个事务执行完毕之后才可以继续进行修改,由于是可重复度,第一个事务改动后,第二个事务读取到的还是原来的值,但是通过update… set a=a-1这样修改的时候,a却应用了两次的修改,例如:

1
2
3
4
5
6
START TRANSACTION;  # 同时开启事务A和事务B
select like_num from gt_audio where id=1; # 事务A和事务B都读取到的值为10
# 外部事务更新记录
update gt_audio set like_num=like_num-10 where id=1; # 事务A对该值-10
select like_num from gt_audio where id=1;
COMMIT; # 事务A提交之后,事务B再读取值,还是10,但是事务B执行上面的update语句之后,数值直接变为-10了

考虑以上sql,两个事务同事执行,刚开始查出来的like_num都是10,
事务1修改并提交之后,事务2继续查出来的还是10,因为是可重复读的隔离级别,但是事务二执行更新语句之后,like_num却突然直接变为了-10,这说明了两个事务通过a=a-1这种形式进行更新是不会覆盖第一个事务做的改动的。

由于InnoDB引擎实现了MVCC多版本并发控制,所以update之后看到的总是最新的数据。

MySQL的innodb引擎是如何实现MVCC的?

innodb会为每一行添加两个字段,分别表示该行创建的版本和删除的版本,填入的是事务的版本号,这个版本号随着事务的创建不断递增。在repeated read的隔离级别(事务的隔离级别请看这篇文章)下,具体各种数据库操作的实现:

  • select:满足以下两个条件innodb会返回该行数据:(1)该行的创建版本号小于等于当前版本号,用于保证在select操作之前所有的操作已经执行落地。(2)该行的删除版本号大于当前版本或者为空。删除版本号大于当前版本意味着有一个并发事务将该行删除了。
  • insert:将新插入的行的创建版本号设置为当前系统的版本号。
  • delete:将要删除的行的删除版本号设置为当前系统的版本号。
  • update:不执行原地update,而是转换成insert + delete。将旧行的删除版本号设置为当前版本号,并将新行insert同时设置创建版本号为当前版本号。

其中,写操作(insert、delete和update)执行时,需要将系统版本号递增。

If Eventual Consistency Seems Hard, Wait Till You Try MVCC

MVCC浅析

最终一致性其实比MVCC简单

实际运用中推荐可重复读REPEATABLE READ这个隔离级别,这个隔离级别能够保证ACID。

讲到了这里,你真的了解事务嵌套吗?怎么选择事务传播特性?事务隔离级别又需要如何考虑?

java concurrent包中的copyonwrite系列就是专门用于优化读远大于写的场景的。写操作的时候,copy一份数据用于读取,写完后原子替换掉旧的数据,这样写操作不会阻塞读操作。

解惑 spring 嵌套事务
浅谈Spring事务隔离级别
《Java事务设计策略》

什么是脏读,不可重复读,幻读

http://www.cnblogs.com/phoebus0501/archive/2011/02/28/1966709.html

怎么防止幻读带来的问题?

(update … 看是否更新到了,从而进行判断)

通过事务保证一致性,就会影响系统的可用性,如何解决这类问题?

在MySQL中update一个记录,直到该事务提交之前,都会锁住该记录,为了让其他事务能够看到其中的改动,就得使用read uncommit隔离级别了,但这样会引入不可重复读的问题,需要权衡,如果允许这种数据的不一致性,则使用REPEATABLE-READ就够了;

对于有竞争关系的资源,如果两个事务同时这样操作:判断是否符合条件,符合条件则修改,发现两个事务都修改了,修改之后变成不符合我们想要达到的改动效果了;

对于有竞争关系的资源,不可以进行这样判断修改(单线程程序才可以这样搞)。

这种情况最好使用乐观锁进行实现(类似抢购系统那种资源处理方式)。在一些高并发的场景中,采用数据库的行锁不如使用Redis的乐观锁,防止请求累积在数据库,通过redis直接过滤掉了一些不合法的请求,减轻数据库连接资源的消耗。

请求 –> redis乐观锁处理 –> MySQL

这样过滤之后,可以保证每一个时刻只会开启一个数据库连接,不会占用数据库连接资源。

也可以使用数据库行锁来保证:

update t_audio set like_num = 1 where id = id and like_num= 0. 1 row affected 说明更新成功.不管多少并发,数据库都能保证只有一个才事务更新成功。

MySQL高并发下的解决方案

脱离 Spring 实现复杂嵌套事务,之三(REQUIRES_NEW - 独立事务)