Redis 知识汇总

325 阅读16分钟

简介

Redis 是一款基于内存的数据库, 即 NoSQL. 采用单线程模型, 但是能10w 的并发. 底层使用 IO 多路复用模型(Java 中的 NIO, 即在进行 IO 是, 线程不阻塞可以处理其他事).

常用的数据结构: String, List(可用作队列), Set, ZSet(sorted set有序集合), Hash

持久化两种方式: RDB(快照) 和 AOF(日志追加)

过期策略: 惰性删除 / 定时删除 / 定期删除

内存淘汰: 八种淘汰方式

分布式锁: 单节点 setnx ex, 集群基于 Red Lock实现 (开源框架 Redisson)

集群方式: 主从, Sentinel 和 Redis Cluster

Keys 和 Scan: 都用用来获取匹配的元素

数据结构

基础结构

struct RedisObject {
    int4 type; // 4bits
    int4 encoding; // 4bits
    int24 lru; // 24bits
    int32 refcount; // 4bytes
    void *ptr; // 8bytes,64-bit system
} robj;

String(sds)

String 底层结构叫做 SDS(Simple Dynamic String). 最大支持 512M 长度, 在长度小于 44 时, 采用emb形式保存(连续空间), 当大于 44 是采用raw形式(不连续), 为什么是 44, 因为分配空间是 2 ^ n 字节来分配, 当内存分配 64 字节时, 去掉RedisObject, 和尾部的\0, 总共可使用 44 长度

扩容: 小于 1M 时, 采用加倍策略, 大于 1M 时, 每次扩容只会分配 1M

struct SDS<T> {
  T capacity; // 数组容量
  T len; // 数组长度
  byte flags; // 特殊标识位,不理睬它
  byte[] content; // 数组内容
}

Hash(dict)

Redis 中的 Hash 和 Java 1.7 的 Hash 结构一样, 都是数组 + 链表

Set 和 ZSet 的底层也是 Hash, 只不过 value 为 null

Hash的内部结构有两个 HashTable, 通常是只会有一个有值. 由于采用正常的扩容方式的时间复杂度为 O(n), 因此 Redis 的 Hash 采用渐进式扩容. 当需要扩容时会在执行命令时扩容一部分, 也会有定时任务对需要扩容的 Hash 进行扩容, 每次扩容一部分, 当扩容结束时, 就删除旧的 HashTable

扩容条件: 当 hash 的个数等于第一位数组的长度是就触发扩容, 扩容的新数组为原来的 2 倍. 如果扩容时刚好赶上 bgsave, 为了减少页分离(bgsave 采用fork 线程 + copy on write), 会尽量不扩容, 如果 hash 的个数已经达到一维数组的五倍, 则会强制扩容

缩容条件: 如果 hash 的个数小于一维数组的 10%, 则会触发缩容, 并且不会考虑 bgsave

// 一维数组
struct dictht {
    dictEntry** table; // 二维
    long size; // 第一维数组的长度
    long used; // hash 表中的元素个数
    ...
}
// 链表
struct dictEntry {
    void* key;
    void* val;
    dictEntry* next; // 链接下一个 entry
}

压缩链表(ziplist)

Redis 为了节约内存空间使用,list, zset 和 hash 容器对象在元素个数较少的时候,采用压缩列表 (ziplist) 进行存储。压缩列表是一块连续的内存空间,元素之间紧挨着存储,没有任何冗余空隙. 增加 zltail_offset 便于定位到最后一个元素实现双向遍历

如果元素小于 254 字节, 则 prevlen 用 1 字节保存, 否则使用 5 字节保存. 也就是由于如果该字节修改前小于 254 字节, 修改后大于 254 字节, 就会导致连锁更新

优点: 节省内存 缺点: 连锁更新可能会导致内存拷贝, 效率低

hash-max-ziplist-entries 512 当 hash 的键值对数量小于 512 时使用压缩列表, 否则使用字典 hash-max-ziplist-value 64 当 hash 的键和值的长度小于 64 时使用压缩列表, 否则使用字典 list-max-ziplist-entries 512 当 list 的数量小于 512 时使用压缩列表, 否则使用快速列表 list-max-ziplist-value 64 当 list 的值小于 64 时使用压缩列表, 否则使用快速列表

struct ziplist<T> {
    int32 zlbytes; // 整个压缩列表占用字节数
    int32 zltail_offset; // 最后一个元素距离压缩列表起始位置的偏移量,用于快速定位到最后一个节点
    int16 zllength; // 元素个数
    T[] entries; // 元素内容列表,挨个挨个紧凑存储
    int8 zlend; // 标志压缩列表的结束,值恒为 0xFF
}
struct entry {
    int<var> prevlen; // 前一个 entry 的字节长度 只能为 1 字节或 5 字节
    int<var> encoding; // 元素类型编码
    optional byte[] content; // 元素内容
}

