高性能Go服务中的内存分配效率之一

阅读前请悉知:本文是一篇翻译文章,出于对原文的喜爱与敬畏,所以需要强调:如果读者英文阅读能力好,请直接移步文末原文链接;如果对这篇翻译所述知识感兴趣,也请一定要再看下英文原文,加深理解。翻译中为了表达的需要,加入了自己的一些理解,不过因为知识有限,翻译过程难免纰漏,如有问题,欢迎留言指正。

至少可以说,内存管理可能会很棘手。然而,在阅读了一些文献之后,人们可能会认为所有的问题都已经解决了:那些复杂的管理内存分配生命周期的自动化系统会将我们从这些负担中解放出来。

但是,如果您曾经尝试过优化java程序的垃圾回收器或优化Go代码库的内存分配模式,那么您就会明白,这个问题远没有解决。自动化内存管理有助于排除大量的错误,但这只是问题的一半。我们的软件的热点路径必须以这些系统能够有效工作的方式构建。

我们找到了分享我们在这个领域的学习的灵感,同时在围棋中建立了一个高吞吐量的服务,称为Centrifuge(离心机),每秒处理数十万个事件。Centrifuge是部分基础设施的重要组成部分。一致的、可预测的行为是一个需求。整洁、高效和精确地使用内存是实现这种一致性的主要部分。

在这篇文章中,我们将介绍与内存分配有关的常见模式,这些模式会导致效率低下和产生意外,以及减少或消除这些问题的实际方法。我们将重点讨论内存分配器的关键机制,它为开发人员提供了一些关于内存使用的思路。

工具

我们的第一个建议是避免过早优化。Go提供了优秀的分析工具,可以直接指向代码库的重分配部分。没有理由重新发明轮子,所以我们将在官方的Go博客上参考这篇优秀的文章,而不是带读者在这里浏览它。它对使用pprof进行CPU和内存分配分析进行了详细的演练。这些是我们在片段中使用的工具,用于查找生产Go代码中的瓶颈,并且应该是您首先要解决的问题。

使用数据驱动优化!

逃逸分析

Go自动管理内存分配。这可以防止所有类型的潜在bug,但这并不能完全让程序员从分配机制中解脱出来。由于Go并没有提供一种直接的方式来操纵内存分配,因此开发者必须理解这套系统的规则,这样更加有利于我们进行代码分析与优化。

如果你还记得这篇文章中的一件事,那就是:栈分配很便宜,堆分配很贵。现在让我们深入了解这到底意味着什么。

Go将内存分配到两个位置:用于动态分配的全局堆和用于每个goroutine的本地栈。Go更倾向于栈分本——Go程序中的大多数内存分配都在栈上。它很便宜,因为它只需要两条CPU指令:一条推到栈上进行分配,另一条从栈中释放

不幸的是,并非所有数据都可以使用栈上分配的内存。栈分配要求可以在编译时确定变量的生存期和内存占用。否则,将在运行时在堆上进行动态分配malloc必须搜索足够大的空闲内存块以容纳新值。稍后,垃圾回收器将扫描堆中不再引用的对象。毫无疑问,对计算机而言,堆分配比栈分配使用的两条指令昂贵得多。

编译器使用一种称为逃逸分析的技术在这两个选项之间进行选择。基本思想是在编译时执行垃圾回收工作。编译器跟踪跨代码区域的变量范围。它使用这些数据来确定哪些变量持有一组检查,这些检查证明它们的生命周期在运行时是完全可知的。如果变量通过了这些检查,则可以在栈上分配该值。如果不是,则称为逃逸,并且必须分配在堆上。

逃逸分析规则不是Go语言规范的一部分。对于Go程序员来说,了解这些规则最直接的方法就是实验。编译器将通过构建go build -gcflags '-m'来输出逃逸分析的结果。让我们来看一个例子:

package main

import "fmt"

func main() {
        x := 42
        fmt.Println(x)
}
$ go build -gcflags '-m' ./main.go
# command-line-arguments
./main.go:7: x escapes to heap
./main.go:7: main ... argument does not escape

这里可以看到变量x“逃逸到堆上”,这意味着它将在运行时在将在堆上动态分配。这个例子有点令人费解。乍一看,x似乎无法逃脱main()函数。编译器的输出并没有解释为什么它认为x会逃逸。要了解更多细节,请多次使用-m选项,以使输出更详细:

