面试必备的数据库悲观锁与乐观锁

前言在上一个章节5分钟带你读懂事务隔离性与隔离级别 的最后,其实我们已经提到了锁的概念。本章节接下来将主要介绍以下数据库悲观锁与乐观锁的相关知识。如有错误还请大家及时指出本文已同步至 GitHub/Gitee/公众号,感兴趣的同学帮忙点波关注问题:为什么需要锁?什么是悲观锁?什么是乐观锁?悲观锁与乐观锁区别与联系?悲观锁与乐观锁的使用场景?为什么需要锁?在并发环境下,如果多个客户端访问同一条数据,此时就会产生数据不一致的问题,如何解决,通过加锁的机制,常见的有两种锁,乐观锁和悲观锁,可以在一定程度上解决并发访问。1. 悲观锁(Pessimistic Lock)1.1 定义百度百科: 悲观锁,正如其名,具有强烈的独占和排他特性。它指的是对数据被外界(包括本系统当前的其他事务,以及来自外部系统的事务处理)修改持保守态度,因此,在整个数据处理过程中,将数据处于锁定状态。悲观锁的实现,往往依靠数据库提供的锁机制(也只有数据库层提供的锁机制才能真正保证数据访问的排他性,否则,即使在本系统中实现了加锁机制,也无法保证外部系统不会修改数据)。其他知识点悲观锁主要是共享锁或排他锁共享锁又称为读锁,简称S锁,顾名思义,共享锁就是多个事务对于同一数据可以共享一把锁,都能访问到数据,但是只能读不能修改。排他锁又称为写锁,简称X锁,顾名思义,排他锁就是不能与其他所并存,如一个事务获取了一个数据行的排他锁,其他事务就不能再获取该行的其他锁,包括共享锁和排他锁,但是获取排他锁的事务是可以对数据就行读取和修改。1.2 案例分析使用场景举例:以MySQL InnoDB为例作为演示,我们继续使用之前的数据库表:product表productIdproductNameproductPriceproductCount1小米19991002魅族1999100首先我们需要set autocommit=0,即不允许自动提交有看过上一篇文章5分钟带你读懂事务隔离性与隔离级别 的同学,可以看到最后我们使用事务隔离级别时,所引申出来的根本问题就是可以通过锁机制解决。问题在并发情况下回导致数据一致性的问题:如果有A、B两个用户需要抢productId =1的小米手机,A、B用户都查询小米手机数量是100,A购买后修改商品的数量为99,B购买后修改数量为99。用法每次获取小米手机时,对该商品加排他锁。也就是在用户A获取获取 id=1 的小米手机信息时对该行记录加锁,期间其他用户阻塞等待访问该记录。代码如下:start transaction; select p.productCount from product p where p.productId = 1 for update; update product p set p.productCount=p.productCount-1 where p.productId=1 ; commit;操作下面同时打开两个窗口模拟2个用户并发访问数据库时间轴事务A事务BT1start transaction; T2select p.productCount from product p where p.productId = 1 for update; T3 start transaction;T4 select p.productCount from product p where p.productId = 1 for update;(等待中…)流程说明用户A start transaction开启一个事物。前一步我们关闭了mysql的autocommit,所以需要手动控制事务的提交。在获得小米手机信息(productId = 1 )时,进行数据加锁操作(for update)。与普通查询方式不同,我们使用了select…for update的方式,这样就通过数据库实现了悲观锁。在这个update事务提交之前其他外界是不能修改这条数据的,但是这种处理方式效率比较低,一般不推荐使用。用户B start transaction开启一个事物。用户B 也进行查询操作,此时处于等待中(阻塞状态)。ps:需要等待用户A事务提交后,才会执行。注意:在事务中,只有select…for update(排他锁) 或lock in share mode(共享锁) 操作同一个数据时才会等待其它事务结束后才执行,一般select… 则不受此影响。例如在 T3中执行select p.productCount from product p where p.productId = 1;则能正常查询出数据,不会受第一个事务的影响。2. 乐观锁(Optimistic Lock)2.1 定义百度百科: 乐观锁机制采取了更加宽松的加锁机制。乐观锁是相对悲观锁而言,也是为了避免数据库幻读、业务处理时间过长等原因引起数据处理错误的一种机制,但乐观锁不会刻意使用数据库本身的锁机制,而是依据数据本身来保证数据的正确性。其他知识点实现乐观锁一般来说有以下2种方式:使用版本号使用数据版本(Version)记录机制实现,这是乐观锁最常用的一种实现方式。何谓数据版本?即为数据增加一个版本标识,一般是通过为数据库表增加一个数字类型的 “version” 字段来实现。当读取数据时,将version字段的值一同读出,数据每更新一次,对此version值加一。当我们提交更新的时候,判断数据库表对应记录的当前版本信息与第一次取出来的version值进行比对,如果数据库表当前版本号与第一次取出来的version值相等,则予以更新,否则认为是过期数据。使用时间戳乐观锁定的第二种实现方式和第一种差不多,同样是在需要乐观锁控制的table中增加一个字段,名称无所谓,字段类型使用时间戳(timestamp), 和上面的version类似,也是在更新提交的时候检查当前数据库中数据的时间戳和自己更新前取到的时间戳进行对比,如果一致则OK,否则就是版本冲突。2.2 案例分析使用场景举例:以MySQL InnoDB为例作为演示,我们继续使用之前的数据库表:product表productIdproductNameproductPriceproductCountversion1小米199910012魅族19991002我们以版本号实现的方式进行说明。操作查询当前事务隔离级别:SELECT @@tx_isolation;结果:REPEATABLE-READ下面同时打开两个窗口模拟2个用户并发访问数据库第一种测试时间轴用户A用户BT1start transaction; T2select * from product p where p.productId = 1;(productCount=100) T3update product p set p.productCount = 99,version=version+1 where p.productId = 1 and version = 1;(受影响的行: 1) T4 start transaction;T5 select * from product p where p.productId = 1;(productCount=100)T6 update product p set p.productCount = 99,version=version+1 where p.productId = 1 and version = 1;(等待中…)T7commit; T8 T6执行(受影响的行: 0)T9 commit;流程说明事务A开启事务。事务A查询当前小米手机数量为100。事务A购买小米手机,小米手机数量更新为99。(此时并未提交事务)。事务B开启事务。事务B查询当前小米手机数量为100。事务B购买小米手机,小米手机数量更新为99。注意:此时处于阻塞状态。事务A提交事务。此时第六步执行完毕,但并未成功(受影响的行: 0)。事务B提交事务。第二种测试时间轴用户A用户BT1select * from product p where p.productId = 1;(productCount=100) T2update product p set p.productCount = 99,version=version+1 where p.productId = 1 and version = 1;(受影响的行: 1) T3 select * from product p where p.productId = 1;(productCount=100)T4 update product p set p.productCount = 99,version=version+1 where p.productId = 1 and version = 1;(受影响的行: 0)乐观锁小结用户B修改数据的时候,受影响行数为0,对业务来说,及更新失败。这时候我们只需要告诉用户购买失败,重新查询一遍即可。对比第一种和第二种测试,我们会发现第一种测试,将update语句放入事务中会出现阻塞的情况,而第二种测试不会出现阻塞情况。这是为什么呢?update其实在不在事务中都无所谓,在内部是这样的:update是单线程的,及如果一个线程对一条数据进行update操作,会获得锁,其他线程如果要对同一条数据操作会阻塞,直到这个线程update成功后释放锁。乐观锁不需要数据库底层的支持!3. 适用场景悲观锁比较适合写入操作比较频繁的场景,如果出现大量的读取操作,每次读取的时候都会进行加锁,这样会增加大量的锁的开销,降低了系统的吞吐量。乐观锁比较适合读取操作比较频繁的场景,如果出现大量的写入操作,数据发生冲突的可能性就会增大,为了保证数据的一致性,应用层需要不断的重新获取数据,这样会增加大量的查询操作,降低了系统的吞吐量。文末本章节主要简单介绍了数据库中乐观锁与悲观锁的相关知识,后续我们将会继续介绍数据库中的其他锁以及相关知识。例如行锁、表锁、死锁、欢迎关注公众号:Coder编程获取最新原创技术文章和相关免费学习资料,随时随地学习技术知识!参考文章:https://chenzhou123520.iteye….https://chenzhou123520.iteye….推荐阅读带你了解数据库中JOIN的用法 带你了解数据库中事务的ACID特性 5分钟带你读懂事务隔离性与隔离级别 ...

