库存扣减设计和下单


前言

大家好,我是练习两年半的Java练习生,前面我们已经介绍了领域驱动和缓存设计,这一章我们将介绍秒杀设计过程中比较重要的一个部分:关于库存扣减。我们将会从一些库存设计的一些原则和方案入手,介绍有哪些方案,进行对比,然后再分析秒杀系统中具体的下单代码,下单代码中就涉及到库存的扣减。让我们一起来看看吧~


正文

库存设计原则

  • 渠道隔离:秒杀活动中使用的库存应当按渠道进行隔离,这样既能保证不对正常售卖渠道产生影响,有利于精细化运作库存管理。比如,根据用户群体或平台支持的力度不同,我们可能需要在不同渠道透出不同的库存数量,并且它们与正常售卖渠道分离,这种场景下就需要渠道隔离;
  • 防止超卖:对业务来说,超卖可能意味着资损;对技术来说,超卖意味着架构的失败。试想,原价999元商品的秒杀价为599,库存100件却卖出了10000件,那么我们就会面临严重的客诉或资损;
  • 防止重复扣减:与超卖相对的是没有卖出去,其同样不可小觑。比如,10000件的库存仅有10人成单,库存明明还在却显示已经售罄,活动未到达预期,前期准备和推广的资金投入都打了水票,而由系统设计缺陷造成的重复扣减 就会导致这种糟糕的情况发生;
  • 高性能:在前面的文章我们谈到了如何通过缓存提高秒杀架构中的”读“性能,殊不知”写“性能也是秒杀架构的重要指标之一。举例来说,10000比订单,每秒写入300单和每秒写入3000单在用户体验上有着显著的差异。

常见库存扣减方案

(一)基于数据库的库存扣减方案:

直接更新:在数据库中直接更新库存字段的数值来进行扣减操作。这是一种简单直接的方案,但在高并发情况下可能存在并发冲突的问题。
悲观锁:使用数据库的悲观锁机制,在进行库存扣减操作时,锁定对应的库存记录,防止其他线程同时进行修改。通过数据库的锁机制确保一次只有一个线程能够进行库存扣减操作。
乐观锁:使用数据库的乐观锁机制,每次进行库存扣减操作时,先读取当前库存的版本号,然后在更新库存时检查版本号是否一致。乐观锁方案通常使用版本号、时间戳等机制来实现,避免了悲观锁的性能开销。

(二)基于缓存的库存扣减方案:
缓存预减:在缓存中存储库存的剩余数量,每次进行扣减操作时,先从缓存中读取当前库存数量,然后在缓存中更新扣减后的库存值。这种方案可以减少对数据库的访问次数和并发压力。
分布式锁 + 缓存:结合分布式锁和缓存,使用分布式锁确保在同一时刻只有一个线程能够进行库存扣减操作,同时使用缓存提高读取性能。

(三)分库分表库存扣减方案:
对于大规模的系统,可以采用分库分表的方式来进行库存扣减操作,将库存数据分散存储在多个数据库或数据表中,以提高系统的并发性能和扩展性。
当然,相较于前两种方案,虽然分库分表的优势明显,但具有更高的复杂性和实现成本。在并发量不是很高的情况下,不推荐使用这种方式。这部分会在后续文章中介绍。

(四)常用库存扣减方式

  • 下单扣库存:优势在于简单,链路短,性能好,缺点在于容易被恶意下单。活动刚开始,可能即被恶意下单清空库存;
  • 支付扣库存:优势在于可以控制恶意下单,最后得到库存的都是有效订单。当然,其缺点也较为明显,无法控制下单人数,用户需要在支付时再次确认库存;
  • 下单预扣库存,超时取消:相较于前两种方式,这种方式较为折中且有效,对于正常下单的用户来说抢单即是得到,对于恶意下单的来说,占据的库存会超时自动释放。

我们在这里会介绍 数据库 + 缓存得综合方案。在库存扣减方式上,我们采取的是下单扣库存,因为这种方式比较快。此外,对于恶意下单的用户,我们也可以通过安全风控和策略来进行识别和屏蔽。

秒杀订单域设计

整体服务

领域模型

package com.actionworks.flashsale.domain.model.entity;

import com.alibaba.fastjson.JSON;
import lombok.Data;

import java.io.Serializable;
import java.util.Date;

@Data
public class FlashOrder implements Serializable {

