博客
关于我
强烈建议你试试无所不能的chatGPT,快点击我
金额操作中的并发事务问题
阅读量:2225 次
发布时间:2019-05-09

本文共 4588 字,大约阅读时间需要 15 分钟。

背景:

关于金额操作的并发事务如何处理是个经典问题,相信很多人都会遇到类似的需求,这里讨论下如何解决此类需求 ~


文章目录


一、需求

一个用户查询请求过来,首先需要数据库查询用户余额是否足够,如果不足则返回余额不足,如果足够则调用第三方接口查询(预计花费2s),如果第三方接口返回true则正常扣费,update用户余额,如果第三方接口返回false或者代码发生异常,则本次查询不扣费。

伪代码如下:

public String query(String userId) {
Double queryCost = 2.50; // 接口查询花费 Double balance = selectUserBalance(userId); // 数据库查询用户余额 if (balance - queryCost < 0) {
return "余额不足"; } // 调用第三方接口,需要扣我们自己的钱,预计花费2秒 String remoteResult = remoteQuery(); if (remoteResult == null) {
return "查询结果为空"; } else {
// 如果查询成功则更新用户余额 updateUserBalance(userId, queryCost); return "查询成功"; }}

过程可以简单抽象为三步:

数据库读余额、执行耗时任务、数据库写余额


二、存在问题

同一个用户同时多次查询,可能第一个请求进来判断余额充足,然后执行任务中还没到更新余额这一步,第二个请求进来判断余额依旧充足,执行任务,但实际该用户的余额可能只够一次查询。

即这三个步骤不是同步操作,无法保证一个请求线程读余额的时候,没有另外一个请求线程正在执行任务中,还没来得及写余额,导致只够查询一次的余额结果查询了多次,被用户薅了羊毛 ~

这种问题我们可能第一个想到的是事务或锁,那么如何正确合理地加事务加锁呢?

首先,锁有代码级别的锁,如synchronized、Lock等,也有数据库级别的锁,如悲观锁、乐观锁,配合事务使用。

我们下面依次进行对比分析。


三、代码级别加锁

我们以synchronized为例:

1.锁方法

public synchronized String query(String userId) {
}

2.或者降低锁粒度:锁方法改为锁代码块,只锁主要三个步骤

public String query(String userId) {
synchronized(this) {
// 读 - 执行耗时任务 - 写 }}

但这种方法明显效率极低,该方法执行时间长达几秒,如果锁住的话,虽然可以保证该service类所有方法读写余额时强制同步阻塞,多个请求进来时至少阻塞几秒,根本不能并发,无法忍受!

3.那你可能想到减少阻塞时间,只锁读写操作,耗时任务不用加锁

public String query(String userId) {
synchronized(this) {
// 读 - 写 } // 执行耗时任务 if (! success) {
// 如果不成功,补上之前扣的金额 }}

这个方案你可能觉得还行,我们确保只有一个线程可以进行余额读写,不会出现一个线程读完没来得及写,然后另一个线程又读了余额。不管最终扣不扣费,先扣了费再说,保证用户不会薅了羊毛,然后再根据第三方查询结果或者是否有异常进行手动调整费用,保证最终费用不会出问题。又由于只锁读写操作,没锁耗时任务,阻塞时间大大减少,可以满足少量并发的情况。

但这种方案也有几个疑点:

1.代码级别上锁,用户越多,总体阻塞时间越长,体验会下降很多。

2.不管是否真正完成了用户服务,先行扣费,后面针对实际状况再加回去。操作步骤稍显复杂,且要从产品角度考虑这样实现是否满足需求。

3.如果先扣了钱,但是当前服务突然挂了(后续补钱操作就没了),回复正常后,用户发现没得到服务但是发现钱被扣了,会投诉你们

4.如果分布式服务的话,那是不是还要改为分布式锁?


四、数据库级别加锁

我们先回顾下数据库锁的相关机制

我们的mysql一般都是InnoDB引擎,InnoDB 实现了以下两种类型的行锁:

共享锁(S):允许一个事务去读一行,阻止其他事务获得相同数据集的排他锁。

排他锁(X):允许获得排他锁的事务更新数据,阻止其他事务取得相同数据集的共享读锁和排他写锁。

为了允许行锁和表锁共存,实现多粒度锁机制,InnoDB 还有两种内部使用的意向锁(Intention Locks),这两种意向锁都是表锁

意向共享锁(IS):事务打算给数据行加行共享锁,事务在给一个数据行加共享锁前必须先取得该表的 IS 锁。

意向排他锁(IX):事务打算给数据行加行排他锁,事务在给一个数据行加排他锁前必须先取得该表的 IX 锁。

InnoDB加锁方法:

1.意向锁是 InnoDB 自动加的, 不需用户干预。

2.对于 UPDATE、 DELETE 和 INSERT 语句, InnoDB会自动给涉及数据集加排他锁(X);
3.对于普通 SELECT 语句,InnoDB 不会加任何锁;

事务可以通过以下语句显式给记录集加共享锁或排他锁

共享锁(S)SELECT * FROM table_name WHERE ... LOCK IN SHARE MODE。 其他 session 仍然可以查询记录,并也可以对该记录加 share mode 的共享锁。但是如果当前事务需要对该记录进行更新操作,则很有可能造成死锁。

排他锁(X)SELECT * FROM table_name WHERE ... FOR UPDATE。其他 session 可以查询该记录,但是不能对该记录加共享锁或排他锁,而是等待获得锁

我们一般给数据库加锁比较多的说法是悲观锁乐观锁,其实无论是悲观锁还是乐观锁,都是人们定义出来的概念,可以认为是一种思想。而某个数据库的某个引擎只是通过自身机制对其进行了实现而已。

悲观锁

当我们要对一个数据库中的一条数据进行修改的时候,为了避免同时被其他人修改,最好的办法就是直接对该数据进行加锁以防止并发。

这种借助数据库锁机制在修改数据之前先锁定,再修改的方式被称之为悲观并发控制。之所以叫做悲观锁,是因为这是一种对数据的修改抱有悲观态度的并发控制方式。我们一般认为数据被并发修改的概率比较大,所以需要在修改之前先加锁。

悲观并发控制实际上是“先取锁再访问”的保守策略,为数据处理的安全提供了保证。但是在效率方面,处理加锁的机制会让数据库产生额外的开销,还有增加产生死锁的机会;另外,还会降低并行性,一个事务如果锁定了某行数据,其他事务就必须等待该事务处理完才可以处理那行数据。

而悲观锁的实现就是上面说的共享锁排他锁。其中共享锁是读锁,多个事务都可以获取,容易造成死锁;我们通常用的比较多的是排他锁,也就是FOR UPDATE语句加锁,配合开启事务实现

注:MySQL InnoDB默认行级锁都是基于索引的,如果一条SQL语句用不到索引是不会使用行级锁的,会使用表级锁把整张表锁住,这点需要注意。

乐观锁

乐观锁( Optimistic Locking ) 是相对悲观锁而言的,乐观锁假设数据一般情况下不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果发现冲突了,则让返回用户错误的信息,让用户决定如何去做。

相对于悲观锁,在对数据库进行处理的时候,乐观锁并不会使用数据库提供的锁机制。一般的实现乐观锁的方式就是记录数据版本

乐观并发控制相信事务之间的数据竞争(data race)的概率是比较小的,因此尽可能直接做下去,直到提交的时候才去锁定,所以不会产生任何锁和死锁

乐观锁的概念中其实已经阐述了他的具体实现细节,主要就是两个步骤:冲突检测数据更新

其实现方式有一种比较典型的就是Compare and Swap(CAS)。CAS是项乐观锁技术,当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。

关于乐观锁的具体实现演变这里就不多说了。

如何选择乐观锁与悲观锁

1、乐观锁并未真正加锁,效率高。一旦锁的粒度掌握不好,更新失败的概率就会比较高,容易发生业务失败。

2、悲观锁依赖数据库锁,效率低。更新失败的概率比较低。

随着互联网三高架构(高并发、高性能、高可用)的提出,悲观锁已经越来越少的被使用到生产环境中了,尤其是并发量比较大的业务场景(这个具体要看业务需求,也不是绝对不能用)。


五、最终实现方案

我们发现可以锁住需要读写用户余额的这一行,加上排他锁,那么只会阻塞同一个用户的多个请求,使之串行化(吻合产品需求,前端每个查询都会有载入中提示,阻塞是正常的,所以也没必要使用乐观锁!),但多个不同用户的请求并不会阻塞(锁定的是不同行)!那么高并发多用户下,也不会影响效率

伪代码实现:

@Transactional(rollbackfor = Exception.class)    // 添加Spring声明式事务注解public String query(String userId) throws Exception {
Double queryCost = 2.50; Double balance = selectUserBalance(userId); // 原先查询语句后添加 for update if (balance - queryCost < 0) {
return "余额不足"; } // 调用第三方接口,需要扣我们自己的钱,预计花费2秒 String remoteResult = remoteQuery(); if (remoteResult == null) {
throw new DefineRuntimeException("查询结果为空"); } else {
// 如果查询成功则更新用户余额 updateUserBalance(userId, queryCost); return "查询成功"; }}

经测试,可以满足业务需求。


:mysql默认也是设置了获取 锁超时时间 的(如果某个事务阻塞等待锁超过这个时间就会报错,我们可以catch回滚),只是 默认值比较大(50秒),可以通过执行 SHOW GLOBAL VARIABLES LIKE'innodb_lock_wait_timeout' 来获取默认配置。

转载地址:http://grlfb.baihongyu.com/

你可能感兴趣的文章
时间复杂度
查看>>
【C++】动态内存管理 new和delete的理解
查看>>
【Linux】了解根目录下每个文件的作用
查看>>
【Linux】进程的理解(一)
查看>>
【Linux】进程的理解(二)
查看>>
【C语言】深度理解函数的调用(栈帧)
查看>>
【Linux】进程的理解(三)
查看>>
【C++】带头节点的双向线链表的实现
查看>>
【C++】STL -- Vector容器的用法
查看>>
【Linux】Linux中的0644 和 0755的权限
查看>>
【数据结构】有关二叉树的面试题
查看>>
【Linux】内核态和用户态
查看>>
【Linux】HTTP的理解
查看>>
【Linux】HTTPS的理解
查看>>
【操作系统】大小端问题
查看>>
Git上传代码时碰到的问题及解决方法
查看>>
【Linux】vim的简单配置
查看>>
【C++】智能指针
查看>>
【C++】const修饰的成员函数
查看>>
【C++】面向对象的三大特性
查看>>