使用MongoDB开发过程常见错误分析
本文主要讨论这几个问题:
Mongo shell中使用大整数字面量
片键使用自增长字段
程序里游标循环迭代过程中进行长时间的操作
滥用数组类型
滥用upsert更新参数
错误的设计索引
错误的认为复制等于备份
(本文讨论在社区交流群以及工作开发过程中常见的一些错误。)
1
Mongo shell中使用大整数字面量,但默认整数字面量类型却是双精度浮点数,导致丢失精度
-
问题描述:
通过mongo shell插入或更新一个大整数(长度约大于等于16位数字)时,例如:
但实际上查询发现,插入的123456789111111111变为另外一个值123456789111111100,如下:
分析:
由于mongo shell实际上是一个js引擎,而在javascript中,基本类型中并没有int或long,所有整数字面量实际上都以双精度浮点数表示(IEEE754格式)。64位的双精度浮点数中,实际是由1bit符号位,11bit的阶码位,52bit的尾数位构成。
11bit的余-1023阶码使得双精度浮点数提供大约-1.7E308~+1.7E308的范围,52bit的尾数位大概能表示15~16位数字(部分16位长的整数已经超出52bit能表示的范围)。
所以当我们在mongo shell中直接使用整数字面量时,实际上它是以double表示的,而当这个整数字面量大约超过16位数字时,就可能发生有些整数无法表示的情况,只能使用一个接近能表示的整数来替代。如上面例子中,存入18位的数字123456789111111111,实际上能有效表示的数字只有16位,另外两位发生精度丢失的情况。
-
解决方法:
使用NumberLong()函数构造长整型的包装类型,记住传入的参数一定要加双引号,否则使用整数字面量的话又会被当做double而可能丢失精度。
注意,除了在mongo shell(javascript语言环境中),在其他不支持长整型而默认使用浮点数代替表示的编程语言中也会存在类似问题,操作时一定要留意。
关于双精度浮点格式详情,可以参考:
a)《双精度浮点数格式》:
https://en.wikipedia.org/wiki/Double-precision_floating-point_format
b)《编程卓越之道-卷:深入理解计算机》- 第4章 浮点表示法
2
片键使用自增长字段,导致写热点
-
问题描述:
使用ObjectId或时间戳等具有自增长性质(并不一定是严格自增长,大致趋势符合也行)的值类型作为分片集合片键时,新写入数据的请求始终都路由到同一个分片节点。
-
分析:
如下图,MongoDB使用片键的范围来对数据分区,每个范围(连续且不重叠),对应一个数据块(Chunk)。因此当片键是自增长类型时,插入的数据实际上都是落在一个Chunk存储的范围内,导致所有写入请求都路由到这个Chunk所在的分片,从而导致这个节点成为写热点,写负载不能均衡的分担到集群中的多个分片节点,从而丧失了通过分片集群横向扩展写性能的意义。
-
解决方法:
a). 使用随机值类型的字段作为片键,例如version 4 UUID (Random UUID)
b) .对自增长型字段创建哈希索引,创建片键时通过hashed选项,指定使用该哈希索引值作为片键,例如:
-
关于如何设计片键,可以参考:
a)《深入学习MongoDB》- 3.1节 选择片键
b)《片键 – 搭建MongoDB分片集群之关键》:
http://www.mongoing.com/blog/post/on-selecting-a-shard-key-for-mongodb
3
程序里游标循环迭代过程中进行长时间的操作
-
问题描述:
大概类似如下代码描述的操作方式,程序中可能经常会遇到这样的需求,取出一个文档后,需要对这个文档做一些比较耗时的复杂处理。
-
分析:
在MongoDB服务器端,也会为相应查询维护一个游标对象,游标会消耗内存和其他资源(比如锁,CPU等)。
游标只有在遍历完了所有查询的结果以后,或者客户端主动发来消息要求终止(比如到达游标使用超时时间,默认是10分钟,或者是客户端检测到客户端游标已经不再使用时),MongoDB才会销毁游标,释放其占用的资源,所以我们应该尽快释放游标,特别是当我们的系统面对的是互联网应用这样高并发的业务场景时,我们应该尽可能的不要浪费数据库端资源,基本原则应该做到减少占用时间,不用时要尽快关闭游标。
-
解决方法:
按需而取,通过查询过滤条件,limit方法,尽量限制游标迭代文档数量。
大部分业务场景,通常我们并不需要在迭代游标过程中完成这些处理操作,如果是这样,我们可以类似如下处理,尽快的迭代游标,将数据提交给队列让另外的线程异步处理,以便能尽快释放游标连接:
-
参考:
游标介绍:
https://docs.mongodb.com/manual/reference/method/db.collection.find/index.html迭代游标:
https://docs.mongodb.com/manual/tutorial/iterate-a-cursor/#read-operations-cursors
4
滥用数组类型
-
问题描述:
在社区的讨论群中,经常会有同学讨论使用MongoDB实现类似微博的关注和粉丝功能,考虑用数组来保存关注好友或者粉丝。
-
分析:
将某个用户的粉丝或者关注好友,保存在该用户文档的数组字段中,虽然这样设计结构看似很直观,在读取时也很高效,一次检索就可以将该用户的基本信息及其粉丝和关注好友都取出来。
但问题是,首先,在MongoDB中文档有大小限制,目前版本中每个文档大不能超过16M,所以使用内嵌文档存储无法满足粉丝或关注好友增长的需求,大用户节点可能将会有大量粉丝或关注用户,超过16M,届时程序将很难扩展。其次,面对排重,排序,过滤筛选等一些复杂需求,使用数组存储将导致操作复杂,性能低下。
-
解决方法:
在使用数组前,我们应该充分评估,结合数组的特性,从业务的读写场景、将来的扩展、查询写入性能、操作维护是否简单等各方面考虑数组是否真的满足我们的需求,不要盲目的进行数据结构设计和开发。
当然,如果存储的元素数量有限,且不会对其进行一些复杂的操作,使用内嵌数组将是很好的方式,它可以减少检索次数,提升读操作性能。
另外,就是在查询时使用project操作,只返回需要的元素和字段,而不是整个内嵌数组,以免浪费带宽。
-
参考:
-
内嵌文档/数组的数据模型:
https://docs.mongodb.com/manual/core/data-model-design/#data-modeling-embedding
查询内嵌数组:
https://docs.mongodb.com/manual/tutorial/query-arrays/
https://docs.mongodb.com/manual/tutorial/query-array-of-documents/对查询的数组使用投影(project)操作:
https://docs.mongodb.com/manual/tutorial/project-fields-from-query-results/
5
滥用upsert更新参数
-
问题描述:
在我们的业务场景中,通常都同时有插入(insert)数据和更新(update)数据的需求,很多时候,我们无法判断正要写入的数据是否已经存在于数据库中,对于这种情况,MongoDB为update操作提供了upsert选项,使得我们在一个操作中能自动处理上述情况,即当数据库不存在写入数据时,执行insert操作,当数据库已经存在写入数据,则执行update操作。(不过,这里要注意,由于并发操作,我们可能会同时对相同数据执行upsert操作,此时可能会造成写入数据重复。为了避免这种情况,应该对upsert操作的query字段建立索引进行约束)。
但很多时候,即使我们能够在写入之前分辨数据是插入还是更新,但由于程序员“懒”这个特性,都会仍然对所有写操作使用update(upsert=true),而不是区分的使用insert和update。
-
分析:
不加区分的使用upsert,虽然简化了我们程序的书写逻辑,但是因此也带来了写入性能的损失。upsert操作在写入前都会先根据查询条件检索一次,判断后再进行操作,同时为了避免并发写入导致重复数据,还需要对query的字段建立索引进行约束,写入时维护索引的开销,进一步降低了写入性能。作者在之前的开发中测试过,不加区分的使用upsert和加以区分的使用insert、update两种情况,性能相差差不多1倍。
-
解决方法:
慎用upsert参数,当我们在写入前可以区分数据是否已经存在数据库中时,在程序中进行判断,区分的使用insert和update操作。
-
参考:
a). upsert参数:
https://docs.mongodb.com/manual/reference/method/db.collection.update/#upsert-parameter
6
错误的设计索引
-
问题描述:
通常,我们开发中遇到的大部分读性能问题,可能都是因为没有为查询、排序操作建立索引,或者建立了错误的索引导致的。特别是在数据量比较大的情况,由于没有利用上索引,导致全表扫描,数据库需要从磁盘读取大量数据到缓存,占用大量的内存,磁盘IO,CPU等系统资源,由于对这些资源的争用,同时也可能会影响到期间进行的写入操作。
-
解决方法:
-
首先,我们要充分了解数据库索引设计的一些原则和技巧。
其次,结合业务中对数据的检索需求,设计合适的索引:
a). 有哪些字段的检索需求,是否有范围查询需求,是否有排序需求,需要检索字段的选择性如何。将这些需求和数据情况一一列出,为我们后续创建索引提供依据。
b). 是否可以建立复合索引,复合索引字段如何组织顺序,才能使得复合索引能够覆盖更多的查询需求,满足范围查询的需求,满足排序的需求(通常复合索引中,按照等值查询、排序、范围查询的顺序来组织索引字段,同时结合考虑索引选择性,是否其他查询能复用复合索引的左前缀)。索引是否能覆盖查询,使得检索性能优。
c). 通过explain查看执行计划,判断我们的查询和排序是否能够用上索引,是否用上我们预期那个合理的索引。
d). 检查我们设计的索引是否有重复索引、无用索引,是否缺失索引。比如复合索引已经能覆盖某些单字段索引。业务查询调整等原因,有些索引已经不再使用。通过慢查询日志,发现有些查询没有索引,严重影响系统性能。及时删除重复的、不再使用的索引,为严重影响性能的查询补上合适的索引。
-
参考:
a)MongoDB索引介绍:
https://docs.mongodb.com/manual/indexes/index.html
b)《数据库索引设计和优化》,这本书虽然比较老,还是非常值得参考。
7
错误的认为复制等于备份
-
问题描述:
MongoDB提供了副本集的部署模式,通过主从的复制架构设计,从节点通过复制主节点的数据,为数据提供了多个副本,并且通过选举机制,在主节点挂掉后,自动选举一个从节点成为新的主节点,实现自动故障转移,保证系统的高可用性。
但是很多同学误解了高可用和复制,将其作用等同于备份,从而忽视了备份的重要性,甚至导致数据丢失无法恢复的后果,跑路事件时有发生。
-
分析:
通过复制实现的高可用架构,并不能代替备份操作。当我们误操作,或者误操作后没有及时处理时(即使在副本集中通过延迟节点留给我们一些缓冲时间),副本也会同步这些误操作,导致数据受到破坏,如果此时我们没有备份数据,数据将无法恢复,从而可能带来无法避免的后果。
另外,即使是高可用架构,99.999%的高可用性,但你也可能命中注定是那0.001%的倒霉蛋。
所以,一定要备份,一定要备份,一定要备份。
-
解决方法:
当然,好和安全的解决方案,是通过MongoDB企业版提供的后台管理工具,比如ops manager进行全量备份,实时增量备份。
或者有技术能力的,可以通过结合参考Mongo Shake,MongoSync等开源同步复制工具 (注意不是备份),实现自己的实时增量备份工具。
-
参考:
MongoDB备份工具介绍:
https://docs.mongodb.com/manual/core/backups/index.htmlMongoDB Ops Manager:
https://www.mongodb.com/products/ops-managerMongo Shake:
https://github.com/aliyun/mongo-shake-
MongoSync:
https://github.com/Qihoo360/mongosync
/
作者简介
/
钟秋
BBD技术经理,架构师。MongoDB中文社区联席主席。
有丰富项目中应用MongoDB经验,熟悉MongoDB相互模式设计及性能优化,熟悉大数据相关技术和互联网及大数据应用架构设计。
往期回顾
热门活动:
福利 | 分享你和MongoDB的故事,获免费海外参会机会
干货分享 | MongoDB 中文社区2018北京大会PPT及视频下载
技术文章:
MongoDB Aggregate 业务场景实战
MongoDB 集群请求连接被拒绝的分析
完美数据迁移-MongoDB Stream的应用
MongoDB 新功能介绍-Change Streams
MongoDB 4.0 系列之 —事务实现解析(一)
MongoDB 4.0 系列之 —事务实现解析(二)
使用mlaunch和m快速搭建MongoDB测试集群
精彩译文:
Java与MongoDB 4.0多文档事务新特性体验
为什么MongoDB适合深度学习?
MongoDB Compass聚合管道构建器新特性介绍
相关文章