乐观锁&悲观锁

May 13, 2018


什么是乐观锁,悲观锁?

关于乐观锁和悲观锁的概念,网上有很多,概括一下是:

  1. 乐观锁: 只会在数据最终提交的时候去锁定数据,判断是否可以更新
  2. 悲观锁: 每次操作都会进行数据的锁定,直到处理完成,杜绝其它操作更改数据的可能。

网上很多博客对乐观锁,悲观锁的区别写的很模糊,就我自己的理解,两者实际最终都会对数据加锁,区别在于锁定的时机,相比于乐观锁,悲观锁往往存在一个预先锁定数据,然后处理的逻辑,所以其持有锁的时间会更长,导致整体性能降低。悲观锁的好处是处理有序进行,多数请求都能成功,而乐观锁可能会导致大量的失败。

乐观锁,悲观锁举例

关于概念就不再做赘述了,直接上代码看吧,这里以电商秒杀场景为例子,这个场景下用户查询量,下单量都会非常大。我们用MySQL来存储被秒杀的商品库存,分别用乐观锁和悲观锁方式来实现,最后对比两者性能。

数据表

product_stock用来存储产品的库存,stock表示剩余商品的数量。

Create Table: CREATE TABLE `product_stock` (
  `id` int(11) DEFAULT NULL,
  `product_id` int(11) DEFAULT NULL,
  `stock` int(11) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8

假设一共有50个商品,这个活动要保证的有两点:

  1. 对用户响应快
  2. 不能超卖

数据

+------+------------+-------+
| id   | product_id | stock |
+------+------------+-------+
|    1 |          1 |    50 |
+------+------------+-------+

Pojo

public class Stock {
    private int id;
    private int product_id;
    private int stock;

    public int getId() {
        return id;
    }

    public void setId(int id) {
        this.id = id;
    }

    public int getProduct_id() {
        return product_id;
    }

    public void setProduct_id(int product_id) {
        this.product_id = product_id;
    }

    public int getStock() {
        return stock;
    }

    public void setStock(int stock) {
        this.stock = stock;
    }
}

悲观锁实现

按悲观锁的思路,为了避免多个线程操作同一条数据导致的冲突,且为了避免超卖,同时应该只允许一个线程操作数据,并判断当前的库存。

代码

@Transactional(rollbackFor = Exception.class)
public void pessimistic() {
	/* 加锁查询库存,两个true参数表示加锁查主库,也就是for update操作 */
    Stock stock = stockMapper.selectByPrimaryKey(1, true, true);
    /* 判断是否还有剩余 */
    if (stock.getStock() <= 0) {
	System.out.println("已经抢完了:" + System.currentTimeMillis() / 1000);
	return;
    }
    stock.setStock(stock.getStock() - 1);
    /* 更新库存 */
    stockMapper.updateByPrimaryKey(stock);
    System.out.println("抢到了,剩下:" + stock.getStock() + 
    " " + System.currentTimeMillis() / 1000);
}

每一个操作先加锁查出当前的库存,然后进行扣减并更新,可能有人会问:为什么要加锁查询?原因很简单,避免超卖。

结果

抢到了,剩下:49 1526222452
抢到了,剩下:48 1526222452
抢到了,剩下:47 1526222452
抢到了,剩下:46 1526222452
抢到了,剩下:45 1526222452
...
已经抢完了:1526222467
已经抢完了:1526222467

执行过程中没有更新失败的情况,总耗时 15s

乐观锁实现

乐观锁往往通过version或者timestamp来实现,以version为例,通过判断版本号,来确定是否可以更新数据,而在查询的时候带上这个version即可,不需要加锁。在这个场景下,我们用一个Trick,把stock字段当作版本号,因为stock正好每次扣减1,而且必须是连续的,所以每次更新时判断stock值是否合法,然后进行减1更新操作。

代码

public void optimistic() {
    /* 不用加锁查询数据 */
    Stock stock = stockMapper.selectByPrimaryKey(1, false, false);
    if (stock.getStock() <= 0) {
        System.out.println("抢完了:" + System.currentTimeMillis() / 1000);
        return;
    }
    /* 抢库存,并同时更新,第一个参数是新的库存,第二个参数是当前查出的库存 */
    int value = stockMapper.updateStock(stock.getStock() - 1, stock.getStock());
    /* 判断是否抢成功,1表示有一条记录被修改 */
    if (value == 1) {
        System.out.println("抢成功,剩下:" + (stock.getStock() - 1) + " "
        + System.currentTimeMillis() / 1000);
    } else {
        System.out.println("抢失败:" + System.currentTimeMillis() / 1000);
    }
}

这里有个区别的地方是用了updateStock方法,这个方法对应的操作是:

update product_stock
set
stock = #{newStock} #设置新的stock
where id = 1 and stock=#{oldStock} #只有传回的oldStock和当前stock相等的情况下才会更新

结果

抢成功,剩下:49 1526223502
抢失败:1526223502
抢失败:1526223502
抢失败:1526223502
抢失败:1526223502
抢失败:1526223502
抢失败:1526223502
抢失败:1526223502
抢失败:1526223502
抢失败:1526223502
抢成功,剩下:48 1526223502
...
抢完了:1526223505

执行过程中有361个抢失败的情况,但总耗时3s

结果分析

从列子的结果来看:乐观锁的总耗时远远低于悲观锁的情况,但却导致了大量的更新失败,实际更新的次数也远高于(悲观锁也就会有50次更新操作)悲观锁,其原因是因为乐观锁在查询库存时不需要加锁,这带来了查询性能的明显提升,但也导致了更多的更新操作(因为只有最后提交的时候,才知道是否能抢到并扣库存成功),这其实就是系统设计的Tradeoff 了。

可能有人会问,更新操作时MySQL加的排他锁不就是一种悲观锁吗,文中的乐观锁或悲观锁到数据库更新的时候不都是悲观的吗?其实我理解,乐观锁和悲观锁体现的是两种不同的设计思想,其粒度可以是数据库的锁设计,也可以是业务的处理逻辑,重点是两种思想的异同。


上一篇博客:Git笔记
下一篇博客:折腾MacVim