April 16, 2019 · 1 min · jiezi

余额,危险的操作,给996留点福报

真的很危险,有人因此进了局子;也有公司因此损失上亿。想象一下你在一个月黑风高的夜晚,大概是10点多钟的样子,加班归来,打算到小卖部弄盒烟抽。夜凉风急,你用力裹了下被风鼓起的外套。那里有你暗恋的收银姑娘。没日没夜的工作,只有这十几分钟,能让你感到些许生活的意义。从羞涩的钱包里翻出仅存的一张百元大钞,结账。然后用颤抖的双手接过收银员的找零。不是因为轻触到了她的指尖。也并非因她如花的笑靥。只因为,脑海里竟然不争气的浮现出这样的过程。balance = dao.getBalance(userid)balance = balance - costdao.setBalance(userid,balance)还真是狗改不了吃屎啊,果然还是一个码畜。提醒着自己,自卑的埋下了脸,快步走开。这是什么?这是我们送给996公司的一点福报。一波麻6的操作余额修改,是交易系统里最常见的操作。上面的伪代码,大意是先取出余额,然后扣掉消费,然后再回写余额。通常情况下这不会发生问题。除非是高并发,与你是否单机无关。对单一余额的高并发操作自然不是正常人发起的,系统正在承受攻击,或者自以为是的使用了MQ。在攻击面前,上面的操作显得不堪一击。拿一个最严重的例子说明:同时发起了一笔消费20元和消费5元的请求。在经过一波猛如虎的操作之后,两个请求都支付成功了,但只扣除了5元。相当于花了5块钱,买了25的东西。划重点:把以上操作扩展到提现操作上,就更加的恐怖。比如你发起了一笔100元的提现和0.01元的提现,结果余额被扣减0.01元的提现给覆盖了。这相当于你有了一个提款机,非要薅到平台倒闭为止。防护办法通过SQL解决update user set balance = balance - 20 where userid=id这条语句就保险了很多,如果考虑到余额不能为负的情况,可以把sql更加精进一点。update user set balance = balance - 20 where userid=id and balance >= 20以上sql,就可以保证余额的安全,高并发下的攻击就变得意义不大了。但会有别的问题,比如重复扣款。通过锁解决现实中,这种直接通过sql扣减的应用,规模都比较小。当你的业务逐渐复杂,又没有进行很好的拆分的情况下,先读再设值的情况还是比较普遍的。比如某些营销操作、打折、积分兑换等。这种情况,可以引入分布式锁。简单点的,只需要使用redis的setnx或者zk来控制就可以;复杂点的方案,可以使用二阶段提交之类的。分布式事务的业务粒度,要足够粗,才能保护这些余额操作;加锁的粒度,要足够细,才能保证系统的效率。begin transition(userid) balance = dao.getBalance(userid) balance = balance - cost dao.setBalance(userid,balance)end类CAS方式解决java的朋友可以回想下concurrent包的解决方式。那就是引入了CAS,全称Compare And Set。扩展到分布式环境下,同样可以采用这一策略。即先比较再设值。如果初始值已经变化了,那么不允许set设值。cas一般通过循环重试的方法进行状态更新,但余额操作一般都是比较单一的,你也可以直接终止操作,并预警风险。sql类似于:update user set balance = balance - 20 where userid=id and balance >= 20 and balance = $old_balance当然,你也可以通过加入版本号概念,而不是余额字段来控制这个过程,但都类似。变种:版本号通过在表中加一个额外的字段version,来控制并发。这种方式不去关注余额,可扩展性更强。version的默认值一般是1,即记录创建时的默认值。操作的伪代码如下:version,balance = dao.getBalance(userid)balance = balance - costdao.exec(" update user set balance = balance - 20 version = version + 1 where userid=id and balance >= 20 and version = $old_version")上面的并发攻击,将会只有一个操作能够成功,我们的余额安全了。End赶紧看一下你的余额操作,是否也暴露在风险之下。你可以选择接受福报继续当兄弟,当然也可以将福报还给资本家。一念成佛,一念成魔。你才是自己的主人。 ...

April 16, 2019 · 1 min · jiezi

一个游戏拨账系统的数据库结算设计

假设现存在一个简单的猜大小游戏,由用户下注大或者小,扣除手续费3%后的钱全部放入奖池中,赢的一方按投注比例平分整个奖池。使用mysql作为数据库,系统精度精确到1位小数。 本文将会讲解其中会出现的业务结算导致的数据问题,以及解决方法。数据库逻辑设计系统内应该存在一个用户钱包表,其中指定两条记录为系统收入账户和系统拨出账户。这样可以将投注的时候,对系统账户余额增加操作,和发奖的时候,对系统账户余额的减去操作分离。可以避免上一期游戏的结算,对下一期游戏的投注发生锁等待的问题。业务加锁考虑到高并发的情况下,推荐使用mysql自带的排他锁,不推荐乐观锁,因为乐观锁需要重试机制,而队列结算暂时不考虑。 当一名用户发起投注的时候,检查顺序应该如下检查系统游戏开关(冗余) 查询一次用户余额是否大于这次下注金额开启事务对系统收入账户加排他锁对用户收入账户加排他锁检查用户余额是否足够对用户进行扣款对系统进行收款为奖池加入97%的投注额度事务提交这里之所以要冗余检查用户的额度,是否了避免开启事务的消耗,防止恶意攻击消耗系统资源,用来开启无意义事务。奖池额度的97%这里计算需要保持一位精度,如果用户投注是98,按照计算得到的值应该是95.06,我们应该取95.0而不是95.1,否则你最后存到奖池里面的数就会大于97%,这样系统抽取就不会达到3%,用户少分点没关系,要保证系统一定能分到3%。简单一句话就是:精度位后都舍弃发奖过程设计假设按照投注比例,瓜分出的奖金总数是22.1,A用户的份额是55.5%,A用户拿到12.2655,B用户的份额是45%,B用户拿到9.8345。这种情况下,你会发现,按照舍弃,原则,分别是12.2和9.8,结果是只发放了22,如果你按照四舍五入原则,才能发放到22.1那为什么还要坚持舍弃原则呢?因为,假设出一个极端情况,当你碰到A的值是12.05,B的值是9.05,按照舍弃原则,总数的确还是22.1。但是按照四舍五入原则,发放的总值就是22.2了。结语在计算机系统内,浮点数的计算本身就是不可靠的,在业务内应该用整形去避免,当设计到百分比操作的时候,请尽量使用舍弃原则,保证不多发。按照舍弃原则,给用户少发0.05这种精度外的值,对业务来说无关紧要。如果超发了,会导致系统内账目混乱,后果将不堪设想。

January 28, 2019 · 1 min · jiezi

Java 中15种锁的介绍:公平锁,可重入锁,独享锁,互斥锁,乐观锁,分段锁,自旋锁等等

