今天聊聊 redis 的数据类型和集群相关的知识,冲~。

多样的数据类型

string 类型简单方便,支持空间预分配,也就是每次会多分配点空间,这样 string 如果下次变长的话,就不需要额外的申请空了,当然前提是剩余的空间够用。

List 类型可以实现简单的消息队列,但是注意可能存在消息丢失哦,它并不持 ACK 模式。

Hash 表有点像关系型数据库,但是当 hash 表越来越大的时候,请注意,避免使用 hgetall 之类的语句,因为请求大量的数据会导致redis阻塞,这样后面的兄弟们就得等待了。

set 集合类型可以帮你做一些统计,比如你要统计某天活跃的用户,可以直接把用户ID扔到集合里,集合支持一些骚操作,比如 sdiff 可以获取集合之间的差集,sunion 可以获取集合之间的并集,功能很多,但是一定需要谨慎,因为牛逼的功能是有代价的,这些操作需要耗费一些 CPU 和IO 资源,可能会导致阻塞,因此大集合之间的骚操作要慎用,

zset 可以说是最闪耀的星,可以做排序,因为可以排序,因此应用场景挺多,比如点赞前xx名用户,延时队列等等。

bitmap 位图的好处就是在于节省空间,特别在做一些统计类的方面,比如要统计某一天有多少个用户签到了并且某个用户是否签到了,如果不用bitmap的话,你可能会想到用set。

SADD day 1234//签到就添加到集合
SISMEMBER day 1234//判断1234是否签到
SCARD day   //有多少个签到的
复制代码

set 在功能上可以满足,但是相比bitmap的话,set要更耗费存储空间,set的底层主要是由整数集合或者 hashtable 组成,整数集合只有在数据量非常小的情况下才会使用,一般是小于512个元素,同时元素必须都是整数,对于set来说,整数集合的数据更加紧凑,他们在内存是上连续的,查询的话只能是二分查找了,时间复杂度是O(logN),而 hashtable 就不同了,这里的 hashtable 和 redis 的5大数据类型中的hash是一样的,只不过没有 value 而已,value 指向个 null,同时也不存在冲突,因为这里是集合,但是需要考虑 rehash 相关问题。ok,扯的有点远,我们说的用户签到问题,在用户非常多的情况下,set 的话肯定会用到 hashtable,hashtable 的话,其实每个元素都是个 dictEntry 结构体

typedef struct dictEntry {
    // 键
    void *key;
    // 值
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
    } v;
    // 指向下个哈希表节点,形成链表
    struct dictEntry *next;

} dictEntry;
复制代码

从这个结构体可以看到什么呢?首先虽然值 union(没有 value)和 next(没有冲突)是空的,但是结构体本身需要空间,还需要加上个 key,这个占用空间是实打实的,而如果用 bitmap 的话,一个bit位就可以代表一个数字,很省空间,我们来看看 bitmap 的方式如何设置和统计。

SETBIT day 1234 1//签到
GETBIT day 1234//判断1234是否签到
BITCOUNT day//有多少个签到的
复制代码

bf 这是 redis4.0 之后支持的布隆过滤器 RedisBloom,但是需要单独加载对应的 module,当然我们也可以基于上述的 bitmap 来实现自己的布隆过滤器,不过既然 redis 已经支持了,通过 RedisBloom 可以减少我们的开发时间,布隆过滤器是干嘛的,我这里就不赘述了,直接来看看 RedisBloom 相关的用法吧。

# 可以通过docker的方式快速拉取镜像来玩耍
docker run -p 6379:6379 --name redis-redisbloom redislabs/rebloom:latest
docker exec -it redis-redisbloom bash
redis-cli
# 相关操作
bf.reserve sign 0.001 10000
bf.add sign 99 //99这个用户加入
bf.add exists 99//判断99这个用户是否存在
复制代码

因为布隆过滤器是存在误判的,所有 bf 支持自定义误判率,0.001就代表误判率,10000 代表布隆过滤器可以存储的元素个数,当实际存储的元素个数超过这个值的时候,误判率会提高。

HyperLogLog 可以用于统计,它的优点就是占用的存储空间极小,只需要 12KB 的内存就可以统计 2^64 个元素,那它主要统计什么呢?其实主要就是基数统计,比如像 UV 这种,从功能上来说 UV 可以用 set 或者 hash 来存储,但是缺点就是耗费存储,容易使之变成大 key,如果想要节省空间,bitmap 也可以,12KB 空间的 bitmap 只能统计 12*1024*8=98304个元素,而 HyperLogLog 却可以统计 2^64 个元素,但是这么牛逼的技术其实是有误差的,HyperLogLog 是基于概率来统计的,标准误算率是 0.81%,在统计海量数据并且对精度要求不那么高的场景下,HyperLogLog 在节省空间这块还是很优秀的。

