Redis 基础 - 优惠券秒杀《分布式锁(使用Redisson)》

Redis基础 - 基本类型及常用命令
Redis基础 - Java客户端
Redis 基础 - 短信验证码登录
Redis 基础 - 用Redis查询商户信息
Redis 基础 - 优惠券秒杀《非集群》
Redis 基础 - 优惠券秒杀《分布式锁(初级)》

基于setnx实现的分布式锁存在的问题

1)不可重入
同一个线程无法多次获取同一把锁

2)不可重试
获取锁只尝试一次就返回false,没有重试机制

3)超时释放
锁超时释放虽然可以避免死锁,但如果是业务执行耗时较长,也会导致锁释放,存在安全隐患

4)主从一致性
暂时可以理解成读写分离模式,即有一个主节点和多个从节点,当执行写操作时,就去访问主节点,当执行读操作时,去访问从节点。当然,主节点需要把自己的数据同步给所有的从节点,保证主和从的数据一致,这样的话,可以在多个节点上完成读的操作,提高整个服务的并发能力和高可用性。
而且如果主节点宕机了,可以在从节点中选出一个当做新的主节点,这样整个集群的可用性就更强了。但是主从之间的数据同步是有延迟的,所以在极端情况下,可能会发生这样的情况,比如有个线程在主节点获取了锁,因为获取锁是set操作,是一个写操作,在主节点完成写操作之后,假如尚未同步给从节点的时候,因为存在延迟,突然间主节点宕机,此时我们就会选新的从作为主,而这个从节点上因为没有完成同步,所以她是没有这个锁的标示,即这时候其他线程可以趁虚而入,去拿到锁(新的set),这时候就等于是多个线程拿到了锁,所以可能在极端情况下出现安全问题。当然,她出现的概率较低,因为主从同步的延时极低,往往可以在毫秒级别(甚至更低)完成。
所以以上说的这四个问题,要么发生概率极低,要么就是不一定有这样的需求,在有些业务上需要,而在有些业务上不需要,所以这些个点都是功能上的拓展点,不是说必须得实现。那么不实现我们也能够用,其实大多数场景下,之前实现的锁已经够用了。比如主从一致性问题若用单节点,就不存在这个问题了。
但如果你对锁的要求很高,或你有这样的需求,必须想办法去解决这四个问题。而解决这些问题可就麻烦了。不可重入问题好说,但不可重试、超时释放、主从一致性等问题解决起来相对麻烦,整个实现起来也很繁琐。所以不推荐亲自去实现,而是去找找成熟的框架帮我们实现,那就是Redisson。

Redisson 分布式锁

Redisson 是一个在Redis基础上实现的一个分布式工具的集合。即分布式系统下用的各种各样的东西她都有,包括分布式锁,分布式锁只是她其中的一块儿功能。
官网地址:https://redisson.org
github地址:https://github.com/redisson/redisson

在企业环境下其实没有必要自己去实现锁,之前的内容只是有助于明白分布式锁的原理。所以推荐大家以后使用分布式锁时,直接使用这种开源框架就可以。(网友1:经典白学)

简单使用例子

1)引入依赖
pom.xml

<dependency>
	<groupId>org.redissongroupId>
	<artifactId>redissonartifactId>
	<version>3.13.6version>
dependency>

2)配置Redisson
src/main/java/com.asd/config/RedissonConfig.java

@Configuration
public class RedissonConfig {
	@Bean
	public RedissonClient redissonClient() {
		// 配置,用org.redisson.config的Config
		Config config = new Config();

		// 配置地址,这里是单节点模式
		config.useSingleServer().setAddress("redis://redis所在服务器IP:6379").setPassword("123123");

		// 创建RedissonClient 对象
		return Redisson.create(config);
	}
}

3)修改优惠券秒杀代码中获取锁部分
VoucherOrderServiceImpl.java

// 注入RedissonClient
@Resource
private RedissonClient redissonClient;
...
@Override
public Result seckillVoucher(Long voucherId) {
	...
	// 创建锁对象(旧的,改为如下)
	// SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
	// 锁对象(新的)
	RLock lock = redissonClient.getLock("lock:order:" + userId);

	// 获取锁(旧的,改为如下)
	boolean isLock = lock.tryLock(1200);

	/*
	获取锁(新的)RLock里面也有tryLock和unlock,RLock的tryLock参数:[1]等待时间(即锁获取
	失败后,可以在这个时间内去重试获取锁)[2]超时时间,[3]时间单位。
	当然,也可以不传参数,那么默认等待时间是-1,即不等,失败了立即返回。超时时间是默认30秒。
	*/
	// 由于在这个例子中是不重试,所以直接不传参数就行。
	boolean isLock = lock.tryLock();
	...
}

