redis篇-基础与应用篇(下)

前言:

参照 《redis深度历险-核心原理与应用实践》

一、布隆过滤器

1. 概述

场景:判断用户是否已经存在

定义:可以将布隆过滤器视为精度没那么高的set,当使用它的contain方法判断对象是否存在时,可能会有误判。

特征:当布隆过滤器认为某个值不存在时,这个值一定不存在;任务存在时,可能不存在

2. redis中的布隆过滤器

redis4.0提供插件功能后,提供了布隆过滤器。

指令:bf.add/bf.exists

若redis版本小于4.0,可使用 pyreBloom(python) 或 orestes-bloomfilter(java)

3. 布隆过滤器原理

数据结构:布隆过滤器对应着redis中一个大型的位数组和几个不一样的无偏hash函数。无偏hash函数是指能够把元素的hash值算得比较均匀,让元素在数组中分布的位置比较随机的函数

 执行流程:

  • 向布隆过滤器添加key时,会使用多个hash函数对key进行hash,算得一个整数索引值,然后对数组位长度取模,得到一个位置,每个hash函数都会算得不同的位置,将这几个位置都置为1,完成add操作
  • 判断key是否存在时,通过hash计算出key的几个位置,判断这几个位是否为1。只要有一个位为0,则说明key不存在;若均为1,不能说明key一定存在。如果这个位数组比较稀疏,判断正确的可能性会大一些。

使用bit操作实现布隆过滤器:

package redis.BloomFilter;

import org.apache.flink.shaded.curator.org.apache.curator.shaded.com.google.common.hash.Funnels;
import org.apache.flink.shaded.curator.org.apache.curator.shaded.com.google.common.hash.Hashing;
import org.apache.flink.shaded.curator.org.apache.curator.shaded.com.google.common.primitives.Longs;
import redis.clients.jedis.HostAndPort;
import redis.clients.jedis.Jedis;

import java.nio.charset.Charset;

public class BloomFilter {
    private final Jedis jedis = new Jedis(new HostAndPort("127.0.0.1", 6379));
    private final int bitlen;
    private final String key;
    private final int numHashFunctions;
    private final long expireSec;

    public BloomFilter(String key, int numHashFunctions, int bitlen, long expireSec) {
        this.key = key;
        this.numHashFunctions = numHashFunctions;
        this.bitlen = bitlen;
        this.expireSec = expireSec;
    }

    public void insert(String val) {
        long[] indices = getBitIndices(val);
        for (long indice : indices) {
            this.jedis.setbit(this.key, indice, true);
        }
        this.jedis.expire(this.key, this.expireSec);
    }

    public boolean isExist(String val) {
        long[] indices = getBitIndices(val);
        for (long indice : indices) {
            boolean bitState = this.jedis.getbit(this.key, indice);
            if (!bitState) {
                return false;
            }
        }
        return true;
    }

    private long[] getBitIndices(String element) {
        long[] indices = new long[numHashFunctions];

        byte[] bytes = Hashing.murmur3_128()
                .hashObject(element, Funnels.stringFunnel(Charset.forName("UTF-8")))
                .asBytes();

        long hash1 = Longs.fromBytes(
                bytes[7], bytes[6], bytes[5], bytes[4], bytes[3], bytes[2], bytes[1], bytes[0]
        );
        long hash2 = Longs.fromBytes(
                bytes[15], bytes[14], bytes[13], bytes[12], bytes[11], bytes[10], bytes[9], bytes[8]
        );

        long combinedHash = hash1;
        for (int i = 0; i < numHashFunctions; i++) {
            indices[i] = (combinedHash & Long.MAX_VALUE) % this.bitlen;
            combinedHash += hash2;
        }

        return indices;
    }


