美文网首页
Redis系列之进阶篇(下)

Redis系列之进阶篇(下)

作者: 可苯 | 来源:发表于2019-06-25 08:56 被阅读0次

    Redis系列之进阶篇(下)

    前言

        上一期我们学习了Redis的一些高级应用,今天我们来继续学习Redis的高级技术。
    

    这篇文章主要内容是:

    • 布隆过滤器

    • 限流

    • GeoHash

    • Scan

      本文所学知识点过多,请做好实践。

    1. 布隆过滤器

    ​ 布隆过滤器是一种高级数据结构,专门用于解决去重和检测某个对象是否存在的问题。

    ​ 布隆过滤器就像一个不怎么精确的set结构,当你使用它的contains方法判断某个对象是否存在时,它可能会误判。布隆过滤器实际上并不是特别不精确,只是会有较小的误判概率。

    ​ 所谓的误判就是:当布隆过滤器说某个值不存在的时候,那么这个值就一定不存在;当它说一个值存在的时候,那么这个值可能存在,也可能不存在。之所以出现误判,不过是因为这个值和它知道的某个值比较相似。

    1.1 简单使用

    ​ Redis4.0提供了插件功能,布隆过滤器作为一个插件加载到Redis Server中,给Redis提供了强大的布隆去重功能。

    ​ 布隆过滤器有两个基本指令,bf.add和bf.exists。bf.add添加元素,bf.exists查询元素是否存在。

        > bf.add user keben
        > bf.add user zhangsan
        > bf.add user zhaosi
        > bf.exists user keben
        > bf.madd user xiaoming zhangwu wanger          # 批量添加
        > bf.mexists user xiaoming zhangwu wanger       # 批量判断是否存在
    

    1.2 自定义参数

    ​ Redis提供了自定义参数的布隆过滤器,来降低误判率,只需要在add之前使用bf.reserve指令创建。如若对应的key已经存在,bf.reserve会报错。

    ​ bf.reserve有三个参数,分别是key,error_rate(错误率)和initial_size。

    ​ error_rate越低,需要的空间越大。

    ​ initial_size表示预计放入的元素数量,当实际数量超过这个数值时,误判率会上升。

    1.3 原理概括

    ​ 每个布隆过滤器对应到Redis的数据结构里面就是一个大型的位数组和几个不一样的无偏hash函数。所谓无偏就是能够把元素的hash值算的比较均匀,让元素被hash映射到位数组中的位置比较随机。

    ​ 向布隆过滤器插入key的时候,会对key进行hash,获取一个整数索引,然后对位数组长度进行取模运算获取一个位置。再把位数组的这几个位置都置为1。

    ​ 向布隆过滤器查询是否存在的时候,和add是相同,也会把hash的几个位置都算出来,看看位数组中这几个位是否都为1,只要有一个位为0,说明布隆过滤器中这key不存在。如果几个位置都为1,并不能说明这个key就一定存在,因为这些位被置为1可能是因为其他key导致的。

    image

    2. 限流

    ​ 限流算法在分布式领域下是一个经常被提起的话题,当系统的处理能力有限时,如何阻止计划外的请求继续对系统施压,这是一个需要重视的问题。除了控制流量,限流还有一个应用目的是控制用户行为,避免垃圾请求。

    ​ ==注意Redis中的限流只是添加用户的行为记录,根据行为记录是否超标来进行真实的行为操作==

    2.1 简单限流

    ​ 来看下一个常见的,简单的限流策略。系统要限定用户的某个行为在指定时间内只能发生N次。

        // 指定用户userId指定行为actionkey在一定时间内period只能执行maxCount次
        publci void isActionAllowed(Long userId,String actionKey,Integer period,Integer maxCount){
            String key = String.format("hist:%s:%s",userId,actionKey);
            // 获取当前时间
            long nowTs = System.currentTimeMillis();
            // 创建连接管道,后续几个都是对同一key处理,使用管道提升redis存取效率
            Pipeline pipe = jedis.pipelined();
            // 开始事务
            pipe.multi();
            // 添加当前时间的行为
            pip.zadd(key,nowTs,""+nowTs);
            // 移除当前时间减去(特定时间)前的所有行为记录
            pipe.zremrangeByScore(key,0,nowTs - period * 1000);
            // 剩余的就是指定时间段内的所有行为记录
            Response<Long> count =  pipe.zcard(key);
            // 设置过期时间
            pipe.expire(key,period + 1);
            pipe.exec();
            pipe.close();
            // 验证是否超过限制
            return count.get() <= maxCount;
        }
    

    ​ 上述方案也有缺点,因为它要记录限定时间内的所有行为记录,如果这个量大,比如“限定60s内操作不能超过100万次”之类,它就不适合做这样的限流,因为会消耗大量的存储空间。

    2.2 漏斗限流

    ​ 漏斗限流是最常用的限流方法之一,显然,这个算法的灵感来自于漏斗的结构。

    ​ 漏斗容量是有限的,若将漏嘴堵住,然后一直灌水,它就会变满,再也转不进去。如果将漏嘴放开,水就会一直流,流走一部分之后就又可以继续往里面灌水。如果漏嘴流水速率大于灌水的速率,那么漏斗永远都装不满。如果漏嘴流水速率小于灌水的速率,那么一旦漏斗满了,灌水就需要暂停并等待漏斗腾出一部分空间。

    ​ 所以漏斗的剩余空间就代表着当前行为可以持续进行的数量,漏嘴的漏水速率代表着系统允许该行为的最大频率。

           /**
     * 根据给定的漏斗参数检查是否允许访问
     *
     * @param username   用户名
     * @param action     操作
     * @param capacity   漏斗容量
     * @param allowQuota 每单个单位时间允许的流量
     * @param perSecond  单位时间(秒)
     * @return 是否允许访问
     */
    public boolean isActionAllowed(String username, String action, int capacity, int allowQuota, int perSecond) {
        String key = "funnel:" + action + ":" + username;
        if (!funnelMap.containsKey(key)) {
            funnelMap.put(key, new Funnel(capacity, allowQuota, perSecond));
        }
        Funnel funnel = funnelMap.get(key);
        return funnel.watering(1);
    }
    
        private static class Funnel {
            private int capacity;
            private float leakingRate;
            private int leftQuota;
            private long leakingTs;
    
            public Funnel(int capacity, int count, int perSecond) {
                this.capacity = capacity;
                // 因为计算使用毫秒为单位的
                perSecond *= 1000;
                this.leakingRate = (float) count / perSecond;
            }
    
            /**
             * 根据上次水流动的时间,腾出已流出的空间
             */
            private void makeSpace() {
                long now = System.currentTimeMillis();
                long time = now - leakingTs;
                int leaked = (int) (time * leakingRate);
                if (leaked < 1) {
                    return;
                }
                leftQuota += leaked;
                // 如果剩余大于容量,则剩余等于容量
                if (leftQuota > capacity) {
                    leftQuota = capacity;
                }
                leakingTs = now;
            }
    
            /**
             * 漏斗漏水
             *
             * @param quota 流量
             * @return 是否有足够的水可以流出(是否允许访问)
             */
            public boolean watering(int quota) {
                makeSpace();
                int left = leftQuota - quota;
                if (left >= 0) {
                    leftQuota = left;
                    return true;
                }
                return false;
            }
        }
    

    ​ 其中mackSpace方法是漏斗算法的核心,其在每次灌水前都会被调用以触发漏水,给漏斗腾出空间。能腾出的空间取决于过去了多久以及流水的速率。

    ​ 这种限流方式还是会有问题,我们无法保证整个过程的原子性。从hash结构中取值,然后在内存中运算,再回填到hash结构,这三个过程无法原子化,意味着需要进行加锁控制,而一旦加锁,就可能会出现加锁失败的可能,就需要选择重试或放弃。如若重试就会性能下降,如若放弃就会影响用户体验。

    2.3 Redis-Cell

    ​ Redis4.0提供了一个限流Redis模块,她叫Redis-Cell。该模块使用了漏斗算法,并提供了原子的限流指令。

    ​ 该模块只有1条指令cl.throttle。

        cl.throttle keben 15 30 60 1
        # keben     key
        # 15 capacity 这是漏斗容量
        # 30 60    30 operations / 60 seconds 这是漏水速率
        # 1 need 1 quota (可选参数,默认为1)
        
        > cl.throttle keben 15 30 60 1
        1) (integer) 0          # 0 表示允许  1 表示拒绝
        2) (integer) 15         # 漏斗容量 capacity
        3) (integer) 14         # 漏斗剩余空间 left_quota
        4) (integer) -1         # 如果被拒绝,需要多长时间后再试
        5) (integer) 2          # 多长时间后,漏斗完全空下来
    

    3. GeoHash

    ​ Redis在3.2版本增加了地图位置Geo模块,让我们可以使用Redis来实现类似于微信的“附近的人”,共享单车的“附近的车”的功能。

    ​ GeoHash是业界比较通用的地理位置距离排序算法。GeoHash是将二维的经纬度数据映射到一维的整数,这样所有的元素都将挂载到一条线上,距离靠近的二维坐标映射到一维后的点之间的距离也会很接近。当我们想要计算“附近的人”,直接将目标位置映射到这条线上,然后在这个一维的线上获取附近的点就行了。

        它将整个地球看成一个二维平面,然后划分成了一系列正方形的方格,就好比围棋棋盘。所有的地图元素坐标都将放置于唯一的方格中。方格越小,坐标越精确。然后对这些方格进行整数编码,越是靠近的方格编码越是接近。那如何编码呢?一个最简单的方案就是切蛋糕法。设想一个正方形的蛋糕摆在你面前,二刀下去均分分成四块小正方形,这四个小正方形可以分别标记为 00,01,10,11 四个二进制整数。然后对每一个小正方形继续用二刀法切割一下,这时每个小小正方形就可以使用 4bit 的二进制整数予以表示。然后继续切下去,正方形就会越来越小,二进制整数也会越来越长,精确度就会越来越高。编码之后,每个地图元素的坐标都将变成一个整数,通过这个整数可以还原出元素的坐标,整数越长,还原出来的坐标值的损失程度就越小。对于「附近的人」这个功能而言,损失的一点精确度可以忽略不计。
    
    image

    ​ GeoHash算法会对上述编码的整数继续做一次base32编码(0 ~ 9,a ~ z)变成一个字符串。Redis中经纬度使用52位的整数进行编码,放进zset中,zset的value元素是key,score是GeoHash的52位整数值。在使用Redis进行Geo查询时,其内部对应的操作其实只是zset(skiplist)的操作。通过zset的score进行排序就可以得到坐标附近的其它元素,通过将score还原成坐标值就可以得到元素的原始坐标

        总之,Redis中处理这些地理位置坐标点的思想是: 二维平面坐标点  --> 一维整数编码值 --> zset(score为编码值) --> zrangebyrank(获取score相近的元素)、zrangebyscore --> 通过score(整数编码值)反解坐标点 --> 附近点的地理位置坐标。
    

    3.1 基本用法

        【增加】
        geoadd 指令携带集合名称以及多个经纬度名称三元组
        > geoadd user 114.11342 39.12231 keben
        > geoadd user 115.12345 38.12658 xiaocai
        
        【距离】
        geodist指令可以用来计算两个元素之间的距离,携带集合名称,两个名称和距离单位。单位可以是m,km,ml和ft,分别表示米,千米,英里和尺。
        > geodist user keben xiaocai km
        
        【获取元素位置】
        geopos指令可以用来获取集合中的任意元素的经纬度坐标,可以一次获取多个
        > geopos user keben
        > geopos user keben xiaocai
        
        【获取元素的hash值】
        geohash可以获取元素的经纬度编码字符串,用这个编码可以直接取geohash.org上直接定位。
        > geohash user keben
        
        【附近的人】
        georadiusbymember指令是最关键的指令之一,它可以用来查找指定元素附近的其他元素
        # 范围20公里以内最多3个元素按照距离正排,不排除自身
        > georadiusbymember user keben 20 km count 3 asc
        
        # 范围20公里以内最多3个元素按照距离倒排
        > georadiusbymember user keben 20km count 3 desc
        
        # 三个可选参数 withcoord,withdist,withhash 用来携带附加参数
        # withdist 很有用, 用来显示距离
        
        【查询附近的元素】
        georadius可以根据用户的定位来计算“附近的车”等。它的参数和georadiusbymember基本一致,唯一的差别是将目标元素改为了经纬度坐标值。
        > georadius user 116.645322 39.890843 20 km withdist count 3 asc
    

    ​ 在使用时注意,车的数据,人的数据可能会有百万甚至上千万条,如果使用redis的Geo数据结构,它们全部都放在一个zset集合中。如果在redis集群下,集合可能会从一个节点迁移到另一个节点,如果单个key的数据过大,会对集群的迁移工作造成较大影响,单个key的数据量不宜超过1M,否则会使得集群迁移变得卡顿,影响线上服务正常运行。

    ​ 建议Geo数据使用单独的Redis部署,不适用集群环境。如果数据量过亿,就需要对Geo数据进行拆分,按照国家,省份等。

    4. Scan

    ​ 在平时线上 Redis 维护工作中,有时候需要从 Redis 实例成千上万的 key 中找出特定前缀的 key 列表来手动处理数据,可能是修改它的值,也可能是删除 key。这里就有一个问题,如何从海量的 key 中找出满足特定前缀的 key 列表来?

    4.1 keys

    ​ Redis 提供了一个简单暴力的指令 keys 用来列出所有满足特定正则字符串规则的 key。

        > keys *
        > keys kob*
        > keys ko*e
    

    这个指令使用非常简单,但有两个明显缺点。

    • 没有offset,limit参数,一次性显示所有满足条件的key,如果有上百万满足条件的key,同样会一次显示完。
    • keys算法是遍历算法,复杂度是O(n),如若实例中上千万以上的key,这个指令会导致redis服务卡顿。

    4.2 scan

    Redis 为了解决这个问题,它在 2.8 版本中加入了大海捞针的指令——scan。scan 相比keys 具备有以下特点:

    • 复杂度虽然也是 O(n),但是它是通过游标分步进行的,不会阻塞线程;*
    • 提供 limit 参数,可以控制每次返回结果的最大条数,limit 只是一个 hint,返回的结果可多可少;
    • 同 keys 一样,它也提供模式匹配功能;
    • 服务器不需要为游标保存状态,游标的唯一状态就是 scan 返回给客户端的游标整数;
    • 返回的结果可能会有重复,需要客户端去重复,这点非常重要;
    • 遍历的过程中如果有数据修改,改动后的数据能不能遍历到是不确定的;
    • 单次返回的结果是空的并不意味着遍历结束,而要看返回的游标值是否为零;
            scan 参数提供了三个参数,第一个是 cursor 整数值,第二个是 key 的正则模式,第三个是遍历的 limit hint。第一次遍历时,cursor 值为 0,然后将返回结果中第一个整数值作为下一次遍历的 cursor。一直遍历到返回的 cursor 值为 0 时结束。
            
            > scan 0 match key88* count 1000
            > scan 122 match key88* count 1000
    

    ​ scan 指令是一系列指令,除了可以遍历所有的 key 之外,还可以对指定的容器集合进行遍历。比如 zscan 遍历 zset 集合元素,hscan 遍历 hash 字典的元素、sscan 遍历 set 集合的元素。

    ​ 注意平日开发中尽量避免大key的产生。如若定位具体大key请使用“redis-cli -h 127.0.0.1 -p 7001 –-bigkeys -i 0.1” 这条指令每隔100条scan指令就会休眠0.1s。

        预知后事如何,请看下回分解。
    

    相关文章

      网友评论

          本文标题:Redis系列之进阶篇(下)

          本文链接:https://www.haomeiwen.com/subject/yopzqctx.html