一个计数器字段,加一,能有多难?难到让两种数据库拉开几十倍的差距。有人把同一批文档、同一个更新操作,分别放进Postgres和MongoDB里跑了一遍,文档大小设成1KB、10KB、100KB三档,结果第一档和第三档的排名直接对调。

先看最扎眼的数字:文档从1KB涨到100KB,用jsonb存整份文档的Postgres,每秒更新次数从6101掉到375。改的只是一个整数,慢了十六倍。同一区间里MongoDB也变慢,但只掉了大约40%。

打开网易新闻 查看精彩图片

测试是怎么搭的

两台托管数据库都在DigitalOcean法兰克福节点,规格一致:2 vCPU / 4GB,单节点,Postgres 17.11 对 MongoDB 8.0.26。压测程序跑在同VPC的一台droplet上,走内网,两边防火墙都只放行这台机器。

文档是伪造的订单:客户、状态、浏览量计数器,再加一串订单行,填充到1KB、10KB或100KB。小文档2万条,大文档2000条。更新动作是随机挑一条文档,把views加一。八个客户端进程,跑二十秒,每个组合跑五轮,每轮都从刚加载的表开始,三种配置轮流上,让漂移均摊。

MongoDB那边一行就够:updateOne({_id: id}, {$inc: {views: 1}})。Postgres这边分两种写法,一种把整份文档塞进一个jsonb列,更新时要用jsonb_set把views取出来加一再写回去;另一种把计数器拎出来,做成文档旁边的真实列,更新就是views = views + 1。

为什么jsonb越写越慢

Postgres从不原地改一行。一次更新会写出这一行的新版本,旧版本留给vacuum处理。如果整份文档都在jsonb列里,那就意味着每次加一都要复制一整份文档。预写日志把这件事摊开了:为了给一个数字加一,写了43KB的日志。

尾延迟跟着同一个走势。100KB档位上,jsonb的第99百分位是62ms,MongoDB是48ms,而独立列只要4ms。

有意思的是第一档。1KB文档时,独立列一点便宜都占不到,两种写法每次更新都写大约1.3KB,因为这么小的文档直接内联在行里,怎么改都得跟着复制一遍。转折点大约在2KB:超过这个尺寸,Postgres会把文档挪到侧表,行里只留一个指针。这时候更新views只复制指针,文档原地不动。这就是独立列在大文档上反而更快的原因。

磁盘数据讲的是同一个故事。100KB档位各跑六十秒,再静置一分钟看占用:2000份大文档是210MB的BSON,在MongoDB里占74MB,在Postgres里占83MB。静置状态下两个引擎咬得很紧。

读取是镜像问题。从100KB文档里取doc->>'views',每秒3150次,因为必须把整份文档取出来解压才能找到一个键;直接选那一列,每秒8412次。

一个差点带偏结论的细节

跑到一半,作者去查了两个库在告诉你"写成功"之前到底做了什么,发现它们不一样。托管Postgres的synchronous_commit是关的,提交在日志刷盘之前就返回了。托管MongoDB用的是write concern majority,要等日志落盘。也就是说,上面那张表里,Postgres每一次写入被要求的保证更少。

没法完全对齐,就把两边反过来各跑一遍,100KB档位同样五轮。两边都等磁盘时,独立列是3326对MongoDB的984;两边都不等时,是7982对1507。100KB档位的排序两种方式下都成立:独立列、MongoDB、jsonb。1KB那一档的结果反而不可信,因为Postgres领先时每次提交做的工作更少。

作者还提到,先在笔记本上用Docker试过脚本,本地MongoDB在100KB文档上每秒能做14673次更新,看着不可战胜;到了托管集群只有984。本地容器是个裸standalone,确认写入前什么都不等。他差点围绕那个数字来设计整篇文章。他原本以为两个服务会提供同等的持久性保证,只是因为Postgres在1KB时快得离谱才去查了一下。

结论很直接:小文档就留在jsonb里,别操心;大文档又有热点字段,就把字段挪出来。