【Redis】常见功能:分布式锁、延时队列、限流...

分布式锁的实现之 redis 篇
Redis(3)——分布式锁深入探究
《Redis深度历险 核心原理与应用实战》

一、分布式锁

我们在系统中修改已有数据时,需要先读取,然后进行修改保存,此时很容易遇到并发问题。由于修改和保存不是原子操作,在并发场景下,部分对数据的操作可能会丢失。

  • 在单服务器系统我们常用本地锁来避免并发带来的问题
  • 然而,当服务采用集群方式部署时,本地锁无法在多个服务器之间生效,这时候保证数据的一致性就需要分布式锁来实现。

在这里插入图片描述

一般情况下,我们使用分布式锁主要有两个场景:

  • 避免不同节点重复相同的工作:比如用户执行了某个操作有可能不同节点会发送多封邮件;
  • 避免破坏数据的正确性:如果两个节点在同一条数据上同时进行操作,可能会造成数据错误或不一致的情况出现;

基于锁的本质:同一时间只允许一个用户操作。所以理论上,能够满足这个需求的工具我们都能够使用:

  1. 基于 MySQL 中的锁:MySQL 本身有自带的悲观锁 for update 关键字,也可以自己实现悲观/乐观锁来达到目的;
  2. 基于 Zookeeper 有序节点:Zookeeper 允许临时创建有序的子节点,这样客户端获取节点列表时,就能够当前子节点列表中的序号判断是否能够获得锁;
  3. 基于 Redis 的单线程:由于 Redis 是单线程,所以命令会以串行的方式执行,并且本身提供了像 SETNX(set if not exists) 这样的指令,本身具有互斥性;

每个方案都有各自的优缺点,例如 MySQL 虽然直观理解容易,但是实现起来却需要额外考虑 锁超时、加事务 等,并且性能局限于数据库,诸如此类我们在此不作讨论,重点关注 Redis。

Redis分布式锁的实现——setnx

Redis 锁主要利用 Redis 的 setnx 命令。

  • 加锁:SETNX key value
    当键不存在时,对键进行设置操作并返回成功,否则返回失败。KEY 是锁的唯一标识,一般按业务来决定命名。
  • 解锁:DEL key
    通过删除键值对释放锁,以便其他线程可以通过 SETNX 命令来获取锁。
  • 锁超时:EXPIRE key timeout
    设置 key 的超时时间,以保证即使锁没有被显式释放,锁也可以在一定时间后自动释放,避免资源被永远锁住。

加锁解锁伪代码:

//尝试加锁
if (setnx(key, 1) == 1){
//设置锁过期时间
    expire(key, 30)
    try {
        //TODO 业务逻辑
    } finally {
    //解锁
        del(key)
    }
}

问题1. setnx与expire是两条指令不能保证原子性

如果在setnx与expire之间服务器进程突然挂掉了,可能是因为机器掉线或者是人为造成的,就会导致expire得不到执行,也会造成死锁。

在这里插入图片描述
这种问题的根源就在于setnx与expire是两条指令而不是原子指令。

有很多分布式锁的开源代码专门用来解决这个问题,如使用Lua脚本将两个指令原子化执行等。但再Redis 2.8版本中,作者加入了set指令的扩展参数,使得两个指令可以一起执行,彻底解决了分布式锁的乱象。

set lock:myKey true ex 5 nx

问题2. 超时问题

超时导致锁误解除

如果线程 A 成功获取到了锁,并且设置了过期时间 30 秒,但线程 A 执行时间超过了 30 秒,锁过期自动释放,此时线程 B 获取到了锁;随后 A 执行完成,线程 A 使用 DEL 命令来释放锁,但此时线程 B 加的锁还没有执行完成,线程 A 实际释放的线程 B 加的锁。

在这里插入图片描述

为了解决这一问题,可以将锁的 value 值设置为一个随机数(生成一个UUID来作为当前线程的标志),释放锁时先匹配随机数是否一致,然后再删除 key,这是为了 确保当前线程占有的锁不会被其他线程释放,除非这个锁是因为过期了而被服务器自动释放的。

但是匹配 value 和删除 key 在 Redis 中并不是一个原子性的操作,也没有类似保证原子性的指令,所以可能需要使用像 Lua 这样的脚本来处理了,因为 Lua 脚本可以 保证多个指令的原子性执行。

// 加锁
String uuid = UUID.randomUUID().toString().replaceAll("-","");
SET key uuid NX EX 30
// 解锁
if (redis.call('get', KEYS[1]) == ARGV[1])
    then return redis.call('del', KEYS[1])
