高并发与锁
高并发系统往往会存在数据不一致的问题。例如某购物网站发布的秒杀商品,在同一时间点,可能存在几万甚至上百万的用户访问,这就是一个典型的高并发场景。
在高并发场景,多个线程同时享有并访问数据。由于线程每一步的完成顺序不一样,会存在数据不一致的问题。
当前互联网主要通过悲观锁和乐观锁来解决高并发场景下的数据不一致问题。
1 悲观锁
悲观锁是一种利用数据库内部机制提供的锁的方法,也就是对更新的数据加锁。这样在并发期间一旦有一个事务持有了数据库记录的锁,其他的线程将不能再对数据进行更新。从字面上来看,悲观锁持一种悲观的态度,认为在访问数据时一定会与其他线程发生冲突。因此悲观锁的机制,就是在每次访问数据时都一定会拿到锁并对数据进行上锁,这样一来,当别的线程也要访问该数据时就会阻塞直到它拿到锁。因为在同一时间,只有一个线程可以独占锁,所以悲观锁有时候也称独占锁或阻塞锁。
例如,在商品秒杀的高并发环境下,商品库存是线程的共享数据,用悲观锁解决数据一致性问题的实践代码如下:
select id, product_id, product_name,stock from tb_product where id=#{id} for update
在SQL中加入for update语句,意味着对该条数据记录的行上锁,有了这个更新锁,才能继续进行修改商品库存等操作。
update tb_product set stock = #{new_value} where id = #{id}
如上图所示,在悲观锁机制之下,大量线程会阻塞,等待持有锁的线程释放锁,一旦锁被释放,所有阻塞的线程就会开始抢夺锁,抢到锁的线程会恢复,其他线程则继续阻塞,周而复始直到所有线程执行完毕。因此悲观锁会造成大量的线程阻塞和恢复,从而导出至CPU频繁切换线程上下文,造成性能低下。
为了克服悲观锁带来的资源消耗,提高系统的并发能力,提出了乐观锁机制。乐观锁在企业中被应用的更为广泛。
2 乐观锁
与悲观锁不同的是,乐观锁(也称非阻塞锁)是一种不会阻塞其他线程并发的机制,它不是通过数据库的锁实现的,因而不会引起线程的频繁阻塞和恢复。乐观锁持乐观态度,认为在访问数据时不会与其他线程发生冲突,只是在更新数据时检查数据。
2.1 CAS 原理
乐观锁使用的是CAS原理。在CAS原理中,对于多个线程共同访问的数据,维护一个旧值(Old Value),当更新问数据时,先比较当前获取的值与旧值是否一致。如果一致,则更新,若不一致,则认为数据已经被其他线程修改,放弃更新或者重试。CAS原理的流程图如下所示:
2.2 ABA问题
CAS原理解决了悲观锁带来的线程频繁切换状态的问题,但是CAS同样存在一个问题,ABA问题。
时刻 | 线程1 | 线程2 | 备注 |
---|---|---|---|
T0 | -- | -- | 初始化X=A |
T1 | 读入X=A | -- | -- |
T2 | -- | 读入X=A | -- |
T3 | 处理线程1的业务逻辑 | X=B | 修改共享变量为B |
T4 | 处理线程2业务逻辑第一段 | 此时线程1在X=B的情况下运行逻辑 | |
T5 | X=A | 还原变量为A | |
T6 | 因为判断X=A,所以执行更新操作 | 处理线程2业务逻辑第二段 | 此时线程1无法知道线程2是否修改过X,引发逻辑错误 |
T7 | -- | 更新数据 | -- |
如图所示,由于X在线程2中的值改变的过程为A->B->A,因此称这类问题为ABA问题。导致ABA问题发生的原因是业务逻辑存在回退的可能性(如图中线程2将X的值从A变为B,后又变为A),解决ABA问题的方法是引入版本号(version),每当修改数据时,版本号递增,不会回退。用版本号消除ABA问题如下图所示。
时刻 | 线程1 | 线程2 | 备注 |
---|---|---|---|
T0 | -- | -- | 初始化X=A,version=0 |
T1 | 读入X=A | -- | 线程1旧值:version=0 |
T2 | -- | 读入X=A | 线程2旧值:version=0 |
T3 | 处理线程1的业务逻辑 | X=B | 修改共享变量为B ,version=1 |
T4 | 处理线程2业务逻辑第一段 | -- | |
T5 | X=A | 还原变量为A,version=2 | |
T6 | 判断version==0,由于线程2两次更新数据,导致数据version=2,所以不执行更新操作 | 处理线程2业务逻辑第二段 | 此时线程1知道旧值version和当前version不一致,将不执行更新操作 |
T7 | -- | 更新数据 | -- |
使用version的乐观锁实践代码:
update tb_product set stock=stock-1,version=version+1 where id=#{id} and version=#{version}
在修改库存时,增加了对版本号的判断version=#{version}。并且,在每次扣减库存时,版本号增加version=version+1,保证了版本号记录了库存的更新记录,从而避免ABA问题。
2.3 重入机制
如上所示,当使用版本号来实现乐观锁时,当版本号与先前获得的不一致时,线程会放弃修改数据并重新尝试访问数据即重入。过多的重入会造成大量的SQL执行,因此目流行的重入会加入两种限制:
(1)按照时间戳的重入:在一定时间戳内,不成功则一直重入到成功为止直至超过时间戳;
(2)按照次数重入:即重入的次数又上限,当达到上限次数还未成功时,则不再重入,请求失败。
3 总结
上述介绍了悲观锁的数据上锁机制,乐观锁的旧值->旧值+版本号->旧值+版本号+重入机制三种递进的方式,总的来说,悲观锁通过对共享数据的上锁来保持数据的一致性,而乐观锁则解决了悲观锁的线程阻塞问题。
乐观锁适合用于写操作比较少的场景(多读场景),悲观锁适用于并发量不大且不允许脏读(脏读指的是读取的数据是错误的,无效的)的场景(多写场景)。概括来说,若对系统的数据一致性要求高,则使用悲观锁;若对系统的并发性能要求高,则使用乐观锁。
参考资料:《Java EE 互联网轻量级框架整合开发》