MongoDB应用篇-mongo索引机制与管理

上篇我们学习了mongoDB的文档相关操作,了解了mongo的查询机制,以及支持的几种常见查询方式,本篇我们从应用的角度学习mongoDB中的索引机制

我们知道如果没有索引,MongoDB在读取数据时必须扫描集合中的每个文件并选取那些符合查询条件的记录。这种扫描全集合的查询效率是非常低的,特别在处理大量的数据时,查询可以要花费几十秒甚至几分钟,这是任何网站都无法接受的,而索引通常能够极大的提高查询的效率,所以索引机制成为了必不可少的一部分。

索引说明

如果是熟悉关系型数据库开发,例如mysql,都知道在数据库查询的时候我们可以使用explain来查看数据库语句的执行情况, 可以看到扫描的行数,索引的选择以及预计的时间等,而在mongoDB中也存在explain函数,可以帮助我们查看mongoDB索引的执行过程,如下:

db.set.find().explain()

可以看到输出了mongoDB的执行计划:

{
        "queryPlanner" : {
                "plannerVersion" : 1,
                "namespace" : "set.set",
                "indexFilterSet" : false,
                "parsedQuery" : {
                        
                },
                "queryHash" : "8B3D4AB8",
                "planCacheKey" : "8B3D4AB8",
                "winningPlan" : {
                        "stage" : "COLLSCAN",
                        "direction" : "forward"
                },
                "rejectedPlans" : [ ]
        },
        "serverInfo" : {
                "host" : "localhost.localdomain",
                "port" : 27017,
                "version" : "4.2.8",
                "gitVersion" : "43d25964249164d76d5e04dd6cf38f6111e21f5f"
        },
        "ok" : 1
}

粗略一看输出的信息很多,我们暂时只需要看几个信息,如queryPlanner属性代表查询计划的内容,serverInfo代表当前mongo客户端的信息。如果是mongoDB3.2及以前的版本的话,我们使用explain得到的结果和当前是不一样的,大致如下:

{
"cursor" : "BasicCursor",
"nscanned" : 102,
"nscannedObjects" : 102,
"n" : 1,
"millis" : 2,
"nYields" : 0,
"nChunkSkips" : 0,
"isMultiKey" : false,
"indexOnly" : false,
"indexBounds" : {
 }
}

而这里的话,我们可以关注 nscanned 属性,这个属性代表当前扫描的行数,而 millis 属性则代表了这个查询大概需要的毫秒数,除此之外还有个 n 属性,这个属性代表着查询返回的结果条数。了解了这些后,我们来给url上添加一个索引试试,添加索引需要使用createIndex函数来完成:

db.set.createIndex({"url":1})

这里需要注意,如果是mongoDB3.2及以前的版本,创建索引的函数则是ensureIndex,而在mongoDB3.2以上版本中,修改为了createIndex函数,不过如果我们使用ensureIndex函数依然可以创建索引,只是ensureIndex函数成为了 createIndex() 的别名。

接着我们来看看 createIndex函数中的参数,第一个参数是需要设置索引的键,而第二个参数与以往查询函数中的1不同,这里的1则是代表了索引按照升序进行创建,而-1则是代表按照降序进行创建

在调用了createIndex函数后,如果该集合数据较多的话,我们会发现此命令会阻塞一段时间,由于每个机器性能不一样,如果我们想要查询当前创建索引的进度,可以选择在开启一个客户端连接窗口,使用如下方式查询当前创建索引的进度,或者选择读取mongoDB日志的方式查看:

db.currentOp()    //查看数据库创建索引进度

在索引创建完毕后,我们再次使用url属性进行文档查询和扫描操作,这个时候我们会发现,效率明显提升,几乎在瞬间就返回了查询的结果。然而,索引虽然好用,但是我们创建索引是需要代价的,第一索引会导致占用的磁盘增大,数据越多,索引越多,磁盘占用则越大,第二,对于每一个索引,每次进行写操作
(插入、更新、删除)都将耗费更多的时间,因为每次更新文档时,还要同步更新文档对应的索引数据,因此mongoDB规定,每个集合无论复杂度如何,最多同时存在64个索引,在实践过程中,几乎很少会给集合创建超过5个索引,因此我们最好在设计集合和选择索引组合上下一点心思。

复合索引

如果我们需要在两个及以上的条件上进行查询,甚至有时候可能会让索引的键方向不同,例如我们需要根据count属性从小到大,但是url则是从Z到A的顺序排序,这个时候如果我们单独给这两个属性设置两个独立的索引,查询则不会变的很高效, 因为这两个属性都是按照指定的方向进行排序的,如果仅仅是查询一个属性,mongo的所以可以很容易的进行逆序操作,但是当多个索引列的时候就无法自动完成快速的逆序操作了,这个时候我们就需要为这几个属性建立多方向的复合索引。例如,我们需要按照url倒序,count正序的方式查询,只要按照同样的排序方式创建这两个属性的复合索引,如果需要按照url正序,count也正序的这种排序查询方式,我们还需要按照这种方式设置一个复合索引,但是我们需要注意的是,如果只有一个条件,索引排序的顺序是相反的也可以直接逆序查询,但是如果是多个条件一起查询,部分属性的索引排序方向是相反的,则会无法触发索引,因此在多条件复合查询的时候,索引的方向显得尤为重要。

覆盖索引

正常情况下我们查询需要的数据,可能只有一部分,每次查询如果都要将整个文档都查询出来,即使触发了索引条件,但是通过索引键还会再去查找对应的文档。如果查询中只需要查找索引中包含的字段,这个时候就际的文档。如果你的查询只需要查找索引中包含的字段,那就可以使用排除键的方式,指定返回的文档数据中只有需要的索引键的数据,这样的话,查找到索引数据后会直接返回,而不需要二次追朔文档数据。因此在实际中,应该优先使用覆盖索引,而不是去获取实际的文档数据。这样可以保证工作集比较
小,但是需要注意的一点是,覆盖索引仅仅针对常规的键-值数据有效,如果键是数组数据,那么无论怎么触发,都不会触发覆盖索引,即使我们选择将数组数据剔除,也无法触发覆盖索引,因此在开发设计过程中需要格外注意特殊类型的键设置为索引和查询性能平衡的问题。

隐式索引

熟悉关系型数据库的可能都知道,在mysql中,如果给几个键设置了联合索引,除了自身的组合之外,还具有隐式索引的功能,例如,我们给url和count设置了联合索引,除了我们在查询的时候按照url + count条件进行查询可以触发索引以外,如果我们仅仅查询url一个键,会发现依然触发了索引机制,但是需要注意的是,AB键联合索引,只有AB顺序和A作为条件才能触发索引,但是如果我们查询顺序是BA或者仅仅有B条件,是无法触发索引的,这个就是联合索引的隐式索引规则。

同样的,在mongoDB中也存在类似的索引机制原则,不过除了需要键顺序以外,还要考虑键的索引方向问题,并且联合索引的性能提升理论上是大于多个键的独立索引带来的优势,因此,在开发设计阶段,如果可以,可以尽量设计联合索引,来带来更多隐式索引的性能优势,以减少单独键索引带来的额外空间开销。

$操作符与索引嵌套文档

前面我们有学习mongoDB自带的一些查询操作符,但是需要知道的是,有部分操作符是无法利用索引机制的,会导致在大数据量下查询缓慢,同时也是我们不推荐且不常用的操作符。

无法利用索引的操作符

$where$exists操作符,我们用来查询和检查一个键是否存在,假设文档中有一个属性X,我们来查询不存在X键的文档,一般写法如下:

{"x":{"$exists":false}}

但是在索引中,不存在的字段和null的方式存储的方式是一样的,必须遍历所有的文档,检查在该文档中是否真的存在或者为null,如果是稀疏索引,使用这类操作符直接会导致报错。除此之外,我们有时候也会使用$not$nin操作符来取反,而取反操作符在mongoDB中效率比较低,理论上说$ne操作符查询,还是有可能会触发索引的,但是因为我们往往需要查看所有的索引里的数据,导致很多时候索引根本不会被利用。例如下面的查询语句:

db.set.find({"count":{"$ne":3}});

这个查询如果换成普通的查询,即:

db.set.find({"count":{"$gt":3,"$lt":3}})

会查找所有的大于3和小于3的索引数据,如果查询的第一个条件能过滤的数据比较多,这个时候还是会触发索引,相对来说还是比较有效的,但是如果数据很少,那么这个时候往往不会再去触发索引机制了,而$nin操作符则基本上不会触发到设置的索引了。

范围查询/or查询

除了无法利用索引机制的操作符以外,我们来看一组常见的可以利用索引机制的查询--范围查询和or查询,假设我们现在需要查询count大于7,以及count小于15的文档数据,这个时候我们往往会利用count键的索引进行快速查询,但是我们需要知道是,如果大于7筛选掉的数据比小于15筛选掉的数据集更大,我们将大于7放在查询条件前部和放在后部,查询效率上能差很多,这也是我们推荐,尽量把筛选数据更多的键放在条件前部的原因。但我们需要注意的是,如果存在多个索引的情况下,mongoDB并不会和mysql等数据库一样,只要按照顺序的键都存在索引,可以连续触发索引,在mongoDB中正常的查询,如果存在多个键都有索引的情况下,mongoDB会根据执行计划,分析较优的索引,选择该索引进行数据查询优化!但是我们会发现$or操作符是个例外,使用$or操作符进行执行计划查看,会发现$or前后的键都可以触发索引,但是需要注意的是$or操作符实际上是把or前后的条件拆开,分别进行一次索引查询进行数据过滤,最后再将多次查询的结果合并在一起,将重复的数据和不符合的数据进行剔除。了解了$or操作符的原理后,我们也能想到,这样的查询机制肯定会比单个索引查询来的更慢,因此在利用$or操作符的场景下,我们可以尽可能使用例如$in操作符来避免多次索引查询,尽可能提升查询的效率。

嵌套文档/嵌套数组

mongoDB允许深入文档内部,对嵌套字段和嵌套数组上建立对应的索引,例如有如下的文档:

{
"username" : "sid",
"lock" : {
"ip" : "117.89.135.01",
"city" : "nanjing",
"state" : "NY"
}
}

我们现在给lock属性上某个字段,例如city字段设置索引,以便于我们查询的时候进行优化:

db.userInfo.ensureIndex({"lock.city" : 1})

不过需要注意的一点是,在嵌套文档内部建立索引和在文档的键设置索引是完全不同的,对嵌套文档建立的索引,只有在查询到嵌套文档层才会触发索引,例如:

db.userInfo.find({"lock":{"city":"nanjing"}})

而嵌套数组也可以建立索引,与之不同的是嵌套数组的索引是建立在每个元素的对应字段上的,以我们的set集合为例,其中有一个ip_array字段,这个字段里面存放了每个访问ip的信息,现在我们给其中的ip字段设置索引:

db.set.ensureIndex({"ip_array.ip" : 1})

另外嵌套数组的索引,每个元素都会标记一个索引字段,因此实际上数组有多少条数据,就会在索引中存在多少个条目,这样会导致维护数组的时候成本比一般的索引要高的多,每一次的插入,修改都会重新维护索引的信息和条目顺序。并且,一个文档的单个索引中最多存在一个数组字段,为了避免在多键索引中索引条目爆炸性增长,每一对可能性的元素都会被索引,因此会导致假设文档有n条数据,而每个文档中的数组会存放m个元素,因此一个集合中索引条目的实际数量是:nm 个,而不是文档数据的n条,因此在一个索引中,最多存在一个数组索引*。

索引原则和散列基数

创建索引的一个关键性原则是索引键的不同值的数量和比例,比如,我们有一个集合,存储的是用户的信息,如果我们将gender字段设置索引列,因为性别可能只有两种,如果用户比较均匀的话,可能会导致散列基数接近50%,如果性别分布不均匀,男性或者女性较多,这样就导致某一性别的用户的散列基数低于40%,而通常一个字段上的散列基数越高,说明不一样的数据越多,而索引就能过滤更多的数据条件,效率也就会越高。因此我们在设计索引键的时候,还需要考虑一下散列基数的问题,尽量在基数较大的字段设计索引

索引类型

在创建索引的时候可以指定一些选项,使用不同选项建立的索引会有不同的行为。其中常见的几种索引类型如下:

唯一索引

唯一索引可以确保集合的每一个文档的指定键都有唯一值。如果想要保证整个文档中的url的值一定是不同的,那么就可以给url创建一个唯一索引,如下:

db.set.ensureIndex({"url": 1}, {unique: true});

接着我们尝试插入两个url一样的数据,会发现mongo报了如下的错误:

db.set.insert({ "url" : "www.baidu.com", "count" : 5, "update_time" : "2020-08-13 12:00:00", "ip_array":[{ "ip":"192.168.1.3"}, {"ip":"192.168.1.4"}]});
//结果
> db.set.insert({ "url" : "www.baidu.com", "count" : 5, "update_time" : "2020-08-13 12:00:00", "ip_array" : [ { "ip" : "192.168.1.3" }, { "ip" : "192.168.1.4" } ] })
WriteResult({
        "nInserted" : 0,
        "writeError" : {
                "code" : 11000,
                "errmsg" : "E11000 duplicate key error collection: set.set index: url_1 dup key: { url: \"www.baidu.com\" }"
        }
})

这个时候我们会发现url为www.baidu.com的数据只有第一次插入成功,除了我们自定义设置的唯一索引键以外,还有一个默认的唯一索引键,我想大概猜到了--_id键索引!没错,__id是mongo中默认给每个集合文档设置的索引键,并且这个索引是无法被删除的。

复合唯一索引

除了唯一索引以外,也可以创建复合的唯一索引。创建复合唯一索引时,单个键的值可以相同,但所有键的组合值必须是唯一的。假设现在我们有一个url和count联合创建的索引,如下:

db.set.ensureIndex({"url": 1,"count":1}, {unique: true});

我们再次插入上述的数据:

db.set.insert({ "url" : "www.baidu.com", "count" : 5, "update_time" : "2020-08-13 12:00:00", "ip_array":[{ "ip":"192.168.1.3"}, {"ip":"192.168.1.4"}]});

这个时候发现能够插入成功,因为虽然url存在一样的数据,但是count不一样,这个时候复合唯一索引就不会管控插入行为,但是我们再次插入一条一样的数据,就会发现报了E11000 duplicate key error collection: set.set index 错误。

如果在创建唯一索引的过程中,发现创建失败,因为该集合的文档中可能已经存在键相同的重复数据了,那么这个时候我们需要先把重复数据清理以后再次创建唯一索引,但是我们在很多情况下,查找所有的重复数据,并且清理一部分是很困难或者是很耗时的一件事,有木有什么办法可以直接帮我们去重,并且建立索引呢?这个时候我们就需要使用dropDups参数了,启用该参数会强制创建唯一索引,并且如果唯一索引键遇到重复数据,会保留第一条数据。其他的数据都会被删除,但是这里我们需要注意,删除了哪些数据我们无法控制,因此如果数据比较重要,千万不要使用dropDups强制创建唯一索引。

稀疏索引

前面我们说过唯一索引,会保整个集合中指定键的值不会重复,其中也包括不存在这个键的数据,以及null的数据,这类数据也是只能存在一条,因此当我们存入的数据,不确定唯一索引的键数据是否一定存在的时候,再次插入不存在或者null的数据,会导致插入失败,这个时候我们可能想要唯一索引只对包含相应键的文档生效。如果有一个可能存在也可能不存在的字段,但是当它存在时,它必须是唯一的,这时就可以将unique和sparse选项组合在一起使用,用于创建稀疏索引。

当然,熟悉mysql等关系型数据库的知道在mysql中也存在稀疏索引的说法,不过mongo的稀疏索引和mysql完全不是一个概念,mongoDB中的稀疏索引只是不需要将每个文档都作为索引条目

没有稀疏索引前,我们针对url字段进行查询:

db.set.find({"count":{"$ne":2}})

返回结果为,可以看到其中没有url字段的数据也被查询出来了:

{ "_id" : ObjectId("5f5e6a73cf72b68c1d21c471"), "url" : "www.baidu.com", "count" : 7, "update_time" : "2020-08-13 12:00:00", "ip_array" : [ { "ip" : "192.168.1.3" }, { "ip" : "192.168.1.4" } ] }
{ "_id" : ObjectId("5f91d6f1fba71470f3d5b2a8"),"update_time" : "2020-08-13 12:00:00", "ip_array" : [ { "ip" : "192.168.1.3" }, { "ip" : "192.168.1.4" } ] }
{ "_id" : ObjectId("5f91d701fba71470f3d5b2a9"), "update_time" : "2020-08-13 12:00:00", "ip_array" : [ { "ip" : "192.168.1.3" }, { "ip" : "192.168.1.4" } ] }

这个时候我们来给url字段设置稀疏索引:

db.set.ensureIndex({"url":1},{"sparse":true})

