> For the complete documentation index, see [llms.txt](https://nixum.gitbook.io/note/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://nixum.gitbook.io/note/redis.md).

# Redis

\[TOC]

## 数据类型及结构

Redis里存的key和value都是二进制数据，即 byte数组，本身是二进制安全的（二进制安全的意思是，存进去怎么样，取出来就是怎么样，程序不会对原始数据进行任意的操作）

### 数据类型

具体的分为5种基本类型和3种特殊类型：

String、List(一般当成队列，尽量少使用随机读写)、Hash、Set、ZSet

* **String**：简单动态字符串(SDS)

  场景：缓存（可以使用json、protobuf等进行序列化反序列化、也可以通过key来分离缓存对象的属性，实现hash的效果，只是是单字段罢了）、token、限流、分布式id
* **List**：压缩列表（元素数量小于512，且所有元素的长度都小于64字节） + 双向链表，高版本是快表（其他情况使用）

  场景：用户消息时间线、消息队列
* **Hash**：压缩列表（元素数量小于512，且key和value字符串长度都小于64字节） + 哈希表（其他情况使用）

  场景：存储对象缓存（可以更方便get、set对应字段）、根据key进行统计
* **Set**：数组（带有编码类型字段，所以元素可以使用整型表示，少于512个时使用）+ 哈希表（key为set中的元素，value为null，其他情况使用）

  场景：交集、并集、点赞、签到、随机弹出获取元素
* **ZSet**：压缩列表（元素小于128个，且所有元素的长度小于64字节时使用） + 跳表 （其他情况使用）

  场景：排行榜

另外三种扩展类型：

* **Bitmap**：位存储，基于String，原理：String类型会保存二进制字节数组，只有0和1两个值，对于这个字节数组的每个bit来表示一个元素的二值状态；

  场景：二值统计，如签到统计、记录每个月签到情况、判断用户登录状态
* **HyperLogLog**：基数统计，类似set，不断往里add值，然后判断有总数量；主要作用是使用少量固定的内存（12KB内存即可统计2^64个不同元素）去存储并识别有大量元素的集合中的唯一元素，能快速算出集合内的元素个数，误差率0.81%；版本2.8.9之后才有

  场景：百万级别网络的UV计数
* **Geo**：推算地理位置，比如两地之间的距离，方圆几里的人；版本3.2之后才有
* **Stream**：5.0之后的版本才有，Stream会在第一次使用 `xadd` 指令追加消息时自动创建。
  * `Consumer Group`：消费组，一个消费组有多个消费者，这些消费者是竞争关系；
  * `Last_delivered_id`：游标，每个消费组的游标，组内任意一个消费者读取了消息都会使游标向前移动；
  * `pending_ids`：消费者的状态变量，用于维护消费者未确认的id，记录当前已经被客户端读取但还没有被ACK的消息。如果客户端没有ACK，这个变量里面的消息ID会越来越多，一旦某个消息被ACK，它就开始减少，用来确保客户端至少消费了消息一次，而不会在网络传输的中途丢失了没处理。
  * 消息ID：组成方式`毫秒级时间戳 - 序号`，消息ID也可以自定义，但必须是整数-整数，且能后面加入的消息ID要大于前面的消息ID。Redis本身会记录 `lastest_generated_id`，防止时间回拨导致ID问题；
  * 消息内容：键值对，类似Hash的结构；

![](https://github.com/Nixum/Java-Note/raw/master/picture/Redis_Stream.png)

### 底层数据结构

Redis所有类型有一个顶层的数据结构叫RedisObject，这个RedisObject底层对应着具体对象类型和其编码方式。

之所以有RedisObject对象，是因为每种不同的数据类型有不同的编码方式和结构，Redis必须让每个键都带有类型信息，使得程序可以检查键的类型，还需要根据数据类型的不同编码进行**多态**处理。

比如：SADD命令只能用于Set，LPUSH命令只能用于List，而DEL、TTL又能用于所有键，要正确实现这些命令，就需要为不同类型的键设置不同的处理方式；另外，同一种数据类型可能由不同的数据结构实现，比如List，底层可能是压缩列表或者双向链表，因此还需要知道数据类型的编码方式。

```c
typedef struct redisObject {
    // 类型，如String、List
    unsigned type:4;
    // 编码方式：如SDS、压缩列表、跳表等
    unsigned encoding:4;
    // LRU - 24位, 记录最后一次访问时间（相对于lru_clock）; 或者 LFU（最少使用的数据：8位频率，16位访问时间）
    unsigned lru:LRU_BITS; // LRU_BITS: 24
    // 引用计数，新创建对象时值为1，当对该对象进行共享时值+1，使用完一个对象后值-1，=0时被GC回收
    int refcount;
    // 指向底层数据结构实例，具体的实例，由type和encoding决定
    void *ptr;

} robj;
```

> **当执行一个处理数据类型命令的时候，redis执行以下步骤**
>
> 1. 根据给定的key，在数据库字典中查找和他相对应的redisObject，如果没找到，就返回NULL；
> 2. 检查redisObject的type属性和执行命令所需的类型是否相符，如果不相符，返回类型错误；
> 3. 根据redisObject的encoding属性所指定的编码，选择合适的操作函数来处理底层的数据结构；
> 4. 返回数据结构的操作结果作为命令的返回值

Redis本身还会预分配一些值对象，比如响应结果OK、ERROR，还有包括0在内，所有小于REDIS\_SHARED\_INTEGERS（默认值是10k，`[0 - 9999]`）的所有整数，共享对象只能被字典或双向链表这类带有指针的数据结果使用。

![](https://github.com/Nixum/Java-Note/raw/master/picture/RedisObject.png)

#### 简单动态字符串 - SDS

Redis的String类型底层有两种保存形式，当保存的是64位有符号整数时，String类型会保存为一个9字节的Long类型整数；当保存的数据包含字符时，String类型就会用简单动态字符串SDS。

简单动态字符串SDS由三个部分组成：

* `buf`：是字节数组，保存实际数据，结束标志位是"/0"；
* `len`：表示buf已用长度，占4字节；SDS不使用 "/0" 来判断字符串是否结束，而是通过 `len` 来判断；`len` 同时也能防止缓冲区溢出；
* `alloc`：表示buf的实际分配长度，一般大于len；

`len`和`alloc`两个属性，对于修改字符串SDS实现了空间预分配和惰性空间释放两种策略：

* 空间预分配：当字符串进行扩展时，扩展的内存会比实际需要的多，减少连续字符串增长带来的内存频繁分配。

  当字符串长度小于 1M 时，扩容时双倍扩容，如果超过 1M，扩容一次只会多扩 1M 的空间，默认的最大长度是 512M，可通过配置文件修改。

  执行Append命令的字符串会带有预分配的空间，这些预分配的空间不会被释放，除非对应的key被删除，或者重启后重新载入，才会删除预分配的空间，如果有存在大量预分配的字符串，可以修改配置使其定时释放预分配的空间，更有效的使用内存。
* 惰性空间释放：当字符串进行缩短时，不会使用内存重新分配的方式来回收多余字节，而只是用`alloc` 记录这些字节的数量，等待后续使用。

RedisObject包含了8个字节的元数据和一个8字节指针，指针指向具体的数据类型的实际数据所在，

对于String类型的RedisObject：

* 当保存的是Long类型整数时，RedisObject中的指针直接就是整数数据，不用额外的指针指向整数；
* 当保存的是字符串时，如果字符串<=44字节，RedisObject中元数据，指针和SDS是一块连续的内存区域，避免内存碎片；
* 当保存的是字符串时，如果字符串>44字节，RedisObject会给SDS分配独立的空间，并用指针指向SDS；

![Redis String RedisObject](https://github.com/Nixum/Java-Note/raw/master/picture/Redis_String_RedisObject.png)

当使用String类型时，且value的类型是String时，如果value的长度太小，可能会出现元数据的大小比数据本身的大小还大，造成额外的内存开销，如果能替换成Long类型，实际存储的大小会大大降低。

#### 哈希表 - Dict

![Redis Dict](https://github.com/Nixum/Java-Note/raw/master/picture/Redis_%E5%85%A8%E5%B1%80%E5%93%88%E5%B8%8C%E8%A1%A8.png)

无论值是什么类型的，所有的键值对会保存在**全局哈希表**中，便于快速找到对应的Key，哈希桶只会保存键值对的指针。全局哈希表中的桶每个元素entry由三个8字节指针组成，分别为key、value、next，但实际会占32字节，因为内存分配库jemalloc会分配最接近24的2的幂次数，所以是32，以减少频繁的分配次数。

因此，即使Redis里存在大量数据，也不影响查找的速度，毕竟都是根据Key进行hash就能找到对应的Value，真正有影响的是哈希表的在解决哈希冲突和rehash时带来的阻塞。

**Redis的哈希表使用拉链法解决哈希冲突，并通过两个全局哈希表加快rehash的操作。**

![Redis Dict](https://github.com/Nixum/Java-Note/raw/master/picture/Redis_Dict.png)

处理全局哈希表有这种操作，Hash的数据结构也是这样的操作，本质是一样的。

**rehash触发条件**

当Redis生成RDB和AOF重写时，哈希表不会进行rehash。

装载因子：哈希表中所有entry的个数 除以 哈希表的哈希桶个数。

* 当装载因子>= 1，且哈希表被允许rehash，即此时没有进行RDB和AOF重写，即没有执行或正在执行 `BGSAVE` 命令或者 `BGREWRITEAOF` 命令
* 当装载因子>= 5，因为此时数据量已远远大于哈希桶的个数了，此时会立马进行rehash，不管是否有没有在执行RDB快照或AOF重写，都会强制进行rehash操作；

**rehash过程**

1. 默认使用哈希表1，此时哈希表2还没有被分配空间
2. 当数据增多至需要rehash时，为哈希表2分配空间，大小会比哈希表1大，比如**大两倍**
3. 把哈希表1中的数据重新映射并拷贝到哈希表2中

   **渐进式rehash**：解决大量数据在哈希表1和2之间拷贝，会导致Redis线程阻塞的问题（因为单线程)。

   3.1 拷贝数据时，Redis仍然正常处理客户端请求，每处理一个请求时，从哈希表1中的第一个索引位置开始，顺便将该索引位置上的所有entries拷贝到哈希表2中；

   3.2 等待处理下一个请求时，再顺带拷贝哈希表1中该索引下一个索引位置的entries到哈希表2中；

   通过这两种方式，将一次性的大量拷贝分散到每次请求和等待间隙中。

   3.3 此外Redis本身也有一个**定时任务**在执行rehash，发生在空闲时间
4. 释放哈希表1的空间，此时哈希表1的空间被回收，原来的哈希表2变成哈希表1，哈希表1变成哈希表2

渐进式rehash过程中，如果有新的键值对存进来，Redis会把该键值对放到哈希表2中，确保哈希表1里的键值对只会减少；如果是有查询进来，则会先查询哈希表1，再查询哈希表2。

**底层的两种实现**

对于Hash数据类型，底层有压缩列表和哈希表两种实现，当底层使用压缩列表时，存储的时候是以key、value的顺序存储的，新增的键值对保存在表尾；使用压缩列表时，底层是无法利用index查找，只能遍历查找。

当Hash 集合中写入的元素个数超过了 `hash-max-ziplist-entries`，或者写入的单个元素大小超过了 `hash-max-ziplist-value`，Redis 就会自动把 Hash 类型的实现结构由压缩列表转为哈希表，且之后不可逆。

#### 压缩列表 - ZipList

![Redis Ziplist](https://github.com/Nixum/Java-Note/raw/master/picture/redis_%E5%8E%8B%E7%BC%A9%E5%88%97%E8%A1%A8.png)

本质上是一个数组，数组中每一个元素保存一个数据。压缩列表的结构：

* 表头有三个字段：
  * `zlbytes`记录整个压缩列表占用的内存字节数，可算出列表长度；
  * `zltail`记录列表最后一个`entry`的偏移量，可算出尾节点到列表起始地址的字节数，快速完成pop操作；
  * `zllen`记录列表中的`entry`个数，只占用2bytes，如果压缩列表中`entry`的数目小于 65535 ，2的16次方，该字段存储的就是实际的`entry`数，如果超过或等于该值，则实际数量要遍历`entry`才能得到；
* 每个节点元素`entry`有四个字段：`previous_entry_length`记录前一个节点的长度；`encoding`记录`content`的数据类型和长度；`content`保存元素的值；`len`表示自身长度。
* 表尾有一字段：`zlend`终止字符，用于标记列表末端。

数据类型List、Hash、ZSet都有使用到压缩列表。

压缩列表可以存储字符串或整数，存储整数时采用整数的二进制而不是字符串形式存储，能在O(1)时间复杂度下完成 list 两端的 push 和 pop 操作，由于每次操作都需要重新分配压缩列表的内存，so实际复杂度跟其内存使用量有关。

压缩列表的优势在于存储结构，普通数组要求数组的每个元素的大小相同，但是当我们需要在每个元素中存储大小不同的字符串时，就会浪费存储空间，压缩列表就是会把每个元素多余的空间进行压缩，让每个元素紧密相连，再为每个元素增加一个长度，用于计算下一个元素在内存中的位置。

另外，在内存的地址查找时，在查找第一个和最后一个的时候有优势，可以利用表头的三个字段查到，是O(1)，但是因为存储紧凑的缘故，查找其他元素只能遍历，是O(n)。

压缩列表或者数组主要因为其数据结构紧凑，节省空间，避免内存碎片，提升内存利用率，线性顺序存储，对CPU高速缓存支持友好。对于查找的时间复杂度的优势提升不大。但是，由于压缩列表比较紧凑，在新增更新删除操作时可能会引发连锁更新，此时最坏为O(n^2)，但触发概率相对较低，利大于弊。

#### 快表 - Quick List

3.2版本后才添加，之前的版本是双向链表，本质上是一个链表，只是每个节点都是一个压缩列表。

快表为了解决压缩列表增删元素时的连锁反应导致的性能下降，会控制每个链表节点中的压缩列表的大小或元素个数，来避免这个问题，因为压缩列表越小或元素个数越少，新增或删除时带来的影响就越小。

#### 跳表 - Skip List

Redis只有ZSet对象底层使用跳表，ZSet结构体里有两个数据结构，一个是跳表，一个是哈希表，哈希表只在用在获取权重，大部分操作还是通过跳表实现；

![Redis Ziplist](https://github.com/Nixum/Java-Note/raw/master/picture/rredis_%E8%B7%B3%E8%A1%A8.png)

先排序分数，分数相同时，再排序元素值，默认是从小到大排。

跳表本质是为链表增加索引，建立多层索引，查找时从顶层的索引开始逐步往下层找，最终定位到元素，适用于范围查询的场景。跳表的相邻两层的节点数量最理想的比例是 2:1，查找的时间复杂度为O(logN)

但是，redis在创建跳表时，随机生成每个节点的层数，没有严格维持相邻两层节点2:1的比例，比如在创建节点时，会生成范围为\[0-1]的一个随机数，如果这个随机数小于0.25，那么层数加一，然后生成下一个随机数，直到改随机数大于0.25，最终确定改节点的层数；这样做相当于每增加一层的概率不超过25%，层数越高，概率越低，层高最大限制是64；Redis 7.0 定义层高为 32，Redis 5.0 定义为 64，Redis 3.0 定义为 32

参考：[Redis底层数据结构详解](https://www.pdai.tech/md/db/nosql-redis/db-redis-x-redis-ds.html)

> Skip List和各种平衡树（如AVL、红黑树等）的元素是有序排列的，而哈希表不是有序的。因此，在哈希表上只能做单个key的查找，不适宜做范围查找。
>
> 在做范围查找的时候，平衡树比Skip List操作要复杂。在平衡树上，我们找到指定范围的小值之后，还需要以中序遍历的顺序继续寻找其它不超过大值的节点。如果不对平衡树进行一定的改造，这里的中序遍历并不容易实现。而在skiplist上进行范围查找就非常简单，只需要在找到小值之后，对第1层链表进行若干步的遍历就可以实现。
>
> 平衡树的插入和删除操作可能引发子树的调整，逻辑复杂，而skiplist的插入和删除只需要修改相邻节点的指针，操作简单又快速。
>
> 从内存占用上来说，Skip List比平衡树更灵活一些。一般来说，平衡树每个节点包含2个指针（分别指向左右子树），而Skip List每个节点包含的指针数目平均为1/(1-p)，具体取决于参数p的大小。如果像Redis里的实现一样，取p=1/4，那么平均每个节点包含1.33个指针，比平衡树更有优势。
>
> 查找单个key，Skip List和平衡树的时间复杂度都为O(log n)，大体相当；而哈希表在保持较低的哈希值冲突概率的前提下，查找时间复杂度接近O(1)，性能更高一些。所以我们平常使用的各种Map或dictionary结构，大都是基于哈希表实现的。
>
> 参考：<https://www.jianshu.com/p/8ac45fd01548>

#### 时间复杂度

对各种数据类型操作的时间复杂度取决于底层的数据结构，对于Set，虽然名字看起来是集合，但由于底层是哈希表 + 数组，因此在SREM、SADD、SRANDMENBER命令时，时间复杂度都是O(1)

* 数据类型的范围查询，都需要进行遍历操作，一般都是比较耗时的。比如List的LRANGE、ZSet的ZRANGE、Set的SMEMBERS等
* 数据类型的统计查询，比如查看某数据类型的元素个数，时间复杂度是O(1)，因为数据结构本身就有记录了。

## 与Memcached的区别

* Redis支持存储多种数据类型：string、list、hash、set、zset；而Memcached只支持string；
* 默认情况下，Redis key的最大上限是512M，string类型、list、hash、set、zset每个元素的value上限是512M，可以通过`proto_max_bulk_len`的值进行修改；memcached key的上限是50个字符，value最大值是1M；
* Redis支持持久化：RDB快照和AOF日志；Memcached不支持持久化；
* Redis支持事务，使用MULTI 和 EXEC命令，支持流水线式发送命令 ；Memcahced不支持事务，命令只能一条一条的发，当然这里的事务并不满足传统意义上的ACID；
* Redis-Cluster 支持分布式存储，可以多台Redis服务器存储同样的数据；Memcached是以**一致性哈希算法**实现分布式存储，即多台Memcached服务器，Memcached根据查找的key计算出该数据在哪台服务器上；
* 在 Redis 中，并不是所有数据都一直存储在内存中，可以将一些很久没用的 value 交换到磁盘；

  Memcached 的数据则会一直在内存中，Memcached使用固定空间分配，将内存分为一组大小不同的slab\_class，每个slab\_class又分为一组大小相同的slab，每个slab又包含一组大小相同的chunk，根据数据大小，放到不同的chunk里，这种管理方式避免内存碎片管理问题，但是会带来内存浪费，即每个chunk内会放小于这个chunk大小的数据，chunk里有些空间没利用到；

**一致性哈希算法**，主要解决传统哈希下节点扩容或缩容导致所有key要重新计算迁移的问题。

1. 构造一个长度为2的32次方的整数环，即0 \~ (2^32)-1的数字空间；
2. 根据节点的唯一名称或者ip作为关键字进行哈希，确定节点在这个Hash环上的位置；
3. 根据数据的key值计算Hash值，在Hash环上顺时针查找距离这个Key的Hash值最近的节点，完成key到节点的Hash映射查找；
4. 当一个节点扩容或缩容时，只会影响到其中一个节点上的key，此时需要将该节点上的key重新计算，迁移到离它最近的一个节点，其他节点上的key则不会受影响，避免大量数据迁移，减少服务器压力。
5. 为了解决负载不均衡问题（比如节点的数量很少，大量的数据就有可能都集中在上面），可以在此基础上增加一个虚拟层，对每个节点的关键字多计算几次哈希，每次计算的结果作为在环中的位置；key算完哈希值后，先在环上找到虚拟节点，再找到物理节点，将数据分散到各个节点，一般一个物理节点对应150个虚拟节点。

[一致性哈希参考](https://zhuanlan.zhihu.com/p/98030096)

## Redis的线程模型

> 对于一个 DB 来说，CPU 通常不会是瓶颈，因为大多数请求不会是 CPU 密集型的，而是 I/O 密集型。具体到 Redis 的话，如果不考虑 RDB/AOF 等持久化方案，Redis 是完全的纯内存操作，执行速度是非常快的，因此这部分操作通常不会是性能瓶颈，Redis 真正的性能瓶颈在于网络 I/O，也就是客户端和服务端之间的网络传输延迟，因此 Redis 选择了单线程的 I/O 多路复用来实现它的核心网络模型。

### 单线程模型

**Redis的单线程，指的是 网络IO 和 键值对读写 由一个线程来完成**，其他的如持久化、异步删除、集群数据同步都有额外的线程完成。Redis在6.0版本后才支持IO多线程的网络模型。

Redis单线程之所以能处理得很快，得益于高效的数据结构，且采用了**多路复用机制**，在网络IO操作中能并发处理大量客户端请求，同时为了支持多种数据结构，保证并发安全，单线程模型下避免加锁解锁带来额外的开销。

监听 + 事件驱动 + 回调 的方式，处理易阻塞的accept和recv事件，是一个单Reactor模型，利用 epoll / select / kqueue 等多路复用技术，在单线程的事件循环中不断去处理事件（客户端请求）。

[一文揭秘单线程的Redis为什么这么快?](https://zhuanlan.zhihu.com/p/57089960?utm_source=wechat_session\&utm_medium=social\&utm_oi=632939468966072320)

[Redis 多线程网络模型全面揭秘](https://strikefreedom.top/multiple-threaded-network-model-in-redis)

总结一下就是：高效的数据结构和数据压缩、纯内存操作、非阻塞IO多路复用，避免频繁的上下文切换、避免同步机制的开销。

### 多线程模型

Redis 4.0之后引入多线程来做一些异步操作，比如以前`DEL`命令在删除big key和大量key时是阻塞操作，为了解决这个问题引入多线程使得部分命令支持多线程的异步操作，比如`UNLINK`、`FLUSHALL ASYNC`、`FLUSHDB ASYNC`，此时的网络IO模型仍然是单线程的（单Reactor模型）

Redis 6.0之后才引入多线程的网络IO模型（多Reactor模型，但不是标准的多Reactor模型），总的流程还是跟单线程模型一样，只是把读取客户端命令和回写响应数据的操作交由I/O线程去执行异步化，**I/O 线程仅仅是读取和解析客户端命令而不会真正去执行命令，客户端命令的执行最终还是要在主线程上完成**；

### 影响性能的场景

关于big key：对于字符串类型，其value的大小超过10KB；集合类类型，其元素的个数超过10k；

* 对big key的操作，比如创建和删除，big key在分配内存和释放内存会比较耗时。big key意味着value很大，当进行全量查询返回、聚合操作、全量删除(因为要释放内存)时可能会阻塞主线程。

  一般可以配合scan命令以及对应数据类型的scan命令来增量获取或删除。
* 一些命令对应的操作时间复杂度高，比如范围查询、keys命令。

  一般使用scan代替keys命令，scan命令时因为使用高位进位法遍历，所以即使在扩容情况下也不会漏key，但在缩容时可能得到重复的key。
* 大量Key集中过期，Redis过期机制也在主线程中执行，大量Key集中过期会导致耗时过长，所以在设置过期时间时要加入随机数。
* 淘汰策略也是在主线程执行的，发生在执行命令之前，会影响用户命令的执行速度。当内存超过Redis内存上限后，每次写入都需要淘汰Key，也会产生耗时，如果淘汰过程中，内存不足，使用 swap 机制，也会阻塞整个主线程；
* 主从全量同步生成RDB快照，虽然采用fork子进程生成数据快照的方式，但fork瞬间也会阻塞主线程（因为涉及页表的复制和分配，虽然copy on write能减少部分影响，如果有大量写入就会出现大量复制了）

  另外，从库在接收RDB快照后，会清空所有键值对，如果量大的话也很耗时，如果RDB文件太大，加载也耗时。
* AOF日志同步写，因为是需要写磁盘的，比如设置为everysec可能会阻塞主线程。
* 切片集群，向其他实例传输哈希槽信息，big key数据的迁移，或者分片内存倾斜。

所以big key基本上是因为占用内存过大，引发了种种问题，阻塞工作线程，导致客户端超时，造成网络阻塞，内存分布不均等。

改进的话，就只有删除操作和AOF日志同步写可以放在异步线程去做，Redis4.0以后才提供删除和AOF同步写的异步命令。

### 使用规范和优化

* String类型的数据不要超过10KB，避免BigKey，value可以使用压缩算法进行压缩来减少大小；
* Key的长度尽量短，尽量控制在39字节以内，减少创建RedisObject内存分配次数，提升性能；
* 当big key真的无法避免时，需要对big key拆分成多个；
* 定时清理 hash 中无效的数据，Redis >= 4.0 ，可以使用 unlink 命令代替 del 命令删除key，该命令会进行异步删除；Redis >= 6.0 ，可以开启 lazy-free机制，执行del命令时，释放内存的操作放到后台线程执行；
* 如果需要频繁操作redis，可以使用pipeline，合并多个命令请求，减少IO次数，也就减少了 CPU上下文切换，当然，pipeline本身组装命令的个数也不能太多
* key的过期时间加上随机值，避免key的集中过期；

  利用Redis分区功能，将数据分散到各个分区，减轻多个Redis内存淘汰的负载压力；
* 集合类型的元素个数不超过一万个；
* 绑核，绑定在同一个物理核心的多个逻辑核心上，让他们能公用L1/L2 Cache，降低Redis在多个CPU核心之间的上下文切换带来的性能损耗，比如 可以让 主线程、后台线程、后台 RDB 进程、AOF rewrite 进程 绑定在不同的核心上；
* Redis实例容量控制在10GB内；
* 禁用Keys、FlushAll、FlushDB命令，慎用全量操作命令、Monitor命令、复杂度过高的命令（如Sort、Sinter、SinterStore、ZUnionStore、ZInterStore）

### 监控

* `redis-cli -h 127.0.0.1 -p 6379 --bigkeys -i 0.01`，每隔0.01秒扫描一次big key，本质是用scan命令遍历所有key，然后针对key的类型，执行`STRLEN, LLEN, HLEN, SCARD和ZCARD` 命令，来获取 String 类型的长度，容器类型（List, Hash, Set, ZSet）的元素个数，使用时，要注意对线上的影响；
* 使用 redis-rdb-tools 分析 redis rdb文件，因为是离线分析，所以对redis服务没影响
* `redis-cli -h 127.0.0.1 -p 6379 --hotkeys`，扫描hotkey，前提是需要开启 `allkey-LFU`，这样才能根据key的访问次数知道哪些是hotkey
* `INFO memory` 命令查看Redis内存碎片率

## 过期时间和数据淘汰策略

### key过期删除原理

* 定期删除策略：Redis起定时器扫描key（默认100ms），判断key是否过期，过期则删除。虽然可以保证过期的key会被删除，但是每次都要扫描会非常消耗CPU资源，且定时器有间距，有可能出现key过期，但是此时定时器还没起，key仍保存在内存中，处理过程中会阻塞主线程；

  抽样检查那些有过期时间的key，每次获取20个（默认），删除其中过期的key，如果占比超过25%，重复此步骤直到低于25%；
* 惰性删除策略：每次获取key的时候才判断key是否过期，过期则删除，对CPU消耗较少，只处理当前key，但如果key一直未被使用，则会一直留在内存里，浪费空间，甚至导致内存泄漏；

  注意在Redis3.2以前，主库惰性删除后，从库不会触发数据删除，此时还能读到，3.2以后的版本才改正，返回空值，4.0后从库才会定时校验过期key。另外，expire命令在主库设置key的过期时间，到了从库可能有延迟导致过期时间比实际的长，expire命令表示执行完多久会过期，expireat/pexpireat命令表示在某一时间点会过期。
* 所以Redis会将这两种策略整合在一起，定期删除策略不在是每次都扫描全部key，而是随机抽取一部分key进行检查，在配合惰性删除策略，正好可以弥补惰性删除策略的缺点。

### 淘汰策略

当内存使用量超出所设置的maxmemory值时，才会执行淘汰策略。默认的淘汰策略是当内存满了之后，新写入操作会报错。maxmemory值一般设置为总数据量的15%-30%。

其他淘汰策略，分为两种，一种是在所有数据范围内，一种是在设置了过期时间的范围内。

* allkeys-random：在所有key中，随机移除某个key
* allkeys-Lru：在所有key中，移除最近最少使用的key（**优先使用**）
* allkeys-Lfu：在所有key中，移除访问次数最少的某个key
* volatile-random：在所有有设置过期时间的key中，随机移除某个key
* volatile-Lru：在所有有设置过期时间的key中，移除最近最少使用的key
* volatile-ttl：在所有有设置过期时间的key中，有更早过期时间的key优先移除
* volatile-Lfu：在所有有设置过期时间的key中，移除访问次数最少的某个key

#### 淘汰策略中的LRU算法

对于LRU算法，如果一些元素被频繁使用，会导致频繁的移动，带来了额外的开销。

Redis的LRU算法做了简化，其只会在RedisObject中记录每个数据最近以此访问的时间戳，当出现数据淘汰时，第一次会**随机**选出N个数据，作为一个候选集合，比较N个数据的lru字段，把lru字段最小的元素淘汰。

再次淘汰时，Redis会挑选那些LRU的值小于候选集合中最小LRU值的数据，进入第一次淘汰时创建的候选集合，可能会把里面最大的换出去或者淘汰里面最小的。准备候选集和淘汰数据是两个解耦操作。

N的值由`maxmemory-samples`决定。

#### 淘汰策略中的LFU算法

Redis的LRU算法有个问题是对于非热点数据，其访问次数很少，直到触发淘汰策略才会被删除，在被删除之前都会一直留存，造成缓存污染。LFU算法就是为了解决这个问题。

LFU缓存策略是在LRU策略基础上，为每个数据增加一个计数器，统计数据访问次数。当使用LFU淘汰策略筛选淘汰数据时，首先会根据访问次数进行筛选，把访问次数最低的数据淘汰出缓存，当淘汰次数相同时，才会比较时间。

LFU在LRU的基础上，将RedisObject中的访问时间戳拆成两半，16bit存时间，8bit存计数，但计数不是线性递增，而是采用一种计算规则，让其增长不那么快到达2^8=255次，同时也有衰减机制，减少次数对时间的影响。

#### 淘汰执行时机

淘汰时机和被动删除过期key的时机一样，在用户的命令真正执行之前执行淘汰策略；

## 存储与持久化

### RDB快照（Redis DataBase）

RDB快照由于保存的是数据，恢复起来会比AOF快（AOF保存的是命令），而且AOF是文本文件，RDB是二进制文件，所以RDB快照在网络传输、IO效率都比AOF好。

#### 相关命令和配置

`bgsave`：会调子进程创建快照写入磁盘，主线程继续处理其他命令请求；

`save`：主线程创建快照写入磁盘，会阻塞其他命令请求，生产环境中比较少用；

redis.conf 文件里的配置：

`save`：`save [时间] [次数]` 表示在\[时间]内有\[次数]写入/更新，就会触发bgsave命令

另外，在进行主从复制，主redis发送sync命令给从redis时，如果刚刚没有执行完bgsave，也会进行一次bgsave操作。

`rdbcompression`：是否启用LZF算法进行压缩，压缩可以使得RDB快照落盘的文件变小，但在压缩时会消耗CPU资源。

#### 原理

将Redis中的数据全量保存在文件中，一般会异步使用子进程进行刷盘操作，不阻塞主线程，此时主线程仍然能处理命令。（**先全量**）

此外，会使用**Copy on Write机制（写时复制）**，解决创建快照的过程中，原有数据被修改对RDB快照的影响。当主线程对原有数据进行修改前，这块数据会被复制一份（复制引用，由bgsave子进程操作），形成副本（此时会消耗两倍内存），由子进程将该副本写入RDB文件中，由于写的是引用，主线程修改后会同步到RDB中。（**后增量**）

Copy on Write：代码是共享的，数据是私有的，子进程被创建出来后，子进程就将父进程的数据拷贝了一份给自己，如果是只读的数据，父子进程页表指向同一个物理内存，如果子进程或者父进程修改了页表，此时会分离成两份，指向不同的物理内存，让父子进程对这一块数据操作不会相互干扰；

从而减少分配和复制大量资源时带来的瞬时延时，缺点就是，如果写操作很多，就会产生大量的分页中断，产生大量复制，带来延时，当然，因为redis是读多写少的场景，所以还算合适。

#### 优点

* RDB文件是某个时间节点的快照，默认使用LZF算法进行压缩，压缩后的文件体积远远小于内存大小，适用于备份、全量复制等场景；
* Redis加载RDB文件恢复数据要远远快于AOF方式；

#### 潜在风险

* 风险在于快照的创建频率，快照是每隔一段时间进行创建和写入的，如果频繁创建快照，多快照写盘会影响磁盘IO，因此每次都进行全量快照并不可取。
* **如果频率过低，则会导致宕机时丢失的数据过多。解决方式是RDB + AOF一起使用，在两次快照期间用AOF代替。**
* 当使用copy on write机制时，主线程会为其申请额外的空间，当进行频繁的写操作时，会导致内存很快被耗光。当实例系统开启了Swap机制时，超过内存使用量部分会转移到磁盘，访问磁盘的那部分就会很慢，如果没有开启Swap机制，则会触发OOM，Redis进程可能被kill或宕机；
* 另外，当出现频繁的写操作时，由于生成RDB的子进程需要CPU核运行，主线程、多个线程或后台进程会竞争使用CPU，导致性能降低；
* 实时性不够，无法做到秒级别的持久化；
* RDB文件是二进制，没有可读性，而AOF文件可以在了解其结构后手动修改或补全；
* 版本兼容RDB文件问题

### AOF（Append Only File）

#### 相关命令和配置

`bgrewriteaof`：执行后，Redis主进程fork子进程执行**AOF重写**，该子进程会创建新的AOF文件来存储重写结果，防止影响旧文件；

redis.conf文件里的配置：

`appendonly yes`：aof默认是关闭的，如果要打开，设置成yes；

`appendfsync`：

* no：每次命令只写内存(日志缓冲区)，刷盘记日志的操作由操作系统决定，不阻塞主线程，调用fsync，通常最多30s；
* everysec（默认）：写命令记录到文件中，**默认是每秒同步一次**（调用一次fsync），所以如果发生故障，最多会丢失一秒的数据，但使用AOF保存的数据文件比RDB快照要大；**Redis会使用另外的线程进行刷盘；**
* always：此外AOF还能选择每接收一个写命令就追加写入到AOF文件中，虽然能避免不丢数据，**但每个写命令都是在主线程上完成，且后面都跟着一个刷盘操作**，对机器的负担较大，影响服务性能；

`auto-aof-rewrite-percentage 100`：aof文件的容量超过原来aof文件容量一倍的时候, 进行aof文件的重写，默认100；

`auto-aof-rewrite-min-size 64mb`：执行aof重写时, aof文件的最小容量，默认64MB；

#### 原理

AOF时，Redis会先把命令追加近AOF内存缓冲区，然后根据`appendfsync`配置的策略将内存中的数据写入磁盘。

不同与MySQL的WAL机制，AOF是先执行命令将数据写入内存，再写入日志。因为AOF会记录Redis收到的每一条命令，并以文本的形式保存，如果先写日志，并不知道命令是否是正确的，因此先写内存，让系统执行成功后，才会记录到日志中，避免错误命令。

#### 潜在风险

* 执行完命令，还没来得及记录日志就宕机，此时会丢失该命令的记录和相应数据。如果使用Redis当缓存，且要保证Redis宕机时不直接读库，利用Redis的AOF机制时就要注意了。
* AOF是在命令执行后才记录日志，所以不会阻塞当前的写操作，但由于日志的写操作也是在主线程中的，虽然避免阻塞当前操作，但可能会阻塞下一个命令操作，比如刷盘时磁盘IO过慢。

#### 重写机制

一般用于**避免AOF日志文件过大**，毕竟如果文件太大会影响磁盘IO、重放会太耗时。

**原理**

当AOF文件大小超过配置时，执行AOF重写机制。Redis会创建一个新的AOF文件，**读取Redis中所有键值对**后进行写入，但在重写时不是原样copy，而是**会对命令进行合并，以此减小文件大小**。

重写时，主线程会fork后台的bgrewriteiaof子进程，把**主线程的内存拷贝一份给bgrewriteiaof子进程**，其中也包括了Redis的最新数据，bgrewriteiaof子进程逐一写入重写日志，避免阻塞主线程。

因为重写是在子进程中处理的，此时主线程仍然能处理客户端的命令，当接收到客户端的写命令时，**会记录到AOF日志**中，**同时**也会写进**AOF重写日志**的缓冲区，等子进程将拷贝数据写完后，再把缓冲区的数据刷入，完成后即可删除旧的AOF文件，留下新的AOF重写文件。

**潜在风险**

* 主线程fork创建bgrewriteaof子进程时，内核会把主线程的PCB内容拷贝给子进程，此时会阻塞主线程，当要拷贝的内容特别大时，fork执行的时间就会变长，阻塞主线程的时间也会变长。
* bgrewriteaof子进程会和主线程共享内存，当主线程收到新增或修改操作时，主线程会申请新的内存空间用来保存，但是如果是bigkey，主线程就会面临申请空间过大导致耗时。

## 高可用集群

RDB和AOF保证了数据少丢失，集群部署保证服务少中断，实现高可用。

### 主从复制 - 保证数据一致性

一般采用主从读写分离，主库进行写操作，然后再同步给从服务；而读操作发生在主从库均可。最好还是读写都在主库，从库只保证高可用，原因是主从数据同步是异步的，总有可能出现网络波动等问题导致主从库数据不一致，或者主库设置过期时间，但是到从库上有延迟导致读到过期数据，主库的过期删除无法同步到从库（4.0才解决）。

> 当 Redis 运行在主从模式下时，**从库不会进行过期扫描，从库对过期的处理是被动的**。也就是即使从库中的 key 过期了，如果有客户端访问从库时，依然可以得到 key 对应的值，像未过期的键值对一样返回。
>
> 从库的过期键处理依靠主服务器控制，**主库在 key 到期时，会在 AOF 文件里增加一条 del 指令，同步到所有的从库**，从库通过执行这条 del 指令来删除过期的 key。

如果写操作可以发生在主从库上，会导致主从库上的数据不一致，取数据时可能会取到旧值，而如果要保持一致，则需要加锁，加锁的话效率旧太差了。

一般用在**从节点**初始化加入时使用，先进行**全量同步(通过快照)**，再进行**增量同步(通过命令缓存同步)**。

#### 相关命令

在从库上使用命令：Redis 5.0以前使用`slaveof`、之后使用`replicaof [主库IP] [主库端口]`

#### 过程

1. 主库创建RDB快照文件，发送给从库，并在发送期间使用缓冲区(replication buffer)记录之后收到的写命令。

   快照文件发送完毕之后，开始向从库发送存储在缓冲区中的写命令；整个过程中主库不会被阻塞；
2. 从库丢弃所有旧数据，载入主库发来的快照文件，之后从库开始接受主库发来的写命令；
3. 主库每执行一次写命令，就向从库发送相同的写命令；

一般只设置一个主节点，当负载上升时，主库可能无法很快地更新所有从库，或者重新连接和重新同步从库将导致系统超载。因为在一次主从数据同步过程中，主库有两个耗时的操作：生成RDB文件和传输RDB文件，如果从库数量过多，会导致主库忙于fork子进程生成RDB文件进行全量复制，导致阻塞主线程的正常请求，同时传输的带宽也带来了压力。

**解决办法**：通过**主从级联模式**解决，可以设置主节点为根节点，向下延申，从节点再设置从节点的方式，形成树状的主从链，让从节点帮忙同步给其子节点的方式，降低主节点的压力。

同样使用命令slaveof或replicaof，只是ip换成从库的ip，这样就形成了主-从-从的模式了。

### 主从库命令传播时网络中断

Redis2.8之前，如果主从库发生网络中断，重新连接时会进行全量复制，开销巨大。

2.8之后，会使用增量复制。断连期间，主库会把收到的写命令写入缓冲区（`replication buffer`和`repl_backlog_buffer`）。

断连时，会记录从库的偏移量，待重新连接后即可根据偏移量进行同步。由于`repl_backlog_buffer`是环形缓冲区，如果从库同步太慢，因此可能会出现新命令覆盖到未读取的命令，只能通过调整其大小解决，配置`repl_backlog_size`，否则，从库将进行全量复制。

主从模式下，从库宕机影响不大，但主库宕机就会影响从库的同步了，此时需要哨兵机制重新选举主库，保证高可用。

`replication buffer`：主从库在进行全量复制时，主库上用于和从库连接的客户端的 buffer，非共享，每个从库对应一个。

`repl_backlog_buffer`：主库上用于持续保存写操作的一块专用 buffer，是一个环形缓冲区， 从库共享。为了支持从库增量复制，主库会记录自己写到的偏移量(`master_repl_offset`)，而每个从库都会有自己的复制进度标志(`slave_repl_offset`)记录在上面，记录自己的写偏移量。

### 哨兵机制

哨兵机制会解决三个问题：1.判断主库是否宕机；2.选择哪个从库为主库；3.如何把新主库的信息通知给从库和客户端。

使用**哨兵模式**（sentinel）来监听和管理Redis集群，存储集群配置，作用类似ZooKeeper，哨兵节点是一台特殊的Redis，功能有限，主要用来支持哨兵机制，哨兵节点有三个任务：**监控、选主、通知**。

#### 原理

**哨兵节点一般设置3个及以上的奇数个，哨兵节点间是平级的，会互相监控**。

* 所有哨兵节点都会周期性的给所有主从库发送Ping命令检测Redis的主从节点是否正常运行，当有Redis节点出现问题时，进行通知。
* 如果发生问题的节点是主节点，会从从节点中选出主节点，代替失效的主节点。
* 之后会把新主库的连接信息发给其他从库，让他们执行`replicaof`命令，和主库建立连接，并进行数据复制。同时把新主库的连接信息通知给客户端，让客户端把请求发送到新主库上。

#### 如何监控，如何判断主库不可用

**主观下线**：每个哨兵节点每隔1s对主从节点发生心跳，当有节点在超过x秒后没有进行回复，此时该节点为主观下线，还需要进一步判断。

**客观下线**：当主观下线的节点是主节点时，该哨兵节点会通过`sentinel is-master-down-by-addr`命令，向其它`n - 1`个哨兵节点询问对主节点的判断，当超过 `n / 2 + 1` 个哨兵节点认为主节点有问题时，该节点为客观下线。**多这一步是为了防止误判。**

客观下线后，会从从节点中选举出主节点，前主节点重新上线后会被设置为从节点。

Redis没有使用什么一致性算法，仅依据**Gossip协议**在有效时间范围内收到其它Sentinel节点的确认。

另外，如果不使用哨兵模式，只使用Redis集群，也可以实现高可用，只不过是把监控和选择转移到各个节点中。

#### 如何选举

哨兵节点会周期性的发送心跳给主从库，在此过程中会对各个节点进行打分。之后按照一定的筛选条件和规则，选出得分最高的从库为新主库。

* 筛选条件：从库的在线状态，之前的网络连接状态。通过设置主从库断连的最大连接超时时间（down-after-milliseconds）、断连次数（n），当超过阈值时则说明从库的网络状况不好。
* 打分规则：从库的优先级（比如不同从库的配置不一样，优先级也就不一样）、从库的复制进度（根据从库在`repl_backlog_buffer`中的偏移量，从库间比较）、从库的ID号（ID号小的分高）

#### 如何通知

客户端在访问主从库时，不能写死主库地址，而是从哨兵节点中获取主库地址；当哨兵选出新的主库时，会更新自己存的新主库地址。哨兵节点通过 发布/订阅 机制，让客户端进行订阅和修改。从而也能让客户端了解整个主从切换过程。

#### 主从切换时，可能产生脑裂

如果主库因为某些原因进入假死状态（但还能处理命令），被哨兵判定为客观下线，哨兵执行主从切换，但此时主库恢复正常，但此期间写操作的数据还未同步到从库，待哨兵完成主从切换后，Redis集群会短暂出现两个主库，导致客户端的写操作会发往不同的主库，或者原主库降级成从库，会清空本地数据重新载入新主库的RDB快照，导致数据不一致或丢失。

判断主从库数据是否不一致，可以通过对比主从库上的复制进度差值来进行判断，计算`matser_repl_offset`和`slave_repl_offset`的差值，如果从库上的`slave_repl_offset`小于原主库的`master_repl_offset`，就说明数据丢失是因为数据未同步完成导致的。

**根本原因**：Redis主从集群没有使用共识算法，每个写操作没有在大多数节点写成功后才认为成功导致的。不像ZooKeeper，客户端的操作都会经过ZooKeeper的主节点，当发生脑裂时，ZooKeeper主节点无法写入大多数节点，写请求直接失败，保证数据一致性。

**解决方法**：在主库上配置`min-slaves-to-write`表示主库能进行数据同步的最少从库数量；`min-slaves-max-lag`表示主从数据复制时，从库给主库发送ACK消息的最大延迟(秒)。这样当主库假死时，无法响应哨兵心跳，也不能和从库同步，确认从库的ACK命令，原主库就无法再接收客户端的写操作。

#### 哨兵集群的高可用

由于哨兵需要进行客观下线的判断，因此需要多个哨兵组成集群，集群就会涉及到高可用。

**哨兵节点间、与主从库间的相互发现机制**

* 哨兵节点间的相互发现：正常情况下，每个哨兵都是平级的。每个哨兵节点在设置时的命令是`sentinel monitor <master-name> <ip> <redis-port> <quorum>` ，并不感知其他哨兵的存在。**哨兵节点间的相互发现，依赖Redis的 发布/订阅 机制**。哨兵节点一旦和主库建立连接，就会把自己的连接信息(如IP、端口)发布到主库上，同时它也会订阅，从而发现其他哨兵节点。
* 哨兵节点发现从库：哨兵节点连接主库后，发送INFO命令，主库就会把从库连接信息列表发给哨兵节点，从而实现哨兵节点对从库的监控。

**哨兵节点Leader选举原理**

正常情况下哨兵集群内的每个哨兵节点是平级的，但是当触发客观下线时，需要选出一个哨兵节点Leader来执行主从库切换。重新选举主库只能由一个哨兵节点来做，如果不是，可能会出现主从库集群脑裂。另外，哨兵节点越多，选举速度越慢，风险也会增加。

哨兵节点Leader选举分为两个阶段：

1. 各个哨兵节点判断主/客观下线阶段：各个哨兵在判断主库是主观下线后，首先会给自己投Yes票，之后会发送`is-master-down-by-addr`命令给其他哨兵节点，其他哨兵节点会根据自己的判断情况，回复Yes / No回去。该哨兵节点收集得到的Yes票，当超过设置的quorum值时，标记主库为客观下线。
2. 每个哨兵在一次选举节点只有一次投票机会，当有哨兵节点得出客观下线结论后，该哨兵再发起投票，进行Leader选举，当收集到的票数超过一半，则该哨兵节点成为Leader节点，如果没有选举成功，则等待一般是故障转移超时时间failover\_timeout的2倍时间后会重新举行选举。

![Redis哨兵下线主库和Leader选举](https://github.com/Nixum/Java-Note/raw/master/picture/Redis%E5%93%A8%E5%85%B5%E4%B8%8B%E7%BA%BF%E4%B8%BB%E5%BA%93%E5%92%8CLeader%E9%80%89%E4%B8%BE.png)

### 几个注意点

* 主从复制设置时，最好在主节点上开启持久化，否则可能会导致从节点的数据被清空；

  当主节点关闭持久化后，从节点开始从主节点复制数据，如果此时主节点挂了，因为关闭了持久化，且重启速度比较快，哨兵节点没有发现，重启后主节点数据为空，当从节点在进行数据复制时发现主节点是空的，从节点上的数据也会被删除。
* Redis支持无磁盘复制，当使用低转速的磁盘时，可能会影响复制性能，当启动无磁盘复制时，主节点子进程直接将RDB通过网络发送给从节点，不使用磁盘。

## 高可用扩展集群

一般一个Redis保存几个G内比较合适，**当单个Redis实例要保存的键值对太多时，会影响Redis的主从复制、RDB快照和AOF日志的大小、影响从库重放速度、fork子进程的速度(太慢会导致阻塞主线程)**，因此需要进行对单Redis实例扩展，常见的方式是对单个Redis实例进行扩展，一般分为纵向扩展和横向扩展。

redis中默认有编号0-15总共16个db，默认使用db0，每个数据库都有属于自己的空间。

在redis集群时，不可以使用select命令选择db，因为redis集群仅支持db0。

Redis集群最大节点个数是 **16384** 个，因为Redis集群没有使用一致性hash，而是哈希槽，Redis集群就有16384个哈希槽，原因是 Redis节点发送心跳包时需要把所有槽放到这个心跳包中，让节点知道当前集群信息，`16384 = 16k` 在发送心跳包时**使用char进行bitmap压缩后是2k（2 \* 8 (8 bit) \* 1024(1k) = 16K）**，因此使用 2k 空间就能创建 16k 的槽数了，尽管CRC16 算法最多课分配 `2 ^ 16 - 1 = 65535` 个槽位，压缩后也才8k，但一般来说，一个Redis集群不会超过1000个master节点，所以16k的槽位就比较合适。

### 扩展

* 纵向扩展：升级单Redis实例的配置，如内存容量、磁盘容量、CPU等，但会影响RDB快照和AOF日志大小，网络传输等，一般用在不使用Redis持久化功能的场景。
* 横向扩展：根据key，对Redis实例进行分片，增加Redis实例个数。

### 分片

Redis分片的实现使用哈希槽。

#### 原理

* 将数据分散到集群的多个机器上，Redis里使用的概念是槽Slot，每个集群的槽数固定为16 \* 1024 = 16384个，使用哈希分片算法对Key进行取模，计算方法：`HASH_SLOT = CRC16(Key) mod 16384`，余数为Key所在的槽。

  之所以会使用槽，是因为要把数据和节点解耦，如果不使用槽，而是使用key与节点的映射表，当key的数量非常庞大时，映射表也会非常大，映射表的修改和迁移的性能不高。

  集群只需要记录槽的ID与槽节点的地址映射即可，默认情况下，当集群 16384 个槽任何一个没有指派到节点时会导致整个集群不可用，so，当有节点故障下线时，从故障发现到自动完成转移期间整个集群不可用，如果要关闭这个配置，需要在redis.conf中配置：`cluster-require-full-coverage no`，这样就只会影响故障节点了。
* 集群内每台机器会存放一些槽，在集群初始化的时候会**对集群内的机器平均分配这16384个槽**，使用查表法进行分配。因此，当需要扩容时，会重新计算槽的位置和迁移key，可以使用官方提供的redis-trib.rb脚本实现。
* 客户端连接分片集群后，即可获得槽与各个Redis分片节点的映射。访问时可以访问集群内的任意节点，先根据key算出在哪个槽，在查询槽和节点间的关系，找到对应的节点进行查询。

  另外，要注意分片后，对于Keys、scan这样的扫描命令的性能就会更加差了。
* 当分片集群有增删时，槽与节点的映射也会随之修改，为了负载均衡，Redis需要把槽重新分布到各个分片上。但是客户端却不感知，当客户端发送命令时，如果节点上该槽已迁往别处，客户端会收到`MOVED 新槽编号 新槽所在的host`的错误信息，客户端将重新请求，同时修改槽与节点的映射关系。
* 如果客户端请求发生在Redis迁移槽的过程中，则会先收到`ASK 新槽编号 新槽所在的host`的错误消息，让客户端进行重试，直到Redis完成槽的迁移，重试成功。
* HashTag，如果key的格式是 `user:order:{3214}`，由{}括起来的部分为HashTag，CRC算法在计算key在哪个槽时只会计算{}里的值，HashTag主要是让多个类型的key可以映射到同一个槽，方便范围查询。

  但使用HashTag可能会导致数据倾斜，使得请求无法平均分到各个分片。

一般的分配规模是几十个以内，不适合构建超大规模的集群，原因是去中心化设计，选举算法使用Gossip，规模越大时，传播速度越慢。如果要构建超大规模的集群，则需要增加一层代理，进行集群间的转发，例如twemproxy或者Codis这类基于代理的集群架构。

## 事务

使用 MULTI 和 EXEC 命令将多个写操作包围起来进行发送，Redis收到后会先将命令暂存到一个队列里，当收到EXEC命令时才会一起顺序执行。

> 如果客户端发送的命令为 EXEC 、 DISCARD 、 WATCH 、 MULTI 四个命令的其中一个， 那么服务器立即执行这个命令。
>
> 与此相反， 如果客户端发送的命令是 EXEC 、 DISCARD 、 WATCH 、 MULTI 四个命令以外的其他命令， 那么服务器并不立即执行这个命令， 而是将这个命令放入一个事务队列里面， 然后向客户端返回 QUEUED 回复。
>
> WATCH: 监视一个或多个key，如果事务在执行前，这个key(或多个key)被其他命令修改，则事务被中断，不会执行事务中的任何命令，此时EXEC执行结果为null。

pipline主要用于批量发送命令，一次性发送多个请求，一次性读取所有返回结果，目的是为了减少 `连接-》发送命令 -》返回结果` 这个过程产生的耗时，减少IO次数，减少 read() 和 write() 的系统调用次数。

MULTI和EXEC事务机制与Redis pipline的区别：

* 事务中的每个命令会先发送到服务端，只会再执行；而pipline是会把命令请求缓存在客户端本地内存中，再发送请求一次性执行
* 事务中的每个命令都会先发送到服务端，请求次数多，而pipline只发送一次请求
* 一般事务和pipline会配合使用，以减少请求次数，减少网络IO，提升性能，保证多个命令发送不会被别的请求打断，保证多个事务的隔离性，

### Redis中的原子性

* 更多的是为了减少客户端与服务端的通信次数。Redis本身不提供回滚机制，如果一次性传输多个命令时，当有一个命令执行失败了，剩下的命令还是会继续往下执行，无法实现事务的原子性。
* 如果命令本身有错，只有到了EXEC命令时才会报错，整个MULTI期间的命令都不会执行，此时才保证了原子性。
* RDB不会在事务执行的时候执行，所以可以保证原子性。
* 事务执行过程中的命令，AOF日志会记录，所以如果事务执行过程中宕机了，重启前需要使用`redis-check-aof工具`检查AOF日志，去除执行到一半的事务命令，才能保证原子性。

**Redis中的原子操作**

* Redis的 incr / decr 命令，本质上是一个`读取 -》修改 -》写入` 的过程；
* 为Redis定义新的数据结构实现原子操作，毕竟Redis本身是单线程的，本身不会有其他线程的影响；
* Lua脚本，才能实现复杂逻辑的原子操作，使用时会先通过script load命令把脚本加载到Redis中，得到唯一摘要，再通过命令evalsha + 摘要的方式执行脚本，避免每次执行脚本都要先传输，减少网络IO；另外Lua脚本的时间复杂度不易过长，否则会阻塞主线。

Demo：限制客户端每分钟访问次数不能超过20次 的Lua脚本，该脚本包含了计数、访问次数判断和过期时间设置。

```lua
// 客户端先获取ip对应的访问次数
current = redis.call("get", KEYS[1])
//如果超过访问次数超过20次，则报错，这两步不涉及临界值的修改，因此可以不放在脚本中原子执行。
IF current != NULL AND current > 20 THEN
    ERROR "exceed 20 accesses per second"

Lua脚本名称：visit_restrict.script
脚本内容
    //如果访问次数不足20次，增加一次访问计数
    value = redis.call("incr", KEYS[1])
    //如果是第一次访问，将键值对的过期时间设置为60s后
    IF value == 1 THEN
        redis.call("expire", KEYS[1], 60)
    END
    //执行其他操作
    DO THINGS
END
执行命令：
redis-cli --eval visit_restrict.script keys , args
```

### Redis中的隔离性

* 需要依赖WATCH机制，因为Redis流水线命令是在EXEC命令执行后开始的，在EXEC命令还未执行前，如果要保证隔离性，需要使用WATCH监控某个key，当EXEC命令执行时，WATCH机制就会触发，如果监控的数据被修改了，就放弃此次事务的执行。
* 如果是在EXEC命令执行后的，由于Redis是单线程的，所以并发情况下不会破坏事务隔离性。

因此调用方可以利用这个特性，不断的重试这个操作，知道成功为止。

使用Watch的实现监控：在使用MULTI命令之前使用WATCH监控某些键值对，然后使用MULTI命令开启事务，执行各种数据结构的操作命令，此时这些命令入队列；当使用EXEC执行事务时，首先会对比WATCH所监控的键值对，如果没有发生变化，它会执行事务队列中的命令，提交事务，如果发生变化，则不执行事务中的任何命令，同时事务回滚，然后取消执行事务前的WATCH命令。

### Redis中的一致性

在命令执行错误或 Redis 发生故障的情况下，Redis 事务机制对一致性属性是有保证的。

### Redis中的持久性

尽管有RDB和AOF日志的支持，但是仍然有丢数据的可能。比如使用RDB模式，在一个事务执行后，而下一次的 RDB 快照还未执行前，如果发生了实例宕机，此时，事务修改的数据也是不能保证持久化的。

## 应用场景

### 聚合统计 - 统计每日新增用户

* 使用Set类型
* 一个Set记录所有登录过的用户ID：`[key：user:id，value：Set<userId>]`，另一个Set记录每日登录过的用户ID`[key：user:id :日期，value：Set<userId>]`
* 统计每日新增用户，计算每日用户 Set 和累计用户 Set 的差集，使用命令`SDIFFSTORE [结果key] [user:id :日期] [user:id]`
* 弊端：Set的差集、并集、交集计算复杂度较高，当数据量较大时计算太慢会阻塞主线程，一般这种计算会在从库上做。

### 排序统计 - 统计最新评论、排行榜、时间段内在线数等

* 使用List，当有新元素LPush或RPush插入时，原先所有元素都会后移一位，使用Lrange读取元素时可能会读到旧数据。
* 使用ZSet，为每个元素设置权重，按权重排序，使用Zrangebyscore则不会读到旧数据。

### 二值状态统计 - 统计是否签到、存不存在、有没有问题

* 针对值只有0 或1的场景，使用bitmap，是Redis的扩展数据类型，本身是String类型的一种特殊应用。
* bitmap利用的是存储的String value的位。并且bitmap支持Bitop命令对多个bitmap进行按位与、或、异或操作，bitcount统计位中1的个数。

### 基数统计 - 统计一个集合中不重复的元素

* 使用Set或Hash，但是可能会比较耗内存
* 使用HyperLogLog，专门用于基数统计，优势在于所需空间总是固定，使用`PFAdd key v1 v2 v3`命令做添加，`PFCount key`统计key的value数量，但是存在误差，误差率约为0.81%
* 一般用于统计注册的IP数、每日访问IP数、页面实时UV数、在线用户数、搜索的关键词数

### GEO - LBS应用

* 底层实现是ZSet，权重值是经纬度，但是由于ZSet的权重值只能是浮点类型，因此一般会对经纬度做GeoHash编码。
* GeoHash编码的基本原理是：二分区间，区间编码。
  * 二分区间：比如把经度范围\[-180,180]会被分成两个子区间：\[-180,0) 和\[0,180]，如果要编码的经度值落在左区间，则用0表示，右区间用1表示，通过不断的对区间进行分区，经过N次之后，得到一个N位数的01组合。纬度同理。

    比如有一个点（39.923201，116.390705），以纬度为例，（-90，90）中间值为0，39.923201在（0，90）得到1，（0，90）的中间值是45，在（0，45）得到0，继续对分区进行二分和比较，最终得到一个二进制数，经度也是以同样的方式进行计算
  * 区间编码：把计算过后的经纬度编码进行组合，规则：偶数位是经度编码值，奇数位是纬度编码值。
  * base32进行编码
* 通过GeoHash编码，地图上的每个方格都能用数字进行表示，分区越多越精准。**通过范围查询 + 方格周围方格的编码，即可实现搜索附近的人功能**。
* GeoHash表示一个矩形的区域，精度取决于二分的次数，次数越多越精确，经过base32编码后得到一个字符串，编码越长，范围越小，位置更精确；两个相同前缀的字符串，长度越短表示范围更大，可以通过此特性来表示两个点之间的大概距离。
* Redis也提供了Geo的数据类型

### 保存时间序列数据

* 时间序列数据的特点是插入速度要快，数据一旦插入就不变、查询模式较多。
* 可以基于Hash和ZSet实现。Hash实现插入快速和点查询，ZSet实现范围查询，但由于插入一条数据的时候需要同时操作Hash和ZSet，因此要求这个操作是原子的。
* ZSet只能提供范围查询，聚合查询只能在让客户端查回去之后自己做，但是大量数据查询传输比较依赖网络资源，可能会导致其他操作响应速度变慢，如果想要Redis实现的话，就得依靠RedisTimeSeries。

### 消息队列

* 消息队列一般要解决三个问题：顺序消费、幂等消费、消息可靠性保证
* 使用List
  * List的每个元素除了消息的内容外还要保存消息的唯一id，用于解决重复性消费；
  * List类型有提供BRPOP供消费者阻塞读取，避免循环读取带来的消耗；
  * List类型有提供BRPOPLPUSH命令，让消费者读取时会把数据存入另一备份List中，用于避免消费者从List中移除消息读取时宕机，重启后可重新读取消息。
  * List不支持消费组实现，无法避免生产速度大于消费速度的场景。
  * 通过 BLPOP 命令，在没有消息的时候，它会一直阻塞，直至有消息到来。
* 实现延迟队列：
  * 使用ZSet，拿时间戳当score，调用ZADD命令生产消息，ZRANGEBYSOCRE命令获取N秒前的数据进行轮询处理。
* 使用Stream类型，解决多端消费问题。
  * Stream的XADD命令，可以为其生成全局唯一ID。
  * 读取时使用命令XRead，可支持指定ID读取，也支持超时阻塞读取。
  * 命令XGroup创建消费组，XREADGROUP指定组内哪个消费者进行消费。
  * Streams 会自动使用内部队列（也称为 PENDING List）留存消费组里每个消费者读取的消息，直到消费者使用 XACK 命令进行回复。如果消费不成功，它就不会给 Streams 发送 XACK 命令，消息仍然会留存。当消费者重启后，用 XPENDING 命令查看已读取、但尚未确认处理完成的消息。
  * Stream是Redis5.0以后才有的专门用来处理消息队列的。

## 缓存可能引发的问题以及应对方法

使用Redis来构建缓存有两种模式

* 只读缓存：加速读请求。应用读取数据，先向Redis查询，查不到再查库，然后保存到缓存中。应用写数据，先改库，删掉旧缓存数据。这种方式可以很好的保证了一致性。
* 读写缓存：同时加速读写请求。读写都会在缓存里发生，最新的数据是在Redis中，但需要严重依赖Redis的持久化。这种方式对一致性的保证就差了点，特别是在高并发下。
  * 同步直写：优先保证数据可靠。写请求会同时修改缓存和库，等到都完成了才返回，需要保证原子性。
  * 异步写回：优先保证快速响应。写请求会先在缓存中处理，等到这些增改数据要从缓存中淘汰，才会写回数据库。

### 缓存雪崩

现象：缓存大面积失效导致请求到达数据库

应对方案：

* 缓存过期时间设置均匀，不能让一大片缓存在某一时间全部失效，比如设置过期时间时加入随机数，让过期时间在一个范围内波动。
* 请求时加锁(比如利用redission的rlock)，后续请求只能等到前面的查完数据库，进行缓存后，才能继续，但会造成吞吐量降低，响应时间变长，或者可以使用semaphore设置一定的信号量，不至于只有一个请求去回源数据库。
* 不设置key的过期时间，另开一个定时任务定期全量更新缓存；或者定时任务定期扫描，将快要过期的key延迟过期时间；设置多级缓存。
* 灰度发布，对缓存进行预热。
* 服务降级，当访问的数据是非核心数据，直接返回预定义数据或空值；当访问的数据是核心数据，仍允许查缓存，查库。
* 缓存标记，比如，给数据设置缓存过期时间为60分钟，标记缓存过期时间为30分钟，当时间超过缓存标记时间后，触发缓存更新，但此时实际缓存还能返回旧数据，直到缓存更新完成，才会返回新缓存。
* 如果是Redis实例发生宕机，只能在应用层中实现服务熔断或请求限流。

### 缓存击穿

现象：大量请求访问热点数据时，但**热点数据刚好过期**，此时请求击穿缓存层，直接到达数据库

应对方案：

* 不给热点数据的缓存设置过期时间，一直保留，或者热点数据快要到期前，异步刷新缓存，重新设置过期时间。
* 使用互斥锁或者信号量，当有大量请求访问一个过期的热点数据时，只有一定数量的请求会到达数据库查询数据并缓存，其他请求等待缓存加载即可。

### 缓存穿透

现象：**查询一个一定不存在的数据**，导致请求一直到达数据库，数据也不在数据库中。

应对方案：

* 使用布隆过滤器，将可能出现查询的值哈希到一个bitMap中，进行拦截，虽然布隆过滤有一定的误报几率，但也能一定程度的减少穿透的影响，常见的方案是配合2一起降低穿透带来的影响。
* 如果查询结果为空，也加入缓存中（可以直接设置为空，或者使用特殊标识来表示），并设置过期时间。
* 通过异步更新服务 + 消息队列的方式进行全量缓存的更新。缓存的设置还是照旧，只是当有数据更新时，触发消息交给消息队列，再由异步更新服务消费消息，实现缓存更新。
* 利用数据库的Bin Log，当数据库执行更新操作时，从数据库接收到Bin Log之后根据Bin Log更新Redis缓存，道理跟消息队列类似，只是不用担心消息发送失败问题。
* 前端预防，对请求进行检测。

### 缓存无底洞

现象：增加缓存节点，性能不升反降，原因是客户端要维护大量的连接，如果key分布在不同机器，需要查多次

应对方案：

* 减少网络请求，能批量查尽量批量查
* 将key进行分类，存到指定节点，查询同类的key时只需要特定的节点去查
* 并发查询

### 缓存污染

现象：对于那些访问次数很少的数据，一直留存在缓存中，占用缓存空间。

应对方案：Redis的淘汰策略，一般会使用LRU、LFU、TTL的淘汰策略。

### 主动更新缓存要注意的点

1. 不推荐先更新缓存再更新数据库（也称 Write Behind Caching 模式，更新数据时，只更新缓存，异步 / 同步 更新数据库）原因是数据库操作可能失败，导致缓存与数据库不一致；

   这个模式也有点像Linux的PageCache算法，好处是因为直接操作内存，异步，write back可以合并同一个数据的多次操作，IO性能极高，但因为要保证数据一致性，write back的实现就会很复杂，它需要知道哪些数据被更新了，然后还要持久化到磁盘上，比如操作系统的write back仅会在当这个缓存需要失效、内存不够、进程退出时，才会真正持久化。
2. 不推荐先更新数据库再更新缓存（也称 Write Through 模式），原因是当AB两个操作同时写，两者的操作顺序无法保证，导致数据不一致，即并发写导致脏数据；另外，更新到缓存的数据也不一定被访问；
3. 不推荐先删缓存再更新数据库，访问时再进行加载，原因是并发情况下，删除缓存后来不及更新数据库，但旧值已经被其他线程读到了，更新到缓存了；或者数据库操作失败了，但缓存已经没了，导致其他请求还要再读一次数据库，应对的方案是延迟双删：`先删缓存 -》更新数据库 -》sleep -》再删除缓存`
4. 推荐先更新数据库再删除缓存（也称为Cache Aside 模式），访问时再进行加载，虽然也可能出现3中的情况，比如读缓存时缓存失效，紧接着一个并发写操作，就有可能出现读操作覆盖了写操作后的数据更新，导致数据不一致，但实际上发生的概率不大，带来的影响会相对小一些，因为写操作通常会比读操作慢，再加上要锁表，而读操作必须在写操作前进入数据库操作，而又要晚于写操作更新缓存，条件就很苛刻了。

   如果删除缓存失败了，可以延迟任务进行删除重试，因为删除操作一般是幂等的，所以即使重复删除也没关系，另外，相比Read/Write Through模式（更新数据库后更新缓存操作），不会因为并发读写产生脏数据。还有由于会删除缓存，所以要注意缓存击穿问题。

   另外，即使使用了先更新数据库再删除缓存的模式，在主从同步延迟的场景也会导致不一致(对于其他模式也是)，解决方案仍然是延迟双删，只是sleep的时间最好等于延迟时间；

   如果还要强一致，就只能使用2PC、3PC、Raft之类的一致性协议或者分布式锁等方案来解决了，但是性能就保证不了了；
5. 另外的方案是利用数据库的能力 + 消息队列的方式，如根据MySQL的Bin log来更新缓存；

![Redis缓存方案操作 - 极客时间Redis核心技术与实战](https://github.com/Nixum/Java-Note/raw/master/picture/Redis%E7%BC%93%E5%AD%98%E6%96%B9%E6%A1%88%E6%93%8D%E4%BD%9C.png)

## 参考

极客时间 - Redis核心技术与实战：讲得很不错，推荐。

还有其他看过的别人博客，但是当时忘记把链接贴下来。


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://nixum.gitbook.io/note/redis.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
