先说点背景
2019年那会儿,团队要做一款物联网设备数据平台,每天大概写入2000万条时间线数据。当时看MongoDB 4.0吹得天花乱坠——什么灵活schema、水平扩展、写性能彪悍。我们被前东家遗留的加字段崩溃问题搞得头皮发麻,于是没做多少POC就把核心业务迁到了MongoDB 4.2,用的是WiredTiger引擎,3节点副本集加一个仲裁节点,服务器是腾讯云S5.2XLARGE16(16核32GB)三台。刚开始确实爽:JSON文档随便嵌套,加个字段不用ALTER TABLE,写入吞吐量压到单节点每秒2.8万次upsert,延迟p99在40ms左右。当时觉得自己真聪明,逃离了关系型数据库的束缚。
噩梦是从第二年第二季度开始的
业务增加了“设备批次”和“告警规则”两个模块,需要把实时数据流和规则配置做关联分析。问题瞬间炸了:MongoDB里那种优雅的嵌套文档,在面对多个集合之间的连接查询时变成一场灾难。我们用$lookup做关联,10个批次加100条规则,跑一次需要7秒多,直接把应用服务器的连接池占空。更讽刺的是,嵌套数组的原子更新在MongoDB里根本不是原子操作,得靠事务——可一旦用事务,锁竞争和写冲突让整个集群的TPS从一万多掉到两千。那段时间线上连续出故障:一次凌晨两点,一条脏数据带着非常深的嵌套层级,导致某个聚合pipeline内存飙升到16GB,直接触发OOM killer,副本集主节点宕机,failover用了127秒,业务中断了将近五分钟。我当时坐在地上,抽了半包烟,查了mongod.log才发现是某个用户上传了一个4MB的JSON文档,里面有6000层嵌套。MongoDB 4.2默认的maxBSONDepth是100层,但那文档是通过GridFS进的,没走BSON校验。哎,不怪它,怪我蠢。
真正促使我动手迁移的,是几个特别具体的场景
第一个,事务隔离级别。MongoDB从4.0开始支持多文档事务,但默认的readConcern是local,snapshot级别需要显式设置,而且只有副本集能真正保证持久性。我们有个库存扣减逻辑,因为跨两个集合操作,用了withTransaction,结果在并发500线程压测下,写冲突异常直接飙升到每分钟3000多个,报错类似TransactionCoordinatorSteppingDown,搞得前端疯狂重试。第二个,聚合框架的内存限制:默认允许使用100MB内存在单个pipeline阶段,超出就报错。我遇到过几次明明排除了大片数据,还是撞到这个限制——因为$group阶段把中间Bson塞进了内存,根本没法控制。第三个最致命:数据一致性。我们是车队数据,包含轨迹和计费。有一次因为网络分区,副本集自动选举了一个滞后非常严重的从节点作为主节点,导致写入丢失。我当时查了db.serverStatus().metrics.repl,看到appliedOps和fetchedOps差了差不多80万条,心都凉了。NoSQL宣传“最终一致性”,但没人告诉你最终是多终——在物联网计费场景下,“最终”等于“客服被投诉打爆”。
然后我回头看了看PostgreSQL,突然觉得它并不老土
我用的版本是PostgreSQL 13,其实早在2020年9月就发布了。我原本担心JSONB的查询限制,结果发现它比MongoDB合我胃口:第一,事务一致性完完全全ACID,快照隔离默认就是READ COMMITTED,不会出现交叉写。第二,分区表原生支持,我们用RANGE分区,按月分,每张子表大概1.2亿行,查询按时间范围过滤时扫描量从全表降到千万级别。第三,JSONB类型本身就是二进制存储,虽然没有MongoDB那种复杂的查询语法,但对于“配置属性”这种场景完全够用,而且能和关系型列混查,一个SQL里搞定聚合+过滤+连接。举个具体数字:同样的设备批次关联告警规则,我写了一个嵌套查询,用了两个CTE和一个JSONB_EXISTS过滤,总耗时从MongoDB的7.2秒降到420毫秒。那一刻我拍了下桌子。
迁移过程远比想象中痛苦,这是必须说的大实话
我们用了etl工具datax和自写的双写脚本,历时三个半月才切完。核心表一共87张,累计数据量3.4TB,从MongoDB导到PostgreSQL得处理类型映射。比如MongoDB里面的ObjectId作为主键,转成PostgreSQL的UUID得改代码生成的ID,否则前端返回的ID在JSON里面会溢出JS的Number精度,因为MongoDB的ObjectId是24位十六进制,但字符串形似5f8d4b3e2c9a8b7d6e5f4a3b——算了,我这里换成自增序列做主键影响不大,但应用代码里用了很多旧ID查询,得做映射表,很烦。另外MongoDB里的BSON日期精确到毫秒,PostgreSQL的timestamp没有时区,结果迁移之后所有时间都多了八小时——到了第二天半夜报警才发现,因为北京时区和UTC的偏移没处理,差点又翻车。最痛苦的是导出工具:我用mongoexport以JSON格式导出,每个文档里有嵌套数组,转回关系型时得手动拆层。我写了个kotlin脚本,每处理100万条就内存泄漏,查了半天才发现是bson4jackson的对象池问题,改完又赶上超时。说真的,技术选型不是画个对比图抬个杠,那是一种折磨。
现在我对数据库选型的偏见与反思
如果你问我现在推荐什么,我的答案不是“用PG替换Mongo”,而是先问你的场景能不能容忍丢失几秒钟数据?如果不能,MongoDB你需要比较高的writeConcern为majority和readConcern为linearizable,但那样写性能会打折扣。我们当时没认真测,是因为被其吞吐量指标骗了——单看TPS是没用的,得压测withTransaction和snapshot read场景。另外,主流PostgreSQL 16已经支持JSONB的jsonpath,功能比早年强太多,很多“文档数据库”场景实际上是可以降级为关系型数据库的一个列来处理的。如果你真需要横向扩展,可以考虑Citus扩展或者整体考虑分布式数据库,比如TiDB,但别忘了跨节点分布式事务带来的性能损耗。我的独立观点呢:所谓“多模型数据库”纯粹是厂商话术,是为了让你交更多许可费。真实世界里,数据一致性问题和业务逻辑复杂度才是大多数团队的瓶颈,而不是schema灵活度。一个简单的JSONB+索引如果能解决80%的问题,为什么要引入另一个心智负担?MongoDB 7.0确实进步了,可我还是选择了稳定性——不是因为PostgreSQL完美,而是因为它不会在我凌晨三点睡得正熟的时候,因为一个wiredTiger的脏缓存eviction把CPU打满,然后告诉我“secondary caught up”。
附几个真真切切的参数和配置,如果你也在考虑迁移
给个参考:我在写本文时用的是PostgreSQL 13.7,shared_buffers=8GB,work_mem=64MB(注意默认4MB太小,聚合排序经常要临时落盘),maintenance_work_mem=2GB,wal_level=logical开了逻辑复制,把每个月的分区表再异步复制到分析库。如果你要拿PG存传感器原始数据,建议开启timescaledb插件,它本身就是用PG的扩展机制做的,可以自动把超表拆成按时间分块,查询延迟比原版分区表更好。MongoDB那边,最后跑了几组压测供你们对比:单文档读取100万次随机ID,MongoDB 4.2平均耗时1.2ms,PostgreSQL主键读取平均耗时0.9ms;二级索引范围查询(10万条),MongoDB没有cover index时平均300ms,PostgreSQL走索引只用了35ms。如果你们的项目已经在NoSQL里烧了三四年,发现每天都在补各种坑,那就果断点迁移。别等老板问“为什么系统又出问题了”才想起来自己当年为什么拍脑袋选型。