Rails读1000万行数据直接OOM?postgresql_cursor:大结果集处理的终极方案
【免费下载链接】postgresql_cursorActiveRecord PostgreSQL Adapter extension for using a cursor to return a large result set项目地址: https://gitcode.com/gh_mirrors/po/postgresql_cursor
postgresql_cursor是一款免费的 Rails/ActiveRecord 扩展 gem,专为 PostgreSQL 数据库打造。它利用 PostgreSQL 原生游标(Cursor)将大结果集按“块”返回(默认每次 1000 行),让你在 Ruby 进程中完整处理上千万行数据也不 OOM。如果你的后台任务曾因为一句Model.where(...).to_a而内存爆炸,这个 gem 就是答案。
为什么 Rails 读大结果集会 OOM?
ActiveRecord 天生为 Web 场景优化——一个页面通常只返回 20 行左右的数据。但当你在后台任务里这样写:
Product.where("id > 0").each { |product| product.process }数据库会把所有匹配行一次性返回给 ActiveRecord 并实例化成模型对象。处理千万级行时,内存瞬间被打满;更糟的是 Ruby 在数组处理完后并不归还内存,进程不断“膨胀”,最终抛出 OOM 异常 💥
Rails 官方的find_each/find_in_batches虽能分块处理,但有明显限制:
| 限制点 | 说明 |
|---|---|
| 排序不可控 | 只能按主键顺序读取,无法order("name") |
| 主键要求 | 主键必须是数字类型 |
| 重复查询 | 每块(1000 行)都要重新执行一次 SQL |
| 复杂查询开销大 | 带 join、子查询的 SQL 会被反复重跑 |
postgresql_cursor 游标原理:一次查询、分批取数
游标的思路完全不同:把 SQL 在数据库侧“挂起”,只声明一次,然后循环分批取数。其核心逻辑等价于(见 cursor 核心实现 中的open、fetch_block、close方法):
SET cursor_tuple_fraction TO 1.0; DECLARE cursor_xxx CURSOR FOR SELECT * FROM products; LOOP rows = FETCH 1000 FROM cursor_xxx; -- 每次只取 1000 行 rows.each { |row| 处理 } END LOOP; CLOSE cursor_xxx;两个关键细节:
cursor_tuple_fraction设为 1.0:PostgreSQL 默认 0.1(按只取 10% 结果集优化执行计划),而游标要读完全部数据,该库自动改为 1.0,让查询规划器为“全量读取”选择最优路径。- 事务包裹:游标必须在事务中运行,该库会自动帮你包一层
transaction(除非指定with_hold: true)。
对应用户而言,这一切被封装成了 4 个 ActiveRecord 方法(扩展定义在 cursor_iterators.rb 中),几乎零学习成本。
三步上手:安装 postgresql_cursor
- 在
Gemfile中加入:
gem 'postgresql_cursor'- 执行
bundle install - 完成 ✅ 无需任何额外配置——它在 ActiveRecord 加载时自动挂载(见 postgresql_cursor.rb 的
on_load钩子)。
环境要求:PostgreSQL + ActiveRecord(Rails 3.2~5.x 均支持,含 5.0),Ruby 1.9+。
核心用法:三种方式遍历百万行数据
① each_row:返回 Hash,速度最快
Product.where("id > 0").order("name").each_row { |hash| Product.process(hash) }每行返回字符串键的 Hash,不实例化模型。官方非正式基准测试显示,比实例化模型快约 4 倍。代价是值均为字符串,需自行做类型转换。
② each_instance:返回模型实例
Product.where("id > 0").each_instance { |product| product.process! } Product.where("id > 0").each_instance(block_size: 100_000) { |p| p.process }需要调用模型回调、关联或自定义方法时选它。ActiveRecord 只在你真正读取属性时才做类型转换,效率也不错。
③ 原生 SQL 游标
复杂报表、跨库查询?直接传 SQL:
Product.each_row_by_sql("select * from products") { |hash| Product.process(hash) } Product.each_instance_by_sql("select * from products") { |p| p.process }💡 所有方法都支持选项参数:block_size(每批行数,默认 1000)、cursor_name(自定义游标名)、while/until(按块返回值提前终止循环)、connection(指定连接)等。
想要完全手动控制 fetch 节奏?把游标包在事务里即可:
Product.transaction do cursor = Product.all.each_row row = cursor.fetch #=> {"id"=>"1"} row = cursor.fetch(symbolize_keys: true) #=> {:id=>"2"} cursor.close end进阶技巧:Enumerable 链式与 FOR UPDATE 锁
天然支持 Enumerable:不传 block 时返回的游标对象已混入Enumerable,且本身就是“懒”的,可继续链式调用:
Product.each_row.map { |r| r["id"].to_i } Product.each_instance.lazy.inject(0) { |sum, r| sum + r.quantity }逐行更新 + 行级锁:配合lock(FOR UPDATE),每批行在处理期间被锁定、处理完自动释放,实现并发安全的大表更新:
Product.lock.each_instance(block_size: 100) do |p| p.update(price: p.price * 1.05) end⚠️ 繁忙表或单行处理耗时长时,建议
block_size <= 10,避免长时间持锁引发死锁。
实用建议与避坑清单
- 🎯只在大结果集场景用游标:小查询直接
select更轻,游标有额外数据库开销 - ✂️少取列:用
.select(:id, :name)限定字段;只要单列值时pluck往往更合适 - 🔁批量场景选 batch 版本:
each_row_batch/each_instance_batch按批 yield,方便攒批写库 - 📦实验性功能:
pluck_rows/pluck_instances仍会构建数组,超大结果集慎用 - 📁 想读源码?核心游标逻辑在 cursor.rb,ActiveRecord 集成在 cursor_iterators.rb,可运行示例见 app.rb
总结
| 方案 | 内存 | 排序自由 | 复杂查询 |
|---|---|---|---|
直接each | ❌ 全量加载,易 OOM | ✅ | ✅ |
find_each | ✅ 分批 | ❌ 仅主键序 | ❌ 每批重跑 |
| postgresql_cursor | ✅ 分批 | ✅ 任意 order | ✅ 只声明一次 |
一句话:Web 请求继续用 ActiveRecord,百万行以上的后台处理交给 postgresql_cursor——一次声明、分批 FETCH、内存恒定,你的 Rails 应用从此告别 OOM。
【免费下载链接】postgresql_cursorActiveRecord PostgreSQL Adapter extension for using a cursor to return a large result set项目地址: https://gitcode.com/gh_mirrors/po/postgresql_cursor
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考