はじめに

頭を抱える男性

開発環境では一瞬で終わるDB取得が、取得先をオンプレ(または別環境)に変えただけで、「異常に遅い」という経験ありませんか?
ネットワークのレイテンシを加味しても異常に遅い…
メモリやCPUに余裕があるのに1回のFetch件数を引き上げても速くならない…

この記事では、私が直面した不可解な速度問題と、その劇的な解決方法を共有します。

背景

あるPython上の処理で、Direct Connect経由のOracle DBから1億件超のデータを抽出するバッチ処理がありました。
データ量が多いため、100万件ごとに抽出・ファイル出力をしています。しかし、この処理時間が異常に長く、業務要件を満たせない恐れがありました。

結論から言うと、Python上で cx_Oracle (または後継の python-oracledb) のたった一つのパラメータを見直すだけで、この処理は2倍以上高速化しました。

その原因特定までのプロセスと、鍵となったパラメータ arraysize についてご紹介します。

ボトルネックの特定:犯人は DB Fetch Time

まずはどこに時間がかかっているのか、コードにログを仕込んで計測しました。
処理は大きく「DBからデータを取得」「CSVに書き込み」「S3にアップロード」の3つに分かれます。

以下が、1ファイル(100万件)あたりの処理時間を示したログの抜粋です。

INFO:root: - Total DB Fetch Time: 0:02:13.958024 <-- (全体の80%以上!)
INFO:root: - Total CSV Write Time: 0:00:18.858070
INFO:root:[1/161] Uploading file to S3 finished. Elapsed: 0:00:06.067239

判明した事実:
処理時間の大半が data_rows = _cur.fetchmany(...) の処理、つまりDBからのデータ取得に費やされていました。
CSV書き込みやS3アップロードはボトルネックではありませんでした。

原因の深掘り:なぜ FETCH_ROW_COUNT を増やしても速くならなかったのか?

この結果を受け、私たちはまず FETCH_ROW_COUNT(一度に取得する行数)を1万件から2万件へと増やしてみました。CPUやメモリには十分な余裕があったからです。

しかし、処理速度は全く変わりませんでした。

ここでドキュメントを読み込み、arraysize というパラメータの存在と、fetchmany の真の挙動に気づきます。

  • fetchmany(N): Python側が「最終的にN件欲しい」と出す命令
  • cursor.arraysize: DBとの1往復の通信で、内部的に何件まとめて運ぶかを決める手段(運搬用の箱のサイズ)

arraysize を指定しない場合、cx_Oracle のデフォルト値(通常100件)が使われます。
つまり、コードで fetchmany(20000) を実行しても、Pythonの裏側では「100件ください」という細かい通信がDBとの間で 200回 も発生していたのです!

実は、この「通信回数の多さ」こそが、開発環境では爆速だったのに本番では終わらないという現象の正体でした。

ボトルネックの正体:ネットワーク遅延(レイテンシ)

両環境の決定的な違いは「通信1回あたりの待ち時間」です。

  • 開発環境(同一VPC内のRDS):
    ネットワーク遅延がほぼゼロ。200往復しても一瞬で終わるため、問題が隠蔽されていました。
  • 本番環境(Direct Connect経由のオンプレDB):
    物理的な距離があり、1往復あたりの遅延が発生します。この「遅延 × 膨大な往復回数」が積み重なり、致命的なボトルネックとなっていました。

解決策:たった1行の追加

原因がわかれば解決は簡単です。
cx_Oracle のカーソルを作成した直後に、arraysizefetchmany で指定するサイズと同じ値に設定するだけでした。

def execute_for_get_row(...):
    _cur = _conn.cursor(...) 

    # 取得したい件数を設定(例: 20,000件)
    batch_size = int(os.environ.get('FETCH_ROW_COUNT', 20000))

    # ★★★ 追加した1行 ★★★
    # 1回の通信で運ぶサイズ(arraysize)を、処理したい件数に合わせる
    _cur.arraysize = batch_size

    _cur.execute(sql)
    return _cur

Before / After の比較

# ❌ Before: デフォルトのarraysize(100)で200回通信
_cur = _conn.cursor()
data = _cur.fetchmany(20000)  # 内部で200往復発生

# ✅ After: arraysizeを設定して通信回数を削減
_cur = _conn.cursor()
_cur.arraysize = 20000  # この1行を追加
data = _cur.fetchmany(20000)  # 大幅に往復回数が減少

検証:開発環境でどうやって「遅延」を再現したか?

修正コードができましたが、開発環境(同一VPC内のRDS)で実行しても、元々速かったため効果が測定できません。
「本番で本当に速くなるのか?」を確認するため、開発環境で疑似的に本番のネットワーク状況を再現することにしました。

別リージョンへの「遠距離接続」テスト

同一リージョン内でVPCを分けてもAWSのバックボーンが速すぎて遅延が再現できなかったため、あえて物理的に離れたリージョン(米国東部など)にテスト用DBを作成し、VPC Peeringで接続しました。

結果:
期待通り(?)、修正前のコードでは処理に数時間かかる「激遅環境」が再現できました。
そして修正後のコードを流したところ、劇的な速度向上が確認でき、自信を持って本番適用に進むことができました。

劇的な改善結果

この1行を追加して、本番環境でジョブを実行しました。

  • 修正前: 午前1時に開始 ⇒ 8時過ぎても終わらない…
  • 修正後: 午前1時に開始 ⇒ 3時52分終了 (4時間以上短縮!)

arraysize を適切に設定することで、ネットワーク遅延によるペナルティを劇的に改善できることが証明されました。

arraysize 設定のベストプラクティス

推奨値の目安

arraysize の適切な値は、メモリリソースとパフォーマンスのバランスで決まります。

  • 小規模データ(〜1万件): 5,000〜10,000
  • 中規模データ(〜100万件): 10,000〜50,000
  • 大規模データ(100万件〜): 50,000〜100,000

メモリ消費の計算方法

# メモリ消費量の目安
# arraysize × 1行あたりのバイト数 = 一時的なメモリ使用量
# 例: arraysize=20,000、1行あたり1KB → 約20MBのメモリ使用

# 安全な設定例
_cur.arraysize = min(batch_size, 50000)  # 上限を設けて安全性を確保

注意点

  • サイズを大きくしすぎるとクライアント側のメモリ消費量が増えるため、アプリケーションサーバーのメモリリソースとのバランスを考慮してください
  • OOM(Out of Memory)エラーが発生した場合は、arraysize を小さくするか、バッチサイズ自体を見直してください

他のDBライブラリでも同様の設定が必要

この問題は cx_Oracle に限った話ではありません。他のデータベースライブラリでも、大量データを扱う際にはデフォルト設定のままだとパフォーマンスが出なかったり、メモリ不足になったりする罠があります。

SQL Server (pyodbc) の場合

Oracleと同様に cursor.arraysize で制御しますが、デフォルト値が「1」であることに注意してください。
設定を忘れると1行ごとにネットワーク通信が発生し、Oracle以上に壊滅的な遅延が発生します。必ず大きな値を設定しましょう。

PostgreSQL (psycopg2) の場合

psycopg2 の通常のカーソル(クライアントサイドカーソル)は、execute() を実行した瞬間に全データをメモリに読み込みます。
そのため、大量データを扱う場合はメモリ不足になる危険性があり、arraysize を設定しても通信回数の削減にはなりません。

大量データを扱う際は、必ず「サーバーサイドカーソル(名前付きカーソル)」を使用してください。この場合、itersize(デフォルト: 2000)というパラメータでネットワーク往復時の取得件数を制御します。

# ❌ 通常のカーソル (大量データには不向き)
# execute() 時点で全件取得されるため、メモリを圧迫する
cursor = conn.cursor()

# ✅ サーバーサイドカーソル (名前を指定して作成)
# itersize 分だけ小分けにネットワーク通信して取得する
cursor = conn.cursor(name='my_cursor_name')
cursor.itersize = 10000  # ★ここでバッチサイズを指定
cursor.execute("SELECT ...")

MySQL (mysql-connector-python) の場合

MySQL公式ドライバも、デフォルト(buffered=True)ではクエリ結果を全件メモリに読み込みます。
1億件のようなデータを扱うと即座にメモリ不足になるため、カーソル作成時に buffered=False を指定して、データをストリーミングで取得するように設定する必要があります。

結論:学んだこと

今回の経験から学んだことは3つです。

  1. arraysize は必須:
    cx_Oracle(後継の python-oracledb も同様)や psycopg2itersize)で大量データを扱う際、この設定は必須です。一般的には1万〜10万件程度を目安に、メモリリソースとのバランスを考慮して設定してください。
  2. 遅延は開発環境では隠蔽される:
    クラウド内(同一VPC)での開発では、通信が速すぎて「通信回数の多さ」という問題が見えなくなります。
  3. 性能テストは「遅延」を再現せよ:
    本番がネットワーク越しのDBなら、開発環境でも別リージョンにDBを置くなどして、意図的に遅延を再現したテストが非常に重要です。

たった1行のコードが、数時間の処理時間を削減できることもあります。
この記事が、同じようにネットワーク越しのDB性能に悩む誰かの助けになれば幸いです。

参考リンク

本記事の執筆にあたり、参照した公式ドキュメントです。

  • Oracle (python-oracledb):
    Tuning Fetch Performance
    arraysizeを変更することで、ラウンドトリップ回数を減らしパフォーマンスを向上させる方法について。
  • PostgreSQL (psycopg2):
    cursor.fetchmany
    Server-side cursors
    パフォーマンスのために arraysize(またはitersize)属性を使用するのが最善であるという記述。
  • SQL Server (pyodbc):
    pyodbc Wiki: Cursor features
    arraysize のデフォルトが 1 であることについての記述。
  • MySQL (mysql-connector-python / PyMySQL):
    MySQL Connector/Python: cursor()
    PyMySQL: SSCursor
    デフォルトでは全件メモリに読み込む仕様であることと、大量データ処理時の設定(buffered=Falseや SSCursor)について。