深入Redis底层原理及其应用

75 阅读5分钟

这里以优惠券的购买场景为背景来介绍redis的应用方案和一些常见问题的解决方案,如:超卖一人一单集群模式下的线程安全问题

1.库存超卖问题

抢购优惠券是一个高并发的业务场景,大量的并发请求可能导致库存超卖的问题,即100份优惠券卖出超过100份数据库中库存为负,对于库存超卖一般可以使用锁来解决,常用乐观锁/悲观锁

乐观锁

乐观锁的思想是不主动去加锁,不需要真正的锁,仅通过代码逻辑便可以实现,我们在操作数据库的时候会对比操作数据库之前和操作数据库时数据的状态,若是不同则表明期间有其他线程操作该数据,此时线程不安全。

  • 优点:性能好,执行快
  • 缺点:获取锁失败率高可能导致商品卖出很少的情况,如同一时间内1000份商品1000个线程来购买只能卖出200份的情况

悲观锁

悲观锁的思想是我们默认这个业务会发生线程安全问题,直接给业务上锁,可以用synchronized,但是这就带来了一些问题,就是性能很差,等于说所有的线程都是串行执行的。

  • 优点:实现简单粗暴
  • 缺点:性能差

解决库存超卖方案

我们使用乐观锁来实现库存超卖,因为在高并发场景下相较于悲观锁有明显优势,具体实现方案就是,在用户下单前获取一次库存1,然后修改数据库扣减库存的时候带上一个条件 当前库存==库存1,其实可以简化这个地方就是直接判断库存大于0就可以了,因为我们要解决的是超卖问题,不超卖就行。

2.一人一单问题

一些优惠力度大的优惠券一般没人限购一张,假设有用户在购买时快速多次发起购买请求,那么就有可能一个用户下单多张优惠券,为什么呢? 首先要了解这个业务流程:

  • 用户下单
  • 查询数据库是否有关联该用户和该优惠券的订单
    • 若没有则下单
    • 若有则拒绝请求

看到这个业务的问题了吗? ——先查数据库,再修改,如果多个线程同时到达,在第一个线程成功下单(创建好关联用户和优惠券的订单)之前,多个线程查询订单数据库的结果都将是空,符合下单条件,这个时候不符合一人一单的要求。

解决一人一单问题

这个时候可以使用悲观锁,在下单时加锁,在单体架构项目中我们根据用户id作为锁可以保证互斥性,这时候问题又来了,synchronized 仅能保证单个 Tomcat 进程内的线程同步,无法跨节点协调,分布式架构集群模式下该怎么办,这时候终于要引入我们的redis了,redis可以作为分布式锁的实现方案,因为redis是独立于tomcat服务器的外部服务,集群模式下的tomcat都可以访问到redis。

3.集群模式下的线程安全问题

刚刚说到synchronized仅能解决单体模式下的一人一单问题,集群模式下依然会有线程问题,synchronized没办法保证两个tomcat服务器锁的互斥性,如果一个用户连下两单分别被nginx负载到两台tomcat上就会导致一个用户下两单,不符合我们的业务需求。

注意事项

仔细想一想,这个锁可以一直被某个线程持有吗?当然不可以,如果这个线程因为意外挂掉了,锁没有释放,那后面的线程都没办法获取锁,整个业务就挂掉了,这就产生死锁了,显然是不合理的,我们需要为整个锁加上过期时间,防止某个线程长时间持有锁

但是锁的过期时间又要如何设置,长了会造成过量等待,短了的话业务没执行完这个锁就释放了,这怎么办呢?其实可以用到看门狗机制来解决,这个后面再说。

解决方案

业务逻辑可以这样设计:

  • 用户在下单时,先尝试从redis中获取该业务的锁。
  • 获取锁成功,下单
  • 获取锁失败,拒绝访问

这种方案看似合理但是其实也有纰漏,在极端情况下可能会发生分布式锁误删的情况

image.png

这个情况发生的根本原因是:业务阻塞+无法认出自己的锁,所以解决方案显而易见——给锁加上唯一的线程标识

我采用的方案是业务名作为redis锁的key,UUID+线程ID作为锁的value,这样可以保证value在集群模式下唯一 (线程id保证每个单体中唯一,UUID保证整个集群中唯一) ,这样每个线程就能分辨出当前的锁是不是自己的锁了,只有满足当前的锁是自己的锁才可以释放。