Redis可以用来干什么?

1.记录帖子的点赞数、评论数和点击数(hash)

2.记录用户的梯子ID列表(排序),便于快速显示用户的帖子列表(zset)。

3.记录帖子的标题、摘要、作者和封面信息,用于列表页展示。(hash)

4.记录帖子的点赞用户ID列表,评论ID列表,用于显示和去重计数。(zset)

5.缓存近期热帖内容(帖子内容空间占用比较大),减少数据库压力(hash)。

6.记录帖子的相关文章ID,根据内容推荐相关帖子(list)。

7.如果帖子ID是整数自增的,可以使用Redis来分配帖子ID(计数器)。

8.收藏集合帖子之间的关系(zset)。

9.记录热榜帖子ID列表,总热榜和分类热榜(zset)。

10.缓存用户行为历史,进行恶意行为过滤(zset,hash)

延时队列

Redis的list(列表)数据结构常用来作为异步消息队列使用,使用rpush/lpush操作如队列,使用lpop/rpop来出队列

redis zset可以存Object吗 redis zset应用_原理


当队列为空时,客户端线程去取数据就会不停的pop,没有数据,接着再pop,导致空查询,拉高客户端的CPU,redis的qps也会被拉高。这时可以让线程睡一会,等待数据,再去尝试,如果没有数据就再睡一会。但是这样因为睡眠时间的不确定,会导致队列延迟。这时就可以使用blpop/brpop替代前面的lpop/rpop,阻塞对在队列没有数据的时候回立即进入休眠状态,一旦数据到来就立刻醒过来,消息的延迟几乎为0.

当blpop/brpop长时间阻塞时,Redis服务器一般会主动断开连接,减少闲置资源占用。这个时候回抛出异常,所以在编写客户端消费者的时候需要注意捕获异常。

延时队列实现

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

import java.lang.reflect.Type;
import java.util.Set;
import java.util.UUID;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.TypeReference;

import redis.clients.jedis.Jedis;

public class RedisDelayingQueue<T> {

  static class TaskItem<T> {
    public String id;
    public T msg;
  }

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

  private Jedis jedis;
  private String queueKey;

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

  public void delay(T msg) {
    TaskItem<T> task = new TaskItem<T>();
    task.id = UUID.randomUUID().toString(); // 分配唯一的 uuid
    task.msg = msg;
    String s = JSON.toJSONString(task); // fastjson 序列化
    jedis.zadd(queueKey, System.currentTimeMillis() + 5000, s); // 塞入延时队列 ,5s 后再试
  }

  public void loop() {
    while (!Thread.interrupted()) {
      // 只取一条
      Set<String> values = jedis.zrangeByScore(queueKey, 0, System.currentTimeMillis(), 0, 1);
      if (values.isEmpty()) {
        try {
          Thread.sleep(500); // 歇会继续
        } catch (InterruptedException e) {
          break;
        }
        continue;
      }
      String s = values.iterator().next();
      if (jedis.zrem(queueKey, s) > 0) { // 抢到了
        TaskItem<T> task = JSON.parseObject(s, TaskType); // fastjson 反序列化
        this.handleMsg(task.msg);
      }
    }
  }

  public void handleMsg(T msg) {
    System.out.println(msg);
  }

  public static void main(String[] args) {
    Jedis jedis = new Jedis();
    RedisDelayingQueue<String> queue = new RedisDelayingQueue<>(jedis, "q-demo");
    Thread producer = new Thread() {

      public void run() {
        for (int i = 0; i < 10; i++) {
          queue.delay("codehole" + i);
        }
      }

    };
    Thread consumer = new Thread() {

      public void run() {
        queue.loop();
      }

    };
    producer.start();
    consumer.start();
    try {
      producer.join();
      Thread.sleep(6000);
      consumer.interrupt();
      consumer.join();
    } catch (InterruptedException e) {
    }
  }
}

Redis的Zerm方法是多线程多进程争抢任务的关键,他的返回值决定了当前实例有没有抢到任务,因为loop方法可能会被多个线程、多个进程调用,同一个任务可能会被多个进程线程抢到,通过zrem来决定唯一的属主。

Redis作为消息队列为什么不可靠?

Redis不做消息接受保证,一般的MQ提供ack机制,要求消费者在接收到消息进行ACK确认,超时未确认mq会再次投递消息,而redis没有这个机制。

位图

位图可以用来解决某些情况下需要统计大量数据状态,并且数据状态只有是或不是两种。如要统计用户的每天活跃状态,一年365天,每天占用一bit,一年则是46个字节。

位图不是特殊的数据结构,他的内容其实就是普通的字符串,也就是byte数组。我们可以使用普通的get/set直接获取和设置整个位图的内容,也可以使用位图操作getbit/setbit等奖byte数组看成[位数组]来处理。

setbit key location value

getbit key location

value 只能是0或者1

location 从0开始

统计和查找

Redis提供了位图统计指令bitcount和位图查找指令bitpos,bitcount用来统计指定位置范围内1的个数,bitpos用来查找指定范围内出现的第一个0或1.

ps:bitcount的参数是一字节为单位的,也就是说我们只能计算8的整数倍的范围的位图。

bitfield

bitfield可以用来对多个位进行操作,有三个子指令,分别是get/set/incrby,它们都可以对指定位片段进行读写,但是最多只能处理64个连续的位,如果超过64位,就得使用多个子指令,bitfield可以一次执行多个子指令。

127.0.0.1:6379> set w hello
OK
127.0.0.1:6379> bitfield w get u4 0 # 从第一个位开始取 4 个位,结果是无符号数 (u)
(integer) 6
127.0.0.1:6379> bitfield w get u3 2 # 从第三个位开始取 3 个位,结果是无符号数 (u)
(integer) 5
127.0.0.1:6379> bitfield w get i4 0 # 从第一个位开始取 4 个位,结果是有符号数 (i)

  1. (integer) 6
    127.0.0.1:6379> bitfield w get i3 2 # 从第三个位开始取 3 个位,结果是有符号数 (i)
  2. (integer) -3

所谓有符号数是指获取的位数组中第一个为是符号位,剩下的才是值。如果第一位是1,那就是负数。无符号数表示非负数,没有符号位,获取的数组全部都是值。有符号数最多可以获取64位,无符号数只能获取63位。

执行多个子指令:

127.0.0.1:6379> bitfield w get u4 0 get u3 2 get i4 0 get i3 2

  1. (integer) 6
  2. (integer) 5
  3. (integer) 6
  4. (integer) -3

再看第三个子指令 incrby,它用来对指定范围的位进行自增操作。既然提到自增,就有可能出现溢出。如果增加了正数,会出现上溢,如果增加的是负数,就会出现下溢出。Redis 默认的处理是折返。如果出现了溢出,就将溢出的符号位丢掉。如果是 8 位无符号数 255,加 1 后就会溢出,会全部变零。如果是 8 位有符号数 127,加 1 后就会溢出变成 -128。

127.0.0.1:6379> set w hello
OK
127.0.0.1:6379> bitfield w incrby u4 2 1 # 从第三个位开始,对接下来的 4 位无符号数 +1

  1. (integer) 11
    127.0.0.1:6379> bitfield w incrby u4 2 1
  2. (integer) 12
    127.0.0.1:6379> bitfield w incrby u4 2 1
  3. (integer) 13
    127.0.0.1:6379> bitfield w incrby u4 2 1
  4. (integer) 14
    127.0.0.1:6379> bitfield w incrby u4 2 1
  5. (integer) 15
    127.0.0.1:6379> bitfield w incrby u4 2 1 # 溢出折返了
  6. (integer) 0

bitfield 指令提供了溢出策略子指令 overflow,用户可以选择溢出行为,默认是折返 (wrap),还可以选择失败 (fail) 报错不执行,以及饱和截断 (sat),超过了范围就停留在最大最小值。overflow 指令只影响接下来的第一条指令,这条指令执行完后溢出策略会变成默认值折返 (wrap)。

HyperLogLog

HyperLogLog实现原理:

HLL中实际存储的是一个长度为m的大数组S,将待统计的数据集合划分成m组,没组根据算法记录一个统计值存入数组中。数组的大小m由算法实现方自己确定,redis中这个数组的大小是16834,m越大,基数统计的误差越小,需要的内存空间也越大。

redis zset可以存Object吗 redis zset应用_实战_02

  1. 通过hash函数计算输入值对应的比特串
  2. 比特串的低(t=log2m)位对应的数字用来找到数组S中对应的位置 i
  3. t+1位开始找到第一个1出现的位置 k,将 k 记入数组Si位置
  4. 基于数组S记录的所有数据的统计值,计算整体的基数值,计算公式可以简单表示为:n^=f(S)

HyperlogLog原理理解:

举一个我们最熟悉的抛硬币例子,出现正反面的概率都是1/2,一直抛硬币直到出现正面,记录下投掷次数k,将这种抛硬币多次直到出现正面的过程记为一次伯努利过程,对于nn次伯努利过程,我们会得到n个出现正面的投掷次数值k1,k2……kn,其中最大值记为kmax,那么可以得到下面结论:

  1. n次伯努利过程的投掷次数都不大于kmax
  2. n次伯努利过程,至少有一次投掷次数等于kmax

对于第一个结论,nn次伯努利过程的抛掷次数都不大于kmax的概率用数学公式表示为:
P(X <= kmax)=(1−1/2kmax)n

第二个结论至少有一次等于kmax的概率用数学公式表示为:
P(X >= kmax)=1−(1−1/2kmax−1)n

当n≪2kmax时,P(X≥kmax)≈0,即当n远小于2^kmax时,上述第一条结论不成立;
当n≫2kmax时,P(X≤kmax)≈0,即当n远大于2^kmax时,上述第二条结论不成立。

因此,我们似乎就可以用2^kmax的值来估计n的大小。

以上结论可以总结为:进行了n次进行抛硬币实验,每次分别记录下第一次抛到正面的抛掷次数k,那么可以用n次实验中最大的抛掷次数kmax来预估实验组数量n: n^=2kmax

redis zset可以存Object吗 redis zset应用_原理_03

回到基数统计的问题,我们需要统计一组数据中不重复元素的个数,集合中每个元素的经过hash函数后可以表示成0和1构成的二进制数串,一个二进制串可以类比为一次抛硬币实验,1是抛到正面,0是反面。二进制串中从低位开始第一个1出现的位置可以理解为抛硬币试验中第一次出现正面的抛掷次数k,那么基于上面的结论,我们可以通过多次抛硬币实验的最大抛到正面的次数来预估总共进行了多少次实验,同样可以可以通过第一个1出现位置的最大值kmax来预估总共有多少个不同的数字(整体基数)。

这也就是上面为什么要记录hash之后字符串中出现的第一个1的位置

分桶平均

HLL的基本思想是利用集合中数字的比特串第一个1出现位置的最大值来预估整体技术,但是这种预估方法存在较大误差,为了改善误差情况,HLL中引入了分桶平均的概念。

分桶平均的基本原理是将统计数据划分为m个桶,每个桶分别添加各自的Kmax并能得到各自的基数预估值,最终对这些基数预估值平均得到整体的基数估计值。LCC中使用几何平均数预估整体的基数值,但是当统计数量较小时误差较大;HLL在LCC的基础上做了改进,采用调和平均数,调和平均数的优点是可以过滤不健康的统计值,具体计算公式为:

redis zset可以存Object吗 redis zset应用_原理_04

偏差修正

上述经过分桶平均后的估计量看似已经很不错了,不过通过数学分析可以知道这并不是基数n的无偏估计。因此需要修正成无偏估计。这部分的具体数学分析在“Loglog Counting of Large Cardinalities”中。

具体计算过程如下:

redis zset可以存Object吗 redis zset应用_Redis_05

分桶数m的选择

如果理解了之前的分桶算法,那么很显然分桶数只能是2的整数次幂。
如果分桶越多,那么估计的精度就会越高,统计学上用来衡量估计精度的一个指标是“相对标准误差”(relative standard deviation,简称RSD),RSD的计算公式这里就不给出了,百科上一搜就可以知道,从直观上理解,RSD的值其实就是((每次估计的值)在(估计均值)上下的波动)占(估计均值)的比例(这句话加那么多括号是为了方便大家断句)。RSD的值与分桶数m存在如下的计算关系:

redis zset可以存Object吗 redis zset应用_原理_06

有了这个公式,你可以先确定你想要达到的RSD的值,然后再推出分桶的数目m。

Redis中的HyperLogLog

Redis提供了HyperLogLog数据结构。HyperLogLog提供不精确去重计数方案,虽然不精确但也不是非常不精确,标准误差是0.81%。

HyperLogLog提供了两个指令pfadd和pfcount,根据字面意义很好理解,一个是增加计数,一个是获取计数。

127.0.0.1:6379> pfadd codehole user1
(integer) 1
127.0.0.1:6379> pfcount codehole
(integer) 1
127.0.0.1:6379> pfadd codehole user2
(integer) 1
127.0.0.1:6379> pfcount codehole
(integer) 2
127.0.0.1:6379> pfadd codehole user3
(integer) 1
127.0.0.1:6379> pfcount codehole
(integer) 3
127.0.0.1:6379> pfadd codehole user4
(integer) 1
127.0.0.1:6379> pfcount codehole
(integer) 4
127.0.0.1:6379> pfadd codehole user5
(integer) 1
127.0.0.1:6379> pfcount codehole
(integer) 5
127.0.0.1:6379> pfadd codehole user6
(integer) 1
127.0.0.1:6379> pfcount codehole
(integer) 6
127.0.0.1:6379> pfadd codehole user7 user8 user9 user10
(integer) 1
127.0.0.1:6379> pfcount codehole
(integer) 10

HyperLogLog出了上面两个指令以外,还提供了第三个指令pfmerge,用于将多个pf计数值累加在一起形成一个新的pf值。

HyperLogLog的使用并不是没有代价的,他需要占据一定的12K的存储空间,所以它不适合统计单个用户相关的数据。Redis对HyperLogLog的存储进行了优化,在计数比较小的时候,它的存储空间会采用稀疏矩阵存储,空间占用很小,仅仅在计数慢慢变大,稀疏矩阵占用空间贱贱超过了阀值时才会一次性转变成稠密矩阵,才会占用12K空间。

布隆过滤器

