1 分布式锁基本概念
1.1 基本概念
对缓存查询数据库的代码进行加锁,有两种方式:
- 本地锁 进程锁 具有局限性,只能在同一个项目中生效,不能控制另一个项目中的方式。保证同一时刻只有一个线程可访问共享资源 例如:对查询数据库的方法加锁,同一时间就只能有一个人查询数据库。
- 分布式锁 分布式锁是指分布式环境下,系统部署在多个机器中,实现多进程分布式互斥的一种锁。为了保证多个进程能看到锁,锁被存在公共存储(比如 Redis、Memcache、数据库等三方存储中),以实现多个进程并发访问同一个临界资源,同一时刻只有一个进程可访问共享资源,确保数据的一致性。 例如:有两个服务器,其中都有查询数据库分类数据的方法,同一时间,只能够有一个项目中的一个线程能够查询成功过。
2 redis分布式锁
2.1 分布式锁的实现逻辑
基本逻辑:所有的客户端同一时间都去一个地方“占坑”,如果当前客户端占到了坑,就执行业务逻辑,如果没有占到就必须等待,直到其他的客户端释放锁。
2.2 基本实现
在 Redis 怎么占坑呢?【加锁】
set命令:set nx
/** * 分布式锁 */ public List<Node> getDataByRedisLock(){ List<Node> nodeList = null; //1.获取锁 执行业务代码 set nx (setIfAbsent) Boolean lock = redisTemplate.opsForValue().setIfAbsent("redis-lock", "100"); if (lock){ //加锁成功查询数据库 nodeList = getNodesByMysql(); //查询完成解锁 redisTemplate.delete("redis-lock"); }else { try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } //重试 getDataByRedisLock(); } return nodeList; }
问题分析:
- 加锁之后 如果代码异常 会导致最终锁没有释放会导致死锁怎么办? 加锁的同时设置过期时间
- 加锁
- 设置过期时间
- 如果要加锁 需要保证加锁和设置过期时间是原子性的
2.3 加锁原子性
在Redis中支持原子性的加锁和过期时间设置
上面的java方法setIfAbsent会被转换为redis的set key value EX 10 NX
redis中的单条命令都是原子操作,要么全部执行成功,要么全部执行失败,不会被其他的命令干扰。redis的命令为什么是原子操作呢?redis底层是单线程执行命令。所有客户端的命令发给redis后都会放入一个队列一条条执行,中间没有其他的线程干扰。所以redis的一个命令都是线程安全的。
为什么redis的QPS那么高还是用的是单线程呢?
redis底层采用的非阻塞多路复用的io模型,可以使用单线程处理很高的访问量。
2.4 删锁优化
问题:
- 在实际的执行中,线程1加了锁,但是有可能业务执行的时间特别长,所以线程1的锁会自己过期
- 在此期间,线程2,加锁成功
- 然后线程1执行完业务代码,执行删除锁的操作,此时会把线程2的锁给删除 如何保证不会删错锁?
解决方案:
加锁的时候,设置值为UUID,每个用户的UUID不同,作为key对应的值
问题分析:
删除锁的过程不是原子性的。删除锁是分为两步的
- 获取值 UUID
- 删除锁
如果在获取值之后,还没有删除锁之前,当前线程的锁过期了,其他的线程设置了分布式锁,那么就会出现问题。
if 判断可以通过,执行删除锁的代码,Redis会删除其他线程的锁
2.5 删锁原子性
LUA脚本
if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end
lua脚本:Lua 是一种轻量小巧的脚本语言,用标准C语言编写并以源代码形式开放
问题分析:
以上代码实现了加锁和解锁的原子性,但是还有问题:在业务代码没有执行完之前,分布式锁不应该过期。
- 锁的过期时间加长 + finally 保证一定会解锁
- 自动续期
redis分布式锁最终版代码
/** * 分布式锁 */ public List<Node> getDataByRedisLock(){ List<Node> nodeList = null; //生成UUID String uuid = UUID.randomUUID().toString(); //1.获取锁 执行业务代码 set nx (setIfAbsent) //setIfAbsent 该方法如果设置了过期时间 底层就是 set Ex nx 加锁和过期时间设置是原子性的 Boolean lock = redisTemplate.opsForValue().setIfAbsent("redis-lock", uuid,100,TimeUnit.SECONDS); if (lock){ System.out.println("获取锁"); //加锁成功查询数据库 try { nodeList = getNodesByMysql(); }finally { //查询完成解锁 String script = "if redis.call(\"get\",KEYS[1]) == ARGV[1] then return redis.call(\"del\",KEYS[1]) else return 0 end"; /** * 参数1 脚本 * 参数2 要删除的key * 参数3 uuid */ redisTemplate.execute( new DefaultRedisScript<Long>(script,Long.class), Arrays.asList("redis-lock"), uuid ); } }else { try { System.out.println("没有获取锁重试"); Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } //重试 getDataByRedisLock(); } return nodeList; }
redisson里面有看门狗watchDog可以对锁的时长进行自动续期。
看门狗的底层原理:客户端对redis加完锁后,会启动一个新线程(看门狗),该线程是一个定时线程,会每个时长/3的间隙给redis发出续期命令,续期的时候会校验该锁是不是当前线程的锁,如果是就续期,不是就终止看门狗线程。当主线程联系不上redis或者宕机后,看门狗线程也会联系不上redis,所以可以避免无限续期产生死锁。
2.6 redisson
Redisson是Redis官方推荐的Java版的Redis客户端。它提供的功能非常多,也非常强大。他里面内置了分布式锁的工具和方法使用起来非常方便就像使用本地锁一样。上面的内容就是redission分布式锁的底层原理。
项目中使用redisson实现分布式锁:
- 引入依赖
<dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId> <version>3.15.6</version> </dependency>
- 配置bean对象
@Configuration public class RedissonConfig { @Bean public RedissonClient redissonClient(){ Config config = new Config(); config.setTransportMode(TransportMode.NIO); SingleServerConfig singleServerConfig = config.useSingleServer(); //可以用"rediss://"来启用SSL连接 singleServerConfig.setAddress("redis://linux100:6379"); RedissonClient redisson = Redisson.create(config); return redisson; } }
- 使用redisson
@RestController public class UserController { @Autowired private RedissonClient redissonClient; @GetMapping("/test") public String test() { RLock lock = redissonClient.getLock("test-lock"); try { System.out.println("加锁成功!!!"); Thread.sleep(10000); } catch (Exception e) { e.printStackTrace(); }finally { System.out.println("释放锁成功!!!"); lock.unlock(); } return "success"; } }