【Redis】简单实现分布式锁

目录标题

  • 思路
    • [🔺 如何加锁、解锁](#🔺 如何加锁、解锁)
    • [🔺 如果获取锁当前失败了,如何进行重试](#🔺 如果获取锁当前失败了,如何进行重试)
    • [🔺 加锁、解锁:如何考虑锁的重入问题](#🔺 加锁、解锁:如何考虑锁的重入问题)
    • [🔺 加锁、解锁的唯一性:防止误删除、独占排他性](#🔺 加锁、解锁的唯一性:防止误删除、独占排他性)
    • [🔺 服务器宕机:Redis 宕机](#🔺 服务器宕机:Redis 宕机)
    • [🔺 锁的自动续期:程序操作时间比加锁的时候长](#🔺 锁的自动续期:程序操作时间比加锁的时候长)
    • [🔺 保证高可用:在集群模式下,会导致锁机制失效](#🔺 保证高可用:在集群模式下,会导致锁机制失效)
  • 优化
  • 代码实现
  • 总结

思路

🔺 如何加锁、解锁

使用基本的命令 SETNX 以及 DEL 指令

🔺 如果获取锁当前失败了,如何进行重试

自旋重试

🔺 加锁、解锁:如何考虑锁的重入问题

利用 HINCRBY 的命令 +1 / -1

🔺 加锁、解锁的唯一性:防止误删除、独占排他性

加锁、解锁的时候,利用给每个锁加个唯一的标识来实现

🔺 服务器宕机:Redis 宕机

获取锁成功以后宕机,也就是在Redis里面存在了一条 k-v 键值对的锁了

加锁的时候,添加上过期时间

🔺 锁的自动续期:程序操作时间比加锁的时候长

先 HEXIST 判断有没有锁存在,如果存在,则使用定时任务进行自动续期

🔺 保证高可用:在集群模式下,会导致锁机制失效

红锁算法


优化

在上面可以得出,我们会有一些逻辑性的判断,再加上 Redis 本身是不支持事务的,所以我们就需要有一个Lua 脚本来辅助我们进行实现。

java 复制代码
加锁:setnx key value:1. 独占排它锁

解锁:del key

重试:递归 or 循环 

过期时间:expire:
2. 避免发生死锁(Redis客户端从Redis服务中获取到锁以后立刻宕机 / 锁重入) 

3. 原子性:加锁与过期时间、判断锁与释放锁

4. 防止误删除 (加锁与解锁必须的原子性,即谁加锁就是谁解锁 + 先判断在删除)

5. 自动续期 (保证程序的运行时间与锁的存活时间保持一致)

6. 在集群模式下,会导致锁机制失效
	1. 客户端10010,从主中获取到锁
	2. 由于主还没来得及同步从数据,主宕机,从升级为主
	3. 客户端10086,从新主中获取到锁

==========================(进一步优化)==================================
解决1,2,(宕机)3 问题:
	加锁:set key value [ex seconds] [ px milliseconds] [nx | xx] 
	设 k - v,[过期时间 seconds 秒] [过期时间 milliseconds 毫秒] [只在键不存在 | 只在键存在, 才对键进行设置操作]
------------------------------------------------
解决2(锁重入):hash + lua脚本 

hset lockName ownerLockName ownNum 

加锁:		
	1. 判断锁是否存在且无人拥有?  EXISTS key [key ...]
		是:获取锁 HSET key field value
			判断是不是自己的锁 HEXISTS key field
				是:重入 HINCRBY key field increment
				否:重试 递归或者循环
		否:重试 递归或者循环		
	# lua脚本1.0:基本实现逻辑
		if redis.call('EXISTS', 'lock') == 0
		then 
			// redis.call('HSET', lock , uuid, 1) 等价于
			redis.call('HINCRBY', 'lock', uuid ,1)
			redis.call('expire', lock, 30)
			return 1
		elseif redis.call('HEXISTS', 'lock', uuid) == 1
		then 
			redis.call('HINCRBY', 'lock', uuid ,1)
			redis.call('expire', lock, 30)
			return 1
		else 
			return 0
		end
	# lua脚本2.0:进一步优化
		if redis.call('EXISTS', 'lock') == 0 or redis.call('HEXISTS', 'lock', uuid) == 1
		then
			redis.call('HINCRBY', 'lock', uuid ,1)
			redis.call('expire', lock, 30)
			return 1
		else
			return 0
		end
	# lua脚本3.0:将形参放到脚本之中
		if redis.call('EXISTS', KEYS[1]) == 0 or redis.call('HEXISTS', KEYS[1], ARGV[1]) == 1
		then
			redis.call('HINCRBY', KEYS[1], ARGV[1] ,1)
			redis.call('expire', KEYS[1], ARGV[2])
			return 1
		else
			return 0
		end
	key: lock
    arg: uuid 30
    # lua脚本4.0:转化为一行
    		if redis.call('EXISTS', KEYS[1]) == 0 or redis.call('HEXISTS',KEYS[1], ARGV[1]) == 1 then redis.call('HINCRBY', KEYS[1], ARGV[1] ,1) redis.call('expire', KEYS[1], ARGV[2]) return 1 else return 0 end
    		
解锁:
	1. 判断锁是否存在,且是否为自己的锁,不存在则返回 恶意释放锁nil;
	2. 如果自己的锁存在,则 减1,判断减1 后的值是否为0,为0则释放锁,返回 全部解锁成功1;
	3. 不为0,则代表已经对锁释放了一次,返回 释放锁一次执行成功0
	
	# lua脚本1.0:基本实现
	if redis.call('HEXISTS', KEYS[1], ARGV[1]) == 0
	then 
		return nil
	elseif redis.call('HINCRBY', KEYS[1], ARGV[1] , -1) == 0
    then 
    	return redis.call('del', KEYS[1])
    else
    	return 0
    end 
    key: lock
    arg: uuid 
    # lua脚本2.0:基本实现
    if redis.call('HEXISTS', KEYS[1], ARGV[1]) == 0 then return nil elseif redis.call('HINCRBY', KEYS[1], ARGV[1] , -1) == 0 then return redis.call('del', KEYS[1]) else return 0 end 	
-------------------------------------------
	
解决4问题:防止误删除 :给每个锁加个唯一的标识 + 解锁用lua脚本
	127.0.0.1:6379> eval "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end" 1 lock 2
(integer) 0
-------------------------------------------
解决5问题:定时任务(Timer定时器) + lua 脚本
	判断自己的锁是否存在(hexist),如果存在则重置过期时间
	
	if redis.call('HEXISTS', KEYS[1], ARGV[1]) == 1
	then 
		return redis.call('expire',KEYS[1],ARGV[2])
	else 
    	return 0
    end	
	
	key: lock
	arg: uuid 30
	
	if redis.call('HEXISTS', KEYS[1], ARGV[1]) == 1 then return redis.call('expire',KEYS[1],ARGV[2]) else return 0 end	
	
6. 解决6 (在集群模式下,会导致锁机制失效	)
	红锁算法:多个Redis,相互独立,没有主从关系
		1.  应用程序获取到系统当前时间T1
		2.  应用程序使用相同的 k-v 一次从多个Redis实例中获取到锁,其中获取锁的尝试时间需要设置,以便于尽快访问下一个结点
		3.  计算 获取锁的消耗时间CT1 = 应用用程序获取到系统当前时间T2 - T1
			成功获取到锁:获取锁的消耗时间CT1 < 应用程序总的锁定时间 ,且 半数以上的结点获取锁成功
		4.  计算剩余锁定时间 = 应用程序总的锁定时间 - 获取锁的消耗时间CT1
		5.  如果获取锁失败了,对所有的Redis节点释放锁

代码实现

java 复制代码
public class RedisLockUtil implements Lock {

    private StringRedisTemplate redisTemplate;

    private String lockName;

    private String uuid;

    // 秒为单位
    private long expire = 30;

    public RedisLockUtil(StringRedisTemplate redisTemplate, String lockName, String uuid) {
        this.redisTemplate = redisTemplate;
        this.lockName = lockName;
        this.uuid = uuid + ":" + Thread.currentThread().getId();
    }

    @Override
    public void lock() {
        this.tryLock();
    }

    @Override
    public void lockInterruptibly() throws InterruptedException {

    }

    @Override
    public boolean tryLock() {
        try {
            return this.tryLock(-1L, TimeUnit.SECONDS);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return false;
    }

    /**
     * 加锁
     *
     * @param time
     * @param unit
     * @return boolean
     */
    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        if (time != -1) {
            this.expire = unit.toSeconds(time);
        }
        String script = "if redis.call('EXISTS', KEYS[1]) == 0 or redis.call('HEXISTS',KEYS[1], ARGV[1]) == 1 " +
                "then " +
                "redis.call('HINCRBY', KEYS[1], ARGV[1] ,1) " +
                "redis.call('expire', KEYS[1], ARGV[2]) " +
                "return 1 " +
                "else " +
                "return 0 " +
                "end";

        // 如果并没有抢占到锁,则稍等一会,在去抢锁
        while (!this.redisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Arrays.asList(lockName), uuid, String.valueOf(expire))) {
            try {
                TimeUnit.MILLISECONDS.sleep(50);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        // 加锁成功,只有等到锁删除成功,定时任务才不会执行,否则开启定时任务自动续期
        this.reNewExpire();
        return true;
    }

    /**
     * 解锁
     */
    @Override
    public void unlock() {
        String script = "if redis.call('HEXISTS', KEYS[1], ARGV[1]) == 0 " +
                "then " +
                "return nil " +
                "elseif redis.call('HINCRBY', KEYS[1], ARGV[1] , -1) == 0 " +
                "then " +
                "return redis.call('del', KEYS[1]) " +
                "else " +
                "return 0 " +
                "end ";
        Long flag = this.redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Arrays.asList(lockName), uuid);
        if (flag == null) {
            throw new IllegalMonitorStateException();
        }
    }

    @Override
    public Condition newCondition() {
        return null;
    }

    /**
     * 锁的自动续期
     */
    private void reNewExpire() {
        String script = "if redis.call('HEXISTS', KEYS[1], ARGV[1]) == 1 " +
                "then " +
                "return redis.call('expire',KEYS[1],ARGV[2]) " +
                "else " +
                "return 0 " +
                "end";

        // 执行一次定时任务,定时任务的延期时间为 过期时间的1/3
        new Timer().schedule(new TimerTask() {
            @Override
            public void run() {
                // 如果续期成功,则准备是否再次续期
                if (redisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Arrays.asList(lockName), uuid, String.valueOf(expire))) {
                    reNewExpire();
                }
            }
            // 由于delay 是以毫秒为单位的,所以得乘以 1000  转化为 秒
        }, this.expire * 1000 / 3);
    }
}
java 复制代码
@Component
public class LockClient {

    @Autowired
    private StringRedisTemplate redisTemplate;

    private String uuid;

    public LockClient(){
        this.uuid = UUID.randomUUID().toString();
    }

    public RedisLockUtil getLockClient(String lockName) {
        return new RedisLockUtil(redisTemplate, lockName, uuid);
    }
}
java 复制代码
@Service
public class DbStockService {

    @Autowired
    private StringRedisTemplate redisTemplate;

    @Autowired
    private LockClient lockClient;

    /**
     * Redis 乐观锁
     */
    public void decrementStock() {
        RedisLockUtil lockClient = this.lockClient.getLockClient("lock");
        lockClient.lock();
        try {
            String stock = redisTemplate.opsForValue().get("stock");
            Integer integer = Integer.valueOf(stock);
            if (integer > 0) {
                redisTemplate.opsForValue().set("stock", String.valueOf(integer - 1));
            }
            // 测试锁的可重入性
            // test();

            // 测试锁的自动续期
            // try { TimeUnit.SECONDS.sleep(50); } catch (InterruptedException e) { e.printStackTrace(); }
        } finally {
            lockClient.unlock();
        }
    }
    
    /**
     * 测试锁的可重入性
     */
    public void test() {
        RedisLockUtil lockClient = this.lockClient.getLockClient("lock");
        lockClient.lock();
        lockClient.unlock();
    }
}    

总结

加锁:

  1. setnx:独占排他 死锁、不可重入、原子性
  2. set kv ex 30nx:独占排他、死锁不可重入
  3. hash +lua脚本:可重入锁
    1. 判断锁是否被占用(exists),如果没有被占用则直接获取锁(hset/hincrby) 并设置过期时间(expire)
    2. 如果锁被占用,则判断是否当前线程占用的(hexists),如果是则重入(hincrby) 并重置过期时间(expire)
    3. 3.否则获取锁失败,将来代码中重试
  4. 重试:递归 循环
  5. Timer定时器+lua脚本:实现锁的自动续期
    判断锁是否自己的锁(hexists),如果是自己的锁则执行(expire)设置过期时间

解锁:

  1. del:导致误删
  2. 先判断再删除同时保证原子性: lua脚本
  3. hash +lua脚本:可重入 T
    1. 判断当前线程的锁是否存在,不存在则返回nil,将来在代码中抛出异常
    2. 存在则直接减1 (hincrby -1),判断减1后的值是否为0,为0则释放锁(del),并返回1
    3. 不为0,则返回0
相关推荐
这样の我13 分钟前
mongodb集群搭建
数据库·mongodb
SlothLu25 分钟前
Debezium-KafkaDatabaseHistory
数据库·mysql·kafka·多线程·debezium·cdc·数据迁移
吃着火锅x唱着歌26 分钟前
Redis设计与实现 学习笔记 第二十章 Lua脚本
redis·笔记·学习
冷瞳30 分钟前
Redis基本的全局命令
数据库·redis·缓存
白云如幻1 小时前
SQL99版外连接
数据库·mysql
我们的五年1 小时前
【MySQL课程学习】:MySQL安装,MySQL如何登录和退出?MySQL的简单配置
linux·服务器·数据库·学习·mysql·adb
Hacker_Fuchen2 小时前
Redis密码设置与访问限制(网络安全)
redis·web安全·bootstrap
zhixingheyi_tian2 小时前
Spark 之 SparkSessionExtensions
大数据·分布式·spark
ProtonBase2 小时前
分布式 Data Warebase - 构筑 AI 时代数据基石
大数据·数据库·数据仓库·人工智能·分布式·数据分析·数据库系统
辰_砂2 小时前
Ubuntu24.04源码安装postgresql17
数据库