ActiveRecord in_batches批量滚动查询若干页后超时问题排查
使用PostgreSQL 10.21运行每日清理任务删除旧记录,涉及表结构如下:
create table example_table ( id bigserial primary key, created_at timestamp not null ); create index index_example_table_on_created_at on example_table (created_at desc);
每日执行的Ruby清理代码:
ExampleTable.expired.in_batches(of: 1000, &:delete_all)
表当前有650万条数据,插入操作远多于读取。清理任务超时总是发生在获取某一批次ID时:ActiveRecord日志显示前几批查询仅耗时数毫秒,某一批次突然耗时5000+ms。
对最后两个查询执行EXPLAIN ANALYZE后发现:正常查询仅过滤少量数据,而超时查询过滤了657万+条数据,shared hit数值极高。移除created_at条件后查询恢复正常。
核心疑问:
- 为何某一批次会出现性能骤降?
- PostgreSQL是否不支持基于非主键条件的滚动查询?
执行计划选择失误
PostgreSQL优化器选择了通过主键索引(example_table_pkey)扫描,再过滤created_at条件。前期批次中,id > X范围内的记录大多满足created_at < 过期时间,过滤量小所以速度快。但当扫描到某个id阈值后,后续id对应的记录均为未过期状态(created_at >= 过期时间),此时数据库需要扫描数百万条无关记录才能找到少量符合条件的结果,直接导致性能骤降。统计信息偏差
PostgreSQL 10的统计信息可能未准确反映id与created_at的关联关系。由于表的插入量极大,若统计信息未及时更新,优化器会误判id > X范围内仍有大量符合created_at条件的记录,从而错误选择主键索引扫描,而非使用created_at专属索引。滚动逻辑不匹配过滤条件
ActiveRecord的in_batches默认按主键id滚动,但你的过滤条件基于created_at,两者并非严格正相关(比如可能存在旧id记录的created_at被更新,或插入顺序与时间戳不完全一致)。当主键滚动到超过所有过期记录的id范围后,后续查询会扫描大量未过期记录,效率极低。
- 改用基于
created_at的滚动查询
放弃按id分批次,改为按created_at范围分页,搭配created_at索引避免扫描无关数据:
expired_time = '2022-04-23 19:49:08.020992'.to_time last_created_at = Time.at(0) loop do batch = ExampleTable.where(created_at: last_created_at...expired_time).limit(1000) break if batch.empty? batch.delete_all last_created_at = batch.last.created_at end
- 创建复合索引
若必须按id滚动,创建(id, created_at)复合索引,让数据库无需回表即可判断created_at条件,大幅降低过滤成本:
CREATE INDEX idx_example_table_id_created_at ON example_table (id ASC, created_at ASC);
- 更新统计信息
手动更新表的统计信息,帮助优化器选择更优执行计划:
ANALYZE example_table;
- 直接执行批量删除SQL
绕过ActiveRecord批次逻辑,直接按created_at范围批量删除(数据量过大时分多次执行):
-- 循环执行此语句直到返回0行 DELETE FROM example_table WHERE created_at < '2022-04-23 19:49:08.020992' LIMIT 1000;
内容的提问来源于stack exchange,提问作者alanhala

