一、Chang Streams数据流事件

Change Stream是指数据的变化事件流,用于追踪MongoDB变动记录,类似于关系数据库的触发器,但原理不完全相同:

Change Stream 是基于 oplog 实现的,提供推送实时增量的推送功能。它在 oplog 上开启一个 tailable cursor 来追踪所有复制集上的变更操作,最终调用应用中定义的回调函数,我们可以监听回调函数来获取实时变动日志并进行业务处理。

被追踪的变更事件主要包括:

  • insert/update/delete:插入、更新、删除;
  • drop:集合被删除;
  • rename:集合被重命名;
  • dropDatabase:数据库被删除;
  • invalidate:drop/rename/dropDatabase 将导致 invalidate 被触发, 并关闭 change stream;

使用场景

  • 跨集群的变更复制——在源集群中订阅 Change Stream,一旦得到任何变更立即写入目标集群。
  • 微服务联动——当一个微服务变更数据库时,其他微服务得到通知并做出相应的变更。
    • 如 监听到某个集合发生了数据变动,就进行相应处理,如 发送用户通知、日志分析等。
  • 其他任何需要系统联动的场景。

Change Stream会采用 “readConcern:majority”这样的一致性级别,保证写入的变更不会被回滚。因此:

  • **未开启 majority readConcern **的集群无法使用 Change Stream;
  • 当集群无法满足 {w: “majority”} 时,不会触发 Change Stream。
    • 写入数据时如果 无法满足 超过半数节点确认,就不会触发 Change Stream

案例 1.监控

我们可以通过ChangeStreams监听某个集合(如 用户表),一旦集合发生了数据变化,就会立即将变更的消息实时推送出去。

案例 2.分析平台

我们需要增量数据去分析用户行为,可以订阅ChangeStreams将消息过滤后转发给下游的计算平台, 比如 类似 Flink、Spark 等。

案例 3.数据同步

  1. 从源复制集上复制一份完整的数据,搭建一个新的复制集 用于冗余备份,但是不对外提供服务
  2. 订阅ChangeStreams回调函数后,将增量消息 写入到 冗余备份的复制集 完成同步。

该方法可以实现 跨地域数据同步,当源复制集宕机后,该备份复制集上的数据依然存在并且可以正常对外提供服务。

案例 4.消息推送

假如用户想实时了解公交车的信息,那么公交车的位置每次变动,都实时推送变更的信息给想了解的用 户,用户能够实时收到公交车变更的数据,非常便捷实用。

注意事项

  • Change Stream 依赖于 oplog,因此中断时间不可超过 oplog 回收的最大时间窗,否则会造成部分数据丢失。
    • 由于oplog是一个固定集合,超过了固定大小后会覆写之前的数据。如果Change Stream出现了中断的情况,在oplog覆写数据之前还没有恢复,就会出现部分数据丢失。
  • 在执行 update 操作时,如果只更新了部分数据,那么 Change Stream 通知的也是增量部分;
  • 删除数据时通知的仅是删除数据的 _id。

Chang Stream整合Spring Boot

项目代码:https://files.javaxing.com/Java%08Demo/spring-boot-mongo-demo-02.zip

引入依赖

1
2
3
4
5
<!--spring data mongodb-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>

修改配置文件 application.yaml

连接配置文档 https://www.mongodb.com/docs/manual/reference/connection-string/

我们可以根据文档里面的属性,设置 uri参数,如 复制集,分片集等其他参数。

1
2
3
4
spring:
data:
mongodb:
uri: mongodb://xing:123123@192.168.31.20:27010,192.168.31.149:27010,192.168.31.144:27010/test?authSource=admin&replicaSet=rs0

增加配置文件

设置监听器类型,指定过滤监听类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Configuration
public class MongoConfig {

@Bean
MessageListenerContainer messageListenerContainer(MongoTemplate template, DocumentMessageListener documentMessageListener) {

Executor executor = Executors.newFixedThreadPool(5);

MessageListenerContainer messageListenerContainer = new DefaultMessageListenerContainer(template, executor) {
@Override
public boolean isAutoStartup() {
return true;
}
};

ChangeStreamRequest<Document> request = ChangeStreamRequest.builder(documentMessageListener)
.collection("user") //需要监听的集合名
//过滤需要监听的操作类型,可以根据需求指定过滤条件
.filter(Aggregation.newAggregation(Aggregation.match(
Criteria.where("operationType").in("insert", "update", "delete"))))
//不设置时,文档更新时,只会发送变更字段的信息,设置UPDATE_LOOKUP会返回文档的全部信息
.fullDocumentLookup(FullDocument.UPDATE_LOOKUP)
.build();
messageListenerContainer.register(request, Document.class);

return messageListenerContainer;
}

}