    /**
     * 订单ID , 雪花算法生成
     */
    private Long id;
    /**
     * 商品ID
     */
    private Long itemId;
    /**
     * 秒杀品标题
     */
    private String itemTitle;
    /**
     * 秒杀价
     */
    private Long flashPrice;
    /**
     * 活动ID
     */
    private Long activityId;
    /**
     * 下单商品数量
     */
    private Integer quantity;
    /**
     * 总金额
     */
    private Long totalAmount;
    /**
     * 订单状态
     */
    private Integer status;

    /**
     * 用户ID
     */
    private Long userId;

    /**
     * 订单创建时间
     */
    private Date createTime;

    @Override
    public String toString() {
        return JSON.toJSONString(this);
    }

    public boolean validateParamsForCreate() {
        if (itemId == null
                || activityId == null
                || quantity == null || quantity <= 0
                || totalAmount == null || totalAmount < 0) {
            return false;
        }
        return true;
    }
}

领域服务

  • 下单:订单域的核心服务;
  • 根据用户获取订单:当前用户可以获取个人所创建过的订单;
  • 根据ID获取订单详情:当前用户可以通过订单ID查看订单详情;
  • 根据ID取消订单:当用户不再需要订单时,可以根据ID执行取消操作。当然,是否能取消成功,要看具体的规则。

领域事件之下单

下单整体流程

下单的整理流程如下:

代码如下:

public class DefaultFlashOrderAppService implements FlashOrderAppService {
    private static final Logger logger = LoggerFactory.getLogger(DefaultFlashOrderAppService.class);
    private static final String PLACE_ORDER_LOCK_KEY = "PLACE_ORDER_LOCK_KEY";

    @Resource
    private FlashOrderDomainService flashOrderDomainService;
    @Resource
    private StockDeductionDomainService stockDeductionDomainService;
    @Resource
    private ItemStockCacheService itemStockCacheService;
    @Resource
    private DistributedLockFactoryService lockFactoryService;
    @Resource
    private SecurityService securityService;
    @Resource
    private PlaceOrderService placeOrderService;

    @Override
    @Transactional
    public AppSimpleResult<PlaceOrderResult> placeOrder(Long userId, FlashPlaceOrderCommand placeOrderCommand) {
        logger.info("placeOrder|下单|{},{}", userId, JSON.toJSONString(placeOrderCommand));

        // 检查userId和placeOrderCommand的有效性
        if (userId == null || placeOrderCommand == null || !placeOrderCommand.validateParams()) {
            throw new BizException(INVALID_PARAMS);
        }

        // 根据userId生成锁的键值
        String placeOrderLockKey = getPlaceOrderLockKey(userId);

        // 从锁工厂服务获取分布式锁
        DistributedLock placeOrderLock = lockFactoryService.getDistributedLock(placeOrderLockKey);

        try {
            // 尝试在5秒内获取锁,如果获取失败则返回失败结果
            boolean isLockSuccess = placeOrderLock.tryLock(5, 5, TimeUnit.SECONDS);
            if (!isLockSuccess) {
                return AppSimpleResult.failed(FREQUENTLY_ERROR.getErrCode(), FREQUENTLY_ERROR.getErrDesc());
            }

            // 使用安全服务进行风险检查
            boolean isPassRiskInspect = securityService.inspectRisksByPolicy(userId);
            if (!isPassRiskInspect) {
                logger.info("placeOrder|综合风控检验未通过|{}", userId);
                return AppSimpleResult.failed(PLACE_ORDER_FAILED);
            }

            // 调用placeOrderService执行实际的下单操作
            PlaceOrderResult placeOrderResult = placeOrderService.doPlaceOrder(userId, placeOrderCommand);
            if (!placeOrderResult.isSuccess()) {
                return AppSimpleResult.failed(placeOrderResult.getCode(), placeOrderResult.getMessage());
            }

            logger.info("placeOrder|下单完成|{}", userId);
            return AppSimpleResult.ok(placeOrderResult);
        } catch (Exception e) {
            logger.error("placeOrder|下单失败|{},{}", userId, JSON.toJSONString(placeOrderCommand), e);
            return AppSimpleResult.failed(PLACE_ORDER_FAILED);
        } finally {
            placeOrderLock.unlock();
        }
    }

...
}

注意点:

  • 下单操作需要加对应的分布式锁。

