Redis高并发场景防止库存数量超卖少卖

 更新时间:2024年09月14日 10:54:08   作者:yiridancan  
商品超卖是销售数量超过实际库存的情况,常因库存管理不当引发,传统库存管理在高并发环境下易出错,可通过线程加锁或使用Redis同步库存状态解决,本文就来详细的介绍一下,感兴趣的可以了解一下

简介

商品超卖现象,即销售数量超过了实际库存量,通常是由于未能正确判断库存状况而发生的。在常规的库存管理系统中,我们会在扣减库存之前进行库存充足性检验:仅当库存数量大于零时,系统才会执行扣减动作;若库存不足,则即时返回错误提示。然而,在高并发的销售场景下,传统的处理方法往往难以确保库存扣减的准确性。为了解决这一问题,我们可以采用线程加锁机制或利用Redis等内存数据结构来同步库存状态,从而保证即使在大量同时交易的情况下,库存扣减也能保持准确无误。

数据库校验

商品类

/**
 * @description 商品类
 * @author yiridancan
 * @date 2024/3/23 9:06
 */
public class Goods {

    private int id;

    /**
     * 商品名称
     */
    private String name;

    /**
     * 库存数量
     */
    private int inventoryCount;

    public int getId() {
        return id;
    }

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

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getInventoryCount() {
        return inventoryCount;
    }

    public void setInventoryCount(int inventoryCount) {
        this.inventoryCount = inventoryCount;
    }
}

实现类

import com.yiridancan.reduceInventory.entity.Goods;
import com.yiridancan.reduceInventory.mapper.GoodsMapper;
import com.yiridancan.reduceInventory.service.IGoodsService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.Objects;

/**
 *  商品实现类
 * @author yiridancan
 * @date 2024/3/23 18:35
 */
@Slf4j
@Service
public class GoodsServiceImpl implements IGoodsService {

    @Autowired
    private GoodsMapper goodsMapper;
    /**
     * 扣减库存
     * @param goodsId 商品id
     * @author yiridancan
     * @date 2024/3/23 18:33
     */
    @Override
    public void reduceInventory(int goodsId) {
        //1.根据商品id获取商品库存数量
        Goods goods = goodsMapper.findGoodsInventory(goodsId);
        if(Objects.isNull(goods)){
            log.error("未获取到商品信息");
            return;
        }
        //2.如果库存数量大于0则扣减库存,如果等于0代表没有货物打印错误信息
        if(goods.getInventoryCount() > 0 ){
            //默认扣减库存1
            goods.setInventoryCount(goods.getInventoryCount()-1);
            goodsMapper.updateGoodsInventory(goods);
            log.info("{}扣减库存成功,扣减后库存为:{}",goods.getName(),goods.getInventoryCount());
        }else {
            log.error("{}库存为0",goods.getName());
        }
    }
}

首先,我们需要根据商品ID获取商品数据。如果无法获取到数据,则打印异常并终止执行。接着,通过查询库存数量进行校验判断:若库存大于0,则扣减库存;反之,若库存为0,则打印异常信息。

数据库

测试代码

    @Test
	void contextLoads() {
		//商品id
		int goodsId = 1;
		//创建固定数量的线程池
		int num = 20;
		ExecutorService executorService = Executors.newFixedThreadPool(num);
		//模拟20个并发同时请求接口
		for (int i = 0; i < num; i++) {
			executorService.submit(() -> {
					goodsService.reduceInventory(goodsId);
			});
		}
		executorService.shutdown();
		try {
			executorService.awaitTermination(1, TimeUnit.MINUTES);
		} catch (InterruptedException e) {
			throw new RuntimeException(e);
		}
		//获取商品最终库存数量
		Goods goodsInventory = goodsMapper.findGoodsInventory(goodsId);
		if(Objects.isNull(goodsInventory)){
			return;
		}
		log.info("{}商品最终库存为:{}",goodsInventory.getName(),goodsInventory.getInventoryCount());
	}

运行结果

测试中,系统面临了20个同时发出的请求,而可用库存量仅为10个。理论上,这意味着应当有10个请求能够成功完成库存扣减,而另外10个请求则需被妥善拒绝。为解决此并发操作导致的数据不一致性问题,我们可以通过引入锁机制来确保数据访问的同步性,从而保障系统的正确性和稳定性。

悲观锁

可以通过synchronized、ReentrantLock等悲观锁来保证原子性和一致性

我们发现,在20次并发请求的测试场景中,仅有10次能够成功减少库存量,而另外10次则遭到拒绝。这种机制确保了数据一致性的严密守护。然而,若我们选择采用悲观锁的策略,虽然可以强化数据完整性,但却可能导致大量请求进入阻塞队列,尤其是在高并发的环境下,这种重量级的同步处理可能会对服务性能和数据库响应能力造成显著负担,甚至有可能引发系统瓶颈。因此,在设计高并发系统时,我们需要权衡锁机制的选择,以优化系统性能,保证服务的高效流畅。