PFADD uv 1 2 3 //1 2 3是活跃用户
PFCOUNT uv //统计
复制代码

GEO 是可以应用在地理位置的业务上,比如微信附近的人或者附近的车辆等等,先来看一下如果没有GEO 这种数据结构,你如何知道你附近的人?首先得上报自己的地理位置信息吧,比如经度 116.397128,纬度 39.916527,此时可以用 string、hash 数据类型存储,但是如果要查找你附近的人,string 和 hash 这种就无能为例了,你不可能每次都要遍历全部的数据来判断,这样太耗时了,当然你也不可能通过 zset 这种数据结构来把经纬度信息当成权重,但是如果我们能把经纬度信息通过某种方式转换成一个数字,然后当成权重好像也可以,这时我们只需通过zrangebyscore key v1 v2也可以找到附近的人。真的需要这么麻烦吗?于是 GEO 出现了,GEO 转换经纬度为数字的方法是“二分区间,区间编码”,这是什么意思呢?以经度为例,它的范围是[-180,180],如果要采用3位编码值,那么就是需要二分3次,二分后落在左边的用0表示,右边的用1表示,以经度是121.48941 来说,第一次是在[0,180]这个区间,因此记1,第二次是在[90,180],因此再记1,第三次是在[90,135],因此记0。纬度也是同样的逻辑,假设此时对应的纬度编码后是010,最后把经纬度合并在一起,需要注意的是经度的每个值在偶数位,纬度的每个值在奇数位。

1 1 0   //经度
 0 1 0  //纬度
------------
101100 //经纬度对应的数值
复制代码

原理是这样,我们再来看看 redis 如何使用 GEO:

GEOADD location 112.123456 41.112345 99 //上报用户99的地理位置信息
GEORADIUS location  112.123456 41.112345 1 km ASC COUNT 10 //获取附近1KM的人
复制代码

搞懂集群

生产环境用单实例 redis 的应该比较少,单实例的风险在于:

  1. 单点故障即服务故障,没有backup
  2. 单实例压力大,又要提供读,又要提供写

于是我们首先想到的就是经典的主从模式,而且往往是一主多从,这是因为大部分应用都是读多写少的情况,我们的主负责更新,从负责提供读,就算我们的主宕机了,我们也可以选择一个从来充当主,这样整个应用依然可以提供服务。

复制过程的细节

当一个 redis 实例首次成为某个主的从的时候,这时主得把数据发给它,也就是 rdb 文件,这个过程 master 是要 fork 一个子进程来处理的,这个子进程会执行 bgsave 把当前的数据重新保存一下,然后准备发给新来的从,bgsave 的本质是读取当前内存中的数据然后保存到 rdb 文件中,这个过程涉及大量的 IO,如果直接在主进程中来处理的话,大概率会阻塞正常的请求,因此使用个子进程是个明智的选择。

那 fork 的子进程在 bgsave 过程中如果有新的变更请求会怎么办?

严格来说子进程出来的一瞬间,要保存的数据应该就是当时那个点的快照数据,所以是直接把当时的内存再复制一份吗?不复制的话,如果这期间又有变更改怎么办?其实这要说到写实复制(COW)机制,首先从表象上来看内存是一整块空间,其实这不太好维护,因此操作系统会把内存分成一小块一小块的,也就是内存分页管理,一页的大小一般是4K、8K或者16K等等,redis 的数据都是分布在这些页面上的,出于效率问题,fork 出来的子进程是和主进程是共享同一块的内存的,并不会复制内存,如果这期间主进程有数据变更,那么为了区分,这时最快捷的做法就是把对应的数据页重新复制一下,然后主的变更就在这个新的数据页上修改,并不会修改来的数据页,这样就保证了子进程处理的还是当时的快照。

以上说的变更是从快照的角度来考虑的,如果从数据的一致性来说,当快照的 rdb 被从库应用之后,这期间的变更该如何同步给从库?答案是缓冲区,这个缓冲区叫做 replication buffer,主库在收到需要同步的命令之后,会把期间的变更都先保存在这个缓冲区中,这样在把 rdb 发给从库之后,紧接着会再把 replication buffer 的数据也发给从库,最终主从就保持了一致。

replication buffer不是万能的补给剂

我们来看看 replication buffer 持续写入的时间有多长。

  1. 我们知道主从同步的时候,主库会执行 fork 来让子进程完成相应地工作,因此子进程从开始执行 bgsave 到执行完毕这期间,变更是要写入 replication buffer 的。
  2. rdb 生成好之后,需要把它发送给从库,这个网络传输是不是也需要耗点时间,这期间也是要写入 replication buffer 的。
  3. 从库在收到 rdb 之后需要把 rdb 应用到内存里,这期间从库是阻塞的,无法提供服务,因此这期间也是要写入 replication buffer 的。

replication buffer 既然是个 buffer,那么它的大小就是有限的,如果说上面3个步骤中,只要有一个耗时长,就会导致 replication buffer 快速增长(前提是有正常的写入),当 replication buffer 超过了限制之后就会导致主库和从库之间的连接断开,断开之后如果从库再次连接上来就会导致重新开始复制,然后重复同样的漫长的复制步骤,因此这个 replication buffer 的大小还是很关键的,一般需要根据写入的速度、每秒写入的量和网络传输的速度等因素来综合判断。

从库网络不好和主库断了该怎么办?

正常来说,只要主从之间的连接建立好了,后面主库的变更可以直接发给从库,让从库直接回放,但是我们并不能保证网络环境是百分百的通畅的,因此也要考虑从库和主库之间的断联问题。

应该是在 redis2.8 以前,只要从库断联,哪怕只有很短的时间,后面从库再次连接上来的时候,主库也会直接无脑的进行全量同步。在 2.8 版本及以后,开始支持增量复制了,增量复制的原理就是得有个缓冲区来保存变更的记录,这里这个缓冲区叫做repl_backlog_buffer,这个缓冲区从逻辑上来说是个环形缓冲区,写满了就会从头开始覆盖,所以也有大小限制。在从库重新连接上来的时候,从库会告诉主库:“我当前已经复制到了xx位置”,主库收到从库的消息之后开始查看xx位置的数据是否还在 repl_backlog_buffer 中,如果在的话,直接把xx后面的数据发给从库即可,如果不在的话,那无能为力了,只能再次进行全量同步。

需要一个管理者

在主从模式下,如果主库挂了,我们可以把一个从库升级成主库,但是这个过程是手动的,靠人力来操作,不能使损失降到最低,还是需要一套自动管理和选举的机制,这就是哨兵,哨兵它本身也是个服务,只不过它不处理数据的读写而已,它只负责管理所有的 redis 实例,哨兵每隔一段时间会和各个 redis 通信(ping 操作),每个 redis 实例只要在规定的时间内及时回复,就可以表明自己的立场。当然哨兵本身也可能存在宕机或者网络不通的情况,因此一般哨兵也会搭建个哨兵集群,这个集群的个数最好是奇数,比如3个或者5这个这种,奇数的目的主要就是为了选举(少数服从多数)。

当某个哨兵在发起 ping 后没有及时收到 pong,那么就会把这个 redis 实例标记下线,此时它还是不是真正的下线,这时其他的哨兵也会判定当前这个哨兵是不是真正的下线,当大多数哨兵都认定这个 redis 是下线状态,那么就会把它从集群中踢出去,如果下线的是从库,那么还好,直接踢出去就ok,如果是主库还要触发选举,选举也不是盲目选举,肯定是要选出最合适的那个从来充当新的主库。这个最合适充当主库的库,一般会按照以下优先级来确定:

  1. 权重,每个从库其实都可以设置一个权重,权重越高的从库会被优先选择
  2. 复制的进度,每个从库复制的进度可能是不一样的,优先选择当前和主库数据差距最小的那个
  3. 服务的 ID,其实每个 redis 实例都有自己的 ID,如果以上条件都一样,那么会选择 ID 最小的那个库来充当主库

更强的横向伸缩性

主从模式解决了单点故障问题,同时读写分离技术使得应用支撑能力更强,哨兵模式可以自动监管集群,实现自动选主,自动剔除故障节点的能力。

正常来说只要读的压力越来越大,我们可以添加从库来缓解,那如果主库压力很大怎么办?这就得提到接下来要说的分片技术了,我们只需要把主库切成几片,部署到不同的机器上即可。这个分片就是 redis 中的概念了,当分片的时候,redis 会默认分成 0~16383 也就是一共 16384 个槽,然后把这些槽平均分到每个分片节点上就可以起到负载均衡的作用了。每个 key 具体该分到哪个槽中,主要是先 CRC16 得到一个 16bit 的数字,然后这个数字再对 16384 取模即可:

crc16(key)%16384
复制代码

然后客户端会缓存槽信息,这样每当一个 key 到来时,只要通过计算就知道该发给哪个实例来处理来了。但是客户端缓存的槽信息并不是一成不变的,比如在增加实例的时候,这时候会导致重新分片,那么原来客户端缓存的信息就会不准确,一般这时候会发生两个常见的错误,严格来说也不是错误,更像一种信息,一个叫做MOVED,一个叫做ASK。moved的意思就说,原来是实例A负责的数据,现在被迁移到了实例B,MOVED 代表的是迁移完成的,但是 ASK 代表的是正在迁移过程中,比如原来是实例A负责的部分数据,现在被迁移到了实例B,剩下的还在等待迁移中,当数据迁移完毕之后 ASK 就会变成 MOVED,然后客户端收到 MOVED 信息之后就会再次更新下本地缓存,这样下次就不会出现这两个错误了。


原文链接:https://juejin.cn/post/7049142173798760485