$ go build -gcflags '-m -m' ./main.go
# command-line-arguments
./main.go:5: cannot inline main: non-leaf function
./main.go:7: x escapes to heap
./main.go:7:         from ... argument (arg to ...) at ./main.go:7
./main.go:7:         from *(... argument) (indirection) at ./main.go:7
./main.go:7:         from ... argument (passed to call[argument content escapes]) at ./main.go:7
./main.go:7: main ... argument does not escape

啊,是的!这表x会逃逸,因为它被传递给一个函数参数,这个参数会自动逃逸。

规则可能一开始看起来是任意的,但是经过一些对这些工具的尝试和错误之后,模式确实开始出现了。对于时间有限,下面列举一些我们已经发现的模式,它们通常会导致变量逃逸到堆中:

  • 发送指针或包含指向channel的指针的值。在编译时,无法知道哪个goroutine将在channel上接收数据。因此,编译器无法确定何时不再引用该数据。

  • slice中存储指针或包含指针的值。这方面的一个例子是[]*string类型。这总会slice的内容发生逃逸。即使slice的数组本身可能仍然在栈上,但是指针指向的数据也会逃逸到堆上。

  • 由于append将超过其本身容量而重新分配的slice返回给数组。在编译时已知slice初始大小的情况下,它将开始在栈上进行分配。如果这个slice的底层存储必须基于运行时已知的数据进行扩展,那么它将被分配到堆上。

  • 调用interface类型上的方法。对interface类型的方法调用是动态的——实际使用的具体实现只能在运行时确定。考虑一个带有interface类型方法io.Reader的变量r。对r.Read(b)的调用将导致br发生逃逸,从而在堆上分配。

根据我们的经验,这四种情况是Go程序中最常见的动态分配来源。幸运的是,这些问题都有解决方案!接下来,我们将更深入地介绍一些具体的例子,说明我们如何解决生产软件中的内存低效问题。

指针

经验法则是:指针指向堆上分配的数据。因此,减少程序中的指针数量会减少堆分配的数量。这不是一个公理,但我们发现它是现实世界Go程序中的常见情况。

我们的经验是,开发人员在不了解值与指针的性能特征的情况下,就能熟练地使用Go进行生产。直觉得出的一个常见假设是这样的:“复制值很昂贵,所以我将使用指针。然而,在许多情况下,复制一个值比使用指针的开销要便宜得多。你可能会问:“为什么?”

  • 当取消引用指针时,编译器会生成检查。如果指针为nil,则通过运行panic()来避免内存损坏。这是必须在运行时执行的额外代码。当数据通过值传递时,它不能为nil

  • 指针的引用位置通常很差。函数中使用的所有值都放在栈的内存中。局部引用是高效代码的一个重要方面。它极大地增加了CPU缓存中值变暖的可能性,并降低了在预取过程中丢失惩罚的风险。

  • 在高速缓存线路中复制对象大致相当于复制一个指针。cpu在缓存层和主内存之间移动内存,缓存行大小不变。在x86上,这是64字节。此外,Go还使用了一种称为Duff’s device的技术,使普通的内存操作(如复制)变得非常高效。

指针应该主要用于反映所有权语义和可变性。在实践中,使用指针来避免副本的频率应该很低。不要落入过早优化的陷阱。养成按值传递数据的习惯是好的,只有在必要的时候才回到传递指针。额外的好处是消除零的安全性增加了。

减少程序中的指针数量可以产生另一个有用的结果,因为垃圾回收器将跳过它可以证明不包含指针的内存区域。例如,堆中返回类型为[]byte的区域根本不被扫描。对于不包含任何带有指针类型的字段的struct类型数组,这也适用。

减少指针不仅会减少垃圾回收器的工作,还会产生更友好的缓存代码。读取内存将数据从主内存转移到CPU缓存中。缓存是有限的,因此必须清除一些其他的数据来腾出空间。回收的数据可能仍然与程序的其他部分相关。由此产生的缓存抖动可能会导致生产服务的行为发生意外和突然的变化。

深挖指针

减少指针的使用通常意味着深入研究用于构建程序的类型的源代码。我们的服务Centrifuge保留了一个失败操作队列,将其作为一个循环缓冲区重试,该循环缓冲区具有一组数据结构,如下所示:

type retryQueue struct {
    buckets       [][]retryItem // each bucket represents a 1 second interval
    currentTime   time.Time
    currentOffset int
}

type retryItem struct {
    id   ksuid.KSUID // ID of the item to retry
    time time.Time   // exact time at which the item has to be retried
}

bucket中的外部数组的大小是不变的,但是包含的[]retryItem中的项的数量在运行时将会改变。重试次数越多,这就越长。

深入研究retryItem每个字段的实现细节,我们发现KSUID是[20]byte的类型别名,没有指针,因此可以排除。currentOffset是一个int,它是一个固定大小的原语,也可以排除。接下来,看时间time.Time[1]:

type Time struct {
    sec  int64
    nsec int32
    loc  *Location // pointer to the time zone structure
}

time.Time结构体包含loc字段的内部指针。在retryItem类型中使用它会导致GC在每次遍历堆的这个区域时跟踪这些结构上的指针。

我们发现这是在意外情况下级联效应的典型例子。在正常的操作过程中,很少发生故障。只有少量内存用于存储重试。当故障突然激增时,重试队列中的项数可能会以每秒数千的速度增加,从而大大增加垃圾回收器的工作负载。

对于这个特定的用例,时区信息在time.Time中存在是没有必要的。这些时间戳保存在内存中,从不序列化。因此,可以重构这些数据结构,以完全避免这种类型:

type retryItem struct {
    id   ksuid.KSUID
    nsec uint32
    sec  int64
}

func (item *retryItem) time() time.Time {
    return time.Unix(item.sec, int64(item.nsec))
}