布隆过滤器可以理解为一个不怎么精确的 set 结构,当你使用它的 contains 方法判断某个对象是否存在时,它可能会误判。但是布隆过滤器也不是特别不精确,只要参数设置的合理,它的精确度可以控制的相对足够精确,只会有小小的误判概率。

当布隆过滤器说某个值存在时,这个值可能不存在;当它说不存在时,那就肯定不存在。打个比方,当它说不认识你时,肯定就不认识;当它说见过你时,可能根本就没见过面,不过因为你的脸跟它认识的人中某脸比较相似 (某些熟脸的系数组合),所以误判以前见过你。

布隆过滤器原理:

算法描述:

一个empty bloom filter是一个有m bits的bit array,每一个bit位都初始化为0。并且定义有k个不同的hash function,每个都以uniform random distribution将元素hash到m个不同位置中的一个。在下面的介绍中n为元素数,m为布隆过滤器或哈希表的slot数,k为布隆过滤器重hash function数。

为了add一个元素,用k个hash function将它hash得到bloom filter中k个bit位,将这k个bit位置1。

为了query一个元素,即判断它是否在集合中,用k个hash function将它hash得到k个bit位。若这k bits全为1,则此元素在集合中;若其中任一位不为1,则此元素比不在集合中(因为如果在,则在add时已经把对应的k个bits位置为1)。

不允许remove元素,因为那样的话会把相应的k个bits位置为0,而其中很有可能有其他元素对应的位。因此remove会引入false negative,这是绝对不被允许的。

当k很大时,设计k个独立的hash function是不现实并且困难的。对于一个输出范围很大的hash function(例如MD5产生的128 bits数),如果不同bit位的相关性很小,则可把此输出分割为k份。或者可将k个不同的初始值(例如0,1,2, … ,k-1)结合元素,feed给一个hash function从而产生k个不同的数。

当add的元素过多时,即n/m过大时(n是元素数,m是bloom filter的bits数),会导致false positive过高,此时就需要重新组建filter,但这种情况相对少见。

时间和空间优势:

当可以承受一些误报时,布隆过滤器比其它表示集合的数据结构有着很大的空间优势。例如self-balance BST, tries, hash table或者array, chain,它们中大多数至少都要存储元素本身,对于小整数需要少量的bits,对于字符串则需要任意多的bits(tries是个例外,因为对于有相同prefixes的元素可以共享存储空间);而chain结构还需要为存储指针付出额外的代价。对于一个有1%误报率和一个最优k值的布隆过滤器来说,无论元素的类型及大小,每个元素只需要9.6 bits来存储。这个优点一部分继承自array的紧凑性,一部分来源于它的概率性。如果你认为1%的误报率太高,那么对每个元素每增加4.8 bits,我们就可将误报率降低为原来的1/10。add和query的时间复杂度都为O(k),与集合中元素的多少无关,这是其他数据结构都不能完成的。

如果可能元素范围不是很大,并且大多数都在集合中,则使用确定性的bit array远远胜过使用布隆过滤器。因为bit array对于每个可能的元素空间上只需要1 bit,add和query的时间复杂度只有O(1)。注意到这样一个哈希表(bit array)只有在忽略collision并且只存储元素是否在其中的二进制信息时,才会获得空间和时间上的优势,而在此情况下,它就有效地称为了k=1的布隆过滤器。

而当考虑到collision时,对于有m个slot的bit array或者其他哈希表(即k=1的布隆过滤器),如果想要保证1%的误判率,则这个bit array只能存储m/100个元素,因而有大量的空间被浪费,同时也会使得空间复杂度急剧上升,这显然不是space efficient的。解决的方法很简单,使用k>1的布隆过滤器,即k个hash function将每个元素改为对应于k个bits,因为误判度会降低很多,并且如果参数k和m选取得好,一半的m可被置为为1,这充分说明了布隆过滤器的space efficient性。

关于布隆过滤器的空间大小设置和根据错误率设置空间大小,大家可以参照这篇博客:布隆过滤器,或者左神的程序员代码面试指南中关于布隆过滤器的讲解。

Redis中的布隆过滤器:

用途:判断大量数据中某个URL是否存在

Redis 官方提供的布隆过滤器到了 Redis 4.0 提供了插件功能之后才正式登场。布隆过滤器作为一个插件加载到 Redis Server 中,给 Redis 提供了强大的布隆去重功能。

布隆过滤器有二个基本指令,bf.add 添加元素,bf.exists 查询元素是否存在,它的用法和 set 集合的 sadd 和 sismember 差不多。注意 bf.add 只能一次添加一个元素,如果想要一次添加多个,就需要用到 bf.madd 指令。同样如果需要一次查询多个元素是否存在,就需要用到 bf.mexists 指令。

127.0.0.1:6379> bf.add codehole user1
(integer) 1
127.0.0.1:6379> bf.add codehole user2
(integer) 1
127.0.0.1:6379> bf.add codehole user3
(integer) 1
127.0.0.1:6379> bf.exists codehole user1
(integer) 1
127.0.0.1:6379> bf.exists codehole user2
(integer) 1
127.0.0.1:6379> bf.exists codehole user3
(integer) 1
127.0.0.1:6379> bf.exists codehole user4
(integer) 0
127.0.0.1:6379> bf.madd codehole user4 user5 user6

  1. (integer) 1
  2. (integer) 1
  3. (integer) 1
    127.0.0.1:6379> bf.mexists codehole user4 user5 user6 user7
  4. (integer) 1
  5. (integer) 1
  6. (integer) 1
  7. (integer) 0

我们上面使用的布隆过滤器只是默认参数的布隆过滤器,它在我们第一次 add 的时候自动创建。Redis 其实还提供了自定义参数的布隆过滤器,需要我们在 add 之前使用bf.reserve指令显式创建。如果对应的 key 已经存在,bf.reserve会报错。bf.reserve有三个参数,分别是 key, error_rateinitial_size。错误率越低,需要的空间越大。initial_size参数表示预计放入的元素数量,当实际数量超出这个数值时,误判率会上升。

所以需要提前设置一个较大的数值避免超出导致误判率升高。如果不使用 bf.reserve,默认的error_rate是 0.01,默认的initial_size是 100。

限流

目前常见的限流算法有:计数器、令牌桶、漏桶。

计数器算法

采用计数器实现限流有点简单粗暴,一般我们会限制一秒钟的能够通过的请求数,比如限流qps为100,算法的实现思路就是从第一个请求进来开始计时,在接下去的1s内,每来一个请求,就把计数加1,如果累加的数字达到了100,那么后续的请求就会被全部拒绝。等到1s结束后,把计数恢复成0,重新开始计数。

具体的实现可以是这样的:对于每次服务调用,可以通过AtomicLong#incrementAndGet()方法来给计数器加1并返回最新值,通过这个最新值和阈值进行比较。

这种实现方式,相信大家都知道有一个弊端:如果我在单位时间1s内的前10ms,已经通过了100个请求,那后面的990ms,只能眼巴巴的把请求拒绝,我们把这种现象称为“突刺现象。

漏桶算法

为了消除"突刺现象",可以采用漏桶算法实现限流,漏桶算法这个名字就很形象,算法内部有一个容器,类似生活用到的漏斗,当请求进来时,相当于水倒入漏斗,然后从下端小口慢慢匀速的流出。不管上面流量多大,下面流出的速度始终保持不变。

不管服务调用方多么不稳定,通过漏桶算法进行限流,每10毫秒处理一次请求。因为处理的速度是固定的,请求进来的速度是未知的,可能突然进来很多请求,没来得及处理的请求就先放在桶里,既然是个桶,肯定是有容量上限,如果桶满了,那么新进来的请求就丢弃。

redis zset可以存Object吗 redis zset应用_Redis_07

令牌桶算法

从某种意义上讲,令牌桶算法是对漏桶算法的一种改进,桶算法能够限制请求调用的速率,而令牌桶算法能够在限制调用的平均速率的同时还允许一定程度的突发调用。

在令牌桶算法中,存在一个桶,用来存放固定数量的令牌。算法中存在一种机制,以一定的速率往桶中放令牌。每次请求调用需要先获取令牌,只有拿到令牌,才有机会继续执行,否则选择选择等待可用的令牌、或者直接拒绝。

放令牌这个动作是持续不断的进行,如果桶中令牌数达到上限,就丢弃令牌,所以就存在这种情况,桶中一直有大量的可用令牌,这时进来的请求就可以直接拿到令牌执行,比如设置qps为100,那么限流器初始化完成一秒后,桶中就已经有100个令牌了,这时服务还没完全启动好,等启动完成对外提供服务时,该限流器可以抵挡瞬时的100个请求。所以,只有桶中没有令牌时,请求才会进行等待,最后相当于以一定的速率执行。

redis zset可以存Object吗 redis zset应用_数据库_08


实现思路:可以准备一个队列,用来保存令牌,另外通过一个线程池定期生成令牌放到队列中,每来一个请求,就从队列中获取一个令牌,并继续执行。

Redis实现计数限流:

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();
    Pipeline pipe = jedis.pipelined();
    pipe.multi();
    pipe.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;
  }

  public static void main(String[] args) {
    Jedis jedis = new Jedis();
    SimpleRateLimiter limiter = new SimpleRateLimiter(jedis);
    for(int i=0;i<20;i++) {
      System.out.println(limiter.isActionAllowed("laoqian", "reply", 60, 5));
    }
  }

}

每一个行为到来时,都维护一次时间窗口。将时间窗口外的记录全部清理掉,只保留窗口内的记录。zset 集合中只有 score 值非常重要,value 值没有特别的意义,只需要保证它是唯一的就可以了。

因为这几个连续的 Redis 操作都是针对同一个 key 的,使用 pipeline 可以显著提升 Redis 存取效率。但这种方案也有缺点,因为它要记录时间窗口内所有的行为记录,如果这个量很大,比如限定 60s 内操作不得超过 100w 次这样的参数,它是不适合做这样的限流的,因为会消耗大量的存储空间。

Redis实现漏斗限流

Funnel 对象的 make_space 方法是漏斗算法的核心,其在每次灌水前都会被调用以触发漏水,给漏斗腾出空间来。能腾出多少空间取决于过去了多久以及流水的速率。Funnel 对象占据的空间大小不再和行为的频率成正比,它的空间占用是一个常量。

问题来了,分布式的漏斗算法该如何实现?能不能使用 Redis 的基础数据结构来搞定?

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) (deltaTs * leakingRate);
      if (deltaQuota < 0) { // 间隔时间太长,整数数字过大溢出
        this.leftQuota = capacity;
        this.leakingTs = nowTs;
        return;
      }
      if (deltaQuota < 1) { // 腾出空间太小,最小单位是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); // 需要1个quota
  }
}

我们观察 Funnel 对象的几个字段,我们发现可以将 Funnel 对象的内容按字段存储到一个 hash 结构中,灌水的时候将 hash 结构的字段取出来进行逻辑运算后,再将新值回填到 hash 结构中就完成了一次行为频度的检测。

但是有个问题,我们无法保证整个过程的原子性。从 hash 结构中取值,然后在内存里运算,再回填到 hash 结构,这三个过程无法原子化,意味着需要进行适当的加锁控制。而一旦加锁,就意味着会有加锁失败,加锁失败就需要选择重试或者放弃。

如果重试的话,就会导致性能下降。如果放弃的话,就会影响用户体验。同时,代码的复杂度也跟着升高很多。这真是个艰难的选择,我们该如何解决这个问题呢?Redis-Cell 救星来了!

Redis-Cell

Redis 4.0 提供了一个限流 Redis 模块,它叫 redis-cell。该模块也使用了漏斗算法,并提供了原子的限流指令。有了这个模块,限流问题就非常简单了。

cl.throttle laoqian:reply 15 30 60 1
▲ ▲ ▲ ▲ ▲
| | | | └───── need 1 quota (可选参数,默认值也是1)
| | └──┴─────── 30 operations / 60 seconds 这是漏水速率
| └───────────── 15 capacity 这是漏斗容量
└─────────────────── key laoqian

上面这个指令的意思是允许「用户老钱回复行为」的频率为每 60s 最多 30 次(漏水速率),漏斗的初始容量为 15,也就是说一开始可以连续回复 15 个帖子,然后才开始受漏水速率的影响。我们看到这个指令中漏水速率变成了 2 个参数,替代了之前的单个浮点数。用两个参数相除的结果来表达漏水速率相对单个浮点数要更加直观一些。

GeoHash

GeoHash的原理就是讲一个地理位置的经纬度,转换成一个可以排序,可以比较的的Hash字符串。

GeoHash代表的不是一个精确地的标,而是一个区域,当Hash值越长的时候,这个hash代表的区域越小,就越精确,比如 wtw3eegq 这个Hash就是上海南京西路周围的的一块,但是 只有前6位 wtw3ee 的话这个Hash代表的区域面积就比 wtw3eegq要大,但是 wtw3eegq 是包扩在 wtw3ee 这个区域里面的,所以可以用这个特性来查找一个坐标周围的餐馆之类的地方。

GeoHash就是这样,他将地球首先分为四个象限,之后每一象限再平分为四个象限,就是这样无限细分下去,这样,地球就根据坐标分为了若干区域,每个区域都会根据算法来生成一个Hash值,Hash值越相似就代表两个区域的位置越近

redis zset可以存Object吗 redis zset应用_Redis_09

具体计算过程:

  • 计算纬度

比如我们需要计算 坐标 121.443469, 31.22246 的GeoHash值

首先将纬度范围(-90, 90)平分成两个区间(-90,0)、(0, 90),如果目标纬度位于前一个区间,则编码为0,否则编码为1。

由于31.22246属于(0, 90),所以取编码为1。

然后再将(0, 90)分成 (0, 45), (45, 90)两个区间,而31.22246位于(0, 45),所以编码为0。

就和中学时代学过的二分法解方程一样简单,对吧~

以此类推,直到精度符合要求为止,得到编码为1010 1100 0110 0111 1100 ,下面的表只是计算了前8位,可以看出,二分次数越多,取得的值就越精确。

left

mid

right

bit

-90

0

90

1

0

22.5

45

0

22.5

33.75

45

1

22.5

30

37.5

0

30

33.75

37.5

1

30

31.875

33.75

1

30

30.9375

31.875

0

30.9375

31.40625

31.875

0

接下来看经度

  • 计算经度

首先将经度范围(-180, 180)平分成两个区间(-180,0)、(0, 180),如果目标经度位于前一个区间,则编码为0,否则编码为1。

由于121.443469属于(0, 180),所以取编码为1。

然后再将(0, 180)分成 (0, 90), (90, 180)两个区间,而121.443469位于(90, 180),所以编码为1。

以此类推,直到精度符合要求为止,得到经度编码为1101 0110 0101 1100 0001 ,下面的表只是计算了前8位。

left

mid

right

bit

-180

0

180

1

0

90

180

1

90

135

180

0

90

112.5

135

1

112.5

123.75

135

0

112.5

118.125

123.75

1

118.125

120.9375

123.75

1

120.9575

122.35375

123.75

0

  • 经度纬度合并

接下来将经度和纬度的编码合并,奇数位是纬度,偶数位是经度

10101100011001111100 和 11010110010111000001 合并为:
1110011001111000001101101011010101010010

  • Base32编码转换

得到合并后的编码之后,每5位一看,转换为十进制,之后按照Base32的编码表来转换为Base32编码

得到合并后的编码之后,每5位一看,转换为十进制,之后按照Base32的编码表来转换为Base32编码

Decimal

0

1

2

3

4

5

6

7

Base32

0

1

2

3

4

5

6

7

Decimal

8

9

10

11

12

13

14

15

Base32

8

9

b

c

d

e

f

g

Decimal

16

17

18

19

20

21

22

23

Base32

h

j

k

m

n

p

q

r

Decimal

24

25

26

27

28

29

30

31

Base32

s

t

u

v

w

x

y

z

刚才合并的编码为:1110011001111000001101101011010101010010

将他分为11100、11001、11100、00011、01101、01101、01010、10010

十进制为:28, 25, 28, 3, 13, 13, 10, 18

根据编码表转换后的Base32编码值为 wtw3eebk

这个值:wtw3eebk 就是坐标121.443469, 31.22246 的GeoHash值

这样的话只要在坐标入库的时候程序顺便算出坐标的GeoHash值一并入库,就可以实现快速进行周边餐馆查找之类的功能了。

Redis 的 Geo 指令的使用

Redis 提供的 Geo 指令只有 6 个,读者们瞬间就可以掌握。使用时,读者务必再次想起,它只是一个普通的 zset 结构。

增加

geoadd 指令携带集合名称以及多个经纬度名称三元组,注意这里可以加入多个三元组

127.0.0.1:6379> geoadd company 116.48105 39.996794 juejin
(integer) 1
127.0.0.1:6379> geoadd company 116.514203 39.905409 ireader
(integer) 1
127.0.0.1:6379> geoadd company 116.489033 40.007669 meituan
(integer) 1
127.0.0.1:6379> geoadd company 116.562108 39.787602 jd 116.334255 40.027400 xiaomi
(integer) 2

也许你会问为什么 Redis 没有提供 geo 删除指令?前面我们提到 geo 存储结构上使用的是 zset,意味着我们可以使用 zset 相关的指令来操作 geo 数据,所以删除指令可以直接使用 zrem 指令即可。

距离

geodist 指令可以用来计算两个元素之间的距离,携带集合名称、2 个名称和距离单位。

127.0.0.1:6379> geodist company juejin ireader km
“10.5501”
127.0.0.1:6379> geodist company juejin meituan km
“1.3878”
127.0.0.1:6379> geodist company juejin jd km
“24.2739”
127.0.0.1:6379> geodist company juejin xiaomi km
“12.9606”
127.0.0.1:6379> geodist company juejin juejin km
“0.0000”

我们可以看到掘金离美团最近,因为它们都在望京。距离单位可以是 m、km、ml、ft,分别代表米、千米、英里和尺。

获取元素位置

geopos 指令可以获取集合中任意元素的经纬度坐标,可以一次获取多个。

127.0.0.1:6379> geopos company juejin

  1. “116.48104995489120483”
  1. “39.99679348858259686”
    127.0.0.1:6379> geopos company ireader
  1. “116.5142020583152771”
  1. “39.90540918662494363”
    127.0.0.1:6379> geopos company juejin ireader
  1. “116.48104995489120483”
  1. “39.99679348858259686”
  1. “116.5142020583152771”
  1. “39.90540918662494363”

我们观察到获取的经纬度坐标和 geoadd 进去的坐标有轻微的误差,原因是 geohash 对二维坐标进行的一维映射是有损的,通过映射再还原回来的值会出现较小的差别。对于「附近的人」这种功能来说,这点误差根本不是事。

获取元素的 hash 值

geohash 可以获取元素的经纬度编码字符串,上面已经提到,它是 base32 编码。 你可以使用这个编码值去 geohash.org/${hash}中进行直… geohash 的标准编码值。

127.0.0.1:6379> geohash company ireader

  1. “wx4g52e1ce0”
    127.0.0.1:6379> geohash company juejin
  2. “wx4gd94yjn0”

附近的公司

georadiusbymember 指令是最为关键的指令,它可以用来查询指定元素附近的其它元素,它的参数非常复杂。

范围 20 公里以内最多 3 个元素按距离正排,它不会排除自身

127.0.0.1:6379> georadiusbymember company ireader 20 km count 3 asc

  1. “ireader”
  2. “juejin”
  3. “meituan”

范围 20 公里以内最多 3 个元素按距离倒排

127.0.0.1:6379> georadiusbymember company ireader 20 km count 3 desc

  1. “jd”
  2. “meituan”
  3. “juejin”

三个可选参数 withcoord withdist withhash用来携带附加参数

withdist 很有用,它可以用来显示距离

127.0.0.1:6379> georadiusbymember company ireader 20 km withcoord withdist withhash count 3 asc

  1. “ireader”
  2. “0.0000”
  3. (integer) 4069886008361398
  1. “116.5142020583152771”
    2) “39.90540918662494363”
  1. “juejin”
  2. “10.5501”
  3. (integer) 4069887154388167
  1. “116.48104995489120483”
    2) “39.99679348858259686”
  1. “meituan”
  2. “11.5748”
  3. (integer) 4069887179083478
  1. “116.48903220891952515”
    2) “40.00766997707732031”

除了 georadiusbymember 指令根据元素查询附近的元素,Redis 还提供了根据坐标值来查询附近的元素,这个指令更加有用,它可以根据用户的定位来计算「附近的车」,「附近的餐馆」等。它的参数和 georadiusbymember 基本一致,除了将目标元素改成经纬度坐标值。

127.0.0.1:6379> georadius company 116.514202 39.905409 20 km withdist count 3 asc
1) 1) "ireader"
   2) "0.0000"
2) 1) "juejin"
   2) "10.5501"
3) 1) "meituan"
   2) "11.5748"

注意:

在一个地图应用中,车的数据、餐馆的数据、人的数据可能会有百万千万条,如果使用 Redis 的 Geo 数据结构,它们将全部放在一个 zset 集合中。在 Redis 的集群环境中,集合可能会从一个节点迁移到另一个节点,如果单个 key 的数据过大,会对集群的迁移工作造成较大的影响,在集群环境中单个 key 对应的数据量不宜超过 1M,否则会导致集群迁移出现卡顿现象,影响线上服务的正常运行。

所以,这里建议 Geo 的数据使用单独的 Redis 实例部署,不使用集群环境。

如果数据量过亿甚至更大,就需要对 Geo 数据进行拆分,按国家拆分、按省拆分,按市拆分,在人口特大城市甚至可以按区拆分。这样就可以显著降低单个 zset 集合的大小。

Scan

keys指令缺点:

  1. 没有 offset、limit 参数,一次性吐出所有满足条件的 key,万一实例中有几百 w 个 key 满足条件,当你看到满屏的字符串刷的没有尽头时,你就知道难受了。
  2. keys 算法是遍历算法,复杂度是 O(n),如果实例中有千万级以上的 key,这个指令就会导致 Redis 服务卡顿,所有读写 Redis 的其它的指令都会被延后甚至会超时报错,因为 Redis 是单线程程序,顺序执行所有指令,其它指令必须等到当前的 keys 指令执行完了才可以继续。

scan相比keys具备的特点:

  1. 复杂度虽然也是 O(n),但是它是通过游标分步进行的,不会阻塞线程;
  2. 提供 limit 参数,可以控制每次返回结果的最大条数,limit 只是一个 hint,返回的结果可多可少;
  3. 同 keys 一样,它也提供模式匹配功能;
  4. 服务器不需要为游标保存状态,游标的唯一状态就是 scan 返回给客户端的游标整数;
  5. 返回的结果可能会有重复,需要客户端去重复,这点非常重要;
  6. 遍历的过程中如果有数据修改,改动后的数据能不能遍历到是不确定的;
  7. 单次返回的结果是空的并不意味着遍历结束,而要看返回的游标值是否为零;

Scan使用

scan 参数提供了三个参数,第一个是 cursor 整数值,第二个是 key 的正则模式,第三个是遍历的 limit hint。第一次遍历时,cursor 值为 0,然后将返回结果中第一个整数值作为下一次遍历的 cursor。一直遍历到返回的 cursor 值为 0 时结束。

limit 不是限定返回结果的数量,而是限定服务器单次遍历的字典槽位数量(约等于)。

Scan遍历顺序

scan 的遍历顺序非常特别。它不是从第一维数组的第 0 位一直遍历到末尾,而是采用了高位进位加法来遍历。之所以使用这样特殊的方式进行遍历,是考虑到字典的扩容和缩容时避免槽位的遍历重复和遗漏。

高位进位法从左边加,进位往右边移动,同普通加法正好相反。但是最终它们都会遍历所有的槽位并且没有重复。

![image-20190106000929007](/Users/yue/Library/Application Support/typora-user-images/image-20190106000929007.png)