記事をどれだけ読んだかを示します

qler: 正直なベンチマーク

バックグラウンドジョブにRedisは本当に必要か?

qlerはSQLite上に構築されたバックグラウンドジョブキューだ。ブローカーなし、デーモンなし、インフラなし;pip install qler でディスク上のファイルに裏付けられたジョブキューができる。問いはその単純さがスループットで何を犠牲にするかだ。そして計測を正直に行えばその答えが変わるかどうか。


環境

全計測を同一マシン、同一実行、同一Pythonプロセスで:

  • Python: 3.13.7
  • qler: 0.5.0(SQLite 3.50.4、WALモード)
  • Celery: 5.6.2 + redis-py 6.4.0
  • Redis: 7.0、localhost(ループバック;実ネットワーク遅延なし)
  • プラットフォーム: Linux x86_64、8コア
  • Celeryプール: --pool solo(シングルスレッド、c=1の公平な比較)

両システムともアーキテクチャの範囲内で同等の設定を受ける。qlerはWALモード;CeleryはRedisのデフォルトインメモリ永続化。一方だけが持つ特別なチューニングはない。


注意点(先に読むこと)

数値の前に、この比較が何であり何でないかを理解すること。

  1. アーキテクチャ: qlerはSQLiteでインプロセス実行 — ネットワークホップゼロ。CeleryはTCPループバック越しに別Redisプロセスと通信する。これは異なるトレードオフであり、単に速度が違うだけではない。
  2. シングルマシンのみ: qlerはマシン間でワークを分散できない。Celeryはできる。その能力はここではテストしない。
  3. SQLite書き込み上限: SQLiteは約1〜5K書き込み/秒を処理する。Redisは100K+。極端なスループットではこれは勝負にならない。
  4. soloプール: Celeryは公平なシングルワーカー比較のために --pool solo を使用。実際のデプロイは複数プロセスの prefork を使う;スループットギャップはさらに広がる。
  5. localhostのRedis: 実ネットワーク遅延なし。本番Redisはしばしばリモート(+0.1〜1ms/ラウンドトリップ)。これはCeleryが本番で直面するギャップを過小評価している。
  6. コールドスタート: 各イテレーションで新鮮なDBと新鮮なRedis。ウォームキャッシュなし、蓄積データなし。

フレーミング: 「バックグラウンドジョブのためだけにRedisをスタックに追加すべきか、それともSQLiteで十分か?」1台のマシンで毎分1K未満のジョブを処理しているなら、これは何を得るか(シンプルさ)と何を犠牲にするか(スループット上限)を示す。


書き込みパス(C1〜C2)

C1: エンキューレイテンシ

単一ジョブをどれだけ速く送信できるか? qlerはローカルSQLiteファイルに INSERT INTO を呼ぶ;CeleryはシリアライズしてTCP越しにRedisにパブリッシュする。

ジョブ数qler(ms)Celery(ms)ギャップ
10072571.3x遅い
500280275同等
1,0005255541.1x速い
5,0002,6092,7941.1x速い

概ね同等。小規模ではCeleryのパイプライン化されたRedis接続が勝つ;大規模では5,000 TCPラウンドトリップのオーバーヘッドが追いつき、qlerの単一トランザクションSQLite書き込みがわずかに先行する。

C2: バッチエンキュー

enqueue_many() はバッチ全体を1つのSQLiteトランザクションでラップする。Celeryの group().apply_async() は個々のRedisパブリッシュをパイプライン化する。

ジョブ数qler(ms)Celery(ms)ギャップ
10034561.6x速い
500752873.8x速い
1,0001215674.7x速い
5,0005013,1746.3x速い

qlerはバッチ書き込みで圧倒する。5,000行の1つのSQLiteトランザクションは、5,000個の個別Redisパブリッシュより根本的に安く、優位性は線形にスケールする。これがSQLiteの「全てはファイル」モデルが完全に勝つシナリオだ。


不公平な近道(C3〜C4)

これらのシナリオは元のベンチマークスイートの一部で、完全性のために残すが、誤解を招くものを計測している。

C3: Raw APIラウンドトリップ

qlerのraw API(Workerディスパッチなし)を使ったエンキュー→クレーム→完了 対 Celeryの delay().get()(実Workerを使用)。

ジョブ数qler(ms)Celery(ms)ギャップ
1003361682.0x遅い
5001,6168531.9x遅い
1,0003,1121,6811.9x遅い

C4: Raw APIスループット

同じraw API、持続的なシーケンシャルサイクル。

ジョブ数qler(ms)Celery(ms)ギャップ
1003151671.9x遅い
5001,5638301.9x遅い
1,0003,0311,6801.8x遅い
5,00015,8058,1971.9x遅い

ギャップは一貫して〜1.9x。Redisのインメモリオペレーションはqlerの3書き込みサイクル(エンキュー+クレーム+完了)を上回る;それは物理的な事実であってバグではない。しかし非対称性に注目: qlerはWorkerを完全にバイパスし、raw Queue APIを呼ぶ。CeleryはリアルWorkerを使う。qlerが同等の作業をしていないため、1.9xのギャップは人工的に狭い。

このバイアスはラウンド1.1の敵対的審査中に発見した。修正はC5とC6の追加: 両サイドでリアルWorkerを使う。


正直な数値(C5〜C6)

C5: Workerラウンドトリップ

真のエンドツーエンド: エンキュー→Workerがジョブを拾う→実行→job.wait() が返る。両サイドともリアルWorkerを使用。

ジョブ数qler(ms)Celery(ms)ギャップ
1004421323.4x遅い
5002,2307043.2x遅い

Celeryはリアルワーカーラウンドトリップで〜3.3x速い。ギャップは2箇所から来る: RedisのインメモリメッセージディスパッチはqlerのUPDATEによるクレームより速く、CeleryのWorkerはメッセージスループットに最適化されたバトルハードニングされたイベントループだ。

C6: Workerスループット

リアルWorkerを通じた持続的なシーケンシャルエンキュー+job.wait()。これが本番ワークロードで重要なスループット数値だ。

ジョブ数qler(ops/s)Celery(ops/s)ギャップ
10074.66008.0x遅い
50077.35967.7x遅い

Celeryはリアルワーカーを通じて〜8x高いスループットを維持する。prefork プール(未テスト)ではギャップはさらに広がる。

この数値はラウンド1.2では12.8xだった。何が変わったかは次のセクションで。


ラウンド1.3: イベント修正

ラウンド1.2では12.8xのスループットギャップが出た。それは正直な結果だったが — 問いはそれが必然的かどうかだった。

ボトルネックは job.wait() だった。ラウンド1.2では、ジョブの完了を待つとはデータベースをポーリングすることを意味した: ジョブのステータスをクエリし、50msスリープし、またクエリする、の繰り返し。全ての待機でジョブごとに0〜50msの不要なレイテンシを払い、ポーリングクエリがSQLiteファイルへの書き込みコンテンションを増やしていた。

修正は asyncio.Event 通知システムだった。Workerがジョブを完了すると、インプロセスのEventを発火する。job.wait() は最初のDBチェックの前にそのEventに登録する;待機中にジョブが完了すれば、Eventが即座に起こす — ポーリングなし、スリープなし、無駄なクエリなし。

# _notify.py — モジュール全体は47行
_registry: dict[str, asyncio.Event] = {}

def register(ulid: str) -> asyncio.Event:
    """wait() が最初のDBチェックの前に呼ぶ。"""
    if ulid not in _registry:
        _registry[ulid] = asyncio.Event()
    return _registry[ulid]

def fire(ulid: str) -> None:
    """complete_job/fail_job/cancel_job が呼ぶ。"""
    ev = _registry.get(ulid)
    if ev is not None:
        ev.set()

クロスプロセス呼び出し元(または wait() が開始する前に完了したジョブ)は透過的に既存のDBポールにフォールバックする。Eventは高速パスであって要件ではない。

修正前後

指標ラウンド1.2ラウンド1.3変化
C6スループット(qler、100ジョブ)50.2 ops/s74.6 ops/s+49%
C6スループット(qler、500ジョブ)51.4 ops/s77.3 ops/s+50%
CeleryとのC6ギャップ12.8x8.0x37%縮小