设置监听器

可以在监听器里面做一系列的业务逻辑,如 推送消息到下游服务器等。

1
2
3
4
5
6
7
8
9
10
11
12
13
@Component
public class DocumentMessageListener<S, T> implements MessageListener<S, T> {

@Override
public void onMessage(Message<S, T> message) {

System.out.println(String.format("Received Message in collection %s.\n\trawsource: %s\n\t",
message.getProperties().getCollectionName(), message.getRaw()));


}

}

实验测试

image-20240129142058384

插入文档后,Change Stream 监听器 监听到数据,并成功打印出来。

image-20240129141953291image-20240129142006586

二、MongoDB性能监控工具

1. mongostat

mongostat是MongoDB自带的监控工具,要求执行用户需具备clusterMonitor角色权限mongostat所监视的对象是数据库进程。mongostat常用于查看当前的QPS/内存使用/连接数,以及多个分片的压力分布。

MongoDB 4.4版本及以上安装包不会附带工具包,所以需要另外下载,请根据实际需求下载:

Centos7~8 X86 64位:https://files.javaxing.com/mongoDB/mongodb-database-tools-rhel80-x86_64-100.9.4.tgz

官网下载(可以选择更多的版本):https://www.mongodb.com/try/download/database-tools

1
mongostat -h localhost --port 27010 -uxing -p123123 --authenticationDatabase=admin --discover -n 300 2

参数说明:

  • -h:指定监听的主机,分片集群模式下指定到一个mongos实例,也可以指定单个mongod,或者复制集的多个节点。
  • –port:接入的端口,如果不提供则默认为27017。
  • -u:接入用户名,等同于-user。
  • -p:接入密码,等同于-password。
  • –authenticationDatabase:鉴权数据库。
  • –discover:启用自动发现,可展示集群中所有分片节点的状态。
  • -n 300 2:表示输出300次,每次间隔2s。也可以不指定“-n 300”,此时会一直保持输出。

image-20240126164704323

指标说明

指标名 说明
inserts 每秒插入数
query 每秒查询数
update 每秒更新数
delete 每秒删除数
getmore 每秒getmore数
command 每秒命令数,涵盖了内部的一些操作
%dirty WiredTiger缓存中脏数据百分比
%used WiredTiger 正在使用的缓存百分比
flushes WiredTiger执行CheckPoint的次数
vsize 虚拟内存使用量
res 物理内存使用量
qrw 客户端读写等待队列数量,高并发时,一般队列值会升高
arw 客户端读写活跃个数
netIn 网络接收数据量
netOut 网络发送数据量
conn 当前连接数
set 所属复制集名称
repl 复制节点状态(主节点/二级节点……)
time 时间戳

mongostat需要关注的指标主要有如下几个:

  • 插入、删除、修改、查询的速率是否产生较大波动,是否超出预期。
  • qrw、arw:队列是否较高,若长时间大于0则说明此时读写速度较慢。
  • conn:连接数是否太多。
  • dirty:百分比是否较高,若持续高于10%则说明磁盘I/O存在瓶颈。
  • netIn、netOut:是否超过网络带宽阈值。
  • repl:状态是否异常,如PRI、SEC、RTR为正常,若出现REC等异常值则需要修复。

2. mongotop

mongotop命令可用于查看数据库的热点表,通过观察mongotop的输出,可以判定是哪些集合占用了大部分读写时间。mongotop与mongostat的实现原理类似,同样需要clusterMonitor角色权限

如果没有权限的话,会提示 没有使用权限。如果只是为了方便测试,可以给与root 最高权限。

image-20240126203309757

1
mongotop -h localhost --port 27010 -uxing -p123123 --authenticationDatabase=admin

默认情况下,mongotop会持续地每秒输出当前的热点表

image-20240126184536635

指标说明

指标名 说明
ns 集合名称空间
total 花费在该集合上的时长
read 花费在该集合上的读操作时长
write 花费在该集合上的写操作时长

mongotop通常需要关注的因素主要包括:

  • 热点表操作耗费时长是否过高。这里的时长是在一定的时间间隔内的统计值,它代表某个集合读写操作所耗费的时间总量。在业务高峰期时,核心表的读写操作一般比平时高一些,通过mongotop的输出可以对业务尖峰做出一些判断。
  • 是否存在非预期的热点表。一些慢操作导致的性能问题可以从mongotop的结果中体现出来

mongotop的统计周期、输出总量都是可以设定的

1
2
#最多输出100次,每次间隔时间为2s
mongotop -h localhost --port 27010 -uxing -p123123 --authenticationDatabase=admin -n 100 2

image-20240126185945111

3. Profiler模块 慢查询日志

Profiler模块可以用来记录、分析MongoDB的详细操作日志。默认情况下该功能是关闭的,对某个业务库开启Profiler模块之后,符合条件的慢操作日志会被写入该库的system.profile集合中。Profiler的设计很像代码的日志功能,其提供了几种调试级别:

级别 说明
0 日志关闭,无任何输出
1 部分开启,仅符合条件(时长大于slowms)的操作日志会被记录
2 日志全开,所有的操作日志都被记录

对当前的数据库开启Profiler模块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 将level设置为2,此时所有的操作会被记录下来。
rs0:PRIMARY> db.setProfilingLevel(2)
{
"was" : 0,
"slowms" : 100,
"sampleRate" : 1,
"ok" : 1,
"$clusterTime" : {
"clusterTime" : Timestamp(1706271873, 1),
"signature" : {
"hash" : BinData(0,"AS33ibg3+F90t3knRdLZgyeuyVs="),
"keyId" : NumberLong("7327570431563530245")
}
},
"operationTime" : Timestamp(1706271873, 1)
}


#检查是否生效
db.getProfilingStatus()

image-20240126202513262

  • slowms是慢操作的阈值,单位是毫秒;
    • 如设置 slowms = 500ms,那超过500ms的查询就会写入到日志中,我们可以根据日志来优化
  • sampleRate表示日志随机采样的比例,1.0则表示满足条件的全部输出。

如果希望只记录时长超过500ms的操作,则可以将level设置为1

1
rs0:PRIMARY> db.setProfilingLevel(1,500)

image-20240126203121760

还可以进一步设置随机采样的比例

1
rs0:PRIMARY> db.setProfilingLevel(1,{slowms:500,sampleRate:0.5})

每次复制集重启后,会默认关闭日志,避免写Profiler日志影响性能。

查看操作日志

开启Profiler模块之后,可以通过system.profile集合查看最近发生的操作日志

1
rs0:PRIMARY> db.system.profile.find().limit(5).sort({ts:-1}).pretty()

image-20240127143041426

这里需要关注的一些字段主要如下所示:

  • op:操作类型,描述增加、删除、修改、查询。
  • ns:名称空间,格式为{db}.{collection}。
  • Command:原始的命令文档。
  • Cursorid:游标ID。
  • numYield:操作数,大于0表示等待锁或者是磁盘I/O操作。
  • nreturned:返回条目数。
  • keysExamined:扫描索引条目数,如果比nreturned大出很多,则说明查询效率不高。docsExamined:扫描文档条目数,如果比nreturned大出很多,则说明查询效率不高。
  • locks:锁占用的情况。
  • storage:存储引擎层的执行信息。
  • responseLength:响应数据大小(字节数),一次性查询太多的数据会影响性能,可以使用limit、batchSize进行一些限制。
  • millis:命令执行的时长,单位是毫秒。
  • planSummary:查询计划的概要,如IXSCAN表示使用了索引扫描。
  • execStats:执行过程统计信息。
  • ts:命令执行的时间点。

根据这些字段,可以执行一些不同维度的查询。比如查看执行时长最大的10条操作记录

查看某个集合中的update操作日志

1
db.system.profile.find().limit(10).sort({millis:-1}).pretty()

查看某个集合中的update操作日志

1
db.system.profile.find({op:"update",ns:"shop.user"})

注意事项

  • system.profile是一个1MB的固定大小的集合,随着记录日志的增多,一些旧的记录会被滚动删除

  • 在线上开启Profiler模块需要非常谨慎,这是因为其对MongoDB的性能影响比较大。建议按需部分开启,同时slowms的值不要设置太低。

  • sampleRate的默认值是1.0,该字段可以控制记录日志的命令数比例,但只有在MongoDB 4.0版本之后才支持。

  • Profiler模块的设置是内存级的,重启服务器后会自动恢复默认状态。

