【📕分布式锁通关指南 01】从解决库存超卖开始加锁的初体验

本文涉及的产品
云数据库 RDS MySQL,集群系列 2核4GB
推荐场景:
搭建个人博客
RDS MySQL Serverless 基础系列,0.5-2RCU 50GB
云数据库 RDS MySQL,高可用系列 2核4GB
简介: 本文通过电商场景中的库存超卖问题,深入探讨了JVM锁、MySQL悲观锁和乐观锁的实现及其局限性。首先介绍了单次访问下库存扣减逻辑的正常运行,但在高并发场景下出现了超卖问题。接着分析了JVM锁在多例模式、事务模式和集群模式下的失效情况,并提出了使用数据库锁机制(如悲观锁和乐观锁)来解决并发问题。悲观锁通过`update`语句或`select for update`实现,能有效防止超卖,但存在锁范围过大、性能差等问题。乐观锁则通过版本号或时间戳实现,适合读多写少的场景,但也面临高并发写操作性能低和ABA问题。最终,文章强调没有完美的方案,只有根据具体业务场景选择合适的锁机制。

引言

在电商业务中,库存超卖问题就如同一颗定时炸弹,随时可能在高并发的环境下引爆。对于后端工程师来说,就需要为这颗炸弹加上防止爆炸的保险,从而避免因为超卖导致的资损问题。本系列文章就将从这个场景入手,一步步地为各位读者引入分布式锁的各种实现,从而让大家可以掌握分布式锁在常见场景的使用。

需求背景

背景非常简单,就是在电商项目中,用户购买商品和数量后后,系统会对商品的库存进行相应数量的扣减。因此,我们模拟这个场景就需要商品表库存表两张表,但业务并不是这里的重点,需要简化一下,一张简单的商品库存表足以,如下:

CREATE TABLE `tb_goods_stock`  (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键id',
  `goods_id` bigint(20) NOT NULL COMMENT '商品id',
  `stock` int NOT NULL COMMENT '库存数',
  PRIMARY KEY (`id`)
) COMMENT = '商品库存表';

接着,我们创建一个SpringBoot的项目,在接口中实现简单的扣减库存的逻辑,示例如下:

public String reductStock(Long goodsId,Integer count){
   
    //1.查询商品库存的库存数量
    Integer stock = stockDao.selectStockByGoodsId(goodsId);
    //2.判断商品的库存数量是否足够
    if (stock < count) return "库存不足";
    //3.如果足够,扣减库存数量
    stockDao.updateStockByGoodsId(goodsId,stock-count);
    //4.返回扣减成功
    return "库存扣减成功!";
}

创建成功后,先往数据库里插入一条商品id为1、库存为1的数据,便于我们测试接口的逻辑。分别执行两次调用,分别得到库存不足库存扣减成功的提示,验证逻辑没有问题,如下:
1.png

2.png

发现问题

上面的例子如果是通过单次访问,那么它的执行结果也是符合我们预期的。但在高并发场景下,多个线程同时访问同一个数据就可能出现超卖问题。因此,我们用JMeter来模拟大量并发数据来进行线上抢购场景复现,如下:
3.png
添加一个线程组,设定50个线程和100次循环次数,如下:
4.png
这时再将数据库里的商品id为1的数据的库存修改为5000,如下:
5.png
接着执行HTTP请求,如下:
6.png
通过聚合报告可以看出5000次请求都执行成功,这个时候按照正常逻辑,库存应该扣完了,回到数据库查询,如下:
7.png
通过查询发现还有4000多个库存,带换到线上场景,这个时候后续还有用户继续请求购买,最终实际卖出的肯定会远远超过库存,这就是经典的超卖问题

JVM锁初显神通

并发问题去找锁这个几乎是大家的共识,那么这里的超卖问题也不例外。因此,最直接的办法就是直接在涉及扣减库存的逻辑或操作上进行加锁处理。首先,最先想到的就是JVM锁,只需要一个synchronized关键字就可以实现,代码修改如下:

public synchronized String reductStock(Long goodsId,Integer count){
   
    //1.查询商品库存的库存数量
    Integer stock = stockDao.selectStockByGoodsId(goodsId);
    //2.判断商品的库存数量是否足够
    if (stock < count) return "库存不足";
    //3.如果足够,扣减库存数量
    stockDao.updateStockByGoodsId(goodsId,stock-count);
    //4.返回扣减成功
    return "库存扣减成功!";
}

我们这时候去把数据库的库存还原下,然后重新用JMeter进行请求(Ps:原参数不变),执行后我们先看数据库结果,如下:
8.png
可以看到这次的库存就被扣减完了,但我们查看聚合报告会发现对比前面的请求,有一项指标下降了很多-吞吐量,从三千多到现在的一千多,所以加锁肯定对性能是会产生影响的,如下:
9.png
当然除了synchronized关键字,还有更为灵活的方式,毕竟它是作用在方法上的,而我们使用reentrantLock则可以实现对代码块进行加锁,如下:

ReentrantLock reentrantLock = new ReentrantLock();

public String reductStock(Long goodsId,Integer count){
   
    //1.加锁
    reentrantLock.lock();
    try {
   
        //2.查询商品库存的库存数量
        Integer stock = stockDao.selectStockByGoodsId(goodsId);
        //3.判断商品的库存数量是否足够
        if (stock < count) return "库存不足";
        //4.如果足够,扣减库存数量
        stockDao.updateStockByGoodsId(goodsId,stock-count);
    } finally {
   
        //5.解锁
        reentrantLock.unlock();
    }
    //6.返回扣减成功
    return "库存扣减成功!";
}

JVM锁是万能的吗?

经过了上面的简单改造就让我们的扣减库存不失效了,那么是否这样就可以真正地解决线上的超卖问题呢?当然不是的,JVM锁并不是万能的,它在部分场景下是会失效的,如下:

1.多例模式

首先,我们都知道Spring默认是单例的,即每个对象都会被注册成为一个bean交给IOC容器进行管理。但是它是可以设置成多例的,只需要一个简单的注解,如下:

@Scope(value = "prototype", proxyMode = ScopedProxyMode.TARGET_CLASS)
@Service
public class StockService {

    @Autowired
    private StockDao stockDao;

    public synchronized String reductStock(Long goodsId,Integer count){
        //1.查询商品库存的库存数量
        Integer stock = stockDao.selectStockByGoodsId(goodsId);
        //2.判断商品的库存数量是否足够
        if (stock < count) return "库存不足";
        //3.如果足够,扣减库存数量
        stockDao.updateStockByGoodsId(goodsId,stock-count);
        //4.返回扣减成功
        return "库存扣减成功!";
    }
}

这个时候我们再次进行调用测试,结果如下:
10.png

可以看到超卖问题又重出江湖了。那么这是为什么呢?其实很好理解,多例模式下这个类对应的bean也可以有多个,也就是我们每次执行到这个方法都是一个新的bean,自然就根本没有锁住。

2.事务模式

事务模式就是在方法上加上事务注解(Ps:这里测试记得把上面的多例注解注释掉),代码如下:

@Transactional
public synchronized String reductStock(Long goodsId,Integer count){
   
    //1.查询商品库存的库存数量
    Integer stock = stockDao.selectStockByGoodsId(goodsId);
    //2.判断商品的库存数量是否足够
    if (stock < count) return "库存不足";
    //3.如果足够,扣减库存数量
    stockDao.updateStockByGoodsId(goodsId,stock-count);
    //4.返回扣减成功
    return "库存扣减成功!";
}

再次进行调用测试,结果如下:
11.png

可以看到依然会有剩余库存,那么为什么加上事务就破坏了JVM锁呢?其实也很好理解:我们看代码,在扣减库存的方法上我们加了事务,方法内部加了锁,可以理解成事务包着锁。那么当请求A执行到扣减库存的方法后,会先进入事务,然后加锁->执行业务逻辑->解锁。这里需要注意的是,一旦解锁之后,请求B就会马上抢夺锁,所以这个时候就出现了旧请求还没提交事务,新请求就拿到锁开始执行了。在读已提交这个默认的隔离级别下,就可能出现新旧请求扣减了同一份库存,自然超卖问题就又出现了。那么是否有解决办法呢?答案是肯定的。这里我们分析了失效的原因,那么其实只要把锁加到事务外,确保事务提交了才释放锁就行。比如按照我们现有的例子,把synchronized关键字加到controller层就行了,这里很简单就不演示了,感兴趣的读者可以自行测试。

3.集群模式

集群模式则是最常见的情况,毕竟应该不会有生产级别的服务只部署一个实例,几乎都是部署多实例的。那么这个时候JVM锁自然就失效了,如下:
12.png
在这个例子中,外部的请求进入到nginx,通过负载均衡策略转发到库存服务,JVM锁只在所在的JVM内部失效,所以这里加的JVM锁其实是3个服务各加了一把锁,那各自锁各自的等于没锁,超卖问题自然就又出现了。

解决JVM锁失效后的并发问题

上文中提到了3种JVM锁失效的场景,那么就需要想出新的策略来应对并发问题,那么让我们把目光投向MysQL,它天然就带有表锁、行锁、间隙锁等,那么我们可以利用这些性质来实现我们业务上的加解锁。这种利用数据库锁机制并且假设数据会冲突在操作前加锁的思想,我们称为悲观锁。它的实现方式主要有以下两种:

悲观锁 - 单条update语句实现

首先,让我们回到扣减库存的业务逻辑,如下:

public String reductStock(Long goodsId,Integer count){
   
    //1.查询商品库存的库存数量
    Integer stock = stockDao.selectStockByGoodsId(goodsId);
    //2.判断商品的库存数量是否足够
    if (stock < count) return "库存不足";
    //3.如果足够,扣减库存数量
    stockDao.updateStockByGoodsId(goodsId,stock-count);
    //4.返回扣减成功
    return "库存扣减成功!";
}

先查询现在的库存数量,然后判断库存是否足够,如果足够再扣减。那么这三步操作我们其实可以合成一步SQL来执行,这是原本的扣减库存的SQL语句,如下:

@Update("update tb_goods_stock set stock= #{count} where goods_id= #{goodsId}")
Integer updateStockByGoodsId(@Param("goodsId") Long goodsId, @Param("count") Integer count);

让我们进行一个迭代,直接在SQL进行扣减和判断操作,如下:

@Update("update tb_goods_stock set stock= stock - #{count} where goods_id= #{goodsId} and stock >= #{count}")
Integer updateStockByGoodsId(@Param("goodsId") Long goodsId, @Param("count") Integer count);

然后回到service那里同步修改,如下:

public String reductStock(Long goodsId,Integer count){
   
    //1.扣减库存数量
    Integer result = stockDao.updateStockByGoodsId(goodsId, count);
    //2.如果数量大于0,则扣减成功
    if (result > 0){
   
        return "库存扣减成功!";
    }
    //3.返回扣减失败
    return "库存扣减失败!";
}

接着我们用JMeter再次进行测试,最终库存按照预期归零了,如下:
13.png

那么这种悲观锁-单条update语句的方式是否就很完美了呢?当然不是,它其实也存在一些问题:

1.易造成锁范围过大

范围过大怎么理解呢,我们在MySQL客户端里进行测试,首先插入id = 1和2的两条商品库存数据,如下:
14.png

然后我们写下update语句,如下:

BEGIN;

UPDATE tb_goods_stock SET stock = stock - 1 WHERE id = 1;

SELECT * FROM tb_goods_stock;

COMMIT;

然后逐行执行,但执行到查询后先不提交,这个时候执行这条sql查询数据库的加锁情况,如下:

select * from performance_schema.data_locks;

然后得到如下结果:
15.png

于是我们可以分析出来,当前的这条update语句会把每条tb_goods_stock表上每条数据都锁起来,虽然锁类型都是行锁,但实际上每行都锁其实已经是表锁了。在我们这个例子中,就是用户购买id = 1的商品,但所有商品库存都被锁住了,一个用户买东西,所有用户都得排队等,这个性能只能说相当感人了。那么这个问题有解决办法吗?当然是有的,我们观察下index_name字段,发现它的值都是主键id,因为我们的商品id并没有建立索引,所以这里锁的时候就会根据主键将全表锁住了。既然知道问题出在哪里了,那么解决办法也很简单,给商品id加个索引就行,加好索引之后我们重新开启事务执行update语句,再来查锁信息,如下:
16.png

这个时候看到id=2的lock_mode发生了变化,多了一个GAP,它表示间隙锁(Ps:它的意思是你在1和2之间插入一条大于1小于2的数据是插入不进去的)。

2.无法在程序中获取扣减库存之前的值

这个就很好理解了,原本在代码中拆了三段逻辑执行,在扣减前会先获取,自然就有记录。现在全部一条SQL执行了,在应用层面是没有旧库存了。

3.很多场景下无法满足业务诉求

我们这里的案例业务逻辑十分简单,一条SQL就搞定了,那么在实际场景中,还可能涉及到拆单、合单等之类的操作,那么这个时候是需要我们在代码中处理业务逻辑的,显然单靠一条update语句就无法满足需求了。

悲观锁 - for update语句实现

那么为了解决上述的后两个问题,我们可以使用悲观锁的另一种方式。只需要在查询语句后加个for update,如下:

@Select("select stock from tb_goods_stock where goods_id= #{goodsId} for update")
Integer selectStockByGoodsIdForUpdate(@Param("goodsId") Long goodsId);

它的作用是在查询的时候加锁,和前面的update语句一样会加行锁,当然,如果你没有建索引,它会建表锁。注意的是:这里的锁是依靠mysql的锁机制实现的,所以当你的事务没提交的时候,当前的连接就会一直持有锁,所以需要我们在方法上加上事务注解,保证逻辑执行完成后自动提交事务,如下:

@Transactional(rollbackFor = Exception.class)
public String reductStock(Long goodsId,Integer count){
   
    //1.查询商品库存数量
    Integer stock = stockDao.selectStockByGoodsIdForUpdate(goodsId);
    //2.判断库存数量是否足够
    if (stock < count){
   
        return "库存不足!";
    }
    //3.如果库存足够,扣减库存
    stockDao.updateStockByGoodsId(goodsId, count);
    //3.返回扣减成功
    return "库存扣减成功!";
}

它的优势也很明显,解决了上述的两个问题:无法在程序中获取扣减库存之前的值和很多场景下无法满足业务诉求。那么,它的问题是什么呢?

1.易造成锁范围过大

这个很好理解,毕竟它本身的实现和单条update语句一样,所以自然也会存在相同的这个问题。

2.性能较差

长时间锁定以及频繁的加锁和解锁操作都会成为性能的瓶颈点。

3.死锁问题

其实这个问题,单条update语句也可能出现,主要和加锁顺序有关。比如现在两个客户端A和B同时请求,客户端A里我们先给商品id=1的加锁,客户端B则先给商品id=2的加锁,接着A再给商品=2的加锁,B则给商品id=1的加锁,这个时候就形成了死锁。

4.select for update和普通select语句读取内容不一致

在默认的隔离级别(即读已提交)下,假如客户端A开启了事务,并做了扣减库存,这个时候还未提交事务,客户端B这个时候使用select语句读取到的值就是扣减前的,但是如果客户端B使用的是select for update来读取,读到的就是扣减后的值,因为它是当前读,即数据的真实值而不受事务影响。那么如果在业务中,有的地方使用select for update,有的地方使用select,而且需要对读取到的值做业务处理,这样处处不一致就可能导致数据问题。

乐观锁-版本号

有悲观锁自然也有乐观锁,和悲观锁相反,它是假设每次去拿数据别人都不会修改,所以不会上锁,只在更新的时候判断一下别人有没有更新这个数据。虽然叫乐观锁,但它其实更像是一种设计思想,先来介绍一下它的一种实现-版本号

1.给指定表增加一个字段version

ALTER TABLE `tb_goods_stock` 
ADD COLUMN `version` int NULL DEFAULT 0 COMMENT '版本号' AFTER `stock`;

17.png

2.读取数据的时候将version字段一起读出

@Select("select id,stock,version from tb_goods_stock where goods_id= #{goodsId}")
List<GoodsStockEntity> selectStockAndVersionByGoodsId(@Param("goodsId") Long goodsId);

3.数据每更新一次,version字段加1

@Select("update tb_goods_stock set stock= #{count}, version=#{version} + 1 where goods_id= #{goodsId} and version = #{version}")
Integer updateStockAndVersionByGoodsIdAndVersion(@Param("goodsId") Long goodsId, @Param("count") Integer count,@Param("version") Integer version);

4.提交更新的时候,判断库中的version字段和前面读出来的进行比较

