乐观锁+Redis缓存实现商品下单减库存【SpringCloud系列17】

SpringCloud 大型系列课程正在制作中,欢迎大家关注与提意见。

本文章是系列文章中的一篇

1 下单减库存 V1 无锁

并发情况下,库存一塌糊涂

     /**
     * 创建订单
     *
     * @param goodsId 商品ID
     * @param userId  用户ID
     * @return
     * @throws Exception
     */
    @Override
    @Transactional
    public OrderInfo createWrongOrder(Long goodsId, Long userId) throws Exception {

        OrderInfo orderInfo = null;
        // 数据库校验库存
        GoodsInfo goodsInfo = checkStock(goodsId);
        // 扣库存(无锁)
        boolean b = saleStock(goodsInfo);
        if (!b) {
            throw new RuntimeException("库存不足 减库存失败");
        }
        log.info("扣库存完成");
        // 生成订单
        orderInfo = createOrder(goodsInfo, userId);
        log.info("下单完成");
        
        return orderInfo;
    }

    private GoodsInfo checkStock(Long sid) throws Exception {
        //查询商品详情
        GoodsInfo goodsInfo = goodsInfoService.getGoodsInfoById(sid);
        log.info("当前库存 {}", goodsInfo.getGoodsStock());
        if (goodsInfo.getGoodsStock() < 1) {
            throw new RuntimeException("库存不足");
        }
        return goodsInfo;
    }

    /**
     * 更新库存
     *
     * @param goodsInfo
     * @return
     */
    private boolean saleStock(GoodsInfo goodsInfo) {
        goodsInfo.setGoodsSale(goodsInfo.getGoodsSale() + 1);
        goodsInfo.setGoodsStock(goodsInfo.getGoodsStock() - 1);
        return goodsInfoService.updateStockById(goodsInfo);
    }

10个商品库存,1秒100个并发下单测试,结果是库存正常减为0,订单创建了88个,严重超卖。
在这里插入图片描述

如下图是JMeter压测
在这里插入图片描述
其中更新库存的实现如下

@Mapper
public interface GoodsInfoMapper extends BaseMapper<GoodsInfo> {
    // 扣库存 Mapper 文件
    @Update("UPDATE tb_goods SET goods_stock = #{goodsStock, jdbcType = INTEGER},  goods_sale = #{goodsSale,jdbcType = INTEGER} WHERE id = #{id, jdbcType = INTEGER}")
    boolean updateStockById(GoodsInfo goodsInfo);
}

1.1 synchronized(同步锁) 悲观锁
   @Override
    @Transactional
    public synchronized OrderInfo createWrongOrder(Long goodsId, Long userId) throws Exception {

        OrderInfo orderInfo = null;
        // 数据库校验库存
        GoodsInfo goodsInfo = checkStock(goodsId);
        // 扣库存(无锁)
        boolean b = saleStock(goodsInfo);
        if (!b) {
            throw new RuntimeException("库存不足 减库存失败");
        }
        log.info("扣库存完成");
        // 生成订单
        orderInfo = createOrder(goodsInfo, userId);
        log.info("下单完成");

        return orderInfo;
    }

10个商品库存,1秒100个并发下单测试,结果是库存正常减为0,订单创建了13个,出现超卖。

1.2 MySql 乐观锁实现

直接在减库存的时候来判断

@Mapper
public interface GoodsInfoMapper extends BaseMapper<GoodsInfo> {
    // 扣库存 Mapper 文件
    @Update("UPDATE tb_goods SET goods_stock =goods_stock-1,goods_sale=goods_sale+1, version = version+1 WHERE id = #{id, jdbcType = INTEGER} AND version = #{version, jdbcType = INTEGER}")
    boolean updateStockById(GoodsInfo goodsInfo);
}

每执行一次更新,version就会+1,库存更新成功后,才会继续创建订单。
在这里插入图片描述
压测如下:
10个商品库存,1秒100个并发下单测试,结果是库存正常减为0,订单创建了10个,正常,中间并发的请求会下单失败,
在这里插入图片描述
启动订单服务两个负载来压测,也可以正常下单,正常减库存
在这里插入图片描述

2. Redis 计数限流

在上述的压力测试中,1个商品10 个库存,有 1000 个并发秒杀请求,最终只有 10 个订单会成功创建,也就是说有 990 的请求是无效的,这些无效的请求也会给数据库带来压力,因此可以在在请求落到数据库之前就将无效的请求过滤掉,将并发控制在一个可控的范围,这样落到数据库的压力就小很多。

所以对下单接口可以出现的大量请求常常是在秒杀场景中,所以可以针对这个接口进行一下限流:

对于每个没有被过滤掉的请求,都会去数据库查询库存来判断库存是否充足,对于这个查询可以放在缓存 Redis 中,Redis 的数据是存放在内存中的,速度快很多。

对于普通商品来说,在创建商品以及修改商品时,就可以将商品信息以及库存同步到Redis中。

对于秒杀商品来说,在秒杀前几分钟,通过定时任务将秒杀商品以及库存同步到Redis中。

3 乐观锁+Redis 实现下单减库存

缓存和数据一致性 ,缓存和 数据库(DB) 的一致性是一个讨论很多的问题

3.1 先更新数据库,再更新缓存策略

假设 A、B 两个线程,A 成功更新数据,在要更新缓存时,A 的时间片用完了,B 更新了数据库接着更新了缓存,这是 CPU 再分配给 A,则 A 又更新了缓存,这种情况下缓存中就是脏数据,具体逻辑如下图所示:
在这里插入图片描述
解决方法就是:缓存不做更新,仅做删除,先更新数据库再删除缓存。
A 更新了数据库,还没来得及删除缓存,B 又更新了数据库,接着删除了缓存,然后 A 删除了缓存,这样只有下次缓存未命中时,才会从数据库中重建缓存,避免了脏数据。

这种方式,由于每次都删除缓存,因此导致多次缓存都不能命中,能命中缓存的次数很少,因此删除这种方案并不可取,效率也并不是很高。

实际可以使用的是 先更新数据库再更新缓存,更新数据库使用乐观锁,更新Redis缓存结合Redis事务。

2 商品信息添加到 Redis 中

我这里是测试使用,所以专门写了一个接口来将商品信息以及库存信息加载到Redis中,常量Key定义如下:

public class Constant {
    public static final String GOOOD_STOCK = "STOCK_COUNT:";//库存
    public static final String GOOOD_SALE= "STOCK_SALE:";//销量
    public static final String GOOOD_VERSION = "STOCK_VERSION:";//版本
    public static final String GOODS_NAME = "GOODS_NAME:";//商品名称
    public static final String GOODS_INFO ="GOODS_INFO:";//商品
}

接下来就是商品信息对应的数据库的数据的基本操作,大家可以查看文章底部的源码。

  • GoodsInfo 是商品信息
  • GoodsInfoMapper 是商品信息操作Mapper
@Service
@Slf4j
public class GoodsInfoServiceImple extends ServiceImpl<GoodsInfoMapper, GoodsInfo> implements GoodsInfoService {

    @Resource
    GoodsInfoMapper goodsInfoMapper;
    @Resource
    RedisService redisService;

    /**
     * 测试使用将商品数据加载到redis中
     *
     * @param sid
     */
    @Override
    public void testRedisLoadGoods(Long sid) {
        GoodsInfo goodsInfo = this.getById(sid);
        if (goodsInfo == null) {
            return;
        }
        Long goodsId = goodsInfo.getId();
        redisService.set(Constant.GOOOD_STOCK + goodsId, goodsInfo.getGoodsStock());
        redisService.set(Constant.GOOOD_SALE + goodsId, goodsInfo.getGoodsSale());
        redisService.set(Constant.GOOOD_VERSION + goodsId, goodsInfo.getVersion());
        redisService.set(Constant.GOODS_NAME + goodsId, goodsInfo.getGoodsName());
        redisService.set(Constant.GOODS_INFO + goodsId, goodsInfo);
    }

}

保存在Redis中的数据如下(这里使用的RDM工具连接的Redis服务)

在这里插入图片描述

然后就是实现下单的逻辑

  • 第一步 校验库存,从 Redis 中获取
  • 第二步 乐观锁更新 MySql 库存
  • 第三步 Redis事务更新Redis缓存
  • 第四步 创建订单
@Service
@Slf4j
public class OrderServiceImpl extends ServiceImpl<OrderMapper, OrderInfo> implements OrderService {

    @Autowired
    SnowFlakeCompone snowFlakeCompone;

    /**
     * redis 分布式事务
     */
    @Resource
    RedisService redisService;

