Mongo原理与实践

2,310 阅读13分钟

基础概念

基本介绍与版本迁移

MongoDB 是目前主流的 NoSQL 数据库之一,由 C++ 语言编写,与关系型数据库和其它的 NoSQL 不同,MongoDB 使用了面向文档的数据存储方式,将数据以类似 JSON 的方式存储在磁盘上

什么是 MongoDB 一个以 JSON 为数据模型的文档数据库。
为什么叫文档数据库?文档来自于“JSON Document”,并非我们一般理解的 PDF,WORD 文档。
谁开发 MongDB?上市公司 MongoDB Inc. ,总部位于美国纽约。
主要用途应用数据库,类似于 Oracle, MySQL 海量数据处理,数据平台。
主要特点建模为可选 JSON 数据模型比较适合开发者 横向扩展可以支撑很大数据量和并发
MongoDB 是免费的吗?MongoDB 有两个发布版本:社区版和企业版。 社区版是基于 SSPL,一种和 AGPL 基本类似的开源协议 。 企业版是基于商业协议,需付费使用。

版本迁移

NoSQL与RDBMS的区别

MongoDB 介绍:从存储引擎到分布式架构

MongoDBRDBMS
数据模型文档模型关系模型
数据库类型OLTPOLTP
CRUD 操作MQL/SQLSQL
高可用复制集集群模式
横向扩展能力通过原生分片完善支持数据分区或者应用侵入式
索引支持B+树、全文索引、地理位置索引、多键(multikey) 索引、TTL 索引B树/B+树
开发难度容易困难
数据容量没有理论上限千万、亿
扩展方式垂直扩展+水平扩展垂直扩展
  • NoSQL是非关系型数据库,相比传统的RDBMS(Relational Database Management System)只能支持高度结构化的数据(预先定义表结构),NoSQL支持的数据结构更加灵活(几乎不需要预先定义数据结构),因此节省了一些时间和空间上的开销;
  • RDB支持结构化查询语言,支持复杂的查询功能和表关联。NoSQL只能进行简单的查询
  • NoSQL中不需要维护数据之间的关系,不支持join操作,仅追求最终一致性,因此更加容易水平扩展。而RDB的很多操作(比如事务)还是依赖于单机系统,保证强一致性,因此不容易水平扩展

  • 由于NoSQL非常利于分布式部署,因此它的特点是能支持大数据量(TB级)、高可用(CAP理论)

概念对比

通过将MongoDB与RDBMS中的概念进行对比,更有利于理解Mongo的一些概念:

  • Collection:相当于MySQL中的表的概念,Mongo中叫做集合
  • Document:MySQL中相当于一条记录,Mongo中叫做一个文档
  • Field:Mongo文档中的字段,相当于MySQL中的一个Column(字段)

BSON 数据格式

MongoDB中的文档是以bson的格式进行存储的,BSON(Binary JSON)是一种类似json的数据格式,但是其支持了更多的数据类型

除此之外,BSON相比JSON还具有更快的 遍历 速度:在JSON中,要跳过一个文档进行数据读取,需要对此文档进行扫描才行,需要进行麻烦的数据结构匹配,比如括号的匹配,而BSON对JSON的一大改进就是,它会将JSON的每一个元素的长度存在元素的头部,这样你只需要读取到元素长度就能直接seek到指定的点上进行读取了。

_id主键

MongoDB会为每一个插入的文档都默认添加一个_id字段,相当于这条记录的主键,用于保证每条记录的唯一性。由于MongoDB是适用于分布式场景的,因此这个全局唯一的_id需要使用一种分布式唯一ID的生成算法。

MongoDB采用的是类似Snowflake的分布式ID生成算法:

其中包括:

  • 4-byte Unix 时间戳
  • 3-byte 机器 ID
  • 2-byte 进程 ID
  • 3-byte 计数器(初始化随机)

WiredTiger 存储引擎

与MySQL类似,MongoDB底层也使用了『可插拔』的存储引擎以满足用户的不同需要。最新版本默认的存储引擎是「Wired Tiger」

看这个很全:WiredTiger介绍.pptx

事务开发

参考资料:

MongoDB 事务的原子性 docs.mongodb.com/manual/core… MongoDB 事务的隔离级别和一致性模型 docs.mongodb.com/manual/core… 如果英文不太感冒,在 MongoDB 中文网站上有不少内容,比如分片相关的: www.mongoing.com/?s= 分片集群

写操作事务

writeConcern

writeConcern 决定一个写操作落到多少个节点上才算成功。writeConcern 的取值包括:

  • 0:发起写操作,不关心是否成功;
  • 1~集群最大数据节点数:写操作需要被复制到指定节点数才算成功;
  • majority:写操作需要被复制到大多数节点上才算成功。

发起写操作的程序将阻塞到写操作到达指定的节点数为止

默认行为

3 节点复制集不作任何特别设定(默认值):

w: “majority”

大多数节点确认模式

w: “all”

全部节点确认模式

j:true

writeConcern 可以决定写操作到达多少个节点才算成功,journal 则定义如何才算成 功。取值包括:

  • true: 写操作落到 journal 文件中才算成功;
  • false: 写操作到达内存即算作成功。

注意事项

  • 虽然多于半数的 writeConcern 都是安全的,但通常只会设置 majority,因为这是 等待写入延迟时间最短的选择;
  • 不要设置 writeConcern 等于总节点数,因为一旦有一个节点故障,所有写操作都 将失败;
  • writeConcern 虽然会增加写操作延迟时间,但并不会显著增加集群压力,因此无论 是否等待,写操作最终都会复制到所有节点上。设置 writeConcern 只是让写操作 等待复制后再返回而已;
  • 应对重要数据应用 {w: “majority”},普通数据可以应用 {w: 1} 以确保最佳性能。

读操作事务

在读取数据的过程中我们需要关注以下两个问题:

  • 从哪里读?由 readPreference 来解决
  • 什么样的数据可以读?由 readConcern 来解决

readPreference

readPreference 决定使用哪一个节点来满足 正在发起的读请求。可选值包括:

  • primary: 只选择主节点;
  • primaryPreferred:优先选择主节点,如 果不可用则选择从节点;
  • secondary:只选择从节点;
  • secondaryPreferred:优先选择从节点, 如果从节点不可用则选择主节点;
  • nearest:选择最近的节点;

场景举例

  • 用户下订单后马上将用户转到订单详情页——primary/primaryPreferred。因为此 时从节点可能还没复制到新订单;
  • 用户查询自己下过的订单——secondary/secondaryPreferred。查询历史订单对 时效性通常没有太高要求;
  • 生成报表——secondary。报表对时效性要求不高,但资源需求大,可以在从节点 单独处理,避免对线上用户造成影响;
  • 将用户上传的图片分发到全世界,让各地用户能够就近读取——nearest。每个地区 的应用选择最近的节点读取数据。

readPreference 与 Tag

readPreference 只能控制使用一类节点。Tag 则可以将节点选择控制 到一个或几个节点。考虑以下场景:

  • 一个 5 个节点的复制集;
  • 3 个节点硬件较好,专用于服务线上客户;
  • 2 个节点硬件较差,专用于生成报表; 可以使用 Tag 来达到这样的控制目的:
  • 为 3 个较好的节点打上 {purpose: "online"};
  • 为 2 个较差的节点打上 {purpose: "analyse"};
  • 在线应用读取时指定 online,报表读取时指定 reporting。

注意事项

  • 指定 readPreference 时也应注意高可用问题。例如将 readPreference 指定 primary,则发生 故障转移不存在 primary 期间将没有节点可读。如果业务允许,则应选择 primaryPreferred;

  • 使用 Tag 时也会遇到同样的问题,如果只有一个节点拥有一个特定 Tag,则在这个节点失效时 将无节点可读。这在有时候是期望的结果,有时候不是。例如:

    • 如果报表使用的节点失效,即使不生成报表,通常也不希望将报表负载转移到其他节点上,此时只有一个 节点有报表 Tag 是合理的选择;
    • 如果线上节点失效,通常希望有替代节点,所以应该保持多个节点有同样的 Tag;
  • Tag 有时需要与优先级、选举权综合考虑。例如做报表的节点通常不会希望它成为主节点,则 优先级应为 0。

readConcern

在 readPreference 选择了指定的节点后,readConcern 决定这个节点上的数据哪些 是可读的,类似于关系数据库的隔离级别。可选值包括:

  • available:读取所有可用的数据;
  • local:读取所有可用且属于当前分片的数据;
  • majority:读取在大多数节点上提交完成的数据;
  • linearizable:可线性化读取文档;
  • snapshot:读取最近快照中的数据;

readConcern: local 和 available

在复制集中 local 和 available 是没有区别的。两者的区别主要体现在分片集上。考虑以下场景:

  • 一个 chunk x 正在从 shard1 向 shard2 迁移;

  • 整个迁移过程中 chunk x 中的部分数据会在 shard1 和 shard2 中同时存在,但源分片 shard1仍然是 chunk x 的负责方:

    • 所有对 chunk x 的读写操作仍然进入 shard1;
    • config 中记录的信息 chunk x 仍然属于 shard1;
  • 此时如果读 shard2,则会体现出 local 和 available 的区别:

    • local:只取应该由 shard2 负责的数据(不包括 x);
    • available:shard2 上有什么就读什么(包括 x);

注意事项:

  • 虽然看上去总是应该选择 local,但毕竟对结果集进行过滤会造成额外消耗。在一些 无关紧要的场景(例如统计)下,也可以考虑 available;
  • MongoDB <=3.6 不支持对从节点使用 {readConcern: "local"};
  • 从主节点读取数据时默认 readConcern 是 local,从从节点读取数据时默认 readConcern 是 available(向前兼容原因)。

readConcern: majority

考虑 t3 时刻的 Secondary1,此时:

  • 对于要求 majority 的读操作,它将返回 x=0;
  • 对于不要求 majority 的读操作,它将返回 x=1;

如何实现?

节点上维护多个 x 版本,MVCC 机制

MongoDB 通过维护多个快照来连接不同的版本:

  • 每个被大多数节点确认过的版本都将是一个快照;
  • 快照持续到没有人使用为止才被删除;

majority 与脏读

MongoDB 中的回滚:

  • 写操作到达大多数节点之前都是不安全的,一旦主节点崩溃,而从节还没复制到该 次操作,刚才的写操作就丢失了;
  • 把一次写操作视为一个事务,从事务的角度,可以认为事务被回滚了。

所以从分布式系统的角度来看,事务的提交被提升到了分布式集群的多个节点级别的 “提交”,而不再是单个节点上的“提交”。 在可能发生回滚的前提下考虑脏读问题:

  • 如果在一次写操作到达大多数节点前读取了这个写操作,然后因为系统故障该操作 回滚了,则发生了脏读问题;

使用 {readConcern: “majority”} 可以有效避免脏读

如何实现安全的读写分离

readConcern 主要关注读的隔离性, ACID 中的 Isolation, 但是是分布式数据库里特有的概念

readCocnern: majority 对应于事务中隔离级别中的哪一级?

  • Read Uncommited
  • Read Committed
  • Repeatable
  • Read Seriazable

readConcern: linearizable

【分享】线性一致性读

只读取大多数节点确认过的数据。和 majority 最大差别是保证绝对的操作线性顺序 ,即,在写操作自然时间后面的发生的读,一定可以读到之前的写

  • 只对读取单个文档时有效;
  • 可能导致非常慢的读,因此总是建议配合使用 maxTimeMS;

readConcern: snapshot

{readConcern: “snapshot”} 只在多文档事务中生效。将一个事务的 readConcern 设置为 snapshot,将保证在事务中的读:

  • 不出现脏读;
  • 不出现不可重复读;
  • 不出现幻读。

因为所有的读都将使用同一个快照,直到事务提交为止该快照才被释放。

多文档事务

