参考文章:《什么是乐观锁,什么是悲观锁》

当程序中可能出现并发的情况时,就需要保证在并发情况下数据的准确性,以此确保当前用户和其他用户一起操作时,所得到的结果和他单独操作时的结果是一样的,这就叫做 并发控制。没有做好并发控制,就可能导致脏读、幻读和不可重复读等问题。
实现 并发控制 的主要手段分为 乐观并发控制悲观并发控制

1 悲观锁

当要对数据库中的一条数据进行修改的时候,为了避免同时被其他人修改,最好的办法就是直接对该数据进行加锁以防止并发。这种借助数据库锁机制,在修改数据之前先锁定,再修改的方式被称之为 悲观并发控制 Pessimistic Concurrency Control,缩写PCC,又名 悲观锁
悲观锁,具有强烈的独占和排他特性。它指的是对数据被外界(包括本系统当前的其他事务,以及来自外部系统的事务处理)修改持保守态度。因此,在整个数据处理过程中,将数据处于锁定状态。悲观锁的实现,往往依靠数据库提供的锁机制,也只有数据库层提供的锁机制才能真正保证数据访问的排他性,否则,即使在本系统中实现了加锁机制,也无法保证外部系统不会修改数据。
之所以叫做悲观锁,是因为这是一种对数据的修改持有悲观态度的并发控制方式。总是假设最坏的情况,每次读取数据的时候都默认其他线程会更改数据,因此需要进行加锁操作,当其他线程想要访问数据时,都需要阻塞挂起。
先取锁再访问” 的保守策略,为数据处理的安全提供了保证。但是在效率方面,处理加锁的机制会让数据库产生额外的开销,还有增加产生死锁的机会。另外还会降低并行性,一个事务如果锁定了某行数据,其他事务就必须等待该事务处理完才可以处理那行数据。

悲观锁的实现:

  • 传统的关系型数据库使用这种锁机制,比如行锁、表锁、读锁、写锁等,都是在操作之前先上锁。
  • Java 里面的同步 synchronized 关键字的实现。

悲观锁主要分为 共享锁悲观锁

  • 共享锁 shared locks 又称为读锁,简称 S 锁。顾名思义,共享锁就是多个事务对于同一数据可以共享一把锁,都能访问到数据,但是只能读不能修改。
  • 排他锁 exclusive locks 又称为写锁,简称 X 锁。顾名思义,排他锁就是不能与其他锁并存,如果一个事务获取了一个数据行的排他锁,其他事务就不能再获取该行的其他锁,包括共享锁和排他锁。获取排他锁的事务可以对数据行读取和修改。

1.1 悲观锁的实现

悲观锁的实现,往往依靠数据库提供的锁机制,Java 的同步 synchronized 关键字也是一种悲观锁的实现。在数据库中,悲观锁的流程如下:

  • 在对记录进行修改前,先尝试为该记录加上排他锁 exclusive locks
  • 如果加锁失败,说明该记录正在被修改,那么当前查询可能要等待或者抛出异常。具体响应方式由开发者根据实际需要决定。
  • 如果成功加锁,那么就可以对记录做修改,事务完成后就会解锁了。
  • 期间如果有其他对该记录做修改或加排他锁的操作,都会等待解锁或直接抛出异常。

以 MySql 举例,在客户端中要使用悲观锁,必须关闭 MySQL 数据库的自动提交属性 set autocommit=0。因为 MySQL 默认使用 autocommit模式,也就是说,当执行一个更新操作后,MySQL 会立刻将结果进行提交。在 java 代码中使用,则需要添加事务关键词。
以下是模拟扣减库存的操作:

// 关闭自动提交set autocommit=0;// 查询库存信息,通过 for update 的方式进行加锁select quantity from items where id = 1 for update;// 更新update items set quantity = 2 where id = 1;// 提交事务commit;

在对 id = 1 的记录修改前,先通过 for update 的方式进行加锁,然后再进行修改。这就是比较典型的悲观锁策略。
如果发生并发,同一时间只有一个线程可以开启事务并获得 id=1 的锁,其它的事务必须等本次事务提交之后才能执行。这样可以保证当前的数据不会被其它事务修改。
使用 select…for update 锁数据,需要注意锁的级别,MySQL 默认行级锁。行级锁都是基于索引的,如果一条 SQL 语句用不到索引是不会使用行级锁的,会使用表级锁把整张表锁住,这点需要注意

1.1.1 synchronized

《Java基础笔记(JUnit测试,正则表达式,多线程)》

java 的 synchronized 修饰词上锁,同一时间只有一个线程能加锁,其他线程需要等待锁,但是引入 synchronized 会造成多个线程排队的问题,相当于让各个线程串行化了,一个接一个的排队、加锁、处理数据、释放锁,下一个再进来。同一时间只有一个线程执行,这样的锁有点 “重量级” 了。这类似于悲观锁的实现,需要获取这个资源,就给它加锁,别的线程都无法访问该资源,直到操作完后释放对该资源的锁。虽然随着 Java 版本更新,也对 synchronized 做了很多优化,但是处理这种简单的累加操作,仍然显得 “太重了”。

在比如实现扣减库存的业务逻辑的时候,如果结合事务使用 synchronized,还需注意 synchronized 修饰词上锁的范围。

@Transactional(rollbackFor = Exception.class)public synchronized Integer createOrder() throws Exception{Product product = null;product = productMapper.selectByPrimaryKey(purchaseProductId);if (product==null){throw new Exception("购买商品:"+purchaseProductId+"不存在");}//商品当前库存Integer currentCount = product.getCount();System.out.println(Thread.currentThread().getName()+"库存数:"+currentCount);//校验库存if (purchaseProductNum > currentCount){throw new Exception("商品"+purchaseProductId+"仅剩"+currentCount+"件,无法购买");}// 更新库存productMapper.updateProductCount(purchaseProductNum,"xxx",new Date(),product.getId());// 生成订单Order order = new Order();// ...orderMapper.insertSelective(order);return order.getId();}

比如以上代码用意就是使用 synchronized 将整个方法上锁,并发的时候,只能有一个线程执行操作,从而避免库存是负数的问题,但这里添加了事务就需要额外小心,因为 synchronized 锁的只是方法,并没有将事务也包含进来,这就有一种情况发生,比如说现在库存是 1,线程A执行了方法扣减了 1 库存,方法已经执行完了,所以锁也释放了其它线程可以执行扣减库存操作,但事务仍未提交,即数据库的数据还没更新,所以线程B读取库存数据时,可能会读取到库存是 1
改进方法就是手动控制事务:

// 事务管理器@Autowiredprivate PlatformTransactionManager platformTransactionManager;// 事务定义@Autowiredprivate TransactionDefinition transactionDefinition;@Transactional(rollbackFor = Exception.class)public Integer createOrder() throws Exception{// 获取事务TransactionStatus transaction1 = platformTransactionManager.getTransaction(transactionDefinition);Product product = null;product = productMapper.selectByPrimaryKey(purchaseProductId);if (product==null){// 事务回滚platformTransactionManager.rollback(transaction1);throw new Exception("购买商品:"+purchaseProductId+"不存在");}//商品当前库存Integer currentCount = product.getCount();System.out.println(Thread.currentThread().getName()+"库存数:"+currentCount);//校验库存if (purchaseProductNum > currentCount){// 事务回滚platformTransactionManager.rollback(transaction1);throw new Exception("商品"+purchaseProductId+"仅剩"+currentCount+"件,无法购买");}// 更新库存productMapper.updateProductCount(purchaseProductNum,"xxx",new Date(),product.getId());// 生成订单Order order = new Order();// ...orderMapper.insertSelective(order);// 提交事务platformTransactionManager.commit(transaction);return order.getId();}

1.1.2 ReentrantLock

ReentrantLock 的使用方法和使用 synchronized 代码块锁 的使用方法差不多,这里要注意尽量别要让事务嵌套,代码如下:

// 实例化 ReentrantLockprivate Lock lock = new ReentrantLock();public Integer createOrder() throws Exception{Product product = null;// 上锁lock.lock();try {// 创建事务TransactionStatus transaction1 = platformTransactionManager.getTransaction(transactionDefinition);product = productMapper.selectByPrimaryKey(purchaseProductId);if (product==null){platformTransactionManager.rollback(transaction1);throw new Exception("购买商品:"+purchaseProductId+"不存在");}//商品当前库存Integer currentCount = product.getCount();System.out.println(Thread.currentThread().getName()+"库存数:"+currentCount);//校验库存if (purchaseProductNum > currentCount){platformTransactionManager.rollback(transaction1);thrownew Exception("商品"+purchaseProductId+"仅剩"+currentCount+"件,无法购买");}productMapper.updateProductCount(purchaseProductNum,"xxx",new Date(),product.getId());platformTransactionManager.commit(transaction1);}// 不管成功与否,都需要释放锁 finally {lock.unlock();}// 创建事务,避免事务嵌套TransactionStatus transaction = platformTransactionManager.getTransaction(transactionDefinition);// 生成订单Order order = new Order();// ...orderMapper.insertSelective(order);platformTransactionManager.commit(transaction);return order.getId();}

2 乐观锁

乐观锁是相对悲观锁而言的,乐观锁假设数据一般情况不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果冲突,则返回给用户异常信息,让用户决定如何去做。乐观锁适用于读多写少的场景,这样可以提高程序的吞吐量。
乐观锁采取了更加宽松的加锁机制。乐观锁不会刻意使用数据库本身的锁机制,而是依据数据本身来保证数据的正确性

2.1 乐观锁的实现

乐观锁的实现主要就是两个步骤:冲突检测数据更新

  • CAS 实现:Java 中 java.util.concurrent.atomic 包下面的原子变量使用了乐观锁的一种 CAS 实现方式。
  • 版本号控制:一般是在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数。当数据被修改时,version 值会 +1。当线程 A 要更新数据时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值与当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功。

2.1.1 CAS

比较典型的就是 比较并交换 CAS (Compare and Swap)。
是解决多线程并行情况下使用锁造成性能损耗的一种机制,CAS 操作包含三个操作数——内存位置 V、预期原值 A 和新值B。如果内存位置的值 V 与预期原值 A 相匹配,那么处理器会自动将该位置值更新为新值 B。Java 中,sun.misc.Unsafe 类提供了硬件级别的原子操作来实现这个 CASjava.util.concurrent 包下大量的类都使用了这个 Unsafe.java 类的 CAS 操作。
当多个线程尝试使用 CAS 同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。比如前面的扣减库存问题,通过乐观锁可以实现如下:

// 查询商品库存信息,假如此时库存是 3select quantity from items where id = 1// 修改库存update items set quantity = 2 where id = 1 and quantity = 3

java.util.concurrent.atomic 包下的类大多是使用 CAS 操作来实现的,比如 AtomicIntegerAtomicBooleanAtomicLong。一般在竞争不是特别激烈的时候,使用该包下的原子操作性能比使用 synchronized 关键字的方式高效的多。在较多的场景都可能会使用到这些原子类操作。一个典型应用就是计数了,在多线程的情况下需要考虑线程安全问题。
比如说:

public class Increment {private int count = 0;public void add() {count++;}}

此时的 count++ 并不是线性安全的,因为 count++不是原子操作,而是三个原子操作的组合:

  • 读取内存中的 count 值赋值给局部变量 temp
  • 执行 temp+1 操作;
  • 赋值给 count

此时可以使用 synchronized 关键词保证线性安全,也可以使用 AtomicInteger 这种 Atomic 原子类就可以保证了线性安全了,如下多个线程可以并发的执行 AtomicIntegerincrementAndGet(),不同在于 Atomic 原子类底层用的不是传统意义的锁机制,而是无锁化的 CAS 机制,通过 CAS 机制保证多线程修改一个数值的安全性。

import java.util.concurrent.atomic.AtomicInteger;public static void main(String[] args) {public static AtomicInteger count = new AtomicInteger(0);public static void increase() {count.incrementAndGet();}}

CAS 形式的更新数据也有一个问题,那就是假如目前有两个线程并发,两个线程读取到的库存都是 3,线程A比线程B先执行,但线程A执行更新后的库存依旧是 3,由于库存数没有改变,所以此时线程B的更新也能顺利执行,但这里面就不能代表没有任何问题。

2.1.2 版本号控制

这时候的改进方法是:在表里创建一个字段类似于版本号 version,每次数据的更新都可以将该版本递增,而且每次数据的更新都需要将该版本号获取出来,做更新操作的时候则需要比较版本号是否一致。
不过此时就会有另外的问题,那就是一旦遇上高并发的时候,就只有一个线程可以修改成功,那么就会存在大量的失败。对于像电商网站,高并发是常有的事,总让用户感知到失败显然是不合理的。所以,还是要想办法减少乐观锁的粒度。一个比较好的建议,就是减小乐观锁力度,最大程度的提升吞吐率,提高并发能力。
比如说:

update items set quantity = quantity - 1 where id = 1 and quantity - 1 > 0

高并发环境下 锁粒度把控 是一门重要的学问。选择一个好的锁,在保证数据安全的情况下,可以大大提升吞吐率,进而提升性能。