redis入门第八课:高可用方案

redis提供了哨兵和自动分区(cluster)两种方案提供高可用性。

一 哨兵

1. 哨兵环境搭建

1.1 windows环境下搭建一主两从三哨兵方案

  • 下载redis
    从官网下载redis,复制三份存放在文件夹下:


    1556155780(1).png
  • 修改配置文件
    分别在reids6379,reids6380,reids6381中添加配置文件sentinel26379.conf,sentinel26380.conf,sentinel26381.conf
port 26379
sentinel monitor mymaster 127.0.0.1 6379 2
sentinel down-after-milliseconds mymaster 5000
sentinel failover-timeout mymaster 15000
port 26380
sentinel monitor mymaster 127.0.0.1 6379 2
sentinel down-after-milliseconds mymaster 5000
sentinel failover-timeout mymaster 15000
port 26381
sentinel monitor mymaster 127.0.0.1 6379 2
sentinel down-after-milliseconds mymaster 5000
sentinel failover-timeout mymaster 15000
  • 启动
    启动写成了脚本,如下所示:
@echo off
title sentinel26379
cd redis6379
redis-server.exe sentinel26379.conf --sentinel
exit
1556156127(1).png
  • 测试

[13076] 25 Apr 11:30:41.963 # Sentinel ID is 4b5835f70e8e470b6a4999cb9a37286fcb2609b8
[13076] 25 Apr 11:30:41.963 # +monitor master mymaster 127.0.0.1 6379 quorum 2
[13076] 25 Apr 11:30:46.964 # +sdown slave 127.0.0.1:6380 127.0.0.1 6380 @ mymaster 127.0.0.1 6379
[13076] 25 Apr 11:30:46.964 # +sdown slave 127.0.0.1:6381 127.0.0.1 6381 @ mymaster 127.0.0.1 6379
[13076] 25 Apr 11:30:46.964 # +sdown sentinel 687b482b013aba1bd41e30b26e954d78fa7eb48e 127.0.0.1 26380 @ mymaster 127.0.0.1 6379
[13076] 25 Apr 11:30:46.964 # +sdown sentinel c08c97fd44a7b2fa8117fe4be359427236281535 127.0.0.1 26381 @ mymaster 127.0.0.1 6379

刚启动时会生成一个sentinel ID.
然后监视主服务器,主服务器下的从服务器,还有其他sentinel.
启动sentinel之后,生成的配置文件:

port 26379
sentinel myid dd815761fbe7d5598658936cee57bbe9adafa25e
sentinel monitor mymaster 127.0.0.1 6379 2
sentinel down-after-milliseconds mymaster 5000
# Generated by CONFIG REWRITE
dir "D:\\company\\redis\\redis6379"
sentinel failover-timeout mymaster 15000
sentinel config-epoch mymaster 0
sentinel leader-epoch mymaster 0
sentinel current-epoch 0

down机器之后,开始选举:

[15244] 25 Apr 11:49:41.571 # +new-epoch 13
[15244] 25 Apr 11:49:41.571 # +try-failover master mymaster 127.0.0.1 6379
[15244] 25 Apr 11:49:41.574 # +vote-for-leader fbc5ed89e3d9f9a2571163760e6c789d2e080136 13
[15244] 25 Apr 11:49:41.581 # 77826f40228c6f9e877f44124734f1637dc2a0b8 voted for fbc5ed89e3d9f9a2571163760e6c789d2e080136 13
[15244] 25 Apr 11:49:41.582 # dd815761fbe7d5598658936cee57bbe9adafa25e voted for fbc5ed89e3d9f9a2571163760e6c789d2e080136 13
[15244] 25 Apr 11:49:41.665 # +elected-leader master mymaster 127.0.0.1 6379
[15244] 25 Apr 11:49:41.665 # +failover-state-select-slave master mymaster 127.0.0.1 6379
[15244] 25 Apr 11:49:41.722 # +selected-slave slave 127.0.0.1:6380 127.0.0.1 6380 @ mymaster 127.0.0.1 6379
[15244] 25 Apr 11:49:41.722 * +failover-state-send-slaveof-noone slave 127.0.0.1:6380 127.0.0.1 6380 @ mymaster 127.0.0.1 6379
[15244] 25 Apr 11:49:41.813 * +failover-state-wait-promotion slave 127.0.0.1:6380 127.0.0.1 6380 @ mymaster 127.0.0.1 6379
[15244] 25 Apr 11:49:42.664 # +promoted-slave slave 127.0.0.1:6380 127.0.0.1 6380 @ mymaster 127.0.0.1 6379
[15244] 25 Apr 11:49:42.664 # +failover-state-reconf-slaves master mymaster 127.0.0.1 6379
[15244] 25 Apr 11:49:42.719 * +slave-reconf-sent slave 127.0.0.1:6381 127.0.0.1 6381 @ mymaster 127.0.0.1 6379
[15244] 25 Apr 11:49:43.729 # -odown master mymaster 127.0.0.1 6379
[15244] 25 Apr 11:49:43.729 * +slave-reconf-inprog slave 127.0.0.1:6381 127.0.0.1 6381 @ mymaster 127.0.0.1 6379
[15244] 25 Apr 11:49:43.730 * +slave-reconf-done slave 127.0.0.1:6381 127.0.0.1 6381 @ mymaster 127.0.0.1 6379
[15244] 25 Apr 11:49:43.784 # +failover-end master mymaster 127.0.0.1 6379
[15244] 25 Apr 11:49:43.784 # +switch-master mymaster 127.0.0.1 6379 127.0.0.1 6380
[15244] 25 Apr 11:49:43.785 * +slave slave 127.0.0.1:6381 127.0.0.1 6381 @ mymaster 127.0.0.1 6380
[15244] 25 Apr 11:49:43.785 * +slave slave 127.0.0.1:6379 127.0.0.1 6379 @ mymaster 127.0.0.1 6380
[15244] 25 Apr 11:49:48.830 # +sdown slave 127.0.0.1:6379 127.0.0.1 6379 @ mymaster 127.0.0.1 6380

配置文件变化:

port 26379
sentinel myid dd815761fbe7d5598658936cee57bbe9adafa25e
sentinel monitor mymaster 127.0.0.1 6380 2
sentinel down-after-milliseconds mymaster 5000
# Generated by CONFIG REWRITE
dir "D:\\company\\redis\\redis6379"
sentinel failover-timeout mymaster 15000
sentinel config-epoch mymaster 13
sentinel leader-epoch mymaster 13
sentinel known-slave mymaster 127.0.0.1 6379
sentinel known-slave mymaster 127.0.0.1 6381
sentinel known-sentinel mymaster 127.0.0.1 26380 77826f40228c6f9e877f44124734f1637dc2a0b8
sentinel known-sentinel mymaster 127.0.0.1 26381 fbc5ed89e3d9f9a2571163760e6c789d2e080136
sentinel current-epoch 13

2. 哨兵详解

2.1 定义

Redis Sentinel 是一个分布式系统, 你可以在一个架构中运行多个 Sentinel 进程(progress), 这些进程使用流言协议(gossip protocols)来接收关于主服务器是否下线的信息, 并使用投票协议(agreement protocols)来决定是否执行自动故障迁移, 以及选择哪个从服务器作为新的主服务器。

2.2 特点

Redis 的 Sentinel 系统用于管理多个 Redis 服务器(instance), 该系统执行以下三个任务:
监控(Monitoring)
Sentinel 会不断地检查你的主服务器和从服务器是否运作正常。
提醒(Notification)
当被监控的某个 Redis 服务器出现问题时, Sentinel 可以通过 API 向管理员或者其他应用程序发送通知。
自动故障迁移(Automatic failover)
当一个主服务器不能正常工作时, Sentinel 会开始一次自动故障迁移操作, 它会将失效主服务器的其中一个从服务器升级为新的主服务器, 并让失效主服务器的其他从服务器改为复制新的主服务器; 当客户端试图连接失效的主服务器时, 集群也会向客户端返回新主服务器的地址, 使得集群可以使用新主服务器代替失效服务器。

2.3 原理

先来介绍一些必要的概念。

2.3.1 定时任务

每个哨兵有3个定时任务:


图片.png
2.3.2 主观下线

sentinel会每秒定期向redis服务器(主服务器,从服务器,其他sentinel服务器)发送PING命令,如果返回有效回复(+pong,-loading,-masterdown)被认为么有下线,网络连接状态好。如果是无效状态(除+pong,-loading,-masterdown之外的回复)或者指定时限内没有返回,则该sentinel主观认定该redis服务器下线。被称为主观下线。


图片.png
2.3.3 客观下线

