总结:本文探讨了Redis在秒杀业务中的应用,重点介绍了全局唯一ID生成方案和分布式锁的实现。首先提出基于Redis的全局ID生成器设计方案,通过时间戳+序列号的组合方式保证ID唯一性。针对秒杀业务中的库存超卖问题,分析了悲观锁和乐观锁的解决方案及各自优缺点。对于一人一单场景,详细说明了synchronized锁的局限性及分布式锁的必要性。最后深入讲解了Redis分布式锁的实现原理,包括误删问题的解决方案和Lua脚本保证原子性的方法,提供了一套完整的分布式锁实现代码。这些技术方案共同构成了高并发秒杀系统的核心保障。
Redis解决秒杀业务
全局唯一ID:用全局ID生成器,是一种在分布式系统下用来生成唯一ID的工具,满足了唯一性、高可用、高性能、递增性、安全性。
Redis实现全局唯一ID:使用String类型的increment满足了前4种特性,但是不满足安全性,因此我们可以不使用它的自增,给它拼接一些其它信息。拼接ID的组成由符号位+时间戳+序列号来组成,符号位1bit永远为正的是0,时间戳31bit以秒为单位可以使用69年,序列号32bit每秒产生2的32次方个不同ID,用于在同一秒很多人下单时间戳一样时防止ID一样。
@Component public class RedisIdWorker{ @Resource private StringRedisTemplate stringRedisTemplate; private static final long BEGIN_TIMESTAMP=”获取到的规定的时间多少秒”; private static final long COUNT_BITS=32; public long nextId(String keyPrefix){ //1.生成时间戳 LocalDateTime now = LocalDateTime.now(); long nowSecond = now.toEpochSecond(ZoneOffset.UTC); long timestamp = nowSecond - BEGIN_TIMESTAMP; //2.生成序列号 //2.1获取当前日期,精确到天 Sting date = now.format(DateTimeFormatter.ofPattern(“yyyy:MM:dd”)); //拼上date每天一个key防止总量超过 long count = stringRedisTemplate.opsForValue().increment(“incr:”+keyPrefix+”:”+date); //3.拼接并返回,用到位运算,因为返回值是long类型 return timestamp << COUNT_BITS | count; } }全局唯一ID生成策略:UUID、Redis自增、snowflake算法、数据库自增(按理说是不能用的,但是这自增的是使用一张表的自增,而不是字段一个个自增,跟redis自增很像)。
实现优惠券秒杀下单:
@Override @Transactional public Result seckillVoucher(Long voucherId){ //1.查询优惠券 SeckillVoucher voucher = seckillVoucherService.getById(voucherId); //2.判断秒杀是否开始 if(voucher.getBeginTime().isAfter(LocalDateTime.now())){ return Result.fail(“秒杀的尚未开始”); } //3.判断秒杀是否已经结束 if(voucher.getEndTime().isBefore(LocalDateTime.now())){ return Result.fail(“秒杀已经结束”); } //4.判断库存是否充足 if(voucher.getStock() < 1){ return Result.fail(“库存不足”); } //5.扣减库存 boolean success = seckillVoucherService.update() .setSql(“stock=stock - 1”) .eq(“voucher_id”,voucherId).update();; if(!success){ return Result.fail(“库存不足”); } //6.创建订单 VoucherOrder voucherOrder = new VoucherOrder(); //6.1.订单id long orderId = redisIdWorker.nextId(“order”); voucherOrder.setId(orderId); //6.2.用户id Long userId = UserHolder.getUser().getId(); voucherOrder.setUserId(userId); //6.3.代金券id voucherOrder.setVoucherId(voucherId); save(voucherOrder); //7.返回订单id return Result.ok(orderId); }库存超卖问题:在高并发下,原本可能100的库存会卖出多于100件,出现超卖问题。也就是java中学到的线程并发安全问题。
解决超卖问题:加锁,悲观锁和乐观锁。
悲观锁:像synchronized、lock互斥锁都属于悲观锁,认为线程安全问题一定会发生,因此在获取数据之前先加锁,确保线程串行执行。
乐观锁:认为线程安全问题不一定会发生,因此不加锁,只是在更新数据时去判断有没有其他线程对数据进行了修改。乐观锁的关键是如何判断之前查询的数据有没有被修改,常见的方式有2种:版本号法和CAS法,一个是通过sql条件查询或用eq()来判断版本号的变化来判断库存是否变化,一个是通过sql条件查询或用eq()库存直接判断库存的变化来判断。
乐观锁的弊端:虽然乐观锁解决了库存超卖,但是会出现库存没卖完就结束了,如每个都抢占100号库存,一个线程拿走后,其他线程判断到的是库存不等于100,就失败直接退出了,那这就是乐观锁的一个弊端成功率太低了。
解决弊端:
方案1,直接将库存判断是否相等改为判断库存是否大于0
方案2,有些就不是库存,只能通过数据来判断是否安全,这时就可以用分段锁也就是分批加锁,也就是将数据分成好几份,用户在抢的时候可以在多张表中分别去抢,这样就可以提高成功率。
当然,乐观锁解决库存超卖其实并不是最完美的,最终毕竟还要去访问数据库,会给数据库带来很大的压力,所以在真正的秒杀场景高并发下,还需要一些优化。
一人一单
根据优惠券id和用户id查询订单,判断订单是否已经存在,已经存在则不让再买,不存在则创建订单,由于都是未存在,可能多个线程同时查询访问抢库存时,都会创建订单,这样会有并发安全问题。所以还要给查询订单、判断订单、创建订单这一整段代码加上事务加上synchronized锁。
//先获取用户id,再对这一整段封装的代码加synchronized锁,保证先提交事务再释放锁,确保事务生效,还要用到事务代理 Long userId = UserHolder.getUser().getId(); synchronized(userId.toString().intern()){ IVoucherOrderService proxy=(IVoucherOrderService)AopContext.currentproxy(); return proxy.createVoucherOrder(voucherId); } //由于每次释放完锁,每次下一个用户都会new获取这个id对象,耗费内存所以用intern()来减少开销。intern 方法是 Java 中String 类提供的一种优化内存使用的机制,用于将字符串对象存储到字符串常量池(String Constant Pool)中,并返回池中字符串的引用。它的主要作用是通过复用字符串对象,减少内存开销并提高性能。
注意:虽然已经解决了一人一单的并发安全问题,但是在集群模式下或有些分布式系统还是会出现并发安全问题,synchronized锁失效,它只用于同一台jvm,也就是说进行新的部署,部署新的tomcat服务器,也就是有2台jvm时,它们有自己的堆栈、自己的方法区,会有新的锁监视器,相当于又变成2个线程创建同一个订单,甚至要是有10台20台jvm就会导致更多线程安全问题。要解决这个问题,就是想办法让多台jvm都用同一把锁,分布式锁。
分布式锁
分布式锁:满足分布式系统下或集群模式下多进程可见(多个jvm都能被看到,像redis、mysql、或让锁监视器都能监控每个jvm线程)并且互斥的锁。
分布式锁的特性:多进程可见、互斥、高性能、高可用、安全性等等。
分布式锁的实现核心是多进程之间的互斥,常见的有3种实现:
基于Redis实现分布式锁:
public class SimpleRedisLock implements ILock{ private String name; private StringRedisTemplate stringRedisTemplate; public SimpleRedisLock(String name , StringRedisTemplate stringRedisTemplate){ this.name = name; this,stringRedisTemplate = stringRedisTemplate; } private static final Sring KEY_PREFIX = “lock”; //下面介绍的分布式锁误删问题,用UUID+线程ID来做线程标示 Private static final String ID_PREFIX = UUID.randomUUID().toString(true+”-”); @Override public boolean tryLock(long timeoutSec){ //获取线程标示 String threadId = ID_PREFIX + Thread.currentThread().getId(); //获取锁 Boolean success = stringRedisTemplate.opsForValue() .setIfAbsent(KEY_PREFIX+name,threadId,timeoutSec,TimeUnit.SECONDS); //避免空指针风险 return Boolean.TRUE.equals(success); } public void unlock(){ //获取线程标示 String threadId = ID_PREFIX+Thread.currentThread().getId(); //获取锁中的标示 String id = stringRedisTempalte.opsForValue().get(KEY_PREFIX+name); //判断线程标示是否一致 if(threadId.equals(id)){ //释放锁 stringRedisTemplate.delete(KEY_PREFIX+name); } } }Redis分布式锁误删问题:某些极端情况下还是会有线程安全问题,线程1由于业务阻塞,业务还没执行完达到了超时时间,导致锁提前释放,这时线程2趁虚而入获取到了锁,但是线程1不知道呀,线程1业务好了开始释放锁,它释放的是线程2的锁。要解决这个问题,关键是获取锁时要把线程标示一起存进去,在释放锁前做一个判断,判断这个线程标示是不是自己的,如果是自己的才能释放锁。
分布式锁的原子性问题:解决了分布式锁误删问题,其实还是有问题。在释放锁的时候也可能发生阻塞,它不是业务阻塞而是如jvm垃圾回收,就会导致锁超时释放,别的线程又可以趁虚而入,这时别的线程就获取到了锁,这时原本准备释放锁的线程开始释放线程,但是已经判断完了锁是自己的,可事实上锁并不是自己的,就把别的线程的锁删掉了又发生误删问题。要解决这个问题,就要保证判断锁标示和释放锁是一个原子性操作,就是保证它们是一起的。
Redis的Lua脚本:Redis提供了Lua脚本功能,在一个脚本编写多条Redis命令,确保多条命令执行时的原子性。Lua是一种编程语言,基本语法参考官方文档。
那要如何调利用Lua语言去调redis?
用redis官方提供的函数redis.call(‘命令名称’ , ’key’ , ’其它参数’ , ...);
例如,redis.call(‘set’ , ’name’ , ’jack’);
写好Lua脚本后要用redis命令来调用脚本,EVAL script numkeys key [key ...] arg [arg ...]
例如,EVAL ”return redis.call(‘set’ , ‘name’ , ‘jack’)” 0 //0是指key类型的参数个数
如果脚本的key、value不想写死,可以作为参数传递。Key类型参数会放入KEYS数组,value类型参数会放入ARGV数组,在脚本中可以从KEYS、ARGV数组中获取参数。
例如,EVAL “return redis.call(‘set’ , ‘KEYS[1]’ , ‘ARGV[1]’) ”1 name jack //Lua的索引是从1开始
调用Lua脚本改造分布式锁代码实现:建个lua文件,然后调用,
在unlock.lua文件中编写:
if(redis.call(‘get’ ,KEYS[1] ) == ARGV[1]) then return redis.call(‘del’ , KEYS[1]) end return 0; private static final DefaultRedisScript<Long> UNLOCK_SCRIPT; static{ UNLOCK_SCRIPT = new DefaultRedisScript<>(); UNLOCK_SCRIPT.setLocation(new ClassPathResource(“unlock.lua”)); UNLOCK_SCRIPT.setResultType(Long.class); } @Override public void unlock(){ //调用Lua脚本 stringRedisTemplate.execute(UNLOCK_SCRIPT, //这边参数传的是集合,所以要将字符串转成集合 Collections.singletonList(KEY_PREFIX+name), ID_PREFIX+Thread.currentThread().getId()); }