当我们再次去查询url不存在的数据的时候,可以看到已经将没有url字段的数据排除在外了:

{ "_id" : ObjectId("5f5e6a73cf72b68c1d21c471"), "url" : "www.baidu.com", "count" : 7, "update_time" : "2020-08-13 12:00:00", "ip_array" : [ { "ip" : "192.168.1.3" }, { "ip" : "192.168.1.4" } ] }

唯一稀疏索引

有时候我们需要的场景比较特殊,即,需要某个字段可以不存在,但是要求存在的话,这个字段的值是不允许重复的,如果是使用唯一性索引,那么这个字段必须存在,否则null的情况只能有一条,但是如果使用稀疏索引的话,无法保证唯一性,这个时候我们就可以选择将两个索引合并设计,即唯一性稀疏索引

db.set.ensureIndex({"count": 1}, {"unique": true,"sparse":true});

这个时候我们再去执行查询,会发现如果针对count字段查询,会自动将没有count字段的数据过滤,而我们插入数据的时候,如果count字段存在的话,会校验唯一性,仅允许插入一条count值不存在的数据

索引管理

在mongodb中,每个集合中同样的索引只能建立一次,重复创建虽然也会提示ok,但是也会提示在集合中已经全部存在,并且需要注意的是,所有的索引信息都保存在system.indexes集合中,这是个系统保留集合,不可以进行任何文档新增和删除操作,只能通过
ensureIndex或者dropIndexes/getIndexes对其进行操作。

查看集合的索引信息

当我们创建了索引以后,如果我们想要查看当前集合中存在哪些索引,我们可以使用getIndexes函数查看:

db.set.getIndexes();
//输出
[
        {
                "v" : 2,
                "key" : {
                        "_id" : 1
                },
                "name" : "_id_",
                "ns" : "set.set"
        },
        {
                "v" : 2,
                "key" : {
                        "url" : 1
                },
                "name" : "url_1",
                "ns" : "set.set",
                "sparse" : true
        }
]

这里有几个比较关键的字段,key字段代表是哪些列一起组合设置的索引,name代表是索引的名称,v代表当前索引的版本,对索引进行改动修改等都会修改v的值,ns代表是当前索引是哪个db中的哪个集合中创建的,而sparse字段为true,则代表当前的索引是稀疏索引。

除了查看详情以外,我们有时候需要知道当前索引的大小,这个时候就可以使用totalIndexSize函数来查看索引大小:

//不指定参数或者传递''查询整个集合的索引大小
 db.set.totalIndexSize();
 //输出
 77824
 //随便指定任何值,或者{}进行查询,会列出来当前集合中每个索引的大小以及总大小
 db.set.totalIndexSize({})
 //输出
 _id_    36864
 url_1   20480
 url_sort        20480
 77824

指定索引名称

前面我们每次创建索引的时候都是指定了索引的策略,而我们查看了索引详情知道每个索引都有一个唯一的名称,事实上我们不指定索引名称的情况下,mongoDB有默认的索引名称规则,即为:

key_name1_dir1_keyname2_dir2_...

其中key_name代表每个索引列的名称,而dir则代表当前列的索引方向,1和-1,如果我们创建的索引有多个索引列的情况下,这个默认的命名会比较长,不过我们可以在创建索引的时候指定名称,例如:

db.set.ensureIndex({"count":1},{"name":"count_desc"});

修改/删除索引

随着文档结构的变更,以及数据量的积累,我们的数据查询方式或者条件可能会随着产生变化,这个时候我们可能需要重构新的索引,这个时候我们可以选择的做法是将原来的索引删除以后,重新建立索引,而删除索引有两种方式,第一种是根据name进行删除,还有一种是将整个集合的索引除了_id以外全部删除:

//根据name删除
db.set.dropIndex("url_1");
//删除当前集合全部索引
db.set.dropIndexes();

当我们删除索引以后可以再次创建对应的索引,但是由于数据集变大,创建索引的时候往往需要较长的时间,这个过程会阻塞,对我们使用影响较大,这个时候我们可以在创建索引的时候指定background选项,这样就会在后台默默创建索引,不会阻塞当前业务的执行,如果遇到数据库操作的时候,会先处理操作再去继续创建索引,但是这种创建索引的方式比起直接阻塞创建索引会导致性能下降,而且创建索引的时间也会变得很长,例如:

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

推荐阅读更多精彩内容