MongoDB是目前最流行的文档型NoSQL数据库,在互联网公司应用非常广泛,特别是在大数据、高并发的场景下,MongoDB相比传统的关系型数据库有很多优势。但是很多人用MongoDB,只是把它当成一个可以存JSON的数据库,对它的底层原理和机制了解不多,用的时候经常踩坑,性能也上不去。今天就来深入剖析一下MongoDB的底层原理,从数据存储、索引、查询、复制、分片等几个方面,带大家深入理解MongoDB的底层机制。

一、文档模型:MongoDB的核心设计

MongoDB和传统关系型数据库最大的区别就是数据模型。关系型数据库用的是表、行、列的模型,数据是结构化的,每一行的字段都是一样的。而MongoDB用的是文档模型,数据以BSON文档的形式存储,一个文档就相当于关系型数据库里的一行,但是文档是灵活的,同一个集合(相当于表)里的不同文档可以有不同的字段,不需要预先定义表结构。

BSON是Binary JSON的缩写,是MongoDB发明的一种二进制序列化格式,和JSON类似,但是支持更多的数据类型,比如日期、二进制数据、正则表达式、ObjectId等等,而且是二进制的,解析速度比JSON快,存储也更紧凑。

一个典型的MongoDB文档长这样:

{
  "_id": ObjectId("507f1f77bcf86cd799439011"),
  "name": "张三",
  "age": 28,
  "email": "zhangsan@example.com",
  "created_at": ISODate("2017-10-06T00:00:00Z"),
  "tags": ["程序员", "PHP", "MongoDB"],
  "address": {
    "city": "北京",
    "street": "某某街道",
    "zip": "100000"
  }
}

可以看到,文档里可以有嵌套对象、数组,字段类型也很丰富,而且不同文档可以有不同的字段。这种灵活的文档模型,非常适合那些数据结构不固定、经常变化的场景,比如博客文章、用户评论、商品信息等等,不用像关系型数据库那样先建表、改表结构,直接存就行,开发效率很高。

但是灵活也有代价,因为没有固定的表结构,应用层需要自己保证数据的一致性和规范性,而且如果文档结构太乱,后期维护和查询都会很麻烦。所以用MongoDB的时候,虽然不用强制建表结构,但是在设计的时候还是应该有一个相对统一的文档结构,不要太随意。

还有一个重要的概念是id字段,每个文档都必须有一个id字段,作为文档的唯一标识,相当于关系型数据库里的主键。如果你插入文档的时候没有指定id,MongoDB会自动生成一个ObjectId类型的id。ObjectId是一个12字节的二进制数据,包含了时间戳、机器标识、进程ID和自增计数器,保证全局唯一,而且是按时间递增的,所以用_id做范围查询和排序效率很高。

二、存储引擎:WiredTiger的底层机制

存储引擎是数据库的核心,负责数据的存储和读取。MongoDB 3.2之后默认的存储引擎是WiredTiger,这是一个非常优秀的存储引擎,支持文档级锁、压缩、多版本并发控制(MVCC)等特性,性能和并发能力都比之前的MMAPv1引擎好很多。

WiredTiger的底层存储机制和MySQL的InnoDB有点类似,都是用B树索引,都有缓冲池,都支持事务和MVCC,但是也有一些区别。

首先是数据文件的组织方式。WiredTiger把每个集合的数据和索引都存在单独的文件里,文件名是集合的UUID,而不是集合名,这样集合重命名的时候不用改文件名。每个文件内部用B树组织,叶子节点存储实际的文档数据或者索引条目。

然后是缓冲池(Cache)。WiredTiger有一个内存缓冲池,默认大小是可用内存的一半,用来缓存热点数据和索引,减少磁盘IO。和InnoDB的缓冲池类似,WiredTiger的缓冲池也是用LRU算法淘汰冷数据,但是WiredTiger的缓冲池管理更灵活,可以根据负载动态调整。

还有就是压缩。WiredTiger支持数据和索引的压缩,默认用的是snappy压缩算法,压缩率不错,解压速度也很快。压缩可以大大减少磁盘占用,也能减少磁盘IO,因为读取的数据量小了,虽然解压需要一点CPU,但是总体来说性能是提升的,特别是对于IO密集型的应用。除了snappy,还可以选择zlib压缩,压缩率更高,但是更耗CPU,可以根据实际情况选择。

WiredTiger还支持多版本并发控制(MVCC),读写不阻塞。读操作不会阻塞写操作,写操作也不会阻塞读操作,因为每个读操作看到的是一个一致性的快照,不会被其他事务的修改影响。这大大提升了并发性能,特别是在读写混合的场景下,比表锁或者行锁的并发能力强很多。