分布式锁是为了解决分布式系统中的并发访问和数据一致性问题而引入的机制。在分布式系统中,多个节点同时访问共享资源时可能会导致数据不一致或竞态条件的问题。通过使用分布式锁,可以确保在同一时间只有一个节点能够访问和修改共享资源,从而保证数据的一致性和正确性。

同步下单

  • 下单有两种实现方式
    • 同步下单
    • 异步下单,

这里只介绍同步下单的方式,异步下单后续再介绍
image.png

同步下单的逻辑如下:

@Override
public PlaceOrderResult doPlaceOrder(Long userId, FlashPlaceOrderCommand placeOrderCommand) {
    logger.info("placeOrder|开始下单|{},{}", userId, JSON.toJSONString(placeOrderCommand));
    
    // 检查userId和placeOrderCommand的有效性
    if (userId == null || placeOrderCommand == null || !placeOrderCommand.validateParams()) {
        throw new BizException(INVALID_PARAMS);
    }
    
    // 检查秒杀活动是否允许下单
    boolean isActivityAllowPlaceOrder = flashActivityAppService.isAllowPlaceOrderOrNot(placeOrderCommand.getActivityId());
    if (!isActivityAllowPlaceOrder) {
        logger.info("placeOrder|秒杀活动下单规则校验未通过|{},{}", userId, placeOrderCommand.getActivityId());
        return PlaceOrderResult.failed(PLACE_ORDER_FAILED);
    }
    
    // 检查秒杀商品是否允许下单
    boolean isItemAllowPlaceOrder = flashItemAppService.isAllowPlaceOrderOrNot(placeOrderCommand.getItemId());
    if (!isItemAllowPlaceOrder) {
        logger.info("placeOrder|秒杀品下单规则校验未通过|{},{}", userId, placeOrderCommand.getActivityId());
        return PlaceOrderResult.failed(PLACE_ORDER_FAILED);
    }
    
    // 获取秒杀商品信息
    AppSimpleResult<FlashItemDTO> flashItemResult = flashItemAppService.getFlashItem(placeOrderCommand.getItemId());
    if (!flashItemResult.isSuccess() || flashItemResult.getData() == null) {
        return PlaceOrderResult.failed(ITEM_NOT_FOUND);
    }
    FlashItemDTO flashItem = flashItemResult.getData();
    
    // 生成订单号并创建待下单的FlashOrder对象
    Long orderId = orderNoGenerateService.generateOrderNo(new OrderNoGenerateContext());
    FlashOrder flashOrderToPlace = toDomain(placeOrderCommand);
    flashOrderToPlace.setItemTitle(flashItem.getItemTitle());
    flashOrderToPlace.setFlashPrice(flashItem.getFlashPrice());
    flashOrderToPlace.setUserId(userId);
    flashOrderToPlace.setId(orderId);
    
    // 创建库存扣减对象
    StockDeduction stockDeduction = new StockDeduction()
            .setItemId(placeOrderCommand.getItemId())
            .setQuantity(placeOrderCommand.getQuantity())
            .setUserId(userId);
    
    boolean preDecreaseStockSuccess = false;
    try {
        // 预扣减库存
        preDecreaseStockSuccess = itemStockCacheService.decreaseItemStock(stockDeduction);
        if (!preDecreaseStockSuccess) {
            logger.info("placeOrder|库存预扣减失败|{},{}", userId, JSON.toJSONString(placeOrderCommand));
            return PlaceOrderResult.failed(PLACE_ORDER_FAILED.getErrCode(), PLACE_ORDER_FAILED.getErrDesc());
        }
        
        // 真正扣减库存
        boolean decreaseStockSuccess = stockDeductionDomainService.decreaseItemStock(stockDeduction);
        if (!decreaseStockSuccess) {
            logger.info("placeOrder|库存扣减失败|{},{}", userId, JSON.toJSONString(placeOrderCommand));
            return PlaceOrderResult.failed(PLACE_ORDER_FAILED.getErrCode(), PLACE_ORDER_FAILED.getErrDesc());
        }
        
        // 下单操作
        boolean placeOrderSuccess = flashOrderDomainService.placeOrder(userId, flashOrderToPlace);
        if (!placeOrderSuccess) {
            throw new BizException(PLACE_ORDER_FAILED.getErrDesc());
        }
    } catch (Exception e) {
        // 下单过程中出现异常
        if (preDecreaseStockSuccess) {
            // 如果预扣减库存成功,则尝试恢复库存
            boolean recoverStockSuccess = itemStockCacheService.increaseItemStock(stockDeduction);
            if (!recoverStockSuccess) {
                logger.error("placeOrder|预扣库存恢复失败|{},{}", userId, JSON.toJSONString(placeOrderCommand), e);
            }
        }
        logger.error("placeOrder|下单失败|{},{}", userId, JSON.toJSONString(placeOrderCommand), e);
        throw new BizException(PLACE_ORDER_FAILED.getErrDesc());
    }
    
    logger.info("placeOrder|下单成功|{},{}", userId, orderId);
    return PlaceOrderResult.ok(orderId);
}

