15、以太坊中的状态树

以太坊采用基于账户的模式,系统中显式地维护每个账户上有多少余额,今天看一下用什么样的数据结构来实现account-based ledger

要完成的功能:

从 账户地址 到 账户状态 的映射

addr -> state

addr:账户地址,以太坊中用的账户地址是160位,也就是20个字节,一般表示成40个十六进制的数(0~f)。

state:外部账户和合约账户的状态,包括余额、交易次数、合约账户还包括代码、存储。

那么要设计什么样的数据结构来实现这个映射呢?


数据结构假设

以下是几个思考的方案:

假设方案一:用哈希表实现

直观上看,像一个很典型的key-value pair,给出一个账户地址,要找到相应的账户状态。

所以一个直观的想法:用哈希表实现。

系统中的全节点维护一个哈希表,每次有一个新的账户,插入到哈希表里面,查询账户的余额,就直接在哈希表中查询。如果不考虑哈希碰撞的话,基本上查询的效率是常数时间内完成的,更新也是很容易在哈希表中更新的。

>>> 问题:如果用这个哈希表要提供merkle proof怎么提供?

比如说你要跟一个人签合同,希望他能证明一下他有多少钱,怎么提供证明呢?

一种方法是 把哈希表中的元素组织成一个Merkle tree,然后算出一个根哈希值,这个根哈希值存在block header里,只要根哈希值是正确的,就能保证底下的树不会被篡改。

如果有新区块发布怎么办?

新区块中包含新的交易,执行这个交易必然会使哈希表的内容发生变化,发布下一个区块的时候,再重新把哈希表中的内容组织成一个Merkle tree吗?

这个代价太大了。实际上,真正发生变化的账户状态只是一小部分,因为只有那个区块里所关联的账户才会发生变化,大多数账户的状态是不变的。所以每次你都重新构造一次Merkle tree,这个代价是很大的。

比特币系统当中难道不是每出现一个区块也要重新构造一个Merkle tree吗?那个为什么没有问题?

那个Merkle tree是把区块里包含的交易组织成一个Merkle tree,那区块中的交易每次发布一个新的区块又有一系列新的交易,所以比特币中的Merkle tree是immutable的,每次发布一个新的区块对应一个Merkle tree,然后这棵Merkle tree构建完之后是不会再改的,下次再发布一个新的区块再构建一个新的Merkle tree。

那区块里有多少个交易呢?最多差不多4000个(按照1M字节,每个交易大概是250M字节左右),这个其实是一个上限,很多区块的交易数目根本到不了4000个,有好多区块就只有几百个,甚至有可能还有更少的,所以每次发布一个区块,比特币里构建一个Merkle tree,是要把这几百个到几千个交易构成一个Merkle tree。

这里如果采用这种方法会是什么情况?

是要把所有的以太坊账户一起构成一个Merkle tree,这个就比刚才讲的几百,几千个交易要高出好几个数量级,相当于每次发布一个区块要把所有的账户遍历一遍构建出一个Merkle tree,下次再有一个区块,再把所有的账户遍历一遍,再构建出一个Merkle tree。

除了提供Merkle proof证明账户有多少钱之外,这个Merkle tree还有另外一个很重要的作用,就是维护各个全节点之间状态的一致性,如果没有根哈希值不去发不出来,每个节点就是在本地维护一个数据结构,那怎么知道你的数据结构的状态跟别人的数据结构的状态是不是一致呢,要各个全节点保持状态的一致才行。这也是为什么比特币中把根哈希值写在块头里的原因,就是对于当前区块中包含哪些交易,所有的全节点要有一个共识。

>>> 结论:不可行,因为每次构建Merkle tree的代价太大

如果每个全节点在本地维护一个哈希表,然后需要构建Merkle tree的时候构建出Merkle tree来,然后根哈希值发到区块头里,这个方法是不行的。哈希表本身的效率是挺好的,插入,更改效率都很好,但是每次构建Merkle tree的代价太大了


假设方案二:直接用一个Merkle tree把所有的账户都放进去

不要哈希表了,直接用一个Merkle tree把所有的账户都放进去,要改的时候直接在Merkle tree里改。因为每个区块更新的只是一小部分账户,所以改的时候只是Merkle tree里的一小部分。

>>> 问题1:Merkle tree没有提供一个高效的查找、更新的方法

比特币的Merkle tree是怎么构建的?

就最底下一层是tx,然后哈希值放到上面节点里,两两结合,然后再取一个哈希往上整。

