什么是乐观锁,悲观锁?
关于乐观锁和悲观锁的概念,网上有很多,概括一下是:
- 乐观锁: 只会在数据最终提交的时候去锁定数据,判断是否可以更新
- 悲观锁: 每次操作都会进行数据的锁定,直到处理完成,杜绝其它操作更改数据的可能。
网上很多博客对乐观锁,悲观锁的区别写的很模糊,就我自己的理解,两者实际最终都会对数据加锁,区别在于锁定的时机,相比于乐观锁,悲观锁往往存在一个预先锁定数据,然后处理的逻辑,所以其持有锁的时间会更长,导致整体性能降低。悲观锁的好处是处理有序进行,多数请求都能成功,而乐观锁可能会导致大量的失败。
乐观锁,悲观锁举例
关于概念就不再做赘述了,直接上代码看吧,这里以电商秒杀场景为例子,这个场景下用户查询量,下单量都会非常大。我们用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
个商品,这个活动要保证的有两点:
- 对用户响应快
- 不能超卖
数据
+------+------------+-------+
| 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加的排他锁不就是一种悲观锁吗,文中的乐观锁或悲观锁到数据库更新的时候不都是悲观的吗?其实我理解,乐观锁和悲观锁体现的是两种不同的设计思想,其粒度可以是数据库的锁设计,也可以是业务的处理逻辑,重点是两种思想的异同。