问题:如何在不爆内存的情况下,读取百万级数据?
答案:分批拉取(chunk),每次只保留少量对象在内存里。
核心思路:
- 以主键递增的方式分段查询
- 用
pk > last_pk替代OFFSET - 每次只取固定数量
示例
import gc
def lazy_fetch_iterator(table, start_pk=0, chunk_size=1000, *args, **kwargs):
"""
Get rows from a table by iterating over QuerySets ordered by primary key.
:param table: Django model class
:param int start_pk: start pk
:param int chunk_size: rows per batch
"""
queryset = table.objects
end_pk = (
queryset.filter(*args, **kwargs)
.order_by("-pk")
.values_list("pk", flat=True)
.first()
)
if end_pk is None:
return
while start_pk < end_pk:
objs = (
queryset.filter(pk__gt=start_pk, *args, **kwargs)
.order_by("pk")[:chunk_size]
)
for obj in objs:
start_pk = obj.pk
yield obj
# gc.collect() # 如果内存紧张可手动触发
为什么不用 queryset.all()
Django 会把查询结果缓存在 QuerySet 内部。
当数据量很大时,缓存会把内存迅速占满。并不是 ORM 在“偷懒”,而是为了让 QuerySet 可重复迭代。
为什么不直接用 iterator()
iterator() 的确能减少缓存,但有几个限制:
- 仍然会把全部结果从 DB 拉到客户端(只是 Django 不缓存)
- 对排序、预取不够友好(例如
prefetch_related会被忽略)
所以超大表场景里,更推荐“分批 + 主键游标”的方案。
为什么用 pk 而不是 OFFSET
OFFSET 在大表里会越来越慢,因为 MySQL 需要先扫描并丢弃前面的行。
例如 LIMIT 1 OFFSET 1000000,MySQL 实际上要扫描 1,000,001 行。
而 pk > last_pk 可以命中索引,性能更稳定。
小结
- 大数据集优先用“主键游标 + 分批”。
iterator()不是万能的,适合“数据量大但不超大”的场景。- 如果你需要绝对稳定的扫描能力,考虑数据库层面的游标或批处理。