Redis设计与实现 笔记

一. 数据结构

1.1 String 字符

redis没有直接使用C语言传统的字符串表示,而是自己构建了一种简单动态字符字符串SDS的抽象类,并将SDS用作redis的默认字符串表示。除了用来标识数据库中的字符串值之外,SDS还被用作缓冲区:AOF模块中的AOF缓以及客户端状态中的输入缓冲区都是有SDS实现的。


image.png

1.1.1 字符实现原理

struct sdshdr{
  //记录buf数组中的已经使用字节数量
  //等于SDS所保留字符的长度
  int len;
  //记录buf数组中未使用的字节的数量
  int free;
  //字符数组,用于保存字符串
  char buf[];
 }

SDS遵循C字符串以空字符结尾的惯例,并且空字符的1字节空间不计算在SDS的len属性内。

1.1.2 SDS与C字符串的区别

根据传统的C语言只用长度为N+1的字符数组来表示长度为N的字符串,并且字符数组的最后一个元素总是空字符'\0',C语言使用的这种简单的字符串表达式并不能满足redis对字符串在安全性,效率以及功能方面的要求。
常数复杂度获取字符串长度
C字符串并不记录自身长度的信息,所以获取一个C字符串长度需要便利整个字符串,对遇到的每个字符串进行计数,直到遇到代表字符串结尾的控制符为止,这个操作的复杂度为O(N),但是对于SDS而言,SDS中记录了len属性,记录了SDS本身的长度,所以获取一个SDS长度的复杂度为仅为0(1),同时在设置和更新SDS长度的工作是有SDS的API在执行的时候自动完成的,使用SDS无需进行任何手动修改长度的工作。

杜绝缓冲区溢出
举个例子,假设程序里有两个在内存中紧邻的C字符串s1和s2,其中s1保存了”redis“ ,而s2保存了"MongoDB",如下图

image.png

如果,程序strcat(s1,"Cluster"),将s1的内容修改为"Redis Cluster",但是并没有给之前的s1分配足够的空间,那么在stract函数后s1的数据将溢出到s2所在的空间中,导致s2保存的内容被修改。如下图
image.png

减少修改字符串时带来的内存重分配次数
为了避免为此修改字符串的时候都进行一次内存重新分配SDS通过未使用空间解除了字符串长度和底层数组长度之间的关联,在SDS中,buf数组的长度不一定就是字符串数量加一,数组里面可以包含未使用的字节,就是SDS的free属性记录值。

通过未使用空间,SDS实现了空间预分配和惰性空间释放两种优化策略
1.空间已分配
空间预分配用于优化SDS的字符串增长操作,当SDS的api对一个SDS进行修改的并且需要对SDS进行空间扩展的时候,程序不仅会为SDS分配修改所需要的空间还会为SDS分配额外的未使用空间,

额外分配的未使用空间数量由以下公式决定:
1.如果对SDS修改后,SDS的长度(len属性值)小于1MB,程序将分配和len值相同的未使用空间,即len = free
例子:修改后SDS的len = 13,那么程序同样会分配free= 13的未使用空间,此时buf数组的实际长度就是 13+13+1 = 27
2.当SDS的len值大于等于1MB,程序会分配1MB的未使用空间。
例子:修改后SDS的len = 30MB,那么程序同样会分配free= 1MB的未使用空间,此时buf数组的实际长度就是 30MB+1MB+1byte

通过以上两种优化策略redis在进行SDS空间扩展时,会先检查未使用空间是否足够,如果够则无需重新分配空间,这样将SDS连续增加N次字符串所需的没存重分配次数有至少N次,变成了至多N次。
2.惰性分配
惰性空间释放用于优化SDS的字符串缩短操作,当SDS的api 需要缩短SDS保持的字符串时,程序并不立即使用内存重分配来回收缩短后多出来的字节(并不会马上回收多余出来的空间),而是使用free属性记录这些字节数量。另外SDS也提供了API,让我们在需要的时候可以真正的释放SDS的未使用空间,这样就不用担心造成内存浪费。

1.2 链表

redis提供的list数据结构底层基于双端无环链表实现,如下


image.png

链表节点

typedef struct listNode{
    //前置节点
    struct listNode *prev;
    //后置节点
    struct listNode *next;
    //节点值
    void *value;
}

链表结构

typedef struct list{
    //表头节点
    listNode *head;
    //表尾节点
    listNode *tail;
    //链表所包含的节点数量
    unsigned long len;
    //节点复制函数
    void *(*dup)(void *ptr);
    //节点释放函数
    void *(*free)(void *ptr);
    //节点值对比函数
    int (*match)(void *ptr,void *key)
}

1.双端:链表节点带有prev 和 next 指针,获取某个节点的前后节点复杂度为O(1)
2.无环:表头节点prev 和 表尾节点next都指向null,对链表的访问一null为终点。
3.表头节点,和表尾节点可以直接获取,复杂度为O(1)
4.链表长度计数器:使用list结构的len属相来对list持有的链表节点进行技数。

1.3 字典

redis的字段使用哈希表作为底层实现,一个哈希表里面可以有多个哈希表节点,而每个哈希表节点就保存了字典中的一个键值对


image.png

1.3.1 字典的实现

两个hash表组成的字典

typedef struct dict{
    //类型特定函数
    dictType type;
    //私有数据
    void privdata;
    //哈希表
    dictht htp[2];
    //当rehash 不进行是,值为-1
    in trehashidx;
} dict;

一个hash表

typedef struct dictht{
    //哈希表数组
    dictEntry **table;
    //哈希表大小
    unsigned long size;
    //哈希表大小掩码,用于计算索引值
    //总是等于size-1
    unsigned long sizemask;
    //使用节点数
    unsigned long used;
}

表节点

typedef struct dictEntry{
    void key;
    union {
        void val;
        uint64_tu64;
        int64_ts64;
    }
    //指向下一个哈希表节点
    struct dictEntry next;
}

1.3.2 哈希算法

将一个键值对添加到字典里面时,需要根据键计算出哈希值和索引值根据索引值将键值对添加到对应的哈希表中

1.3.2 解决哈希冲突

redis的哈希表使用链表地址来解决哈希冲突


image.png

1.3.3 rehash

1.为字典ht[1]哈希表分配空间,大小取决于是扩展还是收缩,以及ht[0]存储的键值对数量(ht[0].used 值)。
1.1 扩展操作,那么ht[1]大小为第一个大于等于(ht[0].used * 2)的2的N次方幂 比如:ht[0].used = 4,4 * 2 = 8(2的3次方幂)恰好是一个大于等于4的2的N次方,所以ht[1]大小扩展为8
1.2 收缩操作那么ht[1]大小为第一个大于等于ht[0].used的2的N次方幂
2.将保存在ht[0]中的键值对重新计算键的哈希值和索引值,然后将键值对放到ht[1]上面指定的位置
3.释放ht[0],将ht[1]设置为ht[0],并且在ht[1]新建一个空白哈希表,留给下次rehash用。

哈希表的扩展与收缩
当满足一下之一条件时,程序会自动开始对哈希表执行扩展操作
1.服务器没有执行bgsave 或者bgrewriteaof 命令,并且哈希表负载因子大于等于1.
2.服务器正在执行bgsave 或者bgrewriteaof 命令,并且哈希表负载因子大于等于5.
负载因子 = ht[0].used / ht[0].size
根据bgsave 或者bgrewriteaof 命令是否正在执行,服务器执行扩展所需的负载因子并不相同,这是因为在执行bgsave 或者bgrewriteaof命令的过程中,redis 需要创建当前副武器进程的子进程,大多数操作系统都采用写时复制(copy-on-write)技术来优化子进程的使用效率,所以在子进程存在期间,服务器会提高执行扩展操作所需的负载因子,从而尽可能避免在子进程存在期间进行哈希表扩展,避免不必要的内存写入操作,

当哈希表负载因子小于0.1是,程序自动为哈希表执行收缩操作

1.3.4 渐进式rehash

redis 的rehash并不是一次性,集中式的一次性完成的,而是分多次,渐进式的完成的,因为在数据量比较大的时候(百万级别)rehash 的过程耗时必将拉长,这样有可能导致服务器在一段时间内停止服务。

渐进式rehash步骤如下:
1.为ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表。
2.字典中维护一个字段,rehashidx = 0 ,索引计数器变量。
3.在rehash进行期间,每次对字典进行增删改查之外,还会将ht[0]哈希表在rehashidx索引上的所有键值对rehash到ht[1],同时rehashidx加一。

rehashidx 为ht[0]进行rehash 的元素的下标
4.当rehash完成后,rehashidx 改为-1.rehash结束。

1.3.5 渐进式rehash执行期间的哈希表操作

在进行渐进式rehash的时候,字典会同时使用ht[0] 和ht[1]两个哈希表,删除,查找,更新会在两个哈希表上进行,如果查询一个键再ht[0]没有查到,会在ht[1]上在查找一次。新增时会直接保存再ht[1]上。

1.4 跳跃表

跳跃表是一种有序的数据结构,它通过在每个节点维护多个指向其他节点的指针从而达到快速访问节点的目的。


image.png

最左边一列为zskiplist,包含一下信息
header : 跳跃表头节点
tail : 跳跃表尾节点
level : 跳跃表层数最大的那个节点的层数(表头节点不算在内)
length : 跳跃表节点数量(表头节点不算在内)

右边四列为zskiplistNode,包含一下信息
level : 层
backword : 后退指针,用于反向便利
score: 分值层级排序
obj:保存的值

1.4.1 跳跃表节点

typedet struct zskiplistNode{
    struct zskiplistNode{
        struct zskiplistNode *froward;
        unsigned int sapn;
    }
    level[];
    struct zskiplistNode *backward;
    double score;
    robj *obj;
} zskiplistNode;

1.4.2 跳跃表

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

1.5 整数集合

整数集合是集合键的底层实现之一,当一个集合只包含整数值元素并且这个集合的元素数量不多时,Redis 就会使用整数集合键的底层实现。

1.5.1 整数集合实现

整数集合(intset) 是Redis 用于保存整数值的集合抽象数据结构,他可以保存类型为int16_t,int32_t或者int_64_t 的整数值,并且集合中不会出现重复元素。

typedef struct intset{
  //编码方式
  uint32_t encoding;
  //集合包含的元素数量
  uint32_t length;
  //保存元素的数组
  int8_t contents[];
} intset;

length : 记录整数集合包含元素数量,即contents 数组长度。
contents : 数组是整数集合的底层实现,数组中的值从小到大的顺序排列,且不会出现重复值。
contents 数组类型取决于encoding的属性值,如下
encoding = INTSET_NEC_INT16 ====> contents 类型为int16_t 大小为-32768~32768
encoding = INTSET_NEC_INT32 ====> contents 类型为int32_t 大小为-2147483648~2147483648
encoding = INTSET_NEC_INT64====> contents 类型为int64_t 大小为-9223372036854775808~9223372036854775808

1.5.2 升级

将一个新元素添加到整数集合的时候,并且新元素类型比整数集合当前所有元素类型都要长,这时整数集合需要先进行升级,然后再添加新元素。

升级需要经历三步骤,如下
1.根据新元素类型,扩展整数集合底层数组的空间大小,并未新元素分配空间。
2.将底层数组现有的所有元素都转换成与新元素相同类型,并将类型转换后的元素放置在正确位置上,而且需要保持底层数组的顺序不变。
3.将新元素添加到底层数组里面。

1.5..3 升级的好处

  1. 提升灵活性 : 整数集合可以通过自动升级底层数组来适应新元素,从而不必担心出现类型错误,
  2. 节约内存 : 节约内存,利用合适的类型保存数组元素,尽量节约内存。