    public static void main(String[] args) {
        BloomFilter bloomFilter = new BloomFilter("bloomFilter", 6, 256, 100);
        bloomFilter.insert("user1");
        bloomFilter.insert("user2");
        bloomFilter.insert("user3");
        bloomFilter.insert("user4");
        bloomFilter.insert("user5");
        bloomFilter.insert("user55");
        bloomFilter.insert("user6");
        bloomFilter.insert("user32");
        bloomFilter.insert("user22");
        System.out.println(bloomFilter.isExist("user1"));
        System.out.println(bloomFilter.isExist("user10"));
        System.out.println(bloomFilter.isExist("user4"));
        System.out.println(bloomFilter.isExist("user6"));
        System.out.println(bloomFilter.isExist("user16"));
        System.out.println(bloomFilter.isExist("user26"));
        System.out.println(bloomFilter.isExist("user36"));
        System.out.println(bloomFilter.isExist("user3"));
        System.out.println(bloomFilter.isExist("user35"));
        System.out.println(bloomFilter.isExist("use2"));
        System.out.println(bloomFilter.isExist("user22"));
    }

}

4. 空间占用估计

布隆过滤器有两个相关的参数,分别是:

k=0.7*(l/n)

m=l/n; f=0.6185^m

公式中,l为位数组长度,n为预计元素数量,f为错误率,k为hash函数最佳数量。

从公式中可见:

  • 位数组/预计元素数量越大,错误率越低
  • 位数组/预计元素数量越大,hash函数需要的最佳数量越多,影响计算效率
  • 当一个元素平均需要1个字节(8bit)的指纹空间时(l/n=8),错误率大约为2%
  • 错误率为10%时,一个元素需要的指纹空间为4.792bit;错误率1%时,9.585bit;错误率0.1%时,14.377bit

 5. 实际元素超出后,误判率变化

布隆过滤器存在公式

f=(1-0.5^t)^k

其中,f为错误率,t为实际元素与预计元素的倍数,k为最佳hash数量

当t增大时,错误率f会增加

6. 布隆过滤器的其他应用

a. 爬虫系统判断url是否已经爬取过

b. Nosql领域应用广泛

c. 垃圾邮件过滤普遍也使用了布隆过滤器

二、简单限流

目的:当系统能力有限时,阻止计划外的请求对系统施压;控制用户行为,避免垃圾请求

1. 如何使用redis实现简单限流

a. 接口定义:

public class SimpleActionAllowMain {
    // 接口函数
    public void method() throws Exception {
        if (isActionAllowed("user1", "reply", 60, 5)) {
            // do_something
        } else {
            throw new Exception("action overflow");
        }
    }

    // 限流函数
    public static boolean isActionAllowed(String userId, String actionKey, int period, int maxCount) {
        return true;
    }
}

b. 限流解决方案:

思路:

  • 限流需要使用滑动窗口:可使用zset,根据zset的score值做滑动窗口
  • 只需要保留时间窗口中的值,窗口外的值可以砍掉
  • zset的value值只需要保证唯一性即可(zset滑动窗口需要统计数量,若value一致会导致计数值变小)
  • 用户的行为会被zset的一个key保存下来,同一个用户同一种行为用一个zset记录;通过一段时间内zset的数量与阈值的比较,可以得到目前行为是否被允许

代码实现:

package redis.SimpleActionAllow;

import redis.clients.jedis.Jedis;
import redis.clients.jedis.Pipeline;
import redis.clients.jedis.Response;

import java.util.UUID;

public class SimpleRateLimiter {
    private final Jedis jedis;

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

    public boolean isActionAllowed(String userId, String actionKey, int period, int maxCnt) {
        String key = userId + "::" + actionKey;
        long timeNow = System.currentTimeMillis();
        Pipeline pipeline = jedis.pipelined();
        pipeline.multi();
        // 当前行为增加数据: score设置为当前时间,方便滑动窗口剔除过期数据
        pipeline.zadd(key, timeNow, key + "_" + timeNow + "_" + UUID.randomUUID().toString());
        // 删除过期数据
        pipeline.zremrangeByScore(key, 0, timeNow - period * 1000);
        // 获取周期内数量
        Response<Long> count = pipeline.zcard(key);
        // 周期外不更新,直接删除
        pipeline.expire(key, period + 1);
        pipeline.exec();
        pipeline.close();
        return count.get() <= maxCnt;
    }
}
package redis.SimpleActionAllow;

