说说大型网站可伸缩性架构的设计原理

可伸缩性架构指的是:不改变网站的软硬件设计,只通过改变部署的服务器数量就可以扩大或缩小网站的服务处理能力。

大型网站中的 “大型”,可以表现在以下几个方面:

  • 用户方面 - 大量的用户与大量访问(Facebook 有超过 20 亿的用户数)
  • 功能方面 - 功能庞杂,产品众多(腾讯有超过 1700 种产品)
  • 技术方面 - 部署大量的服务器(Google 有近 200 万台服务器)

大型网站都是从小型网站(一台廉价的 PC 服务器)开启自己的大型系统演化之路的。在这一过程中,最重要的技术手段就是使用服务器集群,通过不断地向集群中添加服务器来增强整个集群的处理能力。只要在技术上能够向集群中加入的服务器数量与集群的处理能力成线性关系,那么就可以利用这一手段不断提升自己的网站规模,这就是系统的伸缩性架构。

演化过程从总体上来说是渐进式的,网站的规模和服务器的规模总是在不断地扩大,即总是在 “伸”。但也有可能因为运营的需要(促销活动),在某个短时间内,网站的访问量和交易规模突然爆发式增长,然后又回归正常状态。这就需要网站的技术架构具有极好的伸缩性——在活动期间向服务器集群中加入更多的服务器以满足用户的访问,活动结束后再将这些服务器下线,以节约成本。

1 设计伸缩性架构

网站架构发展史其实就是一部不断向网站添加服务器的历史。

伸缩性架构分为两种:

  • 根据功能进行物理分离 - 不同服务器部署不同的服务。
  • 单一功能通过集群实现 - 集群内的多台服务器部署相同的服务,提供相同的功能。

1.1 根据功能进行物理分离

网站发展早期,总是从现有的服务器中分离出部分功能与服务的:

根据功能进行物理分离

每次分离都会有更多的服务器加入,这些新增的服务器被用于处理某种特定的服务。这种伸缩性手段可以用于网站发展的任何阶段,它可以分为两种情况:纵向分离与横向分离。

纵向分离(分层后分离):是将业务流程上的不同层进行分离部署。

纵向分离

横向分离(业务分割后的分离):把不同的业务模块分离部署。

横向分离

横向分离的粒度可以很小,比如一个关键网页可以独立部署为一个服务,专门部署。

1.2 单一功能集群部署

在 “根据功能进行物理分离” 的模式下,随着网站访问量的增长,即使是分离到最小粒度的独立部署也可能无法满足业务规模的需要。这时就必须使用集群,即把相同的服务部署在多台服务器构成的集群上,实现整体对外服务。

当一头牛拉不动车时,不是去寻找一头更强壮的牛,而是用两头牛来拉车。

一个服务的集群规模,需要同时考虑可用性、性能以及关联服务集群的影响。


集群伸缩性的类别有这几种,后面我们会一一探讨哦O(∩_∩)O~

集群伸缩性的类别

2 伸缩性设计之应用服务器集群

把应用服务器设计为无状态模式,这样通过负载均衡服务器,就可以把用户请求转发到不同的应用服务器上咯:

负载均衡实现的应用集群

负载均衡服务器能够感知或配置集群的服务器数量,这样就可以向新上线的服务器分发请求,并停止已下线的服务器,这样就实现了应用服务器集群的伸缩性。

负载均衡技术,不仅可以实现伸缩性,还能改善网站的可用性,所以是网站技术的杀手锏之一哦O(∩_∩)O~

实现负载均衡的基础技术有以下这些。

2.1 HTTP 重定向

利用 HTTP 重定向协议实现负载均衡:

HTTP 重定向协议实现负载均衡

这里的负载均衡服务器只是一台普通的应用服务器,它会根据用户的 HTTP 请求计算出一台真实的 Web 服务器地址,然后把地址写入 HTTP 的重定向响应(状态码 302)返回给用户浏览器。

这种实现技术的优点是简单。缺点是浏览器需要请求两次服务器才能访问一次访问,性能较差;而且重定向服务器自身的处理能力有可能成为瓶颈,所以整个集群的伸缩性规模有限。而且使用 HTTP 302 响应码,有可能会被 SEO 判定为作弊,导致搜索排名被降低的后果。因此在实践中很少使用。

2.2 DNS 域名解析

DNS 域名解析实现负载均衡

DNS 的每一次域名解析请求,都会根据负载均衡算法计算出一个不同的 IP 地址并返回,这样就可以实现负载均衡啦O(∩_∩)O~

DNS 域名解析实现负载均衡的方案优点是:把工作转交给了 DNS,省掉了管理维护负载均衡服务器的麻烦,而且许多 DNS 还支持基于地理位置信息的域名解析,即会把域名解析为距离用户最近的一个服务器的地址,这样可以加快用户的访问速度,提高性能。

但缺点是:DNS 是多级解析,即如果下线了某台服务器,也需要较长的时间才会真正生效。在这段时间内,DNS 依然会将域名解析到已经下线的服务器,这样就会导致用户访问失败;而且 DNS 的负载均衡控制权掌握在域名服务商那里,这样我们就无法对其做出更多的改善。

实践中,会使用 DNS 域名解析作为第一级的负载均衡手段,即域名解析得到的结果是提供负载均衡服务的内部服务器,这样我们就可以进行二次负载均衡,把用户请求分发到真正的 Web 服务器上。

2.3 反向代理

反向代理实现负载均衡

在实际部署时,反向代理服务器位于 Web 服务器之前,这个正好也是负载均衡服务器的位置,所以大多数的反向代理服务器同时会提供负载均衡的功能。

因为 web 服务器不直接对外提供访问,所以它们不需要外部 IP,而反向代理服务器则需要配置双网卡和内外两套 IP。

因为反向代理服务器的转发请求位于 HTTP 协议层,所以叫做应用层的负载均衡。优点是集成了两种功能(反向代理、负载均衡),部署简单。缺点是反向代理服务器是所有请求和响应的中转站,所以它的性能可能会成为瓶颈。

2.4 IP 负载均衡

在网络层通过修改请求的目标地址,实现负载均衡。

IP 负载均衡

用户请求到达负载均衡服务器之后,负载均衡服务器会在操作系统内核进程中获取网络数据包,根据负载均衡算法计算得出一台 Web 服务器,然后把目的的 IP 地址修改为这台 Web 服务器。Web 服务器处理后,返回响应。负载均衡服务器再把数据包的源地址修改为自身的 IP 地址,发送回浏览器。

这里的关键点是:真实的物理 Web 服务器,它发送的响应数据包如何返回给负载均衡服务器。这里有两种方案:

  1. 源地址转换(SNAT)- 负载均衡服务器在修改目的 IP 地址的同时,修改源地址。
  2. 把负载均衡服务器作为真实物理服务器集群的网关服务器,这样所有的响应数据都会到达负载均衡服务器啦O(∩_∩)O~

因为 IP 负载均衡是在操作系统的内核层面完成数据转发,所以相对于反向代理负载均衡,有着更好的处理性能。但由于所有的请求都要经由负载均衡服务器,所以集群中的最大响应数据吞吐量会受制于负载均衡服务器的网卡带宽。

2.5 数据链路层的负载均衡

数据链路层的负载均衡指的是,在通信协议的数据链路层修改 mac 地址:

数据链路层的负载均衡

这种方式又称为三角传输模式或直接路由模式。在分发的过程中,只修改目标的 mac 地址。服务器集群内所有真实、物理的机器都配置一个与负载均衡服务器 IP 地址一样的虚拟 IP。这样配置的目的是为了让处理请求的物理服务器 IP 和与数据请求的目的 IP 一致,这样就无需在负载均衡服务器上进行地址转换啦,响应数据会直接发送给浏览器(通过网关服务器)O(∩_∩)O~

数据链路层的负载均衡是目前大型网站使用最广的一种负载均衡手段。在 Linux 平台推荐使用 LVS(Linux Virtual Server)。

2.6 负载均衡算法

实现负载均衡服务器的步骤如下:

  1. 根据负载均衡算法和 Web 服务器列表,计算出集群中的一台 Web 服务器地址。
  2. 把请求数据发送到这个地址所对应的 Web 服务器上。

目前有这些负载均衡算法:

轮询

所有请求依次分发到每一台应用服务器,即每台服务器处理的请求数是相同的,这适合所有服务器硬件都相同的场景。

加权轮询

根据服务器的硬件性能,在轮询的基础上,按照配置的权重进行请求分发,高性能的服务器会被分配更多的请求。

随机

把请求随机分配到各个服务器。这种方案简单实用,因为好的随机数本身就很均衡。如果服务器的配置不同,也可以使用加权随机。

最少连接

记录每一台服务器正在处理的请求数(连接数),把新到的请求分发到最少连接的服务器上。这个算法最符合 “负载均衡” 原本定义 !也可以使用加权最少连接。

源地址散列

根据请求来源的 IP 地址进行 Hash 计算,算出应用服务器。这样来自同一个 IP 地址的请求总会在同一台服务器上进行处理,因此可以实现会话黏滞。

3 伸缩性设计之分布式缓存集群

分布式缓存集群中的服务器,它们所缓存的数据并不相同,所以缓存的访问请求,必须先找出需要的缓存数据所在的服务器,才能处理请求。

因此,分布式缓存集群的伸缩性设计必须考虑:让新上线的缓存服务器对整个分布式缓存集群影响最小,即保证新加入缓存服务器后,整个缓存服务器集群中已经缓存的数据还是能够尽可能地被访问到!

3.1 Memcached 访问模型

应用通过 Memcached 客户端访问 Memcached 的服务器集群。Memcached 客户端由 API、路由算法、服务器集群列表和通信模块组成。

路由算法会根据缓存数据的 KEY,计算出应该把数据写入到哪一台服务器(写入缓存)或从哪一台服务器读取数据(读取缓存)。

Memcached 访问模型

一个典型的缓存写操作,如图所示。假设应用程序需要写入缓存的数据 <'DENIRO',DATA> ,Memcached API 会把数据输入到路由算法模块。然后路由算法会根据 KEY 和 Memcached 集群服务器列表计算出服务器编号(Node 1),这样就可以得到这台服务器的 IP 地址与端口。然后 Memcached API 调用通信模块与编号为 Node 1 的服务器通信,把数据写入这台服务器。这样就完成了一次分布式缓存写操作。

读缓存的过程与写类似,因为都使用同样的路由算法和服务器列表,所以只要应用程序提供相同的 KEY,那么 Memcached 客户端就总是会访问相同的服务器来读取数据。因此只要服务器还缓存着数据,就能保证缓存被命中。

3.2 Memcached 分布式缓存实现伸缩性

Memcached 分布式缓存系统中,路由算法很重要,因为它决定了应该访问集群中的哪一台服务器。

简单的路由算法是余数 Hash:服务器数除以缓存数据 KEY 的 Hash 值,求得的余数即为服务器列表的下标。因为 Hash 值的随机性,所以余数 Hash 可以保证缓存数据在整个 Memcached 服务器集群中比较均衡地分布。

但是,当分布式缓存服务器集群需要扩容时,事情就棘手咯。假设把目前已有的 3 台缓存服务器扩容为 4 台。更改服务器列表后,仍然使用余数 Hash 算法,会导致缓存不命中(因为原来是除以 3,现在是除以 4,自然有问题咯)。

三台服务器扩容至四台,大约有 75%(3/4)被缓存的数据不命中。随着服务器集群规模的增大,这个比例呈线性上升。当在 N 台服务器集群中加入一台新服务器时,不能命中的概率为 N/(N+1)。如在 100 台中加入一台,不能命中的概率为 99%。

这个结果显然不能接受。网站的大部分业务的读操作请求,实际上都是通过缓存获取的,只有少量的读操作请求会访问数据库,因此数据库的负载能力是以有缓存的前提而设计的。当大部分缓存的数据因为服务器扩容而不能正确读取时,这些访问数据的压力就都落在了数据库身上,这将大大超出数据库的负载能力,甚至会导致数据库宕机。

一种方法是:在网站访问量最少的时候再扩容,这时候对数据库的负载压力最小。然后通过模拟请求来逐步预热缓存,使得缓存服务器中的数据可以重新分布。但这种方案对业务场景有要求,而且还需要技术团队通宵加班。看来好像不是个好主意!

3.3 一致性 Hash 算法

一致性 Hash 算法通过一致性 Hash 环的数据结构来实现 KEY 到缓存服务器的 Hash 映射:

一致性 Hash 算法

先构造一个长度为 0 ~ 2 的 32 次方的整数环(一致性 Hash 环),根据节点名称的 Hash 值,把缓存服务器节点放置在这个 Hash 环上。然后根据需要缓存数据的 KEY 值计算出 Hash 值(范围在 0 ~ 2 的 32 次方),最后再在 Hash 环上顺时针查找距离这个 KEY 的 Hash 值最近的缓存服务器节点,完成 KEY 到服务器的 Hash 映射查找。

