From 0de8ebb80c248730e5a9e80c97ea2497310f7cda Mon Sep 17 00:00:00 2001 From: "shuang.kou" Date: Sun, 22 Mar 2020 10:03:04 +0800 Subject: [PATCH] =?UTF-8?q?[feat]=20=E6=B7=BB=E5=8A=A0redis=E7=B3=BB?= =?UTF-8?q?=E5=88=97=E6=96=87=E7=AB=A0=E5=90=88=E9=9B=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 15 +- .../Redis(1)——5种基本数据结构.md | 498 ++++++++++++++ .../Redis(2)——跳跃表.md | 392 +++++++++++ .../Redis(3)——分布式锁深入探究.md | 228 ++++++ ...—亿级数据过滤和布隆过滤器.md | 361 ++++++++++ ...Redis(6)——GeoHash查找附近的人.md | 227 ++++++ .../Redis(7)——持久化.md | 215 ++++++ .../Redis(8)——发布订阅与Stream.md | 531 ++++++++++++++ .../Redis(9)——集群入门实践教程.md | 648 ++++++++++++++++++ ...神奇的HyperLoglog解决统计问题.md | 469 +++++++++++++ 10 files changed, 3580 insertions(+), 4 deletions(-) create mode 100644 docs/database/Redis/redis-collection/Redis(1)——5种基本数据结构.md create mode 100644 docs/database/Redis/redis-collection/Redis(2)——跳跃表.md create mode 100644 docs/database/Redis/redis-collection/Redis(3)——分布式锁深入探究.md create mode 100644 docs/database/Redis/redis-collection/Redis(5)——亿级数据过滤和布隆过滤器.md create mode 100644 docs/database/Redis/redis-collection/Redis(6)——GeoHash查找附近的人.md create mode 100644 docs/database/Redis/redis-collection/Redis(7)——持久化.md create mode 100644 docs/database/Redis/redis-collection/Redis(8)——发布订阅与Stream.md create mode 100644 docs/database/Redis/redis-collection/Redis(9)——集群入门实践教程.md create mode 100644 docs/database/Redis/redis-collection/Reids(4)——神奇的HyperLoglog解决统计问题.md diff --git a/README.md b/README.md index bd5387a4..288c5345 100644 --- a/README.md +++ b/README.md @@ -191,10 +191,17 @@ Github用户如果访问速度缓慢的话,可以转移到[码云](https://git ### Redis -* [Redis 总结](docs/database/Redis/Redis.md) -* [Redlock分布式锁](docs/database/Redis/Redlock分布式锁.md) -* [如何做可靠的分布式锁,Redlock真的可行么](docs/database/Redis/如何做可靠的分布式锁,Redlock真的可行么.md) -* [几种常见的 Redis 集群以及使用场景](docs/database/Redis/redis集群以及应用场景.md) +* [Redis 常见问题总结](docs/database/Redis/Redis.md) +* **Redis 系列文章合集:** + 1. [5种基本数据结构](docs/database/Redis/redis-collection/Redis(1)——5种基本数据结构.md) + 2. [跳跃表](docs/database/Redis/redis-collection/Redis(2)——跳跃表.md) + 3. [分布式锁深入探究](docs/database/Redis/redis-collection/Redis(3)——分布式锁深入探究.md) 、 [Redlock分布式锁](docs/database/Redis/Redlock分布式锁.md) 、[如何做可靠的分布式锁,Redlock真的可行么](docs/database/Redis/如何做可靠的分布式锁,Redlock真的可行么.md) + 4. [神奇的HyperLoglog解决统计问题](docs/database/Redis/redis-collection/Reids(4)——神奇的HyperLoglog解决统计问题.md) + 5. [亿级数据过滤和布隆过滤器](docs/database/Redis/redis-collection/Redis(5)——亿级数据过滤和布隆过滤器.md) + 6. [GeoHash查找附近的人](docs/database/Redis/redis-collection/Redis(6)——GeoHash查找附近的人.md) + 7. [持久化](docs/database/Redis/redis-collection/Redis(7)——持久化.md) + 8. [发布订阅与Stream](docs/database/Redis/redis-collection/Redis(8)——发布订阅与Stream.md) + 9. [史上最强【集群】入门实践教程](docs/database/Redis/redis-collection/Redis(9)——集群入门实践教程.md) ## 系统设计 diff --git a/docs/database/Redis/redis-collection/Redis(1)——5种基本数据结构.md b/docs/database/Redis/redis-collection/Redis(1)——5种基本数据结构.md new file mode 100644 index 00000000..5e78a15a --- /dev/null +++ b/docs/database/Redis/redis-collection/Redis(1)——5种基本数据结构.md @@ -0,0 +1,498 @@ +> 授权转载自: https://github.com/wmyskxz/MoreThanJava#part3-redis + +![](https://upload-images.jianshu.io/upload_images/7896890-2899175817bb0069.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +# 一、Redis 简介 + +> **"Redis is an open source (BSD licensed), in-memory data structure store, used as a database, cache and message broker."** —— Redis是一个开放源代码(BSD许可)的内存中数据结构存储,用作数据库,缓存和消息代理。 *(摘自官网)* + +**Redis** 是一个开源,高级的键值存储和一个适用的解决方案,用于构建高性能,可扩展的 Web 应用程序。**Redis** 也被作者戏称为 *数据结构服务器* ,这意味着使用者可以通过一些命令,基于带有 TCP 套接字的简单 *服务器-客户端* 协议来访问一组 **可变数据结构** 。*(在 Redis 中都采用键值对的方式,只不过对应的数据结构不一样罢了)* + +## Redis 的优点 + +以下是 Redis 的一些优点: + +- **异常快** - Redis 非常快,每秒可执行大约 110000 次的设置(SET)操作,每秒大约可执行 81000 次的读取/获取(GET)操作。 +- **支持丰富的数据类型** - Redis 支持开发人员常用的大多数数据类型,例如列表,集合,排序集和散列等等。这使得 Redis 很容易被用来解决各种问题,因为我们知道哪些问题可以更好使用地哪些数据类型来处理解决。 +- **操作具有原子性** - 所有 Redis 操作都是原子操作,这确保如果两个客户端并发访问,Redis 服务器能接收更新的值。 +- **多实用工具** - Redis 是一个多实用工具,可用于多种用例,如:缓存,消息队列(Redis 本地支持发布/订阅),应用程序中的任何短期数据,例如,web应用程序中的会话,网页命中计数等。 + +## Redis 的安装 + +这一步比较简单,你可以在网上搜到许多满意的教程,这里就不再赘述。 + +给一个菜鸟教程的安装教程用作参考:[https://www.runoob.com/redis/redis-install.html](https://www.runoob.com/redis/redis-install.html) + +## 测试本地 Redis 性能 + +当你安装完成之后,你可以先执行 `redis-server` 让 Redis 启动起来,然后运行命令 `redis-benchmark -n 100000 -q` 来检测本地同时执行 10 万个请求时的性能: + +![](https://upload-images.jianshu.io/upload_images/7896890-7aea279e8fcdafe5.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +当然不同电脑之间由于各方面的原因会存在性能差距,这个测试您可以权当是一种 **「乐趣」** 就好。 + +# 二、Redis 五种基本数据结构 + +**Redis** 有 5 种基础数据结构,它们分别是:**string(字符串)**、**list(列表)**、**hash(字典)**、**set(集合)** 和 **zset(有序集合)**。这 5 种是 Redis 相关知识中最基础、最重要的部分,下面我们结合源码以及一些实践来给大家分别讲解一下。 + +## 1)字符串 string + +Redis 中的字符串是一种 **动态字符串**,这意味着使用者可以修改,它的底层实现有点类似于 Java 中的 **ArrayList**,有一个字符数组,从源码的 **sds.h/sdshdr 文件** 中可以看到 Redis 底层对于字符串的定义 **SDS**,即 *Simple Dynamic String* 结构: + +```c +/* Note: sdshdr5 is never used, we just access the flags byte directly. + * However is here to document the layout of type 5 SDS strings. */ +struct __attribute__ ((__packed__)) sdshdr5 { + unsigned char flags; /* 3 lsb of type, and 5 msb of string length */ + char buf[]; +}; +struct __attribute__ ((__packed__)) sdshdr8 { + uint8_t len; /* used */ + uint8_t alloc; /* excluding the header and null terminator */ + unsigned char flags; /* 3 lsb of type, 5 unused bits */ + char buf[]; +}; +struct __attribute__ ((__packed__)) sdshdr16 { + uint16_t len; /* used */ + uint16_t alloc; /* excluding the header and null terminator */ + unsigned char flags; /* 3 lsb of type, 5 unused bits */ + char buf[]; +}; +struct __attribute__ ((__packed__)) sdshdr32 { + uint32_t len; /* used */ + uint32_t alloc; /* excluding the header and null terminator */ + unsigned char flags; /* 3 lsb of type, 5 unused bits */ + char buf[]; +}; +struct __attribute__ ((__packed__)) sdshdr64 { + uint64_t len; /* used */ + uint64_t alloc; /* excluding the header and null terminator */ + unsigned char flags; /* 3 lsb of type, 5 unused bits */ + char buf[]; +}; +``` + +你会发现同样一组结构 Redis 使用泛型定义了好多次,**为什么不直接使用 int 类型呢?** + +因为当字符串比较短的时候,len 和 alloc 可以使用 byte 和 short 来表示,**Redis 为了对内存做极致的优化,不同长度的字符串使用不同的结构体来表示。** + +### SDS 与 C 字符串的区别 + +为什么不考虑直接使用 C 语言的字符串呢?因为 C 语言这种简单的字符串表示方式 **不符合 Redis 对字符串在安全性、效率以及功能方面的要求**。我们知道,C 语言使用了一个长度为 N+1 的字符数组来表示长度为 N 的字符串,并且字符数组最后一个元素总是 `'\0'`。*(下图就展示了 C 语言中值为 "Redis" 的一个字符数组)* + +![](https://upload-images.jianshu.io/upload_images/7896890-3a7d503c81b6e6e4.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +这样简单的数据结构可能会造成以下一些问题: + +- **获取字符串长度为 O(N) 级别的操作** → 因为 C 不保存数组的长度,每次都需要遍历一遍整个数组; +- 不能很好的杜绝 **缓冲区溢出/内存泄漏** 的问题 → 跟上述问题原因一样,如果执行拼接 or 缩短字符串的操作,如果操作不当就很容易造成上述问题; +- C 字符串 **只能保存文本数据** → 因为 C 语言中的字符串必须符合某种编码(比如 ASCII),例如中间出现的 `'\0'` 可能会被判定为提前结束的字符串而识别不了; + +我们以追加字符串的操作举例,Redis 源码如下: + +```c +/* Append the specified binary-safe string pointed by 't' of 'len' bytes to the + * end of the specified sds string 's'. + * + * After the call, the passed sds string is no longer valid and all the + * references must be substituted with the new pointer returned by the call. */ +sds sdscatlen(sds s, const void *t, size_t len) { + // 获取原字符串的长度 + size_t curlen = sdslen(s); + + // 按需调整空间,如果容量不够容纳追加的内容,就会重新分配字节数组并复制原字符串的内容到新数组中 + s = sdsMakeRoomFor(s,len); + if (s == NULL) return NULL; // 内存不足 + memcpy(s+curlen, t, len); // 追加目标字符串到字节数组中 + sdssetlen(s, curlen+len); // 设置追加后的长度 + s[curlen+len] = '\0'; // 让字符串以 \0 结尾,便于调试打印 + return s; +} +``` + +- **注:Redis 规定了字符串的长度不得超过 512 MB。** + +### 对字符串的基本操作 + +安装好 Redis,我们可以使用 `redis-cli` 来对 Redis 进行命令行的操作,当然 Redis 官方也提供了在线的调试器,你也可以在里面敲入命令进行操作:[http://try.redis.io/#run](http://try.redis.io/#run) + +#### 设置和获取键值对 + +```console +> SET key value +OK +> GET key +"value" +``` + +正如你看到的,我们通常使用 `SET` 和 `GET` 来设置和获取字符串值。 + +值可以是任何种类的字符串(包括二进制数据),例如你可以在一个键下保存一张 `.jpeg` 图片,只需要注意不要超过 512 MB 的最大限度就好了。 + +当 key 存在时,`SET` 命令会覆盖掉你上一次设置的值: + +```console +> SET key newValue +OK +> GET key +"newValue" +``` + +另外你还可以使用 `EXISTS` 和 `DEL` 关键字来查询是否存在和删除键值对: + +```console +> EXISTS key +(integer) 1 +> DEL key +(integer) 1 +> GET key +(nil) +``` + +#### 批量设置键值对 + +```console +> SET key1 value1 +OK +> SET key2 value2 +OK +> MGET key1 key2 key3 # 返回一个列表 +1) "value1" +2) "value2" +3) (nil) +> MSET key1 value1 key2 value2 +> MGET key1 key2 +1) "value1" +2) "value2" +``` + +#### 过期和 SET 命令扩展 + +可以对 key 设置过期时间,到时间会被自动删除,这个功能常用来控制缓存的失效时间。*(过期可以是任意数据结构)* + +```console +> SET key value1 +> GET key +"value1" +> EXPIRE name 5 # 5s 后过期 +... # 等待 5s +> GET key +(nil) +``` + +等价于 `SET` + `EXPIRE` 的 `SETNX` 命令: + +```console +> SETNX key value1 +... # 等待 5s 后获取 +> GET key +(nil) + +> SETNX key value1 # 如果 key 不存在则 SET 成功 +(integer) 1 +> SETNX key value1 # 如果 key 存在则 SET 失败 +(integer) 0 +> GET key +"value" # 没有改变 +``` + +#### 计数 + +如果 value 是一个整数,还可以对它使用 `INCR` 命令进行 **原子性** 的自增操作,这意味着及时多个客户端对同一个 key 进行操作,也决不会导致竞争的情况: + +```console +> SET counter 100 +> INCR count +(interger) 101 +> INCRBY counter 50 +(integer) 151 +``` + +#### 返回原值的 GETSET 命令 + +对字符串,还有一个 `GETSET` 比较让人觉得有意思,它的功能跟它名字一样:为 key 设置一个值并返回原值: + +```console +> SET key value +> GETSET key value1 +"value" +``` + +这可以对于某一些需要隔一段时间就统计的 key 很方便的设置和查看,例如:系统每当由用户进入的时候你就是用 `INCR` 命令操作一个 key,当需要统计时候你就把这个 key 使用 `GETSET` 命令重新赋值为 0,这样就达到了统计的目的。 + +## 2)列表 list + +Redis 的列表相当于 Java 语言中的 **LinkedList**,注意它是链表而不是数组。这意味着 list 的插入和删除操作非常快,时间复杂度为 O(1),但是索引定位很慢,时间复杂度为 O(n)。 + +我们可以从源码的 `adlist.h/listNode` 来看到对其的定义: + +```c +/* Node, List, and Iterator are the only data structures used currently. */ + +typedef struct listNode { + struct listNode *prev; + struct listNode *next; + void *value; +} listNode; + +typedef struct listIter { + listNode *next; + int direction; +} listIter; + +typedef struct list { + listNode *head; + listNode *tail; + void *(*dup)(void *ptr); + void (*free)(void *ptr); + int (*match)(void *ptr, void *key); + unsigned long len; +} list; +``` + +可以看到,多个 listNode 可以通过 `prev` 和 `next` 指针组成双向链表: + +![](https://upload-images.jianshu.io/upload_images/7896890-8f569f06506845c1.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +虽然仅仅使用多个 listNode 结构就可以组成链表,但是使用 `adlist.h/list` 结构来持有链表的话,操作起来会更加方便: + +![](https://upload-images.jianshu.io/upload_images/7896890-c6fb10cdbb32f517.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +### 链表的基本操作 + +- `LPUSH` 和 `RPUSH` 分别可以向 list 的左边(头部)和右边(尾部)添加一个新元素; +- `LRANGE` 命令可以从 list 中取出一定范围的元素; +- `LINDEX` 命令可以从 list 中取出指定下表的元素,相当于 Java 链表操作中的 `get(int index)` 操作; + +示范: + +```console +> rpush mylist A +(integer) 1 +> rpush mylist B +(integer) 2 +> lpush mylist first +(integer) 3 +> lrange mylist 0 -1 # -1 表示倒数第一个元素, 这里表示从第一个元素到最后一个元素,即所有 +1) "first" +2) "A" +3) "B" +``` + +#### list 实现队列 + +队列是先进先出的数据结构,常用于消息排队和异步逻辑处理,它会确保元素的访问顺序: + +```console +> RPUSH books python java golang +(integer) 3 +> LPOP books +"python" +> LPOP books +"java" +> LPOP books +"golang" +> LPOP books +(nil) +``` + +#### list 实现栈 + +栈是先进后出的数据结构,跟队列正好相反: + +```console +> RPUSH books python java golang +> RPOP books +"golang" +> RPOP books +"java" +> RPOP books +"python" +> RPOP books +(nil) +``` + +## 3)字典 hash + +Redis 中的字典相当于 Java 中的 **HashMap**,内部实现也差不多类似,都是通过 **"数组 + 链表"** 的链地址法来解决部分 **哈希冲突**,同时这样的结构也吸收了两种不同数据结构的优点。源码定义如 `dict.h/dictht` 定义: + +```c +typedef struct dictht { + // 哈希表数组 + dictEntry **table; + // 哈希表大小 + unsigned long size; + // 哈希表大小掩码,用于计算索引值,总是等于 size - 1 + unsigned long sizemask; + // 该哈希表已有节点的数量 + unsigned long used; +} dictht; + +typedef struct dict { + dictType *type; + void *privdata; + // 内部有两个 dictht 结构 + dictht ht[2]; + long rehashidx; /* rehashing not in progress if rehashidx == -1 */ + unsigned long iterators; /* number of iterators currently running */ +} dict; +``` + +`table` 属性是一个数组,数组中的每个元素都是一个指向 `dict.h/dictEntry` 结构的指针,而每个 `dictEntry` 结构保存着一个键值对: + +```c +typedef struct dictEntry { + // 键 + void *key; + // 值 + union { + void *val; + uint64_t u64; + int64_t s64; + double d; + } v; + // 指向下个哈希表节点,形成链表 + struct dictEntry *next; +} dictEntry; +``` + +可以从上面的源码中看到,**实际上字典结构的内部包含两个 hashtable**,通常情况下只有一个 hashtable 是有值的,但是在字典扩容缩容时,需要分配新的 hashtable,然后进行 **渐进式搬迁** *(下面说原因)*。 + +### 渐进式 rehash + +大字典的扩容是比较耗时间的,需要重新申请新的数组,然后将旧字典所有链表中的元素重新挂接到新的数组下面,这是一个 O(n) 级别的操作,作为单线程的 Redis 很难承受这样耗时的过程,所以 Redis 使用 **渐进式 rehash** 小步搬迁: + +![](https://upload-images.jianshu.io/upload_images/7896890-325d968300c47100.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +渐进式 rehash 会在 rehash 的同时,保留新旧两个 hash 结构,如上图所示,查询时会同时查询两个 hash 结构,然后在后续的定时任务以及 hash 操作指令中,循序渐进的把旧字典的内容迁移到新字典中。当搬迁完成了,就会使用新的 hash 结构取而代之。 + +### 扩缩容的条件 + +正常情况下,当 hash 表中 **元素的个数等于第一维数组的长度时**,就会开始扩容,扩容的新数组是 **原数组大小的 2 倍**。不过如果 Redis 正在做 `bgsave(持久化命令)`,为了减少内存也得过多分离,Redis 尽量不去扩容,但是如果 hash 表非常满了,**达到了第一维数组长度的 5 倍了**,这个时候就会 **强制扩容**。 + +当 hash 表因为元素逐渐被删除变得越来越稀疏时,Redis 会对 hash 表进行缩容来减少 hash 表的第一维数组空间占用。所用的条件是 **元素个数低于数组长度的 10%**,缩容不会考虑 Redis 是否在做 `bgsave`。 + +### 字典的基本操作 + +hash 也有缺点,hash 结构的存储消耗要高于单个字符串,所以到底该使用 hash 还是字符串,需要根据实际情况再三权衡: + +```console +> HSET books java "think in java" # 命令行的字符串如果包含空格则需要使用引号包裹 +(integer) 1 +> HSET books python "python cookbook" +(integer) 1 +> HGETALL books # key 和 value 间隔出现 +1) "java" +2) "think in java" +3) "python" +4) "python cookbook" +> HGET books java +"think in java" +> HSET books java "head first java" +(integer) 0 # 因为是更新操作,所以返回 0 +> HMSET books java "effetive java" python "learning python" # 批量操作 +OK +``` + +## 4)集合 set + +Redis 的集合相当于 Java 语言中的 **HashSet**,它内部的键值对是无序、唯一的。它的内部实现相当于一个特殊的字典,字典中所有的 value 都是一个值 NULL。 + +### 集合 set 的基本使用 + +由于该结构比较简单,我们直接来看看是如何使用的: + +```console +> SADD books java +(integer) 1 +> SADD books java # 重复 +(integer) 0 +> SADD books python golang +(integer) 2 +> SMEMBERS books # 注意顺序,set 是无序的 +1) "java" +2) "python" +3) "golang" +> SISMEMBER books java # 查询某个 value 是否存在,相当于 contains +(integer) 1 +> SCARD books # 获取长度 +(integer) 3 +> SPOP books # 弹出一个 +"java" +``` + +## 5)有序列表 zset + +这可能使 Redis 最具特色的一个数据结构了,它类似于 Java 中 **SortedSet** 和 **HashMap** 的结合体,一方面它是一个 set,保证了内部 value 的唯一性,另一方面它可以为每个 value 赋予一个 score 值,用来代表排序的权重。 + +它的内部实现用的是一种叫做 **「跳跃表」** 的数据结构,由于比较复杂,所以在这里简单提一下原理就好了: + +![](https://upload-images.jianshu.io/upload_images/7896890-efd5114939a651ed.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + + +想象你是一家创业公司的老板,刚开始只有几个人,大家都平起平坐。后来随着公司的发展,人数越来越多,团队沟通成本逐渐增加,渐渐地引入了组长制,对团队进行划分,于是有一些人**又是员工又有组长的身份**。 + +再后来,公司规模进一步扩大,公司需要再进入一个层级:部门。于是每个部门又会从组长中推举一位选出部长。 + +跳跃表就类似于这样的机制,最下面一层所有的元素都会串起来,都是员工,然后每隔几个元素就会挑选出一个代表,再把这几个代表使用另外一级指针串起来。然后再在这些代表里面挑出二级代表,再串起来。**最终形成了一个金字塔的结构。** + +想一下你目前所在的地理位置:亚洲 > 中国 > 某省 > 某市 > ....,**就是这样一个结构!** + +### 有序列表 zset 基础操作 + +```console +> ZADD books 9.0 "think in java" +> ZADD books 8.9 "java concurrency" +> ZADD books 8.6 "java cookbook" + +> ZRANGE books 0 -1 # 按 score 排序列出,参数区间为排名范围 +1) "java cookbook" +2) "java concurrency" +3) "think in java" + +> ZREVRANGE books 0 -1 # 按 score 逆序列出,参数区间为排名范围 +1) "think in java" +2) "java concurrency" +3) "java cookbook" + +> ZCARD books # 相当于 count() +(integer) 3 + +> ZSCORE books "java concurrency" # 获取指定 value 的 score +"8.9000000000000004" # 内部 score 使用 double 类型进行存储,所以存在小数点精度问题 + +> ZRANK books "java concurrency" # 排名 +(integer) 1 + +> ZRANGEBYSCORE books 0 8.91 # 根据分值区间遍历 zset +1) "java cookbook" +2) "java concurrency" + +> ZRANGEBYSCORE books -inf 8.91 withscores # 根据分值区间 (-∞, 8.91] 遍历 zset,同时返回分值。inf 代表 infinite,无穷大的意思。 +1) "java cookbook" +2) "8.5999999999999996" +3) "java concurrency" +4) "8.9000000000000004" + +> ZREM books "java concurrency" # 删除 value +(integer) 1 +> ZRANGE books 0 -1 +1) "java cookbook" +2) "think in java" +``` + +# 扩展/相关阅读 + +1. 阿里云 Redis 开发规范 - [https://www.infoq.cn/article/K7dB5AFKI9mr5Ugbs_px](https://www.infoq.cn/article/K7dB5AFKI9mr5Ugbs_px) +2. 为什么要防止 bigkey? - [https://mp.weixin.qq.com/s?__biz=Mzg2NTEyNzE0OA==&mid=2247483677&idx=1&sn=5c320b46f0e06ce9369a29909d62b401&chksm=ce5f9e9ef928178834021b6f9b939550ac400abae5c31e1933bafca2f16b23d028cc51813aec&scene=21#wechat_redirect](https://mp.weixin.qq.com/s?__biz=Mzg2NTEyNzE0OA==&mid=2247483677&idx=1&sn=5c320b46f0e06ce9369a29909d62b401&chksm=ce5f9e9ef928178834021b6f9b939550ac400abae5c31e1933bafca2f16b23d028cc51813aec&scene=21#wechat_redirect) +3. Redis【入门】就这一篇! - [https://www.wmyskxz.com/2018/05/31/redis-ru-men-jiu-zhe-yi-pian/](https://www.wmyskxz.com/2018/05/31/redis-ru-men-jiu-zhe-yi-pian/) + +# 参考资料 + +1. 《Redis 设计与实现》 - [http://redisbook.com/](http://redisbook.com/) +2. 【官方文档】Redis 数据类型介绍 - [http://www.redis.cn/topics/data-types-intro.html](http://www.redis.cn/topics/data-types-intro.html) +3. 《Redis 深度历险》 - [https://book.douban.com/subject/30386804/](https://book.douban.com/subject/30386804/) +4. 阿里云 Redis 开发规范 - [https://www.infoq.cn/article/K7dB5AFKI9mr5Ugbs_px](https://www.infoq.cn/article/K7dB5AFKI9mr5Ugbs_px) +5. Redis 快速入门 - 易百教程 - [https://www.yiibai.com/redis/redis_quick_guide.html](https://www.yiibai.com/redis/redis_quick_guide.html) +6. Redis【入门】就这一篇! - [https://www.wmyskxz.com/2018/05/31/redis-ru-men-jiu-zhe-yi-pian/](https://www.wmyskxz.com/2018/05/31/redis-ru-men-jiu-zhe-yi-pian/) + diff --git a/docs/database/Redis/redis-collection/Redis(2)——跳跃表.md b/docs/database/Redis/redis-collection/Redis(2)——跳跃表.md new file mode 100644 index 00000000..451c91bd --- /dev/null +++ b/docs/database/Redis/redis-collection/Redis(2)——跳跃表.md @@ -0,0 +1,392 @@ +> 授权转载自: https://github.com/wmyskxz/MoreThanJava#part3-redis + + + +![](https://upload-images.jianshu.io/upload_images/7896890-97a4ce9191464f62.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +# 一、跳跃表简介 + +跳跃表(skiplist)是一种随机化的数据结构,由 **William Pugh** 在论文[《Skip lists: a probabilistic alternative to balanced trees》](https://www.cl.cam.ac.uk/teaching/0506/Algorithms/skiplists.pdf)中提出,是一种可以于平衡树媲美的层次化链表结构——查找、删除、添加等操作都可以在对数期望时间下完成,以下是一个典型的跳跃表例子: + +![](https://upload-images.jianshu.io/upload_images/7896890-65a5b1a2849fb91c.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +我们在上一篇中提到了 Redis 的五种基本结构中,有一个叫做 **有序列表 zset** 的数据结构,它类似于 Java 中的 **SortedSet** 和 **HashMap** 的结合体,一方面它是一个 set 保证了内部 value 的唯一性,另一方面又可以给每个 value 赋予一个排序的权重值 score,来达到 **排序** 的目的。 + +它的内部实现就依赖了一种叫做 **「跳跃列表」** 的数据结构。 + +## 为什么使用跳跃表 + +首先,因为 zset 要支持随机的插入和删除,所以它 **不宜使用数组来实现**,关于排序问题,我们也很容易就想到 **红黑树/ 平衡树** 这样的树形结构,为什么 Redis 不使用这样一些结构呢? + +1. **性能考虑:** 在高并发的情况下,树形结构需要执行一些类似于 rebalance 这样的可能涉及整棵树的操作,相对来说跳跃表的变化只涉及局部 _(下面详细说)_; +2. **实现考虑:** 在复杂度与红黑树相同的情况下,跳跃表实现起来更简单,看起来也更加直观; + +基于以上的一些考虑,Redis 基于 **William Pugh** 的论文做出一些改进后采用了 **跳跃表** 这样的结构。 + +## 本质是解决查找问题 + +我们先来看一个普通的链表结构: + +![](https://upload-images.jianshu.io/upload_images/7896890-11b7eebde1779904.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +我们需要这个链表按照 score 值进行排序,这也就意味着,当我们需要添加新的元素时,我们需要定位到插入点,这样才可以继续保证链表是有序的,通常我们会使用 **二分查找法**,但二分查找是有序数组的,链表没办法进行位置定位,我们除了遍历整个找到第一个比给定数据大的节点为止 _(时间复杂度 O(n))_ 似乎没有更好的办法。 + +但假如我们每相邻两个节点之间就增加一个指针,让指针指向下一个节点,如下图: + +![](https://upload-images.jianshu.io/upload_images/7896890-8cae2c261c950b32.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +这样所有新增的指针连成了一个新的链表,但它包含的数据却只有原来的一半 _(图中的为 3,11)_。 + +现在假设我们想要查找数据时,可以根据这条新的链表查找,如果碰到比待查找数据大的节点时,再回到原来的链表中进行查找,比如,我们想要查找 7,查找的路径则是沿着下图中标注出的红色指针所指向的方向进行的: + +![](https://upload-images.jianshu.io/upload_images/7896890-9c0262c7a85c120e.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +这是一个略微极端的例子,但我们仍然可以看到,通过新增加的指针查找,我们不再需要与链表上的每一个节点逐一进行比较,这样改进之后需要比较的节点数大概只有原来的一半。 + +利用同样的方式,我们可以在新产生的链表上,继续为每两个相邻的节点增加一个指针,从而产生第三层链表: + +![](https://upload-images.jianshu.io/upload_images/7896890-22036e274bedaa5a.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +在这个新的三层链表结构中,我们试着 **查找 13**,那么沿着最上层链表首先比较的是 11,发现 11 比 13 小,于是我们就知道只需要到 11 后面继续查找,**从而一下子跳过了 11 前面的所有节点。** + +可以想象,当链表足够长,这样的多层链表结构可以帮助我们跳过很多下层节点,从而加快查找的效率。 + +## 更进一步的跳跃表 + +**跳跃表 skiplist** 就是受到这种多层链表结构的启发而设计出来的。按照上面生成链表的方式,上面每一层链表的节点个数,是下面一层的节点个数的一半,这样查找过程就非常类似于一个二分查找,使得查找的时间复杂度可以降低到 _O(logn)_。 + +但是,这种方法在插入数据的时候有很大的问题。新插入一个节点之后,就会打乱上下相邻两层链表上节点个数严格的 2:1 的对应关系。如果要维持这种对应关系,就必须把新插入的节点后面的所有节点 _(也包括新插入的节点)_ 重新进行调整,这会让时间复杂度重新蜕化成 _O(n)_。删除数据也有同样的问题。 + +**skiplist** 为了避免这一问题,它不要求上下相邻两层链表之间的节点个数有严格的对应关系,而是 **为每个节点随机出一个层数(level)**。比如,一个节点随机出的层数是 3,那么就把它链入到第 1 层到第 3 层这三层链表中。为了表达清楚,下图展示了如何通过一步步的插入操作从而形成一个 skiplist 的过程: + +![](https://upload-images.jianshu.io/upload_images/7896890-1e0626c013de095e.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +从上面的创建和插入的过程中可以看出,每一个节点的层数(level)是随机出来的,而且新插入一个节点并不会影响到其他节点的层数,因此,**插入操作只需要修改节点前后的指针,而不需要对多个节点都进行调整**,这就降低了插入操作的复杂度。 + +现在我们假设从我们刚才创建的这个结构中查找 23 这个不存在的数,那么查找路径会如下图: + +![](https://upload-images.jianshu.io/upload_images/7896890-a8f66d808e8a4d1e.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +# 二、跳跃表的实现 + +Redis 中的跳跃表由 `server.h/zskiplistNode` 和 `server.h/zskiplist` 两个结构定义,前者为跳跃表节点,后者则保存了跳跃节点的相关信息,同之前的 `集合 list` 结构类似,其实只有 `zskiplistNode` 就可以实现了,但是引入后者是为了更加方便的操作: + +```c +/* ZSETs use a specialized version of Skiplists */ +typedef struct zskiplistNode { + // value + sds ele; + // 分值 + double score; + // 后退指针 + struct zskiplistNode *backward; + // 层 + struct zskiplistLevel { + // 前进指针 + struct zskiplistNode *forward; + // 跨度 + unsigned long span; + } level[]; +} zskiplistNode; + +typedef struct zskiplist { + // 跳跃表头指针 + struct zskiplistNode *header, *tail; + // 表中节点的数量 + unsigned long length; + // 表中层数最大的节点的层数 + int level; +} zskiplist; +``` + +正如文章开头画出来的那张标准的跳跃表那样。 + +## 随机层数 + +对于每一个新插入的节点,都需要调用一个随机算法给它分配一个合理的层数,源码在 `t_zset.c/zslRandomLevel(void)` 中被定义: + +```c +int zslRandomLevel(void) { + int level = 1; + while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF)) + level += 1; + return (level-63 的概率被分配到最顶层,因为这里每一层的晋升率都是 50%。 + +**Redis 跳跃表默认允许最大的层数是 32**,被源码中 `ZSKIPLIST_MAXLEVEL` 定义,当 `Level[0]` 有 264 个元素时,才能达到 32 层,所以定义 32 完全够用了。 + +## 创建跳跃表 + +这个过程比较简单,在源码中的 `t_zset.c/zslCreate` 中被定义: + +```c +zskiplist *zslCreate(void) { + int j; + zskiplist *zsl; + + // 申请内存空间 + zsl = zmalloc(sizeof(*zsl)); + // 初始化层数为 1 + zsl->level = 1; + // 初始化长度为 0 + zsl->length = 0; + // 创建一个层数为 32,分数为 0,没有 value 值的跳跃表头节点 + zsl->header = zslCreateNode(ZSKIPLIST_MAXLEVEL,0,NULL); + + // 跳跃表头节点初始化 + for (j = 0; j < ZSKIPLIST_MAXLEVEL; j++) { + // 将跳跃表头节点的所有前进指针 forward 设置为 NULL + zsl->header->level[j].forward = NULL; + // 将跳跃表头节点的所有跨度 span 设置为 0 + zsl->header->level[j].span = 0; + } + // 跳跃表头节点的后退指针 backward 置为 NULL + zsl->header->backward = NULL; + // 表头指向跳跃表尾节点的指针置为 NULL + zsl->tail = NULL; + return zsl; +} + +``` + +即执行完之后创建了如下结构的初始化跳跃表: + +![](https://upload-images.jianshu.io/upload_images/7896890-551660604afd1041.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + + +## 插入节点实现 + +这几乎是最重要的一段代码了,但总体思路也比较清晰简单,如果理解了上面所说的跳跃表的原理,那么很容易理清楚插入节点时发生的几个动作 *(几乎跟链表类似)*: + +1. 找到当前我需要插入的位置 *(其中包括相同 score 时的处理)*; +2. 创建新节点,调整前后的指针指向,完成插入; + +为了方便阅读,我把源码 `t_zset.c/zslInsert` 定义的插入函数拆成了几个部分 + +### 第一部分:声明需要存储的变量 + +```c +// 存储搜索路径 +zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x; +// 存储经过的节点跨度 +unsigned int rank[ZSKIPLIST_MAXLEVEL]; +int i, level; +``` + +### 第二部分:搜索当前节点插入位置 + +```c +serverAssert(!isnan(score)); +x = zsl->header; +// 逐步降级寻找目标节点,得到 "搜索路径" +for (i = zsl->level-1; i >= 0; i--) { + /* store rank that is crossed to reach the insert position */ + rank[i] = i == (zsl->level-1) ? 0 : rank[i+1]; + // 如果 score 相等,还需要比较 value 值 + while (x->level[i].forward && + (x->level[i].forward->score < score || + (x->level[i].forward->score == score && + sdscmp(x->level[i].forward->ele,ele) < 0))) + { + rank[i] += x->level[i].span; + x = x->level[i].forward; + } + // 记录 "搜索路径" + update[i] = x; +} +``` + +**讨论:** 有一种极端的情况,就是跳跃表中的所有 score 值都是一样,zset 的查找性能会不会退化为 O(n) 呢? + +从上面的源码中我们可以发现 zset 的排序元素不只是看 score 值,也会比较 value 值 *(字符串比较)* + +### 第三部分:生成插入节点 + +```c +/* we assume the element is not already inside, since we allow duplicated + * scores, reinserting the same element should never happen since the + * caller of zslInsert() should test in the hash table if the element is + * already inside or not. */ +level = zslRandomLevel(); +// 如果随机生成的 level 超过了当前最大 level 需要更新跳跃表的信息 +if (level > zsl->level) { + for (i = zsl->level; i < level; i++) { + rank[i] = 0; + update[i] = zsl->header; + update[i]->level[i].span = zsl->length; + } + zsl->level = level; +} +// 创建新节点 +x = zslCreateNode(level,score,ele); +``` + +### 第四部分:重排前向指针 + +```c +for (i = 0; i < level; i++) { + x->level[i].forward = update[i]->level[i].forward; + update[i]->level[i].forward = x; + + /* update span covered by update[i] as x is inserted here */ + x->level[i].span = update[i]->level[i].span - (rank[0] - rank[i]); + update[i]->level[i].span = (rank[0] - rank[i]) + 1; +} + +/* increment span for untouched levels */ +for (i = level; i < zsl->level; i++) { + update[i]->level[i].span++; +} +``` + +### 第五部分:重排后向指针并返回 + +```c +x->backward = (update[0] == zsl->header) ? NULL : update[0]; +if (x->level[0].forward) + x->level[0].forward->backward = x; +else + zsl->tail = x; +zsl->length++; +return x; +``` + +## 节点删除实现 + +删除过程由源码中的 `t_zset.c/zslDeleteNode` 定义,和插入过程类似,都需要先把这个 **"搜索路径"** 找出来,然后对于每个层的相关节点重排一下前向后向指针,同时还要注意更新一下最高层数 `maxLevel`,直接放源码 *(如果理解了插入这里还是很容易理解的)*: + +```c +/* Internal function used by zslDelete, zslDeleteByScore and zslDeleteByRank */ +void zslDeleteNode(zskiplist *zsl, zskiplistNode *x, zskiplistNode **update) { + int i; + for (i = 0; i < zsl->level; i++) { + if (update[i]->level[i].forward == x) { + update[i]->level[i].span += x->level[i].span - 1; + update[i]->level[i].forward = x->level[i].forward; + } else { + update[i]->level[i].span -= 1; + } + } + if (x->level[0].forward) { + x->level[0].forward->backward = x->backward; + } else { + zsl->tail = x->backward; + } + while(zsl->level > 1 && zsl->header->level[zsl->level-1].forward == NULL) + zsl->level--; + zsl->length--; +} + +/* Delete an element with matching score/element from the skiplist. + * The function returns 1 if the node was found and deleted, otherwise + * 0 is returned. + * + * If 'node' is NULL the deleted node is freed by zslFreeNode(), otherwise + * it is not freed (but just unlinked) and *node is set to the node pointer, + * so that it is possible for the caller to reuse the node (including the + * referenced SDS string at node->ele). */ +int zslDelete(zskiplist *zsl, double score, sds ele, zskiplistNode **node) { + zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x; + int i; + + x = zsl->header; + for (i = zsl->level-1; i >= 0; i--) { + while (x->level[i].forward && + (x->level[i].forward->score < score || + (x->level[i].forward->score == score && + sdscmp(x->level[i].forward->ele,ele) < 0))) + { + x = x->level[i].forward; + } + update[i] = x; + } + /* We may have multiple elements with the same score, what we need + * is to find the element with both the right score and object. */ + x = x->level[0].forward; + if (x && score == x->score && sdscmp(x->ele,ele) == 0) { + zslDeleteNode(zsl, x, update); + if (!node) + zslFreeNode(x); + else + *node = x; + return 1; + } + return 0; /* not found */ +} +``` + +## 节点更新实现 + +当我们调用 `ZADD` 方法时,如果对应的 value 不存在,那就是插入过程,如果这个 value 已经存在,只是调整一下 score 的值,那就需要走一个更新流程。 + +假设这个新的 score 值并不会带来排序上的变化,那么就不需要调整位置,直接修改元素的 score 值就可以了,但是如果排序位置改变了,那就需要调整位置,该如何调整呢? + +从源码 `t_zset.c/zsetAdd` 函数 `1350` 行左右可以看到,Redis 采用了一个非常简单的策略: + +```c +/* Remove and re-insert when score changed. */ +if (score != curscore) { + zobj->ptr = zzlDelete(zobj->ptr,eptr); + zobj->ptr = zzlInsert(zobj->ptr,ele,score); + *flags |= ZADD_UPDATED; +} +``` + +**把这个元素删除再插入这个**,需要经过两次路径搜索,从这一点上来看,Redis 的 `ZADD` 代码似乎还有进一步优化的空间。 + +## 元素排名的实现 + +跳跃表本身是有序的,Redis 在 skiplist 的 forward 指针上进行了优化,给每一个 forward 指针都增加了 `span` 属性,用来 **表示从前一个节点沿着当前层的 forward 指针跳到当前这个节点中间会跳过多少个节点**。在上面的源码中我们也可以看到 Redis 在插入、删除操作时都会小心翼翼地更新 `span` 值的大小。 + +所以,沿着 **"搜索路径"**,把所有经过节点的跨度 `span` 值进行累加就可以算出当前元素的最终 rank 值了: + +```c +/* Find the rank for an element by both score and key. + * Returns 0 when the element cannot be found, rank otherwise. + * Note that the rank is 1-based due to the span of zsl->header to the + * first element. */ +unsigned long zslGetRank(zskiplist *zsl, double score, sds ele) { + zskiplistNode *x; + unsigned long rank = 0; + int i; + + x = zsl->header; + for (i = zsl->level-1; i >= 0; i--) { + while (x->level[i].forward && + (x->level[i].forward->score < score || + (x->level[i].forward->score == score && + sdscmp(x->level[i].forward->ele,ele) <= 0))) { + // span 累加 + rank += x->level[i].span; + x = x->level[i].forward; + } + + /* x might be equal to zsl->header, so test if obj is non-NULL */ + if (x->ele && sdscmp(x->ele,ele) == 0) { + return rank; + } + } + return 0; +} +``` + + +# 扩展阅读 + +1. 跳跃表 Skip List 的原理和实现(Java) - [https://blog.csdn.net/DERRANTCM/article/details/79063312](https://blog.csdn.net/DERRANTCM/article/details/79063312) +2. 【算法导论33】跳跃表(Skip list)原理与java实现 - [https://blog.csdn.net/brillianteagle/article/details/52206261](https://blog.csdn.net/brillianteagle/article/details/52206261) + +# 参考资料 + +1. 《Redis 设计与实现》 - [http://redisbook.com/](http://redisbook.com/) +2. 【官方文档】Redis 数据类型介绍 - [http://www.redis.cn/topics/data-types-intro.html](http://www.redis.cn/topics/data-types-intro.html) +3. 《Redis 深度历险》 - [https://book.douban.com/subject/30386804/](https://book.douban.com/subject/30386804/) +4. Redis 源码 - [https://github.com/antirez/redis](https://github.com/antirez/redis) +5. Redis 快速入门 - 易百教程 - [https://www.yiibai.com/redis/redis_quick_guide.html](https://www.yiibai.com/redis/redis_quick_guide.html) +6. Redis【入门】就这一篇! - [https://www.wmyskxz.com/2018/05/31/redis-ru-men-jiu-zhe-yi-pian/](https://www.wmyskxz.com/2018/05/31/redis-ru-men-jiu-zhe-yi-pian/) +7. Redis为什么用跳表而不用平衡树? - [https://mp.weixin.qq.com/s?__biz=MzA4NTg1MjM0Mg==&mid=2657261425&idx=1&sn=d840079ea35875a8c8e02d9b3e44cf95&scene=21#wechat_redirect](https://mp.weixin.qq.com/s?__biz=MzA4NTg1MjM0Mg==&mid=2657261425&idx=1&sn=d840079ea35875a8c8e02d9b3e44cf95&scene=21#wechat_redirect) +8. 为啥 redis 使用跳表(skiplist)而不是使用 red-black? - 知乎@于康 - [https://www.zhihu.com/question/20202931](https://www.zhihu.com/question/20202931) + diff --git a/docs/database/Redis/redis-collection/Redis(3)——分布式锁深入探究.md b/docs/database/Redis/redis-collection/Redis(3)——分布式锁深入探究.md new file mode 100644 index 00000000..d05ff594 --- /dev/null +++ b/docs/database/Redis/redis-collection/Redis(3)——分布式锁深入探究.md @@ -0,0 +1,228 @@ +> 授权转载自: https://github.com/wmyskxz/MoreThanJava#part3-redis + +![](https://upload-images.jianshu.io/upload_images/7896890-5fe2adf61ccf11aa.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +# 一、分布式锁简介 + +**锁** 是一种用来解决多个执行线程 **访问共享资源** 错误或数据不一致问题的工具。 + +如果 *把一台服务器比作一个房子*,那么 *线程就好比里面的住户*,当他们想要共同访问一个共享资源,例如厕所的时候,如果厕所门上没有锁...更甚者厕所没装门...这是会出原则性的问题的.. + +![](https://upload-images.jianshu.io/upload_images/7896890-26a364bddb9218eb.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +装上了锁,大家用起来就安心多了,本质也就是 **同一时间只允许一个住户使用**。 + +而随着互联网世界的发展,单体应用已经越来越无法满足复杂互联网的高并发需求,转而慢慢朝着分布式方向发展,慢慢进化成了 **更大一些的住户**。所以同样,我们需要引入分布式锁来解决分布式应用之间访问共享资源的并发问题。 + +## 为何需要分布式锁 + +一般情况下,我们使用分布式锁主要有两个场景: + +1. **避免不同节点重复相同的工作**:比如用户执行了某个操作有可能不同节点会发送多封邮件; +2. **避免破坏数据的正确性**:如果两个节点在同一条数据上同时进行操作,可能会造成数据错误或不一致的情况出现; + +## Java 中实现的常见方式 + +上面我们用简单的比喻说明了锁的本质:**同一时间只允许一个用户操作**。所以理论上,能够满足这个需求的工具我们都能够使用 *(就是其他应用能帮我们加锁的)*: + +1. **基于 MySQL 中的锁**:MySQL 本身有自带的悲观锁 `for update` 关键字,也可以自己实现悲观/乐观锁来达到目的; +2. **基于 Zookeeper 有序节点**:Zookeeper 允许临时创建有序的子节点,这样客户端获取节点列表时,就能够当前子节点列表中的序号判断是否能够获得锁; +3. **基于 Redis 的单线程**:由于 Redis 是单线程,所以命令会以串行的方式执行,并且本身提供了像 `SETNX(set if not exists)` 这样的指令,本身具有互斥性; + +每个方案都有各自的优缺点,例如 MySQL 虽然直观理解容易,但是实现起来却需要额外考虑 **锁超时**、**加事务** 等,并且性能局限于数据库,诸如此类我们在此不作讨论,重点关注 Redis。 + +## Redis 分布式锁的问题 + +### 1)锁超时 + +假设现在我们有两台平行的服务 A B,其中 A 服务在 **获取锁之后** 由于未知神秘力量突然 **挂了**,那么 B 服务就永远无法获取到锁了: + +![](https://upload-images.jianshu.io/upload_images/7896890-4ea386c23ef0eec9.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +所以我们需要额外设置一个超时时间,来保证服务的可用性。 + +但是另一个问题随即而来:**如果在加锁和释放锁之间的逻辑执行得太长,以至于超出了锁的超时限制**,也会出现问题。因为这时候第一个线程持有锁过期了,而临界区的逻辑还没有执行完,与此同时第二个线程就提前拥有了这把锁,导致临界区的代码不能得到严格的串行执行。 + +为了避免这个问题,**Redis 分布式锁不要用于较长时间的任务**。如果真的偶尔出现了问题,造成的数据小错乱可能就需要人工的干预。 + +有一个稍微安全一点的方案是 **将锁的 `value` 值设置为一个随机数**,释放锁时先匹配随机数是否一致,然后再删除 key,这是为了 **确保当前线程占有的锁不会被其他线程释放**,除非这个锁是因为过期了而被服务器自动释放的。 + +但是匹配 `value` 和删除 `key` 在 Redis 中并不是一个原子性的操作,也没有类似保证原子性的指令,所以可能需要使用像 Lua 这样的脚本来处理了,因为 Lua 脚本可以 **保证多个指令的原子性执行**。 + +### 延伸的讨论:GC 可能引发的安全问题 + +[Martin Kleppmann](https://martin.kleppmann.com/) 曾与 Redis 之父 Antirez 就 Redis 实现分布式锁的安全性问题进行过深入的讨论,其中有一个问题就涉及到 **GC**。 + +熟悉 Java 的同学肯定对 GC 不陌生,在 GC 的时候会发生 **STW(Stop-The-World)**,这本身是为了保障垃圾回收器的正常执行,但可能会引发如下的问题: + +![](https://upload-images.jianshu.io/upload_images/7896890-cf3a403968a23be4.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +服务 A 获取了锁并设置了超时时间,但是服务 A 出现了 STW 且时间较长,导致了分布式锁进行了超时释放,在这个期间服务 B 获取到了锁,待服务 A STW 结束之后又恢复了锁,这就导致了 **服务 A 和服务 B 同时获取到了锁**,这个时候分布式锁就不安全了。 + +不仅仅局限于 Redis,Zookeeper 和 MySQL 有同样的问题。 + +想吃更多瓜的童鞋,可以访问下列网站看看 Redis 之父 Antirez 怎么说:[http://antirez.com/news/101](http://antirez.com/news/101) + +### 2)单点/多点问题 + +如果 Redis 采用单机部署模式,那就意味着当 Redis 故障了,就会导致整个服务不可用。 + +而如果采用主从模式部署,我们想象一个这样的场景:*服务 A* 申请到一把锁之后,如果作为主机的 Redis 宕机了,那么 *服务 B* 在申请锁的时候就会从从机那里获取到这把锁,为了解决这个问题,Redis 作者提出了一种 **RedLock 红锁** 的算法 *(Redission 同 Jedis)*: + +```java +// 三个 Redis 集群 +RLock lock1 = redissionInstance1.getLock("lock1"); +RLock lock2 = redissionInstance2.getLock("lock2"); +RLock lock3 = redissionInstance3.getLock("lock3"); + +RedissionRedLock lock = new RedissionLock(lock1, lock2, lock2); +lock.lock(); +// do something.... +lock.unlock(); +``` + +# 二、Redis 分布式锁的实现 + +分布式锁类似于 "占坑",而 `SETNX(SET if Not eXists)` 指令就是这样的一个操作,只允许被一个客户端占有,我们来看看 **源码(t_string.c/setGenericCommand)** 吧: + +```c +// SET/ SETEX/ SETTEX/ SETNX 最底层实现 +void setGenericCommand(client *c, int flags, robj *key, robj *val, robj *expire, int unit, robj *ok_reply, robj *abort_reply) { + long long milliseconds = 0; /* initialized to avoid any harmness warning */ + + // 如果定义了 key 的过期时间则保存到上面定义的变量中 + // 如果过期时间设置错误则返回错误信息 + if (expire) { + if (getLongLongFromObjectOrReply(c, expire, &milliseconds, NULL) != C_OK) + return; + if (milliseconds <= 0) { + addReplyErrorFormat(c,"invalid expire time in %s",c->cmd->name); + return; + } + if (unit == UNIT_SECONDS) milliseconds *= 1000; + } + + // lookupKeyWrite 函数是为执行写操作而取出 key 的值对象 + // 这里的判断条件是: + // 1.如果设置了 NX(不存在),并且在数据库中找到了 key 值 + // 2.或者设置了 XX(存在),并且在数据库中没有找到该 key + // => 那么回复 abort_reply 给客户端 + if ((flags & OBJ_SET_NX && lookupKeyWrite(c->db,key) != NULL) || + (flags & OBJ_SET_XX && lookupKeyWrite(c->db,key) == NULL)) + { + addReply(c, abort_reply ? abort_reply : shared.null[c->resp]); + return; + } + + // 在当前的数据库中设置键为 key 值为 value 的数据 + genericSetKey(c->db,key,val,flags & OBJ_SET_KEEPTTL); + // 服务器每修改一个 key 后都会修改 dirty 值 + server.dirty++; + if (expire) setExpire(c,c->db,key,mstime()+milliseconds); + notifyKeyspaceEvent(NOTIFY_STRING,"set",key,c->db->id); + if (expire) notifyKeyspaceEvent(NOTIFY_GENERIC, + "expire",key,c->db->id); + addReply(c, ok_reply ? ok_reply : shared.ok); +} +``` + +就像上面介绍的那样,其实在之前版本的 Redis 中,由于 `SETNX` 和 `EXPIRE` 并不是 **原子指令**,所以在一起执行会出现问题。 + +也许你会想到使用 Redis 事务来解决,但在这里不行,因为 `EXPIRE` 命令依赖于 `SETNX` 的执行结果,而事务中没有 `if-else` 的分支逻辑,如果 `SETNX` 没有抢到锁,`EXPIRE` 就不应该执行。 + +为了解决这个疑难问题,Redis 开源社区涌现了许多分布式锁的 library,为了治理这个乱象,后来在 Redis 2.8 的版本中,加入了 `SET` 指令的扩展参数,使得 `SETNX` 可以和 `EXPIRE` 指令一起执行了: + +```console +> SET lock:test true ex 5 nx +OK +... do something critical ... +> del lock:test +``` + +你只需要符合 `SET key value [EX seconds | PX milliseconds] [NX | XX] [KEEPTTL]` 这样的格式就好了,你也在下方右拐参照官方的文档: + +- 官方文档:[https://redis.io/commands/set](https://redis.io/commands/set) + +另外,官方文档也在 [`SETNX` 文档](https://redis.io/commands/setnx)中提到了这样一种思路:**把 SETNX 对应 key 的 value 设置为 **,这样在其他客户端访问时就能够自己判断是否能够获取下一个 value 为上述格式的锁了。 + +## 代码实现 + +下面用 Jedis 来模拟实现以下,关键代码如下: + +```java +private static final String LOCK_SUCCESS = "OK"; +private static final Long RELEASE_SUCCESS = 1L; +private static final String SET_IF_NOT_EXIST = "NX"; +private static final String SET_WITH_EXPIRE_TIME = "PX"; + +@Override +public String acquire() { + try { + // 获取锁的超时时间,超过这个时间则放弃获取锁 + long end = System.currentTimeMillis() + acquireTimeout; + // 随机生成一个 value + String requireToken = UUID.randomUUID().toString(); + while (System.currentTimeMillis() < end) { + String result = jedis + .set(lockKey, requireToken, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime); + if (LOCK_SUCCESS.equals(result)) { + return requireToken; + } + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } catch (Exception e) { + log.error("acquire lock due to error", e); + } + + return null; +} + +@Override +public boolean release(String identify) { + if (identify == null) { + return false; + } + + String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; + Object result = new Object(); + try { + result = jedis.eval(script, Collections.singletonList(lockKey), + Collections.singletonList(identify)); + if (RELEASE_SUCCESS.equals(result)) { + log.info("release lock success, requestToken:{}", identify); + return true; + } + } catch (Exception e) { + log.error("release lock due to error", e); + } finally { + if (jedis != null) { + jedis.close(); + } + } + + log.info("release lock failed, requestToken:{}, result:{}", identify, result); + return false; +} +``` + +- 引用自下方 *参考资料 3*,其中还有 RedLock 的实现和测试,有兴趣的童鞋可以戳一下 + +# 推荐阅读 + +1. 【官方文档】Distributed locks with Redis - [https://redis.io/topics/distlock](https://redis.io/topics/distlock) +2. Redis【入门】就这一篇! - [https://www.wmyskxz.com/2018/05/31/redis-ru-men-jiu-zhe-yi-pian/](https://www.wmyskxz.com/2018/05/31/redis-ru-men-jiu-zhe-yi-pian/) +3. Redission - Redis Java Client 源码 - [https://github.com/redisson/redisson](https://github.com/redisson/redisson) +4. 手写一个 Jedis 以及 JedisPool - [https://juejin.im/post/5e5101c46fb9a07cab3a953a](https://juejin.im/post/5e5101c46fb9a07cab3a953a) + +# 参考资料 + +1. 再有人问你分布式锁,这篇文章扔给他 - [https://juejin.im/post/5bbb0d8df265da0abd3533a5#heading-0](https://juejin.im/post/5bbb0d8df265da0abd3533a5#heading-0) +2. 【官方文档】Distributed locks with Redis - [https://redis.io/topics/distlock](https://redis.io/topics/distlock) +3. 【分布式缓存系列】Redis实现分布式锁的正确姿势 - [https://www.cnblogs.com/zhili/p/redisdistributelock.html](https://www.cnblogs.com/zhili/p/redisdistributelock.html) +4. Redis源码剖析和注释(九)--- 字符串命令的实现(t_string) - [https://blog.csdn.net/men_wen/article/details/70325566](https://blog.csdn.net/men_wen/article/details/70325566) +5. 《Redis 深度历险》 - 钱文品/ 著 + diff --git a/docs/database/Redis/redis-collection/Redis(5)——亿级数据过滤和布隆过滤器.md b/docs/database/Redis/redis-collection/Redis(5)——亿级数据过滤和布隆过滤器.md new file mode 100644 index 00000000..b522f656 --- /dev/null +++ b/docs/database/Redis/redis-collection/Redis(5)——亿级数据过滤和布隆过滤器.md @@ -0,0 +1,361 @@ +> 授权转载自: https://github.com/wmyskxz/MoreThanJava#part3-redis + +![](https://upload-images.jianshu.io/upload_images/7896890-59d043fad3a66d7f.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +# 一、布隆过滤器简介 + +[上一次](https://www.wmyskxz.com/2020/03/02/reids-4-shen-qi-de-hyperloglog-jie-jue-tong-ji-wen-ti/) 我们学会了使用 **HyperLogLog** 来对大数据进行一个估算,它非常有价值,可以解决很多精确度不高的统计需求。但是如果我们想知道某一个值是不是已经在 **HyperLogLog** 结构里面了,它就无能为力了,它只提供了 `pfadd` 和 `pfcount` 方法,没有提供类似于 `contains` 的这种方法。 + +就举一个场景吧,比如你 **刷抖音**: + +![](https://upload-images.jianshu.io/upload_images/7896890-c7b6b5c8a47caf4a.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +你有 **刷到过重复的推荐内容** 吗?这么多的推荐内容要推荐给这么多的用户,它是怎么保证每个用户在看推荐内容时,保证不会出现之前已经看过的推荐视频呢?也就是说,抖音是如何实现 **推送去重** 的呢? + +你会想到服务器 **记录** 了用户看过的 **所有历史记录**,当推荐系统推荐短视频时会从每个用户的历史记录里进行 **筛选**,过滤掉那些已经存在的记录。问题是当 **用户量很大**,每个用户看过的短视频又很多的情况下,这种方式,推荐系统的去重工作 **在性能上跟的上么?** + +实际上,如果历史记录存储在关系数据库里,去重就需要频繁地对数据库进行 `exists` 查询,当系统并发量很高时,数据库是很难抗住压力的。 + +![image](https://upload-images.jianshu.io/upload_images/7896890-099f3600b7022ef6.jpg?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +你可能又想到了 **缓存**,但是这么多用户这么多的历史记录,如果全部缓存起来,那得需要 **浪费多大的空间** 啊.. *(可能老板看一眼账单,看一眼你..)* 并且这个存储空间会随着时间呈线性增长,就算你用缓存撑得住一个月,但是又能继续撑多久呢?不缓存性能又跟不上,咋办呢? + +![](https://upload-images.jianshu.io/upload_images/7896890-204e7440395a31b5.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +如上图所示,**布隆过滤器(Bloom Filter)** 就是这样一种专门用来解决去重问题的高级数据结构。但是跟 **HyperLogLog** 一样,它也一样有那么一点点不精确,也存在一定的误判概率,但它能在解决去重的同时,在 **空间上能节省 90%** 以上,也是非常值得的。 + +## 布隆过滤器是什么 + +**布隆过滤器(Bloom Filter)** 是 1970 年由布隆提出的。它 **实际上** 是一个很长的二进制向量和一系列随机映射函数 *(下面详细说)*,实际上你也可以把它 **简单理解** 为一个不怎么精确的 **set** 结构,当你使用它的 `contains` 方法判断某个对象是否存在时,它可能会误判。但是布隆过滤器也不是特别不精确,只要参数设置的合理,它的精确度可以控制的相对足够精确,只会有小小的误判概率。 + +当布隆过滤器说某个值存在时,这个值 **可能不存在**;当它说不存在时,那么 **一定不存在**。打个比方,当它说不认识你时,那就是真的不认识,但是当它说认识你的时候,可能是因为你长得像它认识的另外一个朋友 *(脸长得有些相似)*,所以误判认识你。 + +![image](https://upload-images.jianshu.io/upload_images/7896890-757891d52045869d.jpg?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +## 布隆过滤器的使用场景 + +基于上述的功能,我们大致可以把布隆过滤器用于以下的场景之中: + +- **大数据判断是否存在**:这就可以实现出上述的去重功能,如果你的服务器内存足够大的话,那么使用 HashMap 可能是一个不错的解决方案,理论上时间复杂度可以达到 O(1 的级别,但是当数据量起来之后,还是只能考虑布隆过滤器。 +- **解决缓存穿透**:我们经常会把一些热点数据放在 Redis 中当作缓存,例如产品详情。 通常一个请求过来之后我们会先查询缓存,而不用直接读取数据库,这是提升性能最简单也是最普遍的做法,但是 **如果一直请求一个不存在的缓存**,那么此时一定不存在缓存,那就会有 **大量请求直接打到数据库** 上,造成 **缓存穿透**,布隆过滤器也可以用来解决此类问题。 +- **爬虫/ 邮箱等系统的过滤**:平时不知道你有没有注意到有一些正常的邮件也会被放进垃圾邮件目录中,这就是使用布隆过滤器 **误判** 导致的。 + +# 二、布隆过滤器原理解析 + +布隆过滤器 **本质上** 是由长度为 `m` 的位向量或位列表(仅包含 `0` 或 `1` 位值的列表)组成,最初所有的值均设置为 `0`,所以我们先来创建一个稍微长一些的位向量用作展示: + +![](https://upload-images.jianshu.io/upload_images/7896890-362a693c82af3c8e.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +当我们向布隆过滤器中添加数据时,会使用 **多个** `hash` 函数对 `key` 进行运算,算得一个证书索引值,然后对位数组长度进行取模运算得到一个位置,每个 `hash` 函数都会算得一个不同的位置。再把位数组的这几个位置都置为 `1` 就完成了 `add` 操作,例如,我们添加一个 `wmyskxz`: + +![](https://upload-images.jianshu.io/upload_images/7896890-fdbf75a56fb03c02.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +向布隆过滤器查查询 `key` 是否存在时,跟 `add` 操作一样,会把这个 `key` 通过相同的多个 `hash` 函数进行运算,查看 **对应的位置** 是否 **都** 为 `1`,**只要有一个位为 `0`**,那么说明布隆过滤器中这个 `key` 不存在。如果这几个位置都是 `1`,并不能说明这个 `key` 一定存在,只能说极有可能存在,因为这些位置的 `1` 可能是因为其他的 `key` 存在导致的。 + +就比如我们在 `add` 了一定的数据之后,查询一个 **不存在** 的 `key`: + +![](https://upload-images.jianshu.io/upload_images/7896890-0beb6acc89d5c927.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +很明显,`1/3/5` 这几个位置的 `1` 是因为上面第一次添加的 `wmyskxz` 而导致的,所以这里就存在 **误判**。幸运的是,布隆过滤器有一个可以预判误判率的公式,比较复杂,感兴趣的朋友可以自行去阅读,比较烧脑.. 只需要记住以下几点就好了: + +- 使用时 **不要让实际元素数量远大于初始化数量**; +- 当实际元素数量超过初始化数量时,应该对布隆过滤器进行 **重建**,重新分配一个 `size` 更大的过滤器,再将所有的历史元素批量 `add` 进行; + +# 三、布隆过滤器的使用 + +**Redis 官方** 提供的布隆过滤器到了 **Redis 4.0** 提供了插件功能之后才正式登场。布隆过滤器作为一个插件加载到 Redis Server 中,给 Redis 提供了强大的布隆去重功能。下面我们来体验一下 Redis 4.0 的布隆过滤器,为了省去繁琐安装过程,我们直接用 +Docker 吧。 + +```bash +> docker pull redislabs/rebloom # 拉取镜像 +> docker run -p6379:6379 redislabs/rebloom # 运行容器 +> redis-cli # 连接容器中的 redis 服务 +``` + +如果上面三条指令执行没有问题,下面就可以体验布隆过滤器了。 + +- 当然,如果你不想使用 Docker,也可以在检查本机 Redis 版本合格之后自行安装插件,可以参考这里: [https://blog.csdn.net/u013030276/article/details/88350641](https://blog.csdn.net/u013030276/article/details/88350641) + +## 布隆过滤器的基本用法 + +布隆过滤器有两个基本指令,`bf.add` 添加元素,`bf.exists` 查询元素是否存在,它的用法和 set 集合的 `sadd` 和 `sismember` 差不多。注意 `bf.add` 只能一次添加一个元素,如果想要一次添加多个,就需要用到 `bf.madd` 指令。同样如果需要一次查询多个元素是否存在,就需要用到 `bf.mexists` 指令。 + +```bash +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 +1) (integer) 1 +2) (integer) 1 +3) (integer) 1 +4) (integer) 0 +``` + +上面使用的布隆过过滤器只是默认参数的布隆过滤器,它在我们第一次 `add` 的时候自动创建。Redis 也提供了可以自定义参数的布隆过滤器,只需要在 `add` 之前使用 `bf.reserve` 指令显式创建就好了。如果对应的 `key` 已经存在,`bf.reserve` 会报错。 + +`bf.reserve` 有三个参数,分别是 `key`、`error_rate` *(错误率)* 和 `initial_size`: + +- **`error_rate` 越低,需要的空间越大**,对于不需要过于精确的场合,设置稍大一些也没有关系,比如上面说的推送系统,只会让一小部分的内容被过滤掉,整体的观看体验还是不会受到很大影响的; +- **`initial_size` 表示预计放入的元素数量**,当实际数量超过这个值时,误判率就会提升,所以需要提前设置一个较大的数值避免超出导致误判率升高; + +如果不适用 `bf.reserve`,默认的 `error_rate` 是 `0.01`,默认的 `initial_size` 是 `100`。 + +# 四、布隆过滤器代码实现 + +## 自己简单模拟实现 + +根据上面的基础理论,我们很容易就可以自己实现一个用于 `简单模拟` 的布隆过滤器数据结构: + +```java +public static class BloomFilter { + + private byte[] data; + + public BloomFilter(int initSize) { + this.data = new byte[initSize * 2]; // 默认创建大小 * 2 的空间 + } + + public void add(int key) { + int location1 = Math.abs(hash1(key) % data.length); + int location2 = Math.abs(hash2(key) % data.length); + int location3 = Math.abs(hash3(key) % data.length); + + data[location1] = data[location2] = data[location3] = 1; + } + + public boolean contains(int key) { + int location1 = Math.abs(hash1(key) % data.length); + int location2 = Math.abs(hash2(key) % data.length); + int location3 = Math.abs(hash3(key) % data.length); + + return data[location1] * data[location2] * data[location3] == 1; + } + + private int hash1(Integer key) { + return key.hashCode(); + } + + private int hash2(Integer key) { + int hashCode = key.hashCode(); + return hashCode ^ (hashCode >>> 3); + } + + private int hash3(Integer key) { + int hashCode = key.hashCode(); + return hashCode ^ (hashCode >>> 16); + } +} +``` + +这里很简单,内部仅维护了一个 `byte` 类型的 `data` 数组,实际上 `byte` 仍然占有一个字节之多,可以优化成 `bit` 来代替,这里也仅仅是用于方便模拟。另外我也创建了三个不同的 `hash` 函数,其实也就是借鉴 `HashMap` 哈希抖动的办法,分别使用自身的 `hash` 和右移不同位数相异或的结果。并且提供了基础的 `add` 和 `contains` 方法。 + +下面我们来简单测试一下这个布隆过滤器的效果如何: + +```java +public static void main(String[] args) { + Random random = new Random(); + // 假设我们的数据有 1 百万 + int size = 1_000_000; + // 用一个数据结构保存一下所有实际存在的值 + LinkedList existentNumbers = new LinkedList<>(); + BloomFilter bloomFilter = new BloomFilter(size); + + for (int i = 0; i < size; i++) { + int randomKey = random.nextInt(); + existentNumbers.add(randomKey); + bloomFilter.add(randomKey); + } + + // 验证已存在的数是否都存在 + AtomicInteger count = new AtomicInteger(); + AtomicInteger finalCount = count; + existentNumbers.forEach(number -> { + if (bloomFilter.contains(number)) { + finalCount.incrementAndGet(); + } + }); + System.out.printf("实际的数据量: %d, 判断存在的数据量: %d \n", size, count.get()); + + // 验证10个不存在的数 + count = new AtomicInteger(); + while (count.get() < 10) { + int key = random.nextInt(); + if (existentNumbers.contains(key)) { + continue; + } else { + // 这里一定是不存在的数 + System.out.println(bloomFilter.contains(key)); + count.incrementAndGet(); + } + } +} +``` + +输出如下: + +```bash +实际的数据量: 1000000, 判断存在的数据量: 1000000 +false +true +false +true +true +true +false +false +true +false +``` + +这就是前面说到的,当布隆过滤器说某个值 **存在时**,这个值 **可能不存在**,当它说某个值 **不存在时**,那就 **肯定不存在**,并且还有一定的误判率... + + +## 手动实现参考 + +当然上面的版本特别 low,不过主体思想是不差的,这里也给出一个好一些的版本用作自己实现测试的参考: + +```java +import java.util.BitSet; + +public class MyBloomFilter { + + /** + * 位数组的大小 + */ + private static final int DEFAULT_SIZE = 2 << 24; + /** + * 通过这个数组可以创建 6 个不同的哈希函数 + */ + private static final int[] SEEDS = new int[]{3, 13, 46, 71, 91, 134}; + + /** + * 位数组。数组中的元素只能是 0 或者 1 + */ + private BitSet bits = new BitSet(DEFAULT_SIZE); + + /** + * 存放包含 hash 函数的类的数组 + */ + private SimpleHash[] func = new SimpleHash[SEEDS.length]; + + /** + * 初始化多个包含 hash 函数的类的数组,每个类中的 hash 函数都不一样 + */ + public MyBloomFilter() { + // 初始化多个不同的 Hash 函数 + for (int i = 0; i < SEEDS.length; i++) { + func[i] = new SimpleHash(DEFAULT_SIZE, SEEDS[i]); + } + } + + /** + * 添加元素到位数组 + */ + public void add(Object value) { + for (SimpleHash f : func) { + bits.set(f.hash(value), true); + } + } + + /** + * 判断指定元素是否存在于位数组 + */ + public boolean contains(Object value) { + boolean ret = true; + for (SimpleHash f : func) { + ret = ret && bits.get(f.hash(value)); + } + return ret; + } + + /** + * 静态内部类。用于 hash 操作! + */ + public static class SimpleHash { + + private int cap; + private int seed; + + public SimpleHash(int cap, int seed) { + this.cap = cap; + this.seed = seed; + } + + /** + * 计算 hash 值 + */ + public int hash(Object value) { + int h; + return (value == null) ? 0 : Math.abs(seed * (cap - 1) & ((h = value.hashCode()) ^ (h >>> 16))); + } + + } +} +``` + +## 使用 Google 开源的 Guava 中自带的布隆过滤器 + +自己实现的目的主要是为了让自己搞懂布隆过滤器的原理,Guava 中布隆过滤器的实现算是比较权威的,所以实际项目中我们不需要手动实现一个布隆过滤器。 + +首先我们需要在项目中引入 Guava 的依赖: + +```xml + + com.google.guava + guava + 28.0-jre + +``` + +实际使用如下: + +我们创建了一个最多存放 最多 1500 个整数的布隆过滤器,并且我们可以容忍误判的概率为百分之(0.01) + +```java +// 创建布隆过滤器对象 +BloomFilter filter = BloomFilter.create( + Funnels.integerFunnel(), + 1500, + 0.01); +// 判断指定元素是否存在 +System.out.println(filter.mightContain(1)); +System.out.println(filter.mightContain(2)); +// 将元素添加进布隆过滤器 +filter.put(1); +filter.put(2); +System.out.println(filter.mightContain(1)); +System.out.println(filter.mightContain(2)); +``` + +在我们的示例中,当 `mightContain()` 方法返回 `true` 时,我们可以 **99%** 确定该元素在过滤器中,当过滤器返回 `false` 时,我们可以 **100%** 确定该元素不存在于过滤器中。 + +Guava 提供的布隆过滤器的实现还是很不错的 *(想要详细了解的可以看一下它的源码实现)*,但是它有一个重大的缺陷就是只能单机使用 *(另外,容量扩展也不容易)*,而现在互联网一般都是分布式的场景。为了解决这个问题,我们就需要用到 **Redis** 中的布隆过滤器了。 + +# 相关阅读 + +1. Redis(1)——5种基本数据结构 - [https://www.wmyskxz.com/2020/02/28/redis-1-5-chong-ji-ben-shu-ju-jie-gou/](https://www.wmyskxz.com/2020/02/28/redis-1-5-chong-ji-ben-shu-ju-jie-gou/) +2. Redis(2)——跳跃表 - [https://www.wmyskxz.com/2020/02/29/redis-2-tiao-yue-biao/](https://www.wmyskxz.com/2020/02/29/redis-2-tiao-yue-biao/) +3. Redis(3)——分布式锁深入探究 - [https://www.wmyskxz.com/2020/03/01/redis-3/](https://www.wmyskxz.com/2020/03/01/redis-3/) +4. Reids(4)——神奇的HyperLoglog解决统计问题 - [https://www.wmyskxz.com/2020/03/02/reids-4-shen-qi-de-hyperloglog-jie-jue-tong-ji-wen-ti/](https://www.wmyskxz.com/2020/03/02/reids-4-shen-qi-de-hyperloglog-jie-jue-tong-ji-wen-ti/)\ + +# 参考资料 + +1. 《Redis 深度历险》 - 钱文品/ 著 - [https://book.douban.com/subject/30386804/](https://book.douban.com/subject/30386804/) +2. 5 分钟搞懂布隆过滤器,亿级数据过滤算法你值得拥有! - [https://juejin.im/post/5de1e37c5188256e8e43adfc](https://juejin.im/post/5de1e37c5188256e8e43adfc) +3. 【原创】不了解布隆过滤器?一文给你整的明明白白! - [https://github.com/Snailclimb/JavaGuide/blob/master/docs/dataStructures-algorithms/data-structure/bloom-filter.md](https://github.com/Snailclimb/JavaGuide/blob/master/docs/dataStructures-algorithms/data-structure/bloom-filter.md) + diff --git a/docs/database/Redis/redis-collection/Redis(6)——GeoHash查找附近的人.md b/docs/database/Redis/redis-collection/Redis(6)——GeoHash查找附近的人.md new file mode 100644 index 00000000..47152bf1 --- /dev/null +++ b/docs/database/Redis/redis-collection/Redis(6)——GeoHash查找附近的人.md @@ -0,0 +1,227 @@ +> 授权转载自: https://github.com/wmyskxz/MoreThanJava#part3-redis + +![](https://upload-images.jianshu.io/upload_images/7896890-8ccb98beab9aff6a.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +像微信 **"附近的人"**,美团 **"附近的餐厅"**,支付宝共享单车 **"附近的车"** 是怎么设计实现的呢? + +# 一、使用数据库实现查找附近的人 + +我们都知道,地球上的任何一个位置都可以使用二维的 **经纬度** 来表示,经度范围 *[-180, 180]*,纬度范围 *[-90, 90]*,纬度正负以赤道为界,北正南负,经度正负以本初子午线 *(英国格林尼治天文台)* 为界,东正西负。比如说,北京人民英雄纪念碑的经纬度坐标就是 *(39.904610, 116.397724)*,都是正数,因为中国位于东北半球。 + +所以,当我们使用数据库存储了所有人的 **经纬度** 信息之后,我们就可以基于当前的坐标节点,来划分出一个矩形的范围,来得知附近的人,如下图: + +![](https://upload-images.jianshu.io/upload_images/7896890-c5e82d3cab59ad22.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +所以,我们很容易写出下列的伪 SQL 语句: + +```sql +SELECT id FROM positions WHERE x0 - r < x < x0 + r AND y0 - r < y < y0 + r +``` + +如果我们还想进一步地知道与每个坐标元素的距离并排序的话,就需要一定的计算。 + +当两个坐标元素的距离不是很远的时候,我们就可以简单利用 **勾股定理** 就能够得出他们之间的 **距离**。不过需要注意的是,地球不是一个标准的球体,**经纬度的密度** 是 **不一样** 的,所以我们使用勾股定理计算平方之后再求和时,需要按照一定的系数 **加权** 再进行求和。当然,如果不准求精确的话,加权也不必了。 + +参考下方 *参考资料 2* 我们能够差不多能写出如下优化之后的 SQL 语句来:*(仅供参考)* + +```sql +SELECT + * +FROM + users_location +WHERE + latitude > '.$lat.' - 1 + AND latitude < '.$lat.' + 1 AND longitude > '.$lon.' - 1 + AND longitude < '.$lon.' + 1 +ORDER BY + ACOS( + SIN( ( '.$lat.' * 3.1415 ) / 180 ) * SIN( ( latitude * 3.1415 ) / 180 ) + COS( ( '.$lat.' * 3.1415 ) / 180 ) * COS( ( latitude * 3.1415 ) / 180 ) * COS( ( '.$lon.' * 3.1415 ) / 180 - ( longitude * 3.1415 ) / 180 ) + ) * 6380 ASC + LIMIT 10 '; +``` + +为了满足高性能的矩形区域算法,数据表也需要把经纬度坐标加上 **双向复合索引 (x, y)**,这样可以满足最大优化查询性能。 + +# 二、GeoHash 算法简述 + +这是业界比较通用的,用于 **地理位置距离排序** 的一个算法,**Redis** 也采用了这样的算法。GeoHash 算法将 **二维的经纬度** 数据映射到 **一维** 的整数,这样所有的元素都将在挂载到一条线上,距离靠近的二维坐标映射到一维后的点之间距离也会很接近。当我们想要计算 **「附近的人时」**,首先将目标位置映射到这条线上,然后在这个一维的线上获取附近的点就行了。 + +它的核心思想就是把整个地球看成是一个 **二维的平面**,然后把这个平面不断地等分成一个一个小的方格,**每一个** 坐标元素都位于其中的 **唯一一个方格** 中,等分之后的 **方格越小**,那么坐标也就 **越精确**,类似下图: + +![](https://upload-images.jianshu.io/upload_images/7896890-6396ae153a485857.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +经过划分的地球,我们需要对其进行编码: + +![](https://upload-images.jianshu.io/upload_images/7896890-573525c3f1179bbc.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +经过这样顺序的编码之后,如果你仔细观察一会儿,你就会发现一些规律: + +- 横着的所有编码中,**第 2 位和第 4 位都是一样的**,例如第一排第一个 `0101` 和第二个 `0111`,他们的第 2 位和第 4 位都是 `1`; +- 竖着的所有编码中,**第 1 位和第 3 位是递增的**,例如第一排第一个 `0101`,如果单独把第 1 位和第 3 位拎出来的话,那就是 `00`,同理看第一排第二个 `0111`,同样的方法第 1 位和第 3 位拎出来是 `01`,刚好是 `00` 递增一个; + +通过这样的规律我们就把每一个小方块儿进行了一定顺序的编码,这样做的 **好处** 是显而易见的:每一个元素坐标既能够被 **唯一标识** 在这张被编码的地图上,也不至于 **暴露特别的具体的位置**,因为区域是共享的,我可以告诉你我就在公园附近,但是在具体的哪个地方你就无从得知了。 + +总之,我们通过上面的思想,能够把任意坐标变成一串二进制的编码了,类似于 `11010010110001000100` 这样 *(注意经度和维度是交替出现的哦..)*,通过这个整数我们就可以还原出元素的坐标,整数越长,还原出来的坐标值的损失程序就越小。对于 **"附近的人"** 这个功能来说,损失的一点经度可以忽略不计。 + +最后就是一个 `Base32` *(0~9, a~z, 去掉 a/i/l/o 四个字母)* 的编码操作,让它变成一个字符串,例如上面那一串儿就变成了 `wx4g0ec1`。 + +在 **Redis** 中,经纬度使用 `52` 位的整数进行编码,放进了 zset 里面,zset 的 `value` 是元素的 `key`,`score` 是 **GeoHash** 的 `52` 位整数值。zset 的 `score` 虽然是浮点数,但是对于 `52` 位的整数值来说,它可以无损存储。 + +# 三、在 Redis 中使用 Geo + +> 下方内容引自 *参考资料 1 - 《Redis 深度历险》* + +在使用 **Redis** 进行 **Geo 查询** 时,我们要时刻想到它的内部结构实际上只是一个 **zset(skiplist)**。通过 zset 的 `score` 排序就可以得到坐标附近的其他元素 *(实际情况要复杂一些,不过这样理解足够了)*,通过将 `score` 还原成坐标值就可以得到元素的原始坐标了。 + +Redis 提供的 Geo 指令只有 6 个,很容易就可以掌握。 + +## 增加 + +`geoadd` 指令携带集合名称以及多个经纬度名称三元组,注意这里可以加入多个三元组。 + +```bash +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 的删除指令,但是我们可以通过 zset 相关的指令来操作 Geo 数据,所以元素删除可以使用 `zrem` 指令即可。 + +## 距离 + +`geodist` 指令可以用来计算两个元素之间的距离,携带集合名称、2 个名称和距离单位。 + +```bash +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` 指令可以获取集合中任意元素的经纬度坐标,可以一次获取多个。 + +```bash +127.0.0.1:6379> geopos company juejin +1) 1) "116.48104995489120483" + 2) "39.99679348858259686" +127.0.0.1:6379> geopos company ireader +1) 1) "116.5142020583152771" + 2) "39.90540918662494363" +127.0.0.1:6379> geopos company juejin ireader +1) 1) "116.48104995489120483" + 2) "39.99679348858259686" +2) 1) "116.5142020583152771" + 2) "39.90540918662494363" +``` + +我们观察到获取的经纬度坐标和 `geoadd` 进去的坐标有轻微的误差,原因是 **Geohash** 对二维坐标进行的一维映射是有损的,通过映射再还原回来的值会出现较小的差别。对于 **「附近的人」** 这种功能来说,这点误差根本不是事。 + +## 获取元素的 hash 值 + +`geohash` 可以获取元素的经纬度编码字符串,上面已经提到,它是 `base32` 编码。 你可以使用这个编码值去 `http://geohash.org/${hash}` 中进行直接定位,它是 **Geohash** 的标准编码值。 + +```bash +127.0.0.1:6379> geohash company ireader +1) "wx4g52e1ce0" +127.0.0.1:6379> geohash company juejin +1) "wx4gd94yjn0" +``` + +让我们打开地址 `http://geohash.org/wx4g52e1ce0`,观察地图指向的位置是否正确: + +![](https://upload-images.jianshu.io/upload_images/7896890-b5d4215d6397729c.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +很好,就是这个位置,非常准确。 + +## 附近的公司 +`georadiusbymember` 指令是最为关键的指令,它可以用来查询指定元素附近的其它元素,它的参数非常复杂。 + +```bash +# 范围 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) 1) "ireader" + 2) "0.0000" + 3) (integer) 4069886008361398 + 4) 1) "116.5142020583152771" + 2) "39.90540918662494363" +2) 1) "juejin" + 2) "10.5501" + 3) (integer) 4069887154388167 + 4) 1) "116.48104995489120483" + 2) "39.99679348858259686" +3) 1) "meituan" + 2) "11.5748" + 3) (integer) 4069887179083478 + 4) 1) "116.48903220891952515" + 2) "40.00766997707732031" +``` + +除了 `georadiusbymember` 指令根据元素查询附近的元素,**Redis** 还提供了根据坐标值来查询附近的元素,这个指令更加有用,它可以根据用户的定位来计算「附近的车」,「附近的餐馆」等。它的参数和 `georadiusbymember` 基本一致,除了将目标元素改成经纬度坐标值: + +```bash +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 集合的大小。 + +# 相关阅读 + +1. Redis(1)——5种基本数据结构 - [https://www.wmyskxz.com/2020/02/28/redis-1-5-chong-ji-ben-shu-ju-jie-gou/](https://www.wmyskxz.com/2020/02/28/redis-1-5-chong-ji-ben-shu-ju-jie-gou/) +2. Redis(2)——跳跃表 - [https://www.wmyskxz.com/2020/02/29/redis-2-tiao-yue-biao/](https://www.wmyskxz.com/2020/02/29/redis-2-tiao-yue-biao/) +3. Redis(3)——分布式锁深入探究 - [https://www.wmyskxz.com/2020/03/01/redis-3/](https://www.wmyskxz.com/2020/03/01/redis-3/) +4. Reids(4)——神奇的HyperLoglog解决统计问题 - [https://www.wmyskxz.com/2020/03/02/reids-4-shen-qi-de-hyperloglog-jie-jue-tong-ji-wen-ti/](https://www.wmyskxz.com/2020/03/02/reids-4-shen-qi-de-hyperloglog-jie-jue-tong-ji-wen-ti/) +5. Redis(5)——亿级数据过滤和布隆过滤器 - [https://www.wmyskxz.com/2020/03/11/redis-5-yi-ji-shu-ju-guo-lu-he-bu-long-guo-lu-qi/](https://www.wmyskxz.com/2020/03/11/redis-5-yi-ji-shu-ju-guo-lu-he-bu-long-guo-lu-qi/) + +# 参考资料 + +1. 《Redis 深度历险》 - 钱文品/ 著 - [https://book.douban.com/subject/30386804/](https://book.douban.com/subject/30386804/) +2. mysql经纬度查询并且计算2KM范围内附近用户的sql查询性能优化实例教程 - [https://www.cnblogs.com/mgbert/p/4146538.html](https://www.cnblogs.com/mgbert/p/4146538.html) +3. Geohash算法原理及实现 - [https://www.jianshu.com/p/2fd0cf12e5ba](https://www.jianshu.com/p/2fd0cf12e5ba) +4. GeoHash算法学习讲解、解析及原理分析 - [https://zhuanlan.zhihu.com/p/35940647](https://zhuanlan.zhihu.com/p/35940647) + +> - 本文已收录至我的 Github 程序员成长系列 **【More Than Java】,学习,不止 Code,欢迎 star:[https://github.com/wmyskxz/MoreThanJava](https://github.com/wmyskxz/MoreThanJava)** +> - **个人公众号** :wmyskxz,**个人独立域名博客**:wmyskxz.com,坚持原创输出,下方扫码关注,2020,与您共同成长! + +![](https://upload-images.jianshu.io/upload_images/7896890-fca34cfd601e7449.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +非常感谢各位人才能 **看到这里**,如果觉得本篇文章写得不错,觉得 **「我没有三颗心脏」有点东西** 的话,**求点赞,求关注,求分享,求留言!** + +创作不易,各位的支持和认可,就是我创作的最大动力,我们下篇文章见! \ No newline at end of file diff --git a/docs/database/Redis/redis-collection/Redis(7)——持久化.md b/docs/database/Redis/redis-collection/Redis(7)——持久化.md new file mode 100644 index 00000000..a2eb1d61 --- /dev/null +++ b/docs/database/Redis/redis-collection/Redis(7)——持久化.md @@ -0,0 +1,215 @@ +> 授权转载自: https://github.com/wmyskxz/MoreThanJava#part3-redis + +![](https://upload-images.jianshu.io/upload_images/7896890-7879862264eeea7b.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +# 一、持久化简介 + +**Redis** 的数据 **全部存储** 在 **内存** 中,如果 **突然宕机**,数据就会全部丢失,因此必须有一套机制来保证 Redis 的数据不会因为故障而丢失,这种机制就是 Redis 的 **持久化机制**,它会将内存中的数据库状态 **保存到磁盘** 中。 + +## 持久化发生了什么 | 从内存到磁盘 + +我们来稍微考虑一下 **Redis** 作为一个 **"内存数据库"** 要做的关于持久化的事情。通常来说,从客户端发起请求开始,到服务器真实地写入磁盘,需要发生如下几件事情: + +![](https://upload-images.jianshu.io/upload_images/7896890-5c209bc08da11abb.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +**详细版** 的文字描述大概就是下面这样: + +1. 客户端向数据库 **发送写命令** *(数据在客户端的内存中)* +2. 数据库 **接收** 到客户端的 **写请求** *(数据在服务器的内存中)* +3. 数据库 **调用系统 API** 将数据写入磁盘 *(数据在内核缓冲区中)* +4. 操作系统将 **写缓冲区** 传输到 **磁盘控控制器** *(数据在磁盘缓存中)* +5. 操作系统的磁盘控制器将数据 **写入实际的物理媒介** 中 *(数据在磁盘中)* + +**注意:** 上面的过程其实是 **极度精简** 的,在实际的操作系统中,**缓存** 和 **缓冲区** 会比这 **多得多**... + +## 如何尽可能保证持久化的安全 + +如果我们故障仅仅涉及到 **软件层面** *(该进程被管理员终止或程序崩溃)* 并且没有接触到内核,那么在 *上述步骤 3* 成功返回之后,我们就认为成功了。即使进程崩溃,操作系统仍然会帮助我们把数据正确地写入磁盘。 + +如果我们考虑 **停电/ 火灾** 等 **更具灾难性** 的事情,那么只有在完成了第 **5** 步之后,才是安全的。 + +![机房”火了“](https://upload-images.jianshu.io/upload_images/7896890-de083f477fe1bce4.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +所以我们可以总结得出数据安全最重要的阶段是:**步骤三、四、五**,即: + +- 数据库软件调用写操作将用户空间的缓冲区转移到内核缓冲区的频率是多少? +- 内核多久从缓冲区取数据刷新到磁盘控制器? +- 磁盘控制器多久把数据写入物理媒介一次? +- **注意:** 如果真的发生灾难性的事件,我们可以从上图的过程中看到,任何一步都可能被意外打断丢失,所以只能 **尽可能地保证** 数据的安全,这对于所有数据库来说都是一样的。 + +我们从 **第三步** 开始。Linux 系统提供了清晰、易用的用于操作文件的 `POSIX file API`,`20` 多年过去,仍然还有很多人对于这一套 `API` 的设计津津乐道,我想其中一个原因就是因为你光从 `API` 的命名就能够很清晰地知道这一套 API 的用途: + +```c +int open(const char *path, int oflag, .../*,mode_t mode */); +int close (int filedes);int remove( const char *fname ); +ssize_t write(int fildes, const void *buf, size_t nbyte); +ssize_t read(int fildes, void *buf, size_t nbyte); +``` + +- 参考自:API 设计最佳实践的思考 - [https://www.cnblogs.com/yuanjiangw/p/10846560.html](https://www.cnblogs.com/yuanjiangw/p/10846560.html) + +所以,我们有很好的可用的 `API` 来完成 **第三步**,但是对于成功返回之前,我们对系统调用花费的时间没有太多的控制权。 + +然后我们来说说 **第四步**。我们知道,除了早期对电脑特别了解那帮人 *(操作系统就这帮人搞的)*,实际的物理硬件都不是我们能够 **直接操作** 的,都是通过 **操作系统调用** 来达到目的的。为了防止过慢的 I/O 操作拖慢整个系统的运行,操作系统层面做了很多的努力,譬如说 **上述第四步** 提到的 **写缓冲区**,并不是所有的写操作都会被立即写入磁盘,而是要先经过一个缓冲区,默认情况下,Linux 将在 **30 秒** 后实际提交写入。 + +![image](https://upload-images.jianshu.io/upload_images/7896890-c08b7572ef02d67b.jpg?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +但是很明显,**30 秒** 并不是 Redis 能够承受的,这意味着,如果发生故障,那么最近 30 秒内写入的所有数据都可能会丢失。幸好 `PROSIX API` 提供了另一个解决方案:`fsync`,该命令会 **强制** 内核将 **缓冲区** 写入 **磁盘**,但这是一个非常消耗性能的操作,每次调用都会 **阻塞等待** 直到设备报告 IO 完成,所以一般在生产环境的服务器中,**Redis** 通常是每隔 1s 左右执行一次 `fsync` 操作。 + +到目前为止,我们了解到了如何控制 `第三步` 和 `第四步`,但是对于 **第五步**,我们 **完全无法控制**。也许一些内核实现将试图告诉驱动实际提交物理介质上的数据,或者控制器可能会为了提高速度而重新排序写操作,不会尽快将数据真正写到磁盘上,而是会等待几个多毫秒。这完全是我们无法控制的。 + + +# 二、Redis 中的两种持久化方式 + +## 方式一:快照 + +![image](https://upload-images.jianshu.io/upload_images/7896890-9a4d234c53120b33.gif?imageMogr2/auto-orient/strip) + +**Redis 快照** 是最简单的 Redis 持久性模式。当满足特定条件时,它将生成数据集的时间点快照,例如,如果先前的快照是在2分钟前创建的,并且现在已经至少有 *100* 次新写入,则将创建一个新的快照。此条件可以由用户配置 Redis 实例来控制,也可以在运行时修改而无需重新启动服务器。快照作为包含整个数据集的单个 `.rdb` 文件生成。 + +但我们知道,Redis 是一个 **单线程** 的程序,这意味着,我们不仅仅要响应用户的请求,还需要进行内存快照。而后者要求 Redis 必须进行 IO 操作,这会严重拖累服务器的性能。 + +还有一个重要的问题是,我们在 **持久化的同时**,**内存数据结构** 还可能在 **变化**,比如一个大型的 hash 字典正在持久化,结果一个请求过来把它删除了,可是这才刚持久化结束,咋办? + +![](https://upload-images.jianshu.io/upload_images/7896890-fbfcbd606e95f105.jpg?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +### 使用系统多进程 COW(Copy On Write) 机制 | fork 函数 + +操作系统多进程 **COW(Copy On Write) 机制** 拯救了我们。**Redis** 在持久化时会调用 `glibc` 的函数 `fork` 产生一个子进程,简单理解也就是基于当前进程 **复制** 了一个进程,主进程和子进程会共享内存里面的代码块和数据段: + +![](https://upload-images.jianshu.io/upload_images/7896890-bc264b6a9f0c3404.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +这里多说一点,**为什么 fork 成功调用后会有两个返回值呢?** 因为子进程在复制时复制了父进程的堆栈段,所以两个进程都停留在了 `fork` 函数中 *(都在同一个地方往下继续"同时"执行)*,等待返回,所以 **一次在父进程中返回子进程的 pid,另一次在子进程中返回零,系统资源不够时返回负数**。 *(伪代码如下)* + +```python +pid = os.fork() +if pid > 0: + handle_client_request() # 父进程继续处理客户端请求 +if pid == 0: + handle_snapshot_write() # 子进程处理快照写磁盘 +if pid < 0: + # fork error +``` + +所以 **快照持久化** 可以完全交给 **子进程** 来处理,**父进程** 则继续 **处理客户端请求**。**子进程** 做数据持久化,它 **不会修改现有的内存数据结构**,它只是对数据结构进行遍历读取,然后序列化写到磁盘中。但是 **父进程** 不一样,它必须持续服务客户端请求,然后对 **内存数据结构进行不间断的修改**。 + +这个时候就会使用操作系统的 COW 机制来进行 **数据段页面** 的分离。数据段是由很多操作系统的页面组合而成,当父进程对其中一个页面的数据进行修改时,会将被共享的页面复 +制一份分离出来,然后 **对这个复制的页面进行修改**。这时 **子进程** 相应的页面是 **没有变化的**,还是进程产生时那一瞬间的数据。 + +子进程因为数据没有变化,它能看到的内存里的数据在进程产生的一瞬间就凝固了,再也不会改变,这也是为什么 **Redis** 的持久化 **叫「快照」的原因**。接下来子进程就可以非常安心的遍历数据了进行序列化写磁盘了。 + +## 方式二:AOF + +![](https://upload-images.jianshu.io/upload_images/7896890-e4e08ebef2cf0144.gif?imageMogr2/auto-orient/strip) + +**快照不是很持久**。如果运行 Redis 的计算机停止运行,电源线出现故障或者您 `kill -9` 的实例意外发生,则写入 Redis 的最新数据将丢失。尽管这对于某些应用程序可能不是什么大问题,但有些使用案例具有充分的耐用性,在这些情况下,快照并不是可行的选择。 + +**AOF(Append Only File - 仅追加文件)** 它的工作方式非常简单:每次执行 **修改内存** 中数据集的写操作时,都会 **记录** 该操作。假设 AOF 日志记录了自 Redis 实例创建以来 **所有的修改性指令序列**,那么就可以通过对一个空的 Redis 实例 **顺序执行所有的指令**,也就是 **「重放」**,来恢复 Redis 当前实例的内存数据结构的状态。 + +为了展示 AOF 在实际中的工作方式,我们来做一个简单的实验: + +```bash +./redis-server --appendonly yes # 设置一个新实例为 AOF 模式 +``` + +然后我们执行一些写操作: + +```bash +redis 127.0.0.1:6379> set key1 Hello +OK +redis 127.0.0.1:6379> append key1 " World!" +(integer) 12 +redis 127.0.0.1:6379> del key1 +(integer) 1 +redis 127.0.0.1:6379> del non_existing_key +(integer) 0 +``` + +前三个操作实际上修改了数据集,第四个操作没有修改,因为没有指定名称的键。这是 AOF 日志保存的文本: + +```bash +$ cat appendonly.aof +*2 +$6 +SELECT +$1 +0 +*3 +$3 +set +$4 +key1 +$5 +Hello +*3 +$6 +append +$4 +key1 +$7 + World! +*2 +$3 +del +$4 +key1 +``` + +如您所见,最后的那一条 `DEL` 指令不见了,因为它没有对数据集进行任何修改。 + +就是这么简单。当 Redis 收到客户端修改指令后,会先进行参数校验、逻辑处理,如果没问题,就 **立即** 将该指令文本 **存储** 到 AOF 日志中,也就是说,**先执行指令再将日志存盘**。这一点不同于 `MySQL`、`LevelDB`、`HBase` 等存储引擎,如果我们先存储日志再做逻辑处理,这样就可以保证即使宕机了,我们仍然可以通过之前保存的日志恢复到之前的数据状态,但是 **Redis 为什么没有这么做呢?** + +> Emmm... 没找到特别满意的答案,引用一条来自知乎上的回答吧: +> - **@缘于专注** - 我甚至觉得没有什么特别的原因。仅仅是因为,由于AOF文件会比较大,为了避免写入无效指令(错误指令),必须先做指令检查?如何检查,只能先执行了。因为语法级别检查并不能保证指令的有效性,比如删除一个不存在的key。而MySQL这种是因为它本身就维护了所有的表的信息,所以可以语法检查后过滤掉大部分无效指令直接记录日志,然后再执行。 +> - 更多讨论参见:[为什么Redis先执行指令,再记录AOF日志,而不是像其它存储引擎一样反过来呢? - https://www.zhihu.com/question/342427472](https://www.zhihu.com/question/342427472) + +### AOF 重写 + +![](https://upload-images.jianshu.io/upload_images/7896890-c21e2a37892ee989.jpg?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +**Redis** 在长期运行的过程中,AOF 的日志会越变越长。如果实例宕机重启,重放整个 AOF 日志会非常耗时,导致长时间 Redis 无法对外提供服务。所以需要对 **AOF 日志 "瘦身"**。 + +**Redis** 提供了 `bgrewriteaof` 指令用于对 AOF 日志进行瘦身。其 **原理** 就是 **开辟一个子进程** 对内存进行 **遍历** 转换成一系列 Redis 的操作指令,**序列化到一个新的 AOF 日志文件** 中。序列化完毕后再将操作期间发生的 **增量 AOF 日志** 追加到这个新的 AOF 日志文件中,追加完毕后就立即替代旧的 AOF 日志文件了,瘦身工作就完成了。 + +### fsync + +![](https://upload-images.jianshu.io/upload_images/7896890-384a546b5bf6b86d.gif?imageMogr2/auto-orient/strip) + +AOF 日志是以文件的形式存在的,当程序对 AOF 日志文件进行写操作时,实际上是将内容写到了内核为文件描述符分配的一个内存缓存中,然后内核会异步将脏数据刷回到磁盘的。 + +就像我们 *上方第四步* 描述的那样,我们需要借助 `glibc` 提供的 `fsync(int fd)` 函数来讲指定的文件内容 **强制从内核缓存刷到磁盘**。但 **"强制开车"** 仍然是一个很消耗资源的一个过程,需要 **"节制"**!通常来说,生产环境的服务器,Redis 每隔 1s 左右执行一次 `fsync` 操作就可以了。 + +Redis 同样也提供了另外两种策略,一个是 **永不 `fsync`**,来让操作系统来决定合适同步磁盘,很不安全,另一个是 **来一个指令就 `fsync` 一次**,非常慢。但是在生产环境基本不会使用,了解一下即可。 + +## Redis 4.0 混合持久化 + +![](https://upload-images.jianshu.io/upload_images/7896890-7de9f7706be6216c.gif?imageMogr2/auto-orient/strip) + +重启 Redis 时,我们很少使用 `rdb` 来恢复内存状态,因为会丢失大量数据。我们通常使用 AOF 日志重放,但是重放 AOF 日志性能相对 `rdb` 来说要慢很多,这样在 Redis 实例很大的情况下,启动需要花费很长的时间。 + +**Redis 4.0** 为了解决这个问题,带来了一个新的持久化选项——**混合持久化**。将 `rdb` 文件的内容和增量的 AOF 日志文件存在一起。这里的 AOF 日志不再是全量的日志,而是 **自持久化开始到持久化结束** 的这段时间发生的增量 AOF 日志,通常这部分 AOF 日志很小: + +![](https://upload-images.jianshu.io/upload_images/7896890-2f7887f84eaa34d9.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +于是在 Redis 重启的时候,可以先加载 `rdb` 的内容,然后再重放增量 AOF 日志就可以完全替代之前的 AOF 全量文件重放,重启效率因此大幅得到提升。 + +# 相关阅读 + +1. Redis(1)——5种基本数据结构 - [https://www.wmyskxz.com/2020/02/28/redis-1-5-chong-ji-ben-shu-ju-jie-gou/](https://www.wmyskxz.com/2020/02/28/redis-1-5-chong-ji-ben-shu-ju-jie-gou/) +2. Redis(2)——跳跃表 - [https://www.wmyskxz.com/2020/02/29/redis-2-tiao-yue-biao/](https://www.wmyskxz.com/2020/02/29/redis-2-tiao-yue-biao/) +3. Redis(3)——分布式锁深入探究 - [https://www.wmyskxz.com/2020/03/01/redis-3/](https://www.wmyskxz.com/2020/03/01/redis-3/) +4. Reids(4)——神奇的HyperLoglog解决统计问题 - [https://www.wmyskxz.com/2020/03/02/reids-4-shen-qi-de-hyperloglog-jie-jue-tong-ji-wen-ti/](https://www.wmyskxz.com/2020/03/02/reids-4-shen-qi-de-hyperloglog-jie-jue-tong-ji-wen-ti/) +5. Redis(5)——亿级数据过滤和布隆过滤器 - [https://www.wmyskxz.com/2020/03/11/redis-5-yi-ji-shu-ju-guo-lu-he-bu-long-guo-lu-qi/](https://www.wmyskxz.com/2020/03/11/redis-5-yi-ji-shu-ju-guo-lu-he-bu-long-guo-lu-qi/) +6. Redis(6)——GeoHash查找附近的人[https://www.wmyskxz.com/2020/03/12/redis-6-geohash-cha-zhao-fu-jin-de-ren/](https://www.wmyskxz.com/2020/03/12/redis-6-geohash-cha-zhao-fu-jin-de-ren/) + +# 扩展阅读 + +1. Redis 数据备份与恢复 | 菜鸟教程 - [https://www.runoob.com/redis/redis-backup.html](https://www.runoob.com/redis/redis-backup.html) +2. Java Fork/Join 框架 - [https://www.cnblogs.com/cjsblog/p/9078341.html](https://www.cnblogs.com/cjsblog/p/9078341.html) + +# 参考资料 + +1. Redis persistence demystified | antirez weblog (作者博客) - [http://oldblog.antirez.com/post/redis-persistence-demystified.html](http://oldblog.antirez.com/post/redis-persistence-demystified.html) +2. 操作系统 — fork()函数的使用与底层原理 - [https://blog.csdn.net/Dawn_sf/article/details/78709839](https://blog.csdn.net/Dawn_sf/article/details/78709839) +3. 磁盘和内存读写简单原理 - [https://blog.csdn.net/zhanghongzheng3213/article/details/54141202](https://blog.csdn.net/zhanghongzheng3213/article/details/54141202) + diff --git a/docs/database/Redis/redis-collection/Redis(8)——发布订阅与Stream.md b/docs/database/Redis/redis-collection/Redis(8)——发布订阅与Stream.md new file mode 100644 index 00000000..55dad166 --- /dev/null +++ b/docs/database/Redis/redis-collection/Redis(8)——发布订阅与Stream.md @@ -0,0 +1,531 @@ +> 授权转载自: https://github.com/wmyskxz/MoreThanJava#part3-redis + +![](https://upload-images.jianshu.io/upload_images/7896890-31406a824536c54a.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +# 一、Redis 中的发布/订阅功能 + +**发布/ 订阅系统** 是 Web 系统中比较常用的一个功能。简单点说就是 **发布者发布消息,订阅者接受消息**,这有点类似于我们的报纸/ 杂志社之类的: *(借用前边的一张图)* + +![](https://upload-images.jianshu.io/upload_images/7896890-13aa5cb2668368fe.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +- 图片引用自:「消息队列」看过来! - [https://www.wmyskxz.com/2019/07/16/xiao-xi-dui-lie-kan-guo-lai/](https://www.wmyskxz.com/2019/07/16/xiao-xi-dui-lie-kan-guo-lai/) + +从我们 *前面(下方相关阅读)* 学习的知识来看,我们虽然可以使用一个 `list` 列表结构结合 `lpush` 和 `rpop` 来实现消息队列的功能,但是似乎很难实现实现 **消息多播** 的功能: + +![](https://upload-images.jianshu.io/upload_images/7896890-526a5b110a7c4ea2.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +为了支持消息多播,**Redis** 不能再依赖于那 5 种基础的数据结构了,它单独使用了一个模块来支持消息多播,这个模块就是 **PubSub**,也就是 **PublisherSubscriber** *(发布者/ 订阅者模式)*。 + +## PubSub 简介 + +我们从 *上面的图* 中可以看到,基于 `list` 结构的消息队列,是一种 `Publisher` 与 `Consumer` 点对点的强关联关系,**Redis** 为了消除这样的强关联,引入了另一种概念:**频道** *(channel)*: + +![](https://upload-images.jianshu.io/upload_images/7896890-cc3bb012eeca9fca.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +当 `Publisher` 往 `channel` 中发布消息时,关注了指定 `channel` 的 `Consumer` 就能够同时受到消息。但这里的 **问题** 是,消费者订阅一个频道是必须 **明确指定频道名称** 的,这意味着,如果我们想要 **订阅多个** 频道,那么就必须 **显式地关注多个** 名称。 + +为了简化订阅的繁琐操作,**Redis** 提供了 **模式订阅** 的功能 **Pattern Subscribe**,这样就可以 **一次性关注多个频道** 了,即使生产者新增了同模式的频道,消费者也可以立即受到消息: + +![](https://upload-images.jianshu.io/upload_images/7896890-18ac258e4e9387da.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +例如上图中,**所有** 位于图片下方的 **`Consumer` 都能够受到消息**。 + +`Publisher` 往 `wmyskxz.chat` 这个 `channel` 中发送了一条消息,不仅仅关注了这个频道的 `Consumer 1` 和 `Consumer 2` 能够受到消息,图片中的两个 `channel` 都和模式 `wmyskxz.*` 匹配,所以 **Redis** 此时会同样发送消息给订阅了 `wmyskxz.*` 这个模式的 `Consumer 3` 和关注了在这个模式下的另一个频道 `wmyskxz.log` 下的 `Consumer 4` 和 `Consumer 5`。 + +另一方面,如果接收消息的频道是 `wmyskxz.chat`,那么 `Consumer 3` 也会受到消息。 + +## 快速体验 + +在 **Redis** 中,**PubSub** 模块的使用非常简单,常用的命令也就下面这么几条: + +```bash +# 订阅频道: +SUBSCRIBE channel [channel ....] # 订阅给定的一个或多个频道的信息 +PSUBSCRIBE pattern [pattern ....] # 订阅一个或多个符合给定模式的频道 +# 发布频道: +PUBLISH channel message # 将消息发送到指定的频道 +# 退订频道: +UNSUBSCRIBE [channel [channel ....]] # 退订指定的频道 +PUNSUBSCRIBE [pattern [pattern ....]] #退订所有给定模式的频道 +``` + +我们可以在本地快速地来体验一下 **PubSub**: + +![](https://upload-images.jianshu.io/upload_images/7896890-518e0d1e93135775.gif?imageMogr2/auto-orient/strip) + +具体步骤如下: + +1. 开启本地 Redis 服务,新建两个控制台窗口; +2. 在其中一个窗口输入 `SUBSCRIBE wmyskxz.chat` 关注 `wmyskxz.chat` 频道,让这个窗口成为 **消费者**。 +3. 在另一个窗口输入 `PUBLISH wmyskxz.chat 'message'` 往这个频道发送消息,这个时候就会看到 **另一个窗口实时地出现** 了发送的测试消息。 + +## 实现原理 + +可以看到,我们通过很简单的两条命令,几乎就可以简单使用这样的一个 **发布/ 订阅系统** 了,但是具体是怎么样实现的呢? + +**每个 Redis 服务器进程维持着一个标识服务器状态** 的 `redis.h/redisServer` 结构,其中就 **保存着有订阅的频道** 以及 **订阅模式** 的信息: + +```c +struct redisServer { + // ... + dict *pubsub_channels; // 订阅频道 + list *pubsub_patterns; // 订阅模式 + // ... +}; +``` + +### 订阅频道原理 + +当客户端订阅某一个频道之后,Redis 就会往 `pubsub_channels` 这个字典中新添加一条数据,实际上这个 `dict` 字典维护的是一张链表,比如,下图展示的 `pubsub_channels` 示例中,`client 1`、`client 2` 就订阅了 `channel 1`,而其他频道也分别被其他客户端订阅: + +![](https://upload-images.jianshu.io/upload_images/7896890-218fc15f7c368eee.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +#### SUBSCRIBE 命令 + +`SUBSCRIBE` 命令的行为可以用下列的伪代码表示: + +```python +def SUBSCRIBE(client, channels): + # 遍历所有输入频道 + for channel in channels: + # 将客户端添加到链表的末尾 + redisServer.pubsub_channels[channel].append(client) +``` + +通过 `pubsub_channels` 字典,程序只要检查某个频道是否为字典的键,就可以知道该频道是否正在被客户端订阅;只要取出某个键的值,就可以得到所有订阅该频道的客户端的信息。 + +#### PUBLISH 命令 + +了解 `SUBSCRIBE`,那么 `PUBLISH` 命令的实现也变得十分简单了,只需要通过上述字典定位到具体的客户端,再把消息发送给它们就好了:*(伪代码实现如下)* + +```python +def PUBLISH(channel, message): + # 遍历所有订阅频道 channel 的客户端 + for client in server.pubsub_channels[channel]: + # 将信息发送给它们 + send_message(client, message) +``` + +#### UNSUBSCRIBE 命令 + +使用 `UNSUBSCRIBE` 命令可以退订指定的频道,这个命令执行的是订阅的反操作:它从 `pubsub_channels` 字典的给定频道(键)中,删除关于当前客户端的信息,这样被退订频道的信息就不会再发送给这个客户端。 + +### 订阅模式原理 + +![](https://upload-images.jianshu.io/upload_images/7896890-18ac258e4e9387da.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +正如我们上面说到了,当发送一条消息到 `wmyskxz.chat` 这个频道时,Redis 不仅仅会发送到当前的频道,还会发送到匹配于当前模式的所有频道,实际上,`pubsub_patterns` 背后还维护了一个 `redis.h/pubsubPattern` 结构: + +```c +typedef struct pubsubPattern { + redisClient *client; // 订阅模式的客户端 + robj *pattern; // 订阅的模式 +} pubsubPattern; +``` + +每当调用 `PSUBSCRIBE` 命令订阅一个模式时,程序就创建一个包含客户端信息和被订阅模式的 `pubsubPattern` 结构,并将该结构添加到 `redisServer.pubsub_patterns` 链表中。 + +我们来看一个 `pusub_patterns` 链表的示例: + +![](https://upload-images.jianshu.io/upload_images/7896890-d0d3b1849fdb6162.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +这个时候客户端 `client 3` 执行 `PSUBSCRIBE wmyskxz.java.*`,那么 `pubsub_patterns` 链表就会被更新成这样: + +![](https://upload-images.jianshu.io/upload_images/7896890-edbf11995590de50.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +通过遍历整个 `pubsub_patterns` 链表,程序可以检查所有正在被订阅的模式,以及订阅这些模式的客户端。 + +#### PUBLISH 命令 + +上面给出的伪代码并没有 **完整描述** `PUBLISH` 命令的行为,因为 `PUBLISH` 除了将 `message` 发送到 **所有订阅 `channel` 的客户端** 之外,它还会将 `channel` 和 `pubsub_patterns` 中的 **模式** 进行对比,如果 `channel` 和某个模式匹配的话,那么也将 `message` 发送到 **订阅那个模式的客户端**。 + +完整描述 `PUBLISH` 功能的伪代码定于如下: + +```python +def PUBLISH(channel, message): + # 遍历所有订阅频道 channel 的客户端 + for client in server.pubsub_channels[channel]: + # 将信息发送给它们 + send_message(client, message) + # 取出所有模式,以及订阅模式的客户端 + for pattern, client in server.pubsub_patterns: + # 如果 channel 和模式匹配 + if match(channel, pattern): + # 那么也将信息发给订阅这个模式的客户端 + send_message(client, message) +``` + +#### PUNSUBSCRIBE 命令 + +使用 `PUNSUBSCRIBE` 命令可以退订指定的模式,这个命令执行的是订阅模式的反操作:序会删除 `redisServer.pubsub_patterns` 链表中,所有和被退订模式相关联的 `pubsubPattern` 结构,这样客户端就不会再收到和模式相匹配的频道发来的信息。 + +## PubSub 的缺点 + +尽管 **Redis** 实现了 **PubSub** 模式来达到了 **多播消息队列** 的目的,但在实际的消息队列的领域,几乎 **找不到特别合适的场景**,因为它的缺点十分明显: + +- **没有 Ack 机制,也不保证数据的连续:** PubSub 的生产者传递过来一个消息,Redis 会直接找到相应的消费者传递过去。如果没有一个消费者,那么消息会被直接丢弃。如果开始有三个消费者,其中一个突然挂掉了,过了一会儿等它再重连时,那么重连期间的消息对于这个消费者来说就彻底丢失了。 +- **不持久化消息:** 如果 Redis 停机重启,PubSub 的消息是不会持久化的,毕竟 Redis 宕机就相当于一个消费者都没有,所有的消息都会被直接丢弃。 + + +基于上述缺点,Redis 的作者甚至单独开启了一个 Disque 的项目来专门用来做多播消息队列,不过该项目目前好像都没有成熟。不过后来在 2018 年 6 月,**Redis 5.0** 新增了 `Stream` 数据结构,这个功能给 Redis 带来了 **持久化消息队列**,从此 PubSub 作为消息队列的功能可以说是就消失了.. + +![image](https://upload-images.jianshu.io/upload_images/7896890-3a144fda1a0dafcb.gif?imageMogr2/auto-orient/strip) + +# 二、更为强大的 Stream | 持久化的发布/订阅系统 + +**Redis Stream** 从概念上来说,就像是一个 **仅追加内容** 的 **消息链表**,把所有加入的消息都一个一个串起来,每个消息都有一个唯一的 ID 和内容,这很简单,让它复杂的是从 Kafka 借鉴的另一种概念:**消费者组(Consumer Group)** *(思路一致,实现不同)*: + +![](https://upload-images.jianshu.io/upload_images/7896890-b9d8afde068a165f.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +上图就展示了一个典型的 **Stream** 结构。每个 Stream 都有唯一的名称,它就是 Redis 的 `key`,在我们首次使用 `xadd` 指令追加消息时自动创建。我们对图中的一些概念做一下解释: + +- **Consumer Group**:消费者组,可以简单看成记录流状态的一种数据结构。消费者既可以选择使用 `XREAD` 命令进行 **独立消费**,也可以多个消费者同时加入一个消费者组进行 **组内消费**。同一个消费者组内的消费者共享所有的 Stream 信息,**同一条消息只会有一个消费者消费到**,这样就可以应用在分布式的应用场景中来保证消息的唯一性。 +- **last_delivered_id**:用来表示消费者组消费在 Stream 上 **消费位置** 的游标信息。每个消费者组都有一个 Stream 内 **唯一的名称**,消费者组不会自动创建,需要使用 `XGROUP CREATE` 指令来显式创建,并且需要指定从哪一个消息 ID 开始消费,用来初始化 `last_delivered_id` 这个变量。 +- **pending_ids**:每个消费者内部都有的一个状态变量,用来表示 **已经** 被客户端 **获取**,但是 **还没有 ack** 的消息。记录的目的是为了 **保证客户端至少消费了消息一次**,而不会在网络传输的中途丢失而没有对消息进行处理。如果客户端没有 ack,那么这个变量里面的消息 ID 就会越来越多,一旦某个消息被 ack,它就会对应开始减少。这个变量也被 Redis 官方称为 **PEL** *(Pending Entries List)*。 + + + +## 消息 ID 和消息内容 + +#### 消息 ID + +消息 ID 如果是由 `XADD` 命令返回自动创建的话,那么它的格式会像这样:`timestampInMillis-sequence` *(毫秒时间戳-序列号)*,例如 `1527846880585-5`,它表示当前的消息是在毫秒时间戳 `1527846880585` 时产生的,并且是该毫秒内产生的第 5 条消息。 + +这些 ID 的格式看起来有一些奇怪,**为什么要使用时间来当做 ID 的一部分呢?** 一方面,我们要 **满足 ID 自增** 的属性,另一方面,也是为了 **支持范围查找** 的功能。由于 ID 和生成消息的时间有关,这样就使得在根据时间范围内查找时基本上是没有额外损耗的。 + +当然消息 ID 也可以由客户端自定义,但是形式必须是 **"整数-整数"**,而且后面加入的消息的 ID 必须要大于前面的消息 ID。 + +#### 消息内容 + +消息内容就是普通的键值对,形如 hash 结构的键值对。 + +## 增删改查示例 + +增删改查命令很简单,详情如下: + +1. `xadd`:追加消息 +2. `xdel`:删除消息,这里的删除仅仅是设置了标志位,不影响消息总长度 +3. `xrange`:获取消息列表,会自动过滤已经删除的消息 +4. `xlen`:消息长度 +5. `del`:删除Stream + +使用示例: + +```bash +# *号表示服务器自动生成ID,后面顺序跟着一堆key/value +127.0.0.1:6379> xadd codehole * name laoqian age 30 # 名字叫laoqian,年龄30岁 +1527849609889-0 # 生成的消息ID +127.0.0.1:6379> xadd codehole * name xiaoyu age 29 +1527849629172-0 +127.0.0.1:6379> xadd codehole * name xiaoqian age 1 +1527849637634-0 +127.0.0.1:6379> xlen codehole +(integer) 3 +127.0.0.1:6379> xrange codehole - + # -表示最小值, +表示最大值 +1) 1) 1527849609889-0 + 2) 1) "name" + 2) "laoqian" + 3) "age" + 4) "30" +2) 1) 1527849629172-0 + 2) 1) "name" + 2) "xiaoyu" + 3) "age" + 4) "29" +3) 1) 1527849637634-0 + 2) 1) "name" + 2) "xiaoqian" + 3) "age" + 4) "1" +127.0.0.1:6379> xrange codehole 1527849629172-0 + # 指定最小消息ID的列表 +1) 1) 1527849629172-0 + 2) 1) "name" + 2) "xiaoyu" + 3) "age" + 4) "29" +2) 1) 1527849637634-0 + 2) 1) "name" + 2) "xiaoqian" + 3) "age" + 4) "1" +127.0.0.1:6379> xrange codehole - 1527849629172-0 # 指定最大消息ID的列表 +1) 1) 1527849609889-0 + 2) 1) "name" + 2) "laoqian" + 3) "age" + 4) "30" +2) 1) 1527849629172-0 + 2) 1) "name" + 2) "xiaoyu" + 3) "age" + 4) "29" +127.0.0.1:6379> xdel codehole 1527849609889-0 +(integer) 1 +127.0.0.1:6379> xlen codehole # 长度不受影响 +(integer) 3 +127.0.0.1:6379> xrange codehole - + # 被删除的消息没了 +1) 1) 1527849629172-0 + 2) 1) "name" + 2) "xiaoyu" + 3) "age" + 4) "29" +2) 1) 1527849637634-0 + 2) 1) "name" + 2) "xiaoqian" + 3) "age" + 4) "1" +127.0.0.1:6379> del codehole # 删除整个Stream +(integer) 1 +``` + +## 独立消费示例 + +我们可以在不定义消费组的情况下进行 Stream 消息的 **独立消费**,当 Stream 没有新消息时,甚至可以阻塞等待。Redis 设计了一个单独的消费指令 `xread`,可以将 Stream 当成普通的消息队列(list)来使用。使用 `xread` 时,我们可以完全忽略 **消费组(Consumer Group)** 的存在,就好比 Stream 就是一个普通的列表(list): + +```bash +# 从Stream头部读取两条消息 +127.0.0.1:6379> xread count 2 streams codehole 0-0 +1) 1) "codehole" + 2) 1) 1) 1527851486781-0 + 2) 1) "name" + 2) "laoqian" + 3) "age" + 4) "30" + 2) 1) 1527851493405-0 + 2) 1) "name" + 2) "yurui" + 3) "age" + 4) "29" +# 从Stream尾部读取一条消息,毫无疑问,这里不会返回任何消息 +127.0.0.1:6379> xread count 1 streams codehole $ +(nil) +# 从尾部阻塞等待新消息到来,下面的指令会堵住,直到新消息到来 +127.0.0.1:6379> xread block 0 count 1 streams codehole $ +# 我们从新打开一个窗口,在这个窗口往Stream里塞消息 +127.0.0.1:6379> xadd codehole * name youming age 60 +1527852774092-0 +# 再切换到前面的窗口,我们可以看到阻塞解除了,返回了新的消息内容 +# 而且还显示了一个等待时间,这里我们等待了93s +127.0.0.1:6379> xread block 0 count 1 streams codehole $ +1) 1) "codehole" + 2) 1) 1) 1527852774092-0 + 2) 1) "name" + 2) "youming" + 3) "age" + 4) "60" +(93.11s) +``` + +客户端如果想要使用 `xread` 进行 **顺序消费**,一定要 **记住当前消费** 到哪里了,也就是返回的消息 ID。下次继续调用 `xread` 时,将上次返回的最后一个消息 ID 作为参数传递进去,就可以继续消费后续的消息。 + +`block 0` 表示永远阻塞,直到消息到来,`block 1000` 表示阻塞 `1s`,如果 `1s` 内没有任何消息到来,就返回 `nil`: + +```bash +127.0.0.1:6379> xread block 1000 count 1 streams codehole $ +(nil) +(1.07s) +``` + +## 创建消费者示例 + +Stream 通过 `xgroup create` 指令创建消费组(Consumer Group),需要传递起始消息 ID 参数用来初始化 `last_delivered_id` 变量: + +```bash +127.0.0.1:6379> xgroup create codehole cg1 0-0 # 表示从头开始消费 +OK +# $表示从尾部开始消费,只接受新消息,当前Stream消息会全部忽略 +127.0.0.1:6379> xgroup create codehole cg2 $ +OK +127.0.0.1:6379> xinfo codehole # 获取Stream信息 + 1) length + 2) (integer) 3 # 共3个消息 + 3) radix-tree-keys + 4) (integer) 1 + 5) radix-tree-nodes + 6) (integer) 2 + 7) groups + 8) (integer) 2 # 两个消费组 + 9) first-entry # 第一个消息 +10) 1) 1527851486781-0 + 2) 1) "name" + 2) "laoqian" + 3) "age" + 4) "30" +11) last-entry # 最后一个消息 +12) 1) 1527851498956-0 + 2) 1) "name" + 2) "xiaoqian" + 3) "age" + 4) "1" +127.0.0.1:6379> xinfo groups codehole # 获取Stream的消费组信息 +1) 1) name + 2) "cg1" + 3) consumers + 4) (integer) 0 # 该消费组还没有消费者 + 5) pending + 6) (integer) 0 # 该消费组没有正在处理的消息 +2) 1) name + 2) "cg2" + 3) consumers # 该消费组还没有消费者 + 4) (integer) 0 + 5) pending + 6) (integer) 0 # 该消费组没有正在处理的消息 +``` + +## 组内消费示例 + +Stream 提供了 `xreadgroup` 指令可以进行消费组的组内消费,需要提供 **消费组名称、消费者名称和起始消息 ID**。它同 `xread` 一样,也可以阻塞等待新消息。读到新消息后,对应的消息 ID 就会进入消费者的 **PEL** *(正在处理的消息)* 结构里,客户端处理完毕后使用 `xack` 指令 **通知服务器**,本条消息已经处理完毕,该消息 ID 就会从 **PEL** 中移除,下面是示例: + +```bash +# >号表示从当前消费组的last_delivered_id后面开始读 +# 每当消费者读取一条消息,last_delivered_id变量就会前进 +127.0.0.1:6379> xreadgroup GROUP cg1 c1 count 1 streams codehole > +1) 1) "codehole" + 2) 1) 1) 1527851486781-0 + 2) 1) "name" + 2) "laoqian" + 3) "age" + 4) "30" +127.0.0.1:6379> xreadgroup GROUP cg1 c1 count 1 streams codehole > +1) 1) "codehole" + 2) 1) 1) 1527851493405-0 + 2) 1) "name" + 2) "yurui" + 3) "age" + 4) "29" +127.0.0.1:6379> xreadgroup GROUP cg1 c1 count 2 streams codehole > +1) 1) "codehole" + 2) 1) 1) 1527851498956-0 + 2) 1) "name" + 2) "xiaoqian" + 3) "age" + 4) "1" + 2) 1) 1527852774092-0 + 2) 1) "name" + 2) "youming" + 3) "age" + 4) "60" +# 再继续读取,就没有新消息了 +127.0.0.1:6379> xreadgroup GROUP cg1 c1 count 1 streams codehole > +(nil) +# 那就阻塞等待吧 +127.0.0.1:6379> xreadgroup GROUP cg1 c1 block 0 count 1 streams codehole > +# 开启另一个窗口,往里塞消息 +127.0.0.1:6379> xadd codehole * name lanying age 61 +1527854062442-0 +# 回到前一个窗口,发现阻塞解除,收到新消息了 +127.0.0.1:6379> xreadgroup GROUP cg1 c1 block 0 count 1 streams codehole > +1) 1) "codehole" + 2) 1) 1) 1527854062442-0 + 2) 1) "name" + 2) "lanying" + 3) "age" + 4) "61" +(36.54s) +127.0.0.1:6379> xinfo groups codehole # 观察消费组信息 +1) 1) name + 2) "cg1" + 3) consumers + 4) (integer) 1 # 一个消费者 + 5) pending + 6) (integer) 5 # 共5条正在处理的信息还有没有ack +2) 1) name + 2) "cg2" + 3) consumers + 4) (integer) 0 # 消费组cg2没有任何变化,因为前面我们一直在操纵cg1 + 5) pending + 6) (integer) 0 +# 如果同一个消费组有多个消费者,我们可以通过xinfo consumers指令观察每个消费者的状态 +127.0.0.1:6379> xinfo consumers codehole cg1 # 目前还有1个消费者 +1) 1) name + 2) "c1" + 3) pending + 4) (integer) 5 # 共5条待处理消息 + 5) idle + 6) (integer) 418715 # 空闲了多长时间ms没有读取消息了 +# 接下来我们ack一条消息 +127.0.0.1:6379> xack codehole cg1 1527851486781-0 +(integer) 1 +127.0.0.1:6379> xinfo consumers codehole cg1 +1) 1) name + 2) "c1" + 3) pending + 4) (integer) 4 # 变成了5条 + 5) idle + 6) (integer) 668504 +# 下面ack所有消息 +127.0.0.1:6379> xack codehole cg1 1527851493405-0 1527851498956-0 1527852774092-0 1527854062442-0 +(integer) 4 +127.0.0.1:6379> xinfo consumers codehole cg1 +1) 1) name + 2) "c1" + 3) pending + 4) (integer) 0 # pel空了 + 5) idle + 6) (integer) 745505 +``` + +## QA 1:Stream 消息太多怎么办? | Stream 的上限 + +很容易想到,要是消息积累太多,Stream 的链表岂不是很长,内容会不会爆掉就是个问题了。`xdel` 指令又不会删除消息,它只是给消息做了个标志位。 + +Redis 自然考虑到了这一点,所以它提供了一个定长 Stream 功能。在 `xadd` 的指令提供一个定长长度 `maxlen`,就可以将老的消息干掉,确保最多不超过指定长度,使用起来也很简单: + +```bash +> XADD mystream MAXLEN 2 * value 1 +1526654998691-0 +> XADD mystream MAXLEN 2 * value 2 +1526654999635-0 +> XADD mystream MAXLEN 2 * value 3 +1526655000369-0 +> XLEN mystream +(integer) 2 +> XRANGE mystream - + +1) 1) 1526654999635-0 + 2) 1) "value" + 2) "2" +2) 1) 1526655000369-0 + 2) 1) "value" + 2) "3" +``` + +如果使用 `MAXLEN` 选项,当 Stream 的达到指定长度后,老的消息会自动被淘汰掉,因此 Stream 的大小是恒定的。目前还没有选项让 Stream 只保留给定数量的条目,因为为了一致地运行,这样的命令必须在很长一段时间内阻塞以淘汰消息。*(例如在添加数据的高峰期间,你不得不长暂停来淘汰旧消息和添加新的消息)* + +另外使用 `MAXLEN` 选项的花销是很大的,Stream 为了节省内存空间,采用了一种特殊的结构表示,而这种结构的调整是需要额外的花销的。所以我们可以使用一种带有 `~` 的特殊命令: + +```bash +XADD mystream MAXLEN ~ 1000 * ... entry fields here ... +``` + +它会基于当前的结构合理地对节点执行裁剪,来保证至少会有 `1000` 条数据,可能是 `1010` 也可能是 `1030`。 + +## QA 2:PEL 是如何避免消息丢失的? + + +在客户端消费者读取 Stream 消息时,Redis 服务器将消息回复给客户端的过程中,客户端突然断开了连接,消息就丢失了。但是 PEL 里已经保存了发出去的消息 ID,待客户端重新连上之后,可以再次收到 PEL 中的消息 ID 列表。不过此时 `xreadgroup` 的起始消息 ID 不能为参数 `>` ,而必须是任意有效的消息 ID,一般将参数设为 `0-0`,表示读取所有的 PEL 消息以及自 `last_delivered_id` 之后的新消息。 + + +## Redis Stream Vs Kafka + +Redis 基于内存存储,这意味着它会比基于磁盘的 Kafka 快上一些,也意味着使用 Redis 我们 **不能长时间存储大量数据**。不过如果您想以 **最小延迟** 实时处理消息的话,您可以考虑 Redis,但是如果 **消息很大并且应该重用数据** 的话,则应该首先考虑使用 Kafka。 + +另外从某些角度来说,`Redis Stream` 也更适用于小型、廉价的应用程序,因为 `Kafka` 相对来说更难配置一些。 + + +# 相关阅读 + +1. Redis(1)——5种基本数据结构 - [https://www.wmyskxz.com/2020/02/28/redis-1-5-chong-ji-ben-shu-ju-jie-gou/](https://www.wmyskxz.com/2020/02/28/redis-1-5-chong-ji-ben-shu-ju-jie-gou/) +2. Redis(2)——跳跃表 - [https://www.wmyskxz.com/2020/02/29/redis-2-tiao-yue-biao/](https://www.wmyskxz.com/2020/02/29/redis-2-tiao-yue-biao/) +3. Redis(3)——分布式锁深入探究 - [https://www.wmyskxz.com/2020/03/01/redis-3/](https://www.wmyskxz.com/2020/03/01/redis-3/) +4. Reids(4)——神奇的HyperLoglog解决统计问题 - [https://www.wmyskxz.com/2020/03/02/reids-4-shen-qi-de-hyperloglog-jie-jue-tong-ji-wen-ti/](https://www.wmyskxz.com/2020/03/02/reids-4-shen-qi-de-hyperloglog-jie-jue-tong-ji-wen-ti/) +5. Redis(5)——亿级数据过滤和布隆过滤器 - [https://www.wmyskxz.com/2020/03/11/redis-5-yi-ji-shu-ju-guo-lu-he-bu-long-guo-lu-qi/](https://www.wmyskxz.com/2020/03/11/redis-5-yi-ji-shu-ju-guo-lu-he-bu-long-guo-lu-qi/) +6. Redis(6)——GeoHash查找附近的人[https://www.wmyskxz.com/2020/03/12/redis-6-geohash-cha-zhao-fu-jin-de-ren/](https://www.wmyskxz.com/2020/03/12/redis-6-geohash-cha-zhao-fu-jin-de-ren/) +7. Redis(7)——持久化【一文了解】 - [https://www.wmyskxz.com/2020/03/13/redis-7-chi-jiu-hua-yi-wen-liao-jie/](https://www.wmyskxz.com/2020/03/13/redis-7-chi-jiu-hua-yi-wen-liao-jie/) + + +# 参考资料 + +1. 订阅与发布——Redis 设计与实现 - [https://redisbook.readthedocs.io/en/latest/feature/pubsub.html](https://redisbook.readthedocs.io/en/latest/feature/pubsub.html) +2. 《Redis 深度历险》 - 钱文品/ 著 - [https://book.douban.com/subject/30386804/](https://book.douban.com/subject/30386804/) +3. Introduction to Redis Streams【官方文档】 - [https://redis.io/topics/streams-intro](https://redis.io/topics/streams-intro) +4. Kafka vs. Redis: Log Aggregation Capabilities and Performance - [https://logz.io/blog/kafka-vs-redis/](https://logz.io/blog/kafka-vs-redis/) diff --git a/docs/database/Redis/redis-collection/Redis(9)——集群入门实践教程.md b/docs/database/Redis/redis-collection/Redis(9)——集群入门实践教程.md new file mode 100644 index 00000000..7f3dc953 --- /dev/null +++ b/docs/database/Redis/redis-collection/Redis(9)——集群入门实践教程.md @@ -0,0 +1,648 @@ +> 授权转载自: https://github.com/wmyskxz/MoreThanJava#part3-redis + +![](https://upload-images.jianshu.io/upload_images/7896890-80c61b0ae541a750.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +# 一、Redis 集群概述 + +#### Redis 主从复制 + +到 [目前](#相关阅读) 为止,我们所学习的 Redis 都是 **单机版** 的,这也就意味着一旦我们所依赖的 Redis 服务宕机了,我们的主流程也会受到一定的影响,这当然是我们不能够接受的。 + +所以一开始我们的想法是:搞一台备用机。这样我们就可以在一台服务器出现问题的时候切换动态地到另一台去: + +![](https://upload-images.jianshu.io/upload_images/7896890-c48d255bc0b13672.gif?imageMogr2/auto-orient/strip) + +幸运的是,两个节点数据的同步我们可以使用 Redis 的 **主从同步** 功能帮助到我们,这样一来,有个备份,心里就踏实多了。 + +![](https://upload-images.jianshu.io/upload_images/7896890-4a32b9efa3885655.jpg?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +#### Redis 哨兵 + +后来因为某种神秘力量,Redis 老会在莫名其妙的时间点出问题 *(比如半夜 2 点)*,我总不能 24 小时时刻守在电脑旁边切换节点吧,于是另一个想法又开始了:给所有的节点找一个 **"管家"**,自动帮我监听照顾节点的状态并切换: + +![](https://upload-images.jianshu.io/upload_images/7896890-de8d9ce9e77bf211.gif?imageMogr2/auto-orient/strip) + +这大概就是 **Redis 哨兵** *(Sentinel)* 的简单理解啦。什么?管家宕机了怎么办?相较于有大量请求的 Redis 服务来说,管家宕机的概率就要小得多啦.. 如果真的宕机了,我们也可以直接切换成当前可用的节点保证可用.. + +![](https://upload-images.jianshu.io/upload_images/7896890-c7657fb8140d7cc6.jpg?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +#### Redis 集群化 + +好了,通过上面的一些解决方案我们对 Redis 的 **稳定性** 稍微有了一些底气了,但单台节点的计算能力始终有限,所谓人多力量大,如果我们把 **多个节点组合** 成 **一个可用的工作节点**,那就大大增加了 Redis 的 **高可用、可扩展、分布式、容错** 等特性: + +![](https://upload-images.jianshu.io/upload_images/7896890-8957aa6d1484c5de.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +# 二、主从复制 + +![](https://upload-images.jianshu.io/upload_images/7896890-4956a718c124a81f.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +**主从复制**,是指将一台 Redis 服务器的数据,复制到其他的 Redis 服务器。前者称为 **主节点(master)**,后者称为 **从节点(slave)**。且数据的复制是 **单向** 的,只能由主节点到从节点。Redis 主从复制支持 **主从同步** 和 **从从同步** 两种,后者是 Redis 后续版本新增的功能,以减轻主节点的同步负担。 + +#### 主从复制主要的作用 + +- **数据冗余:** 主从复制实现了数据的热备份,是持久化之外的一种数据冗余方式。 +- **故障恢复:** 当主节点出现问题时,可以由从节点提供服务,实现快速的故障恢复 *(实际上是一种服务的冗余)*。 +- **负载均衡:** 在主从复制的基础上,配合读写分离,可以由主节点提供写服务,由从节点提供读服务 *(即写 Redis 数据时应用连接主节点,读 Redis 数据时应用连接从节点)*,分担服务器负载。尤其是在写少读多的场景下,通过多个从节点分担读负载,可以大大提高 Redis 服务器的并发量。 +- **高可用基石:** 除了上述作用以外,主从复制还是哨兵和集群能够实施的 **基础**,因此说主从复制是 Redis 高可用的基础。 + +## 快速体验 + +在 **Redis** 中,用户可以通过执行 `SLAVEOF` 命令或者设置 `slaveof` 选项,让一个服务器去复制另一个服务器,以下三种方式是 **完全等效** 的: + +- **配置文件**:在从服务器的配置文件中加入:`slaveof ` +- **启动命令**:redis-server 启动命令后加入 `--slaveof ` +- **客户端命令**:Redis 服务器启动后,直接通过客户端执行命令:`slaveof `,让该 Redis 实例成为从节点。 + +需要注意的是:**主从复制的开启,完全是在从节点发起的,不需要我们在主节点做任何事情。** + +#### 第一步:本地启动两个节点 + +在正确安装好 Redis 之后,我们可以使用 `redis-server --port ` 的方式指定创建两个不同端口的 Redis 实例,例如,下方我分别创建了一个 `6379` 和 `6380` 的两个 Redis 实例: + +```bash +# 创建一个端口为 6379 的 Redis 实例 +redis-server --port 6379 +# 创建一个端口为 6380 的 Redis 实例 +redis-server --port 6380 +``` + +此时两个 Redis 节点启动后,都默认为 **主节点**。 + +#### 第二步:建立复制 + +我们在 `6380` 端口的节点中执行 `slaveof` 命令,使之变为从节点: + +```bash +# 在 6380 端口的 Redis 实例中使用控制台 +redis-cli -p 6380 +# 成为本地 6379 端口实例的从节点 +127.0.0.1:6380> SLAVEOF 127.0.0.1ø 6379 +OK +``` + +#### 第三步:观察效果 + +下面我们来验证一下,主节点的数据是否会复制到从节点之中: + +- 先在 **从节点** 中查询一个 **不存在** 的 key: +```bash +127.0.0.1:6380> GET mykey +(nil) +``` +- 再在 **主节点** 中添加这个 key: +```bash +127.0.0.1:6379> SET mykey myvalue +OK +``` +- 此时再从 **从节点** 中查询,会发现已经从 **主节点** 同步到 **从节点**: +```bash +127.0.0.1:6380> GET mykey +"myvalue" +``` + +#### 第四步:断开复制 + +通过 `slaveof ` 命令建立主从复制关系以后,可以通过 `slaveof no one` 断开。需要注意的是,从节点断开复制后,**不会删除已有的数据**,只是不再接受主节点新的数据变化。 + +从节点执行 `slaveof no one` 之后,从节点和主节点分别打印日志如下:、 + +```bash +# 从节点打印日志 +61496:M 17 Mar 2020 08:10:22.749 # Connection with master lost. +61496:M 17 Mar 2020 08:10:22.749 * Caching the disconnected master state. +61496:M 17 Mar 2020 08:10:22.749 * Discarding previously cached master state. +61496:M 17 Mar 2020 08:10:22.749 * MASTER MODE enabled (user request from 'id=4 addr=127.0.0.1:55096 fd=8 name= age=1664 idle=0 flags=N db=0 sub=0 psub=0 multi=-1 qbuf=34 qbuf-free=32734 obl=0 oll=0 omem=0 events=r cmd=slaveof') + +# 主节点打印日志 +61467:M 17 Mar 2020 08:10:22.749 # Connection with replica 127.0.0.1:6380 lost. +``` + +## 实现原理简析 + +![](https://upload-images.jianshu.io/upload_images/7896890-c97a6bcc0936cd17.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +为了节省篇幅,我把主要的步骤都 **浓缩** 在了上图中,其实也可以 **简化成三个阶段:准备阶段-数据同步阶段-命令传播阶段**。下面我们来进行一些必要的说明。 + +#### 身份验证 | 主从复制安全问题 + +在上面的 **快速体验** 过程中,你会发现 `slaveof` 这个命令居然不需要验证?这意味着只要知道了 ip 和端口就可以随意拷贝服务器上的数据了? + +![](https://upload-images.jianshu.io/upload_images/7896890-d0c7a74da972fca3.jpg?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +那当然不能够了,我们可以通过在 **主节点** 配置 `requirepass` 来设置密码,这样就必须在 **从节点** 中对应配置好 `masterauth` 参数 *(与主节点 `requirepass` 保持一致)* 才能够进行正常复制了。 + +#### SYNC 命令是一个非常耗费资源的操作 + +每次执行 `SYNC` 命令,主从服务器需要执行如下动作: + +1. **主服务器** 需要执行 `BGSAVE` 命令来生成 RDB 文件,这个生成操作会 **消耗** 主服务器大量的 **CPU、内存和磁盘 I/O 的资源**; +2. **主服务器** 需要将自己生成的 RDB 文件 发送给从服务器,这个发送操作会 **消耗** 主服务器 **大量的网络资源** *(带宽和流量)*,并对主服务器响应命令请求的时间产生影响; +3. 接收到 RDB 文件的 **从服务器** 需要载入主服务器发来的 RBD 文件,并且在载入期间,从服务器 **会因为阻塞而没办法处理命令请求**; + +特别是当出现 **断线重复制** 的情况是时,为了让从服务器补足断线时确实的那一小部分数据,却要执行一次如此耗资源的 `SYNC` 命令,显然是不合理的。 + +#### PSYNC 命令的引入 + +所以在 **Redis 2.8** 中引入了 `PSYNC` 命令来代替 `SYNC`,它具有两种模式: + +1. **全量复制:** 用于初次复制或其他无法进行部分复制的情况,将主节点中的所有数据都发送给从节点,是一个非常重型的操作; +2. **部分复制:** 用于网络中断等情况后的复制,只将 **中断期间主节点执行的写命令** 发送给从节点,与全量复制相比更加高效。**需要注意** 的是,如果网络中断时间过长,导致主节点没有能够完整地保存中断期间执行的写命令,则无法进行部分复制,仍使用全量复制; + + +部分复制的原理主要是靠主从节点分别维护一个 **复制偏移量**,有了这个偏移量之后断线重连之后一比较,之后就可以仅仅把从服务器断线之后确实的这部分数据给补回来了。 + +> 更多的详细内容可以参考下方 *参考资料 3* + +# 三、Redis Sentinel 哨兵 + +![](https://upload-images.jianshu.io/upload_images/7896890-884d5be9a2ddfebc.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +*上图* 展示了一个典型的哨兵架构图,它由两部分组成,哨兵节点和数据节点: + +- **哨兵节点:** 哨兵系统由一个或多个哨兵节点组成,哨兵节点是特殊的 Redis 节点,不存储数据; +- **数据节点:** 主节点和从节点都是数据节点; + +在复制的基础上,哨兵实现了 **自动化的故障恢复** 功能,下方是官方对于哨兵功能的描述: + +- **监控(Monitoring):** 哨兵会不断地检查主节点和从节点是否运作正常。 +- **自动故障转移(Automatic failover):** 当 **主节点** 不能正常工作时,哨兵会开始 **自动故障转移操作**,它会将失效主节点的其中一个 **从节点升级为新的主节点**,并让其他从节点改为复制新的主节点。 +- **配置提供者(Configuration provider):** 客户端在初始化时,通过连接哨兵来获得当前 Redis 服务的主节点地址。 +- **通知(Notification):** 哨兵可以将故障转移的结果发送给客户端。 + +其中,监控和自动故障转移功能,使得哨兵可以及时发现主节点故障并完成转移。而配置提供者和通知功能,则需要在与客户端的交互中才能体现。 + +## 快速体验 + +#### 第一步:创建主从节点配置文件并启动 + +正确安装好 Redis 之后,我们去到 Redis 的安装目录 *(mac 默认在 `/usr/local/`)*,找到 `redis.conf` 文件复制三份分别命名为 `redis-master.conf`/`redis-slave1.conf`/`redis-slave2.conf`,分别作为 `1` 个主节点和 `2` 个从节点的配置文件 *(下图演示了我本机的 `redis.conf` 文件的位置)* + +![](https://upload-images.jianshu.io/upload_images/7896890-34de77bfca56d32e.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +打开可以看到这个 `.conf` 后缀的文件里面有很多说明的内容,全部删除然后分别改成下面的样子: + +```bash +#redis-master.conf +port 6379 +daemonize yes +logfile "6379.log" +dbfilename "dump-6379.rdb" + +#redis-slave1.conf +port 6380 +daemonize yes +logfile "6380.log" +dbfilename "dump-6380.rdb" +slaveof 127.0.0.1 6379 + +#redis-slave2.conf +port 6381 +daemonize yes +logfile "6381.log" +dbfilename "dump-6381.rdb" +slaveof 127.0.0.1 6379 +``` + +然后我们可以执行 `redis-server ` 来根据配置文件启动不同的 Redis 实例,依次启动主从节点: + +```bash +redis-server /usr/local/redis-5.0.3/redis-master.conf +redis-server /usr/local/redis-5.0.3/redis-slave1.conf +redis-server /usr/local/redis-5.0.3/redis-slave2.conf +``` + +节点启动后,我们执行 `redis-cli` 默认连接到我们端口为 `6379` 的主节点执行 `info Replication` 检查一下主从状态是否正常:*(可以看到下方正确地显示了两个从节点)* + +![](https://upload-images.jianshu.io/upload_images/7896890-a1c935f094240cac.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +#### 第二步:创建哨兵节点配置文件并启动 + +按照上面同样的方法,我们给哨兵节点也创建三个配置文件。*(哨兵节点本质上是特殊的 Redis 节点,所以配置几乎没什么差别,只是在端口上做区分就好)* + +```bash +# redis-sentinel-1.conf +port 26379 +daemonize yes +logfile "26379.log" +sentinel monitor mymaster 127.0.0.1 6379 2 + +# redis-sentinel-2.conf +port 26380 +daemonize yes +logfile "26380.log" +sentinel monitor mymaster 127.0.0.1 6379 2 + +# redis-sentinel-3.conf +port 26381 +daemonize yes +logfile "26381.log" +sentinel monitor mymaster 127.0.0.1 6379 2 +``` + +其中,`sentinel monitor mymaster 127.0.0.1 6379 2` 配置的含义是:该哨兵节点监控 `127.0.0.1:6379` 这个主节点,该主节点的名称是 `mymaster`,最后的 `2` 的含义与主节点的故障判定有关:至少需要 `2` 个哨兵节点同意,才能判定主节点故障并进行故障转移。 + +执行下方命令将哨兵节点启动起来: + +```bash +redis-server /usr/local/redis-5.0.3/redis-sentinel-1.conf --sentinel +redis-server /usr/local/redis-5.0.3/redis-sentinel-2.conf --sentinel +redis-server /usr/local/redis-5.0.3/redis-sentinel-3.conf --sentinel +``` + +使用 `redis-cil` 工具连接哨兵节点,并执行 `info Sentinel` 命令来查看是否已经在监视主节点了: + +```bash +# 连接端口为 26379 的 Redis 节点 +➜ ~ redis-cli -p 26379 +127.0.0.1:26379> info Sentinel +# Sentinel +sentinel_masters:1 +sentinel_tilt:0 +sentinel_running_scripts:0 +sentinel_scripts_queue_length:0 +sentinel_simulate_failure_flags:0 +master0:name=mymaster,status=ok,address=127.0.0.1:6379,slaves=2,sentinels=3 +``` + +此时你打开刚才写好的哨兵配置文件,你还会发现出现了一些变化: + +#### 第三步:演示故障转移 + +首先,我们使用 `kill -9` 命令来杀掉主节点,**同时** 在哨兵节点中执行 `info Sentinel` 命令来观察故障节点的过程: + +```bash +➜ ~ ps aux | grep 6379 +longtao 74529 0.3 0.0 4346936 2132 ?? Ss 10:30上午 0:03.09 redis-server *:26379 [sentinel] +longtao 73541 0.2 0.0 4348072 2292 ?? Ss 10:18上午 0:04.79 redis-server *:6379 +longtao 75521 0.0 0.0 4286728 728 s008 S+ 10:39上午 0:00.00 grep --color=auto --exclude-dir=.bzr --exclude-dir=CVS --exclude-dir=.git --exclude-dir=.hg --exclude-dir=.svn 6379 +longtao 74836 0.0 0.0 4289844 944 s006 S+ 10:32上午 0:00.01 redis-cli -p 26379 +➜ ~ kill -9 73541 +``` + +如果 **刚杀掉瞬间** 在哨兵节点中执行 `info` 命令来查看,会发现主节点还没有切换过来,因为哨兵发现主节点故障并转移需要一段时间: + +```bash +# 第一时间查看哨兵节点发现并未转移,还在 6379 端口 +127.0.0.1:26379> info Sentinel +# Sentinel +sentinel_masters:1 +sentinel_tilt:0 +sentinel_running_scripts:0 +sentinel_scripts_queue_length:0 +sentinel_simulate_failure_flags:0 +master0:name=mymaster,status=ok,address=127.0.0.1:6379,slaves=2,sentinels=3 +``` + +一段时间之后你再执行 `info` 命令,查看,你就会发现主节点已经切换成了 `6381` 端口的从节点: + +```bash +# 过一段时间之后在执行,发现已经切换了 6381 端口 +127.0.0.1:26379> info Sentinel +# Sentinel +sentinel_masters:1 +sentinel_tilt:0 +sentinel_running_scripts:0 +sentinel_scripts_queue_length:0 +sentinel_simulate_failure_flags:0 +master0:name=mymaster,status=ok,address=127.0.0.1:6381,slaves=2,sentinels=3 +``` + +但同时还可以发现,**哨兵节点认为新的主节点仍然有两个从节点** *(上方 slaves=2)*,这是因为哨兵在将 `6381` 切换成主节点的同时,将 `6379` 节点置为其从节点。虽然 `6379` 从节点已经挂掉,但是由于 **哨兵并不会对从节点进行客观下线**,因此认为该从节点一直存在。当 `6379` 节点重新启动后,会自动变成 `6381` 节点的从节点。 + +另外,在故障转移的阶段,哨兵和主从节点的配置文件都会被改写: + +- **对于主从节点:** 主要是 `slaveof` 配置的变化,新的主节点没有了 `slaveof` 配置,其从节点则 `slaveof` 新的主节点。 +- **对于哨兵节点:** 除了主从节点信息的变化,纪元(epoch) *(记录当前集群状态的参数)* 也会变化,纪元相关的参数都 +1 了。 + +## 客户端访问哨兵系统代码演示 + +上面我们在 *快速体验* 中主要感受到了服务端自己对于当前主从节点的自动化治理,下面我们以 Java 代码为例,来演示一下客户端如何访问我们的哨兵系统: + +```java +public static void testSentinel() throws Exception { + String masterName = "mymaster"; + Set sentinels = new HashSet<>(); + sentinels.add("127.0.0.1:26379"); + sentinels.add("127.0.0.1:26380"); + sentinels.add("127.0.0.1:26381"); + + // 初始化过程做了很多工作 + JedisSentinelPool pool = new JedisSentinelPool(masterName, sentinels); + Jedis jedis = pool.getResource(); + jedis.set("key1", "value1"); + pool.close(); +} +``` + +#### 客户端原理 + +Jedis 客户端对哨兵提供了很好的支持。如上述代码所示,我们只需要向 Jedis 提供哨兵节点集合和 `masterName` ,构造 `JedisSentinelPool` 对象,然后便可以像使用普通 Redis 连接池一样来使用了:通过 `pool.getResource()` 获取连接,执行具体的命令。 + +在整个过程中,我们的代码不需要显式的指定主节点的地址,就可以连接到主节点;代码中对故障转移没有任何体现,就可以在哨兵完成故障转移后自动的切换主节点。之所以可以做到这一点,是因为在 `JedisSentinelPool` 的构造器中,进行了相关的工作;主要包括以下两点: + +1. **遍历哨兵节点,获取主节点信息:** 遍历哨兵节点,通过其中一个哨兵节点 + `masterName` 获得主节点的信息;该功能是通过调用哨兵节点的 `sentinel get-master-addr-by-name` 命令实现; +2. **增加对哨兵的监听:** 这样当发生故障转移时,客户端便可以收到哨兵的通知,从而完成主节点的切换。具体做法是:利用 Redis 提供的 **发布订阅** 功能,为每一个哨兵节点开启一个单独的线程,订阅哨兵节点的 + switch-master 频道,当收到消息时,重新初始化连接池。 + +## 新的主服务器是怎样被挑选出来的? + +**故障转移操作的第一步** 要做的就是在已下线主服务器属下的所有从服务器中,挑选出一个状态良好、数据完整的从服务器,然后向这个从服务器发送 `slaveof no one` 命令,将这个从服务器转换为主服务器。但是这个从服务器是怎么样被挑选出来的呢? + +![](https://upload-images.jianshu.io/upload_images/7896890-02dfea57f44fc27e.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +简单来说 Sentinel 使用以下规则来选择新的主服务器: + +1. 在失效主服务器属下的从服务器当中, 那些被标记为主观下线、已断线、或者最后一次回复 PING 命令的时间大于五秒钟的从服务器都会被 **淘汰**。 +2. 在失效主服务器属下的从服务器当中, 那些与失效主服务器连接断开的时长超过 down-after 选项指定的时长十倍的从服务器都会被 **淘汰**。 +3. 在 **经历了以上两轮淘汰之后** 剩下来的从服务器中, 我们选出 **复制偏移量(replication offset)最大** 的那个 **从服务器** 作为新的主服务器;如果复制偏移量不可用,或者从服务器的复制偏移量相同,那么 **带有最小运行 ID** 的那个从服务器成为新的主服务器。 + +# 四、Redis 集群 + +![](https://upload-images.jianshu.io/upload_images/7896890-516eb4a9465451a6.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +*上图* 展示了 **Redis Cluster** 典型的架构图,集群中的每一个 Redis 节点都 **互相两两相连**,客户端任意 **直连** 到集群中的 **任意一台**,就可以对其他 Redis 节点进行 **读写** 的操作。 + +#### 基本原理 + +![](https://upload-images.jianshu.io/upload_images/7896890-f65c71ca6811c634.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +Redis 集群中内置了 `16384` 个哈希槽。当客户端连接到 Redis 集群之后,会同时得到一份关于这个 **集群的配置信息**,当客户端具体对某一个 `key` 值进行操作时,会计算出它的一个 Hash 值,然后把结果对 `16384` **求余数**,这样每个 `key` 都会对应一个编号在 `0-16383` 之间的哈希槽,Redis 会根据节点数量 **大致均等** 的将哈希槽映射到不同的节点。 + +再结合集群的配置信息就能够知道这个 `key` 值应该存储在哪一个具体的 Redis 节点中,如果不属于自己管,那么就会使用一个特殊的 `MOVED` 命令来进行一个跳转,告诉客户端去连接这个节点以获取数据: + +```bash +GET x +-MOVED 3999 127.0.0.1:6381 +``` + +`MOVED` 指令第一个参数 `3999` 是 `key` 对应的槽位编号,后面是目标节点地址,`MOVED` 命令前面有一个减号,表示这是一个错误的消息。客户端在收到 `MOVED` 指令后,就立即纠正本地的 **槽位映射表**,那么下一次再访问 `key` 时就能够到正确的地方去获取了。 + +#### 集群的主要作用 + +1. **数据分区:** 数据分区 *(或称数据分片)* 是集群最核心的功能。集群将数据分散到多个节点,**一方面** 突破了 Redis 单机内存大小的限制,**存储容量大大增加**;**另一方面** 每个主节点都可以对外提供读服务和写服务,**大大提高了集群的响应能力**。Redis 单机内存大小受限问题,在介绍持久化和主从复制时都有提及,例如,如果单机内存太大,`bgsave` 和 `bgrewriteaof` 的 `fork` 操作可能导致主进程阻塞,主从环境下主机切换时可能导致从节点长时间无法提供服务,全量复制阶段主节点的复制缓冲区可能溢出…… +2. **高可用:** 集群支持主从复制和主节点的 **自动故障转移** *(与哨兵类似)*,当任一节点发生故障时,集群仍然可以对外提供服务。 + +## 快速体验 + +#### 第一步:创建集群节点配置文件 + +首先我们找一个地方创建一个名为 `redis-cluster` 的目录: + +```bash +mkdir -p ~/Desktop/redis-cluster +``` + +然后按照上面的方法,创建六个配置文件,分别命名为:`redis_7000.conf`/`redis_7001.conf`.....`redis_7005.conf`,然后根据不同的端口号修改对应的端口值就好了: + +```bash +# 后台执行 +daemonize yes +# 端口号 +port 7000 +# 为每一个集群节点指定一个 pid_file +pidfile ~/Desktop/redis-cluster/redis_7000.pid +# 启动集群模式 +cluster-enabled yes +# 每一个集群节点都有一个配置文件,这个文件是不能手动编辑的。确保每一个集群节点的配置文件不通 +cluster-config-file nodes-7000.conf +# 集群节点的超时时间,单位:ms,超时后集群会认为该节点失败 +cluster-node-timeout 5000 +# 最后将 appendonly 改成 yes(AOF 持久化) +appendonly yes +``` + +记得把对应上述配置文件中根端口对应的配置都修改掉 *(port/ pidfile/ cluster-config-file)*。 + +#### 第二步:分别启动 6 个 Redis 实例 + +```bash +redis-server ~/Desktop/redis-cluster/redis_7000.conf +redis-server ~/Desktop/redis-cluster/redis_7001.conf +redis-server ~/Desktop/redis-cluster/redis_7002.conf +redis-server ~/Desktop/redis-cluster/redis_7003.conf +redis-server ~/Desktop/redis-cluster/redis_7004.conf +redis-server ~/Desktop/redis-cluster/redis_7005.conf +``` + +然后执行 `ps -ef | grep redis` 查看是否启动成功: + +![](https://upload-images.jianshu.io/upload_images/7896890-452c3152054c36f1.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +可以看到 `6` 个 Redis 节点都以集群的方式成功启动了,**但是现在每个节点还处于独立的状态**,也就是说它们每一个都各自成了一个集群,还没有互相联系起来,我们需要手动地把他们之间建立起联系。 + +#### 第三步:建立集群 + +执行下列命令: + +```bash +redis-cli --cluster create --cluster-replicas 1 127.0.0.1:7000 127.0.0.1:7001 127.0.0.1:7002 127.0.0.1:7003 127.0.0.1:7004 127.0.0.1:7005 +``` + +- 这里稍微解释一下这个 `--replicas 1` 的意思是:我们希望为集群中的每个主节点创建一个从节点。 + +观察控制台输出: + +![](https://upload-images.jianshu.io/upload_images/7896890-d5ab644e76e9cc87.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +看到 `[OK]` 的信息之后,就表示集群已经搭建成功了,可以看到,这里我们正确地创建了三主三从的集群。 + +#### 第四步:验证集群 + +我们先使用 `redic-cli` 任意连接一个节点: + +```bash +redis-cli -c -h 127.0.0.1 -p 7000 +127.0.0.1:7000> +``` + +- `-c`表示集群模式;`-h` 指定 ip 地址;`-p` 指定端口。 + +然后随便 `set` 一些值观察控制台输入: + +```bash +127.0.0.1:7000> SET name wmyskxz +-> Redirected to slot [5798] located at 127.0.0.1:7001 +OK +127.0.0.1:7001> +``` + +可以看到这里 Redis 自动帮我们进行了 `Redirected` 操作跳转到了 `7001` 这个实例上。 + +我们再使用 `cluster info` *(查看集群信息)* 和 `cluster nodes` *(查看节点列表)* 来分别看看:*(任意节点输入均可)* + +```bash +127.0.0.1:7001> CLUSTER INFO +cluster_state:ok +cluster_slots_assigned:16384 +cluster_slots_ok:16384 +cluster_slots_pfail:0 +cluster_slots_fail:0 +cluster_known_nodes:6 +cluster_size:3 +cluster_current_epoch:6 +cluster_my_epoch:2 +cluster_stats_messages_ping_sent:1365 +cluster_stats_messages_pong_sent:1358 +cluster_stats_messages_meet_sent:4 +cluster_stats_messages_sent:2727 +cluster_stats_messages_ping_received:1357 +cluster_stats_messages_pong_received:1369 +cluster_stats_messages_meet_received:1 +cluster_stats_messages_received:2727 + +127.0.0.1:7001> CLUSTER NODES +56a04742f36c6e84968cae871cd438935081e86f 127.0.0.1:7003@17003 slave 4ec8c022e9d546c9b51deb9d85f6cf867bf73db6 0 1584428884000 4 connected +4ec8c022e9d546c9b51deb9d85f6cf867bf73db6 127.0.0.1:7000@17000 master - 0 1584428884000 1 connected 0-5460 +e2539c4398b8258d3f9ffa714bd778da107cb2cd 127.0.0.1:7005@17005 slave a3406db9ae7144d17eb7df5bffe8b70bb5dd06b8 0 1584428885222 6 connected +d31cd1f423ab1e1849cac01ae927e4b6950f55d9 127.0.0.1:7004@17004 slave 236cefaa9cdc295bc60a5bd1aed6a7152d4f384d 0 1584428884209 5 connected +236cefaa9cdc295bc60a5bd1aed6a7152d4f384d 127.0.0.1:7001@17001 myself,master - 0 1584428882000 2 connected 5461-10922 +a3406db9ae7144d17eb7df5bffe8b70bb5dd06b8 127.0.0.1:7002@17002 master - 0 1584428884000 3 connected 10923-16383 +127.0.0.1:7001> +``` + +## 数据分区方案简析 + +#### 方案一:哈希值 % 节点数 + +哈希取余分区思路非常简单:计算 `key` 的 hash 值,然后对节点数量进行取余,从而决定数据映射到哪个节点上。 + +不过该方案最大的问题是,**当新增或删减节点时**,节点数量发生变化,系统中所有的数据都需要 **重新计算映射关系**,引发大规模数据迁移。 + +#### 方案二:一致性哈希分区 + +一致性哈希算法将 **整个哈希值空间** 组织成一个虚拟的圆环,范围是 *[0 - 232 - 1]*,对于每一个数据,根据 `key` 计算 hash 值,确数据在环上的位置,然后从此位置沿顺时针行走,找到的第一台服务器就是其应该映射到的服务器: + +![](https://upload-images.jianshu.io/upload_images/7896890-40e8a2c096c8da92.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +与哈希取余分区相比,一致性哈希分区将 **增减节点的影响限制在相邻节点**。以上图为例,如果在 `node1` 和 `node2` 之间增加 `node5`,则只有 `node2` 中的一部分数据会迁移到 `node5`;如果去掉 `node2`,则原 `node2` 中的数据只会迁移到 `node4` 中,只有 `node4` 会受影响。 + +一致性哈希分区的主要问题在于,当 **节点数量较少** 时,增加或删减节点,**对单个节点的影响可能很大**,造成数据的严重不平衡。还是以上图为例,如果去掉 `node2`,`node4` 中的数据由总数据的 `1/4` 左右变为 `1/2` 左右,与其他节点相比负载过高。 + +#### 方案三:带有虚拟节点的一致性哈希分区 + +该方案在 **一致性哈希分区的基础上**,引入了 **虚拟节点** 的概念。Redis 集群使用的便是该方案,其中的虚拟节点称为 **槽(slot)**。槽是介于数据和实际节点之间的虚拟概念,每个实际节点包含一定数量的槽,每个槽包含哈希值在一定范围内的数据。 + +在使用了槽的一致性哈希分区中,**槽是数据管理和迁移的基本单位**。槽 **解耦** 了 **数据和实际节点** 之间的关系,增加或删除节点对系统的影响很小。仍以上图为例,系统中有 `4` 个实际节点,假设为其分配 `16` 个槽(0-15); + +- 槽 0-3 位于 node1;4-7 位于 node2;以此类推.... + +如果此时删除 `node2`,只需要将槽 4-7 重新分配即可,例如槽 4-5 分配给 `node1`,槽 6 分配给 `node3`,槽 7 分配给 `node4`;可以看出删除 `node2` 后,数据在其他节点的分布仍然较为均衡。 + +## 节点通信机制简析 + +集群的建立离不开节点之间的通信,例如我们上访在 *快速体验* 中刚启动六个集群节点之后通过 `redis-cli` 命令帮助我们搭建起来了集群,实际上背后每个集群之间的两两连接是通过了 `CLUSTER MEET ` 命令发送 `MEET` 消息完成的,下面我们展开详细说说。 + +#### 两个端口 + +在 **哨兵系统** 中,节点分为 **数据节点** 和 **哨兵节点**:前者存储数据,后者实现额外的控制功能。在 **集群** 中,没有数据节点与非数据节点之分:**所有的节点都存储数据,也都参与集群状态的维护**。为此,集群中的每个节点,都提供了两个 TCP 端口: + +- **普通端口:** 即我们在前面指定的端口 *(7000等)*。普通端口主要用于为客户端提供服务 *(与单机节点类似)*;但在节点间数据迁移时也会使用。 +- **集群端口:** 端口号是普通端口 + 10000 *(10000是固定值,无法改变)*,如 `7000` 节点的集群端口为 `17000`。**集群端口只用于节点之间的通信**,如搭建集群、增减节点、故障转移等操作时节点间的通信;不要使用客户端连接集群接口。为了保证集群可以正常工作,在配置防火墙时,要同时开启普通端口和集群端口。 + +#### Gossip 协议 + +节点间通信,按照通信协议可以分为几种类型:单对单、广播、Gossip 协议等。重点是广播和 Gossip 的对比。 + +- 广播是指向集群内所有节点发送消息。**优点** 是集群的收敛速度快(集群收敛是指集群内所有节点获得的集群信息是一致的),**缺点** 是每条消息都要发送给所有节点,CPU、带宽等消耗较大。 +- Gossip 协议的特点是:在节点数量有限的网络中,**每个节点都 “随机” 的与部分节点通信** *(并不是真正的随机,而是根据特定的规则选择通信的节点)*,经过一番杂乱无章的通信,每个节点的状态很快会达到一致。Gossip 协议的 **优点** 有负载 *(比广播)* 低、去中心化、容错性高 *(因为通信有冗余)* 等;**缺点** 主要是集群的收敛速度慢。 + +#### 消息类型 + +集群中的节点采用 **固定频率(每秒10次)** 的 **定时任务** 进行通信相关的工作:判断是否需要发送消息及消息类型、确定接收节点、发送消息等。如果集群状态发生了变化,如增减节点、槽状态变更,通过节点间的通信,所有节点会很快得知整个集群的状态,使集群收敛。 + +节点间发送的消息主要分为 `5` 种:`meet 消息`、`ping 消息`、`pong 消息`、`fail 消息`、`publish 消息`。不同的消息类型,通信协议、发送的频率和时机、接收节点的选择等是不同的: + +- **MEET 消息:** 在节点握手阶段,当节点收到客户端的 `CLUSTER MEET` 命令时,会向新加入的节点发送 `MEET` 消息,请求新节点加入到当前集群;新节点收到 MEET 消息后会回复一个 `PONG` 消息。 +- **PING 消息:** 集群里每个节点每秒钟会选择部分节点发送 `PING` 消息,接收者收到消息后会回复一个 `PONG` 消息。**PING 消息的内容是自身节点和部分其他节点的状态信息**,作用是彼此交换信息,以及检测节点是否在线。`PING` 消息使用 Gossip 协议发送,接收节点的选择兼顾了收敛速度和带宽成本,**具体规则如下**:(1)随机找 5 个节点,在其中选择最久没有通信的 1 个节点;(2)扫描节点列表,选择最近一次收到 `PONG` 消息时间大于 `cluster_node_timeout / 2` 的所有节点,防止这些节点长时间未更新。 +- **PONG消息:** `PONG` 消息封装了自身状态数据。可以分为两种:**第一种** 是在接到 `MEET/PING` 消息后回复的 `PONG` 消息;**第二种** 是指节点向集群广播 `PONG` 消息,这样其他节点可以获知该节点的最新信息,例如故障恢复后新的主节点会广播 `PONG` 消息。 +- **FAIL 消息:** 当一个主节点判断另一个主节点进入 `FAIL` 状态时,会向集群广播这一 `FAIL` 消息;接收节点会将这一 `FAIL` 消息保存起来,便于后续的判断。 +- **PUBLISH 消息:** 节点收到 `PUBLISH` 命令后,会先执行该命令,然后向集群广播这一消息,接收节点也会执行该 `PUBLISH` 命令。 + +## 数据结构简析 + +节点需要专门的数据结构来存储集群的状态。所谓集群的状态,是一个比较大的概念,包括:集群是否处于上线状态、集群中有哪些节点、节点是否可达、节点的主从状态、槽的分布…… + +节点为了存储集群状态而提供的数据结构中,最关键的是 `clusterNode` 和 `clusterState` 结构:前者记录了一个节点的状态,后者记录了集群作为一个整体的状态。 + +#### clusterNode 结构 + +`clusterNode` 结构保存了 **一个节点的当前状态**,包括创建时间、节点 id、ip 和端口号等。每个节点都会用一个 `clusterNode` 结构记录自己的状态,并为集群内所有其他节点都创建一个 `clusterNode` 结构来记录节点状态。 + +下面列举了 `clusterNode` 的部分字段,并说明了字段的含义和作用: + +```c +typedef struct clusterNode { + //节点创建时间 + mstime_t ctime; + //节点id + char name[REDIS_CLUSTER_NAMELEN]; + //节点的ip和端口号 + char ip[REDIS_IP_STR_LEN]; + int port; + //节点标识:整型,每个bit都代表了不同状态,如节点的主从状态、是否在线、是否在握手等 + int flags; + //配置纪元:故障转移时起作用,类似于哨兵的配置纪元 + uint64_t configEpoch; + //槽在该节点中的分布:占用16384/8个字节,16384个比特;每个比特对应一个槽:比特值为1,则该比特对应的槽在节点中;比特值为0,则该比特对应的槽不在节点中 + unsigned char slots[16384/8]; + //节点中槽的数量 + int numslots; + ………… +} clusterNode; +``` + +除了上述字段,`clusterNode` 还包含节点连接、主从复制、故障发现和转移需要的信息等。 + +#### clusterState 结构 + +`clusterState` 结构保存了在当前节点视角下,集群所处的状态。主要字段包括: + +```c +typedef struct clusterState { + //自身节点 + clusterNode *myself; + //配置纪元 + uint64_t currentEpoch; + //集群状态:在线还是下线 + int state; + //集群中至少包含一个槽的节点数量 + int size; + //哈希表,节点名称->clusterNode节点指针 + dict *nodes; + //槽分布信息:数组的每个元素都是一个指向clusterNode结构的指针;如果槽还没有分配给任何节点,则为NULL + clusterNode *slots[16384]; + ………… +} clusterState; +``` + +除此之外,`clusterState` 还包括故障转移、槽迁移等需要的信息。 + +> 更多关于集群内容请自行阅读《Redis 设计与实现》,其中有更多细节方面的介绍 - [http://redisbook.com/](http://redisbook.com/) + +# 相关阅读 + +1. Redis(1)——5种基本数据结构 - [https://www.wmyskxz.com/2020/02/28/redis-1-5-chong-ji-ben-shu-ju-jie-gou/](https://www.wmyskxz.com/2020/02/28/redis-1-5-chong-ji-ben-shu-ju-jie-gou/) +2. Redis(2)——跳跃表 - [https://www.wmyskxz.com/2020/02/29/redis-2-tiao-yue-biao/](https://www.wmyskxz.com/2020/02/29/redis-2-tiao-yue-biao/) +3. Redis(3)——分布式锁深入探究 - [https://www.wmyskxz.com/2020/03/01/redis-3/](https://www.wmyskxz.com/2020/03/01/redis-3/) +4. Reids(4)——神奇的HyperLoglog解决统计问题 - [https://www.wmyskxz.com/2020/03/02/reids-4-shen-qi-de-hyperloglog-jie-jue-tong-ji-wen-ti/](https://www.wmyskxz.com/2020/03/02/reids-4-shen-qi-de-hyperloglog-jie-jue-tong-ji-wen-ti/) +5. Redis(5)——亿级数据过滤和布隆过滤器 - [https://www.wmyskxz.com/2020/03/11/redis-5-yi-ji-shu-ju-guo-lu-he-bu-long-guo-lu-qi/](https://www.wmyskxz.com/2020/03/11/redis-5-yi-ji-shu-ju-guo-lu-he-bu-long-guo-lu-qi/) +6. Redis(6)——GeoHash查找附近的人[https://www.wmyskxz.com/2020/03/12/redis-6-geohash-cha-zhao-fu-jin-de-ren/](https://www.wmyskxz.com/2020/03/12/redis-6-geohash-cha-zhao-fu-jin-de-ren/) +7. Redis(7)——持久化【一文了解】 - [https://www.wmyskxz.com/2020/03/13/redis-7-chi-jiu-hua-yi-wen-liao-jie/](https://www.wmyskxz.com/2020/03/13/redis-7-chi-jiu-hua-yi-wen-liao-jie/) +8. Redis(8)——发布/订阅与Stream - [https://www.wmyskxz.com/2020/03/15/redis-8-fa-bu-ding-yue-yu-stream/](https://www.wmyskxz.com/2020/03/15/redis-8-fa-bu-ding-yue-yu-stream/) + +# 参考资料 + +1. 《Redis 设计与实现》 | 黄健宏 著 - [http://redisbook.com/](http://redisbook.com/) +2. 《Redis 深度历险》 | 钱文品 著 - [https://book.douban.com/subject/30386804/](https://book.douban.com/subject/30386804/) +3. 深入学习Redis(3):主从复制 - [https://www.cnblogs.com/kismetv/p/9236731.html](https://www.cnblogs.com/kismetv/p/9236731.html) +4. Redis 主从复制 原理与用法 - [https://blog.csdn.net/Stubborn_Cow/article/details/50442950](https://blog.csdn.net/Stubborn_Cow/article/details/50442950) +5. 深入学习Redis(4):哨兵 - [https://www.cnblogs.com/kismetv/p/9609938.html](https://www.cnblogs.com/kismetv/p/9609938.html) +6. Redis 5 之后版本的高可用集群搭建 - [https://www.jianshu.com/p/8045b92fafb2](https://www.jianshu.com/p/8045b92fafb2) + +> - 本文已收录至我的 Github 程序员成长系列 **【More Than Java】,学习,不止 Code,欢迎 star:[https://github.com/wmyskxz/MoreThanJava](https://github.com/wmyskxz/MoreThanJava)** +> - **个人公众号** :wmyskxz,**个人独立域名博客**:wmyskxz.com,坚持原创输出,下方扫码关注,2020,与您共同成长! + +![](https://upload-images.jianshu.io/upload_images/7896890-fca34cfd601e7449.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +非常感谢各位人才能 **看到这里**,如果觉得本篇文章写得不错,觉得 **「我没有三颗心脏」有点东西** 的话,**求点赞,求关注,求分享,求留言!** + +创作不易,各位的支持和认可,就是我创作的最大动力,我们下篇文章见! \ No newline at end of file diff --git a/docs/database/Redis/redis-collection/Reids(4)——神奇的HyperLoglog解决统计问题.md b/docs/database/Redis/redis-collection/Reids(4)——神奇的HyperLoglog解决统计问题.md new file mode 100644 index 00000000..98847b84 --- /dev/null +++ b/docs/database/Redis/redis-collection/Reids(4)——神奇的HyperLoglog解决统计问题.md @@ -0,0 +1,469 @@ +> 授权转载自: https://github.com/wmyskxz/MoreThanJava#part3-redis + +![](https://upload-images.jianshu.io/upload_images/7896890-a408d790b0b4f4b9.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +# 一、HyperLogLog 简介 + +**HyperLogLog** 是最早由 [Flajolet](http://algo.inria.fr/flajolet/Publications/FlFuGaMe07.pdf) 及其同事在 2007 年提出的一种 **估算基数的近似最优算法**。但跟原版论文不同的是,好像很多书包括 Redis 作者都把它称为一种 **新的数据结构(new datastruct)** *(算法实现确实需要一种特定的数据结构来实现)*。 + +![](http://wx2.sinaimg.cn/large/006oOWahly1fpsc3t7fnng30ab05tkjl.gif) + +## 关于基数统计 + +**基数统计(Cardinality Counting)** 通常是用来统计一个集合中不重复的元素个数。 + +**思考这样的一个场景:** 如果你负责开发维护一个大型的网站,有一天老板找产品经理要网站上每个网页的 **UV(独立访客,每个用户每天只记录一次)**,然后让你来开发这个统计模块,你会如何实现? + +![](https://upload-images.jianshu.io/upload_images/7896890-a9dbcf6374d482ba.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + + +如果统计 **PV(浏览量,用户没点一次记录一次)**,那非常好办,给每个页面配置一个独立的 Redis 计数器就可以了,把这个计数器的 key 后缀加上当天的日期。这样每来一个请求,就执行 `INCRBY` 指令一次,最终就可以统计出所有的 **PV** 数据了。 + +但是 **UV** 不同,它要去重,**同一个用户一天之内的多次访问请求只能计数一次**。这就要求了每一个网页请求都需要带上用户的 ID,无论是登录用户还是未登录的用户,都需要一个唯一 ID 来标识。 + +你也许马上就想到了一个 *简单的解决方案*:那就是 **为每一个页面设置一个独立的 set 集合** 来存储所有当天访问过此页面的用户 ID。但这样的 **问题** 就是: + +1. **存储空间巨大:** 如果网站访问量一大,你需要用来存储的 set 集合就会非常大,如果页面再一多.. 为了一个去重功能耗费的资源就可以直接让你 **老板打死你**; +2. **统计复杂:** 这么多 set 集合如果要聚合统计一下,又是一个复杂的事情; + +![](https://upload-images.jianshu.io/upload_images/7896890-b8ddfcd39cb46cb5.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + + +## 基数统计的常用方法 + +对于上述这样需要 **基数统计** 的事情,通常来说有两种比 set 集合更好的解决方案: + +### 第一种:B 树 + +**B 树最大的优势就是插入和查找效率很高**,如果用 B 树存储要统计的数据,可以快速判断新来的数据是否存在,并快速将元素插入 B 树。要计算基础值,只需要计算 B 树的节点个数就行了。 + +不过将 B 树结构维护到内存中,能够解决统计和计算的问题,但是 **并没有节省内存**。 + +### 第二种:bitmap + +**bitmap** 可以理解为通过一个 bit 数组来存储特定数据的一种数据结构,**每一个 bit 位都能独立包含信息**,bit 是数据的最小存储单位,因此能大量节省空间,也可以将整个 bit 数据一次性 load 到内存计算。如果定义一个很大的 bit 数组,基础统计中 **每一个元素对应到 bit 数组中的一位**,例如: + +![](https://upload-images.jianshu.io/upload_images/7896890-fb4283ad7dbd89a2.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +bitmap 还有一个明显的优势是 **可以轻松合并多个统计结果**,只需要对多个结果求异或就可以了,也可以大大减少存储内存。可以简单做一个计算,如果要统计 **1 亿** 个数据的基数值,**大约需要的内存**:`100_000_000/ 8/ 1024/ 1024 ≈ 12 M`,如果用 **32 bit** 的 int 代表 **每一个** 统计的数据,**大约需要内存**:`32 * 100_000_000/ 8/ 1024/ 1024 ≈ 381 M` + +可以看到 bitmap 对于内存的节省显而易见,但仍然不够。统计一个对象的基数值就需要 `12 M`,如果统计 1 万个对象,就需要接近 `120 G`,对于大数据的场景仍然不适用。 + +![](https://upload-images.jianshu.io/upload_images/7896890-1ebb3265b4297fa1.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +## 概率算法 + +实际上目前还没有发现更好的在 **大数据场景** 中 **准确计算** 基数的高效算法,因此在不追求绝对精确的情况下,使用概率算法算是一个不错的解决方案。 + +概率算法 **不直接存储** 数据集合本身,通过一定的 **概率统计方法预估基数值**,这种方法可以大大节省内存,同时保证误差控制在一定范围内。目前用于基数计数的概率算法包括: + +- **Linear Counting(LC)**:早期的基数估计算法,LC 在空间复杂度方面并不算优秀,实际上 LC 的空间复杂度与上文中简单 bitmap 方法是一样的(但是有个常数项级别的降低),都是 O(Nmax) +- **LogLog Counting(LLC)**:LogLog Counting 相比于 LC 更加节省内存,空间复杂度只有 O(log2(log2(Nmax))) +- **HyperLogLog Counting(HLL)**:HyperLogLog Counting 是基于 LLC 的优化和改进,在同样空间复杂度情况下,能够比 LLC 的基数估计误差更小 + +其中,**HyperLogLog** 的表现是惊人的,上面我们简单计算过用 **bitmap** 存储 **1 个亿** 统计数据大概需要 `12 M` 内存,而在 **HyperLoglog** 中,只需要不到 **1 K** 内存就能够做到!在 Redis 中实现的 **HyperLoglog** 也只需要 **12 K** 内存,在 **标准误差 0.81%** 的前提下,**能够统计 264 个数据**! + +![](https://upload-images.jianshu.io/upload_images/7896890-439fe643e2dc081a.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +**这是怎么做到的?!** 下面赶紧来了解一下! + +# 二、HyperLogLog 原理 + +我们来思考一个抛硬币的游戏:你连续掷 n 次硬币,然后说出其中**连续掷为正面的最大次数,我来猜你一共抛了多少次**。 + +这很容易理解吧,例如:你说你这一次 *最多连续出现了 2 次* 正面,那么我就可以知道你这一次投掷的次数并不多,所以 *我可能会猜是 5* 或者是其他小一些的数字,但如果你说你这一次 *最多连续出现了 20 次* 正面,虽然我觉得不可能,但我仍然知道你花了特别多的时间,所以 *我说 GUN...*。 + +![](https://upload-images.jianshu.io/upload_images/7896890-2042926c4383c027.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + + +这期间我可能会要求你重复实验,然后我得到了更多的数据之后就会估计得更准。**我们来把刚才的游戏换一种说法**: + +![](https://upload-images.jianshu.io/upload_images/7896890-24e8f48f5e3eb81f.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +这张图的意思是,我们给定一系列的随机整数,**记录下低位连续零位的最大长度 K**,即为图中的 `maxbit`,**通过这个 K 值我们就可以估算出随机数的数量 N**。 + +## 代码实验 + +我们可以简单编写代码做一个实验,来探究一下 `K` 和 `N` 之间的关系: + +```java +public class PfTest { + + static class BitKeeper { + + private int maxbit; + + public void random() { + long value = ThreadLocalRandom.current().nextLong(2L << 32); + int bit = lowZeros(value); + if (bit > this.maxbit) { + this.maxbit = bit; + } + } + + private int lowZeros(long value) { + int i = 0; + for (; i < 32; i++) { + if (value >> i << i != value) { + break; + } + } + return i - 1; + } + } + + static class Experiment { + + private int n; + private BitKeeper keeper; + + public Experiment(int n) { + this.n = n; + this.keeper = new BitKeeper(); + } + + public void work() { + for (int i = 0; i < n; i++) { + this.keeper.random(); + } + } + + public void debug() { + System.out + .printf("%d %.2f %d\n", this.n, Math.log(this.n) / Math.log(2), this.keeper.maxbit); + } + } + + public static void main(String[] args) { + for (int i = 1000; i < 100000; i += 100) { + Experiment exp = new Experiment(i); + exp.work(); + exp.debug(); + } + } +} +``` + +跟上图中的过程是一致的,话说为啥叫 `PfTest` 呢,包括 Redis 中的命令也一样带有一个 `PF` 前缀,还记得嘛,因为 **HyperLogLog** 的提出者上文提到过的,叫 `Philippe Flajolet`。 + +截取部分输出查看: + +```java +//n n/log2 maxbit +34000 15.05 13 +35000 15.10 13 +36000 15.14 16 +37000 15.18 17 +38000 15.21 14 +39000 15.25 16 +40000 15.29 14 +41000 15.32 16 +42000 15.36 18 +``` + +会发现 `K` 和 `N` 的对数之间存在显著的线性相关性:**N 约等于 2k** + +## 更近一步:分桶平均 + +**如果 `N` 介于 2k 和 2k+1 之间,用这种方式估计的值都等于 2k,这明显是不合理的**,所以我们可以使用多个 `BitKeeper` 进行加权估计,就可以得到一个比较准确的值了: + +```java +public class PfTest { + + static class BitKeeper { + // 无变化, 代码省略 + } + + static class Experiment { + + private int n; + private int k; + private BitKeeper[] keepers; + + public Experiment(int n) { + this(n, 1024); + } + + public Experiment(int n, int k) { + this.n = n; + this.k = k; + this.keepers = new BitKeeper[k]; + for (int i = 0; i < k; i++) { + this.keepers[i] = new BitKeeper(); + } + } + + public void work() { + for (int i = 0; i < this.n; i++) { + long m = ThreadLocalRandom.current().nextLong(1L << 32); + BitKeeper keeper = keepers[(int) (((m & 0xfff0000) >> 16) % keepers.length)]; + keeper.random(); + } + } + + public double estimate() { + double sumbitsInverse = 0.0; + for (BitKeeper keeper : keepers) { + sumbitsInverse += 1.0 / (float) keeper.maxbit; + } + double avgBits = (float) keepers.length / sumbitsInverse; + return Math.pow(2, avgBits) * this.k; + } + } + + public static void main(String[] args) { + for (int i = 100000; i < 1000000; i += 100000) { + Experiment exp = new Experiment(i); + exp.work(); + double est = exp.estimate(); + System.out.printf("%d %.2f %.2f\n", i, est, Math.abs(est - i) / i); + } + } +} +``` + +这个过程有点 **类似于选秀节目里面的打分**,一堆专业评委打分,但是有一些评委因为自己特别喜欢所以给高了,一些评委又打低了,所以一般都要 **屏蔽最高分和最低分**,然后 **再计算平均值**,这样的出来的分数就差不多是公平公正的了。 + +![](https://upload-images.jianshu.io/upload_images/7896890-6c927d25750f20d1.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +上述代码就有 **1024** 个 "评委",并且在计算平均值的时候,采用了 **调和平均数**,也就是倒数的平均值,它能有效地平滑离群值的影响: + +```java +avg = (3 + 4 + 5 + 104) / 4 = 29 +avg = 4 / (1/3 + 1/4 + 1/5 + 1/104) = 5.044 +``` + +观察脚本的输出,误差率百分比控制在个位数: + +```java +100000 94274.94 0.06 +200000 194092.62 0.03 +300000 277329.92 0.08 +400000 373281.66 0.07 +500000 501551.60 0.00 +600000 596078.40 0.01 +700000 687265.72 0.02 +800000 828778.96 0.04 +900000 944683.53 0.05 +``` + +真实的 HyperLogLog 要比上面的示例代码更加复杂一些,也更加精确一些。上面这个算法在随机次数很少的情况下会出现除零错误,因为 `maxbit = 0` 是不可以求倒数的。 + +## 真实的 HyperLogLog + +有一个神奇的网站,可以动态地让你观察到 HyperLogLog 的算法到底是怎么执行的:[http://content.research.neustar.biz/blog/hll.html](http://content.research.neustar.biz/blog/hll.html) + +![](https://upload-images.jianshu.io/upload_images/7896890-72f00a9983a1395e.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +其中的一些概念这里稍微解释一下,您就可以自行去点击 `step` 来观察了: + +- **m 表示分桶个数:** 从图中可以看到,这里分成了 64 个桶; +- **蓝色的 bit 表示在桶中的位置:** 例如图中的 `101110` 实则表示二进制的 `46`,所以该元素被统计在中间大表格 `Register Values` 中标红的第 46 个桶之中; +- **绿色的 bit 表示第一个 1 出现的位置**: 从图中可以看到标绿的 bit 中,从右往左数,第一位就是 1,所以在 `Register Values` 第 46 个桶中写入 1; +- **红色 bit 表示绿色 bit 的值的累加:** 下一个出现在第 46 个桶的元素值会被累加; + + +### 为什么要统计 Hash 值中第一个 1 出现的位置? + +因为第一个 1 出现的位置可以同我们抛硬币的游戏中第一次抛到正面的抛掷次数对应起来,根据上面掷硬币实验的结论,记录每个数据的第一个出现的位置 `K`,就可以通过其中最大值 Kmax 来推导出数据集合中的基数:**N = 2Kmax** + +### PF 的内存占用为什么是 12 KB? + +我们上面的算法中使用了 **1024** 个桶,网站演示也只有 **64** 个桶,不过在 Redis 的 HyperLogLog 实现中,用的是 **16384** 个桶,即:214,也就是说,就像上面网站中间那个 `Register Values` 大表格有 **16384** 格。 + +**而Redis 最大能够统计的数据量是 264**,即每个桶的 `maxbit` 需要 **6** 个 bit 来存储,最大可以表示 `maxbit = 63`,于是总共占用内存就是:**(214) x 6 / 8** *(每个桶 6 bit,而这么多桶本身要占用 16384 bit,再除以 8 转换成 KB)*,算出来的结果就是 `12 KB`。 + +# 三、Redis 中的 HyperLogLog 实现 + +从上面我们算是对 **HyperLogLog** 的算法和思想有了一定的了解,并且知道了一个 **HyperLogLog** 实际占用的空间大约是 `12 KB`,但 Redis 对于内存的优化非常变态,当 **计数比较小** 的时候,大多数桶的计数值都是 **零**,这个时候 Redis 就会适当节约空间,转换成另外一种 **稀疏存储方式**,与之相对的,正常的存储模式叫做 **密集存储**,这种方式会恒定地占用 `12 KB`。 + +## 密集型存储结构 + +密集型的存储结构非常简单,就是 **16384 个 6 bit 连续串成** 的字符串位图: + +![](https://upload-images.jianshu.io/upload_images/7896890-0ba2adb0214afd0c.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +我们都知道,一个字节是由 8 个 bit 组成的,这样 6 bit 排列的结构就会导致,有一些桶会 **跨越字节边界**,我们需要 **对这一个或者两个字节进行适当的移位拼接** 才可以得到具体的计数值。 + +假设桶的编号为 `index`,这个 6 bity 计数值的起始字节偏移用 `offset_bytes` 表示,它在这个字节的其实比特位置偏移用 `offset_bits` 表示,于是我们有: + +```python +offset_bytes = (index * 6) / 8 +offset_bits = (index * 6) % 8 +``` + +前者是商,后者是余数。比如 `bucket 2` 的字节偏移是 1,也就是第 2 个字节。它的位偏移是 4,也就是第 2 个字节的第 5 个位开始是 bucket 2 的计数值。需要注意的是 **字节位序是左边低位右边高位**,而通常我们使用的字节都是左边高位右边低位。 + +这里就涉及到两种情况,**如果 `offset_bits` 小于等于 2**,说明这 **6 bit 在一个字节的内部**,可以直接使用下面的表达式得到计数值 `val`: + +```python +val = buffer[offset_bytes] >> offset_bits # 向右移位 +``` + +**如果 `offset_bits` 大于 2**,那么就会涉及到 **跨越字节边界**,我们需要拼接两个字节的位片段: + +```python +# 低位值 +low_val = buffer[offset_bytes] >> offset_bits +# 低位个数 +low_bits = 8 - offset_bits +# 拼接,保留低6位 +val = (high_val << low_bits | low_val) & 0b111111 +``` + +不过下面 Redis 的源码要晦涩一点,看形式它似乎只考虑了跨越字节边界的情况。这是因为如果 6 bit 在单个字节内,上面代码中的 `high_val` 的值是零,所以这一份代码可以同时照顾单字节和双字节: + +```c +// 获取指定桶的计数值 +#define HLL_DENSE_GET_REGISTER(target,p,regnum) do { \ + uint8_t *_p = (uint8_t*) p; \ + unsigned long _byte = regnum*HLL_BITS/8; \ + unsigned long _fb = regnum*HLL_BITS&7; \ # %8 = &7 + unsigned long _fb8 = 8 - _fb; \ + unsigned long b0 = _p[_byte]; \ + unsigned long b1 = _p[_byte+1]; \ + target = ((b0 >> _fb) | (b1 << _fb8)) & HLL_REGISTER_MAX; \ +} while(0) + +// 设置指定桶的计数值 +#define HLL_DENSE_SET_REGISTER(p,regnum,val) do { \ + uint8_t *_p = (uint8_t*) p; \ + unsigned long _byte = regnum*HLL_BITS/8; \ + unsigned long _fb = regnum*HLL_BITS&7; \ + unsigned long _fb8 = 8 - _fb; \ + unsigned long _v = val; \ + _p[_byte] &= ~(HLL_REGISTER_MAX << _fb); \ + _p[_byte] |= _v << _fb; \ + _p[_byte+1] &= ~(HLL_REGISTER_MAX >> _fb8); \ + _p[_byte+1] |= _v >> _fb8; \ +} while(0) +``` + +## 稀疏存储结构 + +稀疏存储适用于很多计数值都是零的情况。下图表示了一般稀疏存储计数值的状态: + +![](https://upload-images.jianshu.io/upload_images/7896890-9d5a9018d2eedbd8.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +当 **多个连续桶的计数值都是零** 时,Redis 提供了几种不同的表达形式: + +- `00xxxxxx`:前缀两个零表示接下来的 6bit 整数值加 1 就是零值计数器的数量,注意这里要加 1 是因为数量如果为零是没有意义的。比如 `00010101` 表示连续 `22` 个零值计数器。 +- `01xxxxxx yyyyyyyy`:6bit 最多只能表示连续 `64` 个零值计数器,这样扩展出的 14bit 可以表示最多连续 `16384` 个零值计数器。这意味着 HyperLogLog 数据结构中 `16384` 个桶的初始状态,所有的计数器都是零值,可以直接使用 2 个字节来表示。 +- `1vvvvvxx`:中间 5bit 表示计数值,尾部 2bit 表示连续几个桶。它的意思是连续 `(xx +1)` 个计数值都是 `(vvvvv + 1)`。比如 `10101011` 表示连续 `4` 个计数值都是 `11`。 + +注意 *上面第三种方式* 的计数值最大只能表示到 `32`,而 HyperLogLog 的密集存储单个计数值用 6bit 表示,最大可以表示到 `63`。**当稀疏存储的某个计数值需要调整到大于 `32` 时,Redis 就会立即转换 HyperLogLog 的存储结构,将稀疏存储转换成密集存储。** + +## 对象头 + +HyperLogLog 除了需要存储 16384 个桶的计数值之外,它还有一些附加的字段需要存储,比如总计数缓存、存储类型。所以它使用了一个额外的对象头来表示: + +```c +struct hllhdr { + char magic[4]; /* 魔术字符串"HYLL" */ + uint8_t encoding; /* 存储类型 HLL_DENSE or HLL_SPARSE. */ + uint8_t notused[3]; /* 保留三个字节未来可能会使用 */ + uint8_t card[8]; /* 总计数缓存 */ + uint8_t registers[]; /* 所有桶的计数器 */ +}; +``` + +所以 **HyperLogLog** 整体的内部结构就是 **HLL 对象头** 加上 **16384** 个桶的计数值位图。它在 Redis 的内部结构表现就是一个字符串位图。你可以把 **HyperLogLog 对象当成普通的字符串来进行处理:** + +```console +> PFADD codehole python java golang +(integer) 1 +> GET codehole +"HYLL\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x80C\x03\x84MK\x80P\xb8\x80^\xf3" +``` + +但是 **不可以** 使用 **HyperLogLog** 指令来 **操纵普通的字符串**,**因为它需要检查对象头魔术字符串是否是 "HYLL"**。 + +# 四、HyperLogLog 的使用 + +**HyperLogLog** 提供了两个指令 `PFADD` 和 `PFCOUNT`,字面意思就是一个是增加,另一个是获取计数。`PFADD` 和 `set` 集合的 `SADD` 的用法是一样的,来一个用户 ID,就将用户 ID 塞进去就是,`PFCOUNT` 和 `SCARD` 的用法是一致的,直接获取计数值: + +```console +> PFADD codehole user1 +(interger) 1 +> PFCOUNT codehole +(integer) 1 +> PFADD codehole user2 +(integer) 1 +> PFCOUNT codehole +(integer) 2 +> PFADD codehole user3 +(integer) 1 +> PFCOUNT codehole +(integer) 3 +> PFADD codehole user4 user 5 +(integer) 1 +> PFCOUNT codehole +(integer) 5 +``` + +我们可以用 Java 编写一个脚本来试试 HyperLogLog 的准确性到底有多少: + +```java +public class JedisTest { + public static void main(String[] args) { + for (int i = 0; i < 100000; i++) { + jedis.pfadd("codehole", "user" + i); + } + long total = jedis.pfcount("codehole"); + System.out.printf("%d %d\n", 100000, total); + jedis.close(); + } +} +``` + +结果输出如下: + +```java +100000 99723 +``` + +发现 `10` 万条数据只差了 `277`,按照百分比误差率是 `0.277%`,对于巨量的 UV 需求来说,这个误差率真的不算高。 + +当然,除了上面的 `PFADD` 和 `PFCOUNT` 之外,还提供了第三个 `PFMEGER` 指令,用于将多个计数值累加在一起形成一个新的 `pf` 值: + +```console +> PFADD nosql "Redis" "MongoDB" "Memcached" +(integer) 1 + +> PFADD RDBMS "MySQL" "MSSQL" "PostgreSQL" +(integer) 1 + +> PFMERGE databases nosql RDBMS +OK + +> PFCOUNT databases +(integer) 6 +``` + +# 相关阅读 + +1. Redis(1)——5种基本数据结构 - [https://www.wmyskxz.com/2020/02/28/redis-1-5-chong-ji-ben-shu-ju-jie-gou/](https://www.wmyskxz.com/2020/02/28/redis-1-5-chong-ji-ben-shu-ju-jie-gou/) +2. Redis(2)——跳跃表 - [https://www.wmyskxz.com/2020/02/29/redis-2-tiao-yue-biao/](https://www.wmyskxz.com/2020/02/29/redis-2-tiao-yue-biao/) +3. Redis(3)——分布式锁深入探究 - [https://www.wmyskxz.com/2020/03/01/redis-3/](https://www.wmyskxz.com/2020/03/01/redis-3/) + +# 扩展阅读 + +1. 【算法原文】HyperLogLog: the analysis of a near-optimal +cardinality estimation algorithm - [http://algo.inria.fr/flajolet/Publications/FlFuGaMe07.pdf](http://algo.inria.fr/flajolet/Publications/FlFuGaMe07.pdf) + +# 参考资料 + +1. 【Redis 作者博客】Redis new data structure: the HyperLogLog - [http://antirez.com/news/75](http://antirez.com/news/75) +2. 神奇的HyperLogLog算法 - [http://www.rainybowe.com/blog/2017/07/13/%E7%A5%9E%E5%A5%87%E7%9A%84HyperLogLog%E7%AE%97%E6%B3%95/index.html](http://www.rainybowe.com/blog/2017/07/13/%E7%A5%9E%E5%A5%87%E7%9A%84HyperLogLog%E7%AE%97%E6%B3%95/index.html) +3. 深度探索 Redis HyperLogLog 内部数据结构 - [https://zhuanlan.zhihu.com/p/43426875](https://zhuanlan.zhihu.com/p/43426875) +4. 《Redis 深度历险》 - 钱文品/ 著 + + +> - 本文已收录至我的 Github 程序员成长系列 **【More Than Java】,学习,不止 Code,欢迎 star:[https://github.com/wmyskxz/MoreThanJava](https://github.com/wmyskxz/MoreThanJava)** +> - **个人公众号** :wmyskxz,**个人独立域名博客**:wmyskxz.com,坚持原创输出,下方扫码关注,2020,与您共同成长! + +![](https://upload-images.jianshu.io/upload_images/7896890-fca34cfd601e7449.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) + +非常感谢各位人才能 **看到这里**,如果觉得本篇文章写得不错,觉得 **「我没有三颗心脏」有点东西** 的话,**求点赞,求关注,求分享,求留言!** + +创作不易,各位的支持和认可,就是我创作的最大动力,我们下篇文章见! \ No newline at end of file