mysql高性能索引

转自:http://blog.csdn.net/lemon89/article/details/50193891

理解磁盘IO

主轴让磁盘盘片转动,然后传动手臂可伸展让读取头在盘片上进行读写操作。每个盘片有两面,都可记录信息,所以一张盘片对应着两个磁头。

磁盘物理结构如下图:

image
image

扇区:盘片被分为许多扇形的区域,每个区域叫一个扇区,硬盘中每个扇区的大小固定为512字节
磁道:盘片表面上以盘片中心为圆心,不同半径的同心圆环称为磁道。


image

磁盘垂直视角

一个I/O请求所花费的时间=寻道时间+旋转延迟+数据传输时间(约10ms)

当需要从磁盘读取数据时,系统会将数据逻辑地址传给磁盘,磁盘的控制电路按照寻址逻辑将逻辑地址翻译成物理地址,即确定要读的数据在哪个磁道,哪个扇区。为了读取这个扇区的数据,需要将磁头放到这个扇区上方,为了实现这一点,磁头需要移动对准相应磁道,这个过程叫做寻道,所耗费时间叫做寻道时间,然后磁盘旋转将目标扇区旋转到磁头下,这个过程耗费的时间叫做旋转时间。

寻道时间(Tseek) :

将读写磁头移动至正确的磁道上所需要的时间。寻道时间越短,I/O操作越快,目前磁盘的平均寻道时间一般在3-15ms。

旋转延迟(Trotation)

指盘片旋转将请求数据所在的扇区移动到读写磁盘下方所需要的时间。旋转延迟取决于磁盘转速,通常用磁盘旋转一周所需时间的1/2表示。比如:7200rpm的磁盘平均旋转延迟大约为60*1000/7200/2 = 4.17ms,而转速为15000rpm的磁盘其平均旋转延迟为2ms。

数据传输时间(Transfer)

是指完成传输所请求的数据所需要的时间,它取决于数据传输率,其值等于数据大小除以数据传输率。数据传输时间通常远小于前两部分消耗时间。简单计算时可忽略。

预读

当一次IO时,不光把当前磁盘地址的数据,而是把相邻的数据也都读取到内存缓冲区内,因为局部预读性原理告诉我们,当计算机访问一个地址的数据的时候,与其相邻的数据也会很快被访问到。每一次IO读取的数据我们称之为一页(page)。

IOPS与吞吐量

连续读写性能很好,但随机读写性能很差
机械硬盘的连续读写性能很好,但随机读写性能很差,这主要是因为磁头移动到正确的磁道上需要时间,随机读写时,磁头需要不停的移动,时间都浪费在了磁头寻址上,所以性能不高。

IOPS

IOPS(Input/Output Per Second)即指每秒内系统能处理的I/O请求数量。 随机读写频繁的应用,如小文件存储等,关注随机读写性能,IOPS是关键衡量指标。

可以推算出磁盘的IOPS = 1000ms / (Tseek + Trotation + Transfer)

常见磁盘的随机读写最大IOPS为:

7200rpm的磁盘 IOPS = 76 IOPS
10000rpm的磁盘IOPS = 111 IOPS
15000rpm的磁盘IOPS = 166 IOPS

磁盘吞吐量

指单位时间内可以成功传输的数据数量。

磁盘阵列与服务器之间的数据通道对吞吐量影响很大。
顺序读写频繁的应用,如视频点播,关注连续读写性能、数据吞吐量是关键衡量指标。

InnoDB索引——B+Tree索引

B+Tree,简单来说就是一种为磁盘或者其他存储设备而设计的一种平衡二叉树。
由二叉树,平衡二叉树,BTree演化而来。

二叉树 要保证父节点大于左子结点,小于右子节点。

平衡二叉树 在二叉树的基础上,还要保证任一结点的两个儿子字树高度差不大于1。

BTree 是一种自平衡二叉树,继承了上述平衡二叉树的特性,另外并保证了每个叶子结点到根节点的距离相同。

BTree vs B+Tree:

B+树与BTree主要不同就是data的存放位置,以及叶子结点的指针构成链表。

键值的拷贝被存储在内部节点(或称非叶子结点);键值和记录存储在叶子节点;
一个叶子节点可以包含一个指针,指向另一个叶子节点以加速顺序存取。
二叉树


image

平衡二叉树


image

BTree


image

B+Tree


image

