核心内容摘要
萌宝的专属甜蜜:当“小孩子喂姐姐吃七八”成为爱的代名词
你是一个程序员 老板要你做个游戏平台支撑十多亿游戏用户数据的写入和存储。
游戏用户包含多种字段比如id, 装备、是否参与过节日活动等功能不断迭代需要支持扩展各种属性字段以及多维度查询。
比如没参加过情人节活动的剑士有哪些那么问题就来了你会选择使用什么存储这么大量的游戏数据聊到存储很容易想到可以使用MySQL数据库将id、装备活动等做成一个像excel的数据表。
为了支持多维度查询我们需要为每个可能的属性都预留字段甚至加上索引。
比如预留春节、情人节活动等各种字段但大多数角色并不会全部活动都参与所以很多预留的字段都用不上浪费空间。
很多预留的字段都用不上而且游戏迭代频繁每次增加活动时都需要修改表结构很麻烦。
每次增加活动都需要修改表结构那么问题就来了有没有一种既灵活又高效的存储方案有没有什么是加一层中间层不能解决的如果有那就再加一层。
这次我们要加的中间层是 MongoDB。
MongoDB是什么MongoDB是什么先说结论MongoDB可以简单理解为就是个数据结构灵活点的Mysql。
mongodb是数据结构灵活点的MysqlMysql的表是由多个行组成每个行又由多个列组成。
表由行组成由于列这一概念的存在导致了前面提到的表扩展和字段预留空间浪费的问题。
列的概念存在导致了问题那我们索性抛弃列这个概念将原来的多个列聚合到一个长得像json的数据结构里对于没用到的字段不用预留干脆就没有我们管它叫文档, document就像这样文档document每个文档都有一个_id字段也叫主键ID, 跟mysql表的主键ID是一个意思用来唯一定位数据。
主键ID文档内部想加什么字段就加什么字段文档和文档之间的字段不需要完全一致比如A文档有是否拜师字段B文档没有。
文档和文档之间的字段不需要完全一致这样就完全不需要像 MySQL 那样提前定义表结构。
之前MySQL数据表里的一行数据现在就成了一个文档。
既然MySQL的多行数据可以组合到一起构成一张数据表那多个文档也可以组合到一起构成一个集合又叫collection。
collection文档和集合都是MongoDB里的核心概念。
如果说mysql是一个用于读写数据表行列的服务进程那MongoDB本质上就是个用于读写集合文档的服务进程。
我们通常会使用SQL语句读写MySQL的数据MongoDB也有自己的查询语法而且看起来跟SQL差异比较大比如MongoDB的find语句类似于mysql的select语句。
updateOne就类似于mysql的update语句。
对应关系就像下面这样查询语句接下来我们假装不了解MongoDB来看下它是怎么实现的。
看之前你点赞了吗关注了吗谢谢BSON 编码前面提到文档长得像JSON但JSON只支持数字字符串这类基础类型想要表达二进制这类常用存储类型还要做一层Base64编码不够高效既然MongoDB的定位是存储那当然要支持二进制的高效读写。
所以我们在JSON的基础上做下扩展让它直接支持二进制等数据类型也就成了二进制JSONBinary JSON简称BSON。
BSON数据页有了BSON文档下一步就是考虑将它们持久化到磁盘中。
就像excel数据表在磁盘上是个.xls 文件我们也可以将一个个BSON文档组成的集合写到磁盘里.wt为后缀名的文件上。
wt后缀文件集合越大磁盘上的文件也就越大。
直接读写一个大文件里的全部数据会很慢所以将数据拆成一个个数据页每个页大小 32KB。
每个页大小 32KB现在如果我们需要通过服务进程读写某些个BSON文档数据就只需要读写磁盘里的某几个数据页就好不需要加载整个wt文件大大减少了 IO 开销。
变种 B 树索引集合里的多个文档已经分散到多个32KB的数据页里多个数据页又组成了.wt文件。
那问题就来了如果我们已知某个文档主键_id怎么快速找到包含这个文档的数据页呢好办可以为每个数据页加入页号。
由于每个文档本身就自带一个_id主键我们可以按主键大小排序将每个数据页里最小的主键序号和所在页的页号提出来放入到一个新生成的数据页中并且给数据页加入层级的概念。
这样我们就可以通过上层的数据页快速缩小查找范围最终定位到要查的数据页。
通过这个方式加速查找数据页的过程。
现在页跟页之间看起来就像是一棵倒过来的树这棵可以加速查找数据页的树就是我们常说的B树索引。
B树索引上面提到的是针对主键的索引叫主键索引。
主键索引按同样的思路也可以为其他文档字段去建立索引比如用户名字段这样我们就能快速查找到名字为 xx 的用户有哪些这就是所谓的辅助索引。
辅助索引这一点跟mysql的B树几乎一模一样但不同的是mysql更新B树的数据页时为了防止并发写冲突从根到叶子节点的搜索中会加入短暂内存锁并对目标叶的行记录加锁而MongoDB写数据时几乎不对数据页加锁直接复制个新的数据页出来写也就是所谓的写时复制, Copy On Write这样原来的数据页还能对外提供读操作写操作则在新的数据页上进行两者互不影响。
后面再找机会将复制出来的页合并到原有的B树结构中这样并发读写性能更好。
从效果上来看就像是在原来的B树基础上挂了多个复制页本质上是变种B树。
变种B树注意网上有很多说mongodb底层用的是非叶子节点包含完整文档数据的b树别听他们的以我为准。
加入缓存有了索引查询数据是变高效了但数据本质上还是在磁盘里每次查询都要读磁盘略慢了些。
怎么办呢我们可以在服务进程里加个缓存也叫Cache把经常访问的磁盘热点数据页放到cache里查询优先查cache查不到再去查磁盘这样磁盘IO变少查询就快多了。
加个缓存怕数据量太大内存扛不住还可以根据一些策略删除掉一些内存。
比如可以将最近最少使用的内存删掉也就是 Least Recently Used, LRU,这样不仅解决了内存过大的问题还让 缓存里的数据全是热点数据。
真是一箭双雕。
LRU写前日志 Journal上面提到的Cache里的数据页本质上都是内存。
如果服务崩了内存里的数据页还没来得及写入磁盘那数据不就丢了吗有解法吗有对所有写操作都先将变更行为记录到一个叫Journal Buffer的缓冲区里然后再更新到数据页中Journal Buffer的数据会定时刷到磁盘的Journal文件中。
Journal是什么如果服务进程崩溃了那进程重启后就能通过Journal文件找到历史操作记录重做数据尽可能保证数据不丢失。
这时候问题就来了我有这功夫更新 Journal 文件直接将 cache的数据写入到磁盘不香吗不太一样Journal文件 是顺序写入的cache 的内存数据是随机分散在磁盘各处的顺序写磁盘性能是随机写的几十倍所以很多存储系统在写数据时都会搞个日志来记录操作方便服务重启后进行数据对账确保数据的一致性和完整性这类操作就是所谓的 Write-Ahead Logging (WAL) 。
WAL是什么Checkpoint机制注意上面提到先记录Journal写操作再更新数据页此时数据依然在内存中那内存中的数据什么时候写入磁盘呢如果等内存满了再写一次写入量太大性能会很差。
如果写得太频繁又会占用磁盘IO影响读操作。
怎么办我们可以让系统定期把内存中已修改但未写入磁盘的数据页也就是脏页一次性批量写入磁盘。
这种定期批量写入的机制这就是所谓的Checkpoint机制。
因为数据已经安全写入磁盘了所以在这个时间点之前的Journal日志就可以删除了不需要再保留这些历史操作记录。
WiredTiger是什么到这里我们通过BSON文档这种可以包含任意字段的数据结构替代了mysql的行列的概念让存储格式更加灵活。
将文档放入数据页和wt文件中实现了高效的磁盘存储。
再通过变种B树索引和写时复制机制实现了快速数据查找和高并发写入。
为了进一步提升性能引入了Cache把热点数据放到内存中大幅减少了磁盘IO。
用写前日志Journal和Checkpoint机制保证了数据持久化。
WiredTiger它们共同构成了WiredTiger存储引擎。
并对外提供一系列函数接口。
比如update()用于更新数据search()用于查询数据。
我们平时写的mongodb查询语句最终都会转换成 WiredTiger 提供的函数接口调用。
比如updateOne()会转换为update()方法。
find()会转换为search()方法。
mongodb查询语句但问题就来了我们平时读写 mongodb 用的查询语句是怎么转成存储引擎的函数接口的呢那就需要介绍 Server 层了。
Server 层架构Server 层本质上是mongodb查询语句 和 WiredTiger 存储引擎之间的中间层。
Server 层它内部有一个连接管理模块用于管理来自客户端应用的网络连接。
还有一个查询解析器用于解析 MongoDB 的查询语句语法判断查询语句有没有语法错误比如字段名是否正确等。
再提供一个查询优化器用于根据一定的规则选择该用什么索引生成执行计划。
之后提供一个执行器根据执行计划去调用WiredTiger 存储引擎的接口函数。
server层内部server 层和存储引擎层共同构成了一个完整的文档数据库它就是我们常说的MongoDB 数据库。
MongoDB 数据库并且查询引擎和存储引擎层是通过接口函数进行解耦的换句话说就是只要实现了上面这些接口函数就能作为存储引擎与server层对接。
比如MongoDB 早期用的是 MMAPv1 存储引擎后来才支持的 WiredTiger。
现在 WiredTiger 已经成为默认的存储引擎。
oplog 是什么你听说过删库跑路吧为了防止数据库表被删除带来的影响 server 层会将历史上所有变更操作记录到磁盘上的日志文件中这个日志文件就是所谓的oplog。
一旦误删集合就可以利用oplog来恢复数据。
那么问题就来了wiretiger 有一个 Journal日志 也做类似的事情为什么还要多此一举评论区告诉我答案。
单机MongoDB如果你看过我之前做的「mysql」相关的视频你会发现mongodb和mysql的架构实现惊人的相似。
我甚至演都不演了很多素材都直接复用了。
如果说mysql本质上就是个通过b树读写数据页里行列数据的单机服务。
那mongodb就是个通过变种b树读写数据页里集合数据的单机服务。
MongoDB在WiredTiger存储引擎的加持下高性能是有了但高可扩展和高可用是一点没看到。
回到视频开头的问题面对十亿级数据量时单机CPU、内存、磁盘都会成为瓶颈。
我们先看下怎么解决扩展性问题。
高扩展性既然数据量太大那我们就「切」。
将10亿条游戏用户数据按主键ID范围切分0到1kw放一个MongoDB里1kw到2kw放另一个MongoDB里每个MongoDB只处理1kw条数据。
我们称每个MongoDB为一个分片。
再将多个MongoDB分片分散部署在多台机器上每台机器就是一个Node。
通过增加Node来缓解资源压力。
但这又引入了新问题客户端应用怎么知道某条数据存储在哪个分片上我们可以在客户端和分片之间加一层路由服务它可以根据查询条件计算出数据在哪个分片然后转发请求到分片中、收集分片的结果、合并排序后返回给客户端。
这个路由服务又叫mongos当读写请求量变大时mongos也可以扩展。
mongos是什么mongos的配置信息来自于配置服务器Config Server每个分片都连接config server并主动上报自身信息所以Config Server存储了有哪些分片以及每个分片负责哪些数据范围等信息。
Config Server是什么高可用到这里问题又又来了如果其中一个Node挂了那Node里所有分片都无法对外提供服务了。
怎么做到高可用送分题了属于是我们可以给每个分片都多加几个副本。
将分片分为主节点和副本节点。
主节点将数据实时同步给副本节点副本节点既可以对外提供读能力还能在主节点挂了的时候通过选举机制升级成新的主节点保证系统高可用。
这种由一个主节点和多个副本节点组成的集群就叫副本集Replica Set。
有点类似于mysql的主从模式。
Replica Set分布式MongoDB集群像这种通过多个MongoDB实例切分存储数据实现扩展性并通过mongos路由分发请求、聚合排序结果通过Config Server管理配置信息再给每个分片实例加入副本节点实现高可用的架构就叫分布式MongoDB集群。
接下来我们用一个实际例子将上面提到的内容串起来看看分布式MongoDB集群中的完整数据流程。
分布式MongoDB集群查询更新流程不管是读还是写客户端应用都会连接到mongos发起请求。
mongos 根据请求基于缓存的分片信息确定数据在哪个分片上。
必要时向 Config Server 刷新分片信息。
再将请求转发到对应的分片副本集注意这里可能涉及多个分片。
在每个MongoDB分片内部客户端请求先到达分片的server层经过查询解析器解析查询语法、查询优化器选择索引生成执行计划再给到执行器调用WiredTiger的函数接口。
• 对于读操作请求发送到WiredTiger存储引擎。
WiredTiger先检查Cache中是否存在所需数据页存在则直接返回。
否则从磁盘读取数据页加载到cache中再返回数据给mongos。
mongos收集各分片的查询结果进行合并、排序等处理最终返回给客户端。
• 对于写操作变更操作记录到Journal文件中同时复制一份Cache的数据页写入到复制页中。
WiredTiger结合Checkpoint机制将修改后的数据页写回磁盘。
写操作完成后分片主节点会将数据实时同步给副本节点。
当主节点和足够数量的副本节点都写入成功后分片会返回写入确认给mongos。
mongos收到所有相关分片的写入确认后最终向客户端返回写操作成功的响应。