浅析 MongoDB

基本概念

  • 支持事务:4.2版本增加了分布式事务,增加对分片集群上多文档事务的支持

和传统数据库的对比

对比项 mongo 数据库
table collection
row document
column field 字段

非规范化模型(嵌入式文档)

传统 sql
直接合并

常见使用

capped collection(固定集合):

固定大小,先进先出

支持创建索引,但不支持在创建后更改索引。


capped collection的优点

1.支持高插入吞吐量

2.存储日志信息很有用,按顺序保存

capped collection的缺点

1.不支持分片

2.没有TTL索引

使用场景:

  1. 日志
  2. 实时数据缓存:限制存储的旧数据
  3. 时间序列数据:保存最近的样本
  4. 事件流

使用的操作实例

虽然遍历数据的查询是相对常见的,但是 MongoDB 认为查询单个数据记录远比遍历数据更加常见,由于 B 树的非叶结点也可以存储数据,所以查询一条数据所需要的平均随机 IO 次数会比 B+ 树少,使用 B 树的 MongoDB 在类似场景中的查询速度就会比 MySQL 快。这里并不是说 MongoDB 并不能对数据进行遍历,我们在 MongoDB 中也可以使用范围来查询一批满足对应条件的记录,只是需要的时间会比 MySQL 长一些。

sql 复制代码
SELECT * FROM comments WHERE created_at > '2019-01-01'

很多人看到遍历数据的查询想到的可能都是如上所示的范围查询,然而在关系型数据库中更常见的其实是如下所示的 SQL ------ 查询外键或者某字段等于某一个值的全部记录:

sql 复制代码
SELECT * FROM comments WHERE post_id = 1

上述查询其实并不是范围查询,它没有使用 >< 等表达式,但是它却会在 comments 表中查询一系列的记录,如果 comments 表上有索引 post_id,那么这个查询可能就会在索引中遍历相应索引,找到满足条件的 comment,这种查询也会受益于 MySQL B+ 树相互连接的叶节点,因为它能减少磁盘的随机 IO 次数。

MongoDB 作为非关系型的数据库,它从集合的设计上就使用了完全不同的方法,如果我们仍然使用传统的关系型数据库的表设计思路来思考 MongoDB 中集合的设计,写出类似如上所示的查询会带来相对比较差的性能:

css 复制代码
db.comments.find( { post_id: 1 } )

因为 B 树的所有节点都能存储数据,各个连续的节点之间没有很好的办法通过指针相连,所以上述查询在 B 树中性能会比 B+ 树差很多,但是这并不是一个 MongoDB 中推荐的设计方法,更合适的做法其实是使用嵌入文档,将 post 和属于它的所有 comments 都存储到一起:

json 复制代码
{
    "_id": "...",
    "title": "为什么 MongoDB 使用 B 树",
    "author": "draven",
    "comments": [
        {
            "_id": "...",
            "content": "你这写的不行"
        },
        {
            "_id": "...",
            "content": "一楼说的对"
        }
    ]
}

使用上述方式对数据进行存储时就不会遇到 db.comments.find( { post_id: 1 } ) 这样的查询了,我们只需要将 post 取出来就会获得相关的全部评论,这种区别于传统关系型数据库的设计方式是需要所有使用 MongoDB 的开发者重新思考的,这也是很多人使用 MongoDB 后却发现性能不如 MySQL 的最大原因 ------ 使用的姿势不对。

相关 api

aggregate替代 mapreduce

聚合示例:

less 复制代码
db.collection.aggregate( [ { 阶段操作符:表述 }, { 阶段操作符:表述 }, ... ] )
php 复制代码
db.orders.aggregate([
   # 第一阶段:$match阶段按status字段过滤文档,并将status等于"A"的文档传递到下一阶段。
    { $match: { status: "A" } },
  # 第二阶段:$group阶段按cust_id字段将文档分组,以计算每个cust_id唯一值的金额总和。
    { $group: { _id: "$cust_id", total: { $sum: "$amount" } } }
])

高级

索引

底层结构

WiredTiger使用了 B 树

为什么使用 b树:

  • mongo 对遍历数据的需求没有关系型数据库强,追求的是读写单个记录的性能。
  • 大多数OLTP的数据库都是读多写少,B树和LSM树在该场景下更优

使用了 B 树,因为都是随机查询这样,大宽表

换了个引擎,引擎说是 b+树,就有了争论

background

因为是bson 结构,所以可以给字段的 json 也加索引

explain

stage:

  • COLLSCAN:全表扫描
  • IXSCAN:索引?

索引类型

  • 单键索引:db.userinfos.createIndex({age:1})
    • 1表示升序索引
    • -1表示降序索引
  • 多键索引:当字段里面是 json 数组的时候,就是多键索引
  • 哈希索引:直接 O(1)复杂度
  • 局部索引:例如大于 100 小于 300 的 这样子的就加索引
  • 唯一索引:只有一个 null 值
  • 稀疏索引:不会将 null 值放在索引里面,所以每次查询出来的都没有 null 值 在联合索引中,如果有字段是 null,就不会把这个数据查出来
    • 在需要全表扫描的时候,不会走这个索引,因为要把 null 值查询出来。除非强制指定 hint
    • 和唯一索引一起使用,可以有多个 null 值
  • TTL 索引:定时过期,需要有时间类型的字段配合使用
    • 每60秒触发一次删除任务

数据分片与集群架构

复制集群(副本集群)

oplog(操作日志) 来同步数据的

客户端连接到整个 Mongodb 复制集群,主节点机负责整个复制集群的写,从节点可以进行读操作,但默认还是主节点负责整个复制集群的读。主节点发生故障时,自动从从节点中选举出一个新的主节点,确保集群的正常使用,这对于客户端来说是无感知的。

通常来说,一个复制集群包含 1 个主节点(Primary),多个从节点(Secondary)以及零个或 1 个仲裁节点(Arbiter)。

  • 主节点 :整个集群的写操作入口,接收所有的写操作,并将集合所有的变化记录到操作日志中,即 oplog。主节点挂掉之后会自动选出新的主节点。
  • 从节点 :从主节点同步数据,在主节点挂掉之后选举新节点。不过,从节点可以配置成 0 优先级,阻止它在选举中成为主节点。
  • 仲裁节点 :这个是为了节约资源或者多机房容灾用,只负责主节点选举时投票不存数据,保证能有节点获得多数赞成票。

分片集群

  • 支持 failover :提供自动故障恢复的功能,主节点发生故障时,自动从从节点中选举出一个新的主节点,确保集群的正常使用,这对于客户端来说是无感知的。

  • 支持分片集群 :MongoDB 支持集群自动切分数据,让集群存储更多的数据,具备更强的性能。在数据插入和更新时,能够自动路由和存储。

  • 支持存储大文件 :MongoDB 的单文档存储空间要求不超过 16MB。对于超过 16MB 的大文件,MongoDB 提供了 GridFS 来进行存储,通过 GridFS,可以将大型数据进行分块处理,然后将这些切分后的小文档保存在数据库中。

分片键(Shard Key) 是数据分区的前提, 从而实现数据分发到不同服务器上,减轻服务器的负担。也就是说,分片键决定了集合内的文档如何在集群的多个分片间的分布状况。

分片键就是文档里面的一个字段,但是这个字段不是普通的字段,有一定的要求:

  • 它必须在所有文档中都出现。
  • 它必须是集合的一个索引,可以是单索引或复合索引的前缀索引,不能是多索引、文本索引或地理空间位置索引。
  • MongoDB 4.2 之前的版本,文档的分片键字段值不可变。MongoDB 4.2 版本开始,除非分片键字段是不可变的 _id 字段,否则您可以更新文档的分片键值。MongoDB 5.0 版本开始,实现了实时重新分片(live resharding),可以实现分片键的完全重新选择。
  • 它的大小不能超过 512 字节。

如何选择分片键:

  • 取值基数 取值基数建议尽可能大,如果用小基数的片键,因为备选值有限,那么块的总数量就有限,随着数据增多,块的大小会越来越大,导致水平扩展时移动块会非常困难。 例如:选择年龄做一个基数,范围最多只有100个,随着数据量增多,同一个值分布过多时,导致 chunck 的增长超出 chuncksize 的范围,引起 jumbo chunk,从而无法迁移,导致数据分布不均匀,性能瓶颈。
  • 取值分布 取值分布建议尽量均匀,分布不均匀的片键会造成某些块的数据量非常大,同样有上面数据分布不均匀,性能瓶颈的问题。
  • 查询带分片 查询时建议带上分片,使用分片键进行条件查询时,mongos 可以直接定位到具体分片,否则 mongos 需要将查询分发到所有分片,再等待响应返回。
  • 避免单调递增或递减 单调递增的 sharding key,数据文件挪动小,但写入会集中,导致最后一篇的数据量持续增大,不断发生迁移,递减同理。
分片策略

1、基于范围的分片

  • 优点: Mongos 可以快速定位请求需要的数据,并将请求转发到相应的 Shard 节点中。

  • 缺点: 可能导致数据在 Shard 节点上分布不均衡,容易造成读写热点,且不具备写分散性。

  • 适用场景:分片键的值不是单调递增或单调递减、分片键的值基数大且重复的频率低、需要范围查询等业务场景。

2、基于 Hash 值的分片

MongoDB 计算单个字段的哈希值作为索引值,并以哈希值的范围将数据拆分为不同的块(Chunk)。

  • 优点:可以将数据更加均衡地分布在各 Shard 节点中,具备写分散性。
  • 缺点:不适合进行范围查询,进行范围查询时,需要将读请求分发到所有的 Shard 节点。
  • 适用场景:分片键的值存在单调递增或递减、片键的值基数大且重复的频率低、需要写入的数据随机分发、数据读取随机性较大等业务场景。

分布式锁

了解了 MongoDB 的分布式锁实现机制后,我们再来看看常见的分布式锁问题:

  1. 锁信息如何持久化?

客户端在写 MongoDB 时,使用 writeConcern majority,这样保证即使发生了主从切换,锁信息也不会丢失。

  1. 如何防止客户端 A 释放客户端 B 获得的锁?

每个进程加锁时会在锁资源中设置一个携带机器和 PID 信息的标志,在释放锁时会判断这个标志,防止错误释放。

  1. 如何避免客户端进程挂了,导致锁永远不会释放?

采用租约的方式,进程在获得锁之后,要启动一个后台线程定期续约。如果超过 15 分钟没有续约,则这个锁可以被其他进程抢占。

和其他大多数系统不同的是,MongoDB 没有使 用 TTL 来完成租约,而是记录最后一次续约的时间,将抢占操作交给客户端进程来实现。

  1. 如何避免机器时钟不同步带来的问题?

不同的客户端之间,以及客户端机器和 MongoDB 服务端的时钟可能并不同步。时钟不同步可能会对续租、发起抢占的操作造成影响。

比如 MongoDB 发生了主从切换,但是从节点的时间提前了几分钟,又或者主节点在 NTP 时钟对齐后时钟瞬间提前了几分钟等。这样可能会导致之前的正常续租失效,锁被异常抢占。为了避免时钟跳变带来的影响, MongoDB 内核代码中设置了 15 分钟没有续约才失效,如果 NTP 时钟对齐频繁一些,基本上是不会有啥问题。

  1. 如何避免进程停顿(如 GC)和网络延迟等带来的影响?

进程停顿:客户端进程 A 拿到锁之后,由于其他操作(或者 GC 等)停顿了几分钟,然后再去操作临界资源。但是再停顿期间,可能由于没有续约导致锁被客户端 B 抢占了。此时就存在竞争风险。

网络延迟:和进程停顿的场景类似,也有可能 2 个客户端同时"加锁成功"的情况。

MongoDB 官方文档中明确说明无法 100% 消除这种场景。业界通常的解决方法有:

a. 调大续约超时。MongoDB 推荐的设置为 15 分钟,已经是很长的时间了,现实中很少会有 GC 停顿或者网络请求长达 15 分钟。

b. 使用(严格递增的) fencing token. 进程 A 拿到锁时, 得到的 token 是 v1,然后 GC 导致续约卡住了。然后进程 B 抢占了锁,得到的 token 是 v2 并在要保护的系统上操作了数据。此时进程 A 再使用 v1 的锁再去操作数据时,会由于 token 版本太低被拒绝。这种机制需要第3方受保护的系统支持 token 的递增判断,因此会带来一定的系统复杂度。

有读者可能会认为这个解决方案有点重,实现起来比较繁琐,还不如 TTL 方案直观。我个人的建议是:对于可用性和一致性要求高的系统,尽量不要在关键链路上依赖 TTL,除非明确知道 TTL 方案带来的风险,并确保能承受该风险。

相关推荐
weixin_462446231 分钟前
使用 Go 实现 SSE 流式推送 + 打字机效果(模拟 Coze Chat)
开发语言·后端·golang
JIngJaneIL20 分钟前
基于springboot + vue古城景区管理系统(源码+数据库+文档)
java·开发语言·前端·数据库·vue.js·spring boot·后端
小信啊啊42 分钟前
Go语言切片slice
开发语言·后端·golang
Victor3562 小时前
Netty(20)如何实现基于Netty的WebSocket服务器?
后端
缘不易3 小时前
Springboot 整合JustAuth实现gitee授权登录
spring boot·后端·gitee
Kiri霧3 小时前
Range循环和切片
前端·后端·学习·golang
WizLC3 小时前
【Java】各种IO流知识详解
java·开发语言·后端·spring·intellij idea
Victor3563 小时前
Netty(19)Netty的性能优化手段有哪些?
后端
爬山算法3 小时前
Netty(15)Netty的线程模型是什么?它有哪些线程池类型?
java·后端
白宇横流学长4 小时前
基于SpringBoot实现的冬奥会科普平台设计与实现【源码+文档】
java·spring boot·后端