找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

2505

积分

0

好友

347

主题
发表于 14 小时前 | 查看: 1| 回复: 0

你是一个程序员,老板要你做个游戏平台,支撑十多亿游戏用户数据的写入和存储。

游戏用户包含多种字段,比如 id、装备、是否参与过节日活动等。随着功能不断迭代,未来还需要支持扩展各种属性字段以及多维度查询,例如“没参加过情人节活动的剑士有哪些?”。

那么,你会选择什么技术来存储如此庞大的游戏数据呢?

提到数据存储,我们首先会想到使用 MySQL 这样的关系型数据库。常规做法是将 id、装备、活动等字段设计成一张类似 Excel 的二维数据表。

为了支撑未来的多维度查询,我们可能不得不为每个潜在的活动属性预留字段,并可能为它们加上索引。例如,预留“春节”、“情人节”等活动字段。但实际情况是,大多数游戏角色并不会参与所有活动,导致大量预留字段空置,造成存储空间的浪费。

MySQL表结构预留字段浪费示例

更麻烦的是,游戏功能迭代频繁,每次新增一个活动,都需要执行 ALTER TABLE 语句来修改表结构,这在运维上是个不小的负担。

每次增加活动都需要修改表结构

那么,是否存在一种既灵活又高效的存储方案呢?

有!业界常说,没有什么是加一层中间层不能解决的,如果有,那就再加一层。今天我们要引入的这层,就是 MongoDB

MongoDB在应用与数据间的角色

MongoDB是什么?

简单来说,你可以将 MongoDB 理解为 一个数据结构更灵活的 MySQL

MongoDB vs MySQL 核心区别:灵活性

在 MySQL 中,表由行(Row)组成,每行又由固定的列(Column)构成。

表由行组成

正是“列”这个固定结构,导致了前面提到的表结构扩展困难和字段预留浪费问题。

列的概念存在导致了问题

MongoDB 的解决思路很直接:抛弃“列”的概念。它将原本分散在多列的数据,聚合到一个类似 JSON 的数据结构里。对于用不到的字段,干脆就不存在。这个结构被称为 文档(Document)

MongoDB文档结构示例

每个文档都有一个唯一的 _id 字段,称为主键ID,其作用与 MySQL 表的主键完全相同,用于唯一标识一条数据。

文档主键ID标识

文档内部可以自由添加任意字段,且不同文档之间的字段结构无需保持一致。例如,文档A可以有“是否拜师”字段,而文档B没有。

文档间字段结构可不同

这意味着,你完全不需要像使用 MySQL 那样预先定义表结构。原来 MySQL 表中的一行数据,在 MongoDB 中就对应一个文档。

多个文档组合在一起,就形成了一个 集合(Collection),这与 MySQL 中的“表”概念类似。

MongoDB集合概念示意图

文档(Document)和集合(Collection)是 MongoDB 最核心的两个概念。如果说 MySQL 是一个用于读写表中行列数据的服务进程,那么 MongoDB 本质上就是一个用于读写集合中文档的服务进程。

我们通过 SQL 语句操作 MySQL,MongoDB 也有自己的一套查询语法。例如:

  • db.collection.find() 类似于 SELECT * FROM table
  • db.collection.updateOne() 类似于 UPDATE table SET ...

具体的语句对比如下图所示:

MongoDB查询语句与SQL对比

了解了基本概念后,让我们深入底层,看看 MongoDB 是如何实现这一切的。

BSON 编码

前面提到,文档看起来像 JSON。但标准的 JSON 主要支持字符串、数字等基础类型,若想存储二进制数据,通常需要额外的 Base64 编码,不够高效。MongoDB 作为存储系统,自然要支持高效的二进制读写。因此,它在 JSON 基础上进行了扩展,直接支持二进制等更多数据类型,形成了 Binary JSON,简称 BSON

JSON到BSON的编码过程

数据页

有了 BSON 文档,下一步就是将它们持久化到磁盘。

就像 Excel 表在磁盘上是一个 .xls 文件,由 BSON 文档组成的集合,在 MongoDB 的默认存储引擎中,会被写入以 .wt 为后缀的文件里。

数据集合与.wt文件关系

随着集合增大,文件也会变大。直接读写整个大文件效率很低,因此 MongoDB 将数据拆分成一个个 数据页,每个页大小固定为 32KB

数据页组织方式(每个32KB)

当我们需要读写某些文档时,只需加载对应的数据页即可,无需读取整个文件,这大大减少了磁盘 I/O 开销。

变种 B+ 树索引

现在,集合中的文档已经分散在多个 32KB 的数据页中,这些数据页共同组成了 .wt 文件。

问题来了:如果我们知道某个文档的 _id,如何快速定位到它所在的数据页呢?

解决方案是建立索引。MongoDB 为每个数据页编号,并根据文档主键 _id 的大小进行排序。它将每个数据页中的最小 _id 和对应的页号提取出来,放入新生成的上层数据页中,并引入“层级”的概念。

通过这种方式,查询可以从上层页开始,快速缩小范围,最终定位到目标数据页。这种加速查找的树形结构,就是我们熟悉的 B+树索引

B+树索引结构示意图

以上是针对主键建立的索引,称为 主键索引

主键索引工作原理

同理,也可以为其他字段(如用户名字段)建立索引,以实现快速查找,这类索引称为 辅助索引

非主键(辅助)索引示例

这一点上,MongoDB 的 B+ 树与 MySQL 的原理几乎一致。但关键在于,MongoDB 在写入数据页时,采用了 写时复制(Copy On Write) 机制。它几乎不对原数据页加锁,而是复制一个新页进行修改,修改完成后再合并回 B+ 树。这样,读操作可以继续访问原数据页,写操作则在新页上进行,两者互不阻塞,极大地提升了并发读写性能。

从效果上看,这就像在原 B+ 树上挂接了多个复制页,因此 MongoDB 实际使用的是一种 变种 B+ 树

MongoDB变种B+树结构

请注意:网上有些资料声称 MongoDB 底层使用 B 树(非叶子节点包含完整数据),这是不准确的。应以本文介绍为准。

加入缓存

有了索引,查询路径优化了,但数据本身仍在磁盘上,每次查询都读磁盘还是太慢。怎么办?

在服务进程中加入 缓存(Cache)。将经常访问的热点数据页放入内存中的 Cache,查询时优先查找 Cache,找不到再去读磁盘。这能显著减少磁盘 I/O,提升查询速度。

缓存Cache在查询流程中的作用

为了防止内存被撑爆,需要一套淘汰策略。MongoDB 采用经典的 最近最少使用(Least Recently Used, LRU) 算法,淘汰掉最久未被访问的数据页。这样既能控制内存占用,又能确保缓存中保留的都是热点数据。

LRU(最近最少使用)淘汰策略

写前日志 Journal

Cache 中的数据页毕竟在内存里。如果服务进程突然崩溃,这些未写入磁盘的修改就会丢失。如何解决?

写前日志(Journal) 机制。任何写操作在修改内存中的数据页之前,都会先将变更记录(日志)顺序写入一个称为 Journal Buffer 的缓冲区,随后 Buffer 中的数据会定期刷新到磁盘的 Journal 文件中。

Journal日志写入流程

如果服务崩溃,重启后就可以读取 Journal 文件,重做(Redo)日志中的操作,从而尽可能保证数据不丢失。

你可能会问:既然都要写磁盘,为什么不直接把 Cache 里的脏数据页写回磁盘,而要额外写 Journal 日志?

关键在于写入方式。Journal 文件是顺序写入的,而脏数据页在磁盘上的位置是随机分散的。在机械硬盘上,顺序写的性能可以是随机写的几十倍甚至上百倍。因此,许多存储系统(如 Redis、Elasticsearch 等)都采用先写日志的方式,来保证数据的一致性和完整性。这种机制被称为 预写式日志(Write-Ahead Logging, WAL)

WAL机制在多种存储系统中的应用

Checkpoint 机制

注意,目前数据修改仍然停留在内存(Cache)中。如果等到内存快满了才一次性写入磁盘,会导致写入量巨大,性能骤降。如果写得太频繁,又会过度占用磁盘 I/O,影响读操作。

这就需要 检查点(Checkpoint) 机制。系统会定期(例如每隔 60 秒或日志达到一定大小)将内存中已修改但未写入磁盘的“脏页”,批量、安静地持久化到磁盘数据文件中。

由于数据已经安全落盘,这个时间点之前的 Journal 日志就完成了使命,可以被安全删除,从而回收磁盘空间。

WiredTiger 是什么?

至此,我们已经通过一系列组件构建了一个高性能的存储引擎:

  • 用灵活的 BSON 文档取代固定的行列。
  • 通过数据页和 .wt 文件组织磁盘存储。
  • 通过变种 B+ 树索引和写时复制实现高效查询与高并发写入。
  • 引入 Cache 缓存热点数据,减少磁盘 I/O。
  • 通过 Journal 日志和 Checkpoint 机制保证数据持久性。

这些组件共同构成了 MongoDB 的默认存储引擎—— WiredTiger。它对外提供 update()search() 等一系列函数接口。

WiredTiger存储引擎架构总览

我们平时编写的 MongoDB 查询语句,最终都会转化为对 WiredTiger 引擎接口的调用。例如,updateOne() 会调用 update() 方法,find() 会调用 search() 方法。

MongoDB查询语句到存储引擎接口的转换

那么,下一个问题来了:查询语句是如何转换成存储引擎接口调用的呢?这就引出了 Server 层

Server 层架构

Server 层,本质上是 MongoDB 查询语句与底层存储引擎之间的中间层。

Server层在MongoDB中的位置

它的内部模块与常见的数据库系统类似:

  1. 连接管理:管理客户端的网络连接。
  2. 查询解析器:解析查询语句的语法。
  3. 查询优化器:根据数据统计信息和规则,选择最优的索引,生成查询执行计划。
  4. 执行器:根据执行计划,调用 WiredTiger 存储引擎提供的接口函数。

Server层内部模块详解

Server 层 加上 存储引擎层,共同组成了一个完整的单机版 MongoDB 数据库。

完整的MongoDB单机数据库组成

值得注意的是,Server 层与存储引擎层通过接口解耦。这意味着,只要实现了规定的接口,就可以作为存储引擎接入。MongoDB 早期使用 MMAPv1 引擎,后来才引入并默认使用性能更优的 WiredTiger。

Oplog 是什么

你听过“删库跑路”吧?为了防止误操作(如误删集合)导致数据丢失,MongoDB 的 Server 层会将所有对数据库的变更操作记录到另一个磁盘日志文件中,这个日志就是 操作日志(oplog)

它主要用于数据复制(下文会提到)和点-in-时间恢复。如果误删了数据,可以利用 oplog 进行恢复。

你可能会疑惑:WiredTiger 已经有 Journal 日志了,为什么还要 oplog?关键在于作用范围不同。Journal 是存储引擎层用于保证 数据持久性崩溃恢复 的物理日志。而 oplog 是 Server 层记录的 逻辑操作日志(如“在集合A插入文档B”),主要用于集群间的数据复制和更高级别的数据恢复场景。

单机 MongoDB 的局限

如果你熟悉 MySQL 的架构,会发现单机 MongoDB 的架构与其惊人地相似:本质上都是一个通过 B+ 树索引,在数据页中读写数据的单机服务进程。

在 WiredTiger 引擎的加持下,MongoDB 的单机性能非常出色。但是,面对文章开头提出的“十亿级用户数据”场景,单机的 CPU、内存、磁盘迟早会成为瓶颈。单机架构在高扩展性高可用性方面存在天然不足。那么 MongoDB 是如何解决这些问题的呢?

高扩展性:分片(Sharding)

数据量太大?那就“切”开。将10亿条用户数据,按照主键 _id 的范围进行切分。例如,0-1千万条数据放在一个 MongoDB 实例中,1千万-2千万条放在另一个实例中。每个这样的数据子集称为一个 分片(Shard)

每个分片部署在独立的服务器(节点,Node)上。通过增加节点,可以实现存储容量和计算能力的水平扩展。

但这就引入了新问题:客户端如何知道某条数据存储在哪个分片上?

解决方案是引入一个 路由服务,即 mongos。mongos 根据查询条件,计算出数据所在的分片,将请求转发给对应的分片,然后收集、合并、排序各分片的返回结果,最终交给客户端。当读写请求量增大时,mongos 本身也可以部署多个实例进行扩展。

Mongos路由服务在分片集群中的作用

那么,mongos 的路由规则从哪里来?来自 配置服务器(Config Server)。所有分片都会向 Config Server 注册自己的信息(如负责的数据范围),因此 Config Server 掌握了集群的完整“地图”。

Config Server存储集群元数据

高可用:副本集(Replica Set)

新问题又来了:如果某个承载分片的节点宕机了,该分片的数据就不可用了。如何实现高可用?

答案是 复制(Replication)。为每个分片数据创建多个副本。在一个副本组内,一个节点被指定为 主节点(Primary),负责处理写操作;其他节点作为 副本节点(Secondary),从主节点异步同步数据。

副本节点不仅可以提供读服务(分担主节点压力),更重要的是,当主节点故障时,副本节点可以通过选举机制,自动选出一个新的主节点继续提供服务,从而保证系统的可用性。这种由一个主节点和多个副本节点组成的集群,称为 副本集(Replica Set)。其概念类似于 MySQL 的主从复制。

副本集(Replica Set)结构

分布式 MongoDB 集群全景

现在,我们将所有组件组合起来:

  • 分片(Sharding) 解决数据量和性能的横向扩展问题。
  • 路由(mongos) 负责请求的路由和结果的聚合。
  • 配置服务器(Config Server) 存储集群的元数据和分片信息。
  • 副本集(Replica Set) 为每个分片提供数据冗余和高可用保障。

这四者共同构成了一个功能完备的 分布式 MongoDB 集群

分布式MongoDB集群完整架构图

一次完整的请求流程

无论是读还是写,客户端都直接连接 mongos 发起请求。

  1. 路由定位mongos 解析请求,根据 Config Server 提供的分片信息,确定请求涉及哪些分片。
  2. 请求分发mongos 将请求转发给相关分片对应的副本集主节点。
  3. 分片内部处理:请求进入分片实例的 Server 层,经过解析、优化、执行,最终调用 WiredTiger 引擎接口。
    • 读请求WiredTiger 先查内存 Cache,未命中则从磁盘加载数据页到 Cache,然后返回数据。
    • 写请求:操作被记录到 Journal 日志;数据在 Cache 中通过写时复制修改;Checkpoint 机制负责将脏页异步刷盘。同时,主节点会将此写操作记录到 oplog,并同步给副本集中的从节点。
  4. 结果汇聚:对于读请求,mongos 收集各分片返回的数据,进行合并、排序等处理后返回客户端。对于写请求,mongos 需要等待所有相关分片的主节点确认写入成功(或达到指定副本数),才向客户端返回成功响应。

通过这样一套精密的架构,MongoDB 实现了从灵活的数据模型,到高效的单机存储引擎,再到可无限扩展、高可用的分布式集群的完整技术栈。它特别适合应对业务模式快速变化、数据结构灵活、数据量持续增长的应用场景,例如游戏用户画像、物联网设备数据、内容管理系统等,是构建现代 分布式系统 的重要技术选型之一。

希望这篇深入浅出的解析,能帮助你真正理解 MongoDB 的设计精髓。如果你想了解更多关于数据库、中间件或系统架构的深度内容,欢迎访问 云栈社区 与更多开发者交流探讨。




上一篇:架构师成长路径:从技术专家到系统设计师的四个能力与三个阶段
下一篇:高盛报告解读:GPU与ASIC在AI推理成本上的竞争格局分析
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-1-27 16:58 , Processed in 0.319308 second(s), 38 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表