Java 中15种锁的介绍在读很多并发文章中,会提及各种各样锁如公平锁,乐观锁等等,这篇文章介绍各种锁的分类。介绍的内容如下:公平锁 / 非公平锁可重入锁 / 不可重入锁独享锁 / 共享锁互斥锁 / 读写锁乐观锁 / 悲观锁分段锁偏向锁 / 轻量级锁 / 重量级锁自旋锁上面是很多锁的名词,这些分类并不是全是指锁的状态,有的指锁的特性,有的指锁的设计,下面总结的内容是对每个锁的名词进行一定的解释。公平锁 / 非公平锁公平锁公平锁是指多个线程按照申请锁的顺序来获取锁。非公平锁非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。有可能,会造成优先级反转或者饥饿现象。对于Java ReentrantLock而言,通过构造函数指定该锁是否是公平锁,默认是非公平锁。非公平锁的优点在于吞吐量比公平锁大。对于Synchronized而言,也是一种非公平锁。由于其并不像ReentrantLock是通过AQS的来实现线程调度,所以并没有任何办法使其变成公平锁。可重入锁 / 不可重入锁可重入锁广义上的可重入锁指的是可重复可递归调用的锁,在外层使用锁之后,在内层仍然可以使用,并且不发生死锁(前提得是同一个对象或者class),这样的锁就叫做可重入锁。ReentrantLock和synchronized都是可重入锁synchronized void setA() throws Exception{ Thread.sleep(1000); setB();}synchronized void setB() throws Exception{ Thread.sleep(1000);}上面的代码就是一个可重入锁的一个特点,如果不是可重入锁的话,setB可能不会被当前线程执行,可能造成死锁。不可重入锁不可重入锁,与可重入锁相反,不可递归调用,递归调用就发生死锁。看到一个经典的讲解,使用自旋锁来模拟一个不可重入锁,代码如下import java.util.concurrent.atomic.AtomicReference;public class UnreentrantLock { private AtomicReference<Thread> owner = new AtomicReference<Thread>(); public void lock() { Thread current = Thread.currentThread(); //这句是很经典的“自旋”语法,AtomicInteger中也有 for (;;) { if (!owner.compareAndSet(null, current)) { return; } } } public void unlock() { Thread current = Thread.currentThread(); owner.compareAndSet(current, null); }}代码也比较简单,使用原子引用来存放线程,同一线程两次调用lock()方法,如果不执行unlock()释放锁的话,第二次调用自旋的时候就会产生死锁,这个锁就不是可重入的,而实际上同一个线程不必每次都去释放锁再来获取锁,这样的调度切换是很耗资源的。把它变成一个可重入锁:import java.util.concurrent.atomic.AtomicReference;public class UnreentrantLock { private AtomicReference<Thread> owner = new AtomicReference<Thread>(); private int state = 0; public void lock() { Thread current = Thread.currentThread(); if (current == owner.get()) { state++; return; } //这句是很经典的“自旋”式语法,AtomicInteger中也有 for (;;) { if (!owner.compareAndSet(null, current)) { return; } } } public void unlock() { Thread current = Thread.currentThread(); if (current == owner.get()) { if (state != 0) { state–; } else { owner.compareAndSet(current, null); } } }}在执行每次操作之前,判断当前锁持有者是否是当前对象,采用state计数,不用每次去释放锁。ReentrantLock中可重入锁实现这里看非公平锁的锁获取方法:final boolean nonfairTryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); if (c == 0) { if (compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } //就是这里 else if (current == getExclusiveOwnerThread()) { int nextc = c + acquires; if (nextc < 0) // overflow throw new Error(“Maximum lock count exceeded”); setState(nextc); return true; } return false;}在AQS中维护了一个private volatile int state来计数重入次数,避免了频繁的持有释放操作,这样既提升了效率,又避免了死锁。独享锁 / 共享锁独享锁和共享锁在你去读C.U.T包下的ReeReentrantLock和ReentrantReadWriteLock你就会发现,它俩一个是独享一个是共享锁。独享锁:该锁每一次只能被一个线程所持有。共享锁:该锁可被多个线程共有,典型的就是ReentrantReadWriteLock里的读锁,它的读锁是可以被共享的,但是它的写锁确每次只能被独占。另外读锁的共享可保证并发读是非常高效的,但是读写和写写,写读都是互斥的。独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。对于Synchronized而言,当然是独享锁。互斥锁 / 读写锁互斥锁在访问共享资源之前对进行加锁操作,在访问完成之后进行解锁操作。 加锁后,任何其他试图再次加锁的线程会被阻塞,直到当前进程解锁。如果解锁时有一个以上的线程阻塞,那么所有该锁上的线程都被编程就绪状态, 第一个变为就绪状态的线程又执行加锁操作,那么其他的线程又会进入等待。 在这种方式下,只有一个线程能够访问被互斥锁保护的资源读写锁读写锁既是互斥锁,又是共享锁,read模式是共享,write是互斥(排它锁)的。读写锁有三种状态:读加锁状态、写加锁状态和不加锁状态读写锁在Java中的具体实现就是ReadWriteLock一次只有一个线程可以占有写模式的读写锁,但是多个线程可以同时占有读模式的读写锁。只有一个线程可以占有写状态的锁,但可以有多个线程同时占有读状态锁,这也是它可以实现高并发的原因。当其处于写状态锁下,任何想要尝试获得锁的线程都会被阻塞,直到写状态锁被释放;如果是处于读状态锁下,允许其它线程获得它的读状态锁,但是不允许获得它的写状态锁,直到所有线程的读状态锁被释放;为了避免想要尝试写操作的线程一直得不到写状态锁,当读写锁感知到有线程想要获得写状态锁时,便会阻塞其后所有想要获得读状态锁的线程。所以读写锁非常适合资源的读操作远多于写操作的情况。乐观锁 / 悲观锁悲观锁总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。乐观锁总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和CAS算法实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。在Java中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。分段锁分段锁其实是一种锁的设计,并不是具体的一种锁,对于ConcurrentHashMap而言,其并发的实现就是通过分段锁的形式来实现高效的并发操作。并发容器类的加锁机制是基于粒度更小的分段锁,分段锁也是提升多并发程序性能的重要手段之一。在并发程序中,串行操作是会降低可伸缩性,并且上下文切换也会减低性能。在锁上发生竞争时将通水导致这两种问题,使用独占锁时保护受限资源的时候,基本上是采用串行方式—-每次只能有一个线程能访问它。所以对于可伸缩性来说最大的威胁就是独占锁。我们一般有三种方式降低锁的竞争程度: 1、减少锁的持有时间 2、降低锁的请求频率 3、使用带有协调机制的独占锁,这些机制允许更高的并发性。在某些情况下我们可以将锁分解技术进一步扩展为一组独立对象上的锁进行分解,这成为分段锁。其实说的简单一点就是:容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效的提高并发访问效率,这就是ConcurrentHashMap所使用的锁分段技术,首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。比如:在ConcurrentHashMap中使用了一个包含16个锁的数组,每个锁保护所有散列桶的1/16,其中第N个散列桶由第(N mod 16)个锁来保护。假设使用合理的散列算法使关键字能够均匀的分部,那么这大约能使对锁的请求减少到越来的1/16。也正是这项技术使得ConcurrentHashMap支持多达16个并发的写入线程。偏向锁 / 轻量级锁 / 重量级锁锁的状态:无锁状态偏向锁状态轻量级锁状态重量级锁状态锁的状态是通过对象监视器在对象头中的字段来表明的。四种状态会随着竞争的情况逐渐升级,而且是不可逆的过程,即不可降级。这四种状态都不是Java语言中的锁,而是Jvm为了提高锁的获取与释放效率而做的优化(使用synchronized时)。偏向锁偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价。轻量级轻量级锁是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。重量级锁重量级锁是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低。自旋锁我们知道CAS算法是乐观锁的一种实现方式,CAS算法中又涉及到自旋锁,所以这里给大家讲一下什么是自旋锁。简单回顾一下CAS算法CAS是英文单词Compare and Swap(比较并交换),是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。CAS算法涉及到三个操作数需要读写的内存值 V进行比较的值 A拟写入的新值 B更新一个变量的时候,只有当变量的预期值A和内存地址V当中的实际值相同时,才会将内存地址V对应的值修改为B,否则不会执行任何操作。一般情况下是一个自旋操作,即不断的重试。什么是自旋锁?自旋锁(spinlock):是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。它是为实现保护共享资源而提出一种锁机制。其实,自旋锁与互斥锁比较类似,它们都是为了解决对某项资源的互斥使用。无论是互斥锁,还是自旋锁,在任何时刻,最多只能有一个保持者,也就说,在任何时刻最多只能有一个执行单元获得锁。但是两者在调度机制上略有不同。对于互斥锁,如果资源已经被占用,资源申请者只能进入睡眠状态。但是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁,”自旋”一词就是因此而得名。Java如何实现自旋锁?下面是个简单的例子:public class SpinLock { private AtomicReference<Thread> cas = new AtomicReference<Thread>(); public void lock() { Thread current = Thread.currentThread(); // 利用CAS while (!cas.compareAndSet(null, current)) { // DO nothing } } public void unlock() { Thread current = Thread.currentThread(); cas.compareAndSet(current, null); }}lock()方法利用的CAS,当第一个线程A获取锁的时候,能够成功获取到,不会进入while循环,如果此时线程A没有释放锁,另一个线程B又来获取锁,此时由于不满足CAS,所以就会进入while循环,不断判断是否满足CAS,直到A线程调用unlock方法释放了该锁。自旋锁存在的问题1、如果某个线程持有锁的时间过长,就会导致其它等待获取锁的线程进入循环等待,消耗CPU。使用不当会造成CPU使用率极高。2、上面Java实现的自旋锁不是公平的,即无法满足等待时间最长的线程优先获取锁。不公平的锁就会存在“线程饥饿”问题。自旋锁的优点1、自旋锁不会使线程状态发生切换,一直处于用户态,即线程一直都是active的;不会使线程进入阻塞状态,减少了不必要的上下文切换,执行速度快2、非自旋锁在获取不到锁的时候会进入阻塞状态,从而进入内核态,当获取到锁的时候需要从内核态恢复,需要线程上下文切换。 (线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能)可重入的自旋锁和不可重入的自旋锁文章开始的时候的那段代码,仔细分析一下就可以看出,它是不支持重入的,即当一个线程第一次已经获取到了该锁,在锁释放之前又一次重新获取该锁,第二次就不能成功获取到。由于不满足CAS,所以第二次获取会进入while循环等待,而如果是可重入锁,第二次也是应该能够成功获取到的。而且,即使第二次能够成功获取,那么当第一次释放锁的时候,第二次获取到的锁也会被释放,而这是不合理的。为了实现可重入锁,我们需要引入一个计数器,用来记录获取锁的线程数。public class ReentrantSpinLock { private AtomicReference<Thread> cas = new AtomicReference<Thread>(); private int count; public void lock() { Thread current = Thread.currentThread(); if (current == cas.get()) { // 如果当前线程已经获取到了锁,线程数增加一,然后返回 count++; return; } // 如果没获取到锁,则通过CAS自旋 while (!cas.compareAndSet(null, current)) { // DO nothing } } public void unlock() { Thread cur = Thread.currentThread(); if (cur == cas.get()) { if (count > 0) {// 如果大于0,表示当前线程多次获取了该锁,释放锁通过count减一来模拟 count–; } else {// 如果count==0,可以将锁释放,这样就能保证获取锁的次数与释放锁的次数是一致的了。 cas.compareAndSet(cur, null); } } }}自旋锁与互斥锁自旋锁与互斥锁都是为了实现保护资源共享的机制。无论是自旋锁还是互斥锁,在任意时刻,都最多只能有一个保持者。获取互斥锁的线程,如果锁已经被占用,则该线程将进入睡眠状态;获取自旋锁的线程则不会睡眠,而是一直循环等待锁释放。自旋锁总结自旋锁:线程获取锁的时候,如果锁被其他线程持有,则当前线程将循环等待,直到获取到锁。自旋锁等待期间,线程的状态不会改变,线程一直是用户态并且是活动的(active)。自旋锁如果持有锁的时间太长,则会导致其它等待获取锁的线程耗尽CPU。自旋锁本身无法保证公平性,同时也无法保证可重入性。基于自旋锁,可以实现具备公平性和可重入性质的锁。 ...

January 4, 2019 · 2 min · jiezi

Spring Boot+SQL/JPA实战悲观锁和乐观锁

最近在公司的业务上遇到了并发的问题,并且还是很常见的并发问题,算是低级的失误了。由于公司业务相对比较复杂且不适合公开,在此用一个很常见的业务来还原一下场景,同时介绍悲观锁和乐观锁是如何解决这类并发问题的。公司业务就是最常见的“订单+账户”问题,在解决完公司问题后,转头一想,我的博客项目Fame中也有同样的问题(虽然访问量根本完全不需要考虑并发问题…),那我就拿这个来举例好了。业务还原首先环境是:Spring Boot 2.1.0 + data-jpa + mysql + lombok数据库设计对于一个有评论功能的博客系统来说,通常会有两个表:1.文章表 2.评论表。其中文章表除了保存一些文章信息等,还有个字段保存评论数量。我们设计一个最精简的表结构来还原该业务场景。article 文章表字段类型备注idINT自增主键idtitleVARCHAR文章标题comment_countINT文章的评论数量comment 评论表字段类型备注idINT自增主键idarticle_idINT评论的文章idcontentVARCHAR评论内容当一个用户评论的时候,1. 根据文章id获取到文章 2. 插入一条评论记录 3. 该文章的评论数增加并保存代码实现首先在maven中引入对应的依赖<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.1.0.RELEASE</version> <relativePath/> <!– lookup parent from repository –></parent><dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency></dependencies>然后编写对应数据库的实体类@Data@Entitypublic class Article { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String title; private Long commentCount;}@Data@Entitypublic class Comment { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private Long articleId; private String content;}接着创建这两个实体类对应的Repository,由于spring-jpa-data的CrudRepository已经帮我们实现了最常见的CRUD操作,所以我们的Repository只需要继承CrudRepository接口其他啥都不用做。public interface ArticleRepository extends CrudRepository<Article, Long> {}public interface CommentRepository extends CrudRepository<Comment, Long> {}接着我们就简单的实现一下Controller接口和Service实现类。@Slf4j@RestControllerpublic class CommentController { @Autowired private CommentService commentService; @PostMapping(“comment”) public String comment(Long articleId, String content) { try { commentService.postComment(articleId, content); } catch (Exception e) { log.error("{}", e); return “error: " + e.getMessage(); } return “success”; }}@Slf4j@Servicepublic class CommentService { @Autowired private ArticleRepository articleRepository; @Autowired private CommentRepository commentRepository; public void postComment(Long articleId, String content) { Optional<Article> articleOptional = articleRepository.findById(articleId); if (!articleOptional.isPresent()) { throw new RuntimeException(“没有对应的文章”); } Article article = articleOptional.get(); Comment comment = new Comment(); comment.setArticleId(articleId); comment.setContent(content); commentRepository.save(comment); article.setCommentCount(article.getCommentCount() + 1); articleRepository.save(article); }}并发问题分析从刚才的代码实现里可以看出这个简单的评论功能的流程,当用户发起评论的请求时,从数据库找出对应的文章的实体类Article,然后根据文章信息生成对应的评论实体类Comment,并且插入到数据库中,接着增加该文章的评论数量,再把修改后的文章更新到数据库中,整个流程如下流程图。在这个流程中有个问题,当有多个用户同时并发评论时,他们同时进入步骤1中拿到Article,然后插入对应的Comment,最后在步骤3中更新评论数量保存到数据库。只是由于他们是同时在步骤1拿到的Article,所以他们的Article.commentCount的值相同,那么在步骤3中保存的Article.commentCount+1也相同,那么原来应该+3的评论数量,只加了1。我们用测试用例代码试一下@RunWith(SpringRunner.class)@SpringBootTest(classes = LockAndTransactionApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)public class CommentControllerTests { @Autowired private TestRestTemplate testRestTemplate; @Test public void concurrentComment() { String url = “http://localhost:9090/comment”; for (int i = 0; i < 100; i++) { int finalI = i; new Thread(() -> { MultiValueMap<String, String> params = new LinkedMultiValueMap<>(); params.add(“articleId”, “1”); params.add(“content”, “测试内容” + finalI); String result = testRestTemplate.postForObject(url, params, String.class); }).start(); } }}这里我们开了100个线程,同时发送评论请求,对应的文章id为1。在发送请求前,数据库数据为select * from articleselect count() comment_count from comment发送请求后,数据库数据为select * from articleselect count() comment_count from comment明显的看到在article表里的comment_count的值不是100,这个值不一定是我图里的14,但是必然是不大于100的,而comment表的数量肯定等于100。这就展示了在文章开头里提到的并发问题,这种问题其实十分的常见,只要有类似上面这样评论功能的流程的系统,都要小心避免出现这种问题。下面就用实例展示展示如何通过悲观锁和乐观锁防止出现并发数据问题,同时给出SQL方案和JPA自带方案,SQL方案可以通用“任何系统”,甚至不限语言,而JPA方案十分快捷,如果你恰好用的也是JPA,那就可以简单的使用上乐观锁或悲观锁。最后也会根据业务比较一下乐观锁和悲观锁的一些区别悲观锁解决并发问题悲观锁顾名思义就是悲观的认为自己操作的数据都会被其他线程操作,所以就必须自己独占这个数据,可以理解为”独占锁“。在java中synchronized和ReentrantLock等锁就是悲观锁,数据库中表锁、行锁、读写锁等也是悲观锁。利用SQL解决并发问题行锁就是操作数据的时候把这一行数据锁住,其他线程想要读写必须等待,但同一个表的其他数据还是能被其他线程操作的。只要在需要查询的sql后面加上for update,就能锁住查询的行,特别要注意查询条件必须要是索引列,如果不是索引就会变成表锁,把整个表都锁住。现在在原有的代码的基础上修改一下,先在ArticleRepository增加一个手动写sql查询方法。public interface ArticleRepository extends CrudRepository<Article, Long> { @Query(value = “select * from article a where a.id = :id for update”, nativeQuery = true) Optional<Article> findArticleForUpdate(Long id);}然后把CommentService中使用的查询方法由原来的findById改为我们自定义的方法public class CommentService { … public void postComment(Long articleId, String content) { // Optional<Article> articleOptional = articleRepository.findById(articleId); Optional<Article> articleOptional = articleRepository.findArticleForUpdate(articleId); … }}这样我们查出来的Article,在我们没有将其提交事务之前,其他线程是不能获取修改的,保证了同时只有一个线程能操作对应数据。现在再用测试用例测一下,article.comment_count的值必定是100。利用JPA自带行锁解决并发问题对于刚才提到的在sql后面增加for update,JPA有提供一个更优雅的方式,就是@Lock注解,这个注解的参数可以传入想要的锁级别。现在在ArticleRepository中增加JPA的锁方法,其中LockModeType.PESSIMISTIC_WRITE参数就是行锁。public interface ArticleRepository extends CrudRepository<Article, Long> { … @Lock(value = LockModeType.PESSIMISTIC_WRITE) @Query(“select a from Article a where a.id = :id”) Optional<Article> findArticleWithPessimisticLock(Long id);}同样的只要在CommentService里把查询方法改为findArticleWithPessimisticLock(),再测试用例测一下,肯定不会有并发问题。而且这时看一下控制台打印信息,发现实际上查询的sql还是加了for update,只不过是JPA帮我们加了而已。乐观锁解决并发问题乐观锁顾名思义就是特别乐观,认为自己拿到的资源不会被其他线程操作所以不上锁,只是在插入数据库的时候再判断一下数据有没有被修改。所以悲观锁是限制其他线程,而乐观锁是限制自己,虽然他的名字有锁,但是实际上不算上锁,只是在最后操作的时候再判断具体怎么操作。乐观锁通常为版本号机制或者CAS算法利用SQL实现版本号解决并发问题版本号机制就是在数据库中加一个字段当作版本号,比如我们加个字段version。那么这时候拿到Article的时候就会带一个版本号,比如拿到的版本是1,然后你对这个Article一通操作,操作完之后要插入到数据库了。发现哎呀,怎么数据库里的Article版本是2,和我手里的版本不一样啊,说明我手里的Article不是最新的了,那么就不能放到数据库了。这样就避免了并发时数据冲突的问题。所以我们现在给article表加一个字段versionarticle 文章表字段类型备注versionINT DEFAULT 0版本号然后对应的实体类也增加version字段@Data@Entitypublic class Article { … private Long version;}接着在ArticleRepository增加更新的方法,注意这里是更新方法,和悲观锁时增加查询方法不同。public interface ArticleRepository extends CrudRepository<Article, Long> { @Modifying @Query(value = “update article set comment_count = :commentCount, version = version + 1 where id = :id and version = :version”, nativeQuery = true) int updateArticleWithVersion(Long id, Long commentCount, Long version);}可以看到update的where有一个判断version的条件,并且会set version = version + 1。这就保证了只有当数据库里的版本号和要更新的实体类的版本号相同的时候才会更新数据。接着在CommentService里稍微修改一下代码。// CommentServicepublic void postComment(Long articleId, String content) { Optional<Article> articleOptional = articleRepository.findById(articleId); … int count = articleRepository.updateArticleWithVersion(article.getId(), article.getCommentCount() + 1, article.getVersion()); if (count == 0) { throw new RuntimeException(“服务器繁忙,更新数据失败”); } // articleRepository.save(article);}首先对于Article的查询方法只需要普通的findById()方法就行不用上任何锁。然后更新Article的时候改用新加的updateArticleWithVersion()方法。可以看到这个方法有个返回值,这个返回值代表更新了的数据库行数,如果值为0的时候表示没有符合条件可以更新的行。这之后就可以由我们自己决定怎么处理了,这里是直接回滚,spring就会帮我们回滚之前的数据操作,把这次的所有操作都取消以保证数据的一致性。现在再用测试用例测一下select * from articleselect count() comment_count from comment现在看到Article里的comment_count和Comment的数量都不是100了,但是这两个的值必定是一样的了。因为刚才我们处理的时候假如Article表的数据发生了冲突,那么就不会更新到数据库里,这时抛出异常使其事务回滚,这样就能保证没有更新Article的时候Comment也不会插入,就解决了数据不统一的问题。这种直接回滚的处理方式用户体验比较差,通常来说如果判断Article更新条数为0时,会尝试重新从数据库里查询信息并重新修改,再次尝试更新数据,如果不行就再查询,直到能够更新为止。当然也不会是无线的循环这样的操作,会设置一个上线,比如循环3次查询修改更新都不行,这时候才会抛出异常。利用JPA实现版本现解决并发问题JPA对悲观锁有实现方式,乐观锁自然也是有的,现在就用JPA自带的方法实现乐观锁。首先在Article实体类的version字段上加上@Version注解,我们进注解看一下源码的注释,可以看到有部分写到:The following types are supported for version properties: int, Integer, short, Short, long, Long, java.sql.Timestamp.注释里面说版本号的类型支持int, short, long三种基本数据类型和他们的包装类以及Timestamp,我们现在用的是Long类型。@Data@Entitypublic class Article { … @Version private Long version;}接着只需要在CommentService里的评论流程修改回我们最开头的“会触发并发问题”的业务代码就行了。说明JPA的这种乐观锁实现方式是非侵入式的。// CommentServicepublic void postComment(Long articleId, String content) { Optional<Article> articleOptional = articleRepository.findById(articleId); … article.setCommentCount(article.getCommentCount() + 1); articleRepository.save(article);}和前面同样的,用测试用例测试一下能否防止并发问题的出现。select * from articleselect count() comment_count from comment同样的Article里的comment_count和Comment的数量也不是100,但是这两个数值肯定是一样的。看一下IDEA的控制台会发现系统抛出了ObjectOptimisticLockingFailureException的异常。这和刚才我们自己实现乐观锁类似,如果没有成功更新数据则抛出异常回滚保证数据的一致性。如果想要实现重试流程可以捕获ObjectOptimisticLockingFailureException这个异常,通常会利用AOP+自定义注解来实现一个全局通用的重试机制,这里就是要根据具体的业务情况来拓展了,想要了解的可以自行搜索一下方案。悲观锁和乐观锁比较悲观锁适合写多读少的场景。因为在使用的时候该线程会独占这个资源,在本文的例子来说就是某个id的文章,如果有大量的评论操作的时候,就适合用悲观锁,否则用户只是浏览文章而没什么评论的话,用悲观锁就会经常加锁,增加了加锁解锁的资源消耗。乐观锁适合写少读多的场景。由于乐观锁在发生冲突的时候会回滚或者重试,如果写的请求量很大的话,就经常发生冲突,经常的回滚和重试,这样对系统资源消耗也是非常大。所以悲观锁和乐观锁没有绝对的好坏,必须结合具体的业务情况来决定使用哪一种方式。另外在阿里巴巴开发手册里也有提到:如果每次访问冲突概率小于 20%,推荐使用乐观锁,否则使用悲观锁。乐观锁的重试次数不得小于 3 次。阿里巴巴建议以冲突概率20%这个数值作为分界线来决定使用乐观锁和悲观锁,虽然说这个数值不是绝对的,但是作为阿里巴巴各个大佬总结出来的也是一个很好的参考。 ...

December 18, 2018 · 3 min · jiezi

乐观锁、悲观锁,这一篇就够了!

乐观锁乐观锁顾名思义就是在操作时很乐观,认为操作不会产生并发问题(不会有其他线程对数据进行修改),因此不会上锁。但是在更新时会判断其他线程在这之前有没有对数据进行修改,一般会使用版本号机制或CAS(compare and swap)算法实现。简单理解:这里的数据,别想太多,你尽管用,出问题了算我怂,即操作失败后事务回滚、提示。1.1 版本号机制1.1.1 实现套路:取出记录时,获取当前version更新时,带上这个version执行更新时, set version = newVersion where version = oldVersion如果version不对,就更新失败核心SQL:update table set name = ‘Aron’, version = version + 1 where id = #{id} and version = #{version}; 1.1.2 实例-Mybatis-plus 乐观锁实现原文查看请点击 Mybatis-plus 乐观锁实现1.2 CAS算法乐观锁的另一种技术技术,当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。CAS 操作中包含三个操作数 :需要读写的内存位置V进行比较的预期原值A拟写入的新值B如果内存位置V的值与预期原值A相匹配,那么处理器会自动将该位置值更新为新值B。否则处理器不做任何操作。无论哪种情况,它都会在 CAS 指令之前返回该位置的值(在 CAS 的一些特殊情况下将仅返回 CAS 是否成功,而不提取当前值)。CAS 有效地说明了“ 我认为位置 V 应该包含值 A;如果包含该值,则将 B 放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可。 ”这其实和乐观锁的冲突检查+数据更新的原理是一样的。1.2.1 实例-concurrent包的实现由于java的CAS同时具有 volatile 读和volatile写的内存语义,因此Java线程之间的通信现在有了下面四种方式:A线程写volatile变量,随后B线程读这个volatile变量。A线程写volatile变量,随后B线程用CAS更新这个volatile变量。A线程用CAS更新一个volatile变量,随后B线程用CAS更新这个volatile变量。A线程用CAS更新一个volatile变量,随后B线程读这个volatile变量。Java的CAS会使用现代处理器上提供的高效机器级别原子指令,这些原子指令以原子方式对内存执行读-改-写操作,这是在多处理器中实现同步的关键(从本质上来说,能够支持原子性读-改-写指令的计算机器,是顺序计算图灵机的异步等价机器,因此任何现代的多处理器都会去支持某种能对内存执行原子性读-改-写操作的原子指令)。同时,volatile变量的读/写和CAS可以实现线程之间的通信。把这些特性整合在一起,就形成了整个concurrent包得以实现的基石。仔细分析concurrent包的源代码实现,会发现一个通用化的实现模式:首先,声明共享变量为volatile; 然后,使用CAS的原子条件更新来实现线程之间的同步;同时,配合以volatile的读/写和CAS所具有的volatile读和写的内存语义来实现线程1.2.2 缺点ABA问题比如说一个线程T1从内存位置V中取出A,这时候另一个线程T2也从内存中取出A,并且T2进行了一些操作变成了B,然后T2又将V位置的数据变成A,这时候线程T1进行CAS操作发现内存中仍然是A,然后T1操作成功。尽管线程T1的CAS操作成功,但可能存在潜藏的问题。循环时间长开销大自旋CAS(不成功,就一直循环执行,直到成功)如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。只能保证一个共享变量的原子操作当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁,或者有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如有两个共享变量i = 2,j = a,合并一下ij = 2a,然后用CAS来操作ij。从Java 1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作。2. 悲观锁总是假设最坏的情况,每次取数据时都认为其他线程会修改,所以都会加(悲观)锁。一旦加锁,不同线程同时执行时,只能有一个线程执行,其他的线程在入口处等待,直到锁被释放。悲观锁在MySQL、Java有广泛的使用MySQL的读锁、写锁、行锁等Java的synchronized关键字3. 总结读的多,冲突几率小,乐观锁。写的多,冲突几率大,悲观锁。高并发,悲观锁。觉得有用记得收藏、点赞哦!

October 9, 2018 · 1 min · jiezi