func makeRetryItem(id ksuid.KSUID, time time.Time) retryItem {
    return retryItem{
        id:   id,
        nsec: uint32(time.Nanosecond()),
        sec:  time.Unix(),
}

现在retryItem不包含任何指针。这极大地减少了垃圾回收器的负载,因为在编译时[2]可以知道retryItem的全部占用空间。

切片

slice是热代码路径中低效分配行为的肥沃土壤。除非编译器知道编译时slice的大小,否则将在堆上分配slice(和映射!)的备份数组。让我们探索一些方法来保持栈上的slice并避免堆分配。

Centrifuge大量使用MySQL。总体程序效率在很大程度上取决于MySQL驱动程序的效率。在使用pprof分析分配器行为之后,我们发现了序列化时间的代码。GoMySQL驱动程序中的时间值特别昂贵。

profiler显示大部分堆分配是在序列化一次的代码中。时间值,以便它可以通过连线发送到MySQL服务器。

image.png

这个特定的代码正在调用time.TimeFormat()方法并返回一个字符串。等等,我们不是在说切片吗?好吧,根据官方的Go博客,字符串只是“只读的字节切片,外加一些来自该语言的额外语法支持”。大多数关于分配的规则都适用!

配置文件告诉我们,有12.38%的内存分配发生在调用Format时,Format做了什么?

image.png

事实证明,有一种更有效的方法可以在标准库中使用相同的模式。虽然Format()方法简单方便,但是使用AppendFormat()的代码在分配器上要容易得多。仔细查看time包的源代码,我们注意到所有内部使用都是AppendFormat(),而不是Format()。这是一个非常强烈的暗示,AppendFormat()将产生更多的性能行为。

image.png

实际上,Format方法只是对包装AppendFormat进行了封装:

func (t Time) Format(layout string) string {
          const bufSize = 64
          var b []byte
          max := len(layout) + 10
          if max < bufSize {
                  var buf [bufSize]byte
                  b = buf[:0]
          } else {
                  b = make([]byte, 0, max)
          }
          b = t.AppendFormat(b, layout)
          return string(b)
}

最重要的是,AppendFormat()给了程序员更多的分配控制权。它需要将切片可变,而不是返回像Format()一样返回字符串。使用AppendFormat()而不是Format()让相同目的的操作使用固定大小的内存分配[3],因此可以在栈上分配。

让我们来看看我们对GoMySQL驱动程序进行改进的PR

image.png

首先要注意的是var a[64]字节是一个固定大小的数组。它的大小在编译时已知,它的使用范围完全限定于这个函数,因此我们可以推断它将在栈上分配。

但是,这种类型不能传递给AppendFormat(),它接受[]byte类型。使用a[:0]可以将将固定大小的数组转换为由该数组支持的b表示的slice类型。这将通过编译器的检查并分配到栈上。

最关键的是,将动态分配的内存传递给AppendFormat()AppendFormat()方法本身会通过编译器的栈分配检查。在上一个版本中,使用了Format(),它包含在编译时无法确定的大小分配,因此不符合栈分配条件。

这个相对较小的更改的结果大大减少了代码中的内存分配,与在MySQL驱动程序中使用Append pattern类似,Append()方法在[这个PR](this PR)中被添加到KSUID类型中。同样值得注意的是,strconv包具有用于将数字字符串转换为数字类型的方法。

Interface

众所周知,对接口类型的方法调用比对结构体类型的方法调用更昂贵。对接口类型的方法调用通过动态调度执行。这严重限制了编译器确定代码在运行时执行方式的能力。到目前为止,我们主要讨论了如何对代码进行优化,以便编译器能够在编译时更好地理解其行为。接口类型抛弃所有这些!

不幸的是,接口类型是一个非常有用的抽象——它们允许我们编写更灵活的代码。在程序的热路径中使用接口的常见情况是由标准库的hash提供的哈希功能。hash包定义了一组通用接口,并提供了几个具体的实现。让我们来看一个例子:

package main

import (
        "fmt"
        "hash/fnv"
)

func hashIt(in string) uint64 {
        h := fnv.New64a()
        h.Write([]byte(in))
        out := h.Sum64()
        return out
}

func main() {
        s := "hello"
        fmt.Printf("The FNV64a hash of '%v' is '%v'\n", s, hashIt(s))
}

用逃逸分析输出构建这段代码会得到以下结果:

./foo1.go:9:17: inlining call to fnv.New64a
./foo1.go:10:16: ([]byte)(in) escapes to heap
./foo1.go:9:17: hash.Hash64(&fnv.s·2) escapes to heap
./foo1.go:9:17: &fnv.s·2 escapes to heap
./foo1.go:9:17: moved to heap: fnv.s·2
./foo1.go:8:24: hashIt in does not escape
./foo1.go:17:13: s escapes to heap
./foo1.go:17:59: hashIt(s) escapes to heap
./foo1.go:17:12: main ... argument does not escape

这意味着哈希对象、输入字符串和输入的[]byte都将逃逸到堆中。在人们看来,这些变量显然无法逃脱,但是接口类型将编译器绑定在一起。如果不通过hash包的接口,就无法安全地使用具体的实现。那么,关注效率的开发人员应该做些什么呢?

我们在构建Centrifuge(离心机)时遇到了这个问题,Centrifuge(离心机)在热路径中对小字符串执行非加密哈希。所以我们建造了fasthash。构建起来很简单——完成繁重工作的代码是标准库的一部分。fasthash无需堆分配,只是使用的API重新包装了标准库代码。

让我们检查一下我们的测试程序的fasthash版本:

package main

import (
        "fmt"
        "github.com/segmentio/fasthash/fnv1a"
)

func hashIt(in string) uint64 {
        out := fnv1a.HashString64(in)
        return out
}

func main() {
        s := "hello"
        fmt.Printf("The FNV64a hash of '%v' is '%v'\n", s, hashIt(s))
}

逃逸分析结果输出

./foo2.go:9:24: hashIt in does not escape
./foo2.go:16:13: s escapes to heap
./foo2.go:16:59: hashIt(s) escapes to heap
./foo2.go:16:12: main ... argument does not escape

剩下的惟一逃逸是由于fmt.Printf()函数的动态性。虽然我们非常喜欢从人机工程学的角度使用标准库,但在某些情况下,为了提高分配效率而付出这么大的代价是值得的。

一个奇怪的技巧
我们最后的轶事与其说是实际的,不如说是有趣的。但是,对于理解编译器逃逸分析的机制来说,这是一个有用的例子。在检查所涉及的优化标准库时,我们发现了一段相当奇怪的代码。

// noescape hides a pointer from escape analysis.  noescape is
// the identity function but escape analysis doesn't think the
// output depends on the input.  noescape is inlined and currently
// compiles down to zero instructions.
// USE CAREFULLY!
//go:nosplit
func noescape(p unsafe.Pointer) unsafe.Pointer {
    x := uintptr(p)
    return unsafe.Pointer(x ^ 0)
}

此函数将从编译器的逃逸分析功能中隐藏传递的指针。这到底意味着什么呢?好吧,我们来做个实验看看!

package main

import (
        "unsafe"
)

type Foo struct {
        S *string
}

func (f *Foo) String() string {
        return *f.S
}

type FooTrick struct {
        S unsafe.Pointer
}

func (f *FooTrick) String() string {
        return *(*string)(f.S)
}

func NewFoo(s string) Foo {
        return Foo{S: &s}
}

func NewFooTrick(s string) FooTrick {
        return FooTrick{S: noescape(unsafe.Pointer(&s))}
}

func noescape(p unsafe.Pointer) unsafe.Pointer {
        x := uintptr(p)
        return unsafe.Pointer(x ^ 0)
}

func main() {
        s := "hello"
        f1 := NewFoo(s)
        f2 := NewFooTrick(s)
        s1 := f1.String()
        s2 := f2.String()
}

这段代码包含两个执行相同任务的实现:它们持有一个字符串并使用string()方法返回所包含的字符串。然而,编译器的逃逸分析输出显示FooTrick版本没有逃逸!

./foo3.go:24:16: &s escapes to heap
./foo3.go:23:23: moved to heap: s
./foo3.go:27:28: NewFooTrick s does not escape
./foo3.go:28:45: NewFooTrick &s does not escape
./foo3.go:31:33: noescape p does not escape
./foo3.go:38:14: main &s does not escape
./foo3.go:39:19: main &s does not escape
./foo3.go:40:17: main f1 does not escape
./foo3.go:41:17: main f2 does not escape

这两行是最相关的:

./foo3.go:24:16: &s escapes to heap
./foo3.go:23:23: moved to heap: s

这是编译器,它识别NewFoo()函数对字符串进行引用并将其存储在struct中,从而使其逃逸。然而,NewFooTrick()函数没有出现这样的输出。如果取消了对noescape()的调用,则逃逸分析将FooTrick 结构体引用的数据移动到堆中。这里发生了什么?

func noescape(p unsafe.Pointer) unsafe.Pointer {
    x := uintptr(p)
    return unsafe.Pointer(x ^ 0)
}

noescape()函数掩盖了输入参数和返回值之间的依赖关系。编译器认为p不会通过x逃逸,因为uintptr()生成一个对编译器不透明的引用。内建uintptr类型的名称可能会让人认为这是一个真正的指针类型,但从编译器的角度来看,它只是一个整数,刚好大到可以存储指针。最后一行代码构造并返回一个不安全的代码。指针值来自一个看起来任意的整数值。这里没什么可看的,伙计们!

noescape()用于runtime包中使用unsafe.Pointer的数十个函数。在作者确切知道某个不安全数据引用的情况下,它是有用的。指针不会逃逸,但是编译器天真地认为不会。

需要明确的是,我们不建议使用这种技术。引用的包被称为不安全是有原因的,源代码中包含“小心使用!”

经验

建立一个可靠的Go服务,必须在广泛的现实世界条件下保持高效和稳定,这对我们的团队来说是一个巨大的学习经验。让我们回顾一下我们的主要学习内容:

  1. 不要过早的优化!使用数据来驱动优化工作。

  2. 栈分配便宜,堆分配贵。

  3. 理解逃逸分析的规则以使我们编写更高效的代码。

  4. 指针使得栈分配几乎不可行。

  5. 寻找在代码的性能关键部分中提供分配控制的api。

  6. 在热路径中谨慎使用interface类型。

我们使用这些相对简单的技术来改进我们自己的Go代码,并希望其他人发现这些来之不易的经验有助于构建他们自己的Go程序。

快乐的编码,兄弟!

引用

[1]时间结构类型在Go 1.9中改变

[2]您可能也注意到我们切换了nsec和sec字段的顺序,原因是由于对齐规则,Go会在KSUID之后生成4字节的填充。纳秒字段恰好是4个字节,所以将它放在KSUID Go之后就不需要再添加填充了,因为字段已经对齐了。这将数据结构的大小从40字节降到了32字节,使重试队列使用的内存减少了20%。

[3]Go中的固定大小的数组类似于切片,但是它们的大小直接编码到类型签名中。虽然大多数api接受切片而不是数组,但切片可以由数组组成!


版权声明:

任何个人或机构如需转载本文,无须再获得作者书面授权,但是转载者必须保留作者署名,并注明出处。

作者保留对本文的修改权。他人未经作者许可,不得擅自修改,破坏作品的完整性。

作者保留对本文的其他各项著作权权利。

原文阅读:
Allocation efficiency in high-performance Go services

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

推荐阅读更多精彩内容