对数据库执行库存扣减存在的问题

本文涉及的产品
Redis 开源版,标准版 2GB
推荐场景:
搭建游戏排行榜
云数据库 Tair(兼容Redis),内存型 2GB
简介: 对数据库执行库存扣减存在的问题

公众号merlinsea


  • 背景


  • 以优惠券扣减为例,当用户使用购物的时候可以使用优惠券来抵消一部分金额,不过存在如下可能情况,用户可能在pc端,app端同时下单而且同时使用优惠券,如果用户有多个优惠券,那自然是可以使用的,但倘若用户只有一个优惠券的话,那么就会存在优惠券不足扣减优惠券失败的情况,即在高并发场景下可能存在扣减到优惠券数量已经为负数的情况。

  • 代码实现


  • 核心service层的代码


/**
 * 领取优惠券
 * 1、查询是否有这个优惠券
 * 2、校验优惠券是否可以领取 时间,库存,是否超领
 * 3、扣减库存
 * 4、保存记录
 * @param couponId
 * @param category
 * @return
 */
@Override
public JsonData addCoupon(long couponId, CouponCategoryEnum category) {
    LoginUser loginUser = LoginInterceptor.threadLocal.get();
    //查询coupon
    CouponDO couponDO = couponMapper.selectOne(new QueryWrapper<CouponDO>()
            .eq("id",couponId)
            .eq("category",category.name()));
    //校验coupon
    this.check(couponDO,loginUser.getId());
    //构建优惠券记录
    CouponRecordDO couponRecordDO = new CouponRecordDO();
    BeanUtils.copyProperties(couponDO,couponRecordDO);
    couponRecordDO.setUserId(loginUser.getId());
    couponRecordDO.setCreateTime(new Date());
    couponRecordDO.setUseState(CouponCategoryEnum.NEW_USER.name());
    couponRecordDO.setCouponId(couponId);
    couponRecordDO.setUserName(loginUser.getName());
    //扣减库存 TODO
    int rows = couponMapper.reduceStock(couponId);
    if(rows == 1){
        couponRecordMapper.insert(couponRecordDO);
    }else{
        //扣减库存失败
        log.info("扣减库存失败,用户id:{},优惠券id:{}",loginUser.getId(),couponId);
        throw  new BizException(BizCodeEnum.COUPON_NO_STOCK);
    }
    return JsonData.buildSuccess();
}
private void check(CouponDO couponDO,long userId) {
    //能否查到这个优惠券
    if (couponDO == null) {
        throw new BizException(BizCodeEnum.COUPON_NO_EXIST);
    }
    //库存是否足够
    if (couponDO.getStock() <= 0) {
        throw new BizException(BizCodeEnum.COUPON_NO_STOCK);
    }
    //是否是发布状态
    if (!couponDO.getPublish().equals(CouponPublishEnum.PUBLISH.name())) {
        throw new BizException(BizCodeEnum.COUPON_GET_FAIL);
    }
    //是否在领取的时间
    long time = CommonUtil.getCurrentTimestamp();
    long start = couponDO.getStartTime().getTime();
    long end = couponDO.getEndTime().getTime();
    if (time > end || time < start) {
        throw new BizException(BizCodeEnum.COUPON_OUT_OF_TIME);
    }
    //用户是否超出限制
    int num = couponRecordMapper.selectCount(new QueryWrapper<CouponRecordDO>()
            .eq("coupon_id", couponDO.getId())
            .eq("user_id", userId));
    if (num >= couponDO.getUserLimit()) {
        throw new BizException(BizCodeEnum.COUPON_OUT_OF_LIMIT);
    }
}


  • 扣减库存xml中的sql语句    
<update id="reduceStock">
    update coupon set stock = stock-1 where id=#{couponId}
</update>


  • 存在的问题
  • 只要请求到达数据库层就会执行库存减1,但显然当库存为0的时候是不能减1的,因此存在超发优惠券的问题。
  • 为什么会存在上面的问题呢?
  • 在上面这个xml中的sql语句和service层的业务逻辑中,可以发现只要用于的请求通过了check()逻辑,那么就一定会执行扣减库存的sql语句,但是由于check逻辑是非原子操作,因此在高并发的场景下很多线程会同时通过check逻辑,然后多个线程都会执行接下来的sql扣减库存语句导致优惠券超发问题!!


640.jpg


  • 扣减券过程的时序图

640.jpg


  • 解决方法1:同步代码块synchronized ,lock (集群部署情况下会失效
  • 这种方法在高并发场景下的实际工作中很少使用,因为syncronized和lock机制只能作用在一个jvm实例上(即本地锁),后续项目集群部署的话依旧不能解决优惠券超发问题!!!
  • 这种方法核心思路是把校验逻辑和实际扣减券的操作放在一个同步代码块中。


public synchronized void reduceCouponStock(long couponId ,Integer num)
{
    // 校验操作
    // 扣减操作
}


  • 解决方法2:分布式锁zookeeper或者redis
  • 分布式锁:有一个独立的服务器单独存放redis,每当一个节点要访问数据库时,首先去redis看看锁是否被其他节点获取,如果没有获取,那么就把锁置为已经获取的状态然后再去操作数据库,否则不能操作数据库。
  • 存在的问题是:过于笨重,虽然解决了高并发场景下数据一致性的问题,但是本来直接访问数据库就完事,现在需要先访问redis看看这个集中管理的锁是否空闲再决定是否访问数据库。

  • 解决方法3:通过数据库本身来控制(添加更多where条件)推荐!!!
  • 因为数据库有行级锁,我们在扣减库存的时候需要保证库存大于0才可以扣减库存。这样也保证了数据的一致性
  • 和之前的比较区别在于:现在是当有多个扣减库存的请求到达数据库层面时,由于行级锁的存在,必须一个一个来执行这条sql语句,如果之前请求扣减完库存以后库存小于0了,那么后续的阻塞于这一行的请求就不会扣减成功。 但之前的sql语句可以发现,只要请求到达了数据库这一层,那么就一定都会扣减库存。


<update id="reduceStock">
    update coupon set stock = stock-1 where id=#{couponId} and stock>0
</update>


  • 总结
  • 在操作数据库的时候,一定要注意我们这个操作是否符合实际情况,即如果任意一个请求到达这一步的时候,都可以执行这个操作,那么就可能存在不符合实际预期的情况,尤其是像这种扣减库存,是否会扣减到负值的情况的场景。
相关实践学习
基于Redis实现在线游戏积分排行榜
本场景将介绍如何基于Redis数据库实现在线游戏中的游戏玩家积分排行榜功能。
云数据库 Redis 版使用教程
云数据库Redis版是兼容Redis协议标准的、提供持久化的内存数据库服务,基于高可靠双机热备架构及可无缝扩展的集群架构,满足高读写性能场景及容量需弹性变配的业务需求。 产品详情:https://www.aliyun.com/product/kvstore &nbsp; &nbsp; ------------------------------------------------------------------------- 阿里云数据库体验:数据库上云实战 开发者云会免费提供一台带自建MySQL的源数据库&nbsp;ECS 实例和一台目标数据库&nbsp;RDS实例。跟着指引,您可以一步步实现将ECS自建数据库迁移到目标数据库RDS。 点击下方链接,领取免费ECS&amp;RDS资源,30分钟完成数据库上云实战!https://developer.aliyun.com/adc/scenario/51eefbd1894e42f6bb9acacadd3f9121?spm=a2c6h.13788135.J_3257954370.9.4ba85f24utseFl
相关文章
|
SQL 前端开发 druid
|
SQL 缓存 前端开发
|
12天前
|
关系型数据库 MySQL 数据库
Python处理数据库:MySQL与SQLite详解 | python小知识
本文详细介绍了如何使用Python操作MySQL和SQLite数据库,包括安装必要的库、连接数据库、执行增删改查等基本操作,适合初学者快速上手。
81 15
|
6天前
|
SQL 关系型数据库 MySQL
数据库数据恢复—Mysql数据库表记录丢失的数据恢复方案
Mysql数据库故障: Mysql数据库表记录丢失。 Mysql数据库故障表现: 1、Mysql数据库表中无任何数据或只有部分数据。 2、客户端无法查询到完整的信息。
|
13天前
|
关系型数据库 MySQL 数据库
数据库数据恢复—MYSQL数据库文件损坏的数据恢复案例
mysql数据库文件ibdata1、MYI、MYD损坏。 故障表现:1、数据库无法进行查询等操作;2、使用mysqlcheck和myisamchk无法修复数据库。
|
17天前
|
SQL 关系型数据库 MySQL
MySQL导入.sql文件后数据库乱码问题
本文分析了导入.sql文件后数据库备注出现乱码的原因,包括字符集不匹配、备注内容编码问题及MySQL版本或配置问题,并提供了详细的解决步骤,如检查和统一字符集设置、修改客户端连接方式、检查MySQL配置等,确保导入过程顺利。
|
25天前
|
关系型数据库 MySQL 数据库
GBase 数据库如何像MYSQL一样存放多行数据
GBase 数据库如何像MYSQL一样存放多行数据
|
1月前
|
SQL 关系型数据库 MySQL
12 PHP配置数据库MySQL
路老师分享了PHP操作MySQL数据库的方法,包括安装并连接MySQL服务器、选择数据库、执行SQL语句(如插入、更新、删除和查询),以及将结果集返回到数组。通过具体示例代码,详细介绍了每一步的操作流程,帮助读者快速入门PHP与MySQL的交互。
39 1
|
1月前
|
SQL 关系型数据库 MySQL
go语言数据库中mysql驱动安装
【11月更文挑战第2天】
52 4
|
1月前
|
监控 关系型数据库 MySQL
数据库优化:MySQL索引策略与查询性能调优实战
【10月更文挑战第27天】本文深入探讨了MySQL的索引策略和查询性能调优技巧。通过介绍B-Tree索引、哈希索引和全文索引等不同类型,以及如何创建和维护索引,结合实战案例分析查询执行计划,帮助读者掌握提升查询性能的方法。定期优化索引和调整查询语句是提高数据库性能的关键。
247 1
下一篇
DataWorks