else return 0
end

超时导致并发

上述加锁方式也不是一个完美的方案,它只是相对安全一点。如果加锁和释放锁之间的逻辑执行得太长,以至于超出了锁的超时限制,就会出现问题。因为这时候第一个线程持有的锁过期了,临界区的逻辑还没有执行完,而同时第二个线程就提前重新持有了这把锁,导致临界区代码不能得到严格串行执行。

在这里插入图片描述

  1. 为了避免这个问题,Redis分布式锁不要用于较长时间的任务。如果真的偶尔出现了问题,造成的数据小错乱可能就需要人工的干预。
  2. 还有一种方式是为获取锁的线程增加守护线程,为将要过期但未释放的锁增加有效时间。
    在这里插入图片描述

问题3. 可重入性

可重入性是指线程在持有锁的情况下再次请求加锁,如果一个锁支持同一个线程的多次加锁,那么这个锁就是可重入的,如Java中的ReentrantLock。

借助线程ThreadLocal

Redis分布式锁如果要支持可重入,需要对客户端的set方法进行包装,使用线程的ThreadLocal变量存储当前持有锁的计数。

private static ThreadLocal<Map<String, Integer>> LOCKERS = ThreadLocal.withInitial(HashMap::new);
// 加锁
public boolean lock(String key) {
  Map<String, Integer> lockers = LOCKERS.get();
  if (lockers.containsKey(key)) {
  //如果当前线程ThreadLocal中已记录了此key的锁,那么锁计数+1
    lockers.put(key, lockers.get(key) + 1);
    return true;
  } else {
    if (SET key uuid NX EX 30) {
    //当前线程之前没有持有锁,如果加锁成功,则存入ThreadLocal
      lockers.put(key, 1);
      return true;
    }
  }
  //线程加锁失败
  return false;
}
// 解锁
public void unlock(String key) {
  Map<String, Integer> lockers = LOCKERS.get();
  if (lockers.getOrDefault(key, 0) <= 1) {
    lockers.remove(key);
    DEL key
  } else {
    lockers.put(key, lockers.get(key) - 1);
  }
}

使用Hash结构

ThreadLocal记录重入次数虽然高效,但如果考虑到过期时间和本地、Redis 一致性的问题,就会增加代码的复杂性。

另一种方式是使用Redis Hash数据结构来实现分布式锁,既存锁的标识也对重入次数进行计数。Redission 加锁示例:

// 如果 lock_key 不存在
if (redis.call('exists', KEYS[1]) == 0)
then
    // 设置 lock_key 线程标识 1 进行加锁
    redis.call('hset', KEYS[1], ARGV[2], 1);
    // 设置过期时间
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return nil;
    end;
// 如果 lock_key 存在且线程标识是当前欲加锁的线程标识
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1)
    // 自增
    then redis.call('hincrby', KEYS[1], ARGV[2], 1);
    // 重置过期时间
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return nil;
    end;
// 如果加锁失败,返回锁剩余时间
return redis.call('pttl', KEYS[1]);

问题4. 集群环境下RedLock

为了保证 Redis 的可用性,一般采用主从方式部署。在包含主从模式的集群部署方式中,当主节点挂掉时,从节点会取而代之,但客户端无明显感知。

当客户端 A 成功加锁,指令还未同步,此时主节点挂掉,从节点提升为主节点,新的主节点没有锁的数据,当客户端 B 加锁时就会成功。

在这里插入图片描述
这种不安全也仅在主从发生failover的情况下才产生,而且持续时间极短,业务系统多数情况可以容忍。

RedLock

可以使用RedLock解决这个问题,它的流程比较复杂,不过已经有很多开源的library做了良好的封装,用户可以拿来即用。如redlock-py:

import redlock

addrd = [{
	"host" : "localhsot",
	"port" : 6379,
	"db" : 0
},{
	"host" : "localhsot",
	"port" : 6479,
	"db" : 0
},{
	"host" : "localhsot",
	"port" : 6579,
	"db" : 0
}]
dlm = redlock.Redlock(addrs)
success = dlm.lock("user-lock",5000)
if success:
	print 'lock success'
	dlm.unlock('user-lock')
else
	print 'lock failer'

为了使用RedLock,需要提供多个Redis实例,这些实例之间相互独立,没有主从关系。同很多分布式算法一样,RedLock也使用“大多数机制”。

加锁时,它会向过半节点发送set(key,value,nx=TRUE,ex=xxx)指令,只要过半节点set成功,就认为它加锁成功。释放锁时,要向所有的节点发送del指令。

