暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

聊聊大厂面试中一道库存管理问题

137

写在文章开头

近期和读者聊到了一道大厂面试题:

有一个库存为n的商品,用三个线程进行库存扣减及三个线程进行库存补给,如何设计?

借着这个问题,笔者就从一个设计者的角度聊聊并发编程的设计技巧。

Hi,我是 sharkChili ,是个不断在硬核技术上作死的 java coder ,是 CSDN的博客专家 ,也是开源项目 Java Guide 的维护者之一,熟悉 Java 也会一点 Go ,偶尔也会在 C源码 边缘徘徊。写过很多有意思的技术博客,也还在研究并输出技术的路上,希望我的文章对你有帮助,非常欢迎你关注我的公众号: 写代码的SharkChili

因为近期收到很多读者的私信,所以也专门创建了一个交流群,感兴趣的读者可以通过上方的公众号获取笔者的联系方式完成好友添加,点击备注  “加群”  即可和笔者和笔者的朋友们进行深入交流。

详解多线程库存管理问题

问题分析

无论是面试还是日常开发,对于此类问题,最首先要做的就是明确需求边界,从笔者的角度来看,拿到这题时会首先寻求明确以下几点:

  1. 商品是一个还是多个?
  2. 商品是否是固定?还是会实时新增?

假定我们认为商品是多个,且不定时新增,对应的库存表DDL
语句如下:


CREATE TABLE `inventory` (
  `id` int NOT NULL AUTO_INCREMENT,
  `product_code` varchar(50CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '商品编码',
  `stock` int NOT NULL COMMENT '库存',
  PRIMARY KEY (`id`),
  KEY `inventory_product_code_IDX` (`product_code`USING BTREE
ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

上锁解决库存管理问题

为了保证库存增建数据准确的,我们必须保证单位时间内只有一个线程可以操作单个库存,于是笔者这里给出第一个方案,每次线程进行增减操作时先尝试获取当前的库存锁:

基于这套方案我们给出库存扣减的逻辑(库存补充的逻辑同理,这里就不多赘述了),可以看到笔者通过synchronized
锁住单例的service
,然后进行如下操作:

  1. 库存查询
  2. 非空校验
  3. 超量比对
  4. 扣减更新
public synchronized void decreaseStock(Integer productId, Integer decreaseCount) {

        log.info("库存扣减,请求参数:{},decreaseCount:{}", productId, decreaseCount);
        //库存查询
        Inventory inventory = inventoryMapper.selectByPrimaryKey(productId);
        //非空校验
        if (ObjUtil.isEmpty(inventory)) {
            log.info("当前库存商品不存在:{}", productId);
            throw new Exception("当前库存商品" + productId + "不存在");
        }
        //扣减比对
        if (inventory.getStock().compareTo(decreaseCount) < 0) {
            log.warn("库存不足,decreaseCount:{},库存信息:{}", decreaseCount, JSONUtil.toJsonStr(inventory));
            throw new Exception("当前库存商品" + productId + "库存不足");
        }
        //扣减库存
        inventory.setStock(inventory.getStock() - decreaseCount);

        log.info("库存更新,请求入参:{}", JSONUtil.toJsonStr(inventory));
        inventoryMapper.updateByPrimaryKeySelective(inventory);

    }

我们再给出使用示例,无论是扣减还是补给都采用3个线程进行随机100次的补给和扣减:

long startTime = System.currentTimeMillis();
        CountDownLatch countDownLatch = new CountDownLatch(6);

        InvenToryService invenToryService = SpringUtil.getBean(InvenToryService.class);

        //3个库存扣减线程循环100次进行随机库存扣减
        for (int i = 0; i < 3; i++) {
            new Thread(() -> {
                for (int j = 0; j < 100; j++) {
                    invenToryService.decreaseStock(RandomUtil.randomInt(13), RandomUtil.randomInt(10));
                }
                countDownLatch.countDown();
            }, "increase-thread" + i).start();

        }

        //3个库存扣减线程循环100次进行库存补给
        for (int i = 0; i < 3; i++) {
            new Thread(() -> {
                for (int j = 0; j < 100; j++) {
                    invenToryService.addToStock(RandomUtil.randomInt(13), RandomUtil.randomInt(10));
                }
                countDownLatch.countDown();
            }, "increase-thread" + i).start();

        }

        countDownLatch.await();
        long endTime = System.currentTimeMillis();
        log.info("耗时:{}ms", (endTime - startTime));

最终执行的耗时在5秒左右,而慢的原因原因很简单,所有线程无论操作任何一个商品都在竞争同一把synchronized
锁,最终导致一个时间段内6个线程中只有一个线程可以进行库存管理。

库存管理问题的更优解

因为我们的线程随机操作不同的库存,所以为什么我们不可以针对每一个库存上一把锁呢?我们不妨将所有进行水平拆分,从而降低线程之间的冲突。

基于这个思路我们给出第二套代码,通过productId
计算出对应的商品的哈希值,将不同的库存锁用ConcurrentHashMap
进行管理,从而减小锁的竞争,因为需求明确提出随时可能补给商品,所以我们会用putIfAbsent
确保线程安全的增加新的库存商品管理锁:

@Slf4j
@Component
public class StockLock {

    //用productId作为key,为每一个key分配一个ReentrantLock
    private ConcurrentHashMap<Integer, ReentrantLock> lockMap = new ConcurrentHashMap<>();

    public void lock(Integer productId) {
        //如果当前商品的id不存在于这个lockMap中,则采用putIfAbsent进行初始化
        if (!lockMap.containsKey(productId)) {
            lockMap.putIfAbsent(productId, new ReentrantLock());
        }
        //调用该方法的线程进行上锁操作
        lockMap.get(productId).lock();
    }
    
    //释放当前productId的锁
    public void unlock(Integer productId) {
        lockMap.get(productId).unlock();
    }
}

基于这个锁的方案我们给出新的一套代码示例,最终执行时间变成3秒,当然从目前来看性能提升不算是特别明显,因为当前管理的商品不是很多,随着库存商品的增加,我们的lockMap
因为始终分散各个线程的锁目标性能表现会远超于方案1:

InvenToryService invenToryService = SpringUtil.getBean(InvenToryService.class);
        StockLock stockLock = SpringUtil.getBean(StockLock.class);

        //3个库存扣减线程循环100次进行随机库存扣减
        for (int i = 0; i < 3; i++) {
            new Thread(() -> {
                for (int j = 0; j < 100; j++) {
                    int productId = RandomUtil.randomInt(14);
                    stockLock.lock(productId);

                    try {
                        invenToryService.decreaseStock(productId, RandomUtil.randomInt(10));
                    } catch (Exception e) {
                        log.error("库存扣减失败:{}", e.getMessage(), e);
                    } finally {
                        stockLock.unlock(productId);
                    }
                }
                countDownLatch.countDown();
            }, "decrease-thread" + i).start();

        }

        //3个库存扣减线程循环100次进行库存补给
        for (int i = 0; i < 3; i++) {
            new Thread(() -> {
                for (int j = 0; j < 100; j++) {
                    int productId = RandomUtil.randomInt(14);
                    stockLock.lock(productId);
                    try {
                        invenToryService.addToStock(RandomUtil.randomInt(14), RandomUtil.randomInt(10));
                    } catch (Exception e) {
                        log.error("库存补给失败:{}", e.getMessage(), e);
                    } finally {
                        stockLock.unlock(productId);
                    }
                }
                countDownLatch.countDown();
            }, "increase-thread" + i).start();

        }

小结

以上便是笔者关于6个库存管理线程进行库存操作的设计思路,即通过将商品的互斥操作进行水平拆分,从而降低6个线程间的冲突以提升程序的执行性能,当然这个问题可能还会涉及内存操作的拓展,例如假如我们的商品操作会从多个维度查询定位,为保证查询和扣减的性能,我们可能会将商品信息加载到内存中,此时我们就需要通过redis
setNx
实现不同的商品的库存扣减操作,这其中我们就需要考虑如下问题:

  1. setNx
    设置有效期避免程序意外终止导致死锁问题。
  2. setNx
    失败后线程的重试机制。
  3. 内存操作时对于库存操作的双写一致性问题。

本文到此结束,希望对你有帮助,感谢您的支持。

我是 sharkchiliCSDN Java 领域博客专家开源项目—JavaGuide contributor,我想写一些有意思的东西,希望对你有帮助,如果你想实时收到我写的硬核的文章也欢迎你关注我的公众号: 写代码的SharkChili 。 因为近期收到很多读者的私信,所以也专门创建了一个交流群,感兴趣的读者可以通过上方的公众号获取笔者的联系方式完成好友添加,点击备注  “加群”  即可和笔者和笔者的朋友们进行深入交流。

参考

无锁缓存,每秒10万并发,究竟如何实现?:https://mp.weixin.qq.com/s?__biz=MjM5ODYxMDA5OQ==&mid=2651965031&idx=1&sn=7af2b34d20e8999ff29e4a0d0bca4a73&chksm=bd2d73bb8a5afaada3500d64044c8983bb409b9496314be81775c797174636a2cfd84bb11d50&scene=21&poc_token=HHekGmaj8BOFRuv_YL3vXHHvWgD2D4X-P9KCmaAm


文章转载自写代码的SharkChili,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论