Redis:手写分布式锁

本文将在RedisTemplate中使用Redis的String类型或Hash类型来迭代式实现一个分布式锁的基本功能(互斥、防误删)和附加功能(可重入、自动续期)。

目录

1.0原始版本

2.0Setnx自旋锁版本

3.0Setnx自旋+过期锁版本

3.1Setnx自旋+过期plus锁版本

4.0Setnx自旋+防误删+过期锁版本

5.0Setnx自旋+防误删+过期+续期锁版本

6.0Hset自旋+防误删+过期+续期+可重入锁版本


1.0原始版本

本版本中我们将介绍一个非分布式的锁的功能。我们将假设多个线程将抢占一个锁,并对一个Redis中的String类型进行互斥更改。

@RestController
@RequestMapping("/lock")
public class Lock_String {
    @Autowired
    StringRedisTemplate stringRedisTemplate;
    private static final String KEY = "Number1";
    private static final Lock lock = new ReentrantLock();

    @GetMapping("/inc")
    public String addOne() {
        //上锁
        lock.lock();
        String result1 = null;
        String result2 = null;
        try {
            //1 访问redis中资源
            result1 = stringRedisTemplate.opsForValue().get(KEY);
            //2 更改数值
            stringRedisTemplate.opsForValue().set(KEY, String.valueOf(Integer.parseInt(result1) + 1));
            //3 检测资源是否更改
            result2 = stringRedisTemplate.opsForValue().get(KEY);
        } catch (NumberFormatException e) {
            e.printStackTrace();
        } finally {
            //解锁
            lock.unlock();
        }
        return "数值从" + result1 + "变更为" + result2;
    }
}

2.0Setnx自旋锁版本

实现功能:理想情况下互斥

@RestController
@RequestMapping("/lock")
public class lock2_0 {
    @Autowired
    StringRedisTemplate stringRedisTemplate;

    private static final String KEY_RES = "Number1";
    private static final String KEY_LOCK = "Lock";
    private final String VALUE_LOCK = UUID.randomUUID() + ":" + Thread.currentThread().getId();

    @GetMapping("/inc")
    public String addOne() {
        //加锁
        lock();
        String result1 = null;
        String result2 = null;

        try {
            //1 访问redis中资源
            result1 = stringRedisTemplate.opsForValue().get(KEY_RES);
            //2 增加该资源
            stringRedisTemplate.opsForValue().set(KEY_RES, String.valueOf(Integer.parseInt(result1) + 1));
            //3 检测资源是否更改
            result2 = stringRedisTemplate.opsForValue().get(KEY_RES);
        } catch (NumberFormatException e) {
            e.printStackTrace();
        } finally {
            //解锁
            unlock();
        }
        return "数值从" + result1 + "变更为" + result2;
    }

    private void lock() {
        System.out.println(VALUE_LOCK);
        while (!stringRedisTemplate.opsForValue().setIfAbsent(KEY_LOCK, VALUE_LOCK)) {
            //暂停20毫秒,自旋
            try {
                TimeUnit.MILLISECONDS.sleep(20);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    private void unlock() {
        stringRedisTemplate.delete(KEY_LOCK);
    }
}

目前存在问题:由于是分布式架构,stringRedisTemplate.delete(KEY_LOCK);语句向redis发送的命令可能会由于网络问题丢失,也可能是java微服务崩溃,进而导致该锁始终无法解除。

改进方案:为锁设置过期时间。

3.0Setnx自旋+过期锁版本

目前实现功能:到期锁自动过期。

private static final int expiredTime = 30;
private void lock() {
    System.out.println(VALUE_LOCK);
    while (!stringRedisTemplate.opsForValue().setIfAbsent(KEY_LOCK, VALUE_LOCK)) {
    stringRedisTemplate.expire(key,expiredTime,TimeUnit.SECONDS);//添加过期时间
        //暂停20毫秒,自旋
        try {
            TimeUnit.MILLISECONDS.sleep(20);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

存在问题:

  • 更改过期时间问题:可能expire语句和del语句一样由于网络问题丢失,导致该锁还是无法解除。
    • 改进思路:加锁时原子性设置过期时间。

3.1Setnx自旋+过期plus锁版本

private void lock() {
    System.out.println(VALUE_LOCK);
    while (!stringRedisTemplate.opsForValue().setIfAbsent(KEY_LOCK, VALUE_LOCK, expiredTime, TimeUnit.SECONDS)) {//原子性添加过期时间
        //暂停20毫秒,自旋
        try {
            TimeUnit.MILLISECONDS.sleep(20);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

存在问题:

  • 误删问题:如果业务处理过慢导致del语句在到期后再执行,会导致删除其他线程的锁。
    • 改进思路:删除时使用Lua脚本添加判断逻辑,同时保证原子性(如果不保证原子性,会导致和上面一样的问题)。

4.0Setnx自旋+防误删+过期锁版本

private void unlock() {
    String LuaScript =
            "if (redis.call('get',KEYS[1]) == ARGV[1]) then " +//如果KEY_LOCK的value就是当前线程的uuid
                    "return redis.call('del',KEYS[1]) " +//说明就是当前线程的锁,进行删除
                    "else " +//否则不删除
                    "return 0 " +
                    "end";
    stringRedisTemplate.execute(new DefaultRedisScript<>(LuaScript, Boolean.class), Arrays.asList(KEY_LOCK), VALUE_LOCK);
}

存在问题:

  • 正常情况下锁过期:可能业务处理过慢,导致业务没处理完锁就过期了。
    • 改进思路:另起一个线程,定期将锁的时间延长。(保证在未宕机的情况下锁不会过期,并且依旧不会让锁无法释放)

5.0Setnx自旋+防误删+过期+续期锁版本

private void lock() {
    System.out.println(VALUE_LOCK);
    while (!stringRedisTemplate.opsForValue().setIfAbsent(KEY_LOCK, VALUE_LOCK, expiredTime, TimeUnit.SECONDS)) {
        //暂停20毫秒,自旋
        try {
            TimeUnit.MILLISECONDS.sleep(20);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    renewal();
}
private void renewal() {
    String script =
            "if (redis.call('get',KEYS[1]) == ARGV[1]) then " +//如果KEY_LOCK的value就是当前线程的uuid
                    "return redis.call('expire',KEYS[1],ARGV[2]) " +说明就是当前线程的锁,进行续期
                    "else " +
                    "return 0 " +//否则返回0并退出
                    "end";
    new Thread(() -> {
        while (Boolean.TRUE.equals(stringRedisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Arrays.asList(KEY_LOCK), VALUE_LOCK, String.valueOf(expiredTime)))) {
            System.out.println("重续");
            try {
                Thread.sleep(expiredTime*1000/3);//执行成功就睡1/3个过期时间,不成功说明已经被解锁或已经过期了,就停止该线程。
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }).start();

存在问题:并不存在什么大问题了,只是不支持可重入锁,其实不怎么必需(go就不支持重入锁),但是为了让这个锁更加方便使用,还是实现一下可重入功能。

改进思路:在原先Lock-Key、Lock-Value的基础上,添加一个int值用于记录重入次数,并且使用Lua脚本重新实现lock、unlock、renewal函数的对应逻辑。

6.0Hset自旋+防误删+过期+续期+可重入锁版本

在Hset中存储:Lock-Key:Lock-Value:Lock-time s。

@RestController
@RequestMapping("/lock2")
public class Lock_Hash {
    @Autowired
    StringRedisTemplate stringRedisTemplate;

    private static final String KEY_RES = "Number1";
    private static final String KEY_LOCK = "Lock";
    private final String VALUE_LOCK = UUID.randomUUID() + ":" + Thread.currentThread().getId();
    private static final int expiredTime = 30;

    @GetMapping("/inc")
    public String addOne() {
        //加锁
        lock();
        String result1 = null;
        String result2 = null;

        try {
            //1 访问redis中资源
            result1 = stringRedisTemplate.opsForValue().get(KEY_RES);
            //2 增加该资源
            stringRedisTemplate.opsForValue().set(KEY_RES, String.valueOf(Integer.parseInt(result1) + 1));
            //3 检测资源是否更改
            result2 = stringRedisTemplate.opsForValue().get(KEY_RES);
            //4 测试重入方法
            reEnter();
        } catch (NumberFormatException e) {
            e.printStackTrace();
        } finally {
            //解锁
            unlock();
        }
        return "数值从" + result1 + "变更为" + result2;
    }

    private void reEnter() {
        //加锁
        lock();
        String result1 = null;
        try {
            //1 访问redis中资源
            result1 = stringRedisTemplate.opsForValue().get(KEY_RES);
            //2 偷偷再+1
            stringRedisTemplate.opsForValue().set(KEY_RES, String.valueOf(Integer.parseInt(result1) + 1));
        } catch (NumberFormatException e) {
            e.printStackTrace();
        } finally {
            //解锁
            unlock();
        }
    }

    private void lock() {
        String LuaScript =
                "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) " +//自己的锁或未抢占锁则重入次数加1,并重置或设置锁过期时间
                        "redis.call('expire',KEYS[1],ARGV[2]) " +
                        "return 1 " +
                        "else " +
                        "return 0 " +
                        "end";
        while (!stringRedisTemplate.execute(new DefaultRedisScript<>(LuaScript,Boolean.class), Arrays.asList(KEY_LOCK),VALUE_LOCK,String.valueOf(expiredTime))) {
            //暂停20毫秒,自旋
            try {
                TimeUnit.MILLISECONDS.sleep(20);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        renewal();
    }

    private void unlock() {
        String LuaScript =
                "if redis.call('HEXISTS',KEYS[1],ARGV[1]) == 0 then " +//锁不存在说明已过期
                        "   return nil " +
                        "elseif redis.call('HINCRBY',KEYS[1],ARGV[1],-1) == 0 then " +//否则将重入值-1,如果重入值为到达0,说明释放锁,将锁删除
                        "   return redis.call('del',KEYS[1]) " +
                        "else " +
                        "   return 0 " +
                        "end";
        Long flag = stringRedisTemplate.execute(new DefaultRedisScript<>(LuaScript, Long.class), Arrays.asList(KEY_LOCK),VALUE_LOCK,String.valueOf(expiredTime));
        if(flag == null)
        {
            throw new RuntimeException("This lock doesn't EXIST");
        }
    }

    private void renewal() {
        String script =
                "if redis.call('HEXISTS',KEYS[1],ARGV[1]) == 1 then " +//如果KEY_LOCK的value就是当前线程的uuid
                        "return redis.call('expire',KEYS[1],ARGV[2]) " +说明就是当前线程的锁,进行续期
                        "else " +
                        "return 0 " +//否则返回0并退出
                        "end";
        new Thread(() -> {
            while (Boolean.TRUE.equals(stringRedisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Arrays.asList(KEY_LOCK), VALUE_LOCK, String.valueOf(expiredTime)))) {
                try {
                    Thread.sleep(expiredTime * 1000 / 3);//执行成功就睡1/3个过期时间,不成功说明已经被解锁或已经过期了,就停止该线程。
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }).start();
    }
}

其他改进点:如实现JUC的Lock接口;使用简单工厂模式等,这里就不做赘述了。

到这分布式锁已经写得差不多了,不过还是有一个绕不过的点,就是都是单实例的锁,要求redis的master不会宕机。接下来就需要使用Redlock来改进这个问题,具体请查看后续文章。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值