Redis实现分布式锁

例如:使用Redis分布式锁实现下单减库存操作

不加锁

@Service
public class RedisLockDemo {

    @Autowired
    private StringRedisTemplate redisTemplate;

    public String deduceStock() {

        //获取redis中的库存
        int stock = Integer.valueOf(redisTemplate.opsForValue().get("stock"));
        if (stock > 0) {
            int newStock = stock - 1;
            valueOperations.set("stock", newStock + "");
            System.out.println("扣减库存成功, 剩余库存:" + newStock);
        } else {
            System.out.println("库存已经为0,不能继续扣减");
        }
        return "success";
    }
}

解释:

先从Redis中读取stock的值,表示商品的库存,判断商品库存是否大于0,如果大于0,则库存减1,然后再保存到Redis里面去,否则就报错

使用不加锁的方法从Redis读取、判断值再减1保存到Redis的操作,很容易在并发场景下出问题:商品超卖

**比如:**假设商品的库存有50个,有3个用户同时访问该接口,先是同时读取Redis中商品的库存值,即都是读取到了50,即同时执行到了这一行:

int stock = Integer.valueOf(valueOperations.get("stock"));

然后减1,即到了这一行:

int newStock = stock - 1;

此时3个用户的realStock都是49,然后3个用户都去设置stock为49,那么就会产生库存明明被3个用户抢了,理论上是应该减去3的,结果库存数只减去了1导致商品超卖。

这种问题的产生原因是因为读取库存、减库存、保存到Redis这几步并不是原子操作

改进一:使用synchronized锁处理

那么可以使用加并发锁synchronized来解决:

@Service
public class RedisLockDemo {

    @Autowired
    private StringRedisTemplate redisTemplate;

    public String deduceStock() {

        synchronized (this) {
            //获取redis中的库存
            int stock = Integer.valueOf(redisTemplate.opsForValue().get("stock"));
            if (stock > 0) {
                int newStock = stock - 1;
                valueOperations.set("stock", newStock + "");
                System.out.println("扣减库存成功, 剩余库存:" + newStock);
            } else {
                System.out.println("库存已经为0,不能继续扣减");
            }
        }
        return "success";
    }
}

注意:在Java中关键字synchronized可以保证在同一时刻,只有一个线程可以执行某个方法或某个代码块。 方案一不能称之为分布式锁

改进二:使用分布式锁setnx加锁处理

以上的代码在单体模式下并没太大问题,但是在分布式或集群架构环境下存在问题,在分布式或集群架构下,synchronized只能保证当前的主机在同一时刻只能有一个线程执行减库存操作,如果同时有多个请求过来访问的时候,不同主机在同一时刻依然是可以访问减库存接口的,这就导致问题1(商品超卖)在集群架构下依然存在。相当于synchronized只能处理同一进程的锁问题,就是不能夸Tomact进行加锁处理。

解决方法

使用如下的分布式锁进行解决

@Service
public class RedisLockDemo {
    @Autowired
    private StringRedisTemplate redisTemplate;

    public String deduceStock() {
        String lockKey = "product_001";

        //加锁: setnx,这里使用的是spring封装的setIfAbsent方法
        Boolean isSuccess = redisTemplate.opsForValue().setIfAbsent(lockKey, "1");
        if(null == isSuccess || isSuccess) {
            System.out.println("服务器繁忙, 请稍后重试");
            return "error";
        }

        //------ 执行业务逻辑 ----start------
        int stock = Integer.valueOf(redisTemplate.opsForValue().get("stock"));
        if (stock > 0) {
            int newStock = stock - 1;
            //执行业务操作减库存
            valueOperations.set("stock", newStock + "");
            System.out.println("扣减库存成功, 剩余库存:" + newStock);
        } else {
            System.out.println("库存已经为0,不能继续扣减");
        }
        //------ 执行业务逻辑 ----end------

        //释放锁
        redisTemplate.delete(lockKey);
        return "success";
    }
}

其实就是对每一个商品加一把锁,代码里面是product_001

  • 使用setnx对商品进行加锁
  • 如成功说明加锁成功,如失败说明有其他请求抢占了该商品的锁,则当前请求失败退出
  • 加锁成功之后进行扣减库存操作
  • 删除商品锁

改进三:处理异常问题导致的死锁

