问题:如何在不爆内存的情况下,读取百万级数据?

答案:分批拉取(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() 不是万能的,适合“数据量大但不超大”的场景。
  • 如果你需要绝对稳定的扫描能力,考虑数据库层面的游标或批处理。

References