MongoDB 虽然已经在 4.2 开始全面支持了多文档事务,但并不代表大家应该毫无节制地使用它。相反,对事务的使用原则应该是:能不用尽量不用。

为什么?事务 = 锁,节点协调,额外开销,性能影响。

事务属性支持程度
Atomocity 原子性单表单文档 : 1.x 就支持 复制集多表多行:4.0 复制集 分片集群多表多行 4.2
Consistency 一致性 (分布式数据库里,更多指的是数据在节点之间的一致)writeConcern, readConcern (3.2)
Isolation 隔离性 (不会出现脏读等,把一些还没提交的数据隔离出来)readConcern (3.2)
Durability 持久性Journal and Replication

事务的隔离级别

  • 事务完成前,事务外的操作对该事务所做的修改不可访问
  • 如果事务内使用 {readConcern: “snapshot”},则可以达到可重复读 Repeatable Read

事务写机制

MongoDB 的事务错误处理机制不同于关系数据库:

  • 当一个事务开始后,如果事务要修改的文档在事务外部被修改过,则事务修改这个 文档时会触发 Abort 错误,因为此时的修改冲突了;
  • 这种情况下,只需要简单地重做事务就可以了;
  • 如果一个事务已经开始修改一个文档,在事务以外尝试修改同一个文档,则事务以 外的修改会等待事务完成才能继续进行(write-wait.md实验)。

注意事项

  • 可以实现和关系型数据库类似的事务场景
  • 必须使用与 MongoDB 4.2 兼容的驱动;
  • 事务默认必须在 60 秒(可调)内完成,否则将被取消;
  • 涉及事务的分片不能使用仲裁节点;
  • 事务会影响 chunk 迁移效率。正在迁移的 chunk 也可能造成事务提交失败(重试即可);
  • 多文档事务中的读操作必须使用主节点读;
  • readConcern 只应该在事务级别设置,不能设置在每次读写操作上。

Change Stream

分片集群

复制集

复制集的作用

  • MongoDB 复制集的主要意义在于实现服务高可用

  • 它的现实依赖于两个方面的功能:

    • 数据写入时将数据迅速复制到另一个独立节点上
    • 在接受写入的节点发生故障时自动选举出一个新的替代节点
  • 在实现高可用的同时,复制集实现了其他几个附加作用:

    • 数据分发:将数据从一个区域复制到另一个区域,减少另一个区域的读延迟
    • 读写分离:不同类型的压力分别在不同的节点上执行
    • 异地容灾:在数据中心故障时候快速切换到异地

典型复制集结构

一个典型的复制集由3个以上具有投票权的节点组成,包括:

  • 一个主节点(PRIMARY):接受写入操作和选举时投票
  • 两个(或多个)从节点(SECONDARY):复制主节点上的新数据和选举时投票
  • 不推荐使用 Arbiter(投票节点)

数据是如何复制的?

  • 当一个修改操作,无论是插入、更新或删除,到达主节点时,它对数据的操作将被记录下来(经过一些必要的转换),这些记录称为 oplog。
  • 从节点通过在主节点上打开一个 tailable 游标不断获取新进入主节点的 oplog,并 在自己的数据上回放,以此保持跟主节点的数据一致。

分片集群机制及原理

mongoDB 常见部署架构

mongoDB分片最多可以分1024片。

完整的 分片 集群

路由节点 mongos

  • 提供集群单一入口
  • 转发应用端请求(理论上应用端只需要对接到一个mongos就可以了,其他mongos作为高可用存在)
  • 选择合适数据节点进行读写
  • 合并多个数据节点的返回
  • 无状态,建议至少2个

配置节点 mongod

  • 配置(目录)节点
  • 提供集群元数据存储,分片数据分布的映射

  • 普通复制集架构(有奇数个节点)

数据节点 mongod

  • 以复制集为单位
  • 横向扩展 ,最大1024分片
  • 分片之间数据不重复,所有分片在一起才可完整工作

MongoDB ****分片 集群特点

  • 应用全透明,无特殊处理
  • 数据自动均衡
  • 动态扩容,无须下线
  • 提供三种分片方式

