cover

MongoDB 读写分离踩坑

sorcererxw

发现问题与排查

这两天开发了一个后端更新用户个人信息的接口 POST /profile, 最初的设计是前端提交请求之后,后端更新信息之后,并从数据库里面读取最新的数据,返回给客户端。就是一个写+读的操作,非常简单,很快就开发完了,并通过了单元测试,上线到测试环境。

不过很快测试同学就告诉我每次更新完返回的数据都不是最新的,返回了上一次的旧数据。不应该啊,明明已经通过了单元测试。

于是开始排查,一开始是怀疑代码里面的 Promise 被异步调用了,本地单元测试执行速度过快而没有发现问题。但很快这个问题就被排除了,很明显,如果异步调用了,在 event loop 模式下根本不可能存在正确的情况。

然后开始将目光放到了项目当中使用的 ODM 上,我使用的 MongoDB ODMMongoose, 因为原生的 Mongoose 应该是对不同事件进行 callback 来执行相关逻辑的,但是切换到现在的 async/await 模式,担心 await 的结果不是真实的调用结果。或者说,怀疑 Mongoose 将数据操作发送到 MongoDB 服务器将认为请求成功,那么调用方应该是无法实时获得新的数据的。查了一圈 StackOverflow,翻了一遍文档,发现 Mongoose 的更新操作确实是同步的,一定是 MongoDB 确实写入了数据才返回结果。

最后,将目光瞄向了数据库连接配置上,最后在注意到了 MongoDB 的 url 上有一个参数 readPreference=secondaryPreferred。这下我才恍然大悟过来,原来这中间的延迟出现在 MongoDB 主从复制。这样,同样也能解释为什么本地单元测试就能通过,而线上环境就会出现问题:因为本地连接的并不是数据库集群,所有读写都存在于主库当中,这样就不会存在时延。

解决方案

既然找到问题,那么就得考虑如何解决这个问题

  • ❌ 将读操作放在主库上

    这个当然能够一步到位解决所有相关的问题,但是这样就失去了数据库读写分离的意义了。当系统中的读操作远多于写操作的时候,这样会造成系统性能降低。

    当然,也可以设置需要实时获取新数据的时候,选择从主库进行读。不过 Mongoose 不支持动态选择实例。

  • ⚠️ update 接口不实时返回

    Restful 规范里面,建议更新操作之后,返回更新后的实体。但是一方面并不是所有更新操作都需要立即获取最新的数据。另一方面,如果需要保证数据一致性,还是要以 Get 请求得到的数据为准。前端也不应该以返回的实体是否符合来确定是否更新成功,而是应该以返回的 200 状态码为准。

    当然将 update 接口设计成不返回实体,并不能完全解决主从复制时延的问题。因为系统内部,也有不少操作是将实时生成新数据,然后再从数据库进行拉取。这种情况就需要再重新设计了。

    所以这个这个方案还是陷入了头痛医头脚痛医脚的问题,不具有通用型。

  • ✅ Write Concern

    MongoDB 还有一个 Write-Concern 机制,可以根据具体使用场景,灵活调整分布式数据于读写机制。

    对于写操作,Write-Concern 提供了多个可选参数。

    • 可以通过 {w:<X>} 来限定等多少实例同步完数据之后再返回成功确认。

      默认是 {w:1} 即会在主节点确认之后就返回数据。

      可以设置为 {w:"majority"}, 来保证大多数实例确认后再返回。

      当然也可以设置为 {w:0},就像我上面说的一样,发送了操作就返回,不用等写入确认,更像是进行缓存,哪怕数据丢失也没有关系,关键是快。

    • 通过 {j: boolean}, 来保证数据落盘之后再返回。默认是关闭的,一般 MongoDB 会将数据放在内存当中,适当的时候再写入磁盘。但如果对于数据安全性要求高则可以开启。
    • 如果使用了 {w:"majority"}, 则可能出现节点间同步故障的问题,总不能一直等待下去吧。wtimeout 可以限定多少时间不返回就认为写入失败。

    对于读操作,find 操作有一些有意思的参数

    • readConcern: 可以限定读策略。默认是 local, 即读取本节点的数据。

      也可以设置为 majority,即读取成功写入了大多数节点的数据,和上方的 {w:"majority"} 相对应。但是实际上,最终的读取还是本节点,但如果本节点都没有和 primary 同步 oplog,也不用谈是否是最新的数据。所以 majority 根本保证的是数据是被整个集群都确认,不会被回归的数据。

    • 可以设定 {awaitData:<bool>}, 这个参数可以让 find 操作进行等待,如果超出一定的时间还没找到相应的数据,则认为不存在。这一段时间就能够作为节点间同步的延迟。

    说了这么多,回到我的使用场景上,比较合适的做法应该是 {w: "majority"} + {awaitData: true}