    /**
     * 乐观锁更新库存和Redis
     * @param goodsId 商品ID
     * @param userId 用户ID
     * @return
     * @throws Exception
     */
    @Override
    public OrderInfo createOrderWithLimitAndRedis(Long goodsId, Long userId) throws Exception {
        // 校验库存,从 Redis 中获取
        GoodsInfo goodsInfo = checkStockWithRedis(goodsId);
        // 乐观锁更新库存和Redis
        saleStockOptimsticWithRedis(goodsInfo);
        // 创建订单
        OrderInfo res = createOrder(goodsInfo, userId);
        return res;
    }

    // Redis 中校验库存
    private GoodsInfo checkStockWithRedis(Long goodsId) throws Exception {
        int count = Integer.parseInt(redisService.get(Constant.GOOOD_STOCK + goodsId).toString());
        int sale = Integer.parseInt(redisService.get(Constant.GOOOD_SALE + goodsId).toString());
        int version = Integer.parseInt(redisService.get(Constant.GOOOD_VERSION + goodsId).toString());
        String goodsName = redisService.get(Constant.GOODS_NAME + goodsId).toString();
        if (count < 1) {
            log.info("库存不足");
            throw new RuntimeException("库存不足 Redis currentCount: " + sale);
        }
        GoodsInfo stock = new GoodsInfo();
        stock.setId(goodsId);
        stock.setGoodsStock(count);
        stock.setGoodsSale(sale);
        stock.setVersion(version);
        stock.setGoodsName(goodsName);

        return stock;
    }


    // 更新 DB 和 Redis
    private void saleStockOptimsticWithRedis(GoodsInfo goodsInfo) throws Exception {
        boolean res = goodsInfoService.updateStockByOptimistic(goodsInfo);
        if (!res) {
            throw new RuntimeException("并发更新库存失败");
        }
        // 更新 Redis
        redisService.updateStockWithRedis(goodsInfo);
    }

}

Jemter 测试,1秒2000个并发,订单服务两个负载:
在这里插入图片描述
在这里插入图片描述
18秒处理完这2000个请求,对于用户量小、普通下单来讲的开发这样是可以的,秒杀也可以,只不过是在此基础上添加上限流、Redis校验用户是否秒杀过就可以。

商品乐观锁更新库存的核心代码如下:

@Mapper
public interface GoodsInfoMapper extends BaseMapper<GoodsInfo> {

    //乐观锁更新数据库
    @Update("UPDATE tb_goods SET goods_stock =goods_stock-1,goods_sale=goods_sale+1, version = version+1 WHERE id = #{id, jdbcType = INTEGER} AND version = #{version, jdbcType = INTEGER}")
    boolean updateStockByOptimistic(GoodsInfo goodsInfo);
}

分布式锁更新Redis缓存核心代码如下:

/**
 * Redis操作Service实现类
 */
@Service
@Slf4j
public class RedisServiceImpl implements RedisService {
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    ...其他方法
    
    //Redis - Jedis连接池
    @Autowired
    private JedisPool jedisPool;
    @Override
    public void updateStockWithRedis(GoodsInfo goodsInfo) {
        // Redis 多个写入操作的事务
        Jedis jedis = null;
        try {
            jedis = jedisPool.getResource();
            // 开始事务
            Transaction transaction = jedis.multi();
            // 事务操作
            this.decr(Constant.GOOOD_STOCK+ goodsInfo.getId(),1);
            this.incr(Constant.GOOOD_SALE + goodsInfo.getId(),1);
            this.incr(Constant.GOOOD_VERSION+ goodsInfo.getId(),1);
            // 结束事务
            List<Object> list = transaction.exec();
        } catch (Exception e) {
            log.error("updateStock 获取 Jedis 实例失败:", e);
        } finally {
            if (jedis != null) {
                jedis.close();
            }

        }
    }
}

最后就是源码了:
本项目 SpringCloud 源码 https://gitee.com/android.long/spring-cloud-biglead/tree/master/biglead-api-11-admin
本项目 管理后台web 源码https://gitee.com/android.long/spring-cloud-biglead/tree/master/mall-admin-web-master
本项目 小程序 源码https://gitee.com/android.long/spring-cloud-biglead/tree/master/mall-app-web-master
如果有兴趣可以关注一下公众号 biglead ,每周都会有 java、Flutter、小程序、js 、英语相关的内容分享

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

早起的年轻人

创作源于分享

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值