本文是对 小白debug 发布的视频 8年程序员看完都哭了? MongoDB是什么?架构是怎么样的? 做的学习笔记。更清晰生动的讲解请看原视频。
引言
如果老板突然甩给你一个任务:
“做一个游戏平台,能支撑十亿量级用户的数据写入和存储,用户信息包括 ID、装备、节日活动参与情况,还要支持扩展各种属性字段以及多维度查询。”
第一反应会想到 MySQL,但当需求逐渐变复杂,MySQL 似乎并不是最合适的选择。
1. MySQL 的局限性
假设我们使用 MySQL 存储游戏用户数据,每条记录包括 ID、装备、活动参与情况 等字段。如果需要支持各种节日活动,比如春节、端午节、劳动节等,该怎么设计表结构?
最直接的想法是给每种可能的活动都预留一个字段,比如 is_join_spring
、is_join_valentine
、is_join_halloween
。为了查询方便,可能还会为这些字段加上索引。
但这样做有几个致命问题:
空间浪费
大多数玩家不会参加所有活动,很多预留字段用不上。表结构僵化
游戏迭代频繁,每次增加新活动,都要改表结构,改索引,甚至重建表。多维度查询复杂
比如“没参加过情人节活动的用户有哪些?”
如果活动字段越来越多,查询条件就会越来越复杂,索引也会越来越臃肿。高并发下的写入瓶颈
当数据规模上升到 十亿级,MySQL 的单机写入能力会迅速遇到瓶颈。
所以,当面对“字段动态扩展 + 多维度查询 + 高并发写入”的场景时,MySQL 逐渐显得力不从心。
二、MongoDB 是什么
一句话概括:MongoDB 是一个“文档型数据库”,可以简单理解为数据结构更灵活的 MySQL。
在 MySQL 中,表是由 行(Row) 和 列(Column) 组成,每一行的数据结构必须严格一致。在 MongoDB 中,抛弃了“列”的概念。
文档(Document):将多个列聚合到一个类似 JSON 的数据结构里,不用预留没用到的字段,这个"JSON"数据结构就叫做文档。
- 每个文档都有一个主键ID字段(和MySQL表的主键ID是一个意思) 用来唯一定位数据。
- 文档内部可以随意添加字段,文档之间的字段不需要完全一致,比如A文档有姓名字段,B文档可以没有。这样就不需要像MySQL那样提前定义表结构。
- 类似MySQL数据表里的一行数据,就是一个文档。
集合(Collection):类似MySQL的多行数据可以组合到一起构成一张数据表,多个文档也可以组合到一起构成一个集合(Collection)。
{
"_id": 1,
"username": "player001",
"equipments": ["sword", "shield"],
"events": {
"valentine": true
}
}
{
"_id": 2,
"username": "player002",
"equipments": ["bow"],
"events": {}
}
如果说MySQL是一个用于读写数据表行列的服务进程 那MongoDB本质上就是个用于读写集合文档的服务进程。
三、MongoDB的实现原理
3.1 存储层
3.1.1 BSON:更高效的存储格式
虽然 MongoDB 表面上看起来是 JSON,但底层用的是 BSON(Binary JSON)。原因很简单:JSON 只支持字符串和数字等基础类型,如果要存储二进制数据,比如图片或音频,必须先进行 Base64 编码,效率太低。BSON 扩展了 JSON,支持二进制、日期等类型。
3.1.2 数据页与 WT 文件
有了BSON文档 下一步就是考虑将它们持久化到磁盘中。
MongoDB 使用 WiredTiger 存储引擎(简称 WT)。当写入 BSON 文档时,WiredTiger 会把文档存储在一个个 32KB 的数据页(Page) 中,并把这些数据页组织成一个个以 .wt
为后缀的磁盘文件。
为什么要拆成 32KB 的数据页?
- 如果所有文档直接拼成一个大文件,每次查询都要全量读入,非常低效。
- 拆分成小的数据页,只需加载需要的部分,大大降低磁盘 IO。
3.1.3 B+树索引与 Copy-on-Write
问题:如果我们已知某个文档主键ID,怎么快速找到包含这个文档的数据页呢?
为了快速查找某条文档,MongoDB 为 _id
字段(主键)默认创建 B+树索引:
为每个数据页加入页号,由于每个文档本身就自带一个主键ID,可以按主键大小排序 将每个数据页里最小的主键序号 和所在页的页号提出来放到一个新生成的数据页中 并给数据页加入层级的概念,这样可以通过上层的数据页快速缩小查找范围,最终定位到要查的数据页。
为其他文档字段创建索引,比如用户名字段 这样能快速查找到 “名字为XX的用户有哪些”,这是辅助索引。
MySQL更新B+树的数据页时,为了防止并发写冲突,从根到叶子节点的搜索中会加入短暂内存锁,并对目标页的行记录加锁。MongoDB写数据时,几乎不对数据页加锁,直接复制个新的数据页出来写,也就是所谓的写时复制(Copy and Write),这样原来的数据页还能对外提供读操作,写操作则在新的数据页上进行,两者互不影响。之后再找机会将复制出来的页合并到原有的B+树结构中,这样并发读写性能更好。
3.1.4 Cache + WAL + Checkpoint
有了索引,查询数据是变高效了,但数据本质上还是在磁盘里,每次查询都要读磁盘。
MongoDB 为了优化读写性能,引入了三层机制:
Cache(缓存)
经常访问的数据页会被加载到内存缓存中,查询优先从缓存读取。WAL(Write-Ahead Logging)
对所有写操作 都先将变更行为 记录到一个叫 Journal Buffer的缓冲区里 然后再更新到数据页中。Journal Buffer的数据,会定时刷新到磁盘的 Journal 文件中。如果服务进程崩溃了,那进程重启后就能通过Journal 文件找到历史操作记录重做数据,尽可能保证数据不丢失。
相比于将Cache的数据直接写入到磁盘,Journal 文件是顺序写入的,效率更高。
- Checkpoint(检查点)
脏页(Cache 中修改过但未落盘的数据页)会在合适的时机批量写入磁盘,减少频繁随机写带来的性能损耗。
因为数据已经安全写入磁盘,所以在这个时间点之前的Journal 日志就可以删除了,不再需要保留这些历史操作记录。
Wired Tiger是什么?
- 到这里我们通过Bson文档代替了MySQL的行列的概念,让存储格式更加灵活,将文档放入数据页和WT文档中,实现了高效的磁盘存储。
- 通过变种B+树索引和写时复制机制实现了快速数据查找和高并发写入。
- 为了进一步提升性能引入了Cache 把热点数据放到内存中,大幅减少了磁盘IO。
- 用写前日志Journal 和Checkpoint 机制保证了数据持久化。
它们共同构成了Wired Tiger存储引擎 并对外提供一系列函数接口,比如Update用于更新数据,Search用于查询数据。MongoDB查询语句最终都会转换成 Wired Tiger提供的函数接口调用,比如Update会转换为Update方法,Find会转换为Search方法。
问题:读写MongoDB用的查询语句是怎么转成存储引擎的函数接口的呢?这就需要介绍Server层了。
3.2 Server 层
存储引擎 WiredTiger 提供了底层的读写接口,但我们平时写的是 MongoDB 的查询语句:
db.players.find({ "events.valentine": false })
MongoDB 的 Server 层就是连接客户端与存储引擎的中间层,负责以下工作:
- 连接管理:管理所有客户端的请求连接
- 查询解析器:解析查询语句语法,判断有无语法错误
- 查询优化器:根据查询条件选择最优索引,生成执行计划。
- 执行器:按照执行计划调用 WiredTiger 提供的接口。
Server层和存储引擎层共同构成了一个完整的文档数据库 它就是我们常说的MongoDB数据库。Server 层与存储引擎通过接口完全解耦,这也是 MongoDB 能支持多种存储引擎(如 MMapV1、WiredTiger)的原因。
四、MongoDB的高扩展与高可用
当数据规模达到 十亿级,单机 MongoDB 无论在 CPU、内存还是磁盘上都会遇到瓶颈。
MongoDB 提供了 分片(Sharding)+ 副本集(Replica Set) 机制来解决高扩展性和高可用性的问题。
4.1 分片(Sharding)实现高扩展
- 按主键 ID 范围切分数据,比如:
- 0 ~ 1000 万 → 分片 1
- 1000 万 ~ 2000 万 → 分片 2
- 以此类推。
- 每个分片就是一个独立的 MongoDB 实例。
- 分片分散部署在不同机器(每个机器叫一个Node)上,通过增加节点实现水平扩展。
问题:客户端应用怎么知道某条数据存储到哪个分片上?
答:通过Mongos 路由服务,客户端不会直接访问分片,而是通过 Mongos 路由:
- Mongos 根据查询条件计算数据在哪个分片;
- 把请求转发到对应分片,汇总结果并返回给客户端。
Mongos的配置信息来自于**配置服务器 Config Server **,每个分片都连接Config Server 并主动上报自身信息,服务器存储了有哪些分片 以及每个分片负责哪些数据范围等信息
4.2 副本集(Replica Set)实现高可用
问题: 如果其中一个Node挂了,Node里所有分片都无法对外提供服务,怎么做到高可用?
每个分片内部通常是一个副本集,类似于 MySQL 主从复制:
- 主节点(Primary):负责写入请求。
- 副本节点(Secondary):实时同步主节点数据,并支持只读请求。
- 当主节点宕机时,副本节点通过选举自动升级为新主节点,保证系统不中断。
五、举例说明
下面以一次查询为例说明的完整的数据流。
- 客户端发送查询请求到 Mongos。
- Mongos 根据缓存的分片信息确定目标分片。(必要时向Config Server刷新分片信息)
- 将请求转发到对应的分片副本集;
- 请求先到达分片的Server层,经过解析查询,优化器选择索引,生成执行计划。
- 对于读操作:
- 调用 WiredTiger:
- 先查 Cache,有则直接返回;
- 如果 Cache 没有,则加载磁盘上的数据页到Cache;
- 分片返回查询结果,Mongos 汇总并返回给客户端。
- 调用 WiredTiger:
- 对于写操作:
- 变更操作记录到Geno文档中,开启写时复制;
- WideTiger结合CheckPoint机制 将修改后的数据页写回磁盘;
- 写操作完成,分片主节点将数据实时同步给副本节点,当主节点和足够数量的副本节点都写入成功后会返回写入确认给Mongos;
- Mongos 收到所有相关分片的写入确认后向客户端返回响应。
- 对于读操作: