应用设计调优
1、应用范式设计
1.1、什么是范式
数据库范式概念是数据库技术的基本理论,几乎是伴随着数据库软件产品的推出而产生的。在传统关系型数据库领域,应用开发中遵循范式是最基本的要求。但随着互联网行业的发展,NoSQL开始变得非常流行,在许多的应用实践中也涌现出一些反范式的做法,当然了,这些项目中也不乏使用关系型数据库的。直到现在,对于项目中是以范式,还是反范式设计为主这点,仍然是存在争议的。
下面先看看数据库三范式的定义。
1.1.1、三范式的定义
(1)第一范式(1NF):数据库表的每一列都是不可分割的原子项。
见下表,所在地(address)一列就是不符合第一范式的,其中对于"广东省,深圳市"这样的字符串,实际上应该拆分为省份、城市两个字段。

第一范式要求将列尽可能分割到最小的粒度,希望消除利用某个列存储多值的行为,而且每个列都可以独立进行查询。
(2)第二范式(2NF):每个表必须有且仅有一个主键(primary key),其他属性需完全依赖于主键。这里除了主键,还定义了不允许存在对主键的部分依赖。
下表为一个订单的商品信息表,每一行代表了一个订单中的一款商品。为了满足主键原则,我们将商品ID和订单ID作为联合主键,除此之外,每一行还存放了商品的名称、价格以及商品类别。对于商品类别这个属性,我们认为其仅仅与商品ID有关,也就是仅依赖于主键的一部分,因此这是违反第二范式的。改善的做法应该是将商品类别存放于商品信息表中。

(3)第三范式(3NF):数据表中的每一列都和主键直接相关,而不能间接相关。如果在表1中同时补充了城市的信息,见下表。

很明显,这里的城市气候、人口等属性都仅仅依赖于用户所在的城市,而不是用户,所以只能算作间接的关系。因此为了不违反第三范式,只能将城市相关的属性分离到一个城市信息表中。
除了这里提到的数据库三范式,还有BCNF范式、第四范式、第五范式。而且,每一个范式都以上一个范式作为基础条件,范式的层级越高,其相应的消除冗余的程度也越高。
1.1.2、MongoDB是反范式的吗
可能有许多人会认为,MongoDB是反范式的数据库。这个说法由来已久,可惜的是,这个说法也没有特别好的理论支持。基于前面对范式理论的解读,我们来尝试做几点澄清。
首先,第一范式要求列是不可分割的,但对于MongoDB所支持的子文档、数组字段来说,很明显已经违反了约束;也有另一种观点认为,应当将子文档、数组中的不可分割的元素等同于列,这样便符合第一范式了。这两种观点都有可取之处,却很难形成定论。
其次,对于第二范式所要求的主键,MongoDB本身已经符合该条件。每个写入数据库的文档都应该包含_id主键,如果没有这个字段,那数据库会为你自动生成一个。
最后,第三范式要求消除传递依赖。这种依赖关系可能会在嵌套型文档中产生,比如在文章(article)的文档中嵌入了描述作者(author)信息的子文档,子文档中作者的昵称、性别间接依赖了文章的ID。
可以这么说,MongoDB并不完全是反范式的。我们平时谈论的范式更多是从应用设计层面出发,基于MongoDB的文档模型仍然可以设计出符合范式化的数据库表结构,但这些实际上取决于你的选择。
1.1.3、优缺点
关于范式设计和反范式设计的对比如下:
- 范式设计消除了冗余,因此需要的空间更少。而且,范式化的表更容易进行更新,有利于保证数据的一致性。但是,其缺点在于关联查询较慢,一些查询需要在数据库中执行多次查找,如果只考虑磁盘操作,则相当于增加了磁盘的随机I/O,这是比较昂贵的。
- 反范式的设计一般可以优化读取的性能,MongoDB很少会使用数据库的关联查询,因此通过嵌套式设计的方式还能减少客户端与数据库之间的调用次数(网络I/O)。此外,使用嵌入还能获得写入数据的原子性保证,即要么完全成功,要么完全失败。
事实上,可以同时混用范式和反范式的做法,相信这也是大多数应用所能考虑的最佳实践。如果采用了反范式的做法,则务必要仔细考虑冗余和数据一致性的问题。如果是数据频繁变化,或者一致性要求非常高的场景,则建议用范式设计。如果是读多写少,而且可以接受不一致性,则可以考虑反范式设计。
还应该提到的另一点是关于数据库的演进,范式设计一般会更容易适应未来的一些变化。从管理角度看,建议将范式设计作为一种规范,而将反范式设计视作优化手段,并在适当的场景内使用。
1.2、反范式设计
下面,我们来看一个反范式设计的案例。我们设计一个电子商城的购物车,代码如下所示:
js
{
userId: "u0001",
items: [
{
goodsName: "烫染剂",
goodsId: "g0001",
price: 26,
amount: 2
},
{
goodsName: "云南白药牙膏",
goodsId: "g0002",
price: 34.5,
amount: 1
},
...
],
totalPrice: 299
}
- userId:用户ID,当前登录的用户可以根据这个找到自己的购物车文档。
- items:购物车的商品条目列表,使用内嵌的数组表示。每个商品条目如下。
items.$.goodsName:商品名称。items.$.goodsId:商品ID。items.$.price:商品价格。items.$.amount:商品数量。
- totalPrice:总体的价格。
为了简化理解,这里省去了许多细节。我们只考虑了最关键的一点:在购物车文档中嵌入商品信息。
这么做的最大好处是,用户查看购物车时只需要查询一个文档就可以了,这提升了读的效率。而购物车可能经常被查看,所以反范式获得的效益还是不错的。
但是,如果商品信息发生了变化呢?直接更新所有关联的购物车吗?不提倡这么做。我们先从两个方面来看:
- 商品名称、价格在实际场景中并不会经常变化,这远远不及在购物车中被读取的频次。
- 商品信息和购物车出现了不一致,会不会产生严重后果?答案是不会的,我们可以在购物车确认下单时再对商品信息进行校正,在最终的订单确认页面中呈现出真实的结果。当然了,对于库存不足、价格变动等因素还可以给予一些合理的提示。
2、嵌套设计
2.1、在文档内使用嵌套
尽管反范式的文档设计通常会使用嵌套设计,但并不代表两者是同一回事。正如前面所言,范式/反范式关注的是冗余问题,而嵌套则更多的是关注文档对象之间的结构化关系。通常,嵌套设计具有较强的表现力,代码如下:
js
{
name: "张三",
profile: {
gender: "male",
age: 28,
career: "软件工程师"
},
contact: {
phoneNumber: "134000001",
wechat: "niceman",
weibo: "http://www.weibo.com/niceman"
},
labels: [
"摄影达人", "技术控", "实力派"
]
}
与嵌套设计相对的则是平铺式设计,但平铺式设计会使文档内的字段显得特别繁多。例如,为了对字段做出区分,我们可能会使用很多奇怪而冗长的命名,如label1、label2、contactWeibo等。这些做法会导致后期很难维护,甚至还可能因为误用而产生一些Bug。此外,在嵌套式的文档内查找属性还会更快一些。
在关于MongoDB的设计模式中,一般鼓励使用嵌套设计,但不意味着可以滥用这种特性。初学者很容易出现的一种误区是,将大量无关的实体信息通过嵌套的方式堆砌到一个文档内。这种做法不但破坏了文档的结构合理性,也为性能的扩展和数据库的管理带来了不少麻烦。正确的做法应该是根据业务有选择性地使用嵌套。例如,用户的会话(session)是用于登录和身份识别的,将它和用户的个人资料存放到一起并不是合适的做法。这是因为两者的使用场景不同,个人资料通常很少会发生变化,而会话信息会随着频繁的登录行为发生改变,或者出现状态超时等变化。最好的选择是使用单独的集合存放这两种信息。
2.2、表达关联
如果按照引数(引用数量)来划分,那么表之间的关系一般有一对一、一对多(多对一)、多对多这几种。然而,对文档数据库而言,引数(N)的大小会影响文档的具体模式,一般常见的做法如下。
2.2.1、内嵌文档
对于少量存在包含关系的文档(one to few),可以采用完全嵌入的形式,代码如下:
js
{
name: "张三",
addresses: [
{zone: "南山区", street: "粤海街道130号", city: "深圳", province: "广东"},
{zone: "拱野区", street: "和睦街道文体中心", city: "杭州", privice: "浙江"}
]
}
嵌入设计提升了读性能,可以在查询customer时获得其相关的地址列表,而且对多个地址的更新也只需要一次性完成。但这样做的前提必须是:
- "few"指向的文档数量必须是少量的,比如<1000个。
- 整体文档的大小不能超过16MB。
- 业务上是真正的包含,且总是以"one"作为主体在上下文出现,比如我们总是先查询customer,然后查看它的地址信息。
2.2.2、内嵌引用
内嵌引用是内嵌文档的一个变种,不同点在于父文档只是记录子文档的ID字段引用,而不是全部内容,代码如下:
js
{
name: "左邻之家",
street: "湘湖路18巷9号",
//房间ID列表
rooms: [
ObjectID("A0001"),
ObjectID("A0002"),
ObjectID("A0003"),
...
]
}
{
_id: ObjectID("A0001"),
roomNumber: "3F-904",
price: 250
}
内嵌引用在查询关联文档时需要查找两次,例如先查询hotels获得rooms的ID列表,再根据room ID进行查找。如果查询的是多个房间的信息,则可以利用多值查找(使用$in操作符),这在性能表现上并不会太差。
使用内嵌引用的原因主要如下。
- 内嵌文档的体积太大,可能超出16MB的限制。
- 关联的子文档保持独立性,仅在父文档增加少量的引用,这样不需要在子文档中引入额外的索引。
2.2.3、引用模式
引用模式类似外键(没有强制的约束),一般是以文档的某个字段作为引用。表示多对一关系的代码如下所示。
js
//设备信息,多个设备属于某个产品
{
_id: ObjectID(...),
name: "Camera-001",
productId: ObjectID("P0001"),
...
}
//产品信息
{
_id: ObjectID("P0001"),
...
}
表示多对多关系的代码如下所示。
js
//分类信息
{
_id: ObjectID("C0001")
}
//商品信息
{
_id: ObjectID("G0001")
}
//商品分类关联
{
_id: ObjectID(...),
goods_id: ObjectID("G0001"),
category_id: ObjectID("C0001")
}
或许,你已经很熟悉这种方式,因为这是非常范式化的。引用模式的好处是每个表相对独立,业务处理上也更加灵活;但性能会差一些,需要客户端执行多次数据查找。选择引用模式的原因主要如下。
- 关联文档非常多(引数很大),或者关联的增长是不受控制的,不再适合使用内嵌模式。例如在新浪微博上,一条明星微博的评论数量是相当可观的,这就必须采取引用模式。
- 业务实体关系层级过于复杂。
- 多对多关系优先采用引用模式。
- 对数据一致性要求很高,需要规避冗余的场景。
3、桶模式
3.1、桶模式
桶(bucket)模式是一种常见的"聚合式"的文档设计模式。这里所提到的"桶"与哈希算法中的哈希桶在意义上十分相似。
简而言之,桶模式就是根据某个维度因子(通常是时间),将多个具有一定关系的文档聚合放到一个文档内的方式,具体实现时可以采用MongoDB内嵌文档或是数组。
桶模式非常适合用于物联网(IoT)、实时分析以及时间序列数据的场景。时间序列数据通常以时间为组织维度,并持续不断地流入系统中的一些数据,比如物联网平台所存储的传感器数据、运维系统对于虚拟机CPU、内存的监控数据等。随着时间的流逝,这些时序数据很容易达到非常大的量级。如果将这些时序数据以时间维度进行聚合存储(按时间分桶),则能达到明显的优化效果。
3.2、桶模式案例
下面我们通过一个案例来了解桶模式是如何运作的。
在一个农业自动控制系统中,对环境的温度和湿度进行监控是一项非常重要的工作。假定在自动化系统中已经部署了若干个智能传感器,每个传感器每分钟上报一次温、湿度的数据。后端数据库则负责存储这些传感器的数据,并同时提供查询和聚合分析功能。
3.2.1、传统方式
按照传统的思路,很容易设计为如下的文档结构。