上面的方式是有可能会造成死锁的,比如说加锁成功之后,扣减库存的逻辑可能抛异常了,即并不会执行到释放锁的逻辑,那么该商品锁是一直没有释放,会成为死锁的,其他请求完全无法扣减该商品的

使用try...catch...finally的方式可以解决抛异常的问题,把释放锁的逻辑放到finally里面去,即不管try里面的逻辑最终是成功还是失败都会执行释放锁的逻辑,如下:

@Service
public class RedisLockDemo {
    @Autowired
    private StringRedisTemplate redisTemplate;

    public String deduceStock() {
        String lockKey = "product_001";

        try {
            //加锁: setnx
            Boolean isSuccess = redisTemplate.opsForValue().setIfAbsent(lockKey, "1");
            if(null == isSuccess || isSuccess) {
                System.out.println("服务器繁忙, 请稍后重试");
                return "error";
            }

            //------ 执行业务逻辑 ----start------
            int stock = Integer.valueOf(redisTemplate.opsForValue().get("stock"));
            if (stock > 0) {
                int newStock = stock - 1;
                //执行业务操作减库存
                redisTemplate.opsForValue().set("stock", newStock + "");
                System.out.println("扣减库存成功, 剩余库存:" + newStock);
            } else {
                System.out.println("库存已经为0,不能继续扣减");
            }
            //------ 执行业务逻辑 ----end------
        } finally {
            //释放锁
            redisTemplate.delete(lockKey);
        }
        return "success";
    }
}

改进四:处理服务器异常等问题的死锁

比如程序崩溃、服务器宕机、服务器重启、请求超时被终止、发布、人为kill等都有可能导致释放锁的逻辑没有执行,比如对商品加分布式锁成功之后,在扣减库存的时候服务器正在执行重启,会导致没有执行释放锁。

可以通过对锁设置超时时间来防止死锁的发生,使用Redis的expire命令可以对key进行设置超时时间,加锁成功之后,把锁的超时时间设置为10秒,即10秒之后自动会释放锁,避免死锁的发生。

@Service
public class RedisLockDemo {
    @Autowired
    private StringRedisTemplate redisTemplate;

    public String deduceStock() {
        String lockKey = "product_001";

        try {
            //加锁: setnx
            Boolean isSuccess = redisTemplate.opsForValue().setIfAbsent(lockKey, "1");
            //expire增加超时时间
            redisTemplate.expire(lockKey, 10, TimeUnit.SECONDS);
            if(null == isSuccess || isSuccess) {
                System.out.println("服务器繁忙, 请稍后重试");
                return "error";
            }

            //------ 执行业务逻辑 ----start------
            int stock = Integer.valueOf(redisTemplate.opsForValue().get("stock"));
            if (stock > 0) {
                int newStock = stock - 1;
                //执行业务操作减库存
                redisTemplate.opsForValue().set("stock", newStock + "");
                System.out.println("扣减库存成功, 剩余库存:" + newStock);
            } else {
                System.out.println("库存已经为0,不能继续扣减");
            }
            //------ 执行业务逻辑 ----end------
        } finally {
            //释放锁
            redisTemplate.delete(lockKey);
        }
        return "success";
    }
}

改进五:不是原子操作引发的死锁

上面的方式同样会产生死锁问题,加锁和对锁设置超时时间并不是原子操作,在加锁成功之后,即将执行设置超时时间的时候系统发生崩溃,同样还是会导致死锁

对此,有两种做法:

  • 使用lua脚本
  • set原生命令(Redis 2.6.12版本及以上)

一般是推荐使用set命令,Redis官方在2.6.12版本对set命令增加了NX、EX、PX等参数,即可以将上面的加锁和设置时间放到一条命令上执行,通过set命令即可:

命令官方文档:https://redis.io/commands/set

用法可参考:Redis命令参考

SET key value NX 等同于 SETNX key value命令,并且可以使用EX参数来设置过期时间

注意:其实目前在Redis 2.6.12版本之后,所说的setnx命令,并非单单指Redis的SETNX key value命令,一般是代指Redis中对set命令加上nx参数进行使用,一般不会直接使用SETNX key value命令了

注意:Redis2.6.12之前的版本,只能通过lua脚本来保证原子性了。

@Service
public class RedisLockDemo {
    @Autowired
    private StringRedisTemplate redisTemplate;

    public String deduceStock() {
        String lockKey = "product_001";

        try {
            //加锁: setnx 和 expire增加超时时间
            Boolean isSuccess = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
            if(null == isSuccess || isSuccess) {
                System.out.println("服务器繁忙, 请稍后重试");
                return "error";
            }

            //------ 执行业务逻辑 ----start------
            int stock = Integer.valueOf(redisTemplate.opsForValue().get("stock"));
            if (stock > 0) {
                int newStock = stock - 1;
                //执行业务操作减库存
                redisTemplate.opsForValue().set("stock", newStock + "");
                System.out.println("扣减库存成功, 剩余库存:" + newStock);
            } else {
                System.out.println("库存已经为0,不能继续扣减");
            }
            //------ 执行业务逻辑 ----end------
        } finally {
            //释放锁
            redisTemplate.delete(lockKey);
        }
        return "success";
    }
}

改进六:超时时间设置不合理引起的锁失效

以上的方式其实还是存在着问题,在高并发场景下会存在问题超时时间设置不合理导致的问题

例如:

进程A加锁之后,扣减库存的时间超过设置的超时时间,这里设置的锁是10秒,在第10秒的时候由于时间到期了所以进程A设置的锁被Redis释放了,刚好进程B请求进来了,加锁成功,进程A操作完成(扣减库存)之后,把进程B设置的锁给释放了,刚好进程C请求进来了,加锁成功,进程B操作完成之后,也把进程C设置的锁给释放了,以此类推…

解决方法:

  • 加锁的时候,把值设置为唯一值,比如说UUID这种随机数
  • 释放锁的时候,获取锁的值判断value是不是当前进程设置的唯一值,如果是再去删除
@Service
public class RedisLockDemo {
    @Autowired
    private StringRedisTemplate redisTemplate;

    public String deduceStock() {
        String lockKey = "product_001";
        // 判断加的锁是否为自己的
        String clientId = UUID.randomUUID().toString();

        try {
            //加锁: setnx 和 expire增加超时时间
            Boolean isSuccess = redisTemplate.opsForValue().setIfAbsent(lockKey, clientId, 10, TimeUnit.SECONDS);
            if(null == isSuccess || isSuccess) {
                System.out.println("服务器繁忙, 请稍后重试");
                return "error";
            }

            //------ 执行业务逻辑 ----start------
            int stock = Integer.valueOf(redisTemplate.opsForValue().get("stock"));
            if (stock > 0) {
                int newStock = stock - 1;
                //执行业务操作减库存
                redisTemplate.opsForValue().set("stock", newStock + "");
                System.out.println("扣减库存成功, 剩余库存:" + newStock);
            } else {
                System.out.println("库存已经为0,不能继续扣减");
            }
            //------ 执行业务逻辑 ----end------
        } finally {
            if (clientId.equals(redisTemplate.opsForValue().get(lockKey))) {
                //释放锁
                redisTemplate.delete(lockKey);
            }
        }
        return "success";
    }
}

注意:释放锁时get和del并非原子操作也会引发问题

在finally代码块中,释放锁的时候,get和del并非原子操作,存在进程安全问题。那么删除锁的正确姿势是使用lua脚本,通过redis的eval/evalsha命令来运行:

if redis.call('get', KEYS[1]) == ARGV[1] 
    then 
        -- 执行删除操作
        return redis.call('del', KEYS[1]) 
    else 
        -- 不成功,返回0
        return 0 
end

即lua脚本能够保证原子性,在lua脚本里执行是一个命令(eval/evalsha)去执行的,一条命令没有执行完,其他客户端是看不到的。

基本上Redis的分布式锁的实现思想如下:

  • 获取锁的时候,使用setnx加锁,并使用expire命令为锁添加一个超时时间,超过该时间则自动释放锁,锁的value值为一个随机生成的UUID,通过此在释放锁的时候进行判断。
  • 获取锁的时候还设置一个获取的超时时间,若超过这个时间则放弃获取锁。
  • 释放锁的时候,通过UUID判断是不是该锁,若是该锁,则执行delete进行锁释放。

**注意:**虽然通过上面的方式解决了会删除其他进程的锁的问题,但是超时时间的设置依然是没有解决的,设置成多少依然是个比较棘手的问题,设置少了容易导致业务没有执行完锁就被释放了,而设置过大万一服务出现异常无法正常释放锁会导致出现异常锁的时间也很长。

怎么解决这个问题呢?目前大公司的一个方案是这样子的:

在加锁成功之后,启动一个守护线程,守护线程每隔1/3的锁的超时时间就去延迟锁的超时时间,比如说锁设置为30秒,那就是每隔10秒就去延长锁的超时时间,重新设置为30秒,业务代码执行完成,关闭守护线程

Redis的部署引发的问题

众所周知,Redis有3种部署方式:

  • 单机模式
  • Master-Slave + Sentinel(哨兵)选举模式
  • Redis Cluster(集群)模式

使用 Redis 做分布式锁的缺点在于:如果采用单机部署模式,会存在单点问题,只要 Redis 故障了。加锁就不行了。

针对这个问题,有两个解决方案:

  • RedLock
  • Zookeeper【推荐】

成熟的Redisson开源框架

Redisson 是 Redis 的 Java 实现的客户端,其 API 提供了比较全面的 Redis 命令的支持。通过 Netty 支持非阻塞 I/O。封装了锁的实现,让我们像操作我们的本地 Lock一样来使用,除此之外还有对集合、对象、常用缓存框架等做了友好的封装,易于使用。

除此之外,Redisson还实现了分布式锁的自动续期机制、锁的互斥自等待机制、锁的可重入加锁于释放锁的机制,可以说Redisson对分布式锁的实现是实现了一整套机制的。

Redisson 可以便捷的支持多种Redis部署架构:

  • 单机模式
  • Master-Slave + Sentinel(哨兵)选举模式
  • Redis Cluster(集群)模式

引入Redission之后,使用上非常简单,RedissonClient客户端提供了众多的接口实现,支持可重入锁、公平锁、读写锁、锁超时、RedLock等都提供了完整实现。

使用方式:

  • 引入依赖
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.13.4</version>
</dependency>
  • 创建配置文件
@Configuration
public class RedissonConfig {
 
    @Bean
    public Redisson redisson() {
        Config config = new Config();
        //单机版
        //config.useSingleServer().setAddress("redis://192.168.1.1:8001").setDatabase(0);
 
        //集群版
        config.useClusterServers()
                .addNodeAddress("redis://192.168.1.1:8001")
                .addNodeAddress("redis://192.168.1.1:8002")
                .addNodeAddress("redis://192.168.1.2:8001")
                .addNodeAddress("redis://192.168.1.2:8002")
                .addNodeAddress("redis://192.168.1.3:8001")
                .addNodeAddress("redis://192.168.1.3:8002");
        return (Redisson) Redisson.create(config);
    }
}
  • 使用
@Service
public class RedisLockDemo {
    @Autowired
    private StringRedisTemplate redisTemplate;
 
    @Autowired
    private Redisson redisson;
 
    public String deduceStock() {
        String lockKey = "lockKey";
        RLock redissonLock = redisson.getLock(lockKey);
 
        try {
            //加锁(超时默认30s), 实现锁续命的功能(后台启动一个timer, 默认每10s检测一次是否持有锁)
            redissonLock.lock();
 
            //------ 执行业务逻辑 ----start------
            int stock = Integer.valueOf(redisTemplate.opsForValue().get("stock"));
            if (stock > 0) {
                int newStock = stock - 1;
                //执行业务操作减库存
                redisTemplate.opsForValue().set("stock", newStock + "");
                System.out.println("扣减库存成功, 剩余库存:" + newStock);
            } else {
                System.out.println("库存已经为0,不能继续扣减");
            }
            //------ 执行业务逻辑 ----end------
        } finally {
            //解锁
            redissonLock.unlock();
        }
        return "success";
    }
}

Redisson用法非常简单,内部实现了redis锁出现的所有问题,细节:

  • 为了兼容老的Redis版本,Redisson 所有指令都通过 Lua 脚本执行,Redis 支持 Lua 脚本原子性执行。
  • Redisson 设置的Key 的默认过期时间为 30s,如果某个客户端持有一个锁超过了 30s 怎么办?Redisson 中有一个 Watchdog 的概念,翻译过来就是看门狗,它会在你获取锁之后,每隔 10s 帮你把 Key 的超时时间设为 30s。
  • 如果获取锁失败,Redsson会通过while循环一直尝试获取锁(可自定义等待时间,超时后返回失败)
  • 这样的话,就算一直持有锁也不会出现 Key 过期了,其他线程获取到锁的问题了。

**注意:**Redison并不能有效的解决Redis的主从切换问题的,目前推荐使用Zookeeper分布式锁来解决。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值