深入理解InnoDB -- 存储篇

本文分享InnoDB如何规划表空间,如何存储表空间元信息以及用户数据。

思考一个问题,如果给你一个文件,让你存储MySql的数据,你会怎么做?

下面是一种比较合理的思路。首先把文件划分成大小相等的块(InnoDB中的页),每次取一块使用。为了管理这些块信息,我们也拿出一块空间,存储每一块空间的位置,偏移量,以及已经使用和剩余未使用的块(InnoDB中的FSP HEADER PAGE
,文件管理页)
然后根据不同的逻辑建立对应的对象,如索引对象,回滚信息对象(InnoDB中的段),这些对象从上面分好的块中申请空间使用,并管理属于自己的块,当然,这些对象信息也需要拿出一块空间存储起来(InnoDB中的INODE PAGE)。
这就是InnoDB中段和页的概念

下面来明确几个核心概念

  • 表空间
    InnoDB将所有数据(包括表数据,索引,回滚信息,插入缓冲索引页,系统事务信息,二次写缓冲)逻辑地放在一个空间中,称为共享表空间。
    默认表空间的存储文件为data目录下的ibdata1,初始化为10M。


  • 一个索引(InnoDB都是B+索引)由两个段管理,叶子节点段(leaf segment)和非叶子节点段(non leaf segment)
    回滚数据也是通过段管理。


  • InnoDB申请空间的最小单位,由连续页组成的空间,大小为1MB,保持不变。
    InnoDB一次从磁盘中申请4~5个区。

  • InnoDB访问的最小单位,默认16KB。一个区中一共有64个连续的页。
    缓冲池是以页为管理单位,每次读取或刷新一页数据。
    参数: innodb_page_size,可以将页大小设置为4K,8K.

InnoDB将表空间按Page切分,这些Page主要分为两类:存储表空间元信息的管理页(如FIL_PAGE_TYPE_FSP_HDR)和存储表空间用户数据的索引页(如FIL_PAGE_INDEX,FIL_PAGE_INODE)。

FIL Header

所有页都有两个统一的结构,FIL Header,占据页面的前38个字节,FIL Trailer,占据页面末尾8字节。

FIL Header结构如下

变量 字节 描述
FIL_PAGE_SPACE 4 所在表空间ID(space id)
FIL_PAGE_OFFSET 4 该页在表空间的偏移量(page no)
FIL_PAGE_PREV 4 前驱节点的偏移量(仅对索引页有效)
FIL_PAGE_NEXT 4 后继节点的偏移量(仅对索引页有效)
FIL_PAGE_LSN 8 页最后刷新到磁盘的LSN
FIL_PAGE_TYPE 2 页的类型
FIL_PAGE_FILE_FLUSH_LSN 8 仅在第一个Page(FSP HEADER PAGE)使用,用来判断数据库是否正常关闭
FIL_PAGE_SPACE_ID 8 仅在第一个Page使用,保存数据库关闭时归档重做日志的编号

InnoDB中每一个表空间都会有一个唯一的space id,共享表空间的space id就是0。
每个页都有一个32位序号page no,称为偏移量,即离表空间初始位置的偏移量。因为每个页大小为16kb,所以第0个页的偏移量为0,第一个页的偏移量为16384,以此类推。
通过space id和page no,InnoDB可以定位任何一个页。

FIL_PAGE_TYPE标志页的类型,InnoDB常用页类型如下
FIL_PAGE_TYPE_ALLOCATED:该页为最新分配
FIL_PAGE_IBUF_BITMAP:Insert Buffer位图页
FIL_PAGE_TYPE_SYS:系统页
FIL_PAGE_TYPE_TRX_SYS:事务系统数据页
FIL_PAGE_TYPE_FSP_HDR:FSP HEADER PAGE页
FIL_PAGE_TYPE_XDES:扩展描述页
FIL_PAGE_IBUF_FREE_LIST:Insert Buffer空闲列表页
FIL_PAGE_UNDO_LOG:Undo Log页
FIL_PAGE_INDEX:B+树叶子节点页
FIL_PAGE_INODE:B+树索引节点页
FIL_PAGE_TYPE_BLOB:BLOB页

FIL Trailer

FIL Trailer是在文件末尾的最后8个字节, 低位4个字节是用来表示Page页中数据的checksum,最后4字节和FIL Header中的FIL_PAGE_LSN相同
下面说到的页都有FIL Header,FIL Trailer,不再重复说明。

