LevelDB 中的跳表實現

何為跳表

跳躍表(skiplist),簡稱「跳表」。是一種在鏈表基礎上進行優化的數據結構,最早由 William Pugh 在論文《Skip Lists: A Probabilistic Alternative to Balanced Trees》中提出。

William Pugh 于 1989 在論文中將「跳表」定位為:一種能夠替代平衡樹的數據結構,比起使用強制平衡算法的各種平衡樹,跳表采用一種隨機平衡的策略。因此跳表擁有更為簡單的算法實現,同時擁有與平衡樹相媲美的時間復雜度(logn 級別)和操作效率。

設計思想

普通鏈表是一種順序查找的數據結構。即在查找某個元素時需要依次遍歷所有元素進行比較。且在元素有序的情況下也無法像數組那樣利用二分查找,固元素查詢時間復雜度為 O(n),若需維護鏈表有序,元素插入的時間復雜度也同樣需要 O(n)。

在一些數據量極大的場景下,O(n) 的時間復雜度仍然存在優化的空間。

平衡二叉查找樹 AVL 或者紅黑樹是經常被用來實現上述優化的數據結構。但這些平衡樹需要在整個過程中保持樹的平衡,這帶來了一定的復雜度。

而跳表的核心思想類似于對有序鏈表中元素建立索引。整個跳表按照層次進行構建,底層為原始的有序鏈表,然后抽取鏈表中的關鍵元素到上一層作為索引,從剛構建的索引中可以繼續抽取關鍵元素到新的一層,以此類推。

跳表原理結構如下圖所示:

normal_skip_list.png

以查找元素 2 為例,查找將從第 2 層索引確定 1 ~ 5 范圍,再到第 1 層索引進一步確定 1 ~ 3 范圍,最后回到底層原始鏈表查找到元素 2。

性能分析

上文提到了「抽取每一層的關鍵元素到上一層作為索引」,但是隨著數據量的增加每一層的結點也會越來越多,該如何選取結點讓跳表的結點呈現我們所需的分布?

跳表采用「投硬幣」的方式決定結點是否向上提升。

假設現在底層有序鏈表有 n 個結點且投硬幣概率為 50%,則第一層應該[1]有 n/2 個索引結點,第二層有 n/4 個索引結點,第 i 層索引有 n/2i 個結點,當 n/2i 等于 2 時,意味著已經到了最高層。此時由 n/2i = 2,可推導出 i = log2n。

即投硬幣概率為 50% 時,跳表層高為 log2n,且由于每兩個結點就有一個結點上升為一個索引結點。所以當從最上層向下搜索的過程中,每一個最多只會比較 3 個結點(常數級),所以整個搜索過程時間復雜度最終為 log2n

[1] 概率事件,并非一定具有準確的 n/2 結點。

將上述過程進一步擴展概率為 p,則時間復雜度為 log1/pn。其中每一層比較的次數不超過 1/p。

上述是為了方便理解而簡化的概率推導過程,結論也建立在 n 足夠大的前提下。實際推導過程要復雜很多,有興趣的讀者可以閱讀論文原文:《Skip Lists: A Probabilistic Alternative to Balanced Trees》

實現

上文介紹了跳表的基本思想,其中為了方便理解和講述,我們將索引結點單獨繪制成一個結點。如果完全按照上文圖示實現跳表,則跳表需要額外 n 個結點空間。但在實際實現時,無需額外結點只需使用指針指向相應結點即可,因此只是多出了 n 個指針而已。

即跳表實際實現的結構如下圖所示:

skip_list_wiki.png

其中黃色格子為數據結點,白色格子為數據結點內的指針。

LevelDB 中的跳表源碼解析

我們以 LevelDB 中的跳表實現 skiplist.h 為例,分析跳表的具體實現

設計結點結構如下:

template <typename Key, class Comparator>
struct SkipList<Key, Comparator>::Node {
  explicit Node(const Key& k) : key(k) {}
  // 存儲 key
  Key const key;

  // ...... 

  private:
    // 下標表示結點的層次 level
    // 整個數組表示該結點在各層次的存儲情況
    std::atomic<Node*> next_[1];
}

如下圖所示:

data_structure_0.png

進一步理解如下圖所示:


data_structure_1.png

其中上圖 head_ 內的 next_ 數組存儲著指向各個索引層次第一個元素的指針

其它每個結點(如圖中的結點 1)中的 next_ 數組包含了如下信息:

結點在各個索引層中的下一個結點的指針

查詢元素

元素查詢主要邏輯集中在 FindGreaterOrEqual 這個函數,就以這個函數為例,體現元素查詢過程:

// 搜索大于等于 key 的所有結點
template <typename Key, class Comparator>
typename SkipList<Key, Comparator>::Node*
SkipList<Key, Comparator>::FindGreaterOrEqual(const Key& key,
                                              Node** prev) const {
  Node* x = head_;
  // 獲取當前結點的層高
  // 從最上層的索引層開始遍歷
  int level = GetMaxHeight() - 1;
  while (true) {
    // 假設 next_ = [*3, *5, *6]
    // 表示該結點:
    // 在第 2 層的下一個索引結點為 6
    // 在第 1 層的下一個索引結點為 5
    // 在第 0 層的下一個結點為 3
    // 那么就可以直接通過 next_[level] 找到下一個索引結點
    Node* next = x->Next(level);
    if (KeyIsAfterNode(key, next)) {  // key 是否在當前結點之后(大小關系由比較器最終確認)
      // Keep searching in this list
      // 繼續遍歷搜索該層的剩余結點
      x = next;
    } else {  // key 是否在當前結點之后(大小關系由比較器最終確認)
      // 記錄結點到 prev 數組
      // prev 數組記錄每個索引層次要插入 key 的位置
      if (prev != nullptr) prev[level] = x; prev
      if (level == 0) { // 遍歷到 0 層,遍歷結束
        return next;
      } else {
        // Switch to next list
        // 進入下一層遍歷
        level--;
      }
    }
  }
}

刪除元素

LevelDB 業務層面無刪除結點的需求,見源碼注解如下:

// (1) Allocated nodes are never deleted until the SkipList is
// destroyed.  This is trivially guaranteed by the code since we
// never delete any skip list nodes.

插入元素

template <typename Key, class Comparator>
void SkipList<Key, Comparator>::Insert(const Key& key) {
  // TODO(opt): We can use a barrier-free variant of FindGreaterOrEqual()
  // here since Insert() is externally synchronized.
  Node* prev[kMaxHeight];
  // 獲取所有大于等于(比較器定義) key 的結點
  // prev 保存各個索引層要插入的前一個結點
  Node* x = FindGreaterOrEqual(key, prev);

  // Our data structure does not allow duplicate insertion
  // 不允許插入重復的元素
  // 那么為空,表示沒有 >= key 的結點。要么不等于列表中的所有 key,表示沒有重復元素
  assert(x == nullptr || !Equal(key, x->key));

  // 生成一個隨機高度
  int height = RandomHeight();
  // 如果隨機高度比當前最大高度大
  if (height > GetMaxHeight()) {
    // prev 下標從原先的最大 height 到最新的最大 height 之間初始化為 head_
    for (int i = GetMaxHeight(); i < height; i++) {
      prev[i] = head_;
    }
    // It is ok to mutate max_height_ without any synchronization
    // with concurrent readers.  A concurrent reader that observes
    // the new value of max_height_ will see either the old value of
    // new level pointers from head_ (nullptr), or a new value set in
    // the loop below.  In the former case the reader will
    // immediately drop to the next level since nullptr sorts after all
    // keys.  In the latter case the reader will use the new node.
    // 原子操作:保存最新的最大高度
    max_height_.store(height, std::memory_order_relaxed);
  }

  // 創建一個新結點
  x = NewNode(key, height);
  for (int i = 0; i < height; i++) {
    // NoBarrier_SetNext() suffices since we will add a barrier when
    // we publish a pointer to "x" in prev[i].
    // 
    // 插入新結點,即:
    // new_node->next = pre->next;
    // pre->next = new_node;
    x->NoBarrier_SetNext(i, prev[i]->NoBarrier_Next(i));
    prev[i]->SetNext(i, x);
  }
}

并發處理

LevelDB 的跳表實現支持單線程寫、多線程讀,為了滿足該特點,LevelDB 在更新和讀取時需要注意 C++ memory_order 的設置。

在講解 LevelDB 跳表中的 memory_order 之前需要先介紹相關的基礎知識。

原子性

原子寓意著「不可再分的最小單位」,固計算機領域提及的原子性操作指的是那些「不可或不該再被切分(或中斷)的操作」。

而關于原子性,我們應當具有一個基本的認知:高級語言層面,單條語句并不能保證對應的操作具有原子性