比如图中的 KEY0 在环上顺时针查找,就会找到一个最近的节点 NODE 1。

当缓存服务器需要扩容时,只需要将新加入的节点名称(比如 Node 3)的 Hash 值放入环中,因为 KEY 是顺时针查找距离最近的节点,所以新加入的节点只会影响整个环中的一小段。

增加新的节点

如图所示,原来的大部分的 KEY 还能继续使用原来的节点,这样就能保证大部分被缓存的数据还能被命中。3 台服务器扩容至 4 台服务器,可以继续命中原有缓存数据的概率为 75%;100 台服务器集群增加一台服务器,继续命中原有缓存数据的概率为 99%。是不是很棒呀O(∩_∩)O~

一致性 Hash 环通常使用二叉查找树实现,树最右边的叶子节点和最左边的叶子节点是相连接,构成环。Hash 查找的过程是在树中查找不小于查找数的最小数值。

一致性 Hash 环有一个缺陷:比如上例,新加入的节点 NODE 3 只影响了原来的节点 NODE 1。这意味着 NODE 0 和 NODE 2 缓存的数据量和负载量是 NODE 1 和 NODE 3 的两倍。如果这 4 台服务器性能相同,那么我们自然希望这些服务器缓存的数据量和负载量分布是均衡的。

计算机的任何问题都可以通过增加一个虚拟层来解决。

我们把每一台物理缓存服务器虚拟为一组虚拟缓存服务器,这样就可以将虚拟服务器的 Hash 值放置在环上咯。KEY 会先在环上找出虚拟服务器节点,然后再得到物理服务器的信息。

这样新加入的缓存服务器,会较为均匀地影响原来集群中已经存在的服务器:

  • 绿色:NODE 0 对应的虚拟节点。
  • 蓝色:NODE 1 对应的虚拟节点。
  • 紫色:NODE 2 对应的虚拟节点。
  • 红色:NODE 3 对应的虚拟节点。

显然,每个物理节点对应的虚拟节点越多,那么各个物理节点之间的负载就会越均衡。虚拟节点数的经验值是 150。

3 伸缩性设计之数据存储服务器集群

数据存储服务器必须保证数据的可靠存储,任何情况下都必须保证数据的可用性和正确性。

3.1 关系数据库集群

目前,主流的关系型数据库都支持数据复制功能,我们可以利用这个功能对数据库进行简单伸缩。

MySQL 关系型数据库集群的伸缩性设计

写操作都在主服务器上进行,然后再由主服务器把数据同步到集群中的其他从服务器。

也可以使用数据分库,即把不同的业务数据表部署在不同的数据库集群上。使用这种方式的不足是:跨库的表不能进行关联查询。

在实际应用中,还会对一些数据量很大的单表进行分片,即把一张表拆开,分别存储在多个数据库中。

目前比较成熟的支持数据分片的分布式关系数据库有开源的 Amoeba 和 Cobar。它们有相似的架构,所以我们这里以 Cobar 为例:

Cobar 部署模型

Cobar 是分布式关系数据库的访问代理,部署于应用服务器和数据库服务器之间,也可以以 lib 的方式与应用部署在一起。应用通过 JDBC 访问 Cobar 集群,Cobar 服务器依据 SQL 和分库规则来分解 SQL,然后把请求分发到 MySQL 集群中的不同数据库实例(每个实例都部署为主从结构,保证数据高可用)上执行。

Cobar 系统组件模型

前端通信模块接收应用发送过来的 SQL 请求,然后交与 SQL 解析模块处理,再流转到 SQL 路由模块。SQL 路由模块根据路由配置的规则把 SQL 语句分解为多条 SQL。最后把这些 SQL 发送给多个数据库分别执行。

多个数据库把执行的结果返回给 SQL 执行代理模块,再通过结果合并,把两个结果集合并为一个结果集,最终返回给应用。

Cobar 服务器可以看作是无状态的应用服务器,所以可以直接使用负载均衡手段实现集群伸缩。而 MySQL 服务器中存储着数据,所以我们要做数据迁移(把集群中原有的服务器中的数据迁移到新的服务器),才能保证扩容后数据一致负载均衡。

Cobar 服务器伸缩性原理

可以利用一致性 Hash 算法进行数据迁移,尽量使得要迁移的数据最少。因为迁移数据需要遍历数据库中的每一条记录进行路由计算,所以这会对数据库造成一定的压力。而且还要解决迁移过程中的数据一致性、可访问性、可用性等问题。

实践中,Cobar 服务器利用 MySQL 的数据同步功能进行数据迁移。迁移是以 Schema 为单位。在 Cobar 集群初始化时,为每一个 MySQL 实例创建多个 Schema。Schema 的个数依据业务远景的集群规模来估算,如果未来集群的最大规模为 1000 台数据库服务器,那么总的初始 Schema 数>= 1000。在扩容的时候,从每个服务器中,迁移一部分 Schema 到新服务器。因为迁移是以 Schema 为单位,所以可以利用 MySQL 同步机制:

利用 MySQL 同步机制实现 Cobar 集群伸缩

同步完成后,即新服务器中的 Schema 数据和原服务器中的 Schema 数据一致后,修改 Cobar 服务器的路由配置,把这些 Schema 的 IP 地址修改为新服务器的 IP 地址,最后删除原服务器中被迁移的 Schema,即可完成 MySQL 集群的扩容啦O(∩_∩)O~

Cobar 服务器处理所消耗的时间很少,时间主要还是花费在 MySQL 数据库服务器上。所以应用通过 Cobar 访问分布式关系数据库的性能与直接访问关系数据库是相当的,因此可以满足网站在线业务的实时处理需求。而且Cobar 使用了较少的连接来访问数据库,还因此改善了性能。

但 Cobar 只能在单一数据库实例上处理查询请求,因此无法执行跨库关联操作,当然更不能进行跨库事务处理咯。这是分布式数据库的通病。

面对海量的业务数据存储压力,我们还是得使用分布式数据库呀,怎么办?我们可以避免事务或者利用事务补偿机制来代替数据库事务,也可以通过分解数据访问逻辑来避免数据表关联操作。

有的分布式数据库(如 GreenPlum)支持跨库关联操作,但访问延迟较大,因为跨库关联需要在服务器之间传输大量的数据,所以一般用于数据仓库等非实时的业务中。

3.2 NoSQL 数据库

NoSQL 指的是非关系的、分布式数据库设计模式。它更关注高可用与可伸缩性。

目前应用最广泛的是 Apache HBase。

HBase 依赖可分裂的 HRegion 和可伸缩的分布式文件系统 HDFS。

HBase 架构

数据以 HRegion 为单位。数据的读写操作都是交由 HRegion 进行处理。每个 HRegion 存储一段以 KEY 值为区间 [key1,key2) 的数据。因为 HRegionServer 是物理服务器,所以每个 HRegionServer 上可以启动多个 HRegion 实例。当一个 HRegion 写入的数据超过配置的阈值时,HRegion 就会分裂为两个 HRegion,然后把这两个 HRegion 在整个集群上进行迁移,以使 HRegionServer 达到负载均衡。

所有的 HRegion 信息都记录在 HMaster 服务器上。为了保证高可用,HBase 会启动多个 HMaster,并通过 ZooKeeper 选举出一个主服务器。应用会通过 ZooKeeper 获得主的 HMaster 地址:

HBase 查询数据时序图

写入过程也类似,需要先得到 HRegion 才能写入。HRegion 会把数据存储为多个 HFile 格式的文件,这些文件使用 HDFS 分布式文件系统进行存储,保证在整个集群内分布并高可用。

如果集群中加入了新的服务器(新的 HRegionServer),HBase 会把 HRegion 迁移过去并记录到 HMaster 服务器中,从而实现 HBase 的线性伸缩。


伸缩性架构设计能力是网站架构师必备的技能。

伸缩性架构设计:一方面是简单的,因为有很多案例可供借鉴,而且又有大量商业、开源的具有伸缩性能力的软硬件产品可供选择;另一方面又是复杂的,因为没有通用、完美的产品或解决方案。而且伸缩性往往又跟可用性、正确性和性能耦合在一起,所以架构师必须对网站的商业目标、历史演化和技术路线了然于胸,并综合考虑技术团队的知识储备和结构以及管理层的战略愿景和规划,才能做出最适合的伸缩性架构决策。

一个具有良好的伸缩性架构的网站,它的设计总是走在业务发展的前面,在业务需要处理更多访问和服务之前,就已经做好充分的准备,只要业务有需求,只需要购买或租用服务器简单部署就好咯O(∩_∩)O~

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

推荐阅读更多精彩内容