4. 运行时状态 db.currentOp()

Profiler模块所记录的日志都是已经发生的事情,db.currentOp()命令则与此相反,它可以用来查看数据库当前正在执行的一些操作。想象一下,当数据库系统的CPU发生骤增时,我们最想做的无非是快速找到问题的根源,这时db.currentOp就派上用场了。

db.currentOp()读取的是当前数据库的命令快照,该命令可以返回许多有用的信息,比如:

  • 操作的运行时长,快速发现耗时漫长的低效扫描操作。

  • 执行计划信息,用于判断是否命中了索引,或者存在锁冲突的情况。

  • 操作ID、时间、客户端等信息,方便定位出产生慢操作的源头。

    image-20240127143422480image-20240127143442989

对示例操作的解读如下:

(1)从ns、op字段获知,当前进行的操作正在对test.items集合执行update命令。

(2)command字段显示了其原始信息。其中,command.q和command.u分别展示了update的查询条件和更新操作。

(3)”planSummary”:”COLLSCAN” 说明情况并不乐观,update没有利用索引而是正在全表扫描。(4)microsecs_running:NumberLong(186070)表示操作运行了186ms,注意这里的单位是微秒。

优化方向:

  • value字段加上索引
  • 如果更新的数据集非常大,要避免大范围update操作,切分成小批量的操作

opid表示当前操作在数据库进程中的唯一编号。如果已经发现该操作正在导致数据库系统响应缓慢,则可以考虑将其“杀”死

1
db.killOp(4001)

db.currentOp默认输出当前系统中全部活跃的操作,由于返回的结果较多,我们可以指定一些过滤条件:

  • 查看等待锁的增加、删除、修改、查询操作
1
2
3
4
5
db.currentOp({
$or:[
{op:{$in:["insert","update","remove"]}}
]
})

  • 查看执行时间超过1s的操作
1
2
3
db.currentOp({
secs_running:{$gt:1}
})
  • 查看test数据库中的操作
1
2
3
db.currentOp({
ns:/test/
})

currentOp命令输出说明

  • currentOp.type:操作类型,可以是op、idleSession、idleCursor的一种,一般的操作信息以op表示。其为MongoDB 4.2版本新增功能。

  • currentOp.host:主机的名称。currentOp.desc:连接描述,包含connectionId。currentOp.connectionId:客户端连接的标识符。currentOp.client:客户端主机和端口。currentOp.appName:应用名称,一般是描述客户端类型。

  • currentOp.clientMetadata:关于客户端的附加信息,可以包含驱动的版本。currentOp.currentOpTime:操作的开始时间。MongoDB 3.6版本新增功能。

  • currentOp.lsid:会话标识符。MongoDB 3.6版本新增功能。

  • currentOp.opid:操作的标志编号。

  • currentOp.active:操作是否活跃。如果是空闲状态则为false。

  • currentOp.secs_running:操作持续时间(以秒为单位)。

    • 通过该参数实时可以找到 慢查询命令
  • currentOp.microsecs_running:操作持续时间(以微秒为单位)。

  • currentOp.op:标识操作类型的字符串。可能的值是:”none” “update” “insert””query””command” “getmore” “remove” “killcursors”。其中,command操作包括大多数命令,如createIndexes和findAndModify。

  • currentOp.ns:操作目标的集合命名空间。

  • currentOp.command:操作的完整命令对象的文档。如果文档大小超过1KB,则会使用一种$truncate形式表示。

  • currentOp.planSummary:查询计划的概要信息。

  • currentOp.locks:当前操作持有锁的类型和模式。

  • currentOp.waitingForLock:是否正在等待锁。

  • currentOp.numYields:当前操作执行yield(让步)的次数。一些锁互斥或者磁盘I/O读取都会导致该值大于0。

  • currentOp.lockStats:当前操作持有锁的统计。

  • currentOp.lockStats.acquireCount:操作以指定模式获取锁的次数。

  • currentOp.lockStats.acquireWaitCount:操作获取锁等待的次数,等待是因为锁处于冲突模式。acquireWaitCount小于或等于acquireCount。

  • currentOp.lockStats.timeAcquiringMicros:操作为了获取锁所花费的累积时间(以微秒为单位)。timeAcquiringMicros除以acquireWaitCount可估算出平均锁等待时间。

  • currentOp.lockStats.deadlockCount:在等待锁获取时,操作遇到死锁的次数。

注意事项

  • db.currentOp返回的是数据库命令的瞬时状态,因此,如果数据库压力不大,则通常只会返回极少的结果。
  • 如果启用了复制集,那么currentOp还会返回一些复制的内部操作(针对local.oplog.rs),需要做一些筛选。
  • db.currentOp的结果是一个BSON文档,如果大小超过16MB,则会被压缩。可以使用聚合操作$currentOp获得完整的结果。

5. 性能问题排查参考案例

排查性能的几个步骤:

  1. mongostat 监控MongoDB进程的一个实时情况,如果实时数据没有问题的话在去分析 热点数据
  2. mongotop 分析热点数据库和集合情况
  3. 慢查询日志,分析出哪条命令占用时间较长
  4. db.currentOp() 实时日志
    1. 根据实时日志里面的currentOp.secs_running参数,分析出哪条命令占用时间较长

记一次 MongoDB 占用 CPU 过高问题的排查

MongoDB线上案例:一个参数提升16倍写入速度

三、MongoDB性能调优

1. MongoDB开发规范

(1)命名原则。数据库、集合命名需要简单易懂,数据库名使用小写字符,集合名称使用统一命名风格,可以统一大小写或使用驼峰式命名。数据库名和集合名称均不能超过64个字符。

(2)集合设计。对少量数据的包含关系,使用嵌套模式有利于读性能和保证原子性的写入。对于复杂的关联关系,以及后期可能发生演进变化的情况,建议使用引用模式。

(3)文档设计。避免使用大文档,MongoDB的文档最大不能超过16MB。如果使用了内嵌的数组对象或子文档,应该保证内嵌数据不会无限制地增长。在文档结构上,尽可能减少字段名的长度,MongoDB会保存文档中的字段名,因此字段名称会影响整个集合的大小以及内存的需求。一般建议将字段名称控制在32个字符以内。

(4)避免建立过多的索引,单个集合建议不超过10个索引。MongoDB对集合的写入操作很可能也会触发索引的写入,从而触发更多的I/O操作。无效的索引会导致内存空间的浪费,因此有必要对索引进行审视,及时清理不使用或不合理的索引。遵循索引优化原则,如覆盖索引、优先前缀匹配等,使用explain命令分析索引性能。

(5)分片设计。对可能出现快速增长或读写压力较大的业务表考虑分片。分片键的设计满足均衡分布的目标,业务上尽量避免广播查询。应尽早确定分片策略,最好在集合达到256GB之前就进行分片。如果集合中存在唯一性索引,则应该确保该索引覆盖分片键,避免冲突。为了降低风险,单个分片的数据集合大小建议不超过2TB。

(6)升级设计。应用上需支持对旧版本数据的兼容性,在添加唯一性约束索引之前,对数据表进行检查并及时清理冗余的数据。新增、修改数据库对象等操作需要经过评审,并保持对数据字典进行更新。

(7)考虑数据老化问题,要及时清理无效、过期的数据,优先考虑为系统日志、历史数据表添加合理的老化策略,如使用TTL索引,或者以日期形式存储数据定期删除等。

(8)数据一致性方面,非关键业务使用默认的WriteConcern:1(更高性能写入)对于关键业务类,使用WriteConcern:majority保证一致性(性能下降)。如果业务上严格不允许脏读,则使用ReadConcern:majority选项。

(9)使用update、findAndModify对数据进行修改时,如果设置了upsert:true,则必须使用唯一性索引避免产生重复数据。

(10)业务上尽量避免短连接,使用官方最新驱动的连接池实现,控制客户端连接池的大小,最大值建议不超过200。

(11)对大量数据写入使用Bulk Write批量化API,建议使用无序批次更新。

(12)优先使用单文档事务保证原子性,能不用多文档事务则不用事务,如果需要使用多文档事务,则必须保证事务尽可能小,一个事务的执行时间最长不能超过60s。

(13)在条件允许的情况下,利用读写分离降低主节点压力。对于一些统计分析类的查询操作,可优先从节点上执行。

(14)考虑业务数据的隔离,例如将配置数据、历史数据存放到不同的数据库中。微服务之间使用单独的数据库,尽量避免跨库访问。

(15)维护数据字典文档并保持更新,提前按不同的业务进行数据容量的规划。

2. MongoDB调优

三大导致MongoDB性能不佳的原因:

  1. 慢查询

    1. 通过慢查询日志 进行优化相关命令
  2. 连接阻塞等待

  3. 硬件资源不足