在使用 C、C++、Java 等各種高級語言編寫代碼時,不少人會下意識的認為一條不可再分的單條語句具有原子性,例如常見 i++

// 偽碼

int i = 0;

void increase() {
  i++;
}

int main() {
  /* 創建兩個線程,每個線程循環進行 100 次 increase */
  // 線程 1
  Thread thread1 = new Thread(
    run() {
      for (int i = 0; i < 100; i++) increase();
    }
  );
  // 線程 2
  Thread thread2 = new Thread(
    run() {
      for (int i = 0; i < 100; i++) increase();
    }
  );
}

如果 i++ 是原子操作,則上述偽碼中的 i 最終結果為 200。但實際上每次運行結果可能都不相同,且通常小于 200。

之所以出現這樣的情況是因為 i++ 在執行時通常還會繼續劃分為多條 CPU 指令。以 Java 為例,i++ 編譯將形成四條字節碼指令,如下所示:

// Java 字節碼指令
0:  getstatic
1:  iconst_1
2:  iadd
3:  putstatic

而上述四條指令的執行并不保證原子性,即執行過程可被打斷??紤]如下 CPU 執行序列:

  1. 線程 1 執行 getstatic 指令,獲得 i = 1
  2. CPU 切換到線程 2,也執行了 getstatic 指令,獲得 i = 1。
  3. CPU 切回線程 1 執行剩下指令,此時 i = 2
  4. CPU 切到線程 2,由于步驟 2 讀到的是 i = 1,固執行剩下指令最終只會得到 i = 2

以上四條指令是 Java 虛擬機中的字節碼指令,字節碼指令是 JVM 執行的指令。實際每條字節碼指令還可以繼續劃分為更底層的機器指令。但字節碼指令已經足夠演示原子性的含義了

如果對底層 CPU 層面如何實現機器指令的原子操作[1]感興趣,可查閱 Spinlock、MESI protocol 等資料。

[1] 一條 CPU 指令可能需要涉及到緩存、內存等多個單元的交互,而在多核 CPU 的場景下并會存在與高層次多線程類似的問題。固需要一些機制和策略才可實現機器指令的原子操作。

有序性

上述已經提到 CPU 的一條指令執行時,通常會有多個步驟,如取指IF 即從主存儲器中取出指令、ID 譯碼即翻譯指令、EX 執行指令、存儲器訪問 MEM 取數、WB 寫回。

即指令執行將經歷:IF、ID、EX、MEM、WB 階段。

現在考慮 CPU 在執行一條又一條指令時該如何完成上述步驟?最容易想到并是順序串行,指令 1 依次完成上述五個步驟,完成之后,指令 2 再開始依次完成上述步驟。這種方式簡單直接,但執行效率顯然存在很大的優化空間。

思考一種流水線工作:

指令1   IF ID EX MEM WB
指令2      IF ID EX MEM WB
指令3         IF ID EX MEM WB

采用這種流水線的工作方式,將避免 CPU 、存儲器中各個器件的空閑,從而充分利用每個器件,提升性能。

同時注意到由于每條指令執行的情況有所不同,指令執行的先后順序將會影響到這條流水線的負載情況,而我們的目標則是讓整個流水線滿載緊湊的運行。

為此 CPU 又實現了「指令重排」技術,CPU 將有選擇性的對部分指令進行重排來提高 CPU 執行的性能和效率。例如:

x = 100;    // #1
y = 200;    // #2
z = x + y;  // #3

雖然上述高級語言的語句會編譯成多條機器指令,多條機器指令還會進行「指令重排」,#1 語句與 #2 語句完全有可能被 CPU 重新排序,所以程序實際運行時可能會先執行 y = 200; 然后再執行 x = 100;

但另一方面,指令重排的前提是不會影響線程內程序的串行語義,CPU 在重排指令時必須保證線程內語義不變,例如:

x = 0; // #1
x = 1; // #2
y = x; // #3

上述的 y 一定會按照正常的串行邏輯被賦值為 1。

但不幸的是,CPU 只能保證線程內的串行語義。在多線程的視角下,「指令重排」造成的影響需要程序員自己關注。


// 公共資源
int x = 0;
int y = 0;
int z = 0;

Thread_1:             Thread_2:
x = 100;              while (y != 200);
y = 200;              print x
z = x + y;

如果 CPU 不進行「亂序優化」執行,那么 y = 200 時,x 已經被賦值為 100,此時線程 2 輸出 x = 200。