乐观锁

乐观锁采用了一种比较宽松的并发控制策略。它允许多个线程同时读取和修改共享数据,但在数据提交时会检查是否有其他线程在此期间修改过相同的数据。如果检测到冲突,通常需要重新尝试操作,直到成功为止。乐观锁的核心在于它认为冲突不太可能发生,或者冲突发生的概率较低,因此不一开始就对数据加锁,从而避免了锁机制可能带来的性能开销。一般通过数据库版本号或者时间戳来进行实现

定义一个抽象接口:

    /**
     * 通过乐观锁实现扣减库存
     * @author yiridancan
     * @date 2024/3/25 22:33
     * @param goodsId 商品id
     */
    void casReduceInventory(int goodsId);

实现类:

    /**
     * 通过乐观锁实现扣减库存
     * @param goodsId 商品id
     * @author yiridancan
     * @date 2024/3/25 22:33
     */
    @Override
    public void casReduceInventory(int goodsId) {
        int retryCount = 0;
        //重试次数设置为3,避免无休止的重试占用紫鸢
        while (retryCount <=3){
            //1.根据商品id获取商品信息
            Goods goods = goodsMapper.findGoodsInventory(goodsId);
            if(Objects.isNull(goods) || goods.getInventoryCount() == 0){
                log.error("未获取到商品信息或库存数量不足");
                return;
            }
            //默认扣减库存1
            goods.setInventoryCount(goods.getInventoryCount()-1);
            int updateRow = goodsMapper.updateGoodsInventoryByCAS(goods);
            //如果修改条数大于0代表扣减库存成功
            if(updateRow > 0 ){
                log.info("{}扣减库存成功,扣减后库存为:{}",goods.getName(),goods.getInventoryCount());
                return;
            }
            retryCount++;
            log.error("{}商品被修改过,进行重试!!版本号:{}",goods.getName(),goods.getDataVersion());
        }
    }

首先会先定义一个重试次数,避免一直重试占用资源。然后获取到具体的商品信息,默认扣减库存为1(实际可以根据用户设置的数量进行扣减),然后根据查询出来的版本号和id去数据库中更新数据,如果返回更新数量代表扣减库存成功,则打印相关打印进行结束,否则进行重试,直到库存数量不足或扣减库存成功才结束

<update id="updateGoodsInventoryByCAS">
        update goods set inventory_count=#{inventoryCount},data_version=data_version+1 where id=#{id} and data_version=#{dataVersion}
</update>

Redis

借助Redis单线程的特性,再加上lua脚本执行过程原子性的保障。我们可以在Redis中通过lua脚本进行库存扣减操作

因为lua脚本在执行过程中,可以避免被打断,并且redis执行的过程也是单线程的,所以在脚本中进行判断,再扣减,这个过程是可以避免并发的。所以也就可以实现前面我们说的原子性+有序性了。

并且Redis是一个高性能的分布式缓存,使用Lua脚本扣减库存的方案也非常的高效

首先将商品库存初始化到Redis中,然后后续对Redis进行库存扣减

local key = KEYS[1] -- 商品的键名
local amount = tonumber(ARGV[1]) -- 扣减的数量

-- 获取商品当前的库存量
local stock = tonumber(redis.call('get', key))

-- 如果库存足够,则减少库存并返回新的库存量
if stock >= amount then
    redis.call('decrby', key, amount)
    return redis.call('get', key)
else
    return "INSUFFICIENT STOCK"
end

编写Lua脚本,通常是单独放在一个文件中。这里偷了一个懒直接声明成字符串了

/**
     * 通过Redis扣减库存
     *
     * @param goodsId 商品id
     * @author yiridancan
     * @date 2024/3/27 15:48
     */
    @Override
    public void redisReduceInventory(int goodsId) {
        String prefix = "goodsInventory:";
        //将商品数据缓存到Redis中,key是商品id,value是商品库存数量
        goodsMapper.findGoodsAll().forEach(goods -> {
            stringRedisTemplate.opsForValue().set(prefix+goods.getId(),String.valueOf(goods.getInventoryCount()));
        });

        //lua脚本,一般放在文件中
        String script = "local key = KEYS[1] -- 商品的键名\n" +
                "local amount = tonumber(ARGV[1]) -- 扣减的数量\n" +
                "\n" +
                "-- 获取商品当前的库存量\n" +
                "local stock = tonumber(redis.call('get', key))\n" +
                "\n" +
                "-- 如果库存足够,则减少库存并返回新的库存量\n" +
                "if stock >= amount then\n" +
                "    redis.call('decrby', key, amount)\n" +
                "    return redis.call('get', key)\n" +
                "else\n" +
                "    return \"INSUFFICIENT STOCK\"\n" +
                "end\n";

        DefaultRedisScript<String> redisScript = new DefaultRedisScript<>(script, String.class);

        // 创建一个包含库存key的列表
        List<String> keys = Collections.singletonList(prefix + goodsId);
        // 创建一个包含扣减数量的参数列表
        List<String> args = Collections.singletonList(Integer.toString(1));

        // 执行Lua脚本,传入键列表和参数列表
        String result = stringRedisTemplate.execute(redisScript, keys, args.toArray(new String[0]));
        //如果不是库存不足代表扣减成功
        if(!result.equals("INSUFFICIENT STOCK")){
            log.info("扣减库存成功,库存数量:{}",result);
        }else {
            log.error("库存数量不足");
        }
    }

首先把商品数据统一缓存到Redis中,然后编写一段Lua脚本交给DefaultRedisScript,DefaultRedisScript可以自定义数据返回类型

创建两个集合,分别存放key和参数,通过StringRedisTemplate.execute执行Lua脚本,如果返回的值是INSUFFICIENT STOCK代表库存不足,打印错误日志,否则扣减库存成功

最后在任务执行完成后定时将Redis中的库存同步到数据库中做持久化即可

其他方案

  • Redis+MQ+数据库:利用Redis来扛高并发流量。先在Redis扣减库存,然后发送一个MQ消息,消费者在接收到消息后做数据库库存的真正扣减和业务逻辑
  • 把修改转换成新增,直接插入一次占用记录,然后异步统计剩余库存,或者通过SQL统计流水方式计算剩余库存

  • 通过Redisson进行加锁处理

  • ..............

总结

综合来说,实践中往往会根据业务需求和现有技术栈选择合适的方法,Redis因其高性能和原子操作特性,在很多场景下成为首选方案之一。而具体实施时,可能还需要结合多种手段以及负载均衡、熔断、降级等策略来应对复杂的高并发挑战。

到此这篇关于Redis高并发场景防止库存数量超卖少卖的文章就介绍到这了,更多相关Redis防止超卖少卖内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

相关文章

  • java提供的4种函数式接口

    java提供的4种函数式接口

    这篇文章主要介绍了java提供的4种函数式接口,函数接口是只有一个抽象方法的接口,用作 Lambda 表达式的类型并有且只有一个抽象方法的接口,下文详细内容,需要的小伙伴可以参考一下
    2022-03-03
  • 解决 IDEA 创建 Gradle 项目没有src目录问题

    解决 IDEA 创建 Gradle 项目没有src目录问题

    这篇文章主要介绍了解决 IDEA 创建 Gradle 项目没有src目录问题,本文图文并茂给大家介绍的非常详细,具有一定的参考借鉴价值,需要的朋友可以参考下
    2018-06-06
  • SpringBoot实现RabbitMQ三种使用方式

    SpringBoot实现RabbitMQ三种使用方式

    本文主要介绍了SpringBoot实现RabbitMQ三种使用方式,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2023-07-07
  • Java16 JDK安装并设置环境变量的方法步骤

    Java16 JDK安装并设置环境变量的方法步骤

    突然想起自己大学刚接触java的时候,要下载JDK和配置环境变量,那时候我上网找了很多教学,本文就详细的介绍一下Java16 JDK安装并设置环境变量,感兴趣的可以了解一下
    2021-09-09
  • Java实战之敏感词过滤器

    Java实战之敏感词过滤器

    这篇文章主要介绍了Java实战之敏感词过滤器,文中有非常详细的代码示例,对正在学习java的小伙伴们有非常好的帮助,需要的朋友可以参考下
    2021-04-04
  • 详解关于eclipse中使用jdk15对应javafx15的配置问题总结

    详解关于eclipse中使用jdk15对应javafx15的配置问题总结

    这篇文章主要介绍了详解关于eclipse中使用jdk15对应javafx15的配置问题总结,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2020-11-11
  • Spring Boot 入门指南

    Spring Boot 入门指南

    Spring Boot 简化了Spring 应用的初始搭建、开发过程,开发人员可使用Spring提供的特定的方式来进行配置,不再需要重复自定义样板化的配置。本文就将带你入门Spring Boot
    2021-05-05
  • Java数组(Array)最全汇总(上篇)

    Java数组(Array)最全汇总(上篇)

    这篇文章主要介绍了Java数组(Array)最全汇总(上篇),本文章内容详细,通过案例可以更好的理解数组的相关知识,本模块分为了三部分,本次为上篇,需要的朋友可以参考下
    2023-01-01
  • SpringBoot基于AbstractRoutingDataSource实现多数据源动态切换

    SpringBoot基于AbstractRoutingDataSource实现多数据源动态切换

    本文主要介绍了SpringBoot基于AbstractRoutingDataSource实现多数据源动态切换,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2022-05-05
  • Spring Boot 项目中使用Swagger2的示例

    Spring Boot 项目中使用Swagger2的示例

    本篇文章主要介绍了Spring Boot 项目中使用Swagger2的示例,小编觉得挺不错的,现在分享给大家,也给大家做个参考。一起跟随小编过来看看吧
    2018-01-01

最新评论