1.5.4 降级

整数集合不支持降级操作,一旦对数组进行了降级,编码就会一直保持升级后的状态。

1.6 压缩列表

压缩列表(ziplist)是列表键和哈希键的底层实现之一,当一个列表建只包含少量列表项,并且每个列表项要么就是小整数值,要么就是长度比较短的字符,那么redis就会使用压缩列表来做列表键的底层实现

1.6.1 压缩列表的结构

压缩列表是redis节约内存而开发的,是经过特殊编码连续的内存块组成的顺序型数据结构,一个压缩列表可以包含任意多个节点,每个节点可以保存一个字节数据或者一个整数


image.png

组成部分说明


image.png

1.6.2 压缩列表节点构成

image.png

二.数据库

2.1 服务器中的数据库

Redis 服务器将所有数据库都保存咋服务器状态redis.h结构的db数组中,db数组的每一项都是一个redisDb结构,每一个redisDb标识一个数据库。

struct redisServer{
  //...
  //一个数组,保存服务器所有数据库
  redisDb *db;
  //...
  //服务器初始化数据库数量
  int dbnum
}

dbnum 由服务器的database熟悉决定,默认是16

2.2 切换数据库

服务器内部,客户端状态redisClient结构的db属性记录了客户端当前的目标数据库,指向redisDb结构的指针

typedef struct redisClient{
  //...
  //记录客户端当前正在使用的数据库
  redisDb db;
} redisClient;

redisClient.db指针指向redisServer.db数组中的一个元素,关系如下图


image.png

2.3 数据库键空间

Redis 是一个键值对数据库服务器,数据库的redisDb结构的dict字典保存了所有键值对,称这个字典为键空间

typedef struct redisDb{
  //...
  //数据库键空间,保存数据库中的所有键值对
  dict *dict;
  //..
} redisDb;

每个键都是一个字符串对象,每个值可以使字符串对象,列表对象,哈希表对象,等等。。如下图


image.png

2.4 设置键的生存过期时间

通过命令expire 或者pexpire ,客户端可以以秒或者毫秒为精度为数据库中的键设置生存时间,例如:
redis> set key value
redis> expire key 5
设置key 存活时间为5秒
setex 可以在设置字符串键的同时为键设置过期时间,但只能针对字符串
ttl 命令 返回键剩余生存时间,例如
redis> ttl key 返回5
设置过期时间
1.expire <key> <ttl> 为key设置ttl秒生存时间
2.pexpire <key><ttl>为key设置ttl毫秒生存时间
3.expireat<key><timestamp> 为key的过期时间设置为timestamp所指定的秒数时间戳。
4.pexpireat<key><timestamp> 为key的过期时间设置为timestamp所指定的毫秒秒数时间戳。
保存过期时间
redisDb结构expires字典保存了数据库中所有键的过期时间,所以称这个字大点为过期字典;

typedef struct redisDb{
  //...
  //过期字典保存键的过期时间。
  dict *expires;
  //..
}

如下,带有过期字典的数据库例子

image.png

注意,在实际中,键空间和过期字典的键都是指向同一个键对象,所以不会出现重复对象也不会浪费任何空间。
移除过期时间
使用命令 persist 移除过期时间
reids> persist key
计算并返回剩余生存时间
TTL命令以秒为单位返回键的剩余生存时间,PTTL命令以毫秒为单位返回键剩余的生存时间
redis> pttl key;
过期时间判定
通过过期字典,程序可以用一下步骤检查一个给定的键是否过期
1.检给定键是否粗拿在于过期字典,存在那么获取键的过期时间。
2.检查当前UNIX时间存是否大于键的过期时间,如果是的话那么已经过期,否则未过期。

def is_expired(key):
    #获取过期时间
    expire_time_in_ms = redisDb.expires.get(key);
    #键有没有设置过期时间
    if expire_time_in_ms is None:
        return False
    #获取当前时间unix时间戳
    now_ms = get_current_unix_timestamp_in_ms()
    #检查当前时间是否大于键的过期时间
    if now_ms > expire_time_in_ms:
        return True;
    else 
        return False;

注意:判断时间也可以用TTL 或者PTTL ,返回值大于等于0标识未过期,反之已过期,但是is_expired 直接访问过期字典会比执行命令快一些。

2.5 过期建删除策略

1.定时删除
在设置的过期时间的同时,创建一个定时器(timer),让定时器在键的过期时间来临时,立即执行对键的删除。
优点
尽快删除过期键,释放过期键占用的内存。
缺点
当过期键比较多,且内存不紧张,cup时间非常紧张的手,删除这些键将占用一定的时间,会对服务器吞吐量造成影响
2.惰性删除
放任键过期不管,在每次从键空间中获取键时,判断键是否过期,过期就删除,未过期返回该键
优点
不会花任何专门的cup时间取删除键,不影响服务器吞吐量
缺点
不能及时删除键,释放键所占内存。
3.定期删除
每隔一段时间对数据库进行一次检查,删除里面过期的键,删除多少键,检查多少个数据库有算法决定。
定期删除策略每隔一段时间执行一次删除过期建操作,并通过限制删除操作执行的时长和频率来减少删除操作对CPU时间的影响。
定期删除策略有效的减少了因为过期建而带来的内存浪费。

2.6 内存淘汰策略

有了以上过期策略的说明后,就很容易理解为什么需要淘汰策略了,因为不管是定期采样删除还是惰性删除都不是一种完全精准的删除,就还是会存在key没有被删除掉的场景,所以就需要内存淘汰策略进行补充。

在配置文件redis.conf 中,可以通过参数 maxmemory <bytes> 来设定最大内存,当数据内存达到 maxmemory 时,便会触发redis的内存淘汰策略(我们一般会将该参数设置为物理内存的四分之三)
内存淘汰策略可以通过maxmemory-policy进行配置,目前Redis提供了以下几种(2个LFU的策略是4.0后出现的):

volatile-lru,针对设置了过期时间的key,使用lru算法进行淘汰。
allkeys-lru,针对所有key使用lru算法进行淘汰。
volatile-lfu,针对设置了过期时间的key,使用lfu算法进行淘汰。
allkeys-lfu,针对所有key使用lfu算法进行淘汰。
volatile-random,从所有设置了过期时间的key中使用随机淘汰的方式进行淘汰。
allkeys-random,针对所有的key使用随机淘汰机制进行淘汰。
volatile-ttl,针对设置了过期时间的key,越早过期的越先被淘汰。
noeviction,不会淘汰任何数据,当使用的内存空间超过 maxmemory 值时,再有写请求来时返回错误。

2.7 AOF,RDB和复制功能对过期键的处理

1.生成RDB文件
在执行save命令或者bgsave 命令创建一个新RDB文件时,程序会对数据库中的键进行检查,已过期的键不会被保存到新创建的RDB文件中。
2.载入RDB文件
再启动Redis服务器时,如果服务器开启了RDB功能那么服务器将对RDB文件进行载入。分以下两种情况
1.主服务器模式运行
载入RDB文件时,程序会对文件保存的键进行检查,未过期的键会被载入到数据库中,过期的键会直接被忽略,所以,过期键对载入RDB文件的主服务器不会造成影响。
2.从服务器模式运行
在载入RDB文件时,文件保存的所有键,不论是否过期都会被载入到数据库中,但是在主从服务器同步数据的时候,从服务器的数据库会被清空,所以过期键对载入RDB文件的从服务器也不会造成影响。
3.AOF文件写入
当服务器以AOF持久化模式运行时,如果数据库中的某个键已经过期,但它还没有被惰性删除,或者定期删除,那么AOF文件不会因为这个过期键而产生任何影响。
当过期键被删除后,程序会想AOF文件追加一条DEL命令,标识该键已经被删除了。
4.AOF 重写
在执行AOF重写的过程中,程序会对数据库中的键进行检查已过期的键不会被保存到重写后的AOF文件中。

三.RDB持久化

因为Redis 是内存数据库,他将自己的数据库状态存在内存里面,如果不把内存中的数据库状态保存到磁盘里面,一旦服务器退出或者停止,服务器中数据库状态也会消失不见。

为了解决这个问题,Redis 提供RDB持久化功能(手动或定期执行),这个功能可以将Redis在内存中的数据库状态保存到磁盘里面,避免意外丢失数据

3.1 RDB文件的创建与载入

有两个redis命令可以生成RDB文件,一个是save,另一个是bgsave.

save 命令
此命令会阻塞服务器进程,直到RDB文件创建完成,在此期间,服务器不能执行任何命令

redis> save //等待直到RDB文件创建完成
ok

bgsave 命令
此命令会派生出一个子进程,然后由子进程,然后由子进程负责创建RDB文件,服务器进程继续处理命令请求。

redis> bgsave //派生子进程,并由子进程创建RDB文件
Background saving started

在Redis服务器启动时,如果检测到RDB文件存在,就会载入RDB文件,但是因为AOF文件的更新频率比RDB高,所以如果服务器开启了AOF持久化功能,那么此时会优先使用AOF文件来还原数据。只有在AOF持久化功能关闭的情况下,才会使用RDB文件来做数据还原。

image.png

save 命令执行时服务器状态
当执行save命令时,服务器会被阻塞,不接受处理任何请求。
bgsave命令执行时服务器状态
当执行bgsave命令时,服务器可以继续处理客户端请求,但是save,bgsave 都会被阻塞。
RDB文件载入时的服务器状态
服务器载入RDB文件期间,会一直处于阻塞状态,直到载入完成。

3.2 自动间隔性保存

设置保存条件
通过在redis.conf文件添加配置如下

save    900     1
save    300     10
save    60      10000

服务器在900秒内,对数据库进行了最少1次修改,执行bgsave
服务器在300秒内,对数据库进行了最少10次修改,执行bgsave
。。。。。
服务器程序会根据配置的save选项 设置服务器状态redisServer结构的saveparams 属性

struct redisServer{
  struct saveparam *saveparams;
}

saveparams 是一个数组,每个元素都是一个saveparams结构,而每一个saveparam 结构都保存了一个save选项设置的保存条件

struct saveparam{
  //秒数
  time_t seconds;
  //修改数
  int changes;
}

dirty 计数器和lastsave属性
服务器状态除了维护saveparams数组外,还维护着dirty 计数器和lastsave属性

struct redisServer{
  //修改计数器,
  long dirty;
  //上一次执行保存时间
  time_t lastsave;
}

dirty : 记录距离上一次成功执行save命令或者bgsave命令后,服务器中所有数据库进行了多少次操作。
lastsave : 记录服务器上一次成功执行save命令或者bgsave命令的时间。
检查保存条件是否满足
Redis服务器的周期性函数saverCron默认每隔100毫秒执行一次,进而对服务器的运行进行维护,所以也会对save配置进行检查,满足save配置条件,就会执行bgsave命令。函数数会遍历saveparams数组,只要有一个条件满足就执行bgsave.

3.3 RDB文件的结构

RDB文件结构如下图


image.png

RDB文件存的都是二进制数据,REDIS 保存的是"REDIS"这5个字节长度的字符串,程序载入时通过这个字符串确认是不是RDB文件。

db_version 长度为4字节,值是一个字符串标识的整数,标识RDB文件的版本号。
database 包含0个或者N多个数据库,以及这些数据库中的键值对数据如果服务器的数据库为空,那么database 也为空,如果服务器数据库不为空,那么database保存的就是服务器数据库及数据库中的键值对据。
EOF 长度为1字节,标志着RDB文件正文内容的结束,程序读到这个值时,标识RDB文件加载完毕。
check_sum 为8字节长度,保存着一个校验和,对前面四个字段内容计算得出,用于检查RDB文件是否出错。

四.AOF持久化

AOF是通过redis服务器执行的命令来记录数据库状态的


image.png

4.1 AOF持久化的实现

AOF 持久化功能的实现可以分为命令追加append,文件写入,文件同步sync。
命令追加
当AOF持久化功能开启时,服务器在执行完一个名利后,会以协议格式将被执行的命令追加到服务器的aof_buf缓冲区的末尾

struct redusServer{
  sds aof_buf;
}

AOF文件的写入与同步
在服务器执行完每一个客户端请求时,会调用fushAppendOnlyFile函数将aof_buf缓存区中的内容写入和保存到AOF文件里面,而fushAppendOnlyFile函数的行为有服务器配置的appendfsync选项决定的

image.png

注意
如果没有配置appendfsync选项,默认是everysec。

AOF持久化的效率和安全性

服务器配置的appendfsync选项值决定了AOF持久化功能的效率和安全性。
apppnedfsync = always,执行完每个命令都将aof——buf缓存中的内容写入到aof文件中,并且同步AOF文件中,效率最慢,但是安全性最高
apppnedfsync = everysec,执行完每个命令都将aof——buf缓存中的内容写入到aof文件中,并且每隔一秒由子线程对AOF进行同步
apppnedfsync = no,执行完每个命令都将aof——buf缓存中的内容写入到aof文件中,至于何时对AOF文件进行同步由操作系统控制。

4.2 AOF文件的载入与数据还原

AOF文件包含了重建数据库状态所需的所有写命令,只要执行一遍AOF文件的命令,就能还原数据库,具体步骤如下
1.创建一个不带忘了连接的为客户端(因为redis的命令只能在客户端上下文执行,载入AOF文件所使用的命令直接来源于AOF文件, 所以服务器可以使用伪客户端执行AOF文件命令)
2.分析读取A OF文件命令(循环一条一条读取)
3.使用伪客户端执行命令
4.判断命令是否全部执行完毕,执行完毕就结束,否则跳回第2步。

4.3 AOF重写

随着服务器的运行,AOF文件中的内容会越来越多,文件越来越大,对Redis服务器和宿主计算机影响越来越大,还原所需的时间越来越长,为了解决AOF文件体积膨胀的问题,redis 提供了AOF文件重写的功能。
redis 服务器可以创建一个新的AOF文件来替代现有的AOF文件,新旧两个AOF文件所保存的数据库状态相同,但新的AOF文件不会包含任何浪费的空间冗余命令,但是体积小的多。
AOF文件重写的实现
比如输入以下命令:
redis> rpush list "a" "b"
redis> rpush list "c"
redis> rpush list "d" "e"
redis> lpop list
redis> rpush list "f" "g"
那么服务器为了保存当前list键的状态,必须在AOF文件中写入6条命令,
但是在新重写的AOF文件中,不是先去分析现有AOF的文件内容,而是直接从数据库中读取list的值,然后用一条rpush list "c" "d" "e" "f" "g" 替代原来的六条命令,
AOF后台重写
因为AOF的重写会进行打了的写入操作,并且在执行重写的时候客户端所有命令将被阻塞。
所以,redis不希望AOF重写造成服务器无法处理其他请求,进而将AOF重写程序放到子进程里面执行,这么做的目的有两个
1.可以继续处理其他请求
2.子进程带有服务器进程的数据副本,使用子进程而不是子线程,可以避免使用锁的情况下保证数据安全性。
但是在使用子进程的情况下,在子进程中AOF文件进行重写时,服务器有执行了其他客户端命令,这样就会造成数据不一致,为了解决这个问题,redis服务器设置了一个AOF重写缓冲区,这个缓冲区在服务器创建子进程后开始使用,服务器执行完其他命令后,会发往AOF缓冲区和AOF重写缓冲区。

在子进程完成AOF重写后,会通知父进程,父进程调用信号处理函数执行以下操作(此函数阻塞其他操作)
1.将AOF重写缓冲区内容写到新的AOF文件中。
2.对新的AOF文件进行改名,原子覆盖现有AOF文件。

五.Sentinel(哨兵)

Sentinel 是redis 的高可用行解决方案,有一个或多个Sentinel实例组成Sentinel系统可以监视任意多个主服务器以及解析主服务器下属的所有从服务器,并在被监视的主服务器进入下线模式时自动将下线主服务器下的某个从服务器升级为新的主服务器。


image.png

5.1 启动并初始化哨兵Sentinel

启动一个Sentinel 命令

$ redis-sentinel /path/to/your/sentinel.conf

当一个Sentinel启动时,需要执行以下步骤
1.初始化服务器
2.将普通Redis服务器使用代码替换成Sentinel专用代码
3.初始化Sentinel状态
4.根据配置文件配置Sentinel监听的主服务器列表
5.创建链接主服务器的网络链接

5.2 获取主服务器信息

Sentinel 默认会以每十秒一次的频率,通过命令连接想被监视的的主服务器发送info命令,通过分析info命令的回复获取主服务器的当前信息,可以获取以下两方面的信息
1.主服务器本身的信息:包括run_id 域记录的服务器运行ID,以及role域记录的服务器角色
2.从服务器信息:每一个从服务器都由一个slave开头的字符串的行记录,行记录的ip= 保存服务器ip,port=记录端口

根据run_id 和role 记录的信息,Seninel 会在主服务器重启完之后,通过运行时Id 和实例结构之前保存的运行Id进行比较,从而确定更新主服务器势力结构。

5.3 获取从服务器信息

当Sentinel发现主服务器有新的从服务器加入时,Sentinel除了会为这个从服务器添加相应里的实例外,还会创建Sentnel连接到从服务器的命令链接和订阅链接


image.png

根据info命令的恢复,Sentinel会提取以下信息
1.从服务器的运行ID run_Id
2.从服务器的运行角色 role_Id
3.主服务器ip地址master_host,以及主服务器的端口master_port
4.主从服务器的链接状态,master_link_status
5.从服务器的优先级 slave_priority
6.从服务器的复制偏移量slave_repl_offset

5.4 向主/从服务器发送频道信息

Sentinel 默认会每两秒一次的频率通过命令连接向所有被监视的主服务器和从服务器发送一下格式命令

publish _ sentinel _ : hello    "<s_ip>,<s_port>,<s_runid>,<s_epoch>,<m_name>,<m_ip>,<m_port>,<m_epoch>"

s开头为Sentinel本身信息,m开头为主服务器信息,如果监视的是主服务器,就是主服务器信息,如果是监视的是从服务器,就是从服务器正在复制的主服务器信息。


image.png
image.png

5.5 接收来自主/冲服务器的频道信息

当Sentinel 与一个主服务器或者从服务器建立订阅连接之后,Sentinel会通过订阅连接向服务器发送命令:subscribe _ sentinel _ : hello 。
Sentinel对此频道的订阅会持续到与服务器断开连接,期间Sentinel即通过命令向服务器发送命令,也通过订阅链接从服务器的频道接收信息


image.png

另外,对于监视同一个服务器的多个Sentinel,一个Sentinel发送的信息会被其他Sentinel接收到,这些信息会被用于更新其他Sentinel对发送信息的Sentinel认知,也会更新他们对监听的服务器的认知。

更新sentinels 字典

Sentinel 为主服务器创建的实例结构中的sentinels字典除了保持本身的Sentinel信息外,还保存了其他的对这个主服务器进行监视的Sentinel信息。

通过接收其他的Sentinel发来的消息进行分析对,本身监视的主服务器和保存的其他Sentinels字典进行更新

创建连向其他的Sentinel的命令连接

当Sentinel通过频道信息发现一个新的Sentinel时,不仅为新Sentinel在sentinels字典中创建相应的实例,还会创建一个连向新Sentinel的命令连接,新的Sentinel也会创建一个连向当前Sentinel的命令连接,这个Sentinel间就形成一个联网了。

为什么不需要订阅连接

Sentinel 需要通过接收主服务器或者从服务器发来的频道消息来发现未知的新Sentinel,所以才需要建立订阅连接,而相互已知的Sentinel只需要命令连接通信就够了

5.6 检测主观下线状态

默认情况下,Sentinel会每秒一次的频率向所有与它创建命令连接的实例发送PING命令,通过平PING命令返回的信息判断实例是否在线。

实例对ping命令的回复可分为以下两种
1.有效回复:实例返回+PONG,-LOADING,-MASTERDOWN 之一
2.无效回复:返回有效回复之外的信息

在Sentinel 配置文件中的down-after-milliseconds 指定判断实例进入下线状态需要的时间,比如down-after-milliseconds = 5 如果五秒内向sentinel返回无效回复,那么Sentinel 会修改这个实例结构的flags属性值为SRI_S_DOWN,表示进入下线状态

5.7 检测客观下线状态

当Sentinel将一个主服务器判断为主观下线后,为了确认这个服务器是不是真的下线了,会向同样监视这一服务器的其他Sentinel进行询问,当其他的Sentinel返回足够数量(可配置 sentinel monitor master 127.0.0.1 6397 2)的已下线判断后,Sentinel会将服务器判断为客观下线,并执行故障转移

5.8 选举领头Sentinel

当一个主服务器被判断为客观下线时,监视这个下线主服务器的各个Sentinel会进行协调,选出一个领头的Sentinel,并由这个Sentinel对下线服务器进行故障转移。

Redis选举Sentinel的规则和方法如下

1.监视同一个主服务器的在线Sentinel都有可能成为领头Sentinel

2.每次进行Sentinel选举后,不论成功失败,所有Sentinel的配置纪元(configuration epoch)都会加一,

3.Sentinel一旦被设置为领头的,那在这个配置纪元里面就不能在更改

4.每个发现主服务器客观下线的Sentinel都会要求其他的Sentinel将自己设置为局部Sentinel,通过命令连接发送sentinel is-master-down-by-addr命令要求其他的Sentinel将自己设置为领头的

5.设置规则是先到先得,最先向其他Sentinel发送设置自己为领头的Sentinel将成为领头,其他的在发起会被拒绝

6.在Sentinel 接收到SENTINEL is-master-down-by-addr命令后,回复一条命令,包含leader_runid和leader_epoch参数,分别记录领头Sentinel的运行id和配置纪元

7.Sentinel 接收到回复后,会比较自己的run_id和epoch是否和回复信息中的eader_runid和leader_epoch相同,相同则成为领头的,

8.某个Sentienl需要被半数以上的Sentienl设置为领头的Sentienl才能真正成为领头Sentienl

9.在一个配置纪元里只能出现一个领头Sentienl

10.在规定时间内,没有选出领头Sentienl,会再次进行选举,知道选出领头Sentienl。

5.9 故障转移

故障转移由领头Sentinel执行,分以下步骤

1.选出新的主服务器
领头Sentinel会从已下线的主服务器的所有从服务器列表里面筛选,
1.删除下线或者断线的从服务器,删除最近五秒内没有回复INFO命令的从服务器,删除所有与已下线主服务器断开超过down-after-millisenconds * 10 毫秒的从服务器

2.根据从服务器优先级进行排序,选出优先级最高的从服务器,如果,优先级相同,安装从服务器的复制偏移量为准,偏移量大的优先,如果有多个优先级最高,偏移量最高的根据运行id进行排序,选择id较小的从服务器

2.修改从服务器的修改目标
Sentinel通过向从服务器发送SLAVEOF命令,让已下线主服务器的从服务器去复制新的主服务器。

3.将旧的主服务器变为从服务器
同上。

image.png

6.集群

Redis集群是redis提供分布式数据库方案,集群通过分片来进行数据共享,并提供复制和故障转移功能

6.1 节点

可以使用cluster meet 命令使redis服务器加入到集群当中。
例子:三台服务器 127.0.0.1:7000,127.0.0.1:7001,127.0.0.1:7002
创建集群

127.0.0.1:7000>cluster nodes

加入集群

127.0.0.1:7000> cluster meet 127.0.0.1:7002

启动节点
Redis 服务器启动时会根据cluster-enabled配置选项是否为yes来决定是否开启服务器的集群模式

image.png

集群数据结构
clusterNode结构保存了一个节点当前的状态,比如节点创建时间,名称,配置纪元,ip和端口

struct clusterNode{
  mstime_t ctime;
  char name;
  int flags;
  uint64_t configEpoch;
  char ip ;
  int port;
  clusterLink link;
}

clusterLink 包了节点所需相关信息,比如套接字符,输入缓冲区,输出缓冲区

typedef struct clusterLink{
  mstime_t ctime;
  //tcp 套接字描述符
  int fd;
  //输出缓冲区
  sds sndbuf;
  //输入缓冲区
  sds rcvbuf
  //与这个节点相关联的节点,没有就是null
  struct clusterNode node;
} clusterLink

redisClinet 与 clusterLink的异同

redisClinet 和clusterLink 都有自己的套接字描述符,和输入输出缓冲区,但是redisClinet 事作用域客户端连接的,clusterLink 是作用域节点连接的

每个节点都保存着一个clusterState结构,这个结构记录了当前节点的视角下,集群目前处于什么状态,例如,集群在线还是下线,包含多少节点,配置纪元等等。

typedef struct clusterState{
  //指向当前节点
  clusterNode myself;
  //配置纪元
  uint64_4 currentEpoch;
  //上线还是下线
  int state;
  //集群中至少处理着一个槽的节点数量
  int size;
  //集群节点名单
  dict nodes;
}

Cluster Meet的实现
比例两台服务器 127.0.0.1:7000 称为A服务器,12.0.0.01:7001称为B服务器。
创建一个集群:

127.0.0.1:7000>cluster nodes 

将B服务器添加到A的集群

127.0.0.1:7000>cluster meet 127.0.0.1 7001

过程描述

image.png

  1. A会先为B创建一个ClusterNode结构,并且添加到自己的clusterState.node字典里面。
  2. 之后,节点A根据meet后面的ip和端口向B发送一条meet消息
  3. 一切顺利,节点B收到meet消息后,会为A创建一个ClusterNode结构,并且添加到自己的clusterState.node字典里面
  4. 然后B向A返回一条PONG消息
  5. 一切顺利后,A接收到B发来的PONG消息,可知B成功接收meet消息,然后A再向B发送一条PONG消息,
  6. 这样B会知道A顺利收到到了自己发送的PONG消息,握手过程完成,
  7. 最后节点A将节点B的信息通过Gossip协议传播给集群中的其他节点,让其他的节点与B握手,这样节点B会被集群中所有节点认识。

6.2 曹指派

Redis 集群通过分片的方式来保存数据库中的键值对,集群的整个数据库被分为16384个槽(slot)数据库中的每个键都属于这16384个槽的其中一个,集群中的每个节点可以处理N个槽N <= 16384

数据库中的16384个槽都有节点处理的时候集群处于上线状态,否则处于下线状态,可以通过Cluster addslots命令给一个节点指派曹

127.0.0.1:7000>cluster addslot 0,1,2,,,,,5000

6.2.1 记录节点的曹指派信息

clusterNode 结构的slots属性和numslots属性记录了节点负责处理那些槽

struct clusterNode{
  unsigned char slots[16384/8];
  int numslots;
}

slots是一个二进制数组,长度为16384/8=2048字节,共包含16384个二进制位。


image.png

slots数组在索引i 上的二进制位为1,标识节点负责处理槽,i二进制位为0,标识节点不处理槽如上图,数组索引0-7值为1,其他都不为0,标识该节点处理0-7的槽。

6.2.2 记录集群所有槽的指派信息

typedef struct clusterState{
  clusterNode slots[16384];
}clusterState ;

如果slots[i]指向null,表示槽i没有指派给任何节点,如果slots[i]指向clusterNode结构,那么槽i就指派给了该clusterNode所属的节点。

6.2.3 传播节点曹指派信息

一个节点除了会将自己处理的槽记录在clusterNode结构的slots属性和numslots属性之外,还会将自己的slots数组通过消息发送给集群中的其他节点,告诉其他节点自己目前处理哪些槽。


image.png

6.3 在集群中执行命令

客户端想节点发送与数据库键有关的命令时,接受命令的节点会计算出需要处理的数据库键属于哪个槽,并检查这个槽是否指派给了自己,如果指派给了自己,那么该节点直接执行命令,否则给客户端返回一个moved错误,同事讲客户端请求redirect到正确的节点执行命令


image.png

6.3.1 计算属于哪个曹

使用命令cluster keyslot key 例如

127.0.0.1:7000>cluster keyslot "data"
127.0.0.1:7000>(integer) 2022

(integer) 2022

6.3.2 move 错误

格式:move <slot> <ip>:<port>
例子:

127.0.0.1:7000> set msg "happy new year"
127.0.0.1:7000>Redirected to slot [6257] located at   127.0.0.1:7001
image.png

客户端收到这个move信息后,会根据信息内容转发请求


image.png

6.4 重新分片

Redis 集群的重新分片可以将任意多的已经指派给某个节点的槽重新指派给其他的节点,并且相关槽所属的键值对也会从原来节点移动到新的节点。

重新分片可以在线操作,在重新分片过程中,集群不需要下线,并且来源节点和目标节点可以处理命令请求。

6.4.1 重分片实现原理

image.png

使用redis-trib对集群的单个槽进行重新分片的步骤如下

  1. redis-trib对目标节点发送cluster setslot <slot> importing <source_id> 命令,让目标节点准备好从源节点导入属于槽slot的键值对
  2. redis-trib 对源节点发送cluster setslot <slot> migrating <target_id>命令,让源节点准备好将slotc槽的键值对迁移到目标节点。
  3. redis-trib 对源节点发送cluster getkeysinslot <slot> <count> 命令,获取count个属于槽slot的键值对键名,
  4. 将步骤三获取的键名,redis-trib 向源节点发送一个migrate <target_ip> <target_port> <key_name> 0 <timeout>命令,迁移至目标节点。
  5. redis-trib 向集群中的任意一个节点发送cluster setslot <slot> node <target_id> 命令,将槽slot指派给目标节点,这一指派消息会发送至整个集群。

6.5 ASK错误

客户端向源节点发送一个数据库指令,恰好数据库键值所处的曹正在迁移中,源节点判断是否需要向客户端发送ASK错误的过程,如下图


image.png

ASK错误会引导客户端专向再导入曹的目标节点,再次发送之前的命令请求

6.6 复制与故障转移

7000-7003形成一个集群,7004和7005为7000的从节点,如下图


image.png

当7000节点处于下线状态时,其他在运行的几个主节点会将7000的从节点7004和7005选择一个节点作为顶替7000工作的主节点,负责处理7000处理的槽,并且处理客户端发送的命令请求。如下图


image.png

故障处理完,7000节点重新上线,会加入到7004的从节点里面


image.png

6.4.2 设置从节点

向一个节点发送cluster replicate <node_id> 可以让接受该命令的节点称为node_id所指定节点的从节点,并开始对主节点进行复制。

6.4.3 故障检测

在集群中,每个节点都会定期的想起他节点发送ping消息,用来检测对方是否在线,如果接受ping消息的节点没有在规定时间内返回pong消息,那么该节点视为下线了。

6.4.4 故障转移

主节点下线时,从节点开始对下线的主节点进行故障转移,步骤如下

  1. 复制下线主节点的所有从节点里面会有一个从节点被选中
  2. 选中的从节点会执行命令slaveof no one 命令,称为新的主节点
  3. 新的主节点会撤销所有对已下线主节点指派的槽,并且将这些槽全部指派给自己,
  4. 新的主节点会广播一条pong消息,告诉其他节点自己已成为主节点并且处理之前已下线的主节点负责的槽。
  5. 完成故障转移。

6.4.5 选举新的主节点

  1. 在集群中,开始一次故障转移时,集群配置纪元值+1,
  2. 集群中在线的主节点都有一次投票机会,从节点发起投票,先到先得。
  3. 从节点发现自己的主节点下线后,会向集群广播一条clustermsg_type_failover_auth_request 消息发起投票
  4. 接收到投票信息的主节点,会返回一个clustermsg_type_failover_auth_ack消息,表示投票给了发出投票信息的从节点。
  5. 当集群中有N个具有投票权的主节点,从节点收到的票数大于等于N/2+1 时,表示改从节点当选为新的主节点。

6.7 消息

meet消息:请求接收着加入到发送者当前所有出的集群里面

127.0.0.1:7000>cluster meet 127.0.0.1:7001

ping消息:集群里的每个节点默认情况下每隔一秒就会从已知的节点列表中随机五个节点,然后对这五个节点里面最长时间没有发送ping消息的节点发送ping消息

pong消息:确认接收到meet消息或者ping消息回复pong消息,

fall消息:主节点A判断主节点B进入fall状态时,会在集群中广播一条关于B节点的Fall消息,收到这条消息的节点会将B标记为下线。

publish 消息:节点收到publish命令时,会先执行该命令,然后向集群中广播一条publish消息,收到这条消息的节点都会执行publish命令

7.事务

待完续
切换到事物模式,命令会统一一次提交,中间不提交

8.慢日志

Redis慢日志功能用于记录查询超过设置时长的命令请求,用户可以通过这个功能监听和优化查询速度。
服务器配置有两个慢查询日志相关的选项
1.slow-log-slower-than
指定查询超过多少微妙的领了请求会被记录到日志上面
2.slowlog-max-len
指定服务器上最大保存多少条慢日志,服务器使用先进先出的方式保存日志,当日志条数达到slowlog-max-len时,添加新的日志的时候会删掉一条旧的日志

8.1 慢查询日志的保存

服务器状态中包含了几个和慢日志功能相关的属性

struct redisServer{
  //下一条慢日志id
  long long slowlog_entry_id;
  //保存了所有慢日志的连表
  list *slowlog;
  //服务器配置项
  long long slowlog_log_slower_than;
  //服务器配置项
  unsigned long slowlog_max_len;
}

slowlog_entry_id 初始值0,每加一条日志,slowlog_entry_id就加一
*slowlog 慢日志链表,链表每一个节点(slowlogEntry)保存了一条慢日志记录,slowlogEntry结构如下

typedef struct slowlogEntry{
  //唯一标识
  long long id;
  //执行时间
  time_t time;
  //执行命令消耗时间,微妙
  long long duration;
  //命令参数
  robj **argv;
  //参与命令的参数数量
  int argc;
}
image.png

8.2 慢查询日志的阅览和删除

慢日志查询

127.0.0.1:6379> slowlog get 2
1) 1) (integer) 13
   2) (integer) 1629523068
   3) (integer) 6
   4) 1) "get"
      2) "a"
   5) "127.0.0.1:43942"
   6) "lnrcoder"
2) 1) (integer) 12
   2) (integer) 1629523065
   3) (integer) 3
   4) 1) "client"
      2) "setname"
      3) "lnrcoder"
   5) "127.0.0.1:43942"
   6) "lnrcoder"

慢日志数量

127.0.0.1:6379> slowlog len
127.0.0.1:6379> 100

慢日志清除

127.0.0.1:6379> slowlog reset
127.0.0.1:6379> ok
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,163评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,301评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,089评论 0 352
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,093评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,110评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,079评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,005评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,840评论 0 273
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,278评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,497评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,667评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,394评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,980评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,628评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,796评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,649评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,548评论 2 352

推荐阅读更多精彩内容