但實際運行時,線程 1 可能先執行 y = 200,此時 x 還是初始值 0。線程 2 觀察到 y = 200 后,退出循環,輸出 x = 0;

C++ 中的 atomic 和 memory_order

C++ 提供了 std::atomic 類模板,以保證操作原子性。同時也提供了內存順序模型 memory_order指定內存訪問,以便提供有序性和可見性。

其中 memory_order 共有六種,如下表所示:

memory_order 解釋
memory_order_relaxed 只保證原子操作的原子性,不提供有序性的保證
memory_order_consume 當前線程中依賴于當前加載的該值的讀或寫不能被重排到此加載前
memory_order_acquire 在其影響的內存位置進行獲得操作:當前線程中讀或寫不能被重排到此加載前
memory_order_release 當前線程中的讀或寫不能被重排到此存儲后
memory_order_acq_rel 帶此內存順序的讀修改寫操作既是獲得操作又是釋放操作
memory_order_seq_cst 有此內存順序的加載操作進行獲得操作,存儲操作進行釋放操作,而讀修改寫操作進行獲得操作和釋放操作,再加上存在一個單獨全序,其中所有線程以同一順序觀測到所有修改

六種 memory_order 可以組合出四種順序:

  1. Relaxed ordering 寬松順序
Thread1: 
y.load(std::memory_order_relaxed);

Thread2:
y.store(h, std::memory_order_relaxed);

寬松順序只保證原子變量的原子性(變量操作的機器指令不進行重排序),但無其他同步操作,不保證多線程的有序性。

  1. Release-Acquire ordering 釋放獲得順序
 
std::atomic<std::string*> ptr;
int data;
 
void producer()
{
    std::string* p  = new std::string("Hello"); // #1
    data = 42;  // #2
    ptr.store(p, std::memory_order_release);
}
 
void consumer()
{
    std::string* p2;
    while (!(p2 = ptr.load(std::memory_order_acquire)))
        ;
    assert(*p2 == "Hello"); // 絕無問題 #3
    assert(data == 42); // 絕無問題 #4
}
 
int main()
{
    std::thread t1(producer);
    std::thread t2(consumer);
    t1.join(); t2.join();
}

