Redis-事务/分布式锁

792 阅读9分钟

一、Redis的事务机制

提到事务,相信大家都不陌生,事务的ACID四大特性,其实Redis也是提供了事务机制的,下面就来讲解下Redis的事务机制。

1.1 事务演示

Redis的事务提供了一种将多个命令请求打包,然后一次性、按顺序性地执行多个命令的机制。

在事务执行期间,服务器不会中断事务而去执行其它客户端的命令请求,它会将事务中的所有命令执行完毕,然后才去处理其它客户端的命令请求。

image.png

1.2 事务实现原理

一个事务从开始到结束会经历以下3个阶段:

  1. 事务开始
  2. 命令入队
  3. 事务执行
  • 事务执行开始 MULTI命令的执行标志着事务的开始.

执行完该命令后,客户端状态的flags属性会打开REDIS_MULTI标识,表示该客户端从非事务状态切换至事务状态。

image.png

  • 命令入队 当一个客户端处于非事务状态时,这个客户端发送的命令会立即被服务器执行:

image.png

当一个客户端处于事务状态时,这个客户端发送的命令,服务器是否会立即执行,分为以下2种情况:

  1. 如果客户端发送的命令为MULTIEXECWATCHDISCARD四个命令中的其中1个,服务器会立即执行这个命令。
  2. 如果客户端发送的命令为以上4个命令外的其它命令,服务器不会立即执行这个命令,而是将其放到事务队列里,然后向客户端返回QUEUED回复。

image.png

以上流程可以使用以下流程图来表示:

image.png

  • 事务执行 当一个处于事务状态的客户端执行EXEC命令时,服务器会遍历这个客户端的事务队列,执行队列中保存的所有命令(按先入先出顺序),然后将执行命令的结果一次性返回给客户端。

image.png

1.3 witch命令实现原理

WATCH命令用于监视任意数量的数据库键,并在EXEC命令执行时,检测被监视的键是否被修改,如果被修改了,服务器将拒绝执行事务,并向客户端返回空回复。

首先,我们打开客户端1,执行WATCH命令监视键“name”,然后开启一个事务:

image.png

打开客户端2,执行以下命令修改“name”键的值:

image.png

在客户端1执行EXEC命令时,会返回空回复,因为“name”键的值在客户端2已经被修改:

image.png

那么,WATCH命令的实现原理是什么样的呢?我们从以下3个方面来分析:

  1. 使用WATCH命令监视数据库键
  2. 监视机制的触发
  3. 判断事务是否安全
  • 使用WATCH命令监视数据库键 每个Redis数据库都保存着1个watched_keys字典,这个字典的键是某个被WATCH命令监视的数据库键,字典的值是一个链表,链表中记录了所有监视相应数据库键的客户端。

image.png

假如客户端1正在监视键“name”,客户端2正在监视键“age”,那么watched_keys字典存储的数据大概如下:

image.png

客户端3执行了以下WATCH命令:

那么watched_keys字典存储的数据就变为:

image.png

  • 监视机制的触发

既然watched_keys字典存储了被WATCH命令监视的键,那么监视机制是如何被触发的呢?

所有对数据库修改的命令,比如SETLPUSHSADD等,在执行之后都会对watched_keys字典进行检查,如果有客户端正在监视刚刚被命令修改的键,那么所有监视该键的客户端的REDIS_DIRTY_CAS标识将被打开,表示该客户端的事务安全性已经被破坏。

  • 判断事务是否安全

最后非常关键的一步是,当服务器接收到一个客户端发来的EXEC命令时,服务器会根据这个客户端是否打开了REDIS_DIRTY_CAS标识来决定是否执行事务,判断的流程图如下所示:

image.png

1.4 事务失败的情况

  • 命令入队出错 事务因为命令入队出错被服务器拒绝执行,事务中的所有命令都不会被执行

image.png

  • 不存在的命令 事务入队时出现了不存在的命令,服务器将拒绝执行这个事务

image.png

  • 命令在执行期间报错 RPUSH命令在执行期间报错了,但后续命令仍然继续执行,并且之前执行的命令没有受到任何影响:

image.png

例子也说明:Redis事务不支持回滚机制

1.5 事务总结

Redis的事务提供了一种将多个命令打包,然后一次性、有序地执行的机制。

它的原理是多个命令会被入队到事务队列中,然后按先进先出(FIFO)的顺序执行。

并且事务在执行过程中不会被中断,当事务队列中的所有命令都被执行完毕之后,事务才会结束。

二、Redis实现的分布式锁

分布式锁其实就是,控制分布式系统不同进程共同访问共享资源的一种锁的实现。如果不同的系统或同一个系统的不同主机之间共享了某个临界资源,往往需要互斥来防止彼此干扰,以保证一致性。

image.png

  • 互斥性: 任意时刻,只有一个客户端能持有锁。

  • 锁超时释放:持有锁超时,可以释放,防止不必要的资源浪费,也可以防止死锁。

  • 可重入性:一个线程如果获取了锁之后,可以再次对其请求加锁。

  • 高性能和高可用:加锁和解锁需要开销尽可能低,同时也要保证高可用,避免分布式锁失效。

  • 安全性:锁只能被持有的客户端删除,不能被其他客户端删除

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框架实现的分布式锁

阶段五可能存在锁过期释放,业务没执行完的问题。有些小伙伴认为,稍微把锁过期时间设置长一些就可以啦。其实我们设想一下,是否可以给获得锁的线程,开启一个定时守护线程,每隔一段时间检查锁是否还存在,存在则对锁的过期时间延长,防止锁过期提前释放。

image.png

只要线程一加锁成功,就会启动一个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。

image.png

  • 按顺序向5个master节点请求加锁
  • 根据设置的超时时间来判断,是不是要跳过该master节点。
  • 如果大于等于一半节点加锁成功,并且使用的时间小于锁的有效期,即可认定加锁成功啦。
  • 如果获取锁失败,解锁!

三、链接

-juejin.cn/post/692227…

- https://juejin.cn/post/6936956908007850014#heading-4

- https://juejin.cn/post/6844904126288150542#heading-8

-www.jianshu.com/p/3657397cc…