同步下单中里面需要关注两个点:

  • 预扣减库存
  • 真正的库存扣减

库存预扣减

为什么需要预扣减库存呢?
前面已经分析过扣减库存几种方案了,忘记了可以回头看看

那么预扣减缓存是如何实现的呢?
其实这里也有两种方式。

  • 单数据库扣减缓存
  • 分库分桶扣减缓存

这里也只介绍单数据库扣减缓存的方式
下面这段代码就是预扣减的一个逻辑,其中受用到Lua脚本进行一个库存扣减。使用Lua脚本有什么好处呢?
使用 Lua 脚本的主要原因是实现原子性操作和减少网络开销。下面是 Lua 脚本的一些优点和使用场景:
原子性操作:Lua 脚本在 Redis 服务器端执行,以原子方式执行多个 Redis 命令,保证这些命令在执行期间不会被其他客户端的请求中断。这对于需要保持数据的一致性和避免并发竞争条件非常重要。
减少网络开销:通过将多个操作打包成一个脚本,在一次网络往返中执行多个命令,减少了网络传输的开销。相比于客户端单独发送多个命令,使用 Lua 脚本可以显著降低网络延迟,提高系统的性能和响应速度。
原子性扣减操作:对于一些复杂的操作,如库存扣减、分布式锁的获取释放等,使用 Lua 脚本可以保证这些操作的原子性。通过在脚本中执行多个命令,将它们作为一个单独的操作单元进行处理,避免了并发下的竞态条件和数据不一致性。
服务器端执行:Lua 脚本在 Redis 服务器端执行,减少了客户端的负担和复杂性。客户端只需发送一次脚本执行的请求,而无需关心具体的操作细节和顺序。
代码复用和封装:使用 Lua 脚本可以将一组 Redis 命令封装为一个可复用的脚本,简化了客户端代码的编写和维护。脚本可以在不同的场景中多次使用,提高了代码的可维护性和可重用性。

@Override
    public boolean decreaseItemStock(StockDeduction stockDeduction) {
        logger.info("decreaseItemStock|申请库存预扣减|{}", JSON.toJSONString(stockDeduction));

        // 检查stockDeduction对象的有效性
        if (stockDeduction == null || !stockDeduction.validate()) {
            return false;
        }

        try {
            // 构建缓存键
            String key1ItemStocksCacheKey = getItemStocksCacheKey(stockDeduction.getItemId());
            String key2ItemStocksCacheAlignKey = getItemStocksCacheAlignKey(stockDeduction.getItemId());
            List<String> keys = Lists.newArrayList(key1ItemStocksCacheKey, key2ItemStocksCacheAlignKey);

            // 使用Lua脚本进行库存扣减
            DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(DECREASE_ITEM_STOCK_LUA, Long.class);
            Long result = null;
            long startTime = System.currentTimeMillis();

            // 进行循环,直到得到结果或超过时间限制
            while ((result == null || result == IN_STOCK_ALIGNING) && (System.currentTimeMillis() - startTime) < 1500) {
                // 调用Redis的execute方法执行Lua脚本,传入键和扣减数量作为参数
                result = redisCacheService.getRedisTemplate().execute(redisScript, keys, stockDeduction.getQuantity());

                if (result == null) {
                    logger.info("decreaseItemStock|库存扣减失败|{}", key1ItemStocksCacheKey);
                    return false;
                }

                if (result == IN_STOCK_ALIGNING) {
                    logger.info("decreaseItemStock|库存校准中|{}", key1ItemStocksCacheKey);
                    Thread.sleep(20);
                }

                if (result == -1 || result == -2 || result == -3) {
                    logger.info("decreaseItemStock|库存扣减失败|{}", key1ItemStocksCacheKey);
                    return false;
                }

                if (result == 1) {
                    logger.info("decreaseItemStock|库存扣减成功|{}", key1ItemStocksCacheKey);
                    return true;
                }
            }
        } catch (Exception e) {
            logger.error("decreaseItemStock|库存扣减失败", e);
            return false;
        }

        return false;
    }

