樱花落木兰头像
Redis 分布式锁实战,从踩坑到 Lua 脚本最终方案封面图

Redis 分布式锁实战,从踩坑到 Lua 脚本最终方案

一、什么是分布式锁

在单体项目中,我们可以直接使用synchronized或者reentrantlock实现锁,控制多线程并发访问共享资源。 但是分布式环境下,服务部署在多个不同服务器,jvm 之间相互隔离,本地锁失效。 分布式锁就是用来在多个独立服务实例之间,实现互斥访问共享资源的锁方案。

主流分布式锁实现对比

方案实现方式优点缺点
Mysql唯一索引 / 悲观锁实现简单性能差,容易死锁,超时不好处理
Redissetnx + 过期时间高性能,实现简单需要自己处理锁续期、锁误删问题
Zookeeper临时有序节点可靠性高性能偏低,重连会释放锁,运维复杂

项目中最常用的就是Redis 分布式锁,下面一步步从零实现,逐个解决遇到的坑。

// 先setnx,再expire,两条独立命令,不具备原子性
boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "1");
if(lock){
    // 如果这里程序崩溃,expire没有执行,锁永远不会释放!
    redisTemplate.expire("lock",30,timeunit.seconds);
    //执行业务
}

❗问题:setnx和expire是两条命令,中间一旦服务宕机,锁永久存在,死锁。

正确原子命令(Redis 单命令保证原子性)

// set key value nx ex 过期时间,一条命令完成加锁+设置过期时间
boolean ok = redisTemplate.opsForValue()
        .setIfAbsent("lock", uuid, 30, timeunit.seconds);

nx:not exist,不存在才写入;ex:设置过期时间。

这里 value 存入 uuid,不是随便写死数字,为了区分锁是谁加的,防止线程误删别人的锁。

三、第一个大坑:锁被别的线程释放

场景还原:

  1. 线程 a 拿到锁,过期时间 30s
  2. 业务执行很慢,超过 30 秒,redis 自动把锁释放
  3. 线程 b 成功获取到同一个 lock 锁,开始执行业务
  4. 线程 a 业务终于跑完,执行释放锁代码,直接 del lock,把线程 b 持有的锁删掉了!

解决方案:释放锁前,先判断锁的 value 是不是自己的 uuid,只有自己的锁,才允许删除。

// 伪代码:获取当前锁的值
string val = redisTemplate.opsForValue().get("lock");
if(uuid.equals(val)){
    // 确认是自己的锁,再删除
    redisTemplate.delete("lock");
}

但是!这里依然存在新问题:get 和 delete 是两条命令,非原子操作 极端情况:

  • 判断 value 相等之后,准备执行 delete
  • 此时锁刚好过期,redis 自动释放锁,其他线程拿到锁
  • 当前线程执行 del,删掉别人新锁。

👉get+del 必须保证原子性,这时候就需要 Lua 脚本。

四、Lua 脚本解决原子性问题

Redis 执行 Lua 脚本时,整个脚本作为一个原子操作执行,不会被其他命令打断,完美解决多条命令原子性问题。

Lua 脚本(释放锁脚本)

-- 判断key对应value是否等于传入的uuid,相等才删除,返回1;否则返回0
if redis.call('get',KEYS[1]) == ARGV[1] then
    return redis.call('del',KEYS[1])
else
    return 0
end

java 代码调用脚本:

string script = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
redisscript<long> redisscript = redisscript.of(script);
// keys集合放lock key,args放当前线程的uuid
long result = redistemplate.execute(redisscript, lists.newarraylist("lock"), uuid);

五、第二个大坑:锁超时,业务还没执行完(锁续期问题)

场景:锁过期时间 30s,但是业务执行时间超过 30 秒,redis 自动释放锁,其他线程抢占锁。 方案:看门狗(watchdog)自动续期 原理:

  1. 获取锁成功后,开启一个后台定时线程
  2. 每隔一段时间(一般是过期时间的 1/3,比如 30s 过期,每 10s 执行一次)
  3. 判断锁还存在,就延长锁的过期时间
  4. 业务执行完毕,关闭定时任务,释放锁

手写看门狗比较繁琐,生产环境一般直接使用 redisson 框架,内置看门狗。

六、Redisson 框架快速实现分布式锁

redisson 对 redis 分布式锁做了封装,自动处理:原子加锁、lua 释放锁、看门狗自动续期、可重入锁。

1. maven 依赖

<dependency>
    <groupid>org.redisson</groupid>
    <artifactid>redisson-spring-boot-starter</artifactid>
    <version>3.17.7</version>
</dependency>

2. redisson 配置类

@configuration
public class redissonconfig {
    @bean
    public redissonclient redissonclient(){
        config config = new config();
        // 配置redis地址
        config.usesingleserver().setaddress("redis://127.0.0.1:6379");
        return redisson.create(config);
    }
}

3. 业务代码使用

@autowired
private redissonclient redissonclient;

public void testlock(){
    rlock lock = redissonclient.getlock("product:lock");
    try{
        // 尝试获取锁,最多等待10秒,锁默认30s过期,自动看门狗续期
        boolean isget = lock.trylock(10,30, timeunit.seconds);
        if(isget){
            // 执行业务逻辑
        }
    }catch (exception e){
        e.printstacktrace();
    }finally {
        // 判断当前线程持有锁,才释放
        if(lock.isheldbycurrentthread()){
            lock.unlock();
        }
    }
}

edisson 的 rlock 是可重入锁,同一个线程可以多次加锁,底层也是 lua 脚本实现计数。

七、第三个大坑:redis 主从集群下锁失效(主从同步问题)

场景:

  1. 客户端在 redis 主节点成功 set lock,拿到锁
  2. 主节点还没来得及把数据同步给从节点,主节点宕机
  3. 从节点升级成新主节点,新主没有 lock 这条数据,其他客户端又拿到锁

结果:两个客户端同时持有锁,锁失效!

解决方案:

  1. redlock 红锁方案(多个独立 redis 节点,过半节点加锁成功才算拿到锁)
  2. 业务层面做兜底,数据库增加唯一约束,防止超卖等严重业务问题

红锁代价高,生产中很少用;大部分业务,主从延迟带来的风险可以接受,配合数据库兜底。

八、总结

  1. 基础分布式锁,加锁和过期时间必须一条原子命令,不能分两次执行
  2. 释放锁不能直接 del,必须校验锁归属,get+del 要使用 lua 脚本保证原子性
  3. 业务执行时间超过锁过期时间 → 需要看门狗自动续期,推荐 redisson
  4. redis 主从切换会存在锁失效风险,重要业务必须增加数据库兜底方案
  5. 分布式锁核心目标:保证同一时刻,只有一个线程执行共享资源操作

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/2601_96029214/article/details/167399152

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--