还有一个重要的机制是预写日志(WAL),WiredTiger里叫Journal。和其他数据库一样,WiredTiger修改数据的时候,先写Journal,再改内存里的数据,然后定期把内存里的数据刷到磁盘上,这样即使宕机了,也可以通过Journal恢复数据,保证数据不丢失。Journal默认是100MB一个文件,满了就自动归档,也可以配置定期刷盘的间隔。

了解了这些底层机制,用MongoDB的时候就能更好地优化性能了。比如内存要给够,让缓冲池能缓存更多的热点数据;选择合适的压缩算法,平衡磁盘和CPU;合理配置Journal的刷盘策略,在性能和数据安全之间做权衡。

三、索引:MongoDB查询性能的关键

索引是数据库查询性能的关键,MongoDB也不支持。MongoDB的索引和关系型数据库的索引类似,也是用B树实现的,但是因为是文档模型,所以支持一些更灵活的索引类型。

最常用的是单字段索引,就是对文档的某个字段建索引,比如对name字段建索引:

db.users.createIndex({name: 1})

这里的1表示升序,-1表示降序,对于单字段索引来说,升序和降序区别不大,因为B树可以双向遍历。

然后是复合索引,就是对多个字段建索引,和关系型数据库的复合索引一样,要注意最左前缀原则:

db.users.createIndex({age: 1, created_at: -1})

这个索引可以加速按age查询、按age+createdat查询和排序,但是不能只按createdat查询,因为不满足最左前缀。

除了这些常规的索引,MongoDB还有一些特有的索引类型:

数组索引(Multikey Index):如果字段是数组类型,对这个字段建索引,会自动为数组里的每个元素建一个索引条目。比如tags字段是数组,对tags建索引之后,查询包含某个tag的文档就会很快。这是MongoDB很有特色的一个功能,关系型数据库要实现类似的功能需要建关联表,MongoDB直接用数组索引就搞定了。

文本索引(Text Index):对字符串字段建全文索引,可以做全文检索。比如对文章的标题和内容建文本索引,就可以做关键词搜索,类似一个简单的搜索引擎。当然,MongoDB的全文检索功能和专业的搜索引擎(比如Elasticsearch)比还是有差距的,简单的搜索够用,复杂的搜索还是建议用专业的搜索引擎。

地理空间索引(Geospatial Index):对经纬度字段建地理空间索引,可以做地理位置相关的查询,比如查询附近的人、查询某个范围内的地点,这在LBS应用里非常有用。MongoDB的地理空间索引支持2d和2dsphere两种,2d适合平面地图,2dsphere适合球面(地球),一般用2dsphere比较多。

哈希索引(Hashed Index):对字段做哈希之后建索引,主要用在分片集群的片键上,哈希索引的分布比较均匀,可以避免热点问题。哈希索引只能做等值查询,不能做范围查询和排序。

用MongoDB的时候,一定要根据查询模式建合适的索引,没有索引的查询会做全集合扫描,数据量大了之后非常慢。可以用explain()来查看查询的执行计划,看看有没有走索引,扫描了多少文档,然后针对性地优化。

当然,索引也不是越多越好,每个索引都会占用存储空间,而且会降低写入性能,因为写入的时候要维护所有的索引。所以要根据实际的查询需求建索引,不要给每个字段都建索引,也不要建重复的索引。

四、复制集:高可用和读写分离

MongoDB的复制集(Replica Set)是实现高可用的基础,和MySQL的主从复制类似,但是MongoDB的复制集功能更强大,自带自动故障转移,不需要额外的工具。

一个复制集通常由一个主节点(Primary)和多个从节点(Secondary)组成,主节点负责处理写请求,从节点从主节点同步数据,也可以处理读请求。如果主节点挂了,复制集会自动进行选举,从剩下的从节点里选出一个新的主节点,继续提供服务,整个过程一般在几秒到十几秒内完成,不需要人工干预。

复制集的数据同步机制和MySQL有点像,主节点把写操作记录到oplog(操作日志)里,从节点拉取主节点的oplog,然后在自己身上重放这些操作,从而保持数据一致。oplog是一个固定大小的集合,满了之后会覆盖旧的操作,所以oplog的大小要设置合理,太小的话,如果从节点宕机时间太长,oplog已经被覆盖了,从节点就没法增量同步了,只能全量重新同步。

复制集还支持读写分离,读请求可以发到从节点上,减轻主节点的压力。但是要注意,因为复制是异步的,从节点的数据可能会有延迟,所以对于一致性要求高的读请求,还是应该走主节点。MongoDB的驱动支持设置读偏好(Read Preference),可以选择从主节点读、从从节点读、优先从从节点读等等,根据业务需求灵活配置。

