djangoのQuerySetで大量のデータを扱う

メモリに載り切らないような大量のデータを扱うときは何かしらの工夫が必要で、djangoのQuerySetも例外ではありません。

簡単な例として、以下のコードをメモリが多くない環境で実行すると、ループに入る前にメモリをドカ食いして落ちます。

many_many_objs = MyDBLog.objects.all()  # 1億件ぐらいあるとする

for log in many_many_objs:
    print(log.body)

どうする

件数が大きくなりえるQuerySetを扱う場所では、QuerySetを分割して扱うようにします。

QuerySetはスライスが使えるので、うまく活用しましょう。

def chunked(queryset, chunk_size=1000):
    start = 0
    while True:
        chunk = queryset[start:start + chunk_size]
        for obj in chunk:
            yield obj
        if len(chunk) < chunk_size:
            raise StopIteration
        start += chunk_size

これを最初のコードのqueryset利用箇所に仕込めばOKです。

for log in chunked(many_many_objs):
    print(log.body)

QuerySetの評価時に何が起こるのか

QuerySetは評価されると、自分が保持している条件に応じてクエリを投げ、ヒットしたすべてのデータをオブジェクトに変換してQuerySetオブジェクト内のキャッシュ領域に保持します。

__iter__を含むQuerySetの大概のインターフェースは、このキャッシュされたオブジェクトリストを使う(無ければlen(self)で無理やり評価してキャッシュを作る)実装になっているので、普通に使っている限りは必ず全件オブジェクト化されてしまうと考えたほうがよいです。

どうしてもキャッシュをかわしたい場合は、QuerySet.iteratorというメソッドがあります。このメソッドはクエリの結果を1行ずつfetchしてオブジェクトに変換するイテレータで、ここからオブジェクトを得る分にはキャッシュされません*1

QuerySetだけじゃない

最初は、QuerySet.iterator使えば万事解決と思ってましたが、そもそも件数が多いとDBクライアント*2に保持される生のクエリ結果自体が大量になるので、オブジェクト化される前にやっぱり落ちます。

python-mysqldbでは結果をクライアントに保持するか、サーバー側に置いておいて少量ずつクライアントでfetchするかは選べるようですが、サーバー側のメモリを食うようになると今度は落ちる箇所がDBサーバーになってしまうので、より酷いことになります。

結局

色々調べた結果、スライスする方法に落ち着きました。

djangoを使う上では、QuerySetの評価時になにが起こるのかと、どういうタイミングで評価されるのかは、ソースを読んでよく把握しておかないと結構ハマります。

*1:内部用っぽいですが、敢えて_を付けてないってことはこういう用途で使うことも想定しているんでしょう。

*2:今回はpython-mysqldb