一、Redis的事务机制
提到事务,相信大家都不陌生,事务的ACID四大特性,其实Redis也是提供了事务机制的,下面就来讲解下Redis的事务机制。
1.1 事务演示
Redis的事务提供了一种将多个命令请求打包,然后一次性、按顺序性地执行多个命令的机制。
在事务执行期间,服务器不会中断事务而去执行其它客户端的命令请求,它会将事务中的所有命令执行完毕,然后才去处理其它客户端的命令请求。
1.2 事务实现原理
一个事务从开始到结束会经历以下3个阶段:
- 事务开始
- 命令入队
- 事务执行
- 事务执行开始
MULTI命令的执行标志着事务的开始.
执行完该命令后,客户端状态的flags属性会打开REDIS_MULTI标识,表示该客户端从非事务状态切换至事务状态。
- 命令入队 当一个客户端处于非事务状态时,这个客户端发送的命令会立即被服务器执行:
当一个客户端处于事务状态时,这个客户端发送的命令,服务器是否会立即执行,分为以下2种情况:
- 如果客户端发送的命令为
MULTI、EXEC、WATCH、DISCARD四个命令中的其中1个,服务器会立即执行这个命令。 - 如果客户端发送的命令为以上4个命令外的其它命令,服务器不会立即执行这个命令,而是将其放到事务队列里,然后向客户端返回
QUEUED回复。
以上流程可以使用以下流程图来表示:
- 事务执行 当一个处于事务状态的客户端执行
EXEC命令时,服务器会遍历这个客户端的事务队列,执行队列中保存的所有命令(按先入先出顺序),然后将执行命令的结果一次性返回给客户端。
1.3 witch命令实现原理
WATCH命令用于监视任意数量的数据库键,并在EXEC命令执行时,检测被监视的键是否被修改,如果被修改了,服务器将拒绝执行事务,并向客户端返回空回复。
首先,我们打开客户端1,执行WATCH命令监视键“name”,然后开启一个事务:
打开客户端2,执行以下命令修改“name”键的值:
在客户端1执行EXEC命令时,会返回空回复,因为“name”键的值在客户端2已经被修改:
那么,WATCH命令的实现原理是什么样的呢?我们从以下3个方面来分析:
- 使用WATCH命令监视数据库键
- 监视机制的触发
- 判断事务是否安全
- 使用WATCH命令监视数据库键 每个Redis数据库都保存着1个
watched_keys字典,这个字典的键是某个被WATCH命令监视的数据库键,字典的值是一个链表,链表中记录了所有监视相应数据库键的客户端。
假如客户端1正在监视键“name”,客户端2正在监视键“age”,那么watched_keys字典存储的数据大概如下:
客户端3执行了以下WATCH命令:
那么watched_keys字典存储的数据就变为:
- 监视机制的触发
既然watched_keys字典存储了被WATCH命令监视的键,那么监视机制是如何被触发的呢?
所有对数据库修改的命令,比如SET、LPUSH、SADD等,在执行之后都会对watched_keys字典进行检查,如果有客户端正在监视刚刚被命令修改的键,那么所有监视该键的客户端的REDIS_DIRTY_CAS标识将被打开,表示该客户端的事务安全性已经被破坏。
- 判断事务是否安全
最后非常关键的一步是,当服务器接收到一个客户端发来的EXEC命令时,服务器会根据这个客户端是否打开了REDIS_DIRTY_CAS标识来决定是否执行事务,判断的流程图如下所示:
1.4 事务失败的情况
- 命令入队出错 事务因为命令入队出错被服务器拒绝执行,事务中的所有命令都不会被执行
- 不存在的命令 事务入队时出现了不存在的命令,服务器将拒绝执行这个事务
- 命令在执行期间报错
RPUSH命令在执行期间报错了,但后续命令仍然继续执行,并且之前执行的命令没有受到任何影响:
例子也说明:Redis事务不支持回滚机制
1.5 事务总结
Redis的事务提供了一种将多个命令打包,然后一次性、有序地执行的机制。
它的原理是多个命令会被入队到事务队列中,然后按先进先出(FIFO)的顺序执行。
并且事务在执行过程中不会被中断,当事务队列中的所有命令都被执行完毕之后,事务才会结束。
二、Redis实现的分布式锁
分布式锁其实就是,控制分布式系统不同进程共同访问共享资源的一种锁的实现。如果不同的系统或同一个系统的不同主机之间共享了某个临界资源,往往需要互斥来防止彼此干扰,以保证一致性。
-
互斥性: 任意时刻,只有一个客户端能持有锁。
-
锁超时释放:持有锁超时,可以释放,防止不必要的资源浪费,也可以防止死锁。
-
可重入性:一个线程如果获取了锁之后,可以再次对其请求加锁。
-
高性能和高可用:加锁和解锁需要开销尽可能低,同时也要保证高可用,避免分布式锁失效。
-
安全性:锁只能被持有的客户端删除,不能被其他客户端删除
2.1 分布式锁的演进
- 第一阶段
SETNX 是SET IF NOT EXISTS的简写.日常命令格式是SETNX key value,如果 key不存在,则SETNX成功返回1,如果这个key已经存在了,则返回0。
// 占分布式锁,去redis占坑
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "111");
if(lock) {
//加锁成功... 执行业务
Map<String, List<Catelog2Vo>> dataFromDb = getDataFromDb();
redisTemplate . delete( key: "lock");//fHßti
return dataF romDb ;
} else {
// 加锁失败,重试。synchronized()
// 休眠100ms重试
// 自旋
return getCatalogJsonFromDbwithRedisLock();
}
问题:setnx设置好了值,但是业务代码异常或程序在执行过程中宕机,即没有执行成功删除锁逻辑,导致死锁。
解决:设置锁的自动过期,即使没有删除,会自动删除。
- 第二阶段
// 1. 占分布式锁,去redis占坑
Boolean lock = redisTemplate.opsForValue().setIfAbsent( "lock", "110")
if(lock) {
// 加锁成功...执行业务
// 突然断电
// 2. 设置过期时间
redisTemplate.expire("lock", timeout: 30, TimeUnit.SECONDS) ;
Map<String, List<Catelog2Vo>> dataFromDb = getDataFromDb();
//删除锁
redisTemplate. delete( key; "lock");
return dataFromDb;
} else {
// 加锁失败...重试。 synchronized ()
// 休眠100ms重试
// 自旋的方式
return getCatalogJsonF romDbWithRedisLock();
}
问题:setnx设置好,正要去设置过期时间,宕机,又死锁
解决:设置过期时间和占位必须是原子操作,redis支持使用setNxEx命令。
- 第三阶段
SET key value[EX seconds][PX milliseconds][NX|XX]
// 1. 分布式锁占坑
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "110", 300, TimeUnit.SECONDS);
if(lock)(
// 加锁成功,执行业务
// 2. 设置过期时间,必须和加锁一起作为原子性操作
// redisTemplate. expire( "lock", з0, TimeUnit.SECONDS);
Map<String, List<Catelog2Vo>> dataFromDb = getDataFromDb();
// 删除锁
redisTemplate.delete( key: "lock")
return dataFromDb;
else {
// 加锁失败,重试
// 休眠100ms重试
// 自旋
return getCatalogJsonFromDbithRedislock()
}
问题:业务代码执行的过程中,key过期了,但是业务代码还在执行,最后在删除key的时候会删除到别人的key。
解决:在设置key的值时设置随机值,在删除的时候判断是否为当前线程设置的key。
- 阶段四
Map<String, List<Catelog2Vo>> dataFromDb = getDataFromDb();
String lockValue = redisTemplate.opsForValue().get("lock");
if(uuid.equals(lockValue)) {
// 删除我自己的锁
redisTemplate.delete("lock");
}
问题:如果正好判断是当前值,正要删除锁时,锁已过期,别人已设置成功新值,那删除的就是别人的锁。
解决:删除锁必须保证原子性。使用redis+Lua脚本。
- 阶段五
String script =
"if redis.call('get', KEYS[1]) == ARGV[1]
then return redis.call('del', KEYS[1])
else
return 0
end";
保证加锁【占位+过期时间】和删除锁【判断+删除】的原子性。更难的事情,锁的自动续期。
2.2 Redission框架实现的分布式锁
在阶段五可能存在锁过期释放,业务没执行完的问题。有些小伙伴认为,稍微把锁过期时间设置长一些就可以啦。其实我们设想一下,是否可以给获得锁的线程,开启一个定时守护线程,每隔一段时间检查锁是否还存在,存在则对锁的过期时间延长,防止锁过期提前释放。
只要线程一加锁成功,就会启动一个watch dog看门狗,它是一个后台线程,会每隔10秒检查一下,如果线程1还持有锁,那么就会不断的延长锁key的生存时间。因此,Redisson就是使用Redisson解决了锁过期释放,业务没执行完问题。
public static void main(String[] args) {
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
config.useSingleServer().setPassword("redis1234");
final RedissonClient client = Redisson.create(config);
RLock lock = client.getLock("lock1");
try{
// 加锁
lock.lock();
}finally{
// 释放锁
lock.unlock();
}
}
2.3 Redlock
前面都只是基于单机版的讨论,还不是很完美,其实Redis一般都是集群部署的。
问题:线程一在Redis的master节点上拿到了锁,但是加锁的key还没同步到slave节点。恰好这时,master节点发生故障,一个slave节点就会升级为master节点。线程二就可以获取同个key的锁啦,但线程一也已经拿到锁了,锁的安全性就没了。
Redis作者antirez提出一种高级的分布式锁算法:Redlock。
- 按顺序向5个master节点请求加锁
- 根据设置的超时时间来判断,是不是要跳过该master节点。
- 如果大于等于一半节点加锁成功,并且使用的时间小于锁的有效期,即可认定加锁成功啦。
- 如果获取锁失败,解锁!
三、链接
- https://juejin.cn/post/6936956908007850014#heading-4