快速列表(quicklist)

快速列表为 list 的实现, 3.2 之前的版本为 linkedlist. 为什么通过 quicklist 替换掉是因为 linkedlist的 prev 和 next要占用 16 字节, 并且每个节点的内存都是单独分配,会加剧内存的碎片化, 影响内存管理效率.

list-max-ziplist-size -2 每个快速列表的节点的压缩列表长度

-1 每个quicklistNode节点的ziplist字节大小不能超过4kb。(建议) -2 每个quicklistNode节点的ziplist字节大小不能超过8kb。(默认配置) -3 每个quicklistNode节点的ziplist字节大小不能超过16kb。(一般不建议) -4 每个quicklistNode节点的ziplist字节大小不能超过32kb。(不建议) -5 每个quicklistNode节点的ziplist字节大小不能超过64kb。(正常工作量不建议)

struct ziplist {
    ...
}
struct ziplist_compressed {
    int32 size;
    byte[] compressed_data;
}
struct quicklistNode {
    quicklistNode* prev;
    quicklistNode* next;
    ziplist* zl; // 指向压缩列表
    int32 size; // ziplist 的字节总数
    int16 count; // ziplist 中的元素数量
    int2 encoding; // 存储形式 2bit,原生字节数组还是 LZF 压缩存储
    ...
}
struct quicklist {
    quicklistNode* head;
    quicklistNode* tail;
    long count; // 元素总数
    int nodes; // ziplist 节点的个数
    int compressDepth; // LZF 算法压缩深度
    ...
}

跳跃列表

跳跃列表是 ZSet (Sorted Set) 的实现. Redis 的跳跃列表就是数据结构中的跳表, 在 Java 中的实现参考: ConcurrentSkipListSet 和 ConcurrentSkipListMap. 插入, 删除和修改的时间平均复杂度为O(log(N)), 最坏为 O(N). 之所以 Redis 使用跳表而不使用红黑树的原因是因为跳表实现更简单, 在插入删除是, 红黑树进行 rebalance 时需要影响整个树, 而跳表只影响局部数据

索引即为 ZSet 的score, 如果 score 相同, Redis 会使用 key 进行排序

typedef struct zskiplistNode {
    //节点对应成员的对象,一般是SDS
    robj *obj; 
    //分数,跳跃表的顺序按照分值进行排列
    double score; 
    //存储后继节点的指针
    struct zskiplistNode *backward; 
    // 层级
    struct zskiplistLevel {
        // 存储前驱节点的指针
        struct zskiplistNode *forward; 
        // 跨度
        unsigned int span; 
    } level[];
} zskiplistNode;

typedef struct zskiplist {
    // 跳跃表的表头节点和表尾节点
    struct zskiplistNode *header, *tail;
    // 跳跃表中节点的数量
    unsigned long length;
    // 表中层数最大的节点层数
    int level;
} zskiplist;

img

持久化

Redis 的数据全部放在内存中 如果没有一种良好的持久化日志, 则会在宕机时丢失数据.

目前 Redis 通过两种方式实现持久化 RDB: 即时快照 AOF: 日志追加, 每执行一条命令, 就在日志后追加该条命令

默认 Redis 读取方式: 存在 AOF 加载 AOF, 如果不存在则判断是否存在 RDB 进行加载

RDB

Redis 在持久化是 fork 一个子线程, 通过 COW(Copy On Write)机制实现生成 RDB 文件保证持久化. 配置: save 60 1 表示 60s 内发生发生 1 次写入命令就触发生成 RDB

COW: 在多线程情况下, 拷贝一份用来自己写, 保证线程并发安全问题. 可参考 Java: CopyOnWriteArrayList 但是 Java 的实现是每当写的时候, 都会上锁拷贝出一份, 然后再拷贝的新的去执行写操作, 然后将拷贝的赋值给原始值. 但是在 Linux 不一样. Linux COW 在读时并不会 copy, 只有在写时, 对于写影响的页进行拷贝.

流程: fork 一个子线程, 子线程和父线程共享数据. 子线程遍历数据段来生成 RDB, 父线程当发生写操作是, 拷贝一根新的数据段, 在新的数据段进行写. 父线程的数据段为最新的数据. 子线程的数据不会受到影响, 也就是说子线程拿到的是当时的数据

优点: 服务宕机或重启恢复数据效率高 缺点: . 因为使用 COW机制, 因此如果在 fork 子线程之后, 父线程继续响应写事件, 但是此时宕机了, 就会导致数据丢失

操作命令: save(阻塞) / bgsave(非阻塞)

AOF

AOF 就是一个日志把所有的操作命令记录下来(去掉读), 然后再宕机或者重启时将日志的命令重新执行一遍即可, 是持久化数据最为准确的一种方式. 配置: appendonly yes. appendfsync everysec 每秒同步一次