//1.查询商品库存数量 + version
List<GoodsStockEntity> goodsStockEntities = stockDao.selectStockAndVersionByGoodsId(goodsId);
//2.判空
if (goodsStockEntities.isEmpty()) {
   
    return "商品不存在!";
}
//3.存在则取出
GoodsStockEntity goodsStockEntity = goodsStockEntities.get(0);
//4.判断库存数量是否足够
if (goodsStockEntity.getStock() < count) {
   
    return "库存不足!";
}
//5.如果库存足够,扣减库存
result = stockDao.updateStockAndVersionByGoodsIdAndVersion(goodsId,
        goodsStockEntity.getStock() - count, goodsStockEntity.getVersion());

5.相同更新,不相同重试

public String reductStock(Long goodsId,Integer count) {
   
    //1.声明修改标志变量
    Integer result = 0;
    while (result == 0) {
   
        //1.查询商品库存数量 + version
        List<GoodsStockEntity> goodsStockEntities = stockDao.selectStockAndVersionByGoodsId(goodsId);
        //2.判空
        if (goodsStockEntities.isEmpty()) {
   
            return "商品不存在!";
        }
        //3.存在则取出
        GoodsStockEntity goodsStockEntity = goodsStockEntities.get(0);
        //4.判断库存数量是否足够
        if (goodsStockEntity.getStock() < count) {
   
            return "库存不足!";
        }
        //5.如果库存足够,扣减库存
        result = stockDao.updateStockAndVersionByGoodsIdAndVersion(goodsId,
                goodsStockEntity.getStock() - count, goodsStockEntity.getVersion());
    }
    //6.返回扣减成功
    return "库存扣减成功!";
}

修改完成后,我们再次进行测试,可以看到库存扣减为0,版本号也加到了5000,如下:
18.png
19.png

乐观锁-时间戳

通过版本号机制,我们成功解决了扣减库存的问题,接下来看下乐观锁的另一种实现-时间戳。它的实现方式和版本号类似,这里我们就不演示了,给大家说明下步骤:

  • 1.给表增加timestamp字段;
  • 2.读取数据的时候,将timestamp字段一起读出;
  • 3.数据每更新一次,timestamp取当前时间戳;
  • 4.提交更新时,判断库中的timestamp字段值和之前取出来的timestamp进行比较;
  • 5.相同更新,不相同重试。

乐观锁问题

看了乐观锁的实现,在前文中我们分析了悲观锁存在的问题,那么同样地,接下来我们聊聊乐观锁存在的问题。

1.高并发写操作性能低

因为我们存在重试机制,那么在高并发场景下,存在多个请求不断重试,每个请求的读也都需要和数据库进行IO,所以它更适合读多写少的场景。

2.存在ABA问题

这是一个乐观锁的常见问题,虽然在我们上面的例子中并不会发生,因为它只涉及到减库存,可能有的同学会困惑为什么呢?我来举个例子:假设目前业务还存在取消订单,需要对版本号做减一。那么假设此时有三个线程同时进入,线程A减库存,版本号加一;线程B加库存,版本号减一;线程C也是减库存,但是线程B把版本号减一,把A加的又减回去了,就导致C拿到的和读到的一样了,于是C也减库存了。这个时候A和C扣了一次,但实际消费了两次,超卖问题就又出现了。

小结

本篇文章通过超卖问题引入了JVM锁、MySQL悲观锁和乐观锁,并对每种锁的实现和局限都做了讲解,其实是想在开篇就告诉各位读者没有完美的方案,只有更好的方案。在我们后续的学习中你也会看到不断地肯定与否定,主要的目的是希望各位读者在学习后可以根据自己的业务场景选择合适的方案!

相关实践学习
如何快速连接云数据库RDS MySQL
本场景介绍如何通过阿里云数据管理服务DMS快速连接云数据库RDS MySQL,然后进行数据表的CRUD操作。
全面了解阿里云能为你做什么
阿里云在全球各地部署高效节能的绿色数据中心,利用清洁计算为万物互联的新世界提供源源不断的能源动力,目前开服的区域包括中国(华北、华东、华南、香港)、新加坡、美国(美东、美西)、欧洲、中东、澳大利亚、日本。目前阿里云的产品涵盖弹性计算、数据库、存储与CDN、分析与搜索、云通信、网络、管理与监控、应用服务、互联网中间件、移动服务、视频服务等。通过本课程,来了解阿里云能够为你的业务带来哪些帮助 &nbsp; &nbsp; 相关的阿里云产品:云服务器ECS 云服务器 ECS(Elastic Compute Service)是一种弹性可伸缩的计算服务,助您降低 IT 成本,提升运维效率,使您更专注于核心业务创新。产品详情: https://www.aliyun.com/product/ecs
目录
相关文章
|
8月前
|
NoSQL 算法 安全
Redlock 算法-主从redis分布式锁主节点宕机锁丢失的问题
Redlock 算法-主从redis分布式锁主节点宕机锁丢失的问题
325 0
|
1月前
|
NoSQL Java Redis
秒杀抢购场景下实战JVM级别锁与分布式锁
在电商系统中,秒杀抢购活动是一种常见的营销手段。它通过设定极低的价格和有限的商品数量,吸引大量用户在特定时间点抢购,从而迅速增加销量、提升品牌曝光度和用户活跃度。然而,这种活动也对系统的性能和稳定性提出了极高的要求。特别是在秒杀开始的瞬间,系统需要处理海量的并发请求,同时确保数据的准确性和一致性。 为了解决这些问题,系统开发者们引入了锁机制。锁机制是一种用于控制对共享资源的并发访问的技术,它能够确保在同一时间只有一个进程或线程能够操作某个资源,从而避免数据不一致或冲突。在秒杀抢购场景下,锁机制显得尤为重要,它能够保证商品库存的扣减操作是原子性的,避免出现超卖或数据不一致的情况。
65 10
|
2月前
|
存储 运维 NoSQL
分布式读写锁的奥义:上古世代 ZooKeeper 的进击
本文作者将介绍女娲对社区 ZooKeeper 在分布式读写锁实践细节上的思考,希望帮助大家理解分布式读写锁背后的原理。
100 11
|
7月前
|
消息中间件 NoSQL Java
Redis系列学习文章分享---第六篇(Redis实战篇--Redis分布式锁+实现思路+误删问题+原子性+lua脚本+Redisson功能介绍+可重入锁+WatchDog机制+multiLock)
Redis系列学习文章分享---第六篇(Redis实战篇--Redis分布式锁+实现思路+误删问题+原子性+lua脚本+Redisson功能介绍+可重入锁+WatchDog机制+multiLock)
273 0
|
5月前
|
缓存 NoSQL Java
SpringBoot整合Redis、以及缓存穿透、缓存雪崩、缓存击穿的理解、如何添加锁解决缓存击穿问题?分布式情况下如何添加分布式锁
这篇文章介绍了如何在SpringBoot项目中整合Redis,并探讨了缓存穿透、缓存雪崩和缓存击穿的问题以及解决方法。文章还提供了解决缓存击穿问题的加锁示例代码,包括存在问题和问题解决后的版本,并指出了本地锁在分布式情况下的局限性,引出了分布式锁的概念。
SpringBoot整合Redis、以及缓存穿透、缓存雪崩、缓存击穿的理解、如何添加锁解决缓存击穿问题?分布式情况下如何添加分布式锁
|
8月前
|
缓存 NoSQL Java
分布式项目中锁的应用(本地锁-_redis【setnx】-_redisson-_springcache)-fen-bu-shi-xiang-mu-zhong-suo-de-ying-yong--ben-de-suo--redissetnx-springcache-redisson(一)
分布式项目中锁的应用(本地锁-_redis【setnx】-_redisson-_springcache)-fen-bu-shi-xiang-mu-zhong-suo-de-ying-yong--ben-de-suo--redissetnx-springcache-redisson
101 0
|
6月前
|
负载均衡 NoSQL Java
|
5月前
|
算法
分布式锁设计问题之重建节点锁信息时要分为多个阶段如何解决
分布式锁设计问题之重建节点锁信息时要分为多个阶段如何解决
|
5月前
分布式锁设计问题之节点A向节点C发起对R1的加锁请求如何解决
分布式锁设计问题之节点A向节点C发起对R1的加锁请求如何解决
|
5月前
|
NoSQL Go API
[go 面试] 为并发加锁:保障数据一致性(分布式锁)
[go 面试] 为并发加锁:保障数据一致性(分布式锁)