索引为什么使用B+Tree?

每个页的叶子结点包含较多的数据,因此树的高度较低(3~4),而树的高度也决定了磁盘IO的次数,从而影响了数据库的性能。一般情况下,IO次数与树的高度是一致的
对于组合索引,B+tree索引是按照索引列名进行顺序排序的,因此可以将随机IO转换为顺序IO提升IO效率;并且可以支持order by \group等排序需求;适合范围查询


image

聚集索引 与 非聚集索引

聚集索引:

InnfoBD引擎是索引组织表,所有数据都存放在聚集索引中。

准确来说聚集索引并不是某种单独的索引类型,而是一种数据存储方式。就是指在同一个结构中保存了B+tree索引以及数据行。InnoDB中通常主键就是一个聚集索引。

innoDB中,用户如果没有设置主键索引,会随机选择一个唯一的非空索引替代,
如果没有这样的索引,会隐式的定义一个主键作为隐式的聚集索引。
通常将主键设置为一个与业务无关的自增数字,这样能保证按照主键顺序插入数据,避免页分裂以及碎片问题。

主键索引的非叶子结点存放的是<.key,address.>,address就是指向下一层的指针。

主键索引的叶子结点保存了所有列的信息,因此通过主键索引可以快速获取数据。

辅助索引

(或称为非聚集索引、二级索引)
辅助索引的叶子结点并没有存放数据,而是存放了主键索引的值信息,而是<.key,address.>的形式。address用于指向对应的主键索引的key。

因为二级索引叶子页中存放了主键索引的值信息,如果主键索引很大的话,会导致所有索引都比较大。因此主键索引尽可能要小

也就是说使用辅助索引查询,会通过叶子结点找到对应的主键,在主键索引中找到最终的数据。

为什么要使用索引

  • 使用索引可以大大减少服务器需要扫描的数据量。
  • 使用索引可以帮助服务器避免排序或者临时表
  • 索引是随机I\O变为 顺序I\O.

索引的适用范围

索引并不是适用于任何情况。对于中型、大型表适用。对于小型表全表扫描更高效。而对于特大型表,考虑”分区”技术。

高性能索引策略(其他类型索引:略)

以下讲解使用如下表作为示例:

mysql> show create table people \G
*************************** 1. row ***************************
Table: people
Create Table: CREATE TABLE `people` (
  `last_name` varchar(50) NOT NULL,
  `first_name` varchar(50) NOT NULL,
  `dob` date NOT NULL,
  `gender` enum('m','f') NOT NULL,
  KEY `last_name` (`last_name`,`first_name`,`dob`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8
1 row in set (0.00 sec)

最左前缀匹配原则

对于KEY last_name (last_name,first_name,dob),where 后的谓词必须包含last_name(组合索引的最左列),否则无法使用这个索引.

示例:

select ... from people where .... last_name="..."....

这个语句将使用 last_name (last_name,first_name,dob)索引。

无法跳过某个列使用后续索引列

示例:

SELECT ... FROM people WHERE last_name="..." AND dob="..."

这个语句只使用了last_name (last_name,first_name,dob)的last_name列,因为缺少first_name,所以后续dob列也无法从索引中搜索。

范围查询后的列无法使用索引

示例:

SELECT ... FROM people WHERE last_name > "..."  AND first_name= "..." AND dob= "..."

这个语句只使用了last_name (last_name,first_name,dob)的last_name列,因为last_name 使用了范围查询,所以后续索引的两个列无法使用。

什么事范围查询:
使用了范围查询的语句。范围查询指使用 “>” 、”<”、“between” “like”的查询。注意“in”不算范围查询,属于多值查询条件。
列作为函数参数或表达式的一部分

列作为函数参数或表达式的一部分无法正常使用索引。
示例1:

SELECT ... FROM people WHERE last_name+1 = "1001"

示例2:

SELECT ... FROM people FORCE INDEX(last_name) WHERE LEFT(last_name,3) = "..."

以上示例均无法使用last_name (last_name,first_name,dob)索引。explain 展示位全表扫描。

前缀索引与索引选择性

什么是索引的选择性:

索引的基数(explain 中的一列:Cardinality) / 表的总记录数(#T)
select count(Distinct columnName)/count(*) from Table

范围从 1#T ~ 1 ,值越高查询效率越高。唯一索引的选择性是:1.

注:索引的基数(Cardinality)不重复的索引值。此处计算的基数(Cardinality),与SHOW INDEX 语句中的Cardinality并不一致!explain中只是预估值。

一般情况,将选择性高的列放在左边,选择性高代表这个列的过滤性较好,尽可能的尽快过滤掉无用的数据。

前缀索引

对于 较大的Varchar类型、Text类型、Blob类型,需要建立索引时必须使用前缀索引,因为mysql不允许索引完整大小,而且索引字段越大效率越差。

可以索引开始的部分字符串(取代全部),大大节约索引空间,提高索引效率。但这样会降低索引的选择性。

所以,对比较长的 (Varchar、Text、BLOB等等数据类型)列查询,要保证索引的选择性,又要不能太长以节省空间。所以“前缀”需要选的恰到好处:

“前缀索引”的基数应该接近完整的列索引的基数。

示例:前7个字符的前缀索引

mysql> select count(Distinct last_name)/count(*) from people ;
+------------------------------------+
| count(Distinct last_name)/count(*) |
+------------------------------------+
|                             0.7059 |
+------------------------------------+
1 row in set (0.07 sec)
------------------------------------------------------------
mysql> select count(Distinct left(last_name,5))/count(*), count(Distinct left(la
st_name,6))/count(*) ,count(Distinct left(last_name,7))/count(*)  from people \G

*************************** 1. row ***************************
count(Distinct left(last_name,5))/count(*): 0.6471
count(Distinct left(last_name,6))/count(*): 0.7059
count(Distinct left(last_name,7))/count(*): 0.7059
1 row in set (0.00 sec)
mysql> alter table people add key (last_name(6))

所以使用前6个字符即可达到完整字段的过滤性。

注意:
前缀索引是能够使索引更小,更快的方法,但是无法使用前缀索引做 Group By\Order By,也不能用前缀索引做覆盖查询(Using Index)。

除了使用前缀索引的方式处理这类大字段索引的情况,还有如下方式:

伪哈希索引

step1建表语句
-- step1建表语句

CREATE TABLE `people` (
  `id` BIGINT(20) NOT NULL AUTO_INCREMENT,
  `last_name` VARCHAR(50) COLLATE utf8_bin NOT NULL,
  `first_name` VARCHAR(50) COLLATE utf8_bin NOT NULL,
  `dob` DATE NOT NULL,
  `gender` ENUM('m','f') COLLATE utf8_bin NOT NULL,
  `blog_url` VARCHAR(128) COLLATE utf8_bin DEFAULT NULL,
  `crc32_url` BIGINT(20) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `last_name` (`last_name`,`first_name`,`dob`),
  KEY `crc32_url` (`crc32_url`)
) ENGINE=INNODB

因为blog_url是一个较长的字符串,所以直接将blog_url作为索引列会影响索引的整体效率。现在,尝试用一个伪hash值做一个伪hash索引。

step2 建立触发器,用于每次插入\更新维护hash值
-- step2 建立触发器,用于维护hash值

DELIMITER //
    CREATE TRIGGER pseudohash_crc32_ins BEFORE INSERT
    ON people FOR EACH ROW
    BEGIN
      SET New.crc32_url = CRC32(New.blog_url);
    END //
    CREATE TRIGGER pseudohash_crc32_upd BEFORE UPDATE
    ON people FOR EACH ROW
    BEGIN
      SET New.crc32_url = CRC32(New.blog_url);
    END //
DELIMITER;

效果如下,非常明显:

SELECT COUNT(1) FROM people ;
/**
 636480
*/


SELECT blog_url FROM people WHERE blog_url="http://www.-588141732.com" AND crc32_url=1790086969
/**

sql执行时间:
执行耗时   : 0.004 sec
传送时间   : 0 sec
总耗时      : 0.004 sec

explain:
"id"    "select_type"   "table" "partitions"    "type"  "possible_keys" "key"   "key_len"   "ref"   "rows"  "filtered"  "Extra"
"1" "SIMPLE"    "people"    \N  "ref"   "crc32_url" "crc32_url" "9" "const" "1" "10.00" "Using where"
*/

EXPLAIN SELECT blog_url FROM people WHERE blog_url="http://www.-588141732.com" 

/**
sql执行时间:
执行耗时   : 0.413 sec
传送时间   : 0 sec
总耗时      : 0.413 sec

explain:
"id"    "select_type"   "table" "partitions"    "type"  "possible_keys" "key"   "key_len" "ref" "rows"   "filtered" "Extra"
"1" "SIMPLE"    "people"    \N  "ALL"       \N  \N  \N     \N   "630928"  "10.00"   "Using where"
*/

crc函数当数据量达到93000时,会产生1%的冲突。
如果要避免hash冲突的概率可以使用MD5()截取的方式取代crc()。
如:

SELECT CONV(RIGHT(MD5("http://www.-aqq3feaeaff41732fff.com"),16),16,10) AS hashCode;

三星索引(关系型数据库索引设计及优化)

  • 一星 使用where后的谓词列,按照选择性构造索引。
  • 二星 如果语句中有排序操作,使用索引自带的顺序的排序(消除fileSort)。
  • 三星 如果可以的话,将select后的还不在索引中的列名放到索引后边,可以覆盖索引(using index),而不需要读取表数据。
    如下这个sql就是一个三星索引。
SELECT last_name,first_name,dob FROM people WHERE last_name=”101899” AND first_name=”10189900” AND dob=”2017-08-03” ORDER BY dob

在实际应用中,无法保证三个星每个星都满足。需要权衡取舍。
冗余与重复索引

重复索引:相同列上按照相同顺序创建的相同类型的索引。
冗余索引:已有索引(A,B),现在 创建索引 (A)就是一个冗余索引,因为,索引(A)完全可以被 (A,B)替代。然而,(B,A)、(B) 并不是 (A,B)的冗余索引。
另外当Id列是主键,(A,Id)是冗余索引,因为二级缓存的叶子节点包含了主键值。直接使用(A)作为索引即可。
未使用的索引 也是累赘。建议删除。

Explain output \Profile

关于explain的详细解释,请参考:
explain 详解
或者查看官网文档
explain-output

除了explain,还可以查看sql耗时分布情况。

Show Profiles \Show profile queryId


SHOW PROFILES;
/**
"Query_ID"  "Duration"  "Query"
...
"19"    "0.00007350"    "select ...."
"20"    "0.00026150"    "select state, round(sum(duration),5) ....."
...
*/
SHOW PROFILE FOR QUERY 24
/**
"Status"                "Duration"
"Creating sort index"   "0.748448"
"freeing items"         "0.001355"
"starting"              "0.000029"
"cleaning up"           "0.000019"
"init"                  "0.000015"
"statistics"            "0.000012"
"end"                   "0.000008"
"preparing"             "0.000007"
"Opening tables"        "0.000007"
"closing tables"        "0.000006"
"Sending data"          "0.000005"
"query end"             "0.000005"
"optimizing"            "0.000004"
"System lock"           "0.000004"
"Sorting result"        "0.000003"
"checking permissions"  "0.000001"
"checking permissions"  "0.000001"
"executing"             "0.000001"
*/

高性能SQL

理解sql执行过程

Step1:客户端向Mysql服务器发送SQL语句。

使用”半双工”通信方式,客户端或服务端在一个连接上同一时刻只允许一方进行数据传输,并且直到数据传输完成,另一方才能执行传输。

当语句太长,超过 max_allowed_packet ,服务端会拒绝接收。
通常建议加上limit,可以减少不必要的数据从服务端发送到客户端。

Step2:服务器收到后先查询”查询缓存“,如果命中,从缓存中直接返回sql执行的结果集。否则,进入Step3。

这个缓存通过一个对大小写敏感的hash算法实现,及时只有一个字节不匹配,那也无法命中。

Step3:服务器解析、预处理、优化sql执行计划,然后将处理好的sql放入查询的执行计划中。
在这个阶段,sql会被转换为一个执行计划,使用这个执行计划于具体的存储引擎进行交换。这个阶段包括,解析、预处理、优化sql执行计划这三个子任务。

Step4:执行引擎通过调用”存储引擎”(如,innodb、myisam等)提供的API去执行这个计划。

Step5:服务器返回结果给客户端
这里写图片描述

慢SQL优化步骤

Step1:explain查看 (show profile可以查看耗时分布)

Step2:确认优化目标\方向,对于复杂sql需要理清执行步骤

目标1. type是否能够按照 const>eq_reg>ref>range>index>ALL的顺序优化,最差也要达到range级别。
目标2. 避免filesort的出现、避免rows数据量太大等负面字段、索引选择性是否足够、对于关联查询尽量保证关联字段在第二张表上有可用索引(原因:NestLoop)。

Step3:遵照SQL索引原则增加或调整SQL,常见如下(可参考上文去理解)

保证where后的谓词尽可能出现在索引中,并且组合索引按选择性顺序排序,范围查询条件尽量放在后边
(如果sql中有排序语句)是否能够通过索引解决排序问题
是否能使用use index,全部通过索引获取数据
NestLoop

除了full Join,其他所有类型的查询SQL,都以类似的方式执行。

NestLoop (内嵌套循环)算法,简单来说就是逐行查询处理,或者内嵌逐行查询。对于高版本的使用join buffer对上层表数据缓存,无需多次遍历上层表,下层表直接使用(Block NestLoop)。

以下以两个示例详细说明执行计划,其他join以及单表查询原理也是类似的!

Join执行顺序伪代码演示

示例1:内关联inner join

SELECT 
    people.id,user.id
FROM
    user
        INNER JOIN
    people ON user.name = people.name
WHERE
    user.enumType = 'orange'
        AND people.enumType = 'orange';

示例1对应伪代码:

//先扫描先执行的表,优化器通常选择关联的较小的表,explain第一行。
people_iterator=people_table.iterator();
//逐行遍历,并且丢弃where筛选不通过的行

while(people_iterator.hashNext()){

    people_item=people_iterator.next();

    if(people_item.enumType=='orange'){
        //筛选通过后,在进入第二个嵌套
        user_iterator=user_table.iterator()
        //逐行遍历第二个表
        while(user_iterator.hashNext()){

            user_item=user_iterator.next();
            //过滤:on 的条件匹配以及当前表的where条件
            if(user_item.name==people_item.name&&user_item.enumType=='orange' ){

                output(people.id,user.id);
                                            }
                                        }
                                    }
                                }

示例2:非内关联

SELECT 
    people.id,user.id
FROM
    user
        LEFT JOIN
    people ON user.name = people.name
WHERE
    user.enumType = 'orange'
        AND people.enumType = 'orange';

示例2伪代码

//先扫描先执行的表,优化器通常选择关联的较小的表,explain第一行。
people_iterator=people_table.iterator();
//逐行遍历,并且丢弃where筛选不通过的行
while(people_iterator.hashNext()){

people_item=people_iterator.next();

if(people_item.enumType=='orange'){
    //筛选通过后,在进入第二个嵌套
    user_iterator=user_table.iterator()
    //逐行遍历第二个表
    while(user_iterator.hashNext()){

        user_item=user_iterator.next();
        //过滤:on 的条件匹配以及当前表的where条件
        if(user_item.name==people_item.name&&user_item.enumType=='orange' ){

            output(people.id,user.id);
                                        }
        //与innerjoin不同的,leftJoin需要即使on条件不成立,也要保留左边数据
        else if(!is_innerJoin){
            output(people.id,null);//保留左边数据
        }

                                    }
                                }
                            }   

注意:尽量保证关联字段在第二张表上有可用索引。
(因为第一张表示全表扫描,然后会对第二张表用关联字段查询,详情请看NestLoop理解关联过程)

SQL使用常用策略

1.通常情况下,使用一个性能好的sql去做更多的事情,而不是使用多个sql。

除非这个sql过长效率低下或者对于delete这种语句,过长的delete会导致太多的数据被锁定,耗尽资源,阻塞其他sql。

2.分解关联查询。
将关联(** join……)放在应用中处理,执行小而简单的sql,好处是:

分解后的sql通常由于简单固定,能更好的使用mysql缓存。
执行拆分后的sql,可以减少锁的竞争。
程序具备更强的扩展性
关联sql使用的是内嵌循环算法nestloop,而应用中可以使用hashmap等结构处理数据,效率更高
关于Count()
count()函数有两种含义:统计行数、统计列数。
比如:count(*)代表统计的行数;count(talbe.cloumn)代表统计的是这个列不为null的数量。
关于Limit
在使用Limit 1000,20这种操作的时候,mysql会扫描偏移量(1000条无效查询)数据,而只取后20条,尽量避免这种写法,想办法规避。
关于Union
需要将where、order by、limit 这些限制放入到每个子查询,才能重分提升效率。另外如非必须,尽量使用Union all,因为union会给每个子查询的临时表加入distinct,对每个临时表做唯一性检查,效率较差。

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

推荐阅读更多精彩内容