postgresql_cursor vs find_in_batches:深扒批量读取的4大致命缺陷,find_each为何不够用
postgresql_cursor vs find_in_batches:深扒批量读取的4大致命缺陷,find_each为何不够用
【免费下载链接】postgresql_cursorActiveRecord PostgreSQL Adapter extension for using a cursor to return a large result set项目地址: https://gitcode.com/gh_mirrors/po/postgresql_cursor
处理百万行级数据时,Rails 开发者常陷入内存暴涨的困境。postgresql_cursor是一款扩展 ActiveRecord PostgreSQL 适配器的 Ruby 开源库,它借助 PostgreSQL 游标(Cursor)机制,将超大结果集按块(默认 1000 行)分批取回,让应用内存占用始终保持在可控范围。本文对比find_in_batches/find_each的 4 大致命缺陷,讲清为什么批量读取场景下它才是更优解。
为什么 find_each / find_in_batches 不够用?
先说结论:find_each和find_in_batches是 Rails 提供的"分批读"方案,它们按batch_size(默认 1000 行)分块遍历,避免了把整张表一次性装进内存。看起来很美好?但在真实业务里,它们有 4 个硬伤——
缺陷 1:无法指定排序,只能按主键顺序返回
find_each/find_in_batches强制按主键(通常是id)顺序返回,不支持自定义order。如果你需要按创建时间、价格或任意业务字段排序遍历,它们直接出局。而 postgresql_cursor 基于真实游标,order("name")等任意排序都能正常生效。
缺陷 2:主键必须是数字类型
分页机制依赖"上一批最大 id + 1"这种数字区间推进,因此主键必须是数值型。字符串主键、UUID 主键?抱歉,用不了。游标方案完全没有这个限制,因为它由数据库侧维护结果集位置。
缺陷 3:每个批次都要重新执行查询
每取一批(1000 行),Rails 都要重新跑一次带id > last_seen_id LIMIT 1000的查询。100 万行就意味着 1000 次完整的查询规划与执行,数据库压力随数据量线性放大。游标则不同:只声明一次查询(DECLARE),后续反复 FETCH 取块,数据库侧只执行一遍。
缺陷 4:复杂查询扛不住,性能开销翻倍
由于查询会"重放",任何复杂的 JOIN、子查询都会在每个批次重复付出编译与执行代价,数据量越大浪费越明显。README 在 README.md 中也明确指出:复杂查询配合重放机制会带来额外开销,这正是游标要解决的痛点。
📌 小结:4 个缺陷的共同根源——它们不是真正的流式读取,而是"伪流式"的重放分页。
postgresql_cursor 如何做到真·流式读取?
它直接调用 PostgreSQL 的原生游标操作,伪代码如下(摘自 README):
SET cursor_tuple_fraction TO 1.0; DECLARE cursor_1 CURSOR WITH HOLD FOR select * from widgets; loop rows = FETCH 100 FROM cursor_1; -- 每次只取一块 rows.each {|row| yield row} until rows.size < 100; CLOSE cursor_1;关键机制:
- 查询只执行一次,结果集位置由数据库游标维护;
- 每次 FETCH 只拉取 block_size 行(默认 1000),内存恒定;
- 支持任意 order、任意复杂 SQL、任意类型主键;
with_hold: true时游标甚至能在事务提交后保持打开。
核心迭代器实现在lib/postgresql_cursor/active_record/relation/cursor_iterators.rb,底层游标封装在lib/postgresql_cursor/cursor.rb,你可以直接阅读源码理解细节。
安装与 3 分钟上手
一键安装步骤
在 Gemfile 中添加依赖即可(要求 ActiveRecord >= 6.0):
gem 'postgresql_cursor'本地开发也可以从源码安装:
git clone https://gitcode.com/gh_mirrors/po/postgresql_cursor gem build postgresql_cursor.gemspec && gem install postgresql_cursor.gem最快配置方法:3 行代码开始流式读取
# 逐行返回 Hash(最快,适合批量处理) Product.where("id>0").order("name").each_row { |row| Product.process(row) } # 逐行返回模型实例(需要调用模型方法时用) Product.where("id>0").each_instance { |product| product.process! }不想写块?直接拿游标对象,它是 Enumerable,可以自由链式操作:
Product.each_row.map { |r| r["id"].to_i } #=> [1, 2, 3, ...] Product.each_instance.lazy.inject(0) { |sum, r| sum + r.quantity }常用配置项(options 速查表)
| 选项 | 说明 |
|---|---|
block_size: n | 每次从数据库取回的行数(默认 1000) |
while: value | 块返回该值时继续循环 |
until: value | 块返回该值时停止循环 |
connection: conn | 指定使用的数据库连接 |
with_hold: true | 提交后保持游标打开 |
cursor_name: str | 给游标命名 |
fraction: 1.0 | 设置 cursor_tuple_fraction,不建议改动 |
性能优化:Hash vs 实例,差出 4 倍速度
README 中的非正式基准测试显示:返回 Hash 比实例化模型快约 4 倍。选型建议:
- 只做数据加工、写库、导出?用
each_row(each_hash),拿到的是字符串值 Hash,注意自行做类型转换; - 需要调用模型方法、依赖类型自动转换?用
each_instance,ActiveRecord 只在你读取属性时才惰性转换,效率已经不错; - 只需要几列?配合
select(:id, :name)收窄返回列; - 只要值不要行?
pluck_rows(:id)/pluck_instances(:id, :quantity)可代替传统pluck,且仍是分批惰性加载。
进阶:边遍历边加锁更新(FOR UPDATE)
Product.lock.each_instance(block_size: 100) do |p| p.update(price: p.price * 1.05) endlock会为每个 FETCH 块加FOR UPDATE行锁,块处理完即释放——大表逐行更新时既安全又不阻塞并发。注意:繁忙表或单行处理耗时较长时,block_size建议 ≤ 10,避免死锁。
选型对比:一张表看懂差异
| 维度 | find_in_batches / find_each | postgresql_cursor |
|---|---|---|
| 排序支持 | ❌ 仅主键序 | ✅ 任意 order |
| 主键类型 | ❌ 必须数字 | ✅ 无限制 |
| 查询执行次数 | 每批重跑一次 | 仅执行一次 |
| 复杂查询 | 开销随批次数放大 | 无额外重放开销 |
| 内存占用 | 恒定(单批) | 恒定(单块) |
| 行锁更新 | 需手动实现 | 原生lock支持 |
| 额外依赖 | 无 | 仅限 PostgreSQL 数据库 |
⚠️ 注意:游标有数据库侧开销,只用于大数据量场景;小结果集直接用常规查询即可。另外它依赖to_sql,无法像 ActiveRecord 那样做关联预加载的表 JOIN 拆分,设计查询时请把所需字段 join 好。
常见问题(FAQ)
Q1:我用的不是 PostgreSQL 能用吗?不能。该库扩展的是 PostgreSQL 适配器(见lib/postgresql_cursor/active_record/connection_adapters/postgresql_type_map.rb的类型映射),MySQL 等数据库没有对应游标语义支持。
Q2:需要包在事务里吗?只有当你手动cursor.fetch逐行取数、自己控制节奏时才必须放在事务中;直接使用each_row/each_instance遍历则无需。
Q3:如何本地跑测试?项目自带测试应用,运行test-app/run.sh setup创建测试库,再用test-app/run.sh irb进入交互式控制台体验,示例代码见test-app/app.rb。
总结
如果只需按 id 顺序简单翻页,find_each已经够用;但只要你遇到"要自定义排序、主键不是数字、查询很复杂、数据量上百万"中任意一条,find_in_batches的 4 大致命缺陷就会逐个爆出来。postgresql_cursor 用"查询执行一次 + 游标分块 FETCH"的数据库级方案,一次性解决了全部问题,还能顺手获得 4 倍速的 Hash 遍历和行锁更新能力。批量读取场景,值得把游标纳入你的技术清单。🚀
【免费下载链接】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),仅供参考