第一条和第二条 通常是因为模型/索引设计不佳导致的,所以在设计模型和设计索引很重要。

我们将以Mongo 客户端,驱动,服务端,硬件端 等多个角度来分析,在生产环境中需要注意哪些细节来进行调优。

image-20240126094315962

1)MongoDB 客户端

readPreference 读偏好

通过设置读偏好,可以设置 客户端从主节点读取还是从 从节点读取数据。

readPreference决定使用哪一个类型节点来发起的读请求。 可选值如下:

  • primary: 只选择主节点,默认模式;
  • primaryPreferred:优先选择主节点,如果主节点不可用则选择从节点;
  • secondary:只选择从节点;
  • secondaryPreferred:优先选择从节点, 如果从节点不可用则选择主节点;
  • nearest:根据客户端对节点的 Ping 值判断节点的远近,选择从最近的节点读取。

合理的 ReadPreference 可以极大地扩展复制集的读性能,降低访问延迟。

不希望连接到远距离节点的话,可以用以下几种办法:

  1. 使用nearest方式,会选择客户端ping最低的节点
  2. 通过标签TAG控制可选节点,客户端会选择 绑定tag的节点
  3. 将远距离节点设置成 隐藏节点,设置隐藏后 该节点对客户端不可见

writeConcern

writeConcern 决定一个写操作落到多少个节点上才算成功。

  • {w: 0} 对客户端的写入不需要发送任何确认,适用于性能要求高,但不关注正确性的场景
  • {w: 1} 默认为1,数据写入到Primary就向客户端发送确认
  • {w: majority} 数据写入到副本集大多数成员后向客户端发送确认,适用于对数据安全性要求比较高的场景,该选项会降低写入性能

非关键业务使用默认的WriteConcern:1(更高性能写入)对于关键业务类,使用WriteConcern:majority保证一致性(性能下降)

readConcern

readConcern 决定这个节点上的数据哪些是可读的。可选值有:

  • available:读取所有可用的数据;

  • local:读取所有可用且属于当前分片的数据;

  • majority:读取在大多数节点上提交完成的数据;

    • 可以有效的避免脏读,性能较低但是却比较安全可靠
    • 使用majority 只能查询到已经被多数节点确认过的数据
  • linearizable:可线性化读取文档,仅支持从主节点读;

  • snapshot:读取最近快照中的数据,仅可用于多文档事务;

2)Driver驱动

连接池

maxPoolSize:连接池最大连接数,默认100,可以改成200但是最好别超过200
minPoolSize: 连接池保留的最小连接数,默认0
maxldelTimeMS:在连接删除前允许的空闲时间
连接等待:连接数超过maxPoolSize。只调大连接数不一定有用,优化查询性能才是最主要的
预留连接:设置合理的minPoolSize,避免反复创建连接,消耗更多资源。

为避免writeConcern 写入失败导致连接阻塞,可以设置超时时间 wtimeout: writeConcern ,当连接阻塞超时后 自动断开连接。

DB兼容性

驱动与DB的兼容性,DB用的版本 和 客户端驱动 必须保持一致。如 DB用的4.4 客户端也必须4.4,否则可能出现一些不可预料的问题。

3)服务端存储

MongoDB使用的是WireTiger存储引擎,持久化机制有2个,Checkpoints 快照刷盘,Journal 增量同步日志。

Checkpoints 默认每隔60秒 进行快照持久化刷盘。

Journal 写入的命令会增量存储在 Journal日志中,每隔100ms 进行持久化。 如果在写入命令的时候就指定Journal:true的话,那写入数据后会马上持久化。

4)操作系统

选择合适的内核文件系统和操作系统:Linux推荐EXT4/XFS,不推荐EXT3 生产会出现问题。

启用NTP:保证每个节点的时间完全同步一致,避免出现时差问题

禁用atime、Huge page、SELinux、关闭磁盘预读readahead。

修改Linux ulimit配置,提高MongoDB服务端进程在Linux中最大内存使用量和打开文件数量等限制。

5)硬件配置

  1. 要高效使用MongoDB 必须确保足够的内存,因为MongoDB非常吃内存,默认一启动占用一半以上的内存。
  2. 必须使用SSD或更高级别的硬盘
  3. 有条件的话可以使用RAID10 独立磁盘冗余阵列
  4. 禁用NUMA 非一致性内存访问,该选项是为了避免出现WireTiger的缓存和内存 数据不一致的情况