对应的Lua脚本

if (redis.call('exists', KEYS[2]) == 1) then
  return -9;
end;
if (redis.call('exists', KEYS[1]) == 1) then
    local stock = tonumber(redis.call('get', KEYS[1]));
    local num = tonumber(ARGV[1]);
    if (stock < num) then
        return -3;
    end;
    if (stock >= num) then
        redis.call('incrby', KEYS[1], 0 - num);
        return 1;
    end;
    return -2;
end;
return -1;

好了,到这里预扣减库存完成了,那么我们就应该执行真正的库存扣减了
这里也是有分库分表的方式,但本文中只讲最基本的方式

库存扣减

代码:

@Override
    public boolean decreaseItemStock(StockDeduction stockDeduction) {
        if (stockDeduction == null || stockDeduction.getItemId() == null || stockDeduction.getQuantity() == null) {
        throw new DomainException(PARAMS_INVALID);
    }
return flashItemRepository.decreaseItemStock(stockDeduction.getItemId(), stockDeduction.getQuantity());
}

库存扣减的mapper:

<update id="decreaseItemStock" parameterType="com.actionworks.flashsale.persistence.model.FlashItemDO">
  UPDATE flash_item
  SET modified_time   = now(),
  available_stock = available_stock - #{quantity}
  where id = #{itemId}
  and available_stock <![CDATA[ >= ]]>  #{quantity}
</update>

这里要注意要保证不会扣减负库存,所以得保证有库存得前提下才进行库存扣减

但这段sql还是有问题的。
如果你对秒杀或高并发架构有所了解的话,可能会发现这句SQL并不完美。问题在于,它不是幂等的,在某些特殊情况下会发生重复扣减,这就违背本章节开篇的基本原则。那么,什么情况下会发生重复扣减?业务侧的代码不严谨地重试或底层的重试都会造成重复扣减。针对这个问题,有两个解决方案。
一是用设置库存取代扣库存,也就是将剩余库存在外部计算出来,并设置到数据库中,这样SQL就是幂等的:

<update id="decreaseItemStock" parameterType="com.actionworks.flashsale.persistence.model.FlashItemDO">
    UPDATE flash_item
    SET modified_time = now(),
    available_stock = #{newAvailableStock}
    stock_version = #{newStockVersion}
    where id = #{itemId} and stock_version = #{oldStockVersion}
</update>

二是通过CAS完成库存扣减,即在扣减库存的时候加上原始值。我们知道CAS是高效的无锁更新方式,在Java中有广泛应用,那么我们写个简单CAS:

<update id="decreaseItemStock" parameterType="com.actionworks.flashsale.persistence.model.FlashItemDO">
    UPDATE flash_item
    SET modified_time = now(),
    available_stock = available_stock - #{quantity}
    where id = #{itemId} and available_stock = #{oldAvailableStock}
</update>

虽然这两种方式能解决异常情况下的重复扣减问题,但服务端代码侧的复杂度也会相应地增加很多。

额外讲一下
关于事务@Transactional的作用和用法
作用:

  • 定义事务边界:@Transactional 注解将方法或类标记为一个事务,指定了方法或类内部的操作应该在一个单独的事务中执行。
  • 提供事务管理:@Transactional 注解使 Spring 能够自动管理事务的生命周期、事务的提交和回滚,以及异常处理。

当运行时候有未处理的异常抛出,那么会触发事务的回滚。
这部分在八股里面也应该会出现,需要大家关注


总结

好啦,以上就是本篇文章要介绍的所有内容,主要是将库存扣减的两种方式和下单的整体流程,认识秒杀设计的具体实现是怎么样的。如果大家还有什么问题
如果大家还有什么问题,欢迎私信或者在评论区提出来,让我们一起学习进步!或者加入我一起学习这个项目:https://github.com/jacky-curry/flash-sale
在这里插入图片描述


参考链接

https://juejin.cn/book/7008372989179723787/section/7020345200442605576

  • 0
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值