Redis【二】实战应用

目录

一、分布式锁

1.实现原理

2.超时问题

3.可重入性

二、延时队列

1.队列空了怎么办

2.空闲连接自动断开

三、位图

1.基本用法

2.统计和查找

3.BITFIELD指令

四、HyperLogLog(基数估值)

五、布隆过滤器

六、简单限流

七、漏斗限流

八、GeoHash(位置距离排序)

九、scan指令


一、分布式锁

1.实现原理

Java提供了在多线程下保证线程安全的锁机制,但是在多服务节点的情况下的分布式应用进行逻辑处理时也经常会遇到并发问题,这个时候就需要用到分布式锁,分布式锁保证的问题是同一时刻,仅有一个JVM进程中的一个线程正在执行操作。我们可以使用如下两个命令配合实现分布式锁:

SETNX key value    //只有在 key 不存在时设置 key 的值。若能成功设置则表示获取到了锁
DEL key            //删除该key。相当于释放锁

//如:
SETNX lock true
//doSomething……
DEL lock

如果仅仅是按照上述方式处理还远远不够,假如在doSomething期间出现异常了,那么就可能导致DEL指令没有被调用,锁没有被释放,那么锁永远得不到释放。所以释放锁的逻辑必须得执行,在处理线程锁得时候在Java程序中一般是利用finally{}块来释放锁,但是多个JVM进程只用finally{}是不行的,比如某个得到锁的JVM进行还没有执行到finally{}块时就挂掉了,服务宕机,那么还不能成功释放锁。

综上,在拿到锁之后,可以再给锁加上一个过期时间,比如3s。这样即使中间出现问题Redis也会在3s之后释放锁。注意获取锁的操作设置时间这两个操作一定要是原子地执行,如果这两个操作不是原子的,那么还有可能在获取到锁之后,设置有效时间之前出现问题导致时间设置不成功,那么问题依然得不到解决。在Redis2.8版本中,引入了SET命令的扩展参数,使得SETNX和EXPIRE命令可以原子地执行。如下:

set key value [EX seconds] [PX milliseconds] [NX|XX]
//EX seconds:设置失效时长,单位秒
//PX milliseconds:设置失效时长,单位毫秒
//NX:key不存在时设置value,成功返回OK,失败返回(nil)
//XX:key存在时设置value,成功返回OK,失败返回(nil)

//如:
SET lock true EX 5 NX    

2.超时问题

Redis的分布式锁不能解决超时问题,如果在加锁和释放锁之间的逻辑执行得太长,以至于超出了锁得超时限制,比如第一个JVM进程拿到锁还没处理完毕,但是Redis锁得失效时间到了,导致第二个JVM进程拿到锁,这时就出现问题。为了避免这个问题,Redis分布式锁不要用于较长时间得任务。如果真的偶尔出现问题,造成的数据小错乱可能需要人工介入解决。

还有一个需要注意的问题是在同一个JVM进程中,如果线程A获取到锁,那么线程B再线程A释放锁之前它是不能获取到锁的,这一点SETNX可以保证,因为已经存在锁,线程B是设置不成功的,但是可能存在一个问题是,线程B可以释放掉锁,因为锁的释放逻辑一般是在finally{}块中执行,所以尽管线程B没有获取到锁,但是却有可能执行了释放锁的逻辑(线程A拿到的锁被线程B释放掉了,这是绝对不行的),当然如果在线程间利用Java提供的锁机制控制也不会出现问题。这个问题还可以这样解决,在线程A获取锁时将SET指令的value参数设置为一个随机数,释放锁时先匹配随机数是否一致,然后再删除key。需要注意的是:匹配随机数和删除key的操作同属于“释放锁”中的两个步骤,这两个操作必须是原子的。由于Redis没有提供相应的指令,所以需要使用Lua脚本处理,因为Lua脚本可以保证连续多个指令的原子性执行。

3.可重入性

可重入性是指线程在持有锁的情况下再次请求加锁,如果一个锁支持同一个线程的多次加锁,那么这个锁就是可重入的。Redis分布式锁如果要支持可重入,可以使用线程的ThreadLocal变量存储当前持有锁的计数。

综上述三个方面的概述,如下是代码:

public class RedisLock {
    private ThreadLocal<Map<String,Integer>> threadLocal;
    private Jedis jedis;

    public RedisLock(Jedis jedis){
        this.jedis = jedis;
        this.threadLocal = new ThreadLocal<Map<String, Integer>>();
    }

    private boolean tryLock(String key){
        Map<String, Integer> LockMap = currentLockMap();
        //生成随机数
        Random random = new Random();
        int value = random.nextInt(100000);
        LockMap.put(key + "-threadID", value);
        //锁超时时间为3s
        return jedis.set(key,value + "",new SetParams().ex(3).nx()) != null;
    }
    private Map<String, Integer> currentLockMap() {
        Map<String, Integer> refs = threadLocal.get();
        if(refs == null){
            refs = new HashMap<String,Integer>();
            threadLocal.set(refs);
        }
        return refs;
    }
    public boolean lock(String key){
        Map<String,Integer> LockMap = currentLockMap();
        //每个线程的LockMap中一共保存两个值,一个用于记录重入数(key + "-count")。一个用于区别不同线程(防止线程A的锁被线程B释放)
        Integer count = LockMap.get(key + "-count");
        if(count != null){
            //当前线程已经获取到锁了
            LockMap.put(key + "-count",count + 1);
            return true;
        }
        //当前线程第一次获取锁
        boolean ok = tryLock(key);
        if(!ok){
            //加锁失败,锁被占用
            return false;
        }
        //当前线程第一次获取锁成功
        LockMap.put(key,1);
        return  true;
    }

    public boolean unlock(String key){
        Map<String,Integer> LockMap = currentLockMap();
        Integer count = LockMap.get(key + "-count");
        if(count == null){
            //当前线程未获得锁,直接返回
            return false;
        }
        count -= 1;
        if(count > 0){
            //减少重入计数
            LockMap.put(key + "-count",count);
        }else{
            //释放锁
            tryUnLock(key);
        }
        return true;
    }

    private void tryUnLock(String key) {
        Map<String,Integer> lockMap = currentLockMap();
        String value = lockMap.get(key + "-threadID") + "";
        //使用Lua脚本,保证原子性
        String lua = "if redis.call(\"get\",KEYS[1]) == ARGV[1] then " +
                " return redis.call(\"del\", KEYS[1]) " +
                "else" +
                " return 0" +
                "end";
        List<byte[]> keys = Arrays.asList(key.getBytes());
        List<byte[]> args = Arrays.asList(value.getBytes());

        long eval = (Long)jedis.eval(lua.getBytes(),keys,args);
        if(eval == 1){
            //释放锁成功
            lockMap.remove(key + "-count");
            lockMap.remove(key + "-threadID");
        }
    }

    public static void main(String[] args) {
        Jedis jedis = new Jedis();  //默认连接的是localhost:6379
        RedisLock redisLock = new RedisLock(jedis);
        String locakKey = "redisLock";
        try{
            boolean ok = redisLock.lock(locakKey);
            if(ok){
                //doSomething
                System.out.println("当前进程:\t" + ManagementFactory.getRuntimeMXBean().getName() + "\t获取锁成功");
            }else{
                System.out.println("当前进程:\t" + ManagementFactory.getRuntimeMXBean().getName() + "\t获取锁失败");
                 //这里如果加锁没有成功,那么是否需要阻塞?或者抛出异常,或者将任务放在延时队列中?
            }
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            redisLock.unlock(locakKey);
        }
    }
}

二、延时队列

在平常生活生活中经常可以体会到延时队列的影子,比如:在支付的时候如果一段时间没有支付,则平台会取消订单。我们平时习惯使用RabbitMQ和Kafka作为消息队列中间件,在应用程序之间增加异步消息传递功能,它两都是专业的消息队列中间件,有许多优秀的特性,但是使用起来也比较复杂,假如我们的消息队列只有一组消费者,那么也可以使用Redis解决,但是Redis的消息队列不是专业的消息队列,他没有非常多的高级特性,没有ack保证,如果对消息的可靠性有极高的要求,那么它就不适用。

Redis的list(列表)数据结构常用作异步消息队列使用,用RPUSH和LPUSH操作入队列,对应用LPOP和RPOP操作出队列。它可以支持多个生产者和多个消费者并发进出消息,每个消费者拿到的消息都是不同的列表元素。

使用list结构固然简单,但是这又会引发一些问题:

1.队列空了怎么办

如果使用list结构,那么取消息则使用LPOP或者RPOP操作,消费者通过循环取消息,取到消息进行处理,处理完了接着取。如此循环,如果队列空了,消费者客户端就会陷入pop的死循环,没有数据,只是不停的pop。这不仅空耗CPU,而且Redis的QPS也被拉高,如果这样空轮询的客户但有几十个,Redis的慢查询可能会显著增多。

