---
description: Redis数据结构、架构、常见常见、持久化、主从架构
---

# 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核心技术与实战：讲得很不错，推荐。

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