import redis.clients.jedis.Jedis;

public class SimpleActionAllowMain {
    public static void main(String[] args) throws Exception {
        Jedis jedis = new Jedis("127.0.0.1", 6379);
        SimpleRateLimiter limiter = new SimpleRateLimiter(jedis);
        for (int i = 0; i < 10; i++) {
            System.out.println(limiter.isActionAllowed("zzh", "reply", 60, 5));
        }
    }
}

输出结果为:

 可见,限流起了作用

缺点:

该方案存在缺点,即空间复杂度高,需要记录窗口内所有行为,在类似“限制60s内不能操作超过100w次”的场景下并不适用

三、漏斗限流

1. 单机版漏斗限流

特征:

  • 漏斗剩余空间代表当前行为可以持续进行的数量,漏嘴流速代表系统允许该行为的最大频率
  • 漏斗容量有限,如果漏水速度小于灌水速度(请求接口速度大于系统允许的访问最大频繁),漏斗就会慢慢变满

代码:

package redis.FunekRateLimiter;

import java.util.HashMap;
import java.util.Map;

public class FunnelLimimter {
    static class Funnel {
        final long capacity;  // 容量
        final double leaking;   // 流速
        long leakingTs; // 更新时间
        long leftCapacity; // 剩余空间

        public Funnel(long capacity, double leaking) {
            this.capacity = capacity;
            this.leaking = leaking;
            this.leakingTs = System.currentTimeMillis();
            this.leftCapacity = this.capacity;
        }

        public void makeSpace() {
            // 获取当前时间
            long nowTs = System.currentTimeMillis();
            // 计算流速
            double leakVal = (nowTs - this.leakingTs) * this.leaking / 1000;
            System.out.println("leakVal:" + leakVal);
            System.out.println("nowTs:" + nowTs);
            System.out.println("leakingTs:" + this.leakingTs);
            if (leakVal < 0) {
                this.leftCapacity = this.capacity;
                this.leakingTs = System.currentTimeMillis();
                return;
            }
            if (leakVal < 1.0) {
                return;
            }
            this.leftCapacity += leakVal;
            if (this.leftCapacity > this.capacity) {
                this.leftCapacity = this.capacity;
            }
            System.out.println("leftCapacity:" + leftCapacity);
            this.leakingTs = nowTs;
        }

        public boolean isAllowed(int quota) {
            makeSpace();
            if (this.leftCapacity >= quota) {
                this.leftCapacity -= quota;
                return true;
            }
            return false;
        }
    }

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

    public boolean isAllow(String method, String user, long capacity, long userQbs) {
        String key = method + "|" + user;
        Funnel funnel = this.funnes.get(key);
        if (funnel == null) {
            funnel = new Funnel(capacity, userQbs);
            this.funnes.put(key, funnel);
        }
        return funnel.isAllowed(1);
    }

    public static void main(String[] args) {
        FunnelLimimter limimter = new FunnelLimimter();
        for (int i = 0; i < 10; i++) {
            System.out.println("===================");
            System.out.println(limimter.isAllow("test", "zzh", 5, 1));
        }
    }
}

问题:并发操作时,会出现不一致问题(单机可加锁;分布式需要加分布式锁,可能存在加锁失败的情况,会有问题)

2. redis-cell

redis-cell: redis4.0提供了限流模块 redis-cell,该模块使用漏斗算法,提供原子限流指令

操作:cl.throttle,字段定义如下图

> cl.throttle laoqian:reply 15 30 60
1) (integer) 0 # 0 表示允许,1 表示拒绝
2) (integer) 15 # 漏斗容量 capacity
3) (integer) 14 # 漏斗剩余空间 left_quota
4) (integer) -1 # 如果拒绝了,需要多长时间后再试(漏斗有空间了,单位秒)
5) (integer) 2 # 多长时间后,漏斗完全空出来(left_quota==capacity,单位秒)

三、GeoHash 

1. 用数据库来算附近的人

场景:计算两个元素距离是不是很远时,可以使用勾股定理来计算。如果计算“附近的人”,需要对附近元素按距离进行排序,实现方案如下:

  • 通过矩形区域限制元素数量,然后对区域内元素进行排序计算,可以明显减少计算量。指定矩形区域的方法: select id from positions where x0-r<x and x<x0+r and y0-r<y and y<y0+r (r为指定的矩形区域),为了满足高性能矩形算法,数据表需要加上双向复合索引(x,y),这样可以最大化查询性能。

2. GeoHash算法

思路:

  1. 将二维经纬度映射到一维整数,需要计算附近的人时,可以取一维线上最近的点
  2. 映射算法思路:将二维平面划分为一系列正方形方格,所有的地图元素都将放置于唯一的方格中,方格越小,坐标越精确。对这些方格进行编码。编码方式可以使用对称分配的方法(00 01 10 11编码四个方形,以此类推,对每个方形继续切割编码)
  3. redis中,经纬度用52位整型编码,放进了zset中,zset的value是元素的key,score是GeoHash的52位整数。
  4. 使用Geo查询时,通过zset的score排序即可得到坐标附近的其他元素,将score还原成原始坐标即可

3. redis Geo指令

增加:geoadd 键 经度 维度 元素  (例子:geoadd company 116.4819 39.99679 tx)

删除:zrem即可删除

距离:geodist 键 元素1 元素2 单位 (求元素1与元素2距离) (例子:geodist company tx bd km)

获取元素位置(编码有损,略有差异):geopos 键 元素 (例子:geopos company tx)

获取元素hash值(base32编码,可以在geohash.org查询位置):geohash 键 元素 (例子:geohash company tx)

附近元素:key member radius m|km|ft|mi [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count] [ASC|DES (例子:georadiusbymember company tx 20 km count 3 asc)

附近元素(根据坐标点取附近元素):georadius company 116.514202 39.905409 30 km

4. 注意事项

  • 每个元素都会放入zset中,集合在集群中,可能会从一个节点迁移到另一个节点,若单个key多大, 会影响迁移,因此在集群环境中,key数据不宜超过1MB
  • 如果数据量过亿,可以对geo数据进行拆分(按国家拆分/按城市拆分等等)

四、scan命令

1. scan简介

场景:如何在海量的key中查找满足特定前缀的key列表

方法:

  • keys:可根据提供的正则字符串查询数据,但当key数量很多,由于没有offset和limit参数,会一次性吐出所有key,影响性能;keys算法为遍历算法,时间复杂度为O(n),会影响redis其他指令执行
  • scan:也是遍历算法,时间复杂度为O(n),但可以通过游标分布进行,不会阻塞其他指令执行

scan特点:

  • 通过游标分布查找,不会阻塞线程
  • 提供limit参数,可以控制最大返回条数(limit只是一个hint,返回结果可多可少)
  • 提供模式匹配功能
  • 服务器不需要为游标保存状态,游标的唯一状态就是scan返回给客户端的整数(游标由客户端维护)
  • 返回结果可能有重复
  • 遍历过程中若出现数据修改,修改后的数据能不能遍历到是不确定的
  • 单次返回结果为空不代表遍历结束,需要看游标是否为0、

2. scan基本用法

scan提供三个参数:

  • curser:整型,游标
  • key:字符串,可以是正则表达式
  • limit hint:单次返回最大值

添加测试数据:

package redis.scan;

import redis.clients.jedis.HostAndPort;
import redis.clients.jedis.Jedis;

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

操作:

127.0.0.1:6379> scan 0 match key99* count 1000
1) "8728"
2)  1) "key9921"
    2) "key9924"
    3) "key9979"
    4) "key9954"
    5) "key9969"
    6) "key992"
    7) "key997"
    8) "key9987"
    9) "key9986"
   10) "key9967"
   11) "key991"
   12) "key9982"
   13) "key9998"
   14) "key9999"
   15) "key9963"
   16) "key9917"
   17) "key995"
   18) "key9926"
   19) "key998"
127.0.0.1:6379> scan 8728 match key99* count 1000
1) "12940"
2) 1) "key9985"
   2) "key9959"
   3) "key9916"
127.0.0.1:6379> scan 12940 match key99* count 1000
1) "4178"
2)  1) "key9953"
    2) "key9927"
    3) "key9945"
    4) "key994"
    5) "key9956"
    6) "key9960"
    7) "key9905"
    8) "key9978"
    9) "key9943"
   10) "key9984"
   11) "key9919"
   12) "key9902"

有上述过程看出,limit虽然是1000,但返回的结果不一定一致,有时结果集大小为0,但游标值不为0,遍历未结束

更多scan指令:scan指令可以指定容器集合进行遍历,例如zscan可以遍历zset集合,hscan可以遍历hash字典集合,sscan可以遍历set集合。这些指令的原理和scan类似,因为hash底层就是字典,set则是一种特殊的hash;zset内部也使用了字典来存储所有元素的内容。

3. scan相关原理

a. 字典结构

在redis中,所有的key都存储在一个大字典中,结构和java的hashmap很像;该结构为一个邻接链表,一维数组的大小总是2的n次方,扩容一次,大小空间加倍。

scan指令返回的游标就是一维数组的位置索引,我们将这个位置索引称为槽,若不考虑字典的扩容缩容,直接挨个数组下标遍历即可。limit参数代表需要遍历的槽数量,之所以返回的数据有多有少,是因为并不是所有槽都会挂链表,而有些槽挂的链表元素可能不止一个。

scan每一次遍历,会把limit数量的槽位上挂接的元素进行模式匹配过滤,并返回

b. scan遍历顺序

 scan遍历时,采用了高位进位加法来遍历,目的是为了避免字典扩容/缩容时槽位遗漏或重复

普通加法: 0000 --+1--> 0001 --+1--> 0010
高位加法: 0000 --+1--> 1000 --+1--> 0100

c. 字典扩容

扩容方法:java中hashmap有扩容的概念,当loadfactor达到阈值,需要重新分配一个新的2倍大小的数组,然后将所有元素全部rehash挂到新的数组下,rehash的方法是将元素的hash值对数组长度取模(a & 2^k-1)

扩容后元素槽位的变化:假设当前字典数组长度为n扩为2n,那么第3号槽的元素会从0...11(n位) rehash 到00...11或 10..11(n+1位)

扩容前后scan遍历顺序变化:

由于采用高位进位加法,在rehash后,槽位的遍历顺序是相邻的。假设要遍历110这个位置,扩容后,当前槽位所有元素对应的新槽位为0110和1110。这时只需要从0110往后遍历即可

缩容前后scan遍历顺序变化:

假设当前遍历110,缩容后变成了10,这时,只需要从10处开始遍历即可(缩容后,会对010槽的元素重复遍历)

渐进式rehash:

  • 由于挂载在数组的元素较多,redis进行rehash时,采用了渐进式rehash。它会同时保留旧数组和新数组,然后在定时任务中以及后续对hash的指令操作中渐渐将旧数组的元数迁移到新数组。因此,在进行rehash时,需要同时访问新旧两个数组,若旧数组找不到元素,还需要去新数组查找。
  • scan指令在搜索时也需要考虑这个问题,对于rehash中的字典,需要同时扫描新旧槽位,然后将结果融合后返回客户端

d. 大key扫描

大key(很大的hash或者很大的zset)会对redis集群数据迁移带来很大的问题

  • 若key太大,会导致在集群间数据迁移卡顿;
  • key太大,当它需要扩容时,会一次性申请更大的内存,此时也会导致卡顿;
  • 如果大key被删除,内存会一次性回收,也会导致卡顿。

为了避免大key导致线上redis卡顿,需要用到scan指令,对于每个扫描出来的key,用type获取key类型,然后根据类型对应的size或len方法获取大小,对于每一种类似,将大小排名前n个结果展示出来。

上述过程可以使用脚本实现,不过redis官网已经在redis-cli指令中提供了扫描指令

redis-cli -h 127.0.0.1 -p 6379 --bigkeys 【加-i s 参数可以休眠 s秒,避免redis的ops剧烈抬升】

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值