复制集里还有一个特殊的节点叫仲裁节点(Arbiter),仲裁节点不存数据,只参与选举投票,用来保证选举的时候有多数派。如果你的复制集只有两个节点,主节点挂了之后,剩下的从节点只有一票,达不到多数派,就选不出新的主节点,这时候加一个仲裁节点,就有三票了,就能正常选举了。仲裁节点不占什么资源,但是能大大提升复制集的可用性。

用MongoDB的时候,生产环境一定要用复制集,不要用单节点,单节点挂了服务就停了,数据也可能丢。用复制集,至少一主一从一仲裁,或者一主两从,这样才能保证高可用。

五、分片:水平扩展的利器

当数据量越来越大,单台服务器存不下了,或者读写压力太大,单台服务器扛不住了,这时候就需要分片(Sharding)了。MongoDB的分片功能非常强大,可以把数据水平分散到多台服务器上,实现存储和性能的水平扩展,而且对应用层是透明的,应用层不用关心数据存在哪个分片上,正常读写就行。

一个MongoDB分片集群由三个部分组成:分片(Shard)、路由(mongos)、配置服务器(Config Server)。

分片就是实际存数据的节点,每个分片都是一个复制集,负责存储一部分数据。数据按照片键(Shard Key)分散到不同的分片上,片键是你选择的一个字段或者几个字段的组合,MongoDB根据片键的值,把文档分配到不同的分片上。

路由(mongos)是应用层访问的入口,应用层连接mongos,mongos根据查询条件和片键,把请求路由到对应的分片上,然后把各个分片返回的结果合并起来,返回给应用层。对应用层来说,mongos就像是一个MongoDB节点,不用关心后面有多少个分片,非常透明。

配置服务器(Config Server)存储分片集群的元数据,比如有哪些分片、每个分片存了哪些数据块、片键是什么等等。mongos启动的时候会从配置服务器加载元数据,查询的时候根据元数据路由请求。配置服务器也是一个复制集,保证元数据的高可用。

分片的核心是片键的选择,片键选得好不好,直接决定了分片集群的性能和可扩展性。好的片键应该满足几个条件:第一,分布均匀,不会出现某个分片数据特别多、压力特别大的情况;第二,有足够的基数,也就是片键的值要足够多,这样才能把数据分散开;第三,符合查询模式,大部分查询都能根据片键定位到具体的分片,不要做跨分片的散射查询。

常见的片键选择有范围片键和哈希片键。范围片键就是用普通的字段做片键,数据按片键的值范围分块,比如用id做片键,因为id是按时间递增的,所以新数据都会写到最后一个分片上,容易出现写热点,这就是范围片键的问题。哈希片键就是对片键做哈希之后再分片,哈希之后的值是随机分布的,所以写压力会分散到所有分片上,不会有热点问题,但是哈希片键不支持范围查询,范围查询要查所有分片。

所以选择片键的时候,要根据业务的读写模式来权衡,如果写多读少,而且写是随机的,可以用哈希片键;如果范围查询多,而且片键的分布比较均匀,可以用范围片键。没有完美的片键,只有适合自己业务的片键。

分片是MongoDB处理大数据量和高并发的利器,但是也不要过早分片,单机能搞定的就不要分片,分片会增加架构的复杂度,运维成本也会高很多。当单台服务器确实扛不住了,再考虑分片也不迟。

六、写在最后

MongoDB文档数据库原理剖析:深入理解底层机制。

以上就是我对MongoDB底层原理的一些剖析,从文档模型、存储引擎、索引、复制集、分片这几个方面,介绍了MongoDB的核心机制。当然,MongoDB的内容远不止这些,还有很多细节和高级特性,这里只是抛砖引玉,讲了最核心的部分。

了解MongoDB的底层原理,能帮助我们更好地使用MongoDB,避免踩坑,优化性能。很多人用MongoDB出问题,性能上不去,其实都是因为对底层原理不了解,用错了方式,比如没有建合适的索引、片键选得不好、用单节点部署等等。只要了解了底层原理,避开这些坑,MongoDB的性能和稳定性都是非常好的。

MongoDB作为最流行的文档型NoSQL数据库,在适合的场景下确实非常好用,开发效率高,扩展性好,性能也不错。但是它也不是银弹,不是所有场景都适合,比如需要复杂事务、多表关联查询的场景,还是关系型数据库更合适。我们要根据业务场景选择合适的数据库,不要为了用NoSQL而用NoSQL。

希望这篇文章能帮助大家更深入地理解MongoDB,用好MongoDB。技术是学无止境的,愿我们都能保持学习的热情,不断提升自己。