如果你很在乎高可用性,希望即使挂了一台Redis也完全不受影响,就应该考虑RedLock。不过代价也是有的,需要更多的Redis实例,性能也下降了,代码上还需要引入额外的library,运维上也需要特殊对待,这些都是需要考虑的成本,使用前需要再三斟酌。

二、延时队列

我们平时习惯于使用RabbitMQ和Kafka作为消息队列中间件,在应用程序之间增加异步消息传递功能。对于那些只有一组消费者的消息队列,使用Redis可以非常轻松地实现。但Redis不是专业的消息队列,它没有非常多的高级特性,没有ack保证,如果对消息的可靠性有极高要求,那么它就不适合使用。

异步消息队列实现——List列表结构

Redis的List数据结构常用来作为异步消息队列使用,用rpush和lpush操作入队,用lpop和rpop操作出队。它可以支持多个生产者和多个消费者并发进出消息,每个消费者拿到的消息都是不同的列表元素。

问题1. 队列空了怎么办

如果队列空了,客户端就会进入pop的死循环,这就是浪费生命的空轮询,不但拉高了客户端的CPU消耗,Redis的QPS也会被拉高。如果这样的空轮询客户端有几十个,Redis慢查询可能会显著增多。

通常我们使用sleep解决,无数据时sleep 1s。

阻塞读

用睡眠的方法虽然可以解决问题,但是会导致消息的延迟增大,如果只有1个消费者,延迟就是1s。

阻塞读的命令为blpop和brpop,前缀字符b代表的是blocking。

阻塞读在队列没有数据的时候,会立即进入休眠状态,一旦数据到来,则立刻醒过来,消息的延迟几乎为零。

延时队列实现——Zset有序列表结构

延时队列可以通过Redis的zset来实现,我们将消息序列化成一个字符串作为zset的value,这个消息的处理时间作为score,然后用多个线程轮询zset获取到过期的任务进行处理。多个线程是为了保证可用性,即使挂了一个线程还有其他线程可以继续处理。因为有多个线程,需要考虑并发争抢任务,确保任务不会被多次执行。

public void lpop(){
    while(!Thread.interrupted()){
        //获取 以score排序,也就是执行时间离当前时间最近的、一个元素
        Set<String> values = jedis.zrangeByScore(queueKey, 0, System.currentTimeMillis(), 0, 1);
        if(values.isEmpty()){
            Thread.sleep(500);
            continue;
        }
        // 拿第一条,也只有一条
        String s = values.iterator().next();
        if(jedis.zrem(queueKey, s) > 0){ 
        // 多线程并发的可能,最终只有一个进程可以抢到消息
            TaskItem<T> task = JSON.parseObject(s, TaskType);
            this.handleTask(task);
        }
    }
}

Redis的Zrem方法是多线程争抢任务的关键,他的返回值决定了当前实例有没有抢到任务。

三、限流

1. 简单限流——Zset结构

首先我们来看一个常见的、简单的限流策略。系统要限定用户的某个行为在指定的时间里只允许发生N次,如果使用Redis的数据结构来实现这个功能?

这个限流需求中存在一个滑动时间窗口,我们可以用zset数据结构的score值来圈出这个窗口,而value只需要保证唯一性即可,用uuid会比较浪费空间,那就改用毫秒时间戳吧。

如图,用一个zset结构记录用户的行为历史,每一个行为都会作为zset中的一个key保存下来,同一个用户的同一种行为用一个zset记录。

在这里插入图片描述
通过统计滑动窗口内的行为数量与阈值max_count进行比较,就可以得出当前的行为是否被允许。

public class RedisRateLimit {

    private Jedis jedis;

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

    public boolean isActionAllowed(String userId, String actionKey, int period, int maxCount) throws IOException {
        String key = String.format("hits:%s:%s", userId, actionKey);
        long nowTs = System.currentTimeMillis();
        Pipeline pipeline = jedis.pipelined();
        pipeline.multi();
        // 存储——key:用户行为、score:时间
        pipeline.zadd(key, nowTs, "" + nowTs);
        // 查询——key:用户行为、条件:score(当前时间-5s,当前时间)
        pipeline.zremrangeByScore(key, 0, nowTs - period * 1000);
        // 获取数量
        Response<Long> count = pipeline.zcard(key);
        pipeline.expire(key, period + 1);
        pipeline.exec();
        pipeline.close();
        return count.get() <= maxCount;
    }

