首页 > 其他分享 >高并发场景防止库存数量超卖少卖

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

时间:2024-03-29 11:01:18浏览次数:15  
标签:goods 扣减 少卖 int Redis 并发 库存 超卖 id

简介

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

其他方案

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

  3. 通过Redisson进行加锁处理

  4. ..............

总结

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

标签:goods,扣减,少卖,int,Redis,并发,库存,超卖,id
From: https://blog.csdn.net/yiridancan/article/details/136990938

相关文章

  • 【Linux】生产者消费者模型{基于BlockingQueue的PC模型/RAII风格的加锁方式/串行,并行,
    文章目录1.认识PC模型2.基于BlockingQueue的PC模型2.1串行,并行,并发2.2理解linux下的并发2.2RAII风格的加锁方式2.3阻塞队列2.4深入理解pthread_cond_wait2.5整体代码1.Task.hpp2.lockGuard.hpp3.BlockQueue.hpp4.pcModel.cc3.总结PC模型1.认识PC模型知乎好文「......
  • 在Python中如何使用协程进行并发操作
    在Python中使用协程进行并发操作是一种高效的方式来处理I/O密集型任务或者在单个Python程序内部执行多个操作。本文将详细介绍如何在Python中使用协程进行并发操作,包括协程的基本概念、如何创建和运行协程、如何使用任务来管理多个协程,以及如何利用协程进行并发网络请求等。最......
  • GO并发编程
    Go语言的并发编程是其核心特性之一,它提供了简洁强大的机制来处理并发任务。Go并发模型的基石是goroutines和channels。GoroutinesGoroutine是Go语言中实现并发的基本单位。你可以把它看作一个轻量级的线程,由Go运行时(runtime)进行管理。启动一个新的goroutine非......
  • JVM(六)——内存模型与高效并发
    内存模型与高效并发一、java内存模型【java内存模型】是JavaMemoryModel(JMM)简单的说,JMM定义了一套在多线程读写共享数据时(成员变量、数组)时,对数据的可见性、有序性、和原子性的规则和保障1)原子性原子性在学习线程时讲过,下面来个例子简单回顾一下:问题提出,两个线......
  • JAVA面试大全之并发篇
    目录1、并发基础1.1、多线程的出现是要解决什么问题的?本质什么?1.2、Java是怎么解决并发问题的?1.3、线程安全有哪些实现思路?1.4、如何理解并发和并行的区别?1.5、线程有哪几种状态?分别说明从一种状态到另一种状态转变有哪些方式?1.6、通常线程有哪几种使用方式?1......
  • 并发锁与线程池(一)
    1.什么是线程?线程是操作系统能够进行运算调度的最小单位,它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。在多核或多CPU系统中,线程可以被调度到不同的核心上执行,从而实现真正......
  • 【性能测试】线程数、并发数和TPS的关系
    项目背景某通信产品在提测阶段,领导要求支持1w人同时在线,支持1000并发,去测吧理解需求“支持1w人同时在线,支持1000并发”“1w人同时在线”这个理解起来简单一些,对于即时通讯产品来说,就是1w个长链接,直接写脚本建立长链接就行。“支持1000并发”这里就产生歧义了:1、什么功......
  • 操作系统高级议题:并发控制与进程互斥技术
    ✨✨欢迎大家来访Srlua的博文(づ ̄3 ̄)づ╭❤~✨✨......
  • 别想宰我,怎么查看云厂商是否超卖?详解 cpu steal time
    据说有些云厂商会超卖,宿主有96个核心,结果卖出去100多个vCPU,如果这些虚机负载都不高,大家相安无事,如果这些虚机同时运行一些高负载的任务,相互之间就会抢占CPU,对应用程序有较大影响,我应该如何查看我的CPU是否被抢占了呢?什么是cpustealtime?如果你在物理机上查看这个......
  • GCD 并发队列来实现多读单写
     iOS的多读单写指的是多个线程可以同时读取共享的数据,但是只有一个线程能够写入数据。这是为了保证数据的一致性和避免竞争条件的出现。一在Objective-C中,可以使用GCD的并发队列来实现多读单写。具体实现步骤如下:1.定义一个并发队列和一个串行队列,用于处理读操作和写操......