他没有提供一个快速查找,更新的方法。

>>> 问题2:直接把账户放到Merkle tree里,这个Merkle tree要不要排序?

Sorted Merkle tree

如果不排序会怎么样?

1、查找速度会慢。

2、还有一个问题,这些账户组成了这棵merkle tree,叶节点是这些账户的信息,如果不规定这些账户在叶节点出现的顺序,那么这样构建出来的Merkle tree不是唯一的。

比如,系统中有很多全节点,每个全节点按照自己的某个顺序,比如说他听到某个交易的顺序构建一个Merkle tree,那么叶结点的顺序是乱的,每个节点都是自己决定的,最后构建出的Merkle tree是不一样的,算出的根哈希值也是不一样的

比特币中的节点也是不排序的,那为什么比特币就没有问题呢?

因为比特币中的每个全节点收到的交易的顺序也是不一样的,理论上说构建的Merkle tree的根哈希值也是不一样的。

但是比特币中,每个节点在本地组装一个候选区块,这个节点自己决定哪些交易、以什么顺序顺序打包进这个区块里,然后通过挖矿去竞争记账权。如果他没有抢到记账权,他的任何决定其他人没必要知道;只有他有记账权,且发布区块后最终成为被大家接受的区块,那么,这个顺序就是发布这个区块的节点确定的。

也就是说,比特币中虽然也没用排序的Merkle tree,但是他那个顺序是唯一的,是由发布区块的那个节点确定的。

那为什么以太坊不能这样做?

如果以太坊也这么做的话,需要把账户的状态发布到区块里。也可以说是 每个全节点自己决定 怎么把账户组织成一个Merkle tree,算出跟哈希值、挖出矿,但要怎么让别人知道这个顺序,你得把他发布到区块里。但发布的是所有账户的状态,不是交易,这两者差好几个数量级,比特币发布一个区块只需要几百个/几千个交易。

>>> 结论:不可行,不排序的Merkle tree是不行的。

交易是必须要发布的,不发布别人就没法知道,但账户状态可以维护在本地,而且大部分账户状态是不变的。一个区块里的交易只能改很少的账户,大多数账户是不变的,而且重复发布,每隔十几秒发布一个新的区块,把所有状态都打包发布一遍,下次再过十几秒再发布一遍,这个是不可行。


假设方案三:用Sorted Merkle tree

>>> 问题:新增一个账户怎么办

产生一个账户的地址是随机的,他的叶节点的位置很可能是插在中间的,那后面这些树的结构都得变。

新产生一个账户,对外发生了交互,我需要把他加入到我的数据结构里,这是没错的,但问题是,这个加入的代价有多大?

可能大半棵Merkle tree需要重构,这个代价太大了。

>>> 结论:不可行,用Sorted Merkle tree,插入、删除代价都太大

而且,区块链是不可篡改的,是说添东西容易,删东西难。

其实以太坊中没有显式地删除账户的操作,有的账户上就一点钱,就一两个Wei,你也不能把他删掉。


上面的方案都不行。


以太坊的数据结构

那么以太坊中采用的方法是用一个叫MPT(Merkle Patricia Tree)的结构。

讲这个之前先讲一个简单的数据结构。

【Trie】

来源于“retrieval”,检索。

trie:字典树,前缀树。

也是一种key-value的数据对,一般来说key用字符串用的比较多,比如说一些单词排成一个trie的数据结构。

举例:

general

genesis(区块链的第一个区块叫做genesis block,创世纪块)

go

god

good

下图就是一个trie的结构,这几个单词都是以G开头的,然后第二个字母就开始分裂了,左边是E,右边是O,左边这前两个单词都是N和E,然后下面再分开,R和S,然后是后三个字母,右边这个分支,O这个分支,Go就已经结束了,从这个可以看到单词可能在trie的中间节点结束,然后左边是D,右边是O,左边变成了God,右边下来是Good。

Trie树可以利用字符串公共前缀来节约存储空间。

如果系统中存在大量字符串且这些字符串基本没有公共前缀,则相应的trie树将非常消耗内存,这也是trie树的一个缺点。

Trie的特点

特点一:trie的每个节点的分叉数目,取决于 key值里 每个元素的 取值范围

这个例子当中,每个都是英文单词,而且是小写的,所以每个节点的分叉数目最多是26个,加上一个结束标志位(表示这个单词到这个地方就结束了)。

英文字母的字典树是一个26叉树,数字的字典树是一个10叉树。