现在看一下关键的关键的管理页。

FSP HEADER PAGE

表空间第1页就是文件管理页FSP HEADER PAGE,存储表空间关键元数据信息。由FSP HEADER、XDES ENTRIES构成。

FSP HEADER

FSP HEADER主要存储表空间元信息,维护关键结构分配信息,主要变量如下:

变量 字节 描述
FSP_SIZE 4 表空间大小,以Page数量计算
FSP_FREE_LIMIT 4 当前已经使用的位置
FSP_FREE 16 空闲区链表
FSP_FREE_FRAG 16 部分可以用碎片区链表
FSP_FULL_FRAG 16 已经完全使用的碎片区链表
FSP_SEG_INODES_FULL 16 已经完全使用的INODE PAGE链表
FSP_SEG_INODES_FREE 16 部分可用的INODE PAGE链表

区具体可以分为区(extent)和碎片区(frag extent)
碎片区是比较特殊的区,用于分配碎片页。

XDES ENTRIES

接下来是区描述符XDES ENTRIES,每个区描述符需占用40个字节,用于追踪64个页的使用状态。
每个FSP HEADER PAGE只能管理256个区的信息(也就是16384个页),因此每隔16384个页,会有一个类似FSP HEADER PAGE的Page来描述随后的区信息

XDES ENTRIES主要变量如下

变量 字节 描述
XDES_FLST_NODE 12 维护链表前后节点信息
XDES_STATE 4 标识该区是属于FSP_FREE,FSP_FRAG_FREE或FSP_FRAG_FREE_FULL或XDES_SEG(某个段)
XDES_BITMAP 16 标识区中64个页的使用状态

XDES_BITMAP使用位图方式保存,每个页的使用状态占用2位(预留一位)。

一个区可以属于FSP_FREE,FSP_FRAG_FREE或FSP_FRAG_FREE_FULL或者某一个段。区的分配实现了一套类似于借还的机制。段向表空间租借区,只有段退还该空间时,该区才能重新出现在FSP_FREE/FSP_FULL_FRAG/FSP_FULL中。

INODE PAGE

表空间文件的第3个page的类型为FIL_PAGE_INODE,管理表空间的段。

INODE PAGE由SEGMENT INODE组成,每个SEGMENT INODE为192字节,对应一个段。

SEGMENT INODE结构主要变量如下:

变量 字节 描述
FSEG_FREE 16 未使用的extend链表
FSEG_FULL 16 已完全使用的extend链表
FSEG_NOT_FULL 16 部分可用的extend链表
FSEG_FRAG_ARR[0] 4 碎片页数组首页地址
...
FSEG_FRAG_ARR[31] 4 碎片页数组尾页地址

为节省空间,每个segment都先从FSP HEADER的FSP_FREE_FRAG中分配32个碎片页(FSEG_FRAG_ARR),当这些32个页面不够使用时,再申请区。

每个INODE PAGE默认可存储85个SEGMENT INODE。每个索引使用2个segment,分别用于管理叶子节点和非叶子节点。
所以一个INODE PAGE最多可以保存42个索引信息(一个索引使用两个段)。如果表空间有超过42个索引,则必须再分配一个INODE PAGE。INODE PAGE的分配是从碎片区中申请,但它的位置不是固定的。为了找到索引的INODE ENTRY,InnoDB定义了SEGMENT HEADER,结构如下

变量 字节 描述
FSEG_HDR_SPACE 4 INODE PAGE所在表空间ID
FSEG_HDR_PAGE_NO 4 INODE PAGE所在表空间的偏移量
FSEG_HDR_OFFSET 2 INODE ENTRY在页的偏移量

对于用户表,其索引的Root Page中保存了两个SEGMENT HEADER,分别指向叶子节点的SEGMENT INODE和非叶子节点的SEGMENT INODE。

链表结构

InnoDB的链表都是双向链表,如FSP HEADER中变量FSP_FREE,FSP_FREE_FRAG,FSP_FULL_FRAG,FSP_SEG_INODES_FULL,他们都是链表头结构FLST_BASE_NODE,维护了链表的头指针和末尾指针,

变量 字节 描述
FLST_LEN 4 链表长度
FLST_FIRST 6 链表首节点地址
FLST_LAST 6 链表尾节点地址

它们指向的节点为XDES ENTRIES的XDES_FLST_NODE,每个节点的结构体称为FLST_NODE

