将Rails应用从Mongoid(MongoDB)迁移至ActiveRecord(Postgres)时如何维持内存稳定?
问题背景
我近期在协助一款Rails应用做数据库迁移:原本用MongoDB(搭配Mongoid)存储数据,初创阶段跑得顺风顺水,但随着客户量和复杂统计查询需求上来,我们决定把数据规范化后迁到PostgreSQL(用ActiveRecord做ORM)。
为了保证MongoDB里的非规范化数据符合新库要求,我们在Rails环境里做迁移,这样能让模型的验证、回调和完整性检查都生效。开发环境小数据量迁移没问题,但到staging服务器用真实生产数据跑的时候,部分迁移的内存占用跟着实例数量直线飙升,把16GB内存加16GB交换空间都耗光后直接被系统终止了。
我们现在是逐个迁移实例,想找个能让内存维持在接近恒定水平的办法,目前怀疑的原因有两个:一是ActiveRecord或Mongoid一直握着已导入对象的引用没放;二是迁移在单个数据库事务里执行,PostgreSQL的内存占用一直涨直到迁移完成。
想请教几个问题:
- 内存线性增长的可能原因到底有哪些?
- 怎么才能降低内存占用?
- 有没有办法让Mongoid和/或ActiveRecord释放旧的对象引用?
- 要不要手动调用Ruby的GC?
- 把迁移拆成多个数据库事务有用吗?
迁移的大致代码是这样的:
class MigrateSomeThing < ActiveRecord::Migration[5.2] def up Mongodb::ModelName.all.each do |old_thing| # Mongoid's #.all.each works with batches create_thing(old_thing, Postgres::ModelName.new) end raise "Not all rows could be imported" if MongoDB::ModelName.count != Postgres::ModelName.count end def down Postgres::ModelName.delete_all end def create_thing(old_thing, new_thing) attrs = old_thing.attributes # ... 调整属性适配PostgreSQL new_thing.attributes = attrs new_thing.save! end end
内存线性增长的核心原因
咱们先把可能的原因拆解清楚:
- 对象引用没被释放:虽然Mongoid的
all.each是分批取数据,但你每次循环创建的ActiveRecord实例(new_thing),还有Mongoid的文档实例(old_thing),可能被Ruby的对象引用链牢牢攥着——比如迁移类本身、ORM的内部缓存(比如Mongoid的身份映射、ActiveRecord的连接池缓存),导致GC没法回收这些对象,内存自然越积越多。 - 单事务累积数据:默认情况下,Rails迁移的
up方法是在一个大事务里跑的。PostgreSQL在事务中会保留所有修改的日志和数据版本,迁移的行越多,这个事务占用的内存就越大,直到事务提交才会释放。 - ORM自动缓存拖后腿:ActiveRecord会自动缓存查询结果和实例,
save!后的实例会被存在连接池里;Mongoid如果开了身份映射,也会缓存已加载的文档,这些缓存都会持续占用内存。 - 临时对象没及时清理:迁移中生成的
attrs哈希、中间处理的变量,如果没有被标记为可回收,也会慢慢占满内存。
降低内存占用的具体方案
针对这些原因,给你几个实操性强的解决办法:
1. 主动分批处理+清理ORM引用
别光依赖Mongoid的默认分批,主动控制批次大小,每批处理完就手动清理ORM的缓存:
def up batch_size = 1000 # 根据服务器内存调整,比如500或2000 Mongodb::ModelName.find_each(batch_size: batch_size) do |old_thing| create_thing(old_thing, Postgres::ModelName.new) end # 或者用each_slice做更细的批次控制+清理 # Mongodb::ModelName.all.each_slice(batch_size) do |batch| # batch.each { |old_thing| create_thing(old_thing, Postgres::ModelName.new) } # # 清理Mongoid的文档引用 # Mongoid::IdentityMap.clear if Mongoid.identity_map_enabled? # # 清理ActiveRecord的连接池缓存 # ActiveRecord::Base.clear_active_connections! # # 手动触发GC # GC.start # end raise "Not all rows could be imported" if MongoDB::ModelName.count != Postgres::ModelName.count end
find_each比all.each更可控,明确指定批次大小。Mongoid::IdentityMap.clear:如果开了身份映射,这个方法能直接清空Mongoid缓存的所有文档实例。ActiveRecord::Base.clear_active_connections!:强制断开所有ActiveRecord连接,释放缓存的实例。
2. 拆分迁移为多个小事务
把每一批次的操作放在独立事务里,避免单个大事务累积过多数据:
def up batch_size = 1000 Mongodb::ModelName.all.each_slice(batch_size) do |batch| ActiveRecord::Base.transaction do batch.each { |old_thing| create_thing(old_thing, Postgres::ModelName.new) } end # 每批事务提交后清理缓存+触发GC Mongoid::IdentityMap.clear ActiveRecord::Base.clear_active_connections! GC.start end # ... 校验逻辑 end
这个方法对降低PostgreSQL的内存占用特别有效——每批事务提交后,数据库会立即释放该批次的事务日志和版本数据,不会一直累积。唯一要注意的是,如果你的迁移要求完全原子性(要么全成功要么全失败),这个方案就不适用;但如果可以接受分批提交(就算中间失败,后续可以从断点继续),这绝对是首选。
3. 尽量避免不必要的ORM实例
如果你的迁移不需要依赖模型的验证和回调(当然你说需要,但部分场景可以灵活调整),直接用批量插入代替逐个创建实例:
def up batch_size = 1000 Mongodb::ModelName.all.each_slice(batch_size) do |batch| attrs_list = batch.map do |old_thing| attrs = old_thing.attributes # 调整属性适配PostgreSQL attrs.except('_id') # 去掉MongoDB的_id字段 end # 批量插入,不创建ActiveRecord实例,直接写库 Postgres::ModelName.insert_all!(attrs_list) # 清理+GC Mongoid::IdentityMap.clear GC.start end end
insert_all!会跳过ActiveRecord的实例创建、验证和回调,但会触发数据库的约束检查(比如非空、唯一索引)。如果你的迁移必须用验证和回调,那还是得用save!,但可以在每批后及时清理实例。
4. 手动调用GC的正确姿势
手动调用GC.start是有用的,但别在每次循环都调用——那样会拖慢迁移速度。最好是在每批处理完成后调用一次,让Ruby及时释放那些已经没用的对象内存。对于迁移这种一次性任务,GC的短暂性能开销完全可以接受。
5. 关闭ORM的查询缓存
迁移过程中不需要查询缓存,直接关掉它避免额外内存占用:
def up ActiveRecord::Base.uncached do Mongodb::ModelName.find_each(batch_size: 1000) do |old_thing| create_thing(old_thing, Postgres::ModelName.new) end end # ... 校验逻辑 end
你的问题逐一解答
- 内存线性增长的可能原因:主要是ORM(Mongoid/ActiveRecord)缓存了大量实例未释放、单个大事务累积数据库资源、临时对象未被GC回收这几个点。
- 如何降低内存占用:采用主动分批处理+每批清理ORM缓存、拆分多个小事务、批量插入、手动触发GC这些组合方案。
- 释放Mongoid/ActiveRecord旧引用:Mongoid用
Mongoid::IdentityMap.clear(需开启身份映射);ActiveRecord用ActiveRecord::Base.clear_active_connections!或ActiveRecord::Base.reset_all。 - 是否手动调用Ruby GC:非常建议在每批处理后手动调用,能及时释放内存,避免内存持续暴涨。
- 拆分多个事务是否有效:效果非常明显,尤其是解决PostgreSQL的事务内存累积问题,每批提交后数据库会立即释放该批次的资源。
内容的提问来源于stack exchange,提问作者Qqwy