每次数据上报,会执行数据写入操作,代码如下:

管理人员可以通过查询传感器上的一段时间的温、湿度来查看变化,代码如下:

为了加快查询速度,该集合需要建立sensor_id和timestamp的组合索引,代码如下:

这种传统的方式在读写上比较简单,但问题在于,随时间变化,写入集合的文档数量会变得非常多。以100个传感器为例,仅1个月后sensor_data文档的数量将会达到432万条,如果是10000个传感器则将超过4亿条。
3.2.2、按时间分桶
继续对本系统的需求进行分析,则不难发现:
- 传感器单次上报的值显得并不是很重要,我们其实更关注数据的均值表现以及一段时间内的趋势变化。
- 假定前端系统已经完成了重复值、异常值的检查,则可以认为到达数据库的数值已经经过了修整。
在最终的数据呈现上,则可以按照校准后的时间刻度来反馈这种趋势。基于这个思路,我们采取按天、按小时的分桶方式:
- 以1天为单位,每小时被表示为"0,1,2...23",一共24个刻度。
- 以1小时为单位,每分钟被表示为"0,1,2...59",一共60个刻度。
最终的文档模型如下:


这里的timestamp经过了时区转换,显示为UTC时间。这样,一个传感器在1天内的数据被"压缩"到了一个文档内,因此查询某一天的数据变得更加简单,代码如下:

如果希望查询一天内某个时刻的数据,则可以直接按子文档的刻度进行过滤,代码如下:

对于传感器数据的写入,则变成了一个文档内的更新,代码如下:

3.2.3、预聚合
由于我们已经将一天内多个时刻的值存储在一个文档上,此时对数据的读写方式也发生了一些变化。如果希望进行某些时段的聚合操作,比如求取15:00~16:00的平均温度,有多种方式可以达到该目的:
- 将文档中的时段数据查询出来,由应用进行逐个统计。
- 使用聚合框架,利用objectToArray、avg等操作符实现计算。
- 使用预聚合的方式,提前写入预计算的结果。
前两种方式都需要在查询时进行计算,而预聚合则是采用事先计算的方式,将计算好的结果预写到文档中。比如,在更新温度值时,写入额外的字段,代码如下:

通过预先写入sum_temperature、cnt_temperature,应用在查询时便很容易计算出每小时内的平均值,代码如下:

预聚合方式非常适合统计需求的确定性较高的场景,此时数据只需要计算一遍就可以满足后续的多次查询。
3.2.4、优化对比
为了更直观地看出桶模式与传统模式的区别,我们以上面的两种数据模型作为样板,分别模拟100个传感器在1个月内产生的数据。
代码一:传统模式(不分桶),写入1个月的数据。


代码二:按时间分桶,写入1个月的数据。


分别执行上述两段代码,对于最终生成的两个集合sensor_data、sensor_data_bucket进行对比,见表。

数据表明,经过分桶优化之后的文档数量及索引大小的缩减幅度相当可观。总结一下,使用分桶方式能获得如下好处:
- 文档高度内聚,查询操作一般只需要检索一个或少量的几个文档,可减少很多随机I/O操作。
- 占用空间小,索引存储大小大幅度缩减,大大节省了MongoDB的内存开销。
- 易于使用,基于聚合文档之上可以做一些预聚合计算,减少实时计算消耗。
除此之外,桶模式在应用上仍然需要结合场景进行设计,除了增加开发复杂度增加,还需要考虑以下因素:
- 避免文档的大小无限膨胀,一个BSON文档的大小不能超过16MB。
- 相对insert来说,update或upsert的性能有一定幅度的下降。
- 多个数据的更新都发生在一个文档中,需考虑是否存在锁竞争的问题。
4、海量数据分页
分页应该是极常见的数据呈现方式,就真实环境而言,很难有单个页面就能完全呈现的数据集。因此,无论是前端的UI组件,还是后端系统框架都对数据查询的分页提供了良好的支持。
下面是SpringData提供的分页接口:

Pageable和Page分别对应分页的传参和结果集接口,通过框架的封装,我们可以不理会各种数据库的语法差异,代码如下:

这样看来,开发一个分页的查询功能是非常简单的。但事实上是,这种易用的分页功能是有局限性的。一个最明显的问题就是,在面对日益增长的海量数据时会导致查询性能低下。
下面,我们将探讨这种海量数据场景的分页实现方法。
4.1、传统分页模式
这是最常规的方案,假设我们需要对文章(articles)这个表(集合)进行分页展示,一般前端需要传递两个参数:
- 页码(当前是第几页)。
- 页大小(每页展示的数据个数)。
按照这个做法的查询方式,如图所示。

因为希望最后创建的文章显示在前面,这里使用了_id做降序排列。其中图中的部分语句的执行计划如下:


可以看到,随着页码的增多,skip操作跳过的条目也会随之变多,而这个操作是通过cursor的迭代器来实现的,对于CPU的消耗会比较明显。而当需要查询的数据达到千万级及以上时,会发现响应时间非常长,可能会让你无法接受。
如果你的机器性能很差,那么在数十万、百万数据量时就会出现瓶颈。
4.2、使用偏移量
既然传统的分页方案会出现因翻页而产生大量数据的问题,那么能否避免呢?答案是可以的。改良的做法如下(见下图)。

- 选取一个唯一有序的关键字段作为翻页的排序字段,比如_id。
- 每次翻页时以当前页的最后一条数据_id值作为起点,将此并入查询条件中。
修改后语句的执行计划如下:

可以看到,改良后的查询操作直接避免了翻页阶段,索引命中及扫描范围也非常合理。为了对比这两种方案的性能差异,下面准备了一组测试数据。
准备10万条数据,以每页20条的参数从前往后翻页,对比总体翻页的时间消耗,代码如下:

用传统方法实现翻页,代码如下:


用偏移量方法实现翻页,代码如下:

以100、500、1000、3000页数的样本进行实测,结果如图所示。

可见,当页数越多(数据量越大)时,改良的翻页效果提升越明显。这种分页方案其实采用的是时间轴(TimeLine)模式,实际应用场景非常广,比如Twitter、微博、朋友圈动态都可采用这种方式。而且,HBase、ElastiSearch在Range Query中的实现也支持这种模式。
4.3、折中处理
时间轴(TimeLine)的模式通常是做成"加载更多"、上下翻页的形式,但无法自由地选择某个页码。那么为了实现页码分页,同时也避免传统方案带来的skip性能问题,我们可以采取一种折中的方案。这里参考Google搜索结果页作为说明,如图所示。

通常,在数据量非常大的情况下,页码也会有很多,于是可以采用页码分组的方式。以一段页码作为一组,每一组内数据的翻页采用ID偏移量+少量的翻页(skip)操作实现。具体的操作如图所示。

实现步骤
- 对页码进行分组(groupSize=8,pageSize=20),每组为8个页码。
- 码进行分组(groupSize=8,

- 分页数据查询以本页组start_offset作为起点,在有限的页码上翻页(skip)。由于一个分组的数据量通常很小(8×20=160),在分组内进行翻页产生的代价会非常小,因此性能上可以得到保证。
5、批操作
一般来说,采用批量化操作可以显著提升吞吐量。这就好比独木舟和帆船,帆船的体积更大,能载上百人,一次航行能达成独木舟百倍的效率。
对于MongoDB来说,使用批量化API的关键在于,减少了客户端和数据库之间的数据传送次数,将多个文档或是多个请求放入一次TCP传送任务往往能获得更高的效率。假定服务器不存在其他方面的瓶颈(如磁盘能力不足),那么批量化所带来的性能提升就比较明显了。
5.1、批量读
场景一:使用multi get模式
对于集合中多个_id字段的查询,可以使用$in操作符简化为一次操作。例如,使用userRelation集合来表示用户的好友关系,查找用户的好友信息时,需要先从userRelation找到好友的ID列表,再逐个从用户资料集合userProfile中获得完整的好友信息。
Java代码如下:


的确,上述代码可以正常工作,但当用户有100个好友时,上述的查询也同样需要100次数据库查询。可以使用$in操作符优化,最终只需要一次查询,代码如下:

场景二:调整batchSize的值
在客户端执行查询命令时,可以通过batchSize来指定返回结果集的批次大小。如果不指定,那么默认首次返回101。一般情况下,这个值并不需要怎么调整,这主要是因为在一般的Web应用中,所查询的结果集也不会太大。
在下面这个场景中,deviceStat集合记录了设备每小时产生的事件统计。我们设计了一个离线任务,其负责将上个月的统计数据全量查询出来,进行分类计算和转换之后,再导入新的数据仓库系统。处理的代码如下:

这里使用了MongoTemplate提供的stream方法,通过返回的Iterator接口对满足目标条件的全部文档进行遍历操作。这里我们关心的是向服务器发起查询命令的次数,假定当前系统中有10万个活跃设备,那么一个月新增的数据量会达到7200万条。如果batchSize=100,则整个过程需要执行至少72万次getMore操作。因此,我们尽可能将batchSize的值调大,以此减少查询批次的数量,代码如下:

将batchSize的值调整至10000之后,查询批数缩减为原来的百分之一,整体的执行时间也可以大大缩减。
实际上,batchSize的值应该酌情而定,在本例中其实还存在一个前提,那就是deviceStat表的记录都比较小,比如一个文档是100个字节,那么一次batch传输的数据也不足1MB,这在当前来看还是可以接受的。在大批量读取的场景中,建议明确指定合适的batchSize值,具体可根据文档大小、生产环境的网络带宽等因素来选择。
5.2、批量写
MongoDB所提供的insertMany、updateMany或者BulkAPI都属于批量写的命令。其中Bulk API是最灵活的,它 可以将同一集合中的多个不同写操作合并为一次操作。一次Bulk批操作在MongoDB服务器上仍然会被拆分为一个个的子命令,根据命令的执行顺序不同,分为以下两种。
- 有序Bulk,数据库会严格按照顺序执行每个写入操作。在数据库内部,有序批次会根据当前顺序和写类型(insert、update)进行分组,每个分组最多为1000个。如果超过1000个,则会再次拆分。数据库按序对分组进行提交,如果某一个命令执行出错,则整个批次将立即停止并返回错误。
- 无序Bulk,数据库会并发执行批次中的命令。无序批次在内部同样会进行分组(大小为1000个),但并不保证执行顺序。无论是否存在执行失败的命令,整个批次都会继续进行直到结束。最终,数据库会在返回的响应信息中包含具体的信息,包括哪些命令发生了错误。
默认的Bulk是有序的,但只要条件允许,建议尽量使用无序Bulk进行批操作。无序的处理效率更高,尤其是在分片集合中,执行有序的Bulk操作会变得非常缓慢,Mongos会将每个命令进行排队以保证有序。
使用Bulk API的操作示例如下:

其中,bulkOperations被指定为无序的批操作对象,如果执行失败,则会抛出BulkOperationException异常(该异常封装了BulkWriteException)。应用上可以捕获这个异常,做进一步的处理,代码如下:

写入性能
对于大批量的写入,Bulk的批次设置得越大,整体处理的速度就越快。下图说明了这种情况。

可以看到,随着批次大小的增加,处理时间显著缩短了,到了1000之后逐渐稳定下来。这是由于MongoDB客户端会对批次进行切割,每一批最多处理1000个命令。
对于MongoDB服务器端来说,Bulk API最多能接受10万个操作。但似乎我们不需要太关心这个约束,因为驱动始终会按1000个命令进行提交。
6、读写分离与一致性
无论何时,我们应当将数据库集群看作一个整体。由于在分布式环境中存在多种不确定性,我们可能无法确保所写入的数据是否会丢失,或者刚刚写入的数据是否能马上读取。线性的读写通常可以解决一致性问题,但无法满足高吞吐量的要求。为此,MongoDB提供了一些"弹性"的手段,可以让我们在读写一致性和性能吞吐量方面做出细粒度的权衡。
6.1、读写分离
副本集实现了数据在多个节点间的复制和实时同步,因此基本可以认为这些节点都包含了可用的数据副本。
默认情况下,数据的读写都会在主节点上进行,但这样一来主节点会承担最多的工作。在某些情况下,我们可能希望将业务的读操作指派到一些备节点上,以此来降低主节点的压力,这就是传统的读写分离模式。
读写分离的做法已经经历了大量项目的实践,同时也取得了比较好的效果。但这种方案的适用场景仍然是有限的,这体现在如下两个方面。
- 读写分离仅仅分离了读操作,对于数据的写仍然需要在主节点上进行,因此对于写操作频繁的场景并不能受益。
- 客户端从节点读取到的数据,可能并不是最新的,这是由于主备节点的数据同步存在时延导致的。
所以,读写分离方案一般用于读多写少、对数据一致性要求不是很高的场景,比如社区帖子、商品详情、文件共享系统等。
对于采用了副本集的架构来说,客户端可以选择只读写主节点,或者写主节点、读备节点的方式。下面展示一下这两种不同的读写模式。
(1)数据读写都在主节点上

(2)数据从主节点写入,从备节点读取

默认情况下,副本集采用仅读写主节点的模式,客户端可通过设置Read Preference来将读请求路由到其他节点,其中Read Preference可以有多种选择,具体如下。
- Primary:默认规则,所有读请求发送到Primary。
- PrimaryPreferred:Primary优先,如果Primary不可达,则请求Secondary。
- Secondary:所有的读请求都发送到Secondary。
- SecondaryPreferred:Secondary优先,当所有的Secondary不可达时,请求Primary。
- Nearest:读请求发送到最近的可达节点上(通过ping命令探测得出最近的节点)。
一些特殊行为
- 限制延迟读,通过设置maxStalenessSeconds参数来控制读取的延迟,一旦该节点落后主节点的时间超过该值,则放弃从该节点上读取,这个值设置必须大于90s,否则会报错,MongoDB 3.4以及以后版本支持该特性。
- 定向范围读,可以为备节点成员设定一些标签(TagSet),在读取时指定对应的TagSet(readPreferenceTags),这样读取行为会指向对应的节点。TagSet是一种灵活的成员分组机制,可以根据需要来设定,比如按计算能力,或是地理位置(多数据中心)。
6.2、读写关注
一般认为,MongoDB的读写模式是弱一致性的。在默认配置下的确如此,为了保证性能优先,应用可能允许自己读取的数据并不是最新的,或者刚刚写入的数据存在极小的丢失风险。但是,这些仅限于默认行为的讨论。如果希望获得更强的一致性保证,还可以对写关注(WriteConcern)、读关注(ReadConcern)进行调整。
6.2.1、写关注(WriteConcern)
客户端通过设置写关注(WriteConcern)来设置写入成功的规则。默认情况下WriteConcern的值为1,即数据只要写入主节点即认为成功并返回。可以将WriteConcern设置为majority来保证数据必须在大多数节点上写入成功,如图所示。

对于成功写入大多数节点的数据,即使发生主备切换,仍然能保证新的主节点包含该数据,这意味着持久性有了更高的保证。执行下面的命令,可以实现大多数节点写关注。

wtimeout表示写入等待的超时时间,单位是毫秒。w参数表示一个写入成功的数量,可以有多种配置(见表)。

除此之外,WriteConcern还可以设定j参数来保证数据成功写入磁盘的journa日志,代码如下:

上述代码中,w:2,j:true表示数据将至少在两个节点上写入成功,并且已经持久化到这些节点的journal日志中。当WriteConcern=majority时,如果没有明确指定j选项,则默认是true,可以通过writeConcernMajorityJournalDefault来控制该行为。
6.2.2、读关注(ReadConcern)
读关注是MongoDB 3.2版本新增的一个特性,主要用来解决"脏读"的问题。例如,客户端从主节点上读到了一条数据,但此时主节点发生宕机,由于这条数据还没有同步到其他节点上,在主节点恢复后就会进行回滚。从客户端的角度看便是读到了"脏数据"(可能被回滚)。当ReadConcern设置为majority时,MongoDB可以保证客户端读到的数据已经被大多数节点所接受,这样的数据可以保证不会回滚,从而避免了脏读问题。
MongoDB对读关注定义了多个级别,具体如下。
local:本地读级别,仅读取本地可用的数据,不确保读取的数据是否被大多数节点接受。对主节点的读操作、备节点在因果一致性会话中的读操作默认采用local级别。available:本地可用读级别,仅读取本地可用的数据,不确保读取的数据是否被大多数节点接受。该级别和local级别的区别在于,可能会返回分片迁移产生的"孤儿文档"。对备节点在非因果一致性会话中的读操作默认采用available级别。majority:大多数读级别,可读取已同步到大多数节点的数据,可确保读取到的数据不会被回滚。linearizable:线性读级别(MongoDB 3.4版本提供),该级别保证能读取到的WriteConcern为majority,并且返回确认时间在当前读请求开始之前的数据。linearizable级别只支持单文档操作的线性关系,而且只在读主节点时有效。这意味着如果上一个节点对该文档的写入还未满足大多数写入时,MongoDB会进行等待。因此linearizable通常要和maxTimeMS一起使用,以避免长时间的阻塞。linearizable级别中性能下降比较明显,而且其同时也不适用于因果一致性会话、事务等特性。snapshot:快照读级别,仅可用于多文档事务中,snapshot级别保证了集群级别的快照一致性。为了进一步理解读关注的机制,我们可以关注MongoDB在复制方面的一些细节。为了支持读关注的不同级别,副本集节点上会各自维护一份当前的快照信息,如图所示。

每个节点上都包含自身最新的快照(local)以及大多数节点的快照(majority),oplog复制成功的信息会被反馈到主节点,这样主节点便可以知道哪一条oplog已经成功写入了大多数节点。而备节点在拉取oplog信息时,主节点也会一并将"同步到大多数节点的最新一条oplog"的信息一并返回,这样备节点也获得了majority的oplog信息,进一步更新自身的快照信息。
使用ReadConcern=majority需要开启选项replication.enableMajorityReadConcern,从MongoDB3.6版本开始,该选项默认是true。
6.3、读自身的写入(Read your own writes)
Read your own writes是指读取到自身的写入数据,在一些严谨的业务流程中往往存在这样的需求。如果客户端开启了读写分离模式,那么大概率会读不到上一次写入的数据。下面来看几组操作。
操作一:写主节点,读备节点(w:1,rc: available)。

主节点写入后,由于备节点可能未同步到该数据,因此这里读取到的数据可能是空的。
操作二:写主节点,读主节点(w:1,rc:local)。

可以说,在绝大多数情况下,find操作会返回数据。但意外仍可能存在,假设在写入主节点成功之后,主节点宕机发生主备节点切换,此时新的主节点并不一定具有orderId:10001这条数据,可能会返回null。
操作三:写主节点,读备节点(w:majority, rc:majority)。

写操作w:"majority" 保证了大多数节点都收到了该数据,readConcern:majority保证了从备节点读取到的数据已经同步到了大多数节点,但是这并不能保证当前备节点一定包含刚刚写入的数据。
操作四:写主节点,读主节点(w:majority, rc:local)。

写操作w:"majority" 保证了大多数节点都收到了该数据,就算在下一次读之前发生了主备节点切换,也可以保证新的主节点一定包含该条数据,因此可以保证Readyour own writes。
在第四种操作中,由于读写操作都是针对主节点的,一旦读压力持续增加便无法兼用读写分离方案。为了在任意节点上也实现这种Read your own writes的特性,最 好的办法是使用因果一致性会话。
6.4、因果一致性
MongoDB 3.6版本引入了会话的概念,并基于全局时钟实现了分布式集群上的因果一致性。
因果一致性(causal consistency)是分布式数据库的一致性模型,它保证了一系列逻辑顺序发生的操作,在任意视角中都能保证一致的先后关系(因果关系)。例如只有当问题是可见的情况下才会出现对问题的答复,这样问题与答复就形成了依赖性的因果关系。
因果一致性所涉及的特性主要如下。
- Read your writes,读自身的写入,读操作必须能够反映出在其之前的写操作。
- Monotonic reads,单调读,如果某个读取操作已经看到过数据对象的某个值,那么任何后续访问都不会返回那个值之前的值。
- Monotonic writes,单调写,如果某些写操作必须先于其他写操作执行,那么它们会确实先于那些写操作执行。
- Writes follow reads,读后写,如果某些写操作必须发生在读操作之后,那么它们会确实在那些读操作之后执行。
为了支持因果一致性的全部特性,需要在会话中使用ReadConcern=大多数、WriteConcern=大多数的读写关注级别。另外,为了保证会话中的一组操作满足先后执行的因果一致性,客户端必须在同一个线程中执行这些操作。
因果一致性的上下文(逻辑时序)信息会被绑定到线程上下文中以实现跟踪。执行如下的命令,开启因果一致性会话,代码如下:

6.5、小结
最终,应用如何选择合适的一致性级别呢?下面是几点建议。
- 如果系统数据并非不可丢失,例如物联网场景中的传感器数据,对于单条消息丢失敏感度不大,则可使用ReadConcern:local, WriteConcern:1。
- 一些重要的ETL过程强调写入持久性,可使用WriteConcern:majority。
- 金融应用中的订单、交易业务通常需要更高的一致性,可使用WriteConcern:majority, ReadConcern:majority。在关键的流程操作中,使用因果一致性会话可以保证持久性和可见性。
7、聚合范例
7.1、聚合框架介绍
MongoDB提供了强大的聚合框架(aggregationframework),通常可以用来实现一些轻量级的分析任务。聚合任务常用的场景如下。
- 数据可视化,将业务数据进行汇聚、分组计算后的结果生成报表视图。
- 数据转化,利用聚合操作管道执行数据清洗、转换的一些任务,生成可分析数据。
聚合框架采用了流处理的思路,在设计上与Linux shell的管道命令有异曲同工之处。聚合操作作用于一个集合上,而聚合的整个流程称为管道(pipeline),每个管道 可以包含多个处理阶段(stage),这些阶段分别按指定的次序执行,如图所示。

在管道中,每个阶段都承担了特定的数据转换任务。其中,第一个阶段整个集合作为输入,上一个阶段的输出作为下一个阶段的输入,一个接着一个,最后一个阶段的输出作为整个聚合过程的结果。
利用灵活的管道语法,聚合可以实现一些Map-Reduce类型的计算任务。在MongoDB早期版本中,往往使用原生的mapReduce函数(基于JavaScript)来实现计算,但mapReduce函数在使用上比较复杂,服务器端需要同时支持JavaScript的执行,在性能上不如聚合。一般推荐使用聚合框架来替代mapReduce函数。
7.2、找出重复数据
在对已经存在的集合添加唯一性索引之前,必须先解决重复数据的问题。数据库在创建唯一性索引的过程中,一旦发现已有数据违反了约束会直接报错退出。db.collection.createIndex命令可用来创建索引,在MongoDB早期版本中,还可以使用dropDups选项来自动删除重复的数据。但该选项会产生不确定性的删除行为,因而已被废弃。对于已存在的重复性数据如何处理,妥当的做法还是由应用来决定。
使用如下命令,可以找出用户集合(users)中手机号(phone)出现重复的记录。
js
> db.users.aggregate([
{$group: {_id: "$phone", sameCount: {$sum: 1}}},
{$match: {sameCount: {$gte: 1}}}
])
7.3、写入中间表
在前面的章节中,我们多次提及了利用聚合操作实现分类统计的用法,一种更为常见的场景来自对历史类数据的统计,然而此类数据往往非常庞大,聚合操作很难一次性应付海量数据的处理。此时,我们建议对数据进行拆分处理,例如按时间分段进行统计,并写入中间结果表中。下面来看一个例子。
物联网系统中存储了大量来自设备上报的事件,每个事件可以描述为如下的文档:
js
var eventTypes = ["battery", "noise", "light", "text"]
var timeEnd = new Date().getTime();
var timeStart = timeEnd - 30 * 24 * 3600 * 1000;
function mockEvent() {
var typeLen = eventTypes.length;
return {
eventType: eventTypes[Math.floor(Math.random() * typeLen)],
timestamp: new Date(timeStart + Math.floor(Math.random() * (timeEnd - timeStart))),
deviceId: UUID()
}
}
for(var i = 0; i < 1000; i++){
db.events.insert(mockEvent())
}
> db.events.find().pretty()
> db.events.find().pretty()
{
"_id" : ObjectId("6a633b3c2b96e83b8bc4f986"),
"eventType" : "battery",
"timestamp" : ISODate("2026-07-20T10:31:47.029Z"),
"deviceId" : UUID("c1fbd17e-41f2-4438-9648-711a6ecd4f9e")
}
{
"_id" : ObjectId("6a633b3d2b96e83b8bc4f987"),
"eventType" : "text",
"timestamp" : ISODate("2026-07-06T02:24:44.917Z"),
"deviceId" : UUID("55ca848d-6e58-45ce-8679-748c94be0d55")
}
{
"_id" : ObjectId("6a633b3d2b96e83b8bc4f988"),
"eventType" : "noise",
"timestamp" : ISODate("2026-07-24T06:00:36.861Z"),
"deviceId" : UUID("16d4c2bc-e12f-459e-9599-22e020e45cde")
}
...
这里我们不关心事件具体来自哪个设备,了解某一个设备发生的个别事件的意义并不大。相反,我们更关心某类事件在一段周期内发生的趋势,并以此来了解哪类设备功能属于更高频次的需求。针对此需求,我们可以设计一个周期性的任务,该任务在对每天产生的事件进行分类统计后,将结果写入统计表中。具体的聚合动作命令如下。
js
var startDate = ISODate("2026-07-24T00:00:00.000Z");
var endDate = ISODate("2026-07-25T00:00:00.000Z");
db.events.aggregate([
{
$match: {timestamp: {$gte: startDate, $lt: endDate}}
},
{
$group: {_id: "$eventType", eventCount: {$sum: 1}, timestamp: {$first: "$timestamp"}}
},
{
$project: {_id: 0, eventCount: 1, eventType: "$_id", date: {$dateToString: {format: "%G-%m-%d", date: "$timestamp"}}}
},
{
$merge: {
into: {db: "test", coll: "eventStats"},
on: ["date", "eventType"]
}
}
])
聚合动作可以安排在凌晨的某个时刻运行,通常可以选择在业务低峰期。上述命令的说明如下。
- $match阶段实现了对createdDate字段的过滤,表示仅分析前一天所产生的事件数据。
- $group阶段实现了按eventType字段的分组统计,在这个阶段中完成各种事件类型的分组计算。
- $project阶段实现了字段的提取及转换,例如将日期字段timestamp转换为'2020-02-13'这样的格式。
- $merge阶段实现了将结果写入结果表中。
into子文档描述目标数据库和集合名称。
on是执行merge的判决条件,即按照date、eventType两个字段的组合条件查找当前记录是否存在,如果存在则合并更新,如果不存在则写入。
$merge操作的默认语义是合并,可选值可以是replace(替换)、keepExisting(保持不变)、merge(合并更新)、fail(中断)中的一种。
注意,对于$merge指定判决条件,必须满足唯一性的语义约束,因此需要实现在结果表中创建唯一性索引,代码如下:
js
> use test
> db.eventStats.createIndex({date: 1, eventType: 1}, {unique: true})
在创建唯一性索引之后,执行上述聚合命令,查看结果表中的记录,具体如下:
js
> db.eventStats.find().pretty()
{
"_id" : ObjectId("6a6340ab41c6364c663224f4"),
"date" : "2026-07-24",
"eventType" : "text",
"eventCount" : 3
}
{
"_id" : ObjectId("6a6340ab41c6364c663224f5"),
"date" : "2026-07-24",
"eventType" : "battery",
"eventCount" : 2
}
{
"_id" : ObjectId("6a6340ab41c6364c663224f6"),
"date" : "2026-07-24",
"eventType" : "light",
"eventCount" : 4
}
{
"_id" : ObjectId("6a6340ab41c6364c663224f7"),
"date" : "2026-07-24",
"eventType" : "noise",
"eventCount" : 5
}
将聚合的数据保存到结果表通常是一种最佳实践,因为这可以避免对数据进行重复计算。在一些需要快速响应的图形报表应用中,往往通过直接读取统计表来节省时间,当然,这会牺牲一些实时性,但可以接受。MongoDB提供了createView命令用来创建一个只读的视图,非常方便,但这个视图是动态执行的,每次对视图的查询都需要重新执行整个聚合管道,从成本效果上讲受益不大。$merge这种写中间表的方式类似于SQL中的物化视图概念,可以用它来处理历史数据的分类统计,或是实现数据态势变化的追踪分析等。需要注意的是,$merge是MongoDB 4.2版本提供的一个新特性。在MongoDB 4.2之前的版本中,只能使用$out操作符来写入中间表,但$out命令会替换整个中间表,无法实现追加的功能,因此使用场景有限。
7.4、表连接
$lookup操作符可以用来实现MongoDB的跨表连接(join)操作。准确来说,是左连接(left join)。$lookup默认行为会从源表开始,逐个从目标表中找到匹配的记录,将连接(join)到的目标记录作为数组(array)输出。
简单的用法如下:

这里的orders集合将与inventory表进行连接,localField、foreignField分别表示源表、目标表的连接字段,as表示结果中连接文档的字段名。执行语句后产生的结果如下:

该操作可等价于如下的SQL语句:

从MongoDB 3.6版本开始,$lookup开始支持多条件的连接行为,这可以通过内嵌一个管道来完成。以社交平台为例,为了向用户推荐具有共同兴趣的"潜在"好友,最简单的方式可以按属性匹配的方式来实现。假定用户表中记录了用户偏好(标签)、用户年龄信息,代码如下:


那么,推荐给某用户的好友对象需具备如下条件。
- 至少包含3个以上的共同兴趣(标签)。
- 彼此的年龄相差不能超过10岁。
为此,实现如下的 l o o k u p 操作: ! 在这里插入图片描述 ( h t t p s : / / i − b l o g . c s d n i m g . c n / d i r e c t / 2 d 697 c d 38 b 4 e 4 b 90816 d 5 a e 4 b b 67 e 55 b . p n g ) 在管道的前面, l e t 指令用来对目标表中参与计算的字段声明到变量中,这里是 a g e → o t h e r A g e , t a g s → o t h e r T a g s , u s e r I d → o t h e r U s e r I d 。这些通过 l e t 指令声明的变量需要使用 ' lookup操作: !在这里插入图片描述(https://i-blog.csdnimg.cn/direct/2d697cd38b4e4b90816d5ae4bb67e55b.png) 在管道的前面,let指令用来对目标表中参与计算的字段声明到变量中,这里是age→otherAge,tags→otherTags, userId→otherUserId。这些通过let指令声明的变量需要使用` lookup操作:!在这里插入图片描述(https://i−blog.csdnimg.cn/direct/2d697cd38b4e4b90816d5ae4bb67e55b.png)在管道的前面,let指令用来对目标表中参与计算的字段声明到变量中,这里是age→otherAge,tags→otherTags,userId→otherUserId。这些通过let指令声明的变量需要使用'$` 的形式进行引用。这里的管道包含两个过程。
$matcher,用来找到匹配记录,匹配条件包含以下3个:- 源和目标的userId必须不同,避免推荐自己。
- 源和目标的tags数组存在至少3个或以上的交集。setIntersection用来实现集合计算,通过size返回大小。
- 源和目标的age字段相差(绝对值)不大于10。subtract实现两数相减,而abs会返回结果的绝对值。
$limit,为避免推荐的用户过多,限制最多匹配3个
记录。
最终执行的结果如下:


7.5、使用要点
- 聚合操作最多只能使用100MB的内存,一旦超出将会报错。MongoDB允许使用allowDiskUse选项来扩展这个限制(使用临时磁盘文件),但这样会导致性能下降明显。而且,allowDiskUse不适用于graphLookup、addToSet、$push这些操作。
- 尽可能在
$match、$sort操作中使用索引,特定情况下,$group也可以从索引中受益。 - 提前过滤不必要的记录,使用
$match操作过滤不符合条件的文档,使用$limit、$kip减少需要处理的记录。对于内存排序(无法利用索引)的场景,使用$limit也可以限制排序所用的内存空间。 - 在分片的场景下,尽可能在首个$match条件中添加分片键条件,这样可以减少参与计算的分片节点。如果聚合操作需要多个分片参与,那么MongoDB会随机选择一个分片作为执行者,并由该分片负责合并处理来自其他分片的结果。对存在out、lookup的聚合操作,MongoDB只会选择当前集合所在的主分片作为执行者。