变量 字节 描述
FLST_PREV 6 链表前驱节点地址
FLST_NEXT 6 链表后继节点地址

下面是一个表空间的示意图,请理解该图


第2个Page是FIL_PAGE_IBUF_BITMAP,主要用于跟踪随后的每个PAGE的change buffer信息,使用4个bit来描述每个page的change buffer信息。
由于FIL_PAGE_IBUF_BITMAP的空间有限,同样每隔256个Extent Page之后,也会在XDES PAGE之后创建一个FIL_PAGE_IBUF_BITMAP。

其他的表空间元信息Page,如
FSP_TRX_SYS_PAGE_NO,共享表空间第6个Page,记录了InnoDB重要的事务系统信息。
FSP_DICT_HDR_PAGE_NO,共享表空间第8个Page,存储了SYS_TABLES,SYS_TABLE_IDS,SYS_COLUMNS,SYS_INDEXES和SYS_FIELDS等数据词典表的Root Page(b+树Root节点所在Page)。
有兴趣的同学可以自行了解

索引组织表

上面说了InnoDB通过索引页来存放行记录,那么这些行记录是怎么组织的呢
(这里说的索引页,包括了B+树叶子节点页FIL_PAGE_INDEX和B+树索引节点页FIL_PAGE_INODE)

聚集索引

InnoDB中,表都是根据聚集索引顺序组织存放的,这种存储方式的表称为索引组织表。
而InnoDB中主键索引使用的是B+索引(通过B+树组织的索引)

当我们需要打开一张表时,需要从表空间的数据词典表中加载元数据信息,其中SYS_INDEXES系统表中记录了用户表中所有索引Root Page对应的page no,进而找到B+树Root Page,就可以对整个用户数据B+树进行操作。

B+是为磁盘和其他直接存取辅助设备设计的一种多路平衡查找树。
看一个例子


B+树有以下特点
1、B+树不仅是多叉树,而且每个非叶子节点只存储键值,不存储数据,这样每个非叶子节点所能保存的键值大大增加,可以降低B+的树深度。
该特性应用到索引上,可以使每次加载的节点包括更多的索引数据,也可以减少IO操作(每次读取树的下一层都需要一次IO)。
所以B+索引具有高扇出性,在数据库中,B+树的高度一般都在2~4层,查找某一个键值的行记录最多只需要2到4次IO。

  1. B+树中,所有数据按键值的大小顺序存放在同一层的叶子节点上,由各叶子节点指针进行连接。
    每次查找数据都需要查找到叶子节点,查找次数都相同,所以查询速度很稳定。

  2. B+树所有的叶子节点数据构成了一个有序链表,在查询大小区间的数据时候非常方便。
    如上面例子中的B+树,如果要查询[22,89]范围数据,再需要找到键值22,再遍历到数据键值89就可以了。
    而遍历所有数据,只需要遍历所有的叶子节点即可,而不需要遍历每一层数据,这有利于数据库做全表扫描。

注意:B+树所有的叶子节点数据构成了一个有序链表,这个是逻辑上的有序,而非物理存储是顺序(维护成本过高)。
InnoDB中,Page的FIL Header维护了上下Page的偏移量,组成双向链表,而Page中行记录的记录头中维护了下一行记录的位置,组成单向链表。

B+树的查找
类似于二叉查找树。起始于根节点,自顶向下遍历树,根据目标值与键值比较结果向下查找对应子树。
但B+的数据都存储在叶子节点,所以就算某个非叶子节点的键值与所查的关键字相等时,并不停止查找,而是继续沿着这个节点左边的指针向下,一直查到该关键字所在的叶子节点为止。

B+树的平衡
对于插入和删除操作,B+通过分裂和合并节点维持平衡(类型红黑树的旋转),
InnoDb中B+树的键值和数据都存放在Page中,因此Page也需要合并和分裂,有兴趣的同学可以自行了解。

辅助索引

InnoDB中聚集索引和辅助索引都是B+索引。但辅助索引叶子节点的数据不是存储实际的数据,而是主键的值。要想拿到实际的数据需要再通过主键索引找到对应的行记录然后才能拿到实际的数据,这个过程称为回表。
如果查询语句可以从辅助索引(包括联合索引)中获取到所有需要的列,这时不需要再通过主键索引找到对应的行记录,这种情况称为覆盖索引。

联合索引

