在分片的 MongoDB Collection 上做更新操作时需要注意的问题

目录


logo

如题,当在一个分片的 MongoDB Collection 上做 update 操作时,容易触发一个问题,报错是这样子的(其中敏感信息已经用 XXX 省略):

A single update on a sharded collection must contain an exact match on _id (and have the collection default collation) or contain the shard key (and have the simple collation). Update request: { q: { XXX: XXX }, u: { XXX: XXX } }, multi: false, upsert: false }, shard key pattern: { XXX: XXX }

这个错误坑人的地方在于,它在未分片的 MongoDB collection 上是不会出现的,所以往往出现这样的情况:测试时使用简易的未分片的 MongoDB,测试一切正常,发到线上,使用正式分片的 MongoDB,疯狂报错。

错误的原因是,无论是 MongoShell 的 update 函数,还是 golang 的 Mongo 驱动中的 Update 函数,默认都是“Update Only One”,但我们一般会忽视这一点,因为更新操作是个危险操作,绝大多数情况下我们指定的选择条件往往就是指向某一条数据而不会是多条,比如 query 条件是用户 ID(但字段名不是 _id)。所以此时无论是用“Update One”还是“Update All”,其最终效果都是一样的。但是 MongoDB 对“选择条件只可能命中一条数据”这一业务上的事实并不知情,所以还是会另做一层保护:无论查询到的结果是一条还是多条,只更新其中一条。

但重点来了,这一保护性特性在分片的 collection 上是“不完全支持”的,试想一下,MongoDB 将更新操作发送到多个分片上,但要求全局只能更新一条数据,如果多个分片均命中到数据,那更新哪一个?为了保证只有一个分片进行更新,势必需要各个分片之间相互协调、磋商,这就大大提高了实现成本,我不敢说这个成本高到无法实现,但至少目前 MongoDB 没有实现。

但这事有个例外,那就是查询条件中有指定了 _id 的值或者分片索引所在的字段的值,因为如果指定了 _id,那只可能有一条数据被命中,不会出现在多个分片上都发现命中的情况;而如果指定了分片索引所在的字段的值,那 MongoDB 可以预先知道目标数据在哪个分片上,只把更新操作发送到指定的分片上即可。

所以这也是另一个坑人的地方,实际开发时往往无意中满足了上述的条件,所以一直都没有发现问题,某一天没有那么走运了,触发了报错,会陷入懵逼:“明明是两个差不多的更新操作,为什么一个报错一个不报错?”

这里吐槽一下官方文档的说法

All update() operations for a sharded collection that specify the justOne option must include the shard key or the _id field in the query specification. update() operations specifying justOne in a sharded collection which do not contain either the shard key or the _id field return an error.

不知道是不是我对英文单词的理解有偏差,文中只是说查询条件里 includethe shard key(分片索引) 或 the _id field 即可,但类似于 {"_id":{$gte:100}} 这样的查询条件也是 include 的了呀,但这样的查询条件也是触发同样的错误的,必须是明确指定了 the shard key or the _id field 的值,Update One 才会生效。原因也很容易理解。

但这里并不是说解决方案就是把更新条件涉及到的字段设为 _idshard key,因为这不现实,在设计 collection 时不可能想到以后会以什么条件做更新操作,也不可能限制死某个 collection 只能以某一个条件做更新操作。这太傻了。

那该如何解决?其实很简单很粗暴,我刚说到了,绝大多数情况下我们指定的选择条件往往就是指向某一条数据而不会是多条。那既然如此,当需要更新仅一条数据时,将更新条件限制好,同样使用 Update All, 不就得了?最终还是只更新了一条数据。而如果我们的目的真的是做批量更新,那也会很自然而然的想到用 Update All,从而不会触发报错。

所以,当在分片的 collection 做更新操作时,如果是 MongoShell 则指定 multi 为 true,如果是在代码里则使用 UpdateAll 函数,便可以避免报错。如果更新条件确实是会命中多个,但你确实只希望更新其中一个,那就只能如上文所述,将查询字段设为分片索引,或者干脆放弃分片得了——但在此之前,务必来让我长长见识:

为啥会有这种鬼需求嘞?

后记

2018.07.04

今天同事又碰个类似的问题,也是在分片的 MongoDB Collection 上做更新操作时报出。

db.getCollection('test_c').update({"_id":"XXX"}, {"XXX":"XXX"}, true)

报错的内容为:

An upsert on a sharded collection must contain the shard key and have the simple collation. Update request: { q: { _id: "XXX" }, u: { XXX: "XXX" }, multi: false, upsert: true }, shard key pattern: { _id: 1.0 }

如报错所示,目标 Collection 是以 _id 分片键,查询条件也指定了 _id,所以即使 multi 为 false,由于查询条件中明确指定了分片键的值,操作应该是没有问题的。但实际情况是更新失败了。

问题出在这个操作指定了 upsert 为 true,即当未发现匹配的记录时,则插入新纪录。

而新插入的内容,命令指定为 {"XXX":"XXX"},但在未分片的 Collection 上实际为 {"_id":"XXX", "XXX":"XXX"},查询条件 {"_id":"XXX"} 被整合到了内容里。所以效果与 db.getCollection('test_c').update({"_id":"XXX"}, {"_id":"XXX", "XXX":"XXX"}, true) 是完全一样的。然而奇怪的地方来了,在分片的 Collection 上必须强制在新内容里指定 _id。所以要解决上述的报错,只需要将更新语句改为:

db.getCollection('test_c').update({"_id":"XXX"}, {"_id":"XXX", "XXX":"XXX"}, true)

至于为什么会是这样的设计,我提出了很多假设但都站不住脚,但蛮横地认为这是一个设计缺陷也过于武断。只能暂时先死记硬背下这个情况。

几件事下来,我开始怀疑我对 Mongo 分片的设计是不是有什么误解,没有 get 到它的设计精髓,所以才连连踩雷。

容我有时间再好好研究研究。