当一个redis服务器A被认为主观下线,则sentinel会向这台redis服务器A的其他哨兵服务器询问是否下线,如果有足够多数量的哨兵服务器觉得服务器A下线了,则redis服务器A被认为客观下线了。
需要注意的是:只有主服务器才有客观下线的说法。而从服务器是不需要判断是否客观下线的。


图片.png
2.3.4 选举新的sentinel

当一个主服务器被认为是客观下线,则需要从从服务器中选择一台新的服务器作为主服务器。所以,必须先选出一台新的sentinel作为领头来选举新的主服务器。从而实现故障转移。
redis采用了raft算法作为选举领导sentinel的算法,思想就是先到先得。就是说,一旦有sentinel服务器A觉得主服务客观下线,就想其他sentinel服务器发送命令,并要求其他sentinel将自己设置为领导sentinel服务器A,如果其他服务器没有设置其他服务器作为领导服务器,那么就 将这台sentinel服务器A作为领导服务器,并返回消息给sentinel服务器A。这样sentinel服务器A收集到如果有半数以上的其他sentinel将自己设置为领头sentinel,则这台sentinel服务器A就是领头服务器,接下来就要参与故障转移了。
需要注意的是如果在给定时间内没有选举出领头sentinel,将会新一轮的sentinel。每个sentinel内部都有一个配置纪元,不管选举成功还是失败,这个配置纪元的值都要加1。一般来说,哨兵选择的过程很快,谁先完成客观下线,一般就能成为领导者。

2.3.5 故障转移

故障转移有三个过程:

  • 第一步:先从从结点中选举一台服务器作为主服务器B。
  • 第二步:将原主服务器A下所有从服务器都重新复制新主服务器B。
  • 第三步:sentinel继续监控原主服务器A,一旦检测到原主服务A上线,将原主服务器设置为新主服务器B的从服务器并重新复制。
2.3.5.1 选举新的主节点

选举新的主节点的流程:


图片.png

其实,前面三个过程可以被认为是删除无效的从节点。这样流程就是:

  • 删除无效从节点
  • 根据从服务器的优先级,优先级最高最有可能成为新的主节点
  • 根据偏移量大小,偏移量越大最有可能成为新的主节点
  • 根据运行id,运行id越小最有可能成为新的主节点
2.3.5.2 修改原主服务器下的所有从服务器复制新的主节点

这个过程通过发送slave of new_master_ip new_master_port来实现,让原主结点下的从节点复制新的主节点数据。

2.3.5.3 sentinel继续监测原主节点

这个过程通过发送slave of new_master_ip new_master_port来实现,让原主结点制新的主节点数据。

3. 哨兵主从切换的数据丢失问题

我们可以看到在故障转移的时候有可能丢失数据的,有以下两种情况会发生这种问题:

  • 异步复制导致的数据丢失
    由于master-slave的复制是异步的,如果master在写入命令之后,还没来得及将写命令传播到slave的时候发生了宕机,但是这个时候sentinel就选举出了新的master,而原来master缓存区的写命令就丢失了。
  • 脑裂导致的数据丢失
    脑裂,也就是说,某个 master 所在机器突然脱离了正常的网络,跟其他 slave 机器不能连接,但是实际上 master 还运行着。此时哨兵可能就会认为 master 宕机了,然后开启选举,将其他 slave 切换成了 master。这个时候,集群里就会有两个 master ,也就是所谓的脑裂
    此时虽然某个 slave 被切换成了 master,但是可能 client 还没来得及切换到新的 master,还继续向旧 master 写数据。因此旧 master 再次恢复的时候,会被作为一个 slave 挂到新的 master 上去,自己的数据会清空,重新从新的 master 复制数据。而新的 master 并没有后来 client 写入的数据,因此,这部分数据也就丢失了。
    数据丢失问题的解决方案
    进行如下配置:
min-slaves-to-write 1
min-slaves-max-lag 10

表示,要求至少有 1 个 slave,数据复制和同步的延迟不能超过 10 秒。
如果说一旦所有的 slave,数据复制和同步的延迟都超过了 10 秒钟,那么这个时候,master 就不会再接收任何请求了。

  • 减少异步复制数据的丢失

有了 min-slaves-max-lag这个配置,就可以确保说,一旦 slave 复制数据和 ack 延时太长,就认为可能 master 宕机后损失的数据太多了,那么就拒绝写请求,这样可以把 master 宕机时由于部分数据未同步到 slave 导致的数据丢失降低的可控范围内。

  • 减少脑裂的数据丢失
    如果一个 master 出现了脑裂,跟其他 slave 丢了连接,那么上面两个配置可以确保说,如果不能继续给指定数量的 slave 发送数据,而且 slave 超过 10 秒没有给自己 ack 消息,那么就直接拒绝客户端的写请求。因此在脑裂场景下,最多就丢失 10 秒的数据。

二 集群

1 集群简介

1.1 定义

Redis 集群是一个提供在多个Redis间节点间共享数据的程序集。具有以下两个优点:

* 自动分割数据到不同的节点上
* 整个集群的部分节点失败或者不可达的情况下能够继续处理命令

1.2 集群结点之间的通信

  • 节点之间采用Gossip协议进行通信,Gossip协议就是指节点彼此之间不断通信交换信息
    image

    当主从角色变化或新增节点,彼此通过ping/pong进行通信知道全部节点的最新状态并达到集群同步
  • Gossip协议
    Gossip协议的主要职责就是信息交换,信息交换的载体就是节点之间彼此发送的Gossip消息,常用的Gossip消息有ping消息、pong消息、meet消息、fail消息
    image

    meet消息
    用于通知新节点加入,消息发送者通知接收者加入到当前集群,meet消息通信完后,接收节点会加入到集群中,并进行周期性ping pong交换
    ping消息
    集群内交换最频繁的消息,集群内每个节点每秒向其它节点发ping消息,用于检测节点是在在线和状态信息,ping消息发送封装自身节点和其他节点的状态数据;
    pong消息
    当接收到ping meet消息时,作为响应消息返回给发送方,用来确认正常通信,pong消息也封闭了自身状态数据;
    fail消息
    当节点判定集群内的另一节点下线时,会向集群内广播一个fail消息,
    集群通信:
    CLUSTER MEET <ip> <port>
    图片.png

2 槽

2.1 定义

redis集群中有2^14个槽,编号从0,1,2,3,4,2^13-1。但是这个槽是虚拟的并不存在。集群为每个Master节点指派槽的范围,然后集群使用公式: CRC16(key) % 16383 来计算键 key 属于哪个槽。实际上只有master才有槽的所有权,而master下的slave只有槽的概念,只能使用槽。

2.2 命令

CLUSTER ADDSLOTS 1 2 3

比如,我们有结点A,B,C是主服务器,结点A1,B1,C1是从服务器,我们给A,B,C三个结点指派槽的范围:

127.0.0.1:6279>CLUSTER ADDSLOTS 0 1 2 3 ... 5000
127.0.0.1:6280>CLUSTER ADDSLOTS 5001 5002 5003 ... 10000
127.0.0.1:6281>CLUSTER ADDSLOTS 100001 100002 10003 ... 16383

如下所示


图片.png

现在如果CRC(key)%16383=200,则这个键值对会被映射到结点A上。如果CRC(key)%16383=10010,则这个键值对会被映射到结点B上。
这个命令仅在cluster 模式下生效,而且作用于redis集群以下操作:

  • 创建新集群时,ADDSLOTS用于主节点初始化分配可用的hash slots。
  • 为了修复有未分配slots的坏集群。

2.3 记录结点的槽指派信息

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

struct clusterNode {

           //…

           unsignedchar slots[16384/8];
           int numslots;
};

Slots属性是一个二进制位数组(bitarray),这个数组的长度为16384/8=2048个字节,共包含16384个二进制位。
Master节点用bit来标识对于某个槽自己是否拥有。比如对于编号为1的槽,Master只要判断序列的第二位(索引从0开始)是不是为1即可。时间复杂度为O(1)。

  • 如果索引在第i二进制位上的值是1,则处理这个i的槽。否则,不处理。
  • 传播槽信息
    由于集群中的各个结点之间相互需要通信,检测对方的信息。因此也需要检测各个结点的槽信息。一旦结点初始化了槽信息,这个结点就会将自己的槽信息slosts数组传播给他其他结点,然后其他结点再字典中维护这个结点的槽信息。

2.4 集群中执行命令

  1. 判断key属于哪个槽
    根据公式确定:
CRC16(key)%16383
  1. 判断确定的槽是否由当前结点处理
  • 如果计算的槽在当前结点设置的槽范围内,则当前结点处理。
  • 如果计算的槽不在当前结点设置的槽范围内,请求需要被重定向。
  • 回复一个 MOVED 错误
    当结点发现计算的槽不在当前结点设置的槽范围内,向客户端返回一个MOVED错误,并引导客户端指向正确的结点处理。
    moved错误格式为:
MOVED <slot> <ip> <port>

slot表示计算的槽,ip,port表示负责处理槽的结点的ip和port.比如:moved 6000 127.0.0.1 6380。表示6000这个槽由127.0.0.1:6380这个结点负责。类似下图:


图片.png

2.4 重新分片

在一个稳定的Redis cluster下,每一个slot对应的节点是确定的,但是在某些情况下,节点和分片对应的关系会发生变更。

  • 新增一个结点
    比如在集群中新增一个集群D:Ip:127.0.0.1 port:8888
127.0.0.1:6379>CLUSTER MEET 127.0.0.1 8888  

现在127.0.0.1:8888 已经是集群中的一个结点,但是还不具备结点的存储能力。必须在分配槽之后才能成为真正意义上是一个结点。
然后执行slot迁移,这个后面再介绍。

CLUSTER SETSLOT slot IMPORTING|MIGRATING|STABLE|NODE [node-id]
  • 删除一个结点
    先将节点的数据移动到其他节点上,然后才能执行删除

2.5 槽迁移

我们从上一节可以看到,不管是新增结点还是删除结点都需要槽迁移的操作。那我们来看看槽是如何进行迁移的。
槽迁移会涉及到两个结点,一个源节点B,一个目标结点D,这个源节点B有一个状态:MIGRANTING,表示B结点正在迁移槽。目标结点D也有一个状态:IMPORTING,表示D结点正在迁入槽,如图所示:

图片.png

在槽迁移的过程中,有可能会出现这一样一种情况:结点B部分数据被迁移到结点D,现在客户端想结点B发送一条获取key的命令。而key对应的数据正好是需要被迁移的对象:


图片.png

ASK错误与MOVED错误的区别

  • 发生的时间阶段不一样:ASK错误发生在槽迁移阶段。MOVED错误发生在一个结点被访问key,这个结点没有槽的阶段。

2.6 故障转移

  • 故障检测
    如果一个主节点服务器宕机了,那集群如何检测呢?
    集群的各个结点之间都会定期的像其他结点发送PING命令来检查是否下线。如果接收PING命令的结点在规定时间内没有返回PONG命令,则发送PING命令的结点会认为这个接收ping命令的结点疑似下线(probable fail:PFAIL)
    各个结点通过Gossip协议进行通信,就是相互交换信息,一旦某个结点被认为疑似下线,那么其他结点也会得知这个结点疑似下线,并将结点疑似下线信息保存。如果这个集群里半数以上的结点认为这个结点疑似下线,则这个结点就是已经下线了。如果恰好是主节点被集群中的多数结点认为已下线,则会广播给集群其他结点一条关于主节点下线(FAIL)的消息。这个时候集群需要从这个主节点下面的从节点选出一个新的主节点,继续提供服务。
    加入,这里有个集群,有三个结点,主服务器A,B,C,A的从服务器A1,A2。B的从服务器B1,C的从无服务器C1.
    图片.png

    结点B给结点A发送一条PING命令
    图片.png

    超过规定时间,结点B没有接收到结点A的消息,认为结点A疑似下线PFAL.
    图片.png

    结点之间相互通信告知结点A疑似下线。
    图片.png

    如果结点B,结点C都认为结点A疑似下线,则结点A已下线。并广播其他结点结点A已下线。接下来就要进行故障转移了。
  • 故障转移
    当从节点发现自己的主节点变为已下线(FAIL)状态时,便尝试进Failover,以期成为新的主。
    从上面检测知道结点A已下线:


    图片.png

    以下是故障转移的执行步骤:

  1. 其他在线的主节点从下线主节点的所有从节点中选中一个从节点


    图片.png
  2. 被选中的从节点执行SLAVEOF NO NOE命令,成为新的主节点


    图片.png
  3. 新的主节点会撤销所有对已下线主节点的槽指派,并将这些槽全部指派给自己
  4. 新的主节点对集群进行广播PONG消息,告知其他节点已经成为新的主节点
  5. 新的主节点开始接收和处理槽相关的请求


    图片.png

    6.如果服务器A成功启动,则成为A2的从节点。


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

推荐阅读更多精彩内容