要说Redis高并发场景,有一个很典型的场景就是商品秒杀。而在实际开发的过程中,会有各种各样的问题发生,比如:如何保证订单的唯一性、超卖问题、一人一单等问题,以及这些问题在集群模式下也能够正常运行等问题。下面是解决这些问题的方法。由于篇幅问题,会分成几个主题来写。
1、全局唯一ID生成策略:
全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具,一般要求满足:唯一性、高性能、高可用、安全性和递增性。而Redis命令可以满足基本都能满足以上的要求,但是安全性需要进一步增强。比如,为了增强ID的安全性,我们可以不使用Redis自增的数值,而是拼接一些其他的信息来增强安全性。
比如,我们可以把ID分成三部分,第一部分是符号位,占用一个bit位;第二部分用时间戳来表示,占用31个bit位;第三部分用序列号来代替,占用32个bit位,一个ID总共占用8个字节。
ID的组成部分:
- 符号位:1bit,永远位0
- 时间戳:31bit,以秒为单位,可以使用69年
- 序列号:32bit,秒内的计数器,支持每秒产生2^32个不同的ID
1 | @Slf4j |
解析一下上面这段代码:
- 时间戳的生成是先生成一个当前年份开始第一天零时零分的时间戳,然后再获取生成订单这一刻的时间戳相减得到的时间值。
- 序列号的生成是当前时间的年月日的格式化,然后通过Redis命令去调用
increment
这个方法来生成自增长的id来作为序列号。 - 订单号的拼接是通过偏移量来计算的,因为底层是用补码进行存储,所以可以这样来进行存储。先把时间戳向左偏移32位,即右边就会空出32位(全为0),然后把时间戳新的值与序列号做或运算,就可以得到一个新的值(或运算中,只有全为0才为0,否则为1,这样计算出来的值可以保证拼接的结果无误)。
ID的生成策略还有其他的方法,比如UUID、Redis自增、雪花算法和数据库自增(区别于一般的ID,数据库自增是独立维护一个订单表,里面有id和订单号,维护的是订单号自增)。
2、超卖问题解决方法:
超卖问题是高并发下,在某一时刻,有多个线程同时对数据库库存的值(这个值相同)进行减法操作,导致库存出现负数的情况。针对这一问题,常见的解决方法就是给这个操作上一把锁,如悲观锁和乐观锁:
(1)、悲观锁:
悲观锁总是认为线程安全问题一定会发生,因此在操作数据之前先获取锁,确保线程串行执行。例如Synchronized和Lock都属于悲观锁。
(2)、乐观锁:
乐观锁认为线程安全问题不一定会发生,因此不加锁,只有在更新数据时判断有没有其他线程对数据做了修改。如果没有修改则认为是安全的,自己才更新数据;如果已经被其他线程修改说明发生了线程安全问题,此时可以重试或者报异常。
悲观锁在高并发场景下并不适用,所以下面是基于乐观锁的方式进行解决的,这里给出两种解决方法,分别是版本号法和CSA法。
版本号法:
基于版本号法来解决超卖问题:
在Mybatis-Plus中,提供了一个关键字@Version
来维护版本号,需要在数据库创建version
字段,在实体类的属性中找到这个字段并加上@Version
注释。
1 | // 标识版本号乐观锁字段 |
编写配置文件:
1 | @Configuration |
每次修改前都会先查询版本号,如果版本号一致才允许修改,每次修改成功都会把版本号做加一操作(version = version + 1)。如果版本号不一致,就不允许修改,保证数据安全。
1 | @Transactional |
在SQL语句中,where后面的判断条件会自动加上version=?,如果version和数据库保持一致才能执行成功,如果不一致就执行失败,事务回滚。
CSA法:
既然每次修改数据都是先判断库存是否充足的,那么就可以利用库存来作为一个类似于版本号的左右,判断库存是否和查询到的值是一致的,只有一致的才允许扣库存。但是这也有一个弊端,那就是高并发的情况下,如果其他的线程扣减库存成功,此时数据库库存减一,而等到我执行的时候,库存值不一样了,但是库存的数量还有余量,依旧会秒杀失败。这样执行的效果就是,高并发情况下,明明库存充足,就是下单失败。因此,基于这个思想,我们可以稍作改变,就是判断库存是否一致改为库存是否大于0作为判断标准,只要大于0就允许购买,代码如下:
1 | @Override |
总结起来就是,乐观锁高并发下性能好,但是可能存在成功率低的问题。
3、一人一单问题:
一般来说,秒杀活动都是商家的特惠活动,是为了吸引流量的,那么一个用户应当只允许购买一次才符合秒杀的逻辑,所以下面是解决一人一单这个问题,同一个用户每次秒杀只能参与一次,重复参与就提醒用户不能重复参与。
1 | @Override |
一人一单问题这里使用的是悲观锁的方法,可以在创建订单的方法上的返回值前加上synchronized
关键字来把整个方法上锁,但是这样的作用范围是整个实现类,这样锁的粒度太大了,一旦并发量大,很容易造成卡顿;这里的优化方法是使用用户id这个字段作为一把锁,使用intern
方法可以对这个用户id进行规范化返回(返回的是用户id的地址值,判断结果只有地址值一致才为true),但是由于开启事务的方法是创建订单,这时候会造成事务不生效的问题,可以使用Aspectj拿到当前类的代理,调用AopContext.currentProxy()拿到代理权(需要强转成当前的实现类),然后代理调用创建订单方法才能保证事务的一致性。
需要先导入Aspectj依赖,然后去启动类中暴露代理权。
1 | <dependency> |
1 | @EnableAspectJAutoProxy(exposeProxy = true) |
exposeProxy
默认为false,官网上也不建议使用这种方法,因为代理对象的生命周期和目标对象的生命周期是不同的,存在不稳定性,而且也会存在有安全风险和性能问题。
一人一单问题在单体服务器上,是可以保证一致性的,但是在集群模式下,并不能保证一人一单。
之前是因为单体服务器,JVM内部维护了锁监视器,当线程1获取到锁之后,就会在常量池中的锁监视器里面去维护线程1这个变量,当其他线程想要获取锁的时候,锁监视器里面已经存在一把锁了,那么其他的线程也就无法去获取到锁,只能等待这个线程去主动释放锁,其他的线程才能获取锁。但是集群的时候,就会存在多个JVM,这个时候,它们维护的都是各自的锁监视器(JVM是根据锁监视器来控制线程的,互斥),所以在其他的集群的JVM的线程也可以拿到这把锁(不同的tomcat不同的锁),那么在并发的情况下,它们在数据库查到的count
值都是0,所以都可以创建一个订单去返回。利用分布式锁可以解决这个问题。
考虑到篇幅问题,会在后一篇文章中分享。
本文链接: https://longzas.github.io/2023/08/26/Redis%E9%AB%98%E5%B9%B6%E5%8F%91%E5%9C%BA%E6%99%AF-%E5%95%86%E5%93%81%E7%A7%92%E6%9D%80%E6%B4%BB%E5%8A%A8/
版权声明: 本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。转载请注明出处!