47行のモジュールがスループットギャップを3分の1削減した。残る8xは主にアーキテクチャ的: Redisはメモリでメッセージをディスパッチするがqlerはディスクに書く。そのギャップは実在し、ストレージモデルの根本的な変更なしには埋まらない。


コールドスタート(C7)

全ての初期化を含む、ゼロから最初の完了ジョブまでの時間。

ジョブ数qler(ms)Celery(ms)ギャップ
1343,478102x速い
101,1463,4973.1x速い

qlerは34msで初期化する: SQLiteファイルを作成し、スキーママイグレーションを実行し、asyncioタスクを開始する。Celeryは〜3.5秒かかる: サブプロセスをフォークし、Redisに接続し、タスクを登録し、イベントループを開始する。

102xのギャップが重要な3つのユースケース: ジョブを実行して終了するCLIツール、テストごとにWorkerを起動するテストスイート、コールドスタートが課金時間になるサーバーレス関数。


全結果テーブル

全7シナリオ、ラウンド1.3、中央値。スケール: small(100〜5,000ジョブ)、3イテレーション、1ウォームアップ。

シナリオ指標qlerCeleryギャップ勝者
C1: エンキューレイテンシops/s @ 5K1,9161,7891.1xqler
C2: バッチエンキューops/s @ 5K9,9731,5766.3xqler
C3: Raw APIラウンドトリップops/s @ 1K3215951.9xCelery
C4: Raw APIスループットops/s @ 5K3166101.9xCelery
C5: Workerラウンドトリップops/s @ 5002247113.2xCelery
C6: Workerスループットops/s @ 500775967.7xCelery
C7: コールドスタート(1ジョブ)ms343,478102xqler

どちらをいつ使うか

シナリオ推奨
シングルマシン、< 1K ジョブ/分qler — インフラなし、同プロセス、デバッグ可能
シングルマシン、> 5K ジョブ/分Celery+Redis — SQLite書き込み上限がボトルネックに
マルチマシン / 分散Celery+Redis — qlerは分散できない
開発 / プロトタイピングqler — セットアップゼロ、pip install qler で開始
既にRedisを運用中Celery — キュー追加の限界コストはほぼゼロ
デバッグ / 観測可能性qler — 全ジョブ、試行、失敗にSQLで完全アクセス
CLIツール / テストスイートqler — 34msコールドスタート対3.5秒

足りないもの

これらのベンチマークは1台のマシンとlocalhostのRedisで実行している。未テスト:

  • リモートRedis: 実ネットワーク遅延を加えるとCeleryのラウンドトリップが遅くなる。C1/C5のギャップは縮まり;C2(バッチ)はqlerに有利にさらに広がる。
  • preforkプール: --pool prefork -c 4 のCeleryはスループットを倍増させる。C6のギャップは広がる。
  • 本番データ: ウォームキャッシュ、数千の既存ジョブ、並行ライター。SQLiteのシングルライターロックはコンテンション下で実際の制約になる。
  • マルチマシン: qlerのアーキテクチャは分散できない。これはパフォーマンスの問題ではなく機能の境界だ。
  • 大きなペイロード: 全ジョブは最小限のペイロードを使用。大きなJSONブロブではシリアライゼーションコストがより重要になる。

正直なまとめ: qlerはシンプルさ、コールドスタート、バッチ書き込みで勝つ。Celeryは持続的スループットで勝ち、水平スケーリングでは常に勝つ。8xスループットギャップはRedisを動かさないことの代価だ;多くのアプリケーションにとって、その代価を払う価値はある。


自分で実行する

git clone https://github.com/gabubelern/qler.git
cd qler
uv sync --all-groups

# localhost:6379でRedisが動いていること
uv run --group bench python -m benchmarks run --suite comparison --scale small --warmup 1 --iterations 3 -v
uv run --group bench python -m benchmarks compare

結果は benchmarks/COMPARISON.md に出力される。スイートはマッチした設定、GC分離、計測同等性を自動的に強制する。