Redis高并发分布式锁

分布式环境下高并发访问衍生的问题

针对单机环境下的并发访问,可以通过锁机制(Syschronized或独占锁等)来进行控制,使得一个资源在一段时间内只能被一个线程访问;但在多服务器的分布式环境下,并发访问同一个资源,可能会导致被同时修改或更新,原因在于juc包下的并发控制机制,都是基于JVM层面的,而分布式环境下的多服务器场景,每一个部署了应用的Tomcat服务器都有一个自己的JVM,属于JVM层的锁是无法跨JVM进行资源独占保护的。

以商品减少库存为例,假设库存是100

在redis上设置初始库存

 背景

正常单机环境下,库存会按顺序减少,且不同的线程在库存足够的情况下都可以获得不同的商品。但在集群的环境下,为了减轻服务器的压力,都会有一个负载均衡,或是nginx或是ribbon或F5等其他的方式,根据不同的负载均衡策略,将请求分发到集群的各个服务器上,此时可能会有多个请求同时下单成功,且库存只减少了一次

解决方法

为了解决该问题,可以在减库存的操作发生前,加一把锁,只有获取这把锁的请求,才能下单成功;当一个请求获取到了锁,在它执行完减库存释放锁之前,其他请求只能等待。

通过Redis的SetNx命令,其特点为,当key不存在才能设置成功;否则不做任何操作返回false

@RequestMapping("/deduct_stock")
public String deductStock() {
    String countKey = "商品id";
    
    boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(countKey, "zhangsan");
    if(!lock){
        return "没获取到锁";
    }
    int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); // jedis.get("stock")
    if (stock > 0) {
        int realStock = stock - 1;
        stringRedisTemplate.opsForValue().set("stock", realStock + ""); // jedis.set(key,value)
        System.out.println("扣减成功,剩余库存:" + realStock);
    } else {
        System.out.println("扣减失败,库存不足");
    }
    //下单成功 释放锁
    stringRedisTemplate.delete(countKey);
    return "end";
    }

衍生问题1

针对无异常的情况,上述方案可以解决同一商品被下单多次的问题,但若是下单过程中出现异常,锁未释放就中断,会导致后续的请求都无法下单这一类的商品,因此要有异常处理

@RequestMapping("/deduct_stock")
public String deductStock() {
    String countKey = "商品id";
    
    boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(countKey, "zhangsan");
    if(!lock){
        return "没获取到锁";
    }
    try{
        int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); // jedis.get("stock")
        if (stock > 0) {
            int realStock = stock - 1;
            stringRedisTemplate.opsForValue().set("stock", realStock + ""); // jedis.set(key,value)
            System.out.println("扣减成功,剩余库存:" + realStock);
        } else {
            System.out.println("扣减失败,库存不足");
        }
    }finally {
        //下单成功 释放锁
        stringRedisTemplate.delete(countKey);
    }
    
    return "end";
    }

衍生问题2

即使是出现异常的情况,该锁也会释放,不会影响到其他的请求访问,但也有可能出现应用宕机或投产关停在释放锁之前的情况,因此,最合适的方法是设置过期时间

@RequestMapping("/deduct_stock")
public String deductStock() {
    String countKey = "商品id";
    //设置值和key的过期时间,最好是合并成一个原子操作,保证上了锁不会因程序中断而无法释放
    //            boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(countKey, "zhangsan");
    //            stringRedisTemplate.expire(countKey, 10, TimeUnit.SECONDS);
    boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(countKey, "zhangsan",10,TimeUnit.SECONDS);
    if(!lock){
        return "没获取到锁";
    }
    try{
        int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); // jedis.get("stock")
        if (stock > 0) {
            int realStock = stock - 1;
            stringRedisTemplate.opsForValue().set("stock", realStock + ""); // jedis.set(key,value)
            System.out.println("扣减成功,剩余库存:" + realStock);
        } else {
            System.out.println("扣减失败,库存不足");
        }
    }finally {
        //下单成功 释放锁
        stringRedisTemplate.delete(countKey);
    }
    
    return "end";
    }

衍生问题3

这种处理可以在并发不大的场景下进行使用,但在高并发的大量请求下,还有可能衍生一种新的问题,锁失效:指的是因为并发高了或者其他原因,导致业务逻辑执行的时间超过了锁的过期时间,在请求1未执行完时锁就过期了,此时库存的数量可能还未减少;但另一个请求2因为锁过期,可以进行下单操作,在该请求2还未执行结束时,请求1执行结束了,此时它会执行delete锁的操作,这个时候会导致请求2的锁被请求1释放,以此类推,redis中设置的这个锁的存在时间可能会越来越短,甚至会刚设置好就被上一个或其他请求给清理。

针对这种情况,在释放锁的时候,应该添加一个唯一标识,只有是当前请求进来设置的锁,才能释放

@RequestMapping("/deduct_stock")
public String deductStock() {
    String countKey = "商品id";
    //
    //            boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(countKey, "zhangsan");
    //            stringRedisTemplate.expire(countKey, 10, TimeUnit.SECONDS);
    //增加唯一标识
    String primaryKey = UUID.randomUUID().toString();
    boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(countKey, primaryKey,10,TimeUnit.SECONDS);
    if(!lock){
        return "没获取到锁";
    }
    try{
        int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); // jedis.get("stock")
        if (stock > 0) {
            int realStock = stock - 1;
            stringRedisTemplate.opsForValue().set("stock", realStock + ""); // jedis.set(key,value)
            System.out.println("扣减成功,剩余库存:" + realStock);
        } else {
            System.out.println("扣减失败,库存不足");
        }
    }finally {
        //下单成功 有当前请求设置的锁,才能删除
        if(primaryKey.equals(stringRedisTemplate.opsForValue().get(countKey))){
            stringRedisTemplate.delete(countKey);
        }
        
    }
    
    return "end";
    }

但即使这种情况,由于判断+释放是两步操作,也不是一个原子性的,可以通过锁续命来处理,其实现机制为,启动一个子线程来判断执行业务逻辑的线程是否执行完,若没有,重置其过期时间,以避免,判断后卡顿导致锁未释放。

RedisSon

是一个基于Redis的扩展类库,针对以上的过期时间早于程序的释放锁问题,其提供了良好的解决方案,即通过类似redis中的setNx的操作,加上lua脚本来达到控制分布式系统下并发请求的安全性。

Lua脚本

在Redis中,它的好处在于,可以将多个操作写到一起,组成一个原子性的操作,例如:下面的操作是要先获取key的值进行判断,然后才能进行delete这是两步的操作;但若是使用Lua脚本,就可以将这两个步骤整合到一个Lua脚本中去执行,而Lua脚本的执行是具有原子性的。

@RequestMapping("/deduct_stock")
public String deductStock() {
    String countKey = "商品id";
    //获取锁对象
    RLock rLock = redisson.getLock(countKey);
    //加锁
    rLock.lock();
    try{
        int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock")); // jedis.get("stock")
        if (stock > 0) {
            int realStock = stock - 1;
            stringRedisTemplate.opsForValue().set("stock", realStock + ""); // jedis.set(key,value)
            System.out.println("扣减成功,剩余库存:" + realStock);
        } else {
            System.out.println("扣减失败,库存不足");
        }
    }
    //解锁
    rLock.unlock();  
    return "end";
    }

你可能感兴趣的:(Redis,大数据)