在以太坊中地址是表示成40个十六进制的数,因此分叉数目(branching factor)是17(十六进制的0~f,再加上结束标志位)。


特点二:trie的查找速率,取决于key的长度

键值越长,查找需要访问内存的次数就越多。

在这个例子当中,不同的单词键值长度是不同的。

在以太坊中,所有键值都是40,因为地址都是40位十六进制的数。

比特币和以太坊的地址是不通用的,两个地址的格式长度都是不一样的。

但有一点是类似的,以太坊中的地址也是公钥经过转换得来的。其实就是公钥取哈希,然后前面的不要,只要后面这部分,就得到一个160bit的地址。


特点三:只要两个地址不一样,最后肯定映射到树中的不同分支,所以trie是不会出现碰撞的


特点四:不同的节点,不论按照什么顺序插入这些账户,最后构造出来的树是一样的

前面讲Merkle tree,如果不排序的话,一个问题是账户插入到Merkle tree 的顺序不一样,得到的树的结构也不一样。

那trie呢?

比如上图中的这五个单词,换一个顺序插到这个树里面,得到的是一个不同的树吗?其实是一样的,只要给定的输入不变,无论输入怎么打乱重排,最后插入到trie当中,得到的树是一样的。


特点五:更新操作的局部性很好

每次发布一个区块,系统中绝大多数账户的状态是不变的,只有个别受到影响的账户才会变,所以更新操作的局部性很重要

trie局部性呢?

比如在上图中,我要更新genesis这个key对应的value(这个图当中只画出了key,没有画出value),只要访问genesis的那个分支,其他分支不用访问的,也不用遍历整棵树


Tire的缺点:存储浪费

像左边分支都只有一个子节点,对于这种一脉单传的情况,如果能把节点进行合并,那么可以减小存储的开销,同时也提高了查找的效率,不用一次一个一个的往下找了。


那么就引入了Patricia tree,也有人写成Patricia trie,是经过路径压缩的前缀树,有时候也叫压缩前缀树


【Patricia tree】/【Patricia trie】

这个如果进行路径压缩就变成下图的样子。

可以看到,G下面还是E和O进行分叉,E下面之后跟的都是NE,再往下就是E和S分叉,然后后面都和在一起了,右边都是一样的。

这样压缩之后有什么好处?

直观上看,这个高度明显缩短了,访问内存的次数会大大减少,效率提高了。

注意:对于Patricia tree来说,新插入一个单词,原来压缩的路径可能需要扩展开。

比如这个例子当中,加入geometry,左边的分支就不能那样压缩了。


路径压缩在什么情况下效果比较好?

键值分布比较稀疏的时候,路径压缩效果比较好。

比如说,这个例子当中是用英文单词,假设每个单词都很长,但是一共没有几个单词,

举个例子:

misunderstanding

decentralization(去中心化)

disintermediation(去中间商,非中介化)(intermediaries:中间商)

这三个单词插入到一个普通的trie里面就成了下图的样子

可以看到这样的结构效率是比较低的,基本上是一条线了。

如果用Patricia tree的话,参考下图

这个树的高度明显改善多了。

所以键值分布比较稀疏的时候,路径压缩效果比较好。

以太坊应用中键值是不是稀疏呢?

以太坊应用中键值是地址,地址是160位的,地址空间有2^160,这是一个非常非常大的数。

如果你设计一个计算机程序的算法,他需要进行运算的次数是2^160,那这个在我们所有人的有生之年都不可能算出来,全世界的以太坊的账户数目加在一起也远远没有这么大,跟这个数比,是微乎其微的。

为什么要弄这么稀疏,不把地址长度缩短一点,这样访问效率也快,也没必要那么稀疏了?

以太坊中普通账户跟比特币的创建方法是一样的,没有一个中央的节点,就每个用户独立创建账户。你在本地产生一个公私钥对,就是一个账户。

那怎么防止两个人的账户碰撞,产生的一样呢?

这种可能性是存在的,但是这个概率比地球爆炸还要小。

怎么达到这么小的概率,就是地址要足够长,分布足够稀疏,才不会产生碰撞

这个可能看上去有点浪费,但是这是去中心化的系统防止账户冲突的唯一办法

所以他是非常稀疏的,这就是为什么在数据结构中,要用Patricia tree


【MPT】(Merkle Patricia tree)

>>> Merkle tree和Binary tree的区别

即区块链与普通链表的区别:把普通指针换成了哈希指针。

>>> Merkle Patricia tree和Patricia tree的区别

也是 把普通指针换成了哈希指针。

所有的账户组织成一个Patricia tree,用路径压缩提高效率,然后把普通指针换成哈希指针,所以就可以计算出一个根哈希值。这个跟哈希值也是写在block header里面。

比特币的block header里只有一个根哈希值:交易树,就是区块里包含的交易组成的Merkle tree组成的根哈希值。

以太坊的block header里有三个根哈希值:交易树、状态树、收据树。

现在讲的是状态树账户状态最后组织成了一个Merkle tree,他的根哈希值。

这个根哈希值的作用:

1、防止篡改

只要根哈希值不变,整个树的任何部分都没有办法被篡改,也就是说每个账户的状态都能保证他是没有被篡改过的。

2、Merkle proof

(1)能证明账户的余额是多少

你这个账户所在的分支自己向上作为Merkle proof发给轻节点,轻节点可以验证你的账户上有多少钱。

(2)能证明某个账户是不存在的

之前讲过,Sorted Merkle tree的一个作用是能证明non-membership。

这里的证明方法跟Sorted Merkle tree类似。

比如,给一个地址转账之前,验证一下全节点里有没有这个账户信息。说的更直白一点,证明MPT中某个键值是不存在的

如果存在的话,是在什么样的分支,把这个分支作为Merkle proof发过去,可以证明他是不存在的。


Modified MPT

以太坊中用到的不是原生的MPT,是Modified MPT,就是对MPT做一些修改,这些修改不是很本质的修改。

举个例子(如下图),有四个账户(右上角),为了简单起见,地址都比较短,假设只有7位的地址,而不是40位,账户状态也只显示出了余额,其他账户状态没有显示出来。第一个账户有45个以太币,第二个账户只有1WEI(这个是以太坊中最小的计量单位,1WEI基本上可以忽略不计)

这个例子当中,节点分为三种。

1、Extension Node(扩展节点)

如果这个树中出现了路径压缩就会有一个Extension Node

这四个地址前两位都是一样的a7,所以他的Root(根节点)就是一个Extension Node

shared nibble(nibble:16进制数,一个nibble就是一个16进制数),这里共享的nibble是a7

2、Branch Node(分支节点)

这里第三位就分开了,有1,7,f,所以就跟了一个Branch Node

3、Leaf Node(叶节点)

先说1,这个1之后就是1355,只有这一个地址,就跟了Leaf Node

这个7有两个地址,连着路径压缩d3。

然后再往下3和9分开了,跟着一个Branch Node

下面两个Leaf Node,都是7。

最后一个f,就跟着一个Leaf Node,9365。

这就是状态树

另外,这个树的根节点取哈希之后得到的一个根哈希值,是要写在块头里的(左上角)。

他用的也是哈希指针。比如7这个位置,这里存的是下面这个节点(extension node)的哈希值。如果是普通指针的话,7这个位置存的是下面这个节点的地址。

每次发布一个新的区块的时候,这个状态树中,有一些节点的值会发生变化,这些改变不是在原地改,而是新建一些分支,原来的状态其实是保留下来的


下面这个例子当中,有两个相邻的区块。

Block N Header:State Root就是状态树的根哈希值,下面(灰色)显示的是这棵状态树。

Block N+1 Header:这个是新的区块的状态树

可以看到,虽然每一个区块都有一个状态树,但是这两颗树的大部分节点是共享的

右边这棵树主要都是指向左边这颗树的节点,只有那些发生改变的节点是需要新建一个分支

这个例子中,这个账户是一个合约账户,因为有Code,还有Storage。

合约账户的存储也是由MPT保存下来的。

这个存储其实也是一个Key Value Store,维护的是从这个变量到这个变量取值的一个映射,在以太坊当中,也是用的一棵MPT。

所以以太坊中的这个结构是一个大的MPT,包含很多小的MPT每一个合约账户的存储都是一棵小的MPT

上图中这个账户的新的区块里:

Nonce发生了变化。

Balance余额也发生了变化。

Code不变的,所以Codehash指向原来树中那个节点。

Storage变了(存储下面这个叫存储树),在存储树中,大部分节点也是没有改变。这个例子当中,只有一个节点变了,这个整数变量从29变成了45,所以新建了一个分支。

所以,系统中每个全节点需要维护的不是一棵MPT,而是每次出现一个区块,都要新建一个MPT,只不过这些状态树中,大部分的节点是共享的,只有少部分发生变化的节点要新建分支。

问:为什么要保留历史状态,为什么不在原地直接改了?

系统当中有时候会出现分叉临时性的分叉是很普遍的。

以太坊把出块时间降低到十几秒之后,这种临时性的分叉是常态,因为区块在网上传播时间可能也需要十几秒。

假设由有个分叉,这两个节点同时获得记账权(如下图)。

这两个分叉最终上面那个胜出了,下面这个分叉的节点该怎么办?

这个时候就要回滚(roll back),就这个节点当前的状态,就接受了下面这个节点的状态要取消掉,退回到上一个节点的状态,然后沿着上面那条链往下推进。

有时候可能要把当前状态退回到没有处理到这个区块中交易的前一个状态。

那怎么实现回滚呢?

就是要维护这些历史纪录

这个跟比特币还不太一样,如果是比特币的话,他的交易类型比较简单,有的时候可以通过这种反向操作推算出前一个状态。

比如,一个简单的转账交易(如下图),A转给B10个BTC,这个对账户余额有什么影响呢?

A的账户上少了10个比特币,B的状态多了10个比特币。

假如这个状态要回滚了,退回到前一个状态,那就把B这个账户减少10个比特币,把A这个账户加回去10个比特币就行了。

简单的转账交易回滚其实是比较容易的。

以太坊中为什么不行?

因为以太坊中有智能合约

智能合约是图灵完备的,编程功能是很强的。从理论上说,可以实现很复杂的功能,跟比特币简单的脚本还不太一样。

所以以太坊中如果不保存前面的状态,智能合约执行完之后,想再推算出前面是什么状态,这是不可能的,所以要想支持回滚,必须保存历史状态


以太坊中代码的数据结构

1、block header的结构

ParentHash:父区块的哈希值。是区块链中前一个区块的哈希值,不是区块的块头的哈希值。

UncleHash:叔父区块的哈希值。后面讲Ghost协议的时候,每个区块还有叔父区块。而且后面会看到,比较奇怪的一点是,大多数人表面上看ParentHash,UncleHash是一个辈分的,但是以太坊中不是这样的,这个Uncle比Parent可能大好多辈分。

Coinbase:是挖出这个矿的这个区块的矿工的地址

--------- 下面这三个就是本讲相关的三棵树的根哈希,以太坊中有三棵树:状态树,交易数和收据树

Root:状态树的根哈希值。

TxHash:交易树的根哈希值(类似比特币中的那个根哈希值)。

ReceiptHash:收据树的根哈希值。

Bloom:提供一个高效的查询,符合某种条件的交易的执行结果(跟收据树是相关的)。

Diffculty:挖矿难度(要根据需要调整)

--------- GasLimit和GasUsed是跟汽油费相关的,智能合约要消耗汽油费,类似于比特币中的交易费。

GasLimit:单个区块允许的最多gas总量

GasUsed:该交易消耗的总gas数量

Time:区块的大致的产生时间

--------- MixDigest和Nonce是跟挖矿过程相关的。

nonce:是挖矿时猜的那个随机数(类似于比特币的挖矿),以太坊中的挖矿也是要猜很多个随机数,写在块头里的随机数是最后找到的,符合难度要求的

MixDigest:混合摘要,从nonce这个随机数经过一些计算,算出一个哈希值。


2、区块的结构

对这节课来说比较相关的就是前面三个域:header,uncles,transactions

header:就是指向block header的指针

uncles:是指向叔父区块的header的指针。而且是个数组,因为一个区块可以有多个叔父区块

transactions:就是这个区块中交易的列表


这个区块在网上发布的时候,发布的就是这些信息,其实就是刚刚上面的前三项真正发布出去。


状态树中的value的存储:RLP

状态树中保存的是(key,value)

key就是地址。讲到现在,主要讲的是键值,这个地址的管理方式。

那么这个value呢,这个账户的状态呢,是怎么存储在状态树当中的?

实际上是要经过一个序列化(RLP)的过程,然后再存储。

RLP:Recursive Length Prefix,递归长度前缀,是一种序列化方法。特点是简单,极简主义,越简单越好。

Protocal buffer:简称Protobuf,是个很有名的做序列化的库。

跟这些库相比,RLP的理念就是越简单越好。

它只支持一种类型:nested array bytes,嵌套数组字节。一个一个字节组成的数组,可以嵌套。

以太坊里的所有的其他类型,比如整数,或者比较复杂的哈希表,最后都要变成nested array bytes

所以实现RLP要比实现Protocal buffer简单很多,因为难的东西,他都不做,都推给应用层了。

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

推荐阅读更多精彩内容