Redisson可重入锁原理

什么是锁重入

// 创建锁对象
RLock lock = redissonClient.getLock("lock");

@Test
void method1() {
	boolean isLock = lock.tryLock();
	if (!isLock) {
		return "没获取";
	}
	try {
		method2();// 获取锁后,调用method2
	}finally {
		lock.unlock();
	}
}
void method2() {
	boolean isLock = lock.tryLock();
	if (!isLock) {
		return "没获取";
	}
	try {
		// 获取锁成功
	}finally {
		lock.unlock();
	}
}

这里method1调method2,所以他两是在一个线程里,在这一个线程里,连续两次去获取锁,这就是锁的重入。

为什么setnx方式无法锁重入

之前自定义的锁采用的是Redis的string类型,其获取锁的流程大概是,先尝试获取锁(其实就是执行 set lock thread1 nx ex 10),加nx是为了实现互斥,加标示thread1是为了避免误删。这样的流程为何不能重入呢?比如上面的代码,假如用的是自定义的,那么在method1里获取锁就是set nx操作,接着调用method2,里面再试图获取锁,即又要执行set nx操作,由于加了nx参数,所以在metho1里已经设置了值,所以method2里获取锁是失败的。所以用自定义的那个方式,没法实现重入了。

要想实现可重入锁,可以参考JDK提供的ReentrantLock的原理。简单说的话,所谓的可重入,无非就是在获取锁的时候,当我判断这个锁已经有人的情况下,看看是不是同一个线程的,如果是同一个线程的,我也会让她获取锁。所以,我们实现时,也可以参考她。即我们不仅记录获取锁的线程标示,还要记录她重入的次数,即总共拿了几次锁,没拿一次次数就加一。由于既要记录线程标示,还要记录重入的次数,所以string结构显然有点不行了,所以要使用hash结构。

自定义的方式下简单实现锁重入(主要是有助于了解Redisson的相关原理

即执行上面代码时,执行到method1中的获取锁代码后,在Redis里存线程标示和重入次数即1,接着往下走调用method2,又一次要获取锁,首先要看看这个锁是不是有人(用key判断有无值,用exists命令),如果有,再判断一下获取锁的线程是不是我自己,由于method1是调method2的,所以他两线程标示是一样的,所以查了之后发现一样的话,这时候只需要把重入的次数加1就可以,即变为2,代表第二次获取了锁。以此类推,若再有重入,继续累加。

这时候method2拿到了锁,然后可以执行自己的业务,最后释放锁,释放时不能像以前那样判断标示是我,就直接删除。
如果method2直接把锁删掉,而method2结束并不等于所有业务结束,因为method2完了以后,还要执行method1的剩余部分业务,而如果method2把锁给删了,那么method1还没执行完呢其他的线程就会趁虚而入了,这样就有可能发生一些并发的安全问题。
所以对于可重入锁来讲,在内部被调用的方法里面释放锁的时候是不能直接删除锁,而是采取的措施是要把重入次数减一,即每释放一次,就减一。那么什么时候的释放锁,真正把锁删除呢?当方法走到最外层时(这里是method1),这个重入次数的值,一定会被减成0,比如metho1里又一次unlock,这时候重入数就变成0了,所以每次释放锁时,除了减去重入次数外,还要判断一下这个值是否已经为0了,如果已经是0了,就能证明我已经到了最外层的方法了,也就是说没有其他业务需要执行了,此时可以放心大胆的把这个锁删除。由于这个过程跟以前相比复杂了很多,而且代码也有多个步骤,所以这样的逻辑不可能再用Java代码去实现了,而是用lua脚本,来确保获取锁和释放锁的原子性。

获取锁的lua脚本

local key = KEYS[1]; -- 锁的key
local threadId = ARGV[1]; -- 线程唯一标识
local releaseTime = ARGV[2]; - 锁的自动释放时间
-- 判断是否存在
if (redis.call('exists', key) == 0) then -- 不再用nx了,用exists判断
	-- 不存在,获取锁
	redis.call('hset', key, threadId, '1');-- 由于是哈希结构,所以是hset命令,1是指重入次数,第一次来时设置为1
	-- 设置有效期
	redis.call('expire', key, releaseTime);
	return 1; -- 返回结果
end;
-- 锁已经存在,判断threadId是否是自己。即判断threadId这个标示在hash里存不存在就行
if(redis.call('hexists', key, threadId) == 1) then -- hexists命令:查看哈希表的指定字段是否存在。存在就说明锁是自己的。
	-- 存在,获取锁,重入次数+1
	redis.call('hincrby', key, threadId, '1');
	-- 设置有效期
	redis.call('expire', key, releaseTime); -- 重新设置,让接下来的业务保证执行
	return 1; -- 返回结果
end;
return 0; -- diamante走到这里,说明获取锁的不是自己的,获取锁失败

释放锁的lua脚本

local key = KEYS[1]; --- 锁的key
local threadId = ARGV[1]l -- 线程唯一标识
local releaseTime = ARGV[2]l -- 锁的自动释放时间
-- 判断当前锁是否还是被自己持有
if (redis.call('HEXIST', key, threadId) == 0) then
	return nil; -- 如果已经不是自己,则直接返回(代表自己的锁早已被释放掉了)
end;
-- 是自己的锁,则重入次数 -1
local count = redis.call('HINCRBY', key, threadId, -1);
-- 判断是否重入次数已经为0
if (count > 0) then
	-- 大于0说明不能释放锁,重置有效期然后返回
	redis.call('EXPIRE', key, releaseTime);
	return nil;
else -- 等于0说明可以释放锁,直接删除
	redis.call('DEL', key);
	return nil;
end;

如果看Redisson的锁相关源码的话,就能发现Redisson可重入锁实现原理也跟上面的差不多。

Redisson 可重试、超时释放(以及可重入)原理

原理源码啥的下辈子闲的没事干了再看吧,反正直接使用就行,比如如下:

// 在上面使用redission的tryLock函数时,没有指定参数。其实,她里面是可以指定三个参数的。
RLock lock = redissonClient.getLock("lock");
boolean isLock = lock.tryLock();

参数1是获取锁的最大等待时长,一旦你给了这个参数,那么她在第一次获取锁失败之后,就不会立即返回了,而是在等待时间内不断的去尝试,如果这个时间结束了还没有获取成功,那么他才会返回false。所以一旦加这个参数,就变成一个可重试的锁了。参数2是自动释放失效的时间,参数3是时间的单位。
当然,还可以只传两个参数,因为参数2和参数3是有默认值,所以不传也行,但第一个不传的话,默认是没有可重试的,所以要加才能可重试。即在2个参数时,参数1是等待时间,参数2是时间单位(个人感觉等待时间和超时时间都公用这个单位?),比如如下:

boolean isLock = lock.tryLock(1L, TimeUnit.SECONDS);// 设置等待时间为1秒

Redisson 主从一致性的解决

之前采用的都是单节点Redis,所以如果这台Redis出问题,所有项目中依赖Redis的部分都会出现问题,包括分布式锁。所以在一些核心业务当中,这样的情况是不允许发生的。为了解决这个问题,提高Redis的可用性,往往在实际的应用当中搭建Redis的主从模式。所谓主从其实是有多台Redis,只不过他们的角色不同,其中有一台可以称为主节点,而剩下的都是从节点。而主从他们的职责也不一样,往往会做读写的分离,即在主节点里处理所有发向Redis的写的操作,比如增删改。而从节点只负责处理读的操作。所以可能会有的问题是,既然主节点处理写操作,那所有的数据都是在主节点里存在的,那么从节点没数据的话怎么来处理读的请求呢,所以主和从之间需要进行数据的同步,主节点会把自己的数据不断的同步给从节点,以此来确保主从之间数据一致性。但是毕竟不是在同一台机器,所以主与从之间会有一定的延时,所以数据同步也会存在一定的延时,尽管延时很短,但她毕竟存在。主从一致性的问题正式因为这个延时导致。

比如现在有Java应用,她要来获取锁,即set nx写操作,执行到主节点时,主节点上就要保存锁的标示,而后主节点就向从节点进行同步,但是就在此时,主节点发生了故障,即同步尚未完成,这时候Redis里有哨兵监控集群状况,当他发现主节点宕机以后,首先客户端链接会断开,而后她会从从节点中,去选出一个新的主节点,但是因为之前主从同步未完成,即锁已经丢失了,所以Java应用再来新的主节点时,就会发现锁已经没了,即锁失效。此时其他线程来获取锁,也能获取成功。就会出现并发的安全问题。这就是主从一致性导致的锁失效问题。

那么Redisson是怎么解决这个问题的呢,她的思路非常简单粗暴,既然主从关系是导致一致性问题发生的原因,那干脆我就不要主从了,即我的所有的节点都变成了独立的Redis节点,相互之间没有任何关系,即每个都可以去做读写。那么此时,我们获取锁的方式就变了,以前获取锁,只需要找到主节点,在他那里获取就行,但是现在,必须一次的向多个Redis获取锁,不管你是三个节点还是五个节点,必须依次都获取锁,每个Redis节点都保存锁的标示,才算是获取锁成功。那么此时会不会出现安全问题呢,首先因为没主从,所以没有主从一致性问题,其次可用性,比如就算真的有一个节点宕机了,我们的Redis还是可用的,因为一个宕机了,还有其他几个节点,而这个可用性随着节点增多越来越高。

代码示例

先搭建好3个Redis节点。

1)配置三个Redis客户端
src/main/java/com.asd/config/RedissonConfig.java

@Configuration
public class RedissonConfig {

	@Bean
	public RedissonClient redissonClient() {
		// 配置,用org.redisson.config的Config
		Config config = new Config();

		// 配置地址,这里是单节点模式
		config.useSingleServer().setAddress("redis://redis所在服务器IP:6379").setPassword("123123");

		// 创建RedissonClient 对象
		return Redisson.create(config);

	}

	@Bean
	public RedissonClient redissonClient2() {
		// 配置,用org.redisson.config的Config
		Config config = new Config();

		// 配置地址,这里是单节点模式
		config.useSingleServer().setAddress("redis://redis所在服务器IP:6380").setPassword("123123");

		// 创建RedissonClient 对象
		return Redisson.create(config);
	}

	@Bean
	public RedissonClient redissonClient3() {
		// 配置,用org.redisson.config的Config
		Config config = new Config();

		// 配置地址,这里是单节点模式
		config.useSingleServer().setAddress("redis://redis所在服务器IP:6381").setPassword("123123");

		// 创建RedissonClient 对象
		return Redisson.create(config);
	}
}

RedissonTest.java

@SpringBootTest
class RedissonTest {
	@Resource
	private RedissonClient redissonClient;

	@Resource
	private RedissonClient redissonClient2;

	@Resource
	private RedissonClient redissonClient3;

	private RLock lock;
	
	@BeforeEach
	void setUp() {
		// 三个独立节点对应的三个独立锁
		RLock lock1 = redissonClient.getLock("order");
		RLock lock2 = redissonClient2.getLock("order");
		RLock lock3 = redissonClient3.getLock("order");

		// 创建连锁。这样的话,就会把三个连在一起,变成连锁。
		/* 这里用redissonClient来干,其实用redissonClient2,redissonClient3来干都一样,
		因为进入getMultiLock后,new了RedissonMultiLock(locks),所以谁干都一样。*/
		lock = redissonClient.getMultiLock(lock1,lock2,lock3);
	}

	@Test
	void method1() {
		boolean isLock = lock.tryLock();
		if (!isLock) {
			return "没获取";
		}
		try {
			method2();// 获取锁后,调用method2
		}finally {
			lock.unlock();
		}
	}

	void method2() {
		boolean isLock = lock.tryLock();
		if (!isLock) {
			return "没获取";
		}
		try {

			// 获取锁成功

		}finally {
			lock.unlock();
		}
	}
}

这样的话差不多实现了用Redisson代替自定义的分布式锁,实现了一人一单。

到这为止,秒杀相关业务基本完成了,但正式因为我们加入了各种各样的锁,秒杀业务的性能也会受到巨大的影响,所以还需要一些进一步优化,从而提升她的性能。

你可能感兴趣的:(Redis,Redis基础,Redis实战,Redis分布式锁,Redisson,Redisson分布式锁)