    public static void main(String[] args) throws IOException {
        Jedis jedis = new Jedis();
        RedisRateLimit redisRateLimit = new RedisRateLimit(jedis);
        for (int i = 0; i < 20; i++) {
            System.out.println(redisRateLimit.isActionAllowed("userId", "action-1", 60, 5));
        }
    }

}

整体思路是:没一个行为到来时,都维护一次时间窗口,将时间窗口外的记录全部清理掉,值保留时间窗口内的记录。因为这几个连续的操作都是针对同一个key的,使用pipeline可以显著提升Redis存储效率。

但是这种方案也有缺点,因为它要记录时间窗口内所有的行为记录,如果这个量很大,比如"限定60s内操作不得超过100万次"之类,它是不适合这样做限流的,因为会消耗大量的存储空间。

2. 漏斗限流——Redis-cell

漏斗限流是最常用的限流方法之一,漏斗的容量是有限的,如果将漏嘴堵住,然后一直往里灌水,它就会变满然后再也装不进去。如果将漏嘴放开,水就会往下流,流走一部分之后就又可以继续往里面灌水。

如果漏嘴流水的速率大于灌水的速率,那么漏斗永远都装不满。如果如果漏嘴流水的速率小于灌水的速率,那么一旦漏斗满了,灌水就需要暂停并等待漏斗腾出一部分空间。

  • 漏斗的剩余空间:代表当前行为可以持续进行的数量
  • 漏嘴的流水速率:代表系统允许改行为的最大频率

漏斗算法的Java实现:

public class RedisFunnelRateLimit {

    static class Funnel {
        int capacity; //容量
        float leakingRate; //漏嘴流速
        int leftQuota; //剩余容量
        long leakingTs; //初始时间

        public Funnel(int capacity, float leakingRate) {
            this.capacity = capacity;
            this.leakingRate = leakingRate;
            this.leftQuota = capacity;
            this.leakingTs = System.currentTimeMillis();
        }

		// 计算漏水量
        void makeSpace() {
            long nowTs = System.currentTimeMillis();
            long deltaTs = nowTs - leakingTs;
            // 前一段时间内的流水容量:时间差*速度
            int deltaQuota = (int) (deltaTs * leakingRate);
            // 间隔时间太长,整数数字过大溢出,则恢复漏洞原样
            if (deltaQuota < 0) {
                this.leftQuota = capacity;
                this.leakingTs = nowTs;
                return;
            }
            // 腾出空间太小,最小单位是1
            if (deltaQuota < 1) {
                return;
            }
            // 修正剩余容量
            this.leftQuota += deltaQuota;
            // 记录当前时间、下一次计算时做差值
            this.leakingTs = nowTs;
            if (this.leftQuota > this.capacity) {
                this.leftQuota = this.capacity;
            }
        }

		//灌水
        boolean watering(int quota) {
            // 在每一次灌水之前,都计算一下 前一段时间,用了多少容量
            makeSpace();
            if (this.leftQuota >= quota) {
                this.leftQuota -= quota;
                return true;
            }
            return false;
        }
    }

    private Map<String, Funnel> funnels = new HashMap<>();

    public boolean isActionAllowed(String userId, String actionKey, int capacity, float leakingRate) {
        String key = String.format("%s:%s", userId, actionKey);
        Funnel funnel = funnels.get(key);
        if (funnel == null) {
            funnel = new Funnel(capacity, leakingRate);
            funnels.put(key, funnel);
        }
        return funnel.watering(1);
    }
}

在每次灌水前都会调用makeSpace方法触发漏水,计算出当前的剩余空间。能腾出多少空间取决于过去了多久以及流水的速率。

如果把Funnel对象的几个字段存储到一个hash结构中,计算完最新值再回填到hash结构,就完成了一次行为频度的检测。但是从hash结构中取值、在内存中运算、回填到hash结构,这三个过程无法原子性,意味着需要适当的加锁,而一旦加锁,就意味着有加锁失败的可能,加锁失败就需要选择重试或放弃,将导致性能下降。

Redis-Cell

Redis 4.0提供了一个限流模块Redis-Cell,该模块使用了漏斗算法,并提供了原子的限流指令

// 15: capacity容量
// 30、60:每30s最多进行操作60次,速率:30/60
key:reply 15 30 60
返回值:
0  // 0表示运行,1表示拒绝
15  // 漏洞容量
14  //漏洞剩余容量
-1  // 如果被拒绝了,需要多长时间后再重试
2   // 多长时间后,漏洞完全空出来

在执行限流指令时,如果被拒绝了,就需要丢弃或重试。指令已经将重试时间都帮你算好了,直接取返回结果的第四个值进行sleep即可。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值