AOF 重写: 显而易见长期运行 AOF 的数据会越来越多, 如果重启则会需要非常长时间. 因此 Redis 提供 bgrewriteaof 命令优化掉update. delete等操作, 全部优化为 insert 原理: fork 一个子线程, 通过 COW 遍历数据, 生成对应的操作指令

刷盘: 因为 AOF 是需要磁盘 IO. 那么如果写一条命令就同步, 就会导致性能极其差. 解决方式: 利用 mmap 方式 (mmap 是内存映射文件的一种方式, 内核开辟一块空间映射到磁盘文件. 操作该文件直接操作内存, 依赖于 OS 来刷新内核空间的数据到磁盘)来处理 . 默认 Redis 是 1s 刷新一次

优点: 持久化效率最高, 并且数据不会丢失 缺点: 服务宕机或重启加载数据效率非常低

命令: bgrewriteaof

RDB + AOF 混合(4.0+)

定时生成 RDB, fork 子线程之后的操作指令会记录至 AOF 日志. 这样既能保证宕机或重启恢复效率高, 又能保证数据不丢失

过期策略

对于一个 key 设置过期时间之后, Redis 会将对应的 key 放入到一个字典中

定时删除: 为该 key 创建一个定时器, 到时间之后自动删除 优点: 对内存友好, 不存在废弃数据占用内存 缺点: 对 CPU 极其不友好, 如果 key 比较多, 则会非常吃 CPU

定期删除: 每隔十秒对每个库进行一次随机扫描 20 个 key, 删除已过期的 key, 如果过期的比例低于 1/4, 则重新扫描. 为了防止随机扫描时间过长, 默认不会超过 25ms 优点: 对 CPU 友好 缺点: 对内存不友好, 会导致垃圾数据没有被回收

惰性删除: 当访问该 key 时, 如果该 key 已过期, 则删除 优点: 简单 缺点: 对内存不友好

默认: 定期删除 + 惰性删除

内存淘汰机制

当内存达到 maxmemory 配置时, 就会进行内存淘汰. Redis 提供 7 种淘汰机制

volatile-lru: 根据最近最少使用算法, 淘汰带有 有效期 属性的key及其数据 allkeys-lru: 同样根据最近最少使用算法, 但是淘汰范围的key是所有的key volatile-lfu: 根据最不经常使用算法, 淘汰带有 有效期 属性的key及其数据 4.0 版本支持 allkeys-lfu,与第二种的淘汰范围相同, 不过使用的算法是最不经常使用算法 4.0 版本支持 volatile-random: 随机淘汰带有有效期属性的key及其数据 allkeys-random: 所有key都随机淘汰 volatile-ttl: 淘汰剩余寿命最短的key及其数据, ttl是 Time To Live的缩写 noeviction: 不再进行写操作, 只能读 默认配置

分布式锁

分布式锁其实个人觉得现在并没有一个非常完美的解决方案. 原因是因为对于锁的时间和业务时间无法完美的解决. 例: 如果业务时间超过了锁的时间, 如果继续锁, 那么这个分布式锁的时间设置就没有了意义, 如果不继续锁, 就可能会同时两个线程在操作相同的业务

setnx ex

最简单的实现方式, 即没有该 key 是才去保存并且设置过期时间(2.8 之前没有该命令, 只能通过 lua实现). 而且仍然会有一个问题就是业务时间超时了如何处理. 这个时候新的线程持锁, 旧的线程执行完删除便会把新的线程的锁给删除. 所以相对安全的方式是: 设置锁时, 对 value 设置一个随机数, 这个随机数由本线程生成, 删除时校验该 value 是否为本线程的随机数. 这种方式也只是相对减少出错几率, 导致出错只会存在于超时的线程. 这种方式只能通过 Lua 脚本实现.

但是这种方式仅能使用在单节点的 Redis 中. 在集群的模式中, 因为主从同步延迟问题, 会出现一个线程明明已经上锁, 但是从库查不到又重新上锁的问题

# 上锁
tag = random.nextint()  # 随机数
if redis.set(key, tag, nx=True, ex=5):
    do_something()

# 锁释放 (lua 脚本)
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

Red Lock (算法, 具体实现参考 Redisson)

Red Lock 是目前最佳 Redis 集群实现的一种算法. 具体实现可参考 Redisson

  1. 获取当前时间 (单位是毫秒)

  2. 轮流用相同的key和随机值在N个节点上请求锁, 在这一步里, 客户端在每个master上请求锁时, 会有一个和总的锁释放时间相比小的多的超时时间. 比如如果锁自动释放时间是10秒钟, 那每个节点锁请求的超时时间可能是5-50毫秒的范围, 这个可以防止一个客户端在某个宕掉的master节点上阻塞过长时间, 如果一个master节点不可用了, 我们应该尽快尝试下一个master节点

  3. 客户端计算第二步中获取锁所花的时间, 只有当客户端在大多数master节点上成功获取了锁 (在这里是3个), 而且总共消耗的时间不超过锁释放时间, 这个锁就认为是获取成功了

  4. 如果锁获取成功了, 那现在锁自动释放时间就是最初的锁释放时间减去之前获取锁所消耗的时间

  5. 如果锁获取失败了, 不管是因为获取成功的锁不超过一半 (N/2+1)还是因为总消耗时间超过了锁释放时间, 客户端都会到每个master节点上释放锁, 即便是那些他认为没有获取成功的锁

主从复制

Redis 的集群是满足 AP 理论, 选择最终一致性. Redis 支持主从同步和从从同步(减少主节点压力).

  1. 设置主服务器的地址和端口
  2. 建立套接字连接
  3. 发送PING命令
  4. 身份验证
  5. 发送端口信息
  6. 同步
  7. 命令传播

增量同步

主节点内存中会有指令 buffer, 是一种环形结构, 如果 buffer 满了, 新的指令无论从库是否同步, 都会覆盖. 异步将 buffer 中的指令同步到从节点, 从节点反馈自己同步的偏移量. 如果从节点需要同步偏移量在 buffer 中已经不存在, 则需要执行快照同步

相关属性

  1. 复制偏移量
  2. 复制缓冲区
  3. 运行ID

快照同步

快照同步是一种比较耗时的同步方式. 快照同步会执行一次 bgsave, 然后异步把 rdb 发送给从节点, 从节点再 load. 因此主节点应该配置合适的 buffer size, 尽量避免快照同步

img

集群

原生集群

原生仅支持一主多从, slave of 即为该种方式. 这种方式的缺点就是主节点宕机, 只能人工手动主从切换, 而无法自动主动切换, 无法实现高可用

Sentinel

这种方式俗称哨兵模式. 该模式相当于是对 Redis 主从集群加了一层监控集群 监控集群如果检测到主节点不可用, 立即切换最优从节点为主节点

// 主节点必须至少有一个从节点在进行正常复制,否则就停止对外写服务,丧失可用性 min-slaves-to-write 1 // 10s 没有收到从节点的反馈,就意味着从节点同步不正常 min-slaves-max-lag 10

分片

上述的两种都是主节点保存全量数据的方式. 在应当当下大数据的情况下, 一旦网络抖动或宕机导致全量同步, 就会极其影响效率甚至陷入全量同步死循环. 因此在考虑高可用的情况下, 自然而然就少不了分片

分片的作用是将全量数据分散到各个节点, 各个节点再通过集群保证高可用. 每个集群只维护各自的局部数据

Redis Cluster

Redis Cluster 是一个多主多从的架构. 每一个主节点对应多个从节点. 当检测到主节点宕机时, 自动切换其从节点为主节点. 并且是去中心架构

Redis Cluster 采用虚拟槽分区,所有的key根据哈希函数映射到0~16383槽. 当收到一条指令后, 任何一主节点会计算该指令应保存的槽, 如果不是当前节点, 则会转发到对应的主节点进行处理

FailOver (故障转移): 分为主观下线和客观下线 主观下线: 集群中每个节点都会定期向其他节点发送ping消息, 接收节点回复pong消息作为响应. 如果在cluster-node-timeout时间内通信一直失败, 则发送节点会认为接收节点存在故障, 把接收节点标记为主观下线(pfail)状态 客观下线: 当某个节点判断另一个节点主观下线后, 相应的节点状态会跟随消息在集群内传播. 通过Gossip消息传播, 集群内节点不断收集到故障节点的下线报告. 当半数以上持有槽的主节点都标记某个节点是主观下线时, 触发客观下线流程

  1. 下线节点的集群投票选出新的主节点
  2. 选出的新主节点(从节点)取消复制变为主节点
  3. 执行clusterDelSlot操作撤销故障主节点负责的槽, 并执行clusterAddSlot把这些槽委派给自己
  4. 向集群广播自己的pong消息, 通知集群内所有的节点当前从节点变为主节点并接管了故障主节点的槽信息

Keys 和 Scan

Keys 和 Scan 都是用来获取匹配的 key, 时间复杂度都是 O(n)

Keys: 阻塞主线程, 并且不支持分页, 对大数据量效率极低又因为 Redis 是单线程会阻塞主线程. 因此线上统一禁止使用

Scan: 支持分页, 但是返回的数据可能会重复. 通过游标分布进行, 不会阻塞线程 (设计很复杂). 遍历顺序非常特别, 它不是从第一维数组的第 0 位一直遍历到末尾, 而是采用了高位进位加法来遍历