如果单纯解决这个问题,可以使用BLPOP或者BRPOP(阻塞读,B即blocking)。队列中没有数据的时候消费者进入休眠状态,一旦数据到了,则立即被唤醒。消息的延迟几乎为0。

2.空闲连接自动断开

如果使用上述方案处理队列空的问题,当然可以,但是此时就会又有一个新问题,如果线程一致阻塞在那里,Redis的客户端连接就成了空闲连接,时间一久,服务器就会主动断开连接,减少闲置资源占用,这个时候BLPOP和BRPOP就会抛出异常。所以编写客户端消费者的时候要小心,如果捕获到异常还要重试。

下面使用有序表结构实现一个异步消息队列:

public class RedisDelayingQueue<T> {
    //fastjson序列化对象时存在generic类型时,需要使用TypeReference
    private Type taskType = new TypeReference<T>(){}.getType();

    private Jedis jedis;
    private String queueKey;

    public RedisDelayingQueue(Jedis jedis, String queueKey){
        this.jedis = jedis;
        this.queueKey = queueKey;
    }

    public void producer(T msg){
        String s = JSON.toJSONString(msg);
        //将消息序列化为字符串存储在有序列表中,使用该消息的到期处理时间作为score
        jedis.zadd(queueKey,System.currentTimeMillis() + 5000, s);
    }

    public void consumption(){
        while (!Thread.interrupted()){
            //zrangeByScore操作和zrem操作可以使用Lua脚本原子地执行。
            //同一个任务可能会被多个进程取到之后在使用zrem进行争抢,哪些没抢到地进程都白取了一次,这是浪费。
            Set<String> values = jedis.zrangeByScore(queueKey,0+"",System.currentTimeMillis()+"",0,1);
            if(values.isEmpty()){
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    break;
                }
                continue;
            }
            String s = values.iterator().next();
            if(jedis.zrem(queueKey,s) > 0){
                //抢到了
                T msg = JSON.parseObject(s,taskType);
                handleMsg(msg);
                jedis.close();
            }
        }
    }

    private void handleMsg(T msg) {
        System.out.println(Thread.currentThread().getName() + "\t:" + msg);
    }

}

如下是生产者和消费者测试:

public class Producer {
    public static void main(String[] args) throws InterruptedException {
        Jedis jedis = new Jedis("localhost",6379);
        RedisDelayingQueue<String> queue = new RedisDelayingQueue<>(jedis,"q-demo");

        for (int i = 0; i < 10; i++) {
            queue.producer("codehole" + i);
        }
        jedis.close();
    }
}

消费者开启两个线程消费:

public class Customer {

    public static void main(String[] args) throws InterruptedException {
        for (int index = 0; index < 2; index++) {
            Jedis jedis = new Jedis("127.0.0.1",6379);
            RedisDelayingQueue<String> queue = new RedisDelayingQueue<>(jedis,"q-demo");

            Thread t = new Thread(()->{
                queue.consumption();
            }).start();
 
        }
    }
}

特别需要注意的是这里消费者两个线程分别用两个Jedis连接,因为Jedis是线程不安全的,而且如果使用多个线程共用一个Jedis连接,那么会出现许多五花八门的异常,大多都是底层Socket连接异常。

如下是消费者端的输出:

三、位图

1.基本用法

一个业务场景,我们现在需要存取用户一年的签到记录,只有两种可能性:签到或者没有签到。签到了就用1表示,没有签到则用0表示,要记录365天。如果使用普通的key/value,那么每个用户要记录365个,当用户数量极大的时候,那么需要极大的存储空间。为了解决这个问题,Redis提供了位图,它其实就是普通的字符串,字符串用字符数组char[]实现,一个字符char就是一个字节,一个字节占8个bit,故可以将一个字符串看成是由许多bit组成的数组。位图操作的就是某个字符串的某个比特位。比如“he”的bit如下:

public static void main(String[] args) {
    byte[] bytes = "he".getBytes();//获取的是char[]==>{'h','e','l','l','o'}的对应字节
    for (int i = 0; i < bytes.length; i++) {
        System.out.println(Integer.toBinaryString(bytes[i]));
    }
    //输出为:
    //1101000
    //1100101
    //      h         e
    //即:01101000 01100101   可以将这个看成是bit数组,下表从0开始到15结束
}

这样每天签到记录只占一个比特位,365天就是365个位,46个字节(一个稍微长一点的字符串)就可以完全容纳下,这就大大节省了空间。位图操作的是bit,故取值只能是0和1。Redis提供了比如SET或者GET指令操作字符串,也提供了位图操作的指令。如下:

SETBIT key offset value      //对key所储存的字符串值,设置指定偏移量上的位(bit)。value的取值只能是0或者1。我是这样理解的,如果可以没有key对应的字符串,那么它将创建一个bit位全为0的字符串
GETBIT key offset            //获取key对应字符串值的offset偏移量上的bit值(0或者1)。
BITCOUNT key [start] [end]   //统计指定范围内1的个数,对于不存在的key,返回值为0。start和end表示检测的范围。start表示“待检测字符串中字符的起始下标,end表示待检测字符串中字符的末下标”。
BITPOS key bit [start] [end]  //查找指定范围内出现的第一个0或者1。其中bit表示查找的目标(0或者1),start和end与上面一样,指定检测范围。注意返回值是“位数组”的下标

如下使用SETBIT指令创造一个字符串“he”,上面已经得到“he”的“bit数组”为 01101000(h) 01100101(e)。将它看成bit数组进行操作如下:

2.统计和查找

统计和查找使用BITCOUNT和BITPOS指令,使用它两分别可以统计用户一共签到多少天以及从哪一天开始签到的。如下是使用BITCOUNT和BITPOS的示例:

3.BITFIELD指令

上述对位图的操作都是对单个位进行操作,在Redis3.2版本以后新增了一个功能强大的指令BITFIELD,利用它可以一次性操作多个位。

BITFIELD key [GET type offset] [SET type offset value] [INCRBY type offset increment] [OVERFLOW WRAP|SAT|FAIL]
//GET、SET、INCRBY和OVERFLOW是它支持的子指令
//GET <type> <offset> —— 返回指定的二进制位范围。
//SET <type> <offset> <value> —— 对指定的二进制位范围进行设置,并返回它的旧值。
//INCRBY <type> <offset> <increment> —— 对指定的二进制位范围执行加法操作,并返回它的旧值。用户可以通过向 increment 参数传入负值来实现相应的减法操作。
//OVERFLOW [WRAP|SAT|FAIL]可以改变之后执行的 INCRBY 子命令在发生溢出情况时的行为:

//对于子指令的type选项:用户可以在类型参数的前面添加 i 来表示有符号整数, 或者使用 u 来表示无符号整数。 比如说, 我们可以使用 u8 来表示 8 位长的无符号整数, 也可以使用 i16 来表示 16 位长的有符号整数。
//BITFIELD 命令最大支持 64 位长的有符号整数以及 63 位长的无符号整数, 其中无符号整数的 63 位长度限制是由于 Redis 协议目前还无法返回 64 位长的无符号整数而导致的。
/*
用户可以通过 OVERFLOW 命令以及以下展示的三个参数, 指定 BITFIELD 命令在执行自增或者自减操作时,
 碰上向上溢出(overflow)或者向下溢出(underflow)情况时的行为:

WRAP : 使用回绕(wrap around)方法处理有符号整数和无符号整数的溢出情况。
对于无符号整数来说, 回绕就像使用数值本身与能够被储存的最大无符号整数执行取模计算, 
这也是 C 语言的标准行为。 对于有符号整数来说, 上溢将导致数字重新从最小的负数开始计算, 
而下溢将导致数字重新从最大的正数开始计算。 
比如说, 如果我们对一个值为 127 的 i8 整数执行加一操作, 那么将得到结果 -128 。

SAT : 使用饱和计算(saturation arithmetic)方法处理溢出, 也即是说, 
下溢计算的结果为最小的整数值, 而上溢计算的结果为最大的整数值。 
举个例子, 如果我们对一个值为 120 的 i8 整数执行加 10 计算, 
那么命令的结果将为 i8 类型所能储存的最大整数值 127 。 
与此相反, 如果一个针对 i8 值的计算造成了下溢, 那么这个 i8 值将被设置为 -127 。

FAIL : 在这一模式下, 命令将拒绝执行那些会导致上溢或者下溢情况出现的计算, 
并向用户返回空值表示计算未被执行。

需要注意的是, OVERFLOW 子命令只会对紧随着它之后被执行的 INCRBY 命令产生效果, 
这一效果将一直持续到与它一同被执行的下一个 OVERFLOW 命令为止。 
在默认情况下, INCRBY 命令使用 WRAP 方式来处理溢出计算。

指令使用与结果分析:

127.0.0.1:6379> set w hello
OK
//hello的bit数组:01101000 01100101 01101100 01101100 01101111

//从第一个位开始取4个位,将结果表示为无符号数(u)。结果为0110 == 6(0110)
127.0.0.1:6379> BITField w get u4 0    
1) (integer) 6

//从第一个位开始取4个位,将结果表示为有符号数(i)。结果为0110 == 6(符号位为0)
127.0.0.1:6379> BITField w get i4 0
1) (integer) 6

//还可以一次执行多条子指令
127.0.0.1:6379> BITField w get u4 0 get u3 2 get i4 0
1) (integer) 6
2) (integer) 5
3) (integer) 6

//从第9个位开始,将接下来的8个位用无符号数97替换。'a'的ASCII码是97
127.0.0.1:6379> BITField w set u8 8 97
1) (integer) 101    //返回的是旧值01100101 == 101
127.0.0.1:6379> GET w
"hallo"

//下面是incrby子指令
127.0.0.1:6379> BITFIELD w incrby u4 2 1    //从第3个位开始,取4个比特位,对其自增1.即对1010表示的无符号数+1  ===>  10 + 1 == 11
1) (integer) 11
127.0.0.1:6379> BITFIELD w incrby u4 2 1
1) (integer) 12
127.0.0.1:6379> BITFIELD w incrby u4 2 1
1) (integer) 13
127.0.0.1:6379> BITFIELD w incrby u4 2 1
1) (integer) 14
127.0.0.1:6379> BITFIELD w incrby u4 2 1
1) (integer) 15
127.0.0.1:6379> BITFIELD w incrby u4 2 1    //注意这里溢出了,因为4位无符号整数的表示范围为0-15。无符号数15的二进制即1111, 1111 + 1 == 10000(丢弃掉前面的1,则为0000 == 0)
1) (integer) 0

四、HyperLogLog(基数估值)

HyperLogLog的应用场景:

两个概念:

  • 网页的PV数据:页面浏览量或者说点击量。
  • 网页的UV数据:网页被不同用户点击的用户数量。在同一天内,如果一个用户第一次访问了该网页则记数一次,该用户同天内再次访问该页面则不进行计数。

假如我们需要统计网站上每个网页每天的PV数量该如何实现?可以给每个网页分配一个独立的Redis计数器,把这个计数器的key后缀加上当天的日期,这样来一个请求,执行一次incrby指令,最终就可以统计出PV数据。

但是假如要统计网站上每个网页每天的UV数呢?我们可以为每个页面设置一个独立的set集合来存储所有当天访问过页面的用户ID,当一个请求过来时,使用sadd将用户的ID存储进去即可。通过scard可以取出这个集合的大小,这个数字就是这个页面的UV数据。这个方案是可行的,但是当页面访问量巨大,那么就每个页面就会对应一个很大的set集合,对空间要求非常高。而且其实我们可能需要的数据不必要太精确,比如不需要确定到1060002,我们最终得到的如果是105万其实也可以说明问题。基于此,可以使用HyperLogLog这种数据结构。

HyperLogLog 是用来做基数(不重复元素)统计的算法,HyperLogLog 的优点是,在输入元素的数量或者体积非常非常大时,计算基数所需的空间总是固定的、并且是很小的。在 Redis 里面,每HyperLogLog 键只需要花费 12 KB 内存,就可以计算接近 2^64 个不同元素的基数。这和计算基数时,元素越多耗费内存就越多的集合形成鲜明对比。但是,因为 HyperLogLog 只会根据输入元素来计算基数,而不会储存输入元素本身,所以 HyperLogLog 不能像集合那样,返回输入的各个元素。HyperLogLog的标准误差是0.81%。

Redis提供的操作指令如下:

//PF是该数据结构的发明者Philippe Flajolet的首字母。
PFADD key element [element ...]                //添加指定元素到HyperLogLog中。
PFCOUNT key [key ...]                          //返回给定HyperLogLog的基数估算值。
PFMERGE destkey sourcekey [sourcekey ...]      //将多个HyperLogLog合并为一个新的HyperLogLog
public static void main(String[] args) {
    Jedis jedis = new Jedis();
    for (int i = 0; i < 1000000; i++) {
        //假设user + i为该用户的ID
        jedis.pfadd("codehole","user" + i);
    }
    long total = jedis.pfcount("codehole"); 

    System.out.println(total);//输出1001788,100万的数据量,可见误差不是很大
    jedis.close();
}

五、布隆过滤器

布隆过滤器的使用场景:比如我们在使用新闻客户端看新闻时,他会给我们不停地推荐新地内容,而它每次推荐都要去重,以去掉那些我们已经看过地内容。布隆过滤器就是专门解决这种去重问题的。它在去重的同时,能节省90%以上的空间,但是稍微有点不太准确,有一定误判的概率。

布隆过滤器可以理解为一个稍微有点不精确的set结构,可以用它来判断某个对象是否存在,它可能会误判:

  • 当布隆过滤器说某个值存在时,这个值可能不存在。
  • 当布隆过滤器说某个值不存在时,它一定不存在。

造成上面两种情况出现的就是它内部的原理,布隆过滤器的内部结构可以看成是一个大型的位数组和几个不一样的无偏hash函数(无偏就是hash比较均匀,让元素被hash映射到位数组中的位置比较随机)。

向布隆过滤器中添加key时,会使用多个hash函数对key进行hash,算得一个整数索引值,然后对位数组长度取模运算得到一个位置,每个hash函数都会算得一个 不同得位置。在把位数组得这几个位置都置为1.就完成了add操作。

如下图,假设key1和key2被计算后得到如下图:

 它是这样判断是否包含的。假设现在有一个key3被三个hash函数计算得到的位数组下标位置为"0,2,7",他会去查看“0,2,7”这三个位置是否都为1,由上图可知0处不为1,那就说明不包含这个key3,假设现在key3计算后的位置为“2,6,7”,那么他就会判断这个key3极有可能存在,这并不能说明这个key3一定存在,只是极有可能,根据图也可以看到,这个key3是被key1和key2影响所致,此时还需要看位数组的稀疏程度,如果这个位数组比较拥挤,那么判断key3存在的概率就会比较低,如果这个位数组比较稀疏,那么判断key3存在的概率就会比较高。具体概率计算的公式也是比较复杂的。

所以大概明白原理之后在使用布隆过滤器时,不要让实际数量远大于初始化数量,当实际元素数量开始超出初始化数量时,应该对布隆过滤器进行重建,重新分配一个size更大的过滤器,再将所有的历史元素批量add进去(这就要求我们在其他的存储器中记录所有的历史元素)。

Redis中使用布隆过滤器需要先下载相应的插件,然后才能进行后续的操作。

【单个操作命令】
bf.add key value        //向key对应的布隆过滤器中添加单个元素
bf.exists key value     //判断是否包含key对应的布隆过滤器是否包含value
【批量操作命令】
bf.madd key value1 [value2……]
bf.mexists key value1 [value2……]

还有许多基于Java实现的布隆过滤器,比如: https://github.com/Baqend/Orestes-Bloomfilter里面有详细的用法介绍。

六、简单限流

合理的限流算法可以在系统处理问题能力有限时阻止计划外的请求继续对系统施压,这是一个需要重视的问题,除了控制流量,限流还有一个应用目的是控制用户行为,避免垃圾请求。比如用户的发帖、回复、点赞、访问量等行为都要收到严格的控制,一般要求严格限定行为在规定时间被允许的次数,超过了此处就是违法行为,是不被允许的。

比如现在要求用户在10秒之内发回复的数量不能超过5次,这借用Redis该如何实现?

首先可以选取zset结构,选取它的主要目的是其中包含score,通过score可以圈出一个10s的时间窗口,我们只需要保留这个时间窗口,窗口之外的数据都可以砍掉,而zset结构的value没有特殊的作用,只需要将其设置为唯一的值即可,就是避免用户某次的回复被覆盖,以便准确记录用户的操作次数。

具体一点来说就是每一个不重复的value对应一个score值,改值为当前系统的时间。假设用户从系统时间10000-25000的区间内操作了15次(也就是每秒操作一次),每一次操作都往zset结构里面存一个不重复的value,同时移除zset中socre值在区间 [0 到 当前时间-10*1000]的数据 (10*1000 表示10s时间 )所以zset中存储的都是10s内的数据,如果哪一次操作完后数据量超过阈值就说明该次操作不被允许。

如下为代码实现:

public class SimpleRateLimiter {
    private Jedis jedis;

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

    public boolean isActionAllowed(String userId,String actionKey,int period, int maxCount){
        String key = String.format("hist:%s:%s", userId, actionKey);
        long nowTs = System.currentTimeMillis();
        //获取管道:Redis管道技术可以在服务端未响应时,客户端可以继续向服务端发送请求,并最终一次性读取所有服务端的响应。
        Pipeline pipeline = jedis.pipelined();
        pipeline.multi();
        pipeline.zadd(key,nowTs,UUID.randomUUID().toString());
        //移除有序集合中给定的排名区间的所有成员
        Response<Long> number = pipeline.zremrangeByScore(key,0,nowTs - period * 1000);
        Response<Long> count = pipeline.zcard(key);
        pipeline.expire(key, period);
        pipeline.exec();
        pipeline.close();
        //表示当前的行为不被允许
        return count.get() <= maxCount;
    }

    public static void main(String[] args) throws InterruptedException {
        Jedis jedis = new Jedis();
        SimpleRateLimiter srl = new SimpleRateLimiter(jedis);
        for (int i = 0; i < 25; i++) {
            System.out.println(srl.isActionAllowed("yhj","reply",10,5));
            //一秒操作一次
            Thread.sleep(1000);
        }
    }
}

七、漏斗限流

漏斗限流是最常用的限流方法之一,这个算法的灵感来源于漏斗。如果注水的速率大于漏水的速率,那么漏斗满了就会溢出,溢出的操作全部是被拒绝的操作。如下是代码模拟:

public class FunnelRateLimiter {
        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) ((int)(deltaTs / 1000)  * leakingRate); //计算漏掉的水量(每秒)
                //考虑边界,int类型溢出,时间间隔过大
                if(deltaQuota < 0){
                    this.leftQuota = capacity;
                    this.leakingTs = nowTs;
                    return;
                }
                //没有漏水
                if(deltaQuota < 1){
                    return;
                }
                //漏斗剩余容量增加
                this.leftQuota += deltaQuota;
                this.leakingTs = nowTs;
                //处理边界
                if(this.leftQuota > this.capacity){
                    this.leftQuota = this.capacity;
                }
            }

            boolean watering(int quota){    //注水
                System.out.println("当前剩余容量:" + this.leftQuota);
                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);
        }

    public static void main(String[] args) throws InterruptedException {
        FunnelRateLimiter funnelRateLimiter = new FunnelRateLimiter();
        for (int i = 0; i < 10; i++) {
            //漏水速率2s漏一个
            boolean ok = funnelRateLimiter.isActionAllowed("yhj","reply", 5,0.5f);
            System.out.println(ok ? "操作成功" : "操作被限制");
            Thread.sleep(1000);
        }
    }
}

Redis4.0提供了一个限流模块,他叫Redis-Cell。该模块也使用了漏斗算法,并提供了原子限流指令,使用前需要先安装Redis-Cell插件。该模块只有一条指令cl.throttle

//下面的15表示漏斗容量capacity
//30和60用于计算速率 (60s内可执行30次操作)  30/60即速率 == 0.5 (次/s)
//1表示quota,它是可选参数,默认值也是1.
> cl.throttle yhj:reply 15 30 60 1
1) (integer) 0    // 0表示允许,1表示拒绝
2) (integer) 15   // 漏斗容量capacity
3) (integer) 14   // 漏斗剩余空间left_quota
4) (integer) -1   // 如果拒绝了,需要多长时间后再重试,单位秒
5) (integer) 2    // 多长时间后,漏斗完全空出来,单位秒

再执行限流指令时,如果被拒绝了,就需要丢弃或者重试。cl.throttle指令已经将重试时间计算好,直接取返回结果数组的第四个值进行sleep即可,若不想阻塞线程,也可以异步定时任务来重试。

八、GeoHash(位置距离排序)

地图元素的位置数据使用二维的经纬度表示,经度范围为[-180,180],正负以本初子午线为界,东正西负;纬度范围为[-90,90],以赤道为界,北正难负。如果需要计算A点与B点之间的距离,需要直到AB点的经纬度,然后根据某种算法得到距离。如果需要实现“附近的人”功能,比如查找附近10km的人,那么明确A点坐标,然后计算出A点与其他元素之间的距离并进行排序。筛选出前10km内的人即可。

GeoHash是业界比较通用的地理位置距离排序算法,Redis也使用GeoHash算法。算法内部原理暂且不谈。Redis在3.2版本以后增加了地理位置Geo模块(底层存储结构为zset),意味着我们可以使用Redis来查找“附近的人”。Redis提供的Geo指令只有6个。如下:

  • geoadd:添加地理位置的坐标。(注意:它是添加进zset结构中)
  • geopos:获取地理位置的坐标。
  • geodist:计算两个位置之间的距离。
  • georadius:根据用户给定的经纬度坐标来获取指定范围内的地理位置集合。
  • georadiusbymember:根据储存在位置集合里面的某个地点获取指定范围内的地理位置集合。
  • geohash:返回一个或多个位置对象的 geohash 值。
//将一个或多个经度(longitude)、纬度(latitude)、位置名称(member)添加到指定的key中。注意key对应的是一个zset
GEOADD key longitude latitude member [longitude latitude member ...]

//从给定的 key 里返回所有指定名称(member)的位置(经度和纬度),不存在的返回 nil。
GEOPOS key member [member ...]

//返回两个给定位置之间的距离
GEODIST key member1 member2 [m|km|ft|mi]

//以给定的经纬度为中心, 返回key对应的zset中与中心的距离不超过给定最大距离的所有位置元素
//longitude 和 latitude为中心距离的经纬度坐标
//radius为给定最大距离
GEORADIUS key longitude latitude radius m|km|ft|mi [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count] [ASC|DESC] [STORE key] [STOREDIST key]

//georadiusbymember 和 GEORADIUS 命令一样, 都可以找出位于指定范围内的元素, 
//但是 georadiusbymember的中心点是由给定的位置元素(member)决定的, 而不是使用经度和纬度来决定中心点。
GEORADIUSBYMEMBER key member radius m|km|ft|mi [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count] [ASC|DESC] [STORE key] [STOREDIST key]
//参数说明:
//m :米,默认单位。
//km :千米。
//mi :英里。
//ft :英尺。
//WITHDIST: 在返回位置元素的同时, 将位置元素与中心之间的距离也一并返回。
//WITHCOORD: 将位置元素的经度和维度也一并返回。
//WITHHASH: 以 52 位有符号整数的形式, 返回位置元素经过原始geohash编码的有序集合分值。 这个选项主要用于底层应用或者调试, 实际中的作用并不大。
//COUNT 限定返回的记录数。
//ASC: 查找结果根据距离从近到远排序。
//DESC: 查找结果根据从远到近排序。

//Redis GEO 使用 geohash 来保存地理位置的坐标。geohash 用于获取一个或多个位置元素的 geohash 值。
GEOHASH key member [member ...]

在一个地图应用中,车的数据、餐馆的数据可能会有几百万条甚至几千万条,如果使用Redis的Geo数据结构,他们将被全部放在一个zset集合中。在Redis集群环境中,集合可能会从一个节点迁移到另一个节点,如果单个key的数据过大,会对集群的迁移工作造成较大的影响,在集群环境中单个key对应的数据量不宜超过1MB,否则会导致集群迁移出现卡顿现象,影响线上服务的正常运行。所以这里建议Geo的数据使用单独的Redis实例部署,使用集群环境。如果数据量过亿,甚至更大,就需要对Geo数据进行拆分,按国家拆分、按省拆分,在人口特大城市甚至可以按区拆分。这样就可以显著降低单个zset集合的大小。

九、scan指令

我们有时候需要从Redis实例的成千上万个key中找到满足特定前缀的key列表来手动处理数据。Redis提供了scan命令来列出所有满足特定正则字符串规则的key。scan指令具有如下特点:

SCAN cursor [MATCH pattern] [COUNT limit]
  • 复杂度为O(n),该查找动作通过游标分步进行,单次扫描完毕之后还可以做其他操作。
  • 提供limit参数,可以控制每次返回结果的,返回的结果可多可少。(注意:limit不是限定返回结果的数量,而是限定服务器单次遍历的hash桶的桶位数量(约等于))。
  • 单次扫描之后scan会返回给客户端一个游标整数
  • 返回的结果可能会有重复,需要客户端去重。
  • 遍历过程中如果有数据修改,改动后的数据能不能遍历到是不确定的。
  • 单次返回的结果是空的并不意味着遍历结束,而要看返回的游标值是否为0。

在Redis中,所有的key都存储在一个HashMap中(数组+链表)的结构。scan指令返回的游标就是数组的位置索引(桶位),limit参数表示的是本次遍历需要遍历的桶位数量。scan的遍历顺序并非从数组的0位置处一致遍历到末尾,而是采用了高位进位加法来遍历。是考虑到HashMap扩容和缩容时避免槽位的遍历重复和遗漏。

使用示例如下,先添加10000条数据,再进行遍历:

public static void main(String[] args) {
    Jedis jedis = new Jedis();
    for (int i = 0; i < 10000; i++) {
        jedis.set("key" + i, i + "");
    }
}

scan是一系列指令,除了可以遍历所有key之外,还可以对指定的容器进行遍历。

  • zscan遍历zset元素
  • hscan遍历hash元素
  • sscan遍历set元素

其中原理类似,因为都是使用哈希表作为底层结构。

 

参考自《Redis深度历险》

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值