分片 集群数据分布方式

  1. 基于范围

ProsCons
片键范围查询性能好数据分布可能不均匀
优化读容易有热点
  1. 基于哈希

ProsCons
数据分布均匀,写优化范围查询效率低
适用:日志,物联网等高并发场景
  1. 自定义Zone

分片集群设计

合理的架构?

分片 大小

分片的基本标准:

  • 关于数据:数据量不超过3TB,尽可能保持在2TB一个片;
  • 关于索引:常用索引必须容纳进内存;

按照以上标准初步确定分片后,还需要考虑业务压力,随着压力增大,CPU、RAM、 磁盘中的任何一项出现瓶颈时,都可以通过添加更多分片来解决。

需要多少个 分片

工作集:热数据+索引

mongo会用60%的内存做缓存

0.7是因为有一定的额外开销

正确的姿势?

各种概念由小到大

  • 片键 shard key:文档中的一个字段
  • 文档 doc :包含 shard key 的一行数据
  • 块 Chunk :包含 n 个文档
  • 分片 Shard:包含 n 个 chunk
  • 集群 Cluster: 包含 n 个分片

选择合适片键

影响片键效率的主要因素:

  • 取值基数(Cardinality)
  • 取值分布
  • 分散写,集中读
  • 被尽可能多的业务场景用到
  • 避免单调递增或递减的片键
  1. 选择基数大的片键

对于小基数的片键:

  • 因为备选值有限,那么块的总数量就有限;
  • 随着数据增多,块的大小会越来越大;
  • 太大的块,会导致水平扩展时移动块会非常困难;

例如:存储一个高中的师生数据,以年龄(假设年龄范围为15~65岁)作为片键, 那么:

  • 15<=年龄<=65,且只为整数
  • 最多只会有51个 chunk

结论:取值基数要大!

  1. 选择分布均匀的片键

对于分布不均匀的片键:

  • 造成某些块的数据量急剧增大
  • 这些块压力随之增大
  • 数据均衡以 chunk 为单位,所以系统无能为力

例如:存储一个学校的师生数据,以年龄(假设年龄范围为15~65岁)作为片键, 那么:

  • 15<=年龄<=65,且只为整数
  • 大部分人的年龄范围为15~18岁(学生)
  • 15、16、17、18四个 chunk 的数据量、访问压力远大于其他 chunk

结论:取值分布应尽可能均匀

  1. 定向性好

考虑:4个分片的集群,你希望读取某条特定的数据

如果你用片键作为条件查询,mongos可以直接定位到具体的分片

如果你不用片键,mongos需要把查询发到4个分片

等最后一个分片响应,mongos才能响应应用端。

结论:对主要查询要具有定向能力

一个 email 系统的片键例子

{
    _id: ObjectId(), 
    user: 123, 
    time: Date(), 
    subject: “...”, 
    recipients: [], 
    body: “...”, 
    attachments: []
}

备份与恢复

备份机制

MongoDB 的备份机制分为:

  • 延迟节点备份
  • 全量备份 + Oplog 增量

最常见的全量备份方式包括:

  • mongodump;
  • 复制数据文件;
  • 文件系统快照;

方案一:延迟节点备份

安全范围内的任意时间点状态 = 延迟从节点当前状态 + 定量重放 oplog

可以保护人为误操作。

主节点的 oplog 时间窗t应满足:t >= 延迟时间 + 48小时

方案二:全量备份加 oplog

  • 最近的 oplog 已经在 oplog.rs 集合中,因此可以在定期从集合中导出便得到了 oplog;
  • 如果主节点上的 oplog.rs 集合足够大,全量备份足够密集,自然也可以不用备份 oplog;
  • 只要有覆盖整个时间段的 oplog,就可以结合全量备份得到任意时间点的备份。

复制文件全量备份注意事项:

  • 必须先关闭节点才能复制,否则复制到的文件无效;
  • 也可以选择 db.fsyncLock() 锁定节点,但完成后不要忘记 db.fsyncUnlock() 解锁;
  • 可以且应该在从节点上完成;
  • 该方法实际上会暂时宕机一个从节点,所以整个过程中应注意投票节点总数。

文件系统快照注意事项:

  • MongoDB 支持使用文件系统快照直接获取数据文件在某一时刻的镜像;
  • 快照过程中可以不用停机;
  • 数据文件和 Journal 必须在同一个卷上;
  • 快照完成后请尽快复制文件并删除快照,防止对系统的负担;

Mongodump 全量备份注意事项:

  • 使用 mongodump 备份最灵活,但速度上也是最慢的;
  • mongodump 出来的数据不能表示某个个时间点,只是某个时间段

A在t3还是10

解决方案:幂等性

oplog的幂等性可以解决一致性问题

分片 集备份

分片集备份大致与复制集原理相同,不过存在以下差异:

  • 应分别为每个片和 config 备份;
  • 分片集备份不仅要考虑一个分片内的一致性问题,还要考虑分片间的一致性问题,因此每 个片要能够恢复到同一个时间点;

分片集的增量备份:

  • 尽管理论上我们可以使用与复制集同样的方式来为分片集完成增量备份,但实际上 分片集的情况更加复杂。这种复杂性来自两个方面:

    • 各个数据节点的时间不一致:每个数据节点很难完全恢复到一个真正的一致时间点上,通 常只能做到大致一致,而这种大致一致通常足够好,除了以下情况;
    • 分片间的数据迁移:当一部分数据从一个片迁移到另一个片时,最终数据到底在哪里取决于config 中的元数据。如果元数据与数据节点之间的时间差异正好导致数据实际已经迁移到新分片上,而元数据仍然认为数据在旧分片上,就会导致数据丢失情况发生。虽然这种情况发生的概率很小,但仍有可能导致问题。
  • 要避免上述问题的发生,只有定期停止均衡器;只有在均衡器停止期间,增量恢复 才能保证正确。

索引机制

术语

  • Index / Key

索引/键/数据页?

  • Covered Query

如果所有需要的字段都在索引中,不需要额外的字段,就可 以不再需要从数据页加载数据,这就是查询覆盖。

  • IXSCAN/COLLSCAN 索引扫描/集合扫描

  • Query Shape

查询用到了哪些字段。

  • Index Prefix 索引前缀

  • Selectivity

在一个有10000条记录的集合中:

满足 gender= F 的记录有4000 条

满足 city=LA 的记录有 100 条

满足 ln=‘parker’ 的记录有 10 条

条件 ln 能过滤掉最多的数据,city 其次,gender 最弱。所以 ln 的过 滤性(selectivity)大于 city 大于 gender。

如果要查询同时满足: gender == F && city == SZ && ln == ‘parker’ 的记录,但只允许为 gender/city/ln 中的一个建立索引,应该把索引放在哪里?ln

B+树结构

索引背后是 B+树。要正确使用索引,必须先了解 B+树的工作原理。

B+ 树: 基于B树,但是子节点数量可以超过2个

索引执行计划

假设集合有两个索引

  1. {city: 1}
  2. {last_name:1 }

查询: db.members.find({ city: “LA”, last_name: “parker”})

问题:用哪个索引?

两个线程同时尝试两个索引 看哪个索引跑的比较快就选谁

explain()

索引类型

  • 单键索引
  • 组合索引
  • 多值索引
  • 地理位置索引
  • 全文索引
  • TTL索引
  • 部分索引
  • 哈希索引

组合索引 – Compound Index

组合索引的最佳方式: ESR原则

  • 精确(Equal)匹配的字段放最前面
  • 排序(Sort)条件放中间
  • 范围(Range)匹配的字段放最后面

同样适用: ES, ER

查询:db.members.find({ gender: “F”, age: {$gte: 18}}).sort(“join_date:1”)

索引根据ESR,这样顺序建立比较好:

{ gender: 1, join_date:1, age: 1 }

范围组合查询: 索引字段顺序的影响:

范围+排序组合查询: 索引字段顺序的影响:

地理位置索引

全文索引

部分索引

只针对符合条件的数据建立索引

其他索引技巧

后台创建索引

  • db.member.createIndex( { city: 1}, {background: true} )

对BI / 报表专用节点单独创建索引

  • 该从节点priority设为0

  • 关闭该从节点,

  • 以单机模式启动

  • 添加索引(分析用)

  • 关闭该从节点,以副本集模式启动

工作实践

分页问题

避免使用 count

尽可能不要计算总页数,特别是数据量大和查询条件不能完整命中索引时。

考虑以下场景:假设集合总共有 1000w 条数据,在没有索引的情况下考虑以下查询:

db.coll.find({x: 100}).limit(50);

db.coll.count({x: 100});

  • 前者只需要遍历前 n 条,直到找到 50 条队伍 x=100 的文档即可结束;
  • 后者需要遍历完 1000w 条找到所有符合要求的文档才能得到结果。

为了计算总页数而进行的 count() 往往是拖慢页面整体加载速度的原因

分页

避免使用skip/limit形式的分页,特别是数据量大的时候;

替代方案:使用查询条件+唯一排序条件;

例如:

第一页:db.posts.find({}).sort({_id: 1}).limit(20);

第二页:db.posts.find({_id: {$gt: <第一页最后一个_id>}}).sort({_id: 1}).limit(20);

第三页:db.posts.find({_id: {$gt: <第二页最后一个_id>}}).sort({_id: 1}).limit(20);

……

aigc 需求中的 分页 实践

原代码

func GetUserChallengeListByTime(ctx context.Context, userID string, cursor int64, count int64) (res []*Challenge, hasMore bool, err error) {
   filter := bson.M{
      Bson_UserID: userID,
   }
   if cursor > 0 {
      filter[Bson_UpdatedAt] = bson.D{{"$lt", time.Unix(cursor, 0)}}
   }
   totalCount, err := mongo.Count(ctx, mongo.DB, GetCollectionName(), filter)
   if err != nil {
      log.Error(ctx, "err", err)
      return nil, false, lingo_err.DBErr
   }
   res = make([]*Challenge, 0)
   if err := mongo.Find(ctx, mongo.DB, GetCollectionName(), filter, &res, &options.FindOptions{
      Limit: &count,
      Sort:  bson.D{{Bson_UpdatedAt, -1}},
   }); err != nil {
      log.Error(ctx, "err", err)
      return nil, false, lingo_err.DBErr
   }
   hasMore = totalCount > int64(len(res))
   return res, hasMore, nil
}

优化后

func GetUserChallengeListByTime(ctx context.Context, userID string, cursor int64, count int64) (res []*Challenge, hasMore bool, err error) {
   // 根据ESR的复合索引原则,建立{user_id, update_at}的复合索引
   filter := bson.M{
      Bson_UserID: userID,
   }
   if cursor > 0 {
      filter[Bson_UpdatedAt] = bson.D{{"$lt", time.Unix(cursor, 0)}}
   }
   countPlusOne := count + 1
   resPlusOne := make([]*Challenge, 0)
   if err := mongo.Find(ctx, mongo.DB, GetCollectionName(), filter, &resPlusOne, &options.FindOptions{
      Limit: &countPlusOne,
      Sort:  bson.D{{Bson_UpdatedAt, -1}},
   }); err != nil {
      log.Error(ctx, "err", err)
      return nil, false, lingo_err.DBErr
   }
   hasMore = int64(len(resPlusOne)) == countPlusOne
   if hasMore {
      res = resPlusOne[:len(resPlusOne)-1]
   } else {
      res = resPlusOne
   }
   return res, hasMore, nil
}

优化点:

  1. 去掉用totalCount判断hasMore的逻辑,因为mongo count需要遍历所有数据,非常消耗性能。

  2. ESR的复合索引建立原则,在user_id(精准定位),update_at (sort条件)上加复合索引,省去每次排序,可以直接利用b+树索引的顺序。

参考资料

MongoDB 介绍:从存储引擎到分布式架构

WiredTiger介绍.pptx

time.geekbang.org/course/intr…