《Redis深度历险学习笔记》:Redis的分布式锁使用

分布式应用中常见的并发问题

以修改用户的状态为例,一个操作将会拆分成三个步骤:

  1. 读出用户的状态。
  2. 在内存里进行修改操作。
  3. 改完之后存回去。

读取和保存两个操作并不是原子的,多线程情况下,会出现并发问题。

可以使用分布式锁来限制程序的并发执行。

分布式锁的使用

使用setnx命令,set if not exist,只允许被一个客户端占坑,用完之后,调用del指令释放。

> setnx lock true
(integer) 1
# do something
> del lock
(integer) 1

其中存在问题:假设do something发生异常导致del指令没有被调用,这样就会形成死锁,锁永远都不会释放。

那如何解决呢?我们可以在拿到锁之后,设定一个过期时间比如10s,保证10s内锁自动释放。

> setnx lock true
> expire lock 10
# do something
> del lock

可是,这个操作包含setnx和expire两条指令,不是原子指令。如果setnx成功,expire失败了,还是会导致死锁。

从 Redis 2.6.12 版本开始, set命令的行为可以通过一系列参数来修改,从替代:setnx,setex,psetex等:

  • EX second :设置键的过期时间为 second 秒。 SET key value EX second 效果等同于 SETEX key second value
  • PX millisecond :设置键的过期时间为 millisecond 毫秒。 SET key value PX millisecond 效果等同于 PSETEX key millisecond value
  • NX :只在键不存在时,才对键进行设置操作。 SET key value NX 效果等同于 SETNX key value
  • XX :只在键已经存在时,才对键进行设置操作。

也就是说我们可以使用:

> set lock true ex 10 nx
# do something
> del lock

将setnx和expire组合成一条原子指令,就是分布式锁的奥义所在。

超时问题

Redis 的分布式锁不能解决超时问题,如果在加锁和释放锁之间的逻辑执行的太长,以至于超出了锁的超时限制,就会出现问题。因为这时候锁过期了,第二个线程重新持有了这把锁,但是紧接着第一个线程执行完了业务逻辑,就把锁给释放了,第三个线程就会在第二个线程逻辑执行完之间拿到了锁。

解决方案:

为 set 指令的 value 参数设置为一个随机数,释放锁时先匹配随机数是否一致,然后再删除 key。但是匹配 value 和删除 key 不是一个原子操作,Redis 也没有提供类似于delifequals这样的指令,这就需要使用 Lua 脚本来处理了,因为 Lua 脚本可以保证连续多个指令的原子性执行

逻辑如下:

# delifequals
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

可重入性

可重入性是指线程在持有锁的情况下再次请求加锁,如果一个锁支持同一个线程的多次加锁,那么这个锁就是可重入的。比如 Java 语言里有个 ReentrantLock 就是可重入锁。Redis 分布式锁如果要支持可重入,需要对客户端的 set 方法进行包装,使用线程的 Threadlocal 变量存储当前持有锁的计数

public class RedisWithReentrantLock {

    private ThreadLocal<Map<String, Integer>> lockers = new ThreadLocal<>();
    private Jedis jedis;

    public RedisWithReentrantLock(Jedis jedis){
        this.jedis = jedis;
    }

    private boolean _lock(String key) {
        return jedis.set(key, "", SetParams.setParams().nx().ex(5)) != null;
    }

    private void _unlock(String key) {
        jedis.del(key);
    }

    public boolean lock(String key) {
        Map<String, Integer> refs = currentLocker();
        Integer refCnt = refs.get(key);
        if (refCnt != null) {
            refs.put(key, refCnt + 1);
            return true;
        }
        boolean ok = _lock(key);
        if (!ok) {
            return false;
        }
        refs.put(key, 1);
        return true;
    }

    public boolean unlock(String key) {
        Map<String, Integer> refs = currentLocker();
        Integer refCnt = refs.get(key);
        if (refCnt == null) {
            return false;
        }
        refCnt -= 1;
        if (refCnt > 0) {
            refs.put(key, refCnt);
        } else {
            refs.remove(key);
            this ._unlock(key);
        }
        return true;
    }

    private Map<String, Integer> currentLocker() {
        Map<String, Integer> refs = lockers.get();
        if (refs != null) {
            return refs;
        }
        lockers.set(new HashMap<>());
        return lockers.get();
    }

    public static void main(String[] args) {
        Jedis jedis = new Jedis();
        RedisWithReentrantLock redis = new RedisWithReentrantLock(jedis);
        System.out.println(redis.lock("codehole"));
        System.out.println(redis.lock("codehole"));
        System.out.println(redis.unlock("codehole"));
        System.out.println(redis.unlock("codehole"));
    }
    
}

客户端加锁失败

客户端在处理请求时加锁没加成功怎么办?一般有三种解决方案:

  1. 直接抛出异常,通知用户稍后重试。
  2. sleep一会,再重试
  3. 将请求转移至延时队列,一会再试。

我们分别看看三种方案:

直接抛出特定类型的异常

这种方式比较适合由用户直接发起的请求,用户看到错误对话框后,会先阅读对话框的内容,再点击重试,这样就可以起到人工延时的效果。如果考虑到用户体验,可以由前端的代码替代用户自己来进行延时重试控制。它本质上是对当前请求的放弃,由用户决定是否重新发起新的请求。

sleep一会,再重试

sleep 会阻塞当前的消息处理线程,会导致队列的后续消息处理出现延迟。如果碰撞的比较频繁或者队列里消息比较多,sleep 可能并不合适。如果因为个别死锁的 key 导致加锁不成功,线程会彻底堵死,导致后续消息永远得不到及时处理。

延时队列

这种方式比较适合异步消息处理,将当前冲突的请求扔到另一个队列延后处理以避开冲突。

参考阅读

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值