Redis底层原理揭秘:数据结构、持久化和网络通信技术解析

Redis是一款快速、开源、高效的缓存和数据存储系统,已被广泛应用于各种应用场景。但是,要真正理解Redis的性能和可靠性,需要了解其底层实现原理。本文将深入探讨Redis的数据结构、持久化和网络通信技术,从底层到上层逐一剖析其实现原理和工作机制,为读者提供深度了解Redis的机会。此外,本文还将介绍一些常见的Redis应用场景和优化技巧,帮助读者更好地利用Redis提升应用性能。

基本数据结构

  • 所有的底层数据结构都被包装成一个redisObject
    1
    2
    3
    4
    5
    6
    7
    typedef struct redisObject {
    unsigned type:4;
    unsigned encoding:4;
    unsigned lru:LRU_BITS;
    int refcount;
    void *ptr; // 指向真正的数据
    } robj;

支持的数据结构

支持的数据结构底层原理备注常用场景
stringint
raw
embstr
二机制安全kv缓存,分布式锁,计数器,限流器等
listlinklist(废弃)
ziplist(废弃)
quicklist
listpack
redis7.0中list的底层是quicklist队列,栈,mq
setintset
lishpack
hashtable
集合求交差并集,去重
sortsetskiplist+hashtable
listpack
有序集合排行榜,关系链,分层排序
hashlistpack
hashtable
hash表去重,保存对象信息
bitmapstring利用strign对象实现的去重,统计,签到
hyperloglogstring利用strign对象实现的统计uv,计算去重的个数
geospatialsortsetgeohash编码地理位置,zset存储最后编码的数据,相邻分数距离更近统计附近的人等

string的三种编码方式

  • 为了节省内存,redis的string对象有三种编码方式

int

  • ptr字段直接保存成int
1
2
3
4
5
6
7
8
9
10
11
12
13
┌─────────────┐
type
│ OBJ_STRING │
├─────────────┤
│ encoding │
│ int │
├─────────────┤
│ lru │
├─────────────┤
│ refcount │
├─────────────┤
│ ptr ├────► 123
└─────────────┘

raw

  • ptr指向一个sds结构体
1
2
3
4
5
6
7
8
9
10
11
12
13
┌─────────────┐
type
│ OBJ_STRING │
├─────────────┤
│ encoding │ ┌─────────┐
raw │ │ len │
├─────────────┤ ├─────────┤
│ lru │ │ alloc │
├─────────────┤ ├─────────┤
│ refcount │ │ flags │
├─────────────┤ ├─────────┤ ┌────┬────┬────┬────┬────┬─────┐
│ ptr ├────►│ ruf[] ├──────►│ r │ e │ d │ i │ s │ \n │
└─────────────┘ └─────────┘ └────┴────┴────┴────┴────┴─────┘

embstr

  • ptr指向一个EMBSTR,区别在与sds的buf是指向另外一个内存地址,还是直接紧凑保存的数据
  • 紧凑的数据结构能带来更好的处理器cache命中率(缓存局部性原理)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
┌─────────────┐
type
│ OBJ_STRING │
├─────────────┤
│ encoding │ ┌─────────┐
│ embstr │ │ len │
├─────────────┤ ├─────────┤
│ lru │ │ alloc │
├─────────────┤ ├─────────┤
│ refcount │ │ flags │
├─────────────┤ ├─────────┤
│ ptr ├────►│ r │
└─────────────┘ ├─────────┤
│ e │
├─────────┤
│ d │
├─────────┤
│ i │
├─────────┤
│ s │
├─────────┤
│ \n │
└─────────┘

list的底层数据的演变

ziplist

  • ziplist记录了总字节数,最后一个元素的偏移量,总元素个数
  • 单个节点,保存了前一个节点的长度,当前节点的长度
  • 根据最后一个节点的偏移量,和前一个节点的长度,就能从后往前遍历
  • 根据第一个节点,和当前节点的长度,就能从前往后遍历
  • prelen长度字段编码后的,编码规则是第一个字节是否为FF来区分是1字节还是5字节
  • 查找需要遍历,复杂度O(n)
  • 连锁更新,插入元素后,会导致prelen长度改变,有可能连锁导致所有的长度发生变化,会发生多次内存的重新分配
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

32 32 16 8
┌─────────────┬──────────────────┬─────────┬───────┬─────┐
│ │ │ │ │ │
│ total byteslast item offsetitem num│ │ 255
└─────────────┴──────────────────┴─────────┼───────┼─────┘
│ │
│ │
│ │
┌───────────────────┘ └───────────────────┐
│ │
│ │
├──────┬────────┬──────┬───────┬────────┬───────┤
│ │ │ │ │ │ │
│prelen│encoding│ data │ prelen│encoding│ data │
└──────┴────────┴──────┴───────┴────────┴───────┘

quicklist

  • 双向链表组织多个ziplist
  • 让连锁更新发生在局部
  • 分片思维
  • 链表使得原本紧凑的数据结构又分散了

listpack(list还未采用)

  • 目前底层替换的很少
  • 没有连锁更新的问题
    • 连锁更新发生的原因?
      • 在ziplist中节点会引用前一个节点的长度,并且长度是编码后的,不定长,所以当前一个节点长度变更可能导致后一个节点跟着变化,
  • listpack如何解决的?
    • listpack的节点不再保存前一个节点的长度,只关注自己的长度
    • 只关注自己的长度,如何拥有往前往后遍历的能力?
      • 数据编码信息encoding,保存当前节点长度,并且是变长的
      • len字段也是变长的,
      • len如果第一字节的最高位为1,说明还有一个字节继续表示其长度,直到遇到的字节最高位为0为止
    • 从前往后遍历
      • 只需要根据encoding里面的长度计算偏移
    • 从后往前遍历
      • 只需要先定位到最后一个节点,根据len计算前一个节点的偏移位置
1
2
3
4
5
6
7
8
9
10
11
12
13
┌─────────────┬──────────┬───────┬────┐
│ │ │ │ │
│ total bytesitem num │ │ 255
└─────────────┴──────────┼───────┼────┘
│ │
│ │
┌──────────────────┘ └────────┐
│ │
│ │
├────────┬────┬───┬────────┬────┬───┤
│ │ │ │ │ │ │
│encoding│data│len│encoding│data│len
└────────┴────┴───┴────────┴────┴───┘

set的底层实现

intset(整数集合)

1
2
3
4
5
typedef struct intset {
uint32_t encoding;
uint32_t length;
int8_t contents[];
} intset;
  • contents 并不是表示只能保存int8,这里只是占位,实际保存的整数的长度是可变的,根据encoding决定
  • 实际上是根据encoding计算偏移量,然后按照16位,32位或者64位来解释内存
  • 添加删除元素都是O(N)
  • 有序保存,所以查找是O(logN)

hashtable

  • 拉链式解决hash冲突
  • 渐进式rehash
  • 负载因子,节点总数/桶的数量 > 1,可以进行扩容
    • rdb和aof进行的时候,会阻止扩容,因为hashtable扩容会导致大量的cow
  • 负载因子大于5,表示需要立马扩容
  • 渐进式rehash最小单位是桶
  • 防止cpu空转,会控制最大扫描的空桶个数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
                      ┌──────┐     ┌─────┐   ┌─────┐
┌─────┤entry ├───► │entry├──►│entry
│ ├──────┤ └─────┘ └─────┘
│ │entry
┌──────┐ │ ├──────┤
│... │ │ │ │
├──────┤ │ ├──────┤
│table0├────────┘ │ │
├──────┤ ├──────┤
│table1│ │ │
├──────┤ └──────┘
│used0 │
├──────┤
│used1 │
├──────┤
│... │
└──────┘

sortset 有序集合

skiplist 跳表

  • 第一层就是双向链表
  • 再对数据进行多层索引
  • 每一层会有指向前一个节点的指针
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
┌─────┐
│ L5 │
├─────┤
│ L4 │
├─────┤ 4 ┌─────┐
│ L3 ├──────────────────────────────────────────────────►│ L3 │
├─────┤ 1 ┌─────┐ 2 ┌─────┐ 1 ├─────┤
│ L2 ├──────►│ L2 ├─────────────────────►│ L2 ├───────►│ L2 │
├─────┤ 1 ├─────┤ 1 ┌─────┐ 1 ├─────┤ 1 ├─────┤
│ L1 ├──────►│ L1 ├───────►│ L1 ├──────►│ L1 ├───────►│ L1 │
├─────┤ ├─────┤ ├─────┤ ├─────┤ ├─────┤
│ BW │◄──────┤ BW │◄───────┤ BW │◄──────┤ BW │◄───────┤ BW │
├─────┤ ├─────┤ ├─────┤ ├─────┤ ├─────┤
0 │ │ 1 │ │ 2 │ │ 3 │ │ 4
└─────┘ └─────┘ └─────┘ └─────┘ └─────┘

hyperloglog

  • 近似算法,用来近似统计集合元素去重个数
  • N次伯努利过程
  • 本质上就是数所有记录的key中,按位表示时连续0出现的次数,根据出现的个数可以反推出key的大概个数
  • 有一定的误差,redis的实现误差是0.81%

geo

  • geohash算法可以对经纬度进行编码,把两个数值转换成一个,保存在zset中,相邻的数据表示地理位置更近
  • z字填充
  • 有突变,查询周围八个点消除突变

缓存应用

缓存一致性

  • 异常
    • 考虑异常情况下的表现,主要是写超时的情况,无法感知到底是成功还是失败
  • 并发
    • 考虑写写,读写并发情况下的表现

先写缓存,再写db or 先写db,再写缓存

异常

sequenceDiagram
    线程1 ->> 线程1 : 写缓存
    线程1 ->> 线程1 : 写db
  • 无论是先写缓存还是先写db,如果其中一个写失败了,就会导致数据不一致

并发

sequenceDiagram
    线程1 ->> 线程1 : 写缓存
    线程2 ->> 线程2 : 写缓存

    线程2 ->> 线程2 : 写db
    线程1 ->> 线程1 : 写db
  • 最终缓存是线程2写的值,但是db是线程1的值
sequenceDiagram
    线程1 ->> 线程1 : 写db
    线程2 ->> 线程2 : 写db

    线程2 ->> 线程2 : 写缓存
    线程1 ->> 线程1 : 写缓存
  • 最终缓存是线程1写的值,但是db是线程2的值

可见并发情况下,无论是先写哪个最终都会导致数据不一致

先删除缓存,后写db

sequenceDiagram
    线程1 ->> 线程1 : 删除缓存
    线程2 ->> 线程2 : 重建缓存

    线程1 ->> 线程1 : 写db
  • 最终db是线程1的值,但是缓存是线程2读到的旧值

先写db,后删缓存

并发下

sequenceDiagram
    线程2 ->> 线程2 : 读取db
    线程1 ->> 线程1 : 写db    
    线程1 ->> 线程1 : 删除缓存
    线程2 ->> 线程2 : 写入缓存
  • 极端情况下,还是会出现不一致
  • 由于线程2读取到旧值,没有及时更新到缓存,导致最终写入缓存的还是旧值
  • 这种情况比较极端

缓存雪崩

  • key集中过期,导致请求大量穿透存储

解决

  • 设置随机过期,或者不过期
  • 预热,解决服务启动的时候,缓存大量穿透的问题

缓存穿透

  • key不存在,恶意请求导致大量请求穿透到存储

解决

  • 对不存在的key,也设置缓存,并且设置过期
  • 利用布隆过滤器过滤(有一定概率)
    • 使用布隆过滤器,需要保存存在的key,因为布隆过滤器的特性是有可能会把不在布隆过滤器中的key判断为在里面,但是不会把存在的判断为不存在
    • 所以有一定概率让一些不存在的key穿透到db(因为在布隆过滤器的才是存在的key,才会穿透到db)
    • 反过来,如果保存的是不存在的key,那么可能误伤一些本来存在的,被判断为不存在的了

