一、背景:
之前在测试工作过程中发现一次并发请求静默登陆接口,出现了插入多条数据到数据库的情况,接着review代码揭开谜底,原来是开发忘记加锁了,之前对于悲观锁、乐观锁、分布式锁总是百度一堆知识点,但是过段时间就忘记,今天通过不同的角度来强化对锁的印象以及怎么使用锁,方便之后在不同场景测试中能早点发现代码问题。
二、锁的基础知识以及分类
什么是锁?
在介绍悲观锁和乐观锁之前,让我们来了解一下锁。锁,在我们生活中随处可见,我们的门上有锁,我们存钱的保险柜上有锁,是用来保护我们财产安全的。程序中也有锁,数据库层面:当多个线程修改共享数据时,我们可以给修改操作上锁(syncronized)。当多个用户修改表中同一数据时,我们可以给该行数据上锁(行锁)。多线程层面:如何确保一个方法,或者一块代码在高并发情况下,同一时间只能被一个线程执行,单体应用可以使用并发处理相关的 API 进行控制,但单体应用架构演变为分布式微服务架构后,跨进程的实例部署,显然就没办法通过应用层锁的机制来控制并发了,就用到了给线程上锁。
什么是乐观锁?
乐观锁好比表示一种积极乐观的态度,认为数据变化不会太频繁,每次去获取共享的数据的时候认为不会被修改,所以不会上锁,但是在更新的时候你会判断这期间有没有人去更新这个数据。通常会在数据表中加上版本号或者时间戳记录更新记录。使用乐观锁的代码通常如下,使用在前,校验在后:
什么是悲观锁?
悲观锁跟乐观锁刚好反过来,它认为数据是不可靠的,可能会被随意改动,所以每次去处理数据的时候会先上锁再处理,处理完再把锁释放。
使用悲观锁的代码通常如下,加锁在前,使用在后:
什么是分布式锁?
乐观锁以及悲观锁可以解决同一个JVM中不同线程同步执行,但是在分布式的集群环境中就需要分布式锁保证不同节点的线程同步,分布式锁的实现方式比较多,有Memcached分布式锁、Redis分布式锁、Zookeeper分布式锁、Chubby,本篇主要重点讲具有代表性的Redis分布式锁的运用。
三、具体案例分析
了解了锁的基础知识以及简单的使用方法后,对锁已经有了大概的了解就差实践了,毕竟
纸上得来终觉浅,通过案例实践来练练手吧。
在电商系统中最常见的就是库存的扣减操作,为了在高并发情况下避免扣减导致的超卖情况,通常就使用到了锁。比如我的库存数据只有100个。并发情况下第1笔请求卖出100个,第2批卖出100元,导致当前的库存数量为负数。遇到这种场景应该如何破解呢?用刚学习的锁出两种方案,如下:
1.数据库行锁(悲观锁)
利用悲观锁的解决思路是,第一笔请求A卖出前先给货品的库存这行数据加上悲观锁(行锁)。此时这行数据只能请求A来操作。请求B想买就必须一直等待。当A买好后,B再想去买的时候会发现数量已经为0,那么B看到后就会放弃购买。虽然可以达到目的但是存在很多缺点:
-
其中一个缺点就是性能问题,在数据库层面会一直阻塞,直到事务提交,这里也是串行执行;
-
第二个需要注意设置事务的隔离级别是Read Committed,否则并发情况下,另外的事务无法看到提交的数据,依然会导致超卖问题;
-
缺点三是容易打满数据库连接,如果事务中有第三方接口交互(存在超时的可能性),会导致这个事务的连接一直阻塞,打满数据库连接。
-
最后一个缺点,容易产生交叉死锁,如果多个业务的加锁控制不好,就会发生AB两条记录的交叉死锁。
2.数据库乐观锁
上面乐观锁的介绍中,我们提到了,乐观锁是通过版本号version来实现的。 所以,我们需要给库存表加上version字段,A和B同时将商品库存的数据查出来,然后A先买,A将库存唯一id和version=0作为条件进行数据更新,即将数量减一,并且将版本号加一。此时版本号变为1。A此时就完成了商品的购买。最后B开始买,B也将库存唯一id和version=0作为条件进行数据更新,但是更新完后,发现更新的数据行数为0,此时就说明已经有人改动过数据,此时就应该提示用户重新查看最新数据购买。
A的操作:
B的操作:
伪代码如下:
3.分布式锁(redis)
主要分为三点:1.加锁:最简单的方法是setnx命令,伪代码setnx(key,1),当一个线程执行setnx返回1,说明key原本不存在,该线程成功得到了锁;当一个线程执行setnx返回0,说明key已经存在,该线程抢锁失败。2.解锁:伪代码del(key),当执行释放锁以后其他线程就可以继续执行setnx命令来获得锁。在解锁的过程中,为了避免上一个线程A未在设置的超时时间内执行完毕就把锁给下一个线程B,导致上个线程A执行完毕后解锁过程中误删除了还在执行过程中的线程B的操作,可以在del释放锁之前做一个唯一线程ID判断,验证当前的锁是不是自己加的锁,伪代码如下:
3.锁超时:setnx指令本身是不支持传入超时时间的,一般使用expire(key, 超时时间),但是在分布式集群中要是其中一个节点执行了加锁操作还没来得及执行超时时间的命令就挂了,所以在Redis 2.6.12以上版本为set指令增加了可选参数,伪代码如下:set(key,1,30,NX),这样就可以取代setnx指令。
结语:
三种锁类型的使用场景分析中可以知道因为悲观锁会影响系统吞吐的性能,所以适合应用在写为居多的场景下,因为乐观锁就是为了避免悲观锁的弊端出现的,所以适合应用在读为居多的场景下,因为分布式锁的加锁和过期设置的原子性,所以适用于分布式环境。