3. MongoDB数据建模

不要一上来咔咔咔的就造,而是要精心考量多方面后再着手设计,设计数据模型和索引是非常重要的,良好的设计可以提高查询效率,减少多文档事务。

1)最简易模型

内嵌文档一对一关系

下面的示例包含2个文档集合,一个用户 一个地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 用户文档
{
_id: "joe",
name: "Joe Bookreader"
}

// 地址文档
{
street: "123 Fake Street",
city: "Faketon",
state: "MA",
zip: "12345"
}

如果我们需要查询用户信息顺带查询出地址的话,可以将地址内嵌到用户里边

1
2
3
4
5
6
7
8
9
10
11
// 嵌入式文档
{
_id: "joe",
name: "Joe Bookreader",
address: {
street: "123 Fake Street",
city: "Faketon",
state: "MA",
zip: "12345"
}
}

内嵌文档一对多关系

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// patron document
{
_id: "joe",
name: "Joe Bookreader"
}

// address one
{
street: "123 Fake Street",
city: "Faketon",
state: "MA",
zip: "12345"
}

// address two
{
street: "1 Some Other Street",
city: "Boston",
state: "MA",
zip: "12345"
}

如果地址出现了多个的情况,就可以将地址以数组的形式内嵌到用户里面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
"_id": "joe",
"name": "Joe Bookreader",
"addresses": [
{
"street": "123 Fake Street",
"city": "Faketon",
"state": "MA",
"zip": "12345"
},
{
"street": "1 Some Other Street",
"city": "Boston",
"state": "MA",
"zip": "12345"
}
]
}

内嵌引用ID 实现一对多关系

此时我们需要做一个图书管理模型,需要通过作者信息去查他名下所有的书籍。

1
2
3
4
5
6
7
8
9
10
11
12
{
title: "MongoDB: The Definitive Guide",
author: [ "Kristina Chodorow", "Mike Dirolf" ],
published_date: ISODate("2010-09-24"),
pages: 216,
language: "English",
publisher: {
name: "O'Reilly Media",
founded: 1980,
location: "CA"
}
}

上面的模型明显是不合理的,不合理的地方一共有2点:

1、将 作者信息放到了book集合里,作者信息可能会存在变动,一旦变动需要修改所有的文档作者信息。

2、无法根据作者信息 查询他名下的所有书籍(加索引也可以 但是效率不高)

合理的做法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 作者集合
{
_id: "oreilly",
name: "O'Reilly Media",
founded: 1980,
location: "CA"
}

// 图书集合
{
_id: 123456789,
title: "MongoDB: The Definitive Guide",
author: [ "Kristina Chodorow", "Mike Dirolf" ],
published_date: ISODate("2010-09-24"),
pages: 216,
language: "English",
publisher_id: "oreilly"
}

将作者单独创建一个集合,图书创建一个集合,然后在图书里面增加publisher_id字段用于存储作者ID。

等同于 关系型数据库(如MySQL)的 多表ID关联一样,通过ID将多个表之间给逻辑关联起来。

2)朋友圈评论点赞设计

需求:模拟设计出 类似微信朋友圈的 数据模型,可以清晰的看到 哪些朋友点赞了,评论了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 用户集合
{
"id":"10000",
"username":"javaxing",
"nick_name":"肖总",
" ... " // 后续就不写了 都大同小异,如 image ,性别,地区什么的基本信息
}

// 发的朋友圈记录集合
{
"id":"1",
"uid":"10000",
"title":"恭喜肖总喜提帕拉梅拉走上人生巅峰",
"images":["xxxx.img","xxx.img","xxx.img"],
"praise_uid_list":["10001","10002","10003"],
"comment_msg_list":[
{"uid":"10001","body":"晋江人民发来贺电"},
{"uid":"10002","body":"厦门人民发来贺电"},
],
}

我们创建了个用户集合和朋友圈记录集合,记录集合存praise_uid_list(点赞列表)、comment_msg_list(评论列表)。将用户ID和对应的评论内容存于上述字段中。

MongoDB的文档模型固然强大,但是也不能一股脑的全部嵌套进去,要考虑到 实际应用场景 合理的设计文档模型。如果不管三七二十一全都往里面嵌套,反而会遇到更麻烦的问题。

3)电影票多渠道价格

需求是基于电影票售卖的不同渠道价格存储。某一个场次的电影,不同的销售渠道对应不同的价格。

  • 数据字段:

    1. 场次信息;
    2. 播放影片信息;
    3. 渠道信息,与其对应的价格;
    4. 渠道数量最多几十个;
  • 业务查询有两种:

    1. 根据电影场次,查询某一个渠道的价格;
    2. 根据渠道信息,查询对应的所有场次信息;

比较糟糕的做法

1
2
3
4
5
6
7
8
9
{
"scheduleId": "0001",
"movie": "你的名字",
"price": {
"gewala": 30,
"maoyan": 50,
"taopiao": 20
}
}

直接将渠道的价格 内嵌到文档里面。后续增加价格的话,就增加渠道商字段。

  1. 根据电影场次,查询某一个渠道的价格

    • 建立createIndex({scheduleId:1, movie:1})索引,虽然没有对price创建索引,但是能如果查询到某个电影,在通过该电影找到price字段 捞出渠道商的价格,也是可行的,就是效率不高
  2. 根据渠道信息,查询对应的所有场次信息

    • 想通过渠道来查询所有电影的话,就需要为渠道商增加索引,但是总不能一个渠道商就创建一个索引吧?如果有几十个渠道商,不得创建几十个索引?那简直是一场噩梦

完善的做法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
"scheduleId": "0001",
"movie": "你的名字",
"provider": [
{
"channel": "gewala",
"price": 30
},
{
"channel": "maoyan",
"price": 50
},
{
"channel": "taopiao",
"price": 20
}
]
}

将供应商和价格字段放成一个小集合,放在provider数组里面,然后塞到电影票的文档里面。数组是可以创建索引的,所以也不用担心查询速度。

  1. 根据电影场次,查询某一个渠道的价格
    • 建立createIndex({scheduleId:1, movie:1, “provider.channel”:1})索引;
  2. 根据渠道信息,查询对应的所有场次信息
    • 建立createIndex({“provider.channel”:1})索引;

4)物联网时序数据库建模

本案例非常适合与IoT场景的数据采集,结合MongoDB的Sharding能力,文档数据结构等优点,可以非常好的解决物联网使用场景。

需求

案例背景是来自真实的业务,美国州际公路的流量统计。数据库需要提供的能力:

  • 存储事件数据

  • 提供分析查询能力

  • 理想的平衡点:

    • 内存使用
    • 写入性能
    • 读取分析性能
  • 可以部署在常见的硬件平台上

每个事件用一个独立的文档存储

1
2
3
4
5
{
segId: "I80_mile23",
speed: 63,
ts: ISODate("2013-10-16T22:07:38.000-0500")
}
  • 非常“传统”的设计思路,每个事件都会写入一条同样的信息。多少的信息,就有多少条数据,数据库会变得非常庞大

  • 数据采集操作全部是Insert语句

每分钟的信息用一个独立的文档存储(存储平均值)

1
2
3
4
5
6
{
segId: "I80_mile23",
speed_num: 18,
speed_sum: 1134,
ts: ISODate("2013-10-16T22:07:00.000-0500")
}
  • 可行,但是数据不够精确。数据精度降为一分钟;
  • 数据采集操作基本是Update语句;

每分钟的信息用一个独立的文档存储(秒级记录)

1
2
3
4
5
{
segId: "I80_mile23",
speed: [10,20,41,... ... ,15,51],
ts: ISODate("2013-10-16T22:07:00.000-0500")
}
  • 将一分钟划分成60份,然后将这60份以数组的形式都存在speed中。 由于时间是恒定的,如果要取出里面哪一秒的数据,直接按size -1去取就行了,因为默认是从0开始计算的
  • 数据采集操作基本是Update语句;

每小时的信息用一个独立的文档存储(秒级记录)

1
2
3
4
5
6
7
{
segId: "I80_mile23",
speed: [
[10,20,41,... ... ,15,51],[10,20,41,... ... ,15,51],[10,20,41,... ... ,15,51] ... ...
],
ts: ISODate("2013-10-16T22:07:00.000-0500")
}
  • 将一小时划分成60份,因为每小时有60分钟恒定的,所以可以通过size找到具体第几分钟的数据。在通过几分钟 找到里面的第几秒
  • 数据采集操作基本是Update语句
  • WiredTiger每60秒持久化一次,如果采用每小时一个文档的方案,他需要 反反复复持久化后再load到PageCache 内存缓存中,修改后再持久化,性能较低。