MongoDB丢数据问题的分析
坊间有很多传说MongoDB会丢数据。特别是最近有一个InfoQ翻译的Sven的一篇水文(为什么叫做水文?因为里面并没有他自己的原创,只是搜罗了一些网上的博客,炒了些冷饭吃),其中又提到了丢数据的事情。大家知道作为一个数据库来说,数据的持久性基本上是数据库的最低要求了。如果MongoDB真的有那么糟糕的数据安全问题,它早就在技术选择众多的今天被无情地淘汰掉了。那么真相到底如何呢?
实事求是地来说,MongoDB确实在其发展的过程中,有一些数据持久化的问题没有处理好,特别是一些默认值的选定上。大部分用户会拿来就用,直到遇到问题之后才发现他们应该在开始的时候做一些必要的配置。但是,所有这些已经被发现的问题也好,默认设置也好,已经在 MongoDB 2.6以后得到了妥善的解决。我可以负责任地告诉你,你看到的数据安全问题,基本上都是2.4或者之前版本的问题或者是用户配置的问题。接下来我们来仔细分析一下MongoDB的数据安全机制,通过这个分析来更好地理解为什么有丢数据问题的说法,以及如何来正确的配置MongoDB来保证数据的安全。
MongoDB的数据安全包括以下几个概念:
- 恢复日志(Journal)
- 写关注(Write Concern)
恢复日志
在MySQL, PostgreSQL,Oracle等关系型数据库里都有一个Write Ahead Log(Redo Log)的机制用来解决因为系统掉电或者崩溃时导致内存数据丢失问题。MongoDB 的journal就是实现这个目的的一种WAL日志。在MongoDB 2.0之前,Journal没有被支持或者不是一个默认开的选项。所以当你进行写入操作时。在没有Journal的情况下,MongoDB是这样保存数据的:
简单来说,数据在写入内存之后即刻返回给应用程序。而数据刷盘动作则在后台由操作系统来进行。MongoDB会每隔60秒强制把数据刷到磁盘上。那么大家可以想象得到,如果这个时候发生了系统崩溃或者掉电,那么未刷盘的数据就会彻底丢失了。如果大家看到的博客是2011年左右的,那基本上是碰到了这种情况。
自从2.0开始,MongoDB已经把Journal日志设为默认开启。
在上图的情况下,MongoDB会先把数据更新写入到Journal Buffer里面然后再更新内存数据,然后再返回给应用端。Journal会以100ms的间隔批量刷到盘上。这样的情况下,即使出现断电数据尚未保存到文件,由于有Journal文件的存在,MongoDB会自动根据Journal里面的操作历史记录来对数据文件重新进行追加。
有细心的同学可能注意到,Journal文件是100ms 刷盘一次。那么要是系统掉电正好发生在上一次刷journal的50ms之后呢?这个时候,我们就可以来看一下MongoDB持久化的下一个概念了:写关注
写关注(Write Concern)
写关注(或翻译为写安全机制)是MongoDB特有的一个功能。它可以让你灵活地指定你写操作的持久化设定。这是一个在性能和可靠性之间的一个权衡。 写关注有以下几个级别:
{w: 0} Unacknowledged
Unacknowledged指的是对每一个写入操作,MongoDB并不会返回一个是否成功的状态值。这个级别是写入性能最好但也是最不安全的级别。比如说,你试图插入一个违反了唯一性的文档(重复的身份证号),那么MongoDB会拒绝写入并报错。但是由于驱动端并没有在乎你的报错,应用程序还满心欢喜以为一切都没问题,下回再来查询那条数据的时候就会出现数据缺失的情况。
有不少时候MongoDB用来保存一些监控和程序日志数据,这个时候如果你有1、2条数据丢失,是不会对应用程序有什么影响的。基于这些MongoDB早些时候不成熟考量,MongoDB在2.2之前的默认设置就是 {w:0}。这是个让MongoDB 悔恨无比的选择,因为这个是很多人觉得MongoDB数据不安全的根本原因。
在MongoDB 2.4,这个设置已经被改为下面的 {w:1}
{w: 1} Acknowledged
Acknowledged 的意思就是对每一个写入MongoDB都会确认操作的完成状态,不管是成功还是失败。当然这个确认只是基于主节点的内存写入。但就是这个级别,可以侦测到重复主键, 网络错误,系统故障或者是无效数据等错误。
自2.4版本起,MongoDB的默认写安全设置就是 {w:1} Acknowledged。在这种情况下,出现因为系统故障掉电原因而导致的数据丢失只会是我们早些提到的日志没有及时刷盘的情况。如果你不能接受因为系统崩溃而引起的可能的100ms的数据损失,那么你可以选用下一个级别: {j:1} Journaled
{j:1} Journaled
使用这种方式意味着每一次的写操作会在MongoDB实实在在的把journal落盘以后才会返回。当然这并不意味着每一个写操作就等于一个IO。MongoDB并不会对每一个操作都立即刷盘,而是会等最多30ms,把30ms内的写操作集中到一起,采用顺序追加的方式写入到盘里。在这30ms内客户端线程会处于等待状态。这样对于单个操作的总体响应时间将有所延长,但对于高并发的场景,综合下来平均吞吐能力和响应时间不会有太大的影响。特别是你能给journal部署一个对顺序写有优化的IO带宽足够的专门的存储系统的话,这个对性能的影响可以降到最低。
那么使用 {j:1} 是不是就100% 安全了呢?如果是单机版本,这个基本上就是可以确保的了(除非硬盘损坏)。可是在复制集的场景下,我们还需要来考虑一种更高的级别: {w: “majority”}
{w: “majority”} 写到多数节点
MongoDB 的默认部署是至少3个节点的复制集(Replicaset)。使用复制集的好处很多,最关键的就是提高系统的高可用性。另外一个好处就是提供数据的持久性。在复制集下哪怕你的整个主机连内存带硬盘坏掉,你的数据还是健康的存在在第二台或者第N台从节点上。但是复制集作为一种分布式的架构也对我们数据一致性提出了新的挑战。以上述的{w: 1} 写安全配置为例,我们来分析一种比较复杂的场景。
- 01:00:00 网络故障,主从之间网络断开
- 01:00:01 应用写入一个文档: {ts: “01:00:01”} 注意这个文档无法复制到B和C。此时主节点尚未完全确认网络已故障,所以按照{w:1}规则继续接受并确认写入。
- 01:00:02 主节点A意识到自己无法和从节点B,C 联络上,主动降级为从节点,停止接受写操作
- 01:00:05 B,C 选举结果成功,B升级为主节点。B开始接受写操作。{ts: “01:00:06”}
- 01:00:08 网络恢复,A重新加入集群。这个时候A的oplog 和B的oplog已经有不一致了。A会主动把B上面不存在的写操作回滚掉(rollback),并写入一个回滚文件。
在这个时候应用如果再去查询 {ts: “01:00:01”}这个文档,MongoDB 将会说文档不存在!
怎么办怎么办? {w: “majority”} 就是我们的答案。 “majority” 指的是“大多数节点”。使用这个写安全级别,MongoDB只有在数据已经被复制到多数个节点的情况下才会向客户端返回确认。
我们来看一下在使用 {w: “majaority”} 之后,刚才的情况就变成了:
- 01:00:00 网络故障,主从之间网络断开
- 01:00:01 应用要求写入一个文档: {ts: “01:00:01″} 文档会首先成功写入主节点。但是由于网络断开这个文档无法复制到B和C。因为无法满足{w:”majority”}要求,从应用的角度这个文档并没有写入成功。
- 01:00:02 主节点A意识到自己无法和从节点B,C 联络上,主动降级为从节点,停止接受写操作
- 01:00:05 B,C 选举结果成功,B升级为主节点。B开始接受写操作。{ts: “01:00:06”}
- 01:00:08 网络恢复,A重新加入集群。这个时候A会产生回滚,把{ts: “01:00:01”}这个文档删除。 此时集群的数据状态为一致和正确的。
至此,如果使用 {w: “majority”, j:1 }, 那么MongoDB可以满足所有级别数据持久性的要求。值得注意的是在2013年5月Kyle Kingsly 发表了一篇博客 Call Me Maybe: http://aphyr.com/posts/284-call-me-maybe-mongodb 在这片文章里Kyle 汇报了一些关于 {w: “majority”} 的bug, 这些bug已经在2.6里被解决了。当然像Sven那样的哗众取宠之流,估计并没有去研究3.0里面是否真的有问题,而是随便google了一下几年前的东西来做文章。
总结
一般来说,MongoDB建议在集群中使用 {w: “majority”} 设置。在一个集群是健壮的部署的情况下(如:足够网络带宽,机器没有满负荷),这个可以满足绝大部分数据安全的要求,因为MongoDB的复制在正常情况下是毫秒级别的,往往在Journal刷盘之前已经复制到从节点了。如果你追求完美,那么可以再进一步使用{j:1} 。两者相结合,
传说中MongoDB 丢数据的事情,确实已经成为传说了。
后记:在我写这篇文章之时,社区又有人汇报数据丢失的问题。说的是每一万条记录就会丢一两条记录。对于这种情况,我的第一反应就是:查查你的代码吧,很多时候往往问题出在程序上。果不其然,经过仔细检查,原来是代码的问题。