如例子所示,store 使用 memory_order_release,load 使用 memory_order_acquire,CPU 將保證如下兩點:

  • store 之前的語句不允許被重排序到 store 之后(例子中的 #1 和 #2 語句一定在 store 之前執行)
  • load 之后的語句不允許被重排序到 load 之前(例子中的 #3 和 #4 一定在 load 之后執行)

同時 CPU 將保證 store 之前的語句比 load 之后的語句「先行發生」,即先執行 #1、#2,然后執行 #3、#4。這實際上就意味著線程 1 中 store 之前的讀寫操作對線程 2 中 load 執行后是可見的。

注意是所有操作都同步了,不管 #3 是否依賴了 #1 或 #2

值得關注的是這種順序模型在一些強順序系統例如 x86、SPARC TSO、IBM 主框架上是自動進行的。但在另外一些系統如 ARM、Power PC 等需要額外指令來保障。

  1. Release-Consume ordering 釋放消費順序
    理解了釋放獲得順序順序后,就非常容易理解釋放消費順序,因為兩者十分類似。
 
std::atomic<std::string*> ptr;
int data;
 
void producer()
{
    std::string* p  = new std::string("Hello"); // #1
    data = 42; // #2
    ptr.store(p, std::memory_order_release);
}
 
void consumer()
{
    std::string* p2;
    while (!(p2 = ptr.load(std::memory_order_consume)))
        ;
    assert(*p2 == "Hello"); // #3 絕無出錯: *p2 從 ptr 攜帶依賴
    assert(data == 42); // #4 可能也可能不會出錯: data 不從 ptr 攜帶依賴
}
 
int main()
{
    std::thread t1(producer);
    std::thread t2(consumer);
    t1.join(); t2.join();
}

store 使用 memory_order_release,load 使用 memory_order_consume。其效果與 Release-Acquire ordering 釋放獲得順序類似,唯一不同的是并不是所有操作都同步(不夠高效),而是只對依賴操作進行同步,保證其有序性上例就是 #3 一定發生在 #1 之后,因為這兩個操作依賴于 ptr。但不會保證 #4 一定發生在 #2 之后(注意「釋放獲得順序」可以保證這一點)。

  1. Sequential consistency 序列一致順序
    理解上述幾種順序后,Sequential consistency 就很好理解了。

「釋放獲得順序」是對某一個變量進行同步,Sequential consistency 序列一致順序則是對所有變量的所有操作都進行同步。

store 和 load 都使用 memory_order_seq_cst,可以理解對每個變量都進行 Release-Acquire 操作。所以這也是最慢的一種順序模型。

LevelDB 跳表的并發處理

在 LevelDB 的 skiplist.h 中,涉及到了 atomic 和 memory_order,我們結合上文的介紹來理解其中的實現邏輯。

首先對跳表的最大高度 max_height_ 設置了 atomic,并采用 memory_order_relaxed 進行讀寫:

// 確保在所有平臺下以及內存對齊或非對齊情況下
// 對 max_height_ 的讀寫都是原子性的
std::atomic<int> max_height_;

// ....

// store 和 load 都采用了 memory_order_relaxed
// 即采用 Relaxed ordering 寬松順序
// 即對多線程有序性不做保證
max_height_.store(height, std::memory_order_relaxed);

// ...

max_height_.load(std::memory_order_relaxed);

max_height_ 如同實現一個計數器 i++ 一樣,如果多線程讀不是原子性的,那么就會造成類似某個線程讀到舊數據或不完整數據的局面。

其次對跳表結點的索引結點也進行了 atomic 的處理,如下所示:

std::atomic<Node*> next_[1];

// ...

// 插入結點時
next_[n].store(x, std::memory_order_release);

// ...

// 讀取結點時
next_[n].load(std::memory_order_acquire);

從中可知,對 next_[n] 使用了 Release-Acquire ordering 釋放獲得順序,其可以保證某個線程進行 store 后,其他所有執行 load 的讀線程都將讀到 store 的最新數據。因為釋放獲得順序保證了 先 store 后 load 的執行順序。

這也正是 LevelDB 的跳表支持多線程讀的原因。

值得注意的是其中還實現了 NoBarrier_SetNextNoBarrier_Next。這兩個沒有內存屏障的操作實際就是使用了寬松順序對 next_[n] 進行讀寫。這種操作是線程不安全的,為什么需要這種操作?

void SkipList<Key, Comparator>::Insert(const Key& key) {
  // ... 

  // 插入新結點
  x = NewNode(key, height);
  for (int i = 0; i < height; i++) {
    // 這兩句相當于:
    // new_node->next = pre->next;
    // pre->next = new_node;
    x->NoBarrier_SetNext(i, prev[i]->NoBarrier_Next(i));
    prev[i]->SetNext(i, x);
  }
}

在一個鏈表中插入一個結點的步驟實際就是:

new_node->next = pre->next;
pre->next = new_node;

而 new_node->next = pre->next; 這一步賦值不必立馬對所有讀線程可見,因為此時還未完全插入結點,并不影響讀線程的讀取。如下圖所示:

concurrency.png

為什么要特意使用 NoBarrier_SetNext ?因為寬松順序效率更高,可以看到 LevelDB 的跳表實現為了性能已經優化到了如此變態的地步。

附錄

源碼注解

對 LevelDB 中跳表 skiplist.h 的源碼實現做了詳細注解,可見源碼注解

操作樣例

  1. 創建一個 SkipList,數據結構初始化如下圖所示:
implement_0.png
  1. 新增一個結點,且設 key = 10,隨機 height = 4,則數據結構如下圖所示:
implement_1.png
  1. 繼續新增一個結點,且設 key = 5,隨機 height= 3,則數據結構如下圖所示:
implement_2.png
  1. 繼續新增一個結點,且設 key = 4,隨機 height = 5,則數據結構如下圖所示:
    implement_3.png

參考資料

跳躍列表 Wikipedia
Skip list Wikipedia
Skip Lists: A Probabilistic Alternative to Balanced Trees
Atomic_semantics Wikipedia
Spinlock Wikipedia
MESI_protocol Wikipedia
CPU的工作過程
std::memory_order
周志明.2011.深入理解 Java 虛擬機
葛一鳴,郭超.2015.Java高并發程序設計

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,501評論 6 544
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,673評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事?!?“怎么了?”我有些...
    開封第一講書人閱讀 178,610評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,939評論 1 318
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,668評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 56,004評論 1 329
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 44,001評論 3 449
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,173評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,705評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,426評論 3 359
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,656評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,139評論 5 364
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,833評論 3 350
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,247評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,580評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,371評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,621評論 2 380