缓存击穿

  • 热点key过期,导致请求大量穿透到后端

解决

  • 读后端存储,加锁

redis的缓存淘汰策略

  • 淘汰策略
    • no-enviction(不进行淘汰)
    • 需要淘汰
      • volatile(在设置了过期时间的key中)
        • volatile-random
        • volatile-ttl
        • volatile-lru
        • volatile-lfu
      • allkeys(所有key中)
        • allkeys-lru
        • allkeys-random
        • allkeys-lfu

lru(least recently used)最近最少使用

常见实现方式

  • 链表记录所有访问的key
  • 表头是mru(最近最多使用),表尾是lru
  • key被访问会被放到表头,淘汰的时候从表尾进行淘汰
  • 由于链表操作复杂度高,因此,通常是使用map+list来实现

LRU-K

  • 最近k次访问记录
  • 选择最近第k次访问时间距离现在的时间为淘汰依据

redis中的实现

  • 如果redis去维护这样的链表,那会带来很多麻烦,频繁操作链表带来额外开销
  • 在事件循环的时候,检查是否达到内存阈值,然后选择待淘汰的key放到待淘汰的集合,进行删除,达到类似的lru效果
  • redisObject中有一个字段保存lur时钟,24bit
  • 可以同步,异步淘汰

全局lru时钟

  • 精度是秒
  • 为什么不每次都直接获取时间?
    • 因为获取时间的操作,是一个系统调用,内核态的切换可能影响redis的性能。这里相当于是缓存了时间

lfu(最不频繁使用)

  • 根据访问频次进行淘汰

redis实现

  • 记录时间和次数(频率是一定时间内的访问次数)
    • 比如15分钟访问15次,和5分访问10次,虽然绝对值10次小,但是实际上5分钟的频率更高,数据更热
  • 复用lru的字段
    • 因为服务启动不同同时存在多种淘汰策略
  • 高16为记录时间,低7位记录次数
    • 时间粒度是分钟
  • 先衰减次数,再增加次数
    1
    2
    3
    4
    5
    void updateLFU(robj *val) {
    unsigned long counter = LFUDecrAndReturn(val);
    counter = LFULogIncr(counter);
    val->lru = (LFUGetTimeInMinutes()<<8) | counter;
    }
  • 保存的并不是真正的次数,8bit最大次数才255,显然不够用
  • 因此保存的是概率性的次数,越大越难涨,难易程度是可配置的

惰性删除

  • lru和lfu在删除key的时候都是可以配置是否启用惰性删除的
  • 针对一些大key的场景,同步删除可能导致redis突然延时增加,影响其他客户端

启用惰性删除的场景配置

场景配置
缓存淘汰时的数据删除场景lazyfree-lazy-evictio
过期 key 的删除场景lazyfree-lazy-expire
隐式进行删除操作的 server 命令执行场景lazyfree-lazy-server-del
从节点完成全量同步后,删除原有旧数据的场景replica-lazy-flush

惰性删除流程

删除操作

  • 全局dict表删除key
  • 释放空间(同步或者异步)
  • 不同对象,有不同的删除代价,list,set,zset,hash的成员数量就是删除的的代价
  • 内存紧凑的对象,直接释放空间代价并不高
  • 后台线程和主线程使用条件变量和互斥锁实现同步

redis为什么这么快

事件循环

  • 事件反应堆
    • fd读写回调的时候,会再次产生读写事件,把读写事件继续加入事件循环器,这样就形成事件反应堆了
  • 优先使用epoll
  • 单线程也能处理多fd
  • select监听fd数量有限,需要轮询fd
  • poll也需要轮询fd
  • epoll,不需要轮询,效率更高
  • 一般还要把fd设置为非阻塞的,这样read,write不会阻塞
  • 单线程,实现简单,操作数据不需要加锁,cpu不是redis的瓶颈
    • 所以大key对redis的影响很大,会阻塞其他客户端
  • 耗时操作会使用其他线程,比如惰性删除的释放内存,aof刷盘等

单 Reactor 单线程

  • accept,read,write,处理业务逻辑都在一个线程
  • 缺点
    • redis6.0都是这种模型,缺点是无法利用多核提高效率
    • 单个事件的处理,可能影响其他请求
    • 并发请求,read和write可能出现瓶颈
    • redis6.0后,采用的是读写fd用线程池(不是一定用,看配置和其他条件),处理业务逻辑还是单线程,因为是内存操作,这里单线程也不会是瓶颈,而且单线程减少了锁的使用

单 Reactor 多线程

  • accept,read,write 在一个线程
  • 业务处理在线程池

主从Reactor多线程模型

  • 主Reactor处理监听套接字,负责和客户端建立连接,然后把连接套接字给从Reactor处理
  • 从Reactor和客户端进行读写,处理业务逻辑,通常从Reactor和cpu个数相等
  • Nginx的实现就是这样,不过nginx的slave进程都是可以进程accept的

事件类型

文件事件

  • 和客户端的连接事件,读写事件
  • 每次写fd,如果大于NET_MAX_WRITES_PER_EVENT(64k),会分成多次写,这样不会因为单次写耗时太大,从节点除外

时间事件

  • 定时任务
  • redis这里实现是链表,正常应该是堆或者红黑树来实现,因为redis这里只有2个时间事件
  • 事件循环wait的时间就是最近的时间事件到来的时间,这样每次事件循环阻塞结束后,如果没有网络请求,可以找到时间事件来执行,常见的定时器调度也是这样实现的

redis线程模型

三个后台线程

1
2
3
4
5
6
7
8
#define BIO_CLOSE_FILE    0 /* Deferred close(2) syscall.  close客户端连接*/
#define BIO_AOF_FSYNC 1 /* Deferred AOF fsync. 后台刷aof*/
#define BIO_LAZY_FREE 2 /* Deferred objects freeing. 惰性删除*/

static pthread_t bio_threads[BIO_NUM_OPS];// 线程描述符
static pthread_mutex_t bio_mutex[BIO_NUM_OPS];// 互斥量
static pthread_cond_t bio_newjob_cond[BIO_NUM_OPS];// 条件变量
static list *bio_jobs[BIO_NUM_OPS]; // 任务
  • 互斥锁和条件变量实现的生产者-消费者模型
    1
    2
    3
    4
    5
    6
    void bioSubmitJob(int type, bio_job *job) {
    pthread_mutex_lock(&bio_mutex[type]);
    listAddNodeTail(bio_jobs[type],job);
    pthread_cond_signal(&bio_newjob_cond[type]);
    pthread_mutex_unlock(&bio_mutex[type]);
    }
sequenceDiagram
    线程1 ->> 线程1 : pthread_mutex_lock
    线程1 ->> 线程1 : pthread_cond_wait,unlock,等待,lock
    线程1 ->> 线程1 : 消费任务
    线程1 ->> 线程1 : pthread_mutex_unlock

    线程2 ->> 线程2 : pthread_mutex_lock
    线程2 ->> 线程2 : 生产任务
    线程2 ->> 线程2 : pthread_mutex_unlock
    线程2 ->> 线程2 : pthread_cond_signal

io线程池

1
2
3
4
pthread_t io_threads[IO_THREADS_MAX_NUM];// io线程描述符
pthread_mutex_t io_threads_mutex[IO_THREADS_MAX_NUM]; // 互斥锁,主线程通过这个控制io线程阻塞
threads_pending io_threads_pending[IO_THREADS_MAX_NUM]; // 线程的任务数
int io_threads_op; // 读写空闲标记,这个变量只在主线程会修改,不需要使用原子的
  • io线程数量大于1的时候开启
  • 初始化后没有立马启用
  • 使用原子变量进行同步
  • io_threads_mutex 用于主线程控制暂停线程池(防止空闲时空转

io线程池

  • 默认不开启,需要配置文件显示指定
  • io线程的读和普通直接读都是同一个回调函数readQueryFromClient
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    int postponeClientRead(client *c) {
    if (server.io_threads_active && // io线程开启(启动不等于开启)
    server.io_threads_do_reads && // 读操作开启使用io线程池
    !ProcessingEventsWhileBlocked && // processEventsWhileBlocked 没有执行
    !(c->flags & (CLIENT_MASTER|CLIENT_SLAVE|CLIENT_BLOCKED)) && // master,slave,已经阻塞的客户端不使用
    io_threads_op == IO_THREADS_OP_IDLE) // io线程池空闲
    {
    // 没有设置单个客户端的状态了,直接根据io_threads_op判断
    listAddNodeHead(server.clients_pending_read,c);
    c->pending_read_list_node = listFirst(server.clients_pending_read);
    return 1;
    } else {
    return 0;
    }
    }
  • readQueryFromClient会将阻塞客户端添加任务到阻塞,读事件的回调
  • 事件循环wait前,都会检查clients_pending_read是否需要用io线程读
  • 主线程也会参与io读
  • 主线程把自己任务处理完后,会等待io线程结束,使用原子变量看任务是否已经处理完
  • 读取完成后,主线程进行命令的执行
  • 每次最大读16M数据,epoll默认采用水平触发(有未消费的数据就会触发epoll_wait返回),redis采用的水平触发

io线程池

  • 事件循环wait前,会检查写的阻塞链表```clients_pending_write``是否有数据,有的话就进行处理,和读的类似
  • 写操作如果回包过大,会暂时break写操作,下次事件循环仔写,这样不会阻塞其他客户端,比如执行keys *的这种操作
    • keys *这种回包大的处理的流程是,先把key都读到一个缓存链表中(每个16k),再使用io线程在多个事件循环中分批回给客户端
    • 在处理的流程中,读所有的key是阻塞的,可能影响整个server

可靠性

rdb

  • 二进制数据
  • fork子进程写入(COW)
    • fork是阻塞的系统调用,可能 会给系统带来阻塞

生成的时机

  • 手动触发
    • save命令
    • bgsave命令
  • 被动触发
    • 主从复制(不会生成文件,直接传输到socket)
  • 定时触发
    • serverCron执行频率自动检查触发
  • flushall
  • 关闭server

aof

  • 记录逻辑操作
  • 追加写
  • 策略
    • always:每次都写,最多丢失一个事件循环的数据
      • 因为redis的aof刷盘是在beforSleep中执行的,是下一次事件循环的开始,所以此时,上一次的aof是还没有fsync的,如果此时重启,会丢失一个事件循环的操作
      • 和mysql不同的是,mysql是WAL(Write-Ahead Logging),并且通过两阶段提交,来保证了事务不丢失
    • everyec:每秒执行
    • no:操作系统自动刷盘

aof重写

  • 防止aof文件过大,因为是记录的操作,aof重写可以把多个操作合并为一个
  • aof重写有一个标记aof_rewrite_scheduled,用来防止重入
  • 全量+增量

aof重写演进

redis2.8以及以前

sequenceDiagram
    父进程 ->> 子进程 : fork
    子进程 ->> 子进程 : 写全量数据
    父进程 ->> 父进程 : 暂存增量命令到aof buffer(老aof文件使用)和rewrite buffer(新aof使用)
    子进程 ->> 子进程 : 全量数据写结束
    父进程 ->> 父进程 : 把aof buffer一次性写到aof文件(可能阻塞)
  • 一次性写buffer到磁盘会导致redis阻塞

redis3.0

sequenceDiagram
    父进程 ->> 子进程 : fork
    子进程 ->> 子进程 : 写全量数据
    父进程 ->> 父进程 : 暂存增量命令到aof buffer和rewrite buffer
    父进程 ->> 子进程 : 通过管道把rewrite buffer发送给子进程
    子进程 ->> 子进程 : 全量数据写结束
  • 同一份数据需要写两份buffer
  • 需要使用6个管道进行通信

redis7.0的multi part aof

sequenceDiagram
    父进程 ->> 子进程 : fork
    子进程 ->> 子进程 : 写全量数据到base.rdb(混合持久化)
    父进程 ->> 父进程 : 暂存增量命令到aof buffer
    父进程 ->> 父进程 : 把aof buffer写到incr.aof
    子进程 ->> 子进程 : 全量数据写结束
    父进程 ->> 父进程 : 把base和incr合并成mainfest目录,mainfest目录中老的记录标记为history,后续清理
  • aof文件由全量数据文件+增量文件构成
  • base.rdb是rdb文件,这样空间更小(混合持久化)

主从复制

  • 全量同步
  • 命令传播
    • 推拉结合,master会主动把命令传播给slave,slave也会定时拉取
    • master不会等到slave写入成功,就返回客户端

      演进过程

redis2.8以前

sequenceDiagram
    slave ->> master : sync
    master ->> master : fork生成快照
    master ->> slave : 发送快照
    master ->> master : 暂存增量命令
    master ->> slave : 发送缓存命令
    master ->> slave : 命令传播
  • slave断开连接重新连接需要再次全量同步

redis2.8

graph TB
    start[开始] --> isFirst{是否首次复制}  
    isFirst -- Yes --> psyncAll[PSYNC ? -1]
    isFirst -- No --> psyncPart[PSYNC master offset]
    psyncPart --> isOk{主节点检查}
    isOk -- Yes --> run[执行部分同步]
    isOk -- No --> runAll[执行全量同步]
    psyncAll --> runAll[执行全量同步]
  • 需要从节点记录master的信息,但是重启也会丢失
  • 如果offset不在master的缓存中,那么只能进行全量同步
  • 如果master发生主从切换,新主id不同,那么所有的slave都要重新全量同步

redis4.0

  • 引入replid,相当于之前依赖master的runid,现在依赖replid
  • 主从断开的时间是有限的,取决于backlog的大小,如果offset不在buffer中,只能全量同步

redis7.0(百度贡献)

  • 共享复制缓冲区
    • 从库的复制缓冲区从私有变为共享,多个slave共享buffer,通过引用计数来组织
    • 链表把所有的buffer串起来,如果引用计数为0,就释放

哨兵机制

目的

  • 监控主从节点
  • 故障转移(automatic failover)
  • 配置提供(客户端在初始化时,通过连接哨兵来获得当前Redis服务的主节点地址)
  • 通知(哨兵可以将故障转移的结果发送给客户端)

哨兵集群的发现和建立

  • master节点会有一个```sentinel:hello``的频道,哨兵通过订阅发布进行感知
  • 哨兵通过info命令感知从库

感知节点故障

  • 主观下线:哨兵通过自己和节点的链接,主观判断节点是否下线(可能误判)
  • 客观下线:配置数量的哨兵共同判断节点下线,则为客观下线

故障切换

  • 哨兵选举领导者(raft选举,超半数同意,成为leader)
  • 由领导者发起故障转移,进行主从切换
  • 选择新主
    • 过滤掉不健康的(下线或断线),没有回复过哨兵ping响应的从节点
    • 选择salve-priority从节点优先级最高(redis.conf)的
    • 选择复制偏移量最大,只复制最完整的从节点
  • 把其中一个升级为master
  • 让其他节点成为新主的从节点
  • 通知客户端新主
  • 主从切换期间,可能有命令还没同步到从库,导致从库的数据慢于主库,此时切换后回出现丢数据的情况

常见使用场景

cas

  • cas set,带版本号的set
  • 利用lua实现,保证原子,可以把版本号拼接在数据的前面,这样只需要一个key就能保存数据和版本号
  • 在lua中使用struct.unpack可以解出版本号,进行判断

QA

codelover wechat
原创公众号
-----若有不妥,敬请斧正-----