联合索引也是B+索引。
联合索引中列的顺序很重要。
InnoDB首先根据联合索引中最左边的、也就是第一列进行排序,在第一列排序的基础上,再对联合索引中后面的第二列进行排序,依此类推。
所以如果想使用联合索引的第n列,查询条件中必须包括联合索引前面的第1列到第n-1列的查询信息。

(group, score),可能出现以下排序(1, 46), (1,58), (2,23), (2,96), (3,25), (3,67)
如果要使用该索引的score,查询条件中必须包含group,如where group = 2 and score = 96,InnoDB通过group查询后,再通过score查询。
这个规则称为最左前缀匹配原则。

页结构

下面看一下InnoDB索引页如何保持用户数据,索引页由以下部分组成

变量 字节 描述
Page Header 56 页头,记录页的一些状态信息
Infimun/Supremum Records 26 系统记录
User Records 不确定 用户记录,即行记录
Free Space 不确定 空闲空间
Page Directory 不确定 页目录

Page Header

变量 字节 描述
PAGE_N_DIR_SLOTS 2 page directory中槽的数量
PAGE_HEAP_TOP 2 堆中空闲空间的偏移量
PAGE_N_HEAP 2 记录数据数量,包含用户记录,系统记录以及标记删除的记录
PAGE_FREE 2 删除记录的链表
PAGE_GARBAGE 2 已标记删除记录数量
PAGE_N_RECS 2 用户记录数量,不包含系统记录以及标记删除的记录
PAGE_MAX_TRX_ID 8 最近一次修改该Page记录的事务ID
PAGE_LEVEL 2 当前页在索引树的位置
PAGE_INDEX_ID 8 索引id,表示当前页属于那个索引
PAGE_BTR_SEG_LEAF 10 B+树叶子节点所在段的segment header,仅在B+树的Root Page中定义
PAGE_BTR_SEG_TOP 10 B+树非叶子节点所在段的segment header,仅在B+树的Root Page中定义

PAGE_LAST_INSERT,PAGE_DIRECTION,PAGE_N_DIRECTION等变量并未在表中列出,他们用于进行页的分裂操作。

当记录被删除(不仅是将记录的deleted_flag设置为1,而是彻底删除),会放到PAGE_FREE链表中(链表通过记录头信息next_record串联),如果这个页上有记录要插入,会先检查PAGE_FREE链表空间是否满足,如果空间满足,直接从PAGE_FREE链表空间分配,如果空间不够,再从空闲地址(PAGE_HEAP_TOP)分配。
注意:检查PAGE_FREE链表空间时,仅检查第一个节点的可用空间,不会通过next_record进行遍历。

页记录是根据主键顺序排序的,这个排序是逻辑上的,而非物理上的(开销过大)。当页空间不足时,会调用函数btr_page_reorganize_low进行页的重新组织,即根据页中记录主键的顺序重新进行整理,这样就能整理出碎片的空间。若还是空间不足,则进行分裂操作。

Infimun和Supremum Records
系统虚拟的记录,Infimun表示比任何主键值都小的值,Supremum表示比任何可能的值都大的值。

User Records
行记录以链表的形式存放在 User Records 中,行记录格式中的记录头中的 next_record 存放着下一条记录的地址

Free Space
随着记录越来越多,Free Space空间越来越小,User Records空间越来越大。当Free Space的全部空间都被分配完了,这个页也就使用完了,需要申请新的页。

Page Directory
B+索引本身不能定位具体的一条记录,只能找到该记录所在的页。
InnoDB将页载入到内存后,可以遍历页所有的记录找到目标记录,但这样做太慢了。
(Page的记录非物理顺序存储,无法通过物理地址二分查询)

InnoDB将页中数据进行分组,将每个组最后一条数据的偏移量按顺序存储起来,组成目录。
每个偏移量也被称为一个槽(Slot,两个字节)。这些偏移量都会被存储到靠近页的尾部的地方,被称为Page Directory。
这样InnoDB可以通过Page Directory进行二叉查找定位目标所在分组,再遍历该组数据就可以。

每个槽可以包括4~8条记录,每个记录的记录头中n_owned变量,维护该记录所在槽的记录数量。
(例外,第1个槽仅包含一个1记录,即Infimun,最后一个槽可包含1~8个记录)

行格式

上面说了InnoDB索引页如何保存用户数据,即表的行记录,下面看看每一行的存储格式

InnodB中行记录存储方法有Compress,Redundant,Compressed,Dynamic。
Redundant行格式是MySql5.0之前使用的,现在基本不会再使用,这里就不介绍了。

compact格式下,一行记录依次为以下内容:
变长字段长度列表,NULL标志位,记录头信息,rowID,TransactionID,RollPointer,列1数据,列2数据,...
其中rowID,TransactionID,RollPointer由InnoDB生成,
注意,如果表中已经指定主键,则不生成rowID。
(TransactionID,RollPointer用于实现MVCC功能,将在事务篇解析)

变长字段长度列表
若列的长度小于255字节,用1字节表示
若列的长度大于255字节,用2字节表示(varchar最大限制为65535)

NULL标志位
占用字节为(可为NULL的列数量/8)向上取整,字节中哪一位为1,表示该行数据对应列为NULL值

注意,变长字段长度列表和NULL标志位都是是按列定义的倒叙保存的,他们都是可选的,如果表中没有变长字段和允许NULL值的字段,那么这两个都是占用0字节长度

char(N)中的N指定的是字符,UTF-8下CHAR(10)类型的列,最小可以存储10字节的字符,最大可以存储30字节的字符。
所以对于多字节的字符,char类型在InnoDB存储引擎内部被视为变长字符类型。也意味着这些CHAR数据类型的长度会记录在变长长度列表中。

记录头信息
固定5字节,结构如下

变量 字节 描述
() 2 预留位
deleted_flag 1 该行是否已被删除
min_rec_flag 1 如果该行记录是预定义为最小的记录,为1
n_owned 4 该记录所在Slot拥有的记录数
heap_no 13 索引堆中该条记录的索引号
record_type 3 记录类型,000(普通),001(B+Tree节点指针),010(Infimum),011(Supremum)
next_record 16 页中下一条记录的相对位置

看一例子

create table mytest (
    t1 varchar(10),
    t2 varchar(10),
    t3 char(10),
    t4 varchar(10)
) engine=INNODB charset=LATIN1 ROW_format=compact;
insert into mytest3 values('d','11',NULL,'fff');

使用vim打开ibd文件,在“命令”模式中输入“:%!xxd”命令,将文本转换为16进制,找到supremum字符串,后面的就是列数据了。
(ibd中可能有多个页,可以依照行的实际数据判断哪个是自己要找的数据。)

00010070: 7375 7072 656d 756d 0302 0104 0000 10ff  supremum........
00010080: ef00 0000 0002 0100 0000 0010 2281 0000  ............"...
00010090: 010a 0110 6431 3166 6666 0000 0000 0000  ....d11fff......

解析如下

03 02 01   // 变长字段长度列表, d列-03,c列-null,不记录  b列-02  a列-01
04  // null标准位, 二进制-00000100,逆序,d,c-null,b,a
00 00 10 ff ef  // Record Header,固定5字节 
00 0000 0002 01  // RowID ,InnoDB自动创建,6字节 
00 0000 0010 22  // TransactionID
81 0000 010a 0110  // Roll Pointer
64  // 列1数据  'a'
31 31  // 列2数据 '11'
66 6666  // 列4数据  'fff'

行溢出
对于占用字节数非常大的列,在记录的真实数据中只会存储一小部分数据(768个字节),剩余的数据分散在其他溢出页中(BLOG类型的页),记录的真实数据中记录这些页的地址,以便找到他们。
注意:行溢出与列定义的类型无关。如果varchar过长会发生行溢出,而text,blog不够长则不会发生行溢出。

InnoDB 1.0.x引入新的行格式,以前支持的Compress 和Redundant称为Antelope文件格式,新的文件格式为Barracuda文件格式,有两个行记录格式:Compressed和Dynamic。他们是Compact的变种形式。他们基本没什么本质上的区别,唯一的区别就是对于行溢出的处理不同。Compressed在数据页只存储一个指向溢出页的地址,所有的实际数据都存放在溢出页中。
而Compressed还可以是zlib算法对行数据进行压缩,因此对于BLOB,TEXT,VARCHAR这类大长度类型的数据能够非常有效的存储。

参考文档:
《MySQL技术内幕InnoDB存储引擎》
《MYSQL内核:INNODB存储引擎》
Innodb表空间
深度 | 解析InnoDB引擎
Chapter 22 InnoDB Storage ngine
InnoDB 文件系统之文件物理结构

如果您觉得本文不错,欢迎关注我的微信公众号,您的关注是我坚持的动力!


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