Redis--一文搞懂Redis分布式锁
分布式锁
当多个线程要访问一个共享资源(数据库数据或Redis中的数据或共享文件)时,为了达到多个线程同步访问,此时需要使用分布式锁。
让这些线程在访问共享资源之前先获取一个令牌token,持有令牌的线程才可以访问共享资源。这个令牌就是分布式锁。这个分布式锁是一种“互斥资源”,只有一个。
只要有线程抢到锁,其他线程只能等待,直到锁被释放或等待超时。
场景引入
电商普通对商品sk:0008进行秒杀销售。假设商品数量amount=1000,每个账户只能抢购一台,就是每个请求只能减少一台库存。
普通代码
@RestController
public class SecSkillController {
@Autowired
StringRedisTemplate stringRedisTemplate;
@RequestMapping("/secSkill")
public String secSkill() {
// 1. 判断库存是否充足
String stockKey = "stock:0008";
String stockStr = stringRedisTemplate.opsForValue().get(stockKey);
int stock = Integer.parseInt(stockStr);
if (stock <= 0) {
return "秒杀失败,库存不足";
}else{
// 2. 扣减库存
stringRedisTemplate.opsForValue().decrement(stockKey);
return "秒杀成功,剩余库存:" + (stock - 1);
}
}
}
上述代码问题:秒杀是高并发场景,而且生产环境下一定是部署在一个集群中。如果用户量非常大,会存在多用户同时读取Redis缓存中的sk:0008,那么读取到的value很可能相同,都大于0,都能购买,此时会出现“超卖”问题。
setnx 实现方式解决
setnx 是只有在指定的key不存在时才能执行成功,分布式系统中,哪个节点抢到了setnx,谁就抢到了锁,谁就能操作共享资源。其他节点只能等待锁的释放。然后重启使用setnx命令抢注该key。
具体代码如下:
// 锁的key
public static final String Redis_lock = "Redis_lock";
@RequestMapping("/secSkill2")
public String secSkill2() {
String res= "抢购失败!";
Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(Redis_lock, "lock");
try {
if (lock) {
// 1. 判断库存是否充足
String stockKey = "stock:0008";
String stockStr = stringRedisTemplate.opsForValue().get(stockKey);
int stock = Integer.parseInt(stockStr);
if (stock <= 0) {
res = "秒杀失败,库存不足";
}else{
// 2. 扣减库存
stringRedisTemplate.opsForValue().decrement(stockKey);
res = "秒杀成功,剩余库存:" + (stock - 1);
}
}else{
res = "没抢到锁哦!";
}
}finally {
// 释放锁
stringRedisTemplate.delete(Redis_lock);
}
return res;
}
存在的问题:如果该节点在执行完“添加锁”的语句后宕机,其finally中的代码没有执行。那其他节点将永远无法获得锁。
为锁添加过期时间来解决
为key添加过期时间有两种:
- 通过expire 命令为key指定过期时间(setnx和expire命令分别执行,不具备原子性,仍会出现问题)
- 在setnx命令中直接给出key的过期时间(直接在setnx中完成两步操作,具有原子性,应采用这种方式)
@RequestMapping("/secSkill2")
public String secSkill2() {
String res= "抢购失败!";
Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(Redis_lock, "lock", 5, TimeUnit.SECONDS);
try {
if (lock) {
// 1. 判断库存是否充足
String stockKey = "stock:0008";
String stockStr = stringRedisTemplate.opsForValue().get(stockKey);
int stock = Integer.parseInt(stockStr);
if (stock <= 0) {
res = "秒杀失败,库存不足";
}else{
// 2. 扣减库存
stringRedisTemplate.opsForValue().decrement(stockKey);
res = "秒杀成功,剩余库存:" + (stock - 1);
}
}else{
res = "没抢到锁哦!";
}
}finally {
// 释放锁
stringRedisTemplate.delete(Redis_lock);
}
return res;
}
存在问题:代码中设置的过期时间是5秒,如果请求A 的处理的时间超过了5秒钟还没处理完,由于这个锁过期了,另一个请求B 通过setnx申请到锁,此时请求A刚好处理完,开始执行删除锁操作,就会把B申请的锁删掉。
此时其他请求申请锁,就会与B同时访问共享资源,很可能会引发数据不一致问题。
为锁添加标识
上述那种锁被误删的情况,主要是因为所有请求添加锁的value相同,应该是“谁加的锁,谁来删”。
解决办法:为每个请求生成一个uuid,使用这个uuid作为锁的value,只有请求的uuid一致时才可以删除。
实现代码如下:
@RequestMapping("/secSkill3")
public String secSkill3() {
String uuid = java.util.UUID.randomUUID().toString();
String res= "抢购失败!";
Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(Redis_lock, uuid, 5, TimeUnit.SECONDS);
try {
if (lock) {
// 1. 判断库存是否充足
String stockKey = "stock:0008";
String stockStr = stringRedisTemplate.opsForValue().get(stockKey);
int stock = Integer.parseInt(stockStr);
if (stock <= 0) {
res = "秒杀失败,库存不足";
}else{
// 2. 扣减库存
stringRedisTemplate.opsForValue().decrement(stockKey);
res = "秒杀成功,剩余库存:" + (stock - 1);
}
}else{
res = "没抢到锁哦!";
}
}finally {
if(uuid.equals(stringRedisTemplate.opsForValue().get(Redis_lock))){
// 释放锁
stringRedisTemplate.delete(Redis_lock);
}
}
return res;
}
存在问题:在finally中对于删除锁的客户端身份的判断与删除操作是两个语句,不具有原子性,在并发场景下可能会出现问题。
比如:客户端a在节点A中加锁后,执行业务用了6秒,此时锁已过期,然后执行到finally中的判断,并判断结果为真,然后时间片到,暂停执行。
由于A的锁已过期,客户端b在节点B加锁成功,然后很快执行到了业务逻辑(未超过锁的过期时间),此时b的时间片到了。
此时A中的代码获得cpu,继续执行。那么A就会删掉B加的锁。这是很严重的问题。
添加Lua脚本解决
可以通过Lua脚本实现身份判断和删除锁操作的合并。
对Lua脚本执行。可通过eval命令来完成。
在RedisTemplate中没有eval命令对应的方法,而Jedis中有同名方法。
所以先获取到Jedis客户端,才能调用jedis.eval();
- 先导入Jedis依赖
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
实现代码如下:
@Value("${spring.redis.host}")
private String redisHost;
@Value("${spring.redis.port}")
private int redisPort;
@RequestMapping("/secSkill4")
public String secSkill4() {
String uuid = java.util.UUID.randomUUID().toString();
String res= "抢购失败!";
Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(Redis_lock, uuid, 5, TimeUnit.SECONDS);
try {
if (lock) {
// 1. 判断库存是否充足
String stockKey = "stock:0008";
String stockStr = stringRedisTemplate.opsForValue().get(stockKey);
int stock = Integer.parseInt(stockStr);
if (stock <= 0) {
res = "秒杀失败,库存不足";
}else{
// 2. 扣减库存
stringRedisTemplate.opsForValue().decrement(stockKey);
res = "秒杀成功,剩余库存:" + (stock - 1);
}
}else{
res = "没抢到锁哦!";
}
}finally {
JedisPool jedisPool = new JedisPool(redisHost, redisPort);
try(Jedis jedis = jedisPool.getResource()) {
String scrippt = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Object eval = jedis.eval(scrippt, Collections.singletonList(Redis_lock), Collections.singletonList(uuid));
if ("1".equals(eval.toString())){
System.out.println("释放锁成功");
}else{
System.out.println("释放锁失败");
}
}
}
return res;
}
存在问题:请求a的过期,但是业务未执行完毕,请求b申请到锁,也在执行业务,如果此时这两个请求都修改了库存数据,就又出现数据不一致问题,在高并发场景下,问题会被无线放大。
解决方案:采用“锁续约”方式解决。
在当前业务进程开始执行时,fork一个子进程,用于开启一个定时任务。该定时任务的定时时间小于锁的过期时间,其会定时查看请求加的锁是否已被删除。如果被删除,就会结束子进程;如果未被删除,说明请求的业务还未执行完毕,子进程就将锁的时间重新设置为原过期时间。这种方式称为锁续约,也称为锁续命。
Redisson 可重入锁
可重入锁:同一个线程,可以多次获取同一把锁,不会阻塞、不会死锁,释放锁时也要释放多次,直到全部释放。
使用Redisson 的可重入锁可以解决上述问题。
Redisson 使用Lua脚本实现了对可重入锁的添加、冲入、续约、释放。
Redisson 需要用户为锁指定一个key,无需为锁指定过期时间,因为它有默认过期时间30秒,当然也可以手动指定。
Redisson 会为该锁生成一个计数器,记录一个线程重入锁的次数。
那怎么识别是哪个线程加的锁呢?
这个锁存的value的类型是hash
其key 是锁名称;field是客户端唯一 ID + 线程 ID(标记是谁加的锁);value:是加锁次数(重入计数)
遇到加锁时,判断是当前线程,那么计数+1
释放锁时,每释放一次,计数-1,减到0时,才真正删除这个锁。
加锁次数必须和解锁次数一样,才会释放锁。
其依然使用Lua脚本保证其 身份判断和加锁/解锁操作 的原子性。
使用看门狗线程实现锁续约。
加锁成功后,后台开启看门狗线程。
每10秒自动延长锁过期时间,防止业务没执行完而锁被释放。
导入Redisson 依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.5.0</version>
</dependency>
修改启动类Application
在Application 中添加一个由单Redis节点构建的Redisson 的Bean。
@SpringBootApplication
public class SpringbootRedisApplication {
public static void main(String[] args) {
SpringApplication.run(SpringbootRedisApplication.class, args);
}
@Value("${spring.redis.host}")
private String redisHost;
@Value("${spring.redis.port}")
private int redisPort;
@Bean
public Redisson redisson(){
Config config = new Config();
config.useSingleServer().setAddress("redis://" + redisHost + ":" + redisPort).setDatabase(0);
return (Redisson) Redisson.create(config);
}
}
修改controller类
@Autowired
private Redisson redisson;
@RequestMapping("/secSkill5")
public String secSkill5() {
String res= "抢购失败!";
RLock rlock = redisson.getLock(Redis_lock);
try {
boolean lock = rlock.tryLock();
if (lock) {
// 1. 判断库存是否充足
String stockKey = "stock:0008";
String stockStr = stringRedisTemplate.opsForValue().get(stockKey);
int stock = Integer.parseInt(stockStr);
if (stock <= 0) {
res = "秒杀失败,库存不足";
}else{
// 2. 扣减库存
stringRedisTemplate.opsForValue().decrement(stockKey);
res = "秒杀成功,剩余库存:" + (stock - 1);
}
}else{
res = "没抢到锁哦!";
}
}catch (Exception e) {
System.out.println("发生异常了");
} finally {
rlock.unlock();
}
return res;
}
存在问题:Redis单机情况下,以上代码没有问题;如果在Redis主从集群中,会存在锁丢失问题。
在Redis主从集群中,节点A为master,B、C为slave。
如果请求a处理请求时加锁,向节点A添加一个key,然后调用应用服务器Sa响应,然后向slave同步key。但是很不幸,同步还没开始,节点A宕机,节点B晋升为master。此时正好请求b请求服务器Sb响应。
由于Sa和Sb都加锁成功,他们同时对共享数据进行处理,这就由出现并发问题。该问题称为主从集群的锁丢失问题。
Redisson 红锁
Redisson 红锁可以防止主从集群锁丢失问题。
Redisson 红锁要求。必须构建除至少三个Redis主从集群。若一个请求要申请锁,必须向所有主从集群中提交key写入,当大多数集群锁写入成功后,该锁才算申请成功。
修改启动类Application
在启动类中添加三个Sentinel 集群构建的Redisson 的Bean。
@Bean("redisson-1")
public Redisson redisson1(){
Config config = new Config();
config.useSentinelServers().setMasterName("mymaster1").addSentinelAddress("redis:16380","redis:16381","redis:16382");
return (Redisson) Redisson.create(config);
}
@Bean("redisson-2")
public Redisson redisson2(){
Config config = new Config();
config.useSentinelServers().setMasterName("mymaster2").addSentinelAddress("redis:26380","redis:26381","redis:26382");
return (Redisson) Redisson.create(config);
}
@Bean("redisson-3")
public Redisson redisson3(){
Config config = new Config();
config.useSentinelServers().setMasterName("mymaster3").addSentinelAddress("redis:36380","redis:36381","redis:36382");
return (Redisson) Redisson.create(config);
}
修改controller类
@Resource(name = "redisson-1")
private Redisson redisson1;
@Resource(name = "redisson-2")
private Redisson redisson2;
@Resource(name = "redisson-3")
private Redisson redisson3;
@RequestMapping("/secSkill6")
public String secSkill6() {
String res= "抢购失败!";
RLock rlock1 = redisson1.getLock(Redis_lock+"_1");
RLock rlock2 = redisson2.getLock(Redis_lock+"_2");
RLock rlock3 = redisson3.getLock(Redis_lock+"_3");
RLock rlock = new RedissonRedLock(rlock1,rlock2,rlock3);
try {
boolean lock = rlock.tryLock();
if (lock) {
// 1. 判断库存是否充足
String stockKey = "stock:0008";
String stockStr = stringRedisTemplate.opsForValue().get(stockKey);
int stock = Integer.parseInt(stockStr);
if (stock <= 0) {
res = "秒杀失败,库存不足";
}else{
// 2. 扣减库存
stringRedisTemplate.opsForValue().decrement(stockKey);
res = "秒杀成功,剩余库存:" + (stock - 1);
}
}else{
res = "没抢到锁哦!";
}
}catch (Exception e) {
System.out.println("发生异常了");
} finally {
rlock.unlock();
}
return res;
}
问题
无论哪种锁,他们解决变更发问题的思路都是相同的,就是将所有请求通过锁实现串行化,而这在高并发场景下势必会引发性能问题。
分段锁
分段锁的本质就是使访问并行化。将一个资源拆成多个资源,这样就将一把锁的需求变为多把锁,从而实现并行化。
例如:对于秒杀商品sk:0008,库存1000件,现将其拆分为10份,每份100件。
也就是将秒杀商品变为了10件,这样就能用10把锁控制请求并发,每个时刻可以同时处理10个请求。并发提高了10倍。
本文来自博客园,作者:NE_STOP,转载请注明原文链接:https://www.cnblogs.com/alineverstop/p/20033847
浙公网安备 33010602011771号