Pythonで数百万件のレコードを処理する:MemoryErrorでサーバーをダウンさせないために

Python tutorial - IT technology blog
Python tutorial - IT technology blog

MemoryErrorという名の悪夢

Pythonでログをパースするスクリプトを書き始めたばかりの頃、私は利便性のためにすべてのデータをlistに格納する癖がありました。ある日、システムログが5GBを超えた瞬間に事態は一変。スクリプトは数秒で停止し、MemoryErrorという簡潔ながらも絶望的なメッセージを残して息絶えました。

原因は、ファイルの内容すべてを一気にRAMに読み込もうとしたことにあります。小規模なデータなら高速ですが、数千万行のログとなるとどれだけRAMがあっても足りません。そこで私は、ジェネレーター(Generator)とイテレーター(Iterator)を用いた「順次処理」の重要性に気づいたのです。

なぜRAMはこれほど早く「蒸発」してしまうのか?

実際、Pythonのlistはすべての要素をメモリ上に同時に保持します。試しに1000万個の整数リストを作成してみましょう。

# メモリを大量消費する書き方
my_list = [i for i in range(10000000)] # システムにより約80MB〜400MBのRAMを占有

Pythonは1000万個の数値を格納するために、OSに対して十分に大きな連続したメモリ領域を要求します。サーバーが他のサービスも動かしている場合、この急激なメモリ占有はフリーズの原因になります。これは「先行評価(Eager Evaluation)」と呼ばれる、使わない分まであらかじめ計算して用意しておく仕組みの弊害です。

イテレーター:必要な時に、必要な分だけ取り出す仕組み

カゴいっぱいのオレンジを一度に家に持ち帰るのではなく、食べたい時に庭へ行って1つずつ収穫するようなイメージです。大きな倉庫も必要ありませんし、放置して腐らせる心配もありません。

Pythonでは、__iter__()__next__()という2つのメソッドを持つ「プロトコル」を実装したオブジェクトをイテレーターと呼びます。next()を呼び出した瞬間にはじめて次の値が計算され、前回の状態を保持して次回の開始地点を記憶します。

class MyCounter:
    def __init__(self, low, high):
        self.current = low
        self.high = high

    def __iter__(self):
        return self

    def __next__ (self):
        if self.current > self.high:
            raise StopIteration
        self.current += 1
        return self.current - 1

# 使用例
counter = MyCounter(1, 10000000)
for num in counter:
    # ここでnumを処理しても、メモリ消費は極めて微量
    pass

たとえ10億までカウントしても、消費されるメモリはself.current変数を保持するだけの極わずかな量です。サーバーも一安心ですね。

ジェネレーター:yieldキーワードによる必殺技

イテレーターを作るためにわざわざclassを書くのは少し面倒です。Pythonにはもっと簡潔な方法があります。それがジェネレーター(Generator)です。関数を終了するreturnの代わりにyieldを使うだけです。

yieldに到達すると、関数はその時点の状態をすべて「凍結」して一時停止します。次に呼び出された際、解凍されてyieldの直後の行から再開されます。非常にスマートな仕組みです。

def my_generator(n):
    i = 0
    while i < n:
        yield i
        i += 1

# nがどれほど大きくても、数KBের RAMしか消費しません
gen = my_generator(10000000)

「軽さ」の実際の比較

sysモジュールを使って、実際のオブジェクトサイズを確認してみましょう。結果に驚くはずです。

import sys

n = 1000000
list_data = [i for i in range(n)]
gen_data = (i for i in range(n))

print(f"リストの消費量: {sys.getsizeof(list_data)} bytes (~8MB)")
print(f"ジェネレーターの消費量: {sys.getsizeof(gen_data)} bytes (112 bytes)")

リストはジェネレーターに比べて7万倍以上のメモリを消費しています。データが数十億件に達する場合、ジェネレーターを使うかどうかが、スクリプトがスムーズに動くか、深夜にサーバーがOOM(Out Of Memory)でダウンするかの境界線になります。

実践的な応用:10GBのログファイルを処理する

DevOpsの仕事では、頻繁にログのフィルタリングを行います。数GBのファイルに対してf.readlines()を使うのは自殺行為です。正しいやり方は、Pythonのファイルオブジェクト自体がイテレーターであることを活用することです。

def filter_error_logs(file_path):
    with open(file_path, "r") as f:
        for line in f: # ファイル全体をRAMに読み込まず、1行ずつ読み込む
            if "ERROR" in line:
                yield line.strip()

# データパイプラインの処理
logs = filter_error_logs("huge_production.log")
for error in logs:
    # このエラーをTelegramに送信したり、DBに保存したりする
    print(f"エラー検出: {error}")

データは本当に必要な時だけ読み込まれ、処理されます。これは「遅延評価(Lazy Evaluation)」と呼ばれ、パフォーマンスにおいて非常に有益な「怠惰」の形です。

ジェネレーター式:短くスマートなコードを書く

[x for x in data]のようなリスト内包表記に慣れているなら、角括弧を丸括弧 (x for x in data) に変えるだけで、すぐにジェネレーター式が使えます。

# メモリを消費せずに1000万個の数値の平方和を計算
total = sum(x*x for x in range(10000000))

sum()関数は各数値を1つずつ取り出し、平方を計算して加算していきます。リソースを浪費する中間リストは作成されません。

ジェネレーターの連結(パイプライン・パターン)

ETL処理で私が最も気に入っているテクニックは、複数のジェネレーターを連結することです。これは製造ラインのようなもので、各工程は一度に1つの製品だけを処理します。

def get_lines(file_obj): yield from file_obj

def clean_lines(lines): 
    for line in lines: yield line.strip()

def find_critical(lines):
    for line in lines:
        if "CRITICAL" in line: yield line

# 各工程を接続
with open("app.log") as f:
    critical_issues = find_critical(clean_lines(get_lines(f)))
    for issue in critical_issues:
        print(f"緊急事態: {issue}")

この書き方は非常に明快です。RAMへの影響を心配することなく、簡単にメンテナンスや処理工程の追加が可能です。

実践経験からの結び

午前2時に起きてサーバーを再起動する羽目になった経験から、私は1つのルールを導き出しました。「サイズが不明なデータセットを扱うときは、常にジェネレーターを使え」ということです。

  • リストを使う場合:データが小さく、ランダムアクセス(10番目の要素を取得した後に2番目に戻るなど)が必要な場合や、何度もソートが必要な場合。
  • ジェネレーター/イテレーターを使う場合:巨大なデータを処理する場合、ファイルの読み込み、データベースからのストリーミング、またはデータを一度だけ走査すれば十分な場合。

メモリの最適化は決して高尚で難しいことではありません。時には []() に変えるだけで済むこともあります。この記事が、皆さんの次のプロジェクトでMemoryErrorを回避する助けになれば幸いです。

Share: