バックグラウンドジョブに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のデフォルトインメモリ永続化。一方だけが持つ特別なチューニングはない。
注意点(先に読むこと)
数値の前に、この比較が何であり何でないかを理解すること。
- アーキテクチャ: qlerはSQLiteでインプロセス実行 — ネットワークホップゼロ。CeleryはTCPループバック越しに別Redisプロセスと通信する。これは異なるトレードオフであり、単に速度が違うだけではない。
- シングルマシンのみ: qlerはマシン間でワークを分散できない。Celeryはできる。その能力はここではテストしない。
- SQLite書き込み上限: SQLiteは約1〜5K書き込み/秒を処理する。Redisは100K+。極端なスループットではこれは勝負にならない。
- soloプール: Celeryは公平なシングルワーカー比較のために
--pool soloを使用。実際のデプロイは複数プロセスのpreforkを使う;スループットギャップはさらに広がる。 - localhostのRedis: 実ネットワーク遅延なし。本番Redisはしばしばリモート(+0.1〜1ms/ラウンドトリップ)。これはCeleryが本番で直面するギャップを過小評価している。
- コールドスタート: 各イテレーションで新鮮なDBと新鮮なRedis。ウォームキャッシュなし、蓄積データなし。
フレーミング: 「バックグラウンドジョブのためだけにRedisをスタックに追加すべきか、それともSQLiteで十分か?」1台のマシンで毎分1K未満のジョブを処理しているなら、これは何を得るか(シンプルさ)と何を犠牲にするか(スループット上限)を示す。
書き込みパス(C1〜C2)
C1: エンキューレイテンシ
単一ジョブをどれだけ速く送信できるか? qlerはローカルSQLiteファイルに INSERT INTO を呼ぶ;CeleryはシリアライズしてTCP越しにRedisにパブリッシュする。
| ジョブ数 | qler(ms) | Celery(ms) | ギャップ |
|---|---|---|---|
| 100 | 72 | 57 | 1.3x遅い |
| 500 | 280 | 275 | 同等 |
| 1,000 | 525 | 554 | 1.1x速い |
| 5,000 | 2,609 | 2,794 | 1.1x速い |
概ね同等。小規模ではCeleryのパイプライン化されたRedis接続が勝つ;大規模では5,000 TCPラウンドトリップのオーバーヘッドが追いつき、qlerの単一トランザクションSQLite書き込みがわずかに先行する。
C2: バッチエンキュー
enqueue_many() はバッチ全体を1つのSQLiteトランザクションでラップする。Celeryの group().apply_async() は個々のRedisパブリッシュをパイプライン化する。
| ジョブ数 | qler(ms) | Celery(ms) | ギャップ |
|---|---|---|---|
| 100 | 34 | 56 | 1.6x速い |
| 500 | 75 | 287 | 3.8x速い |
| 1,000 | 121 | 567 | 4.7x速い |
| 5,000 | 501 | 3,174 | 6.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) | ギャップ |
|---|---|---|---|
| 100 | 336 | 168 | 2.0x遅い |
| 500 | 1,616 | 853 | 1.9x遅い |
| 1,000 | 3,112 | 1,681 | 1.9x遅い |
C4: Raw APIスループット
同じraw API、持続的なシーケンシャルサイクル。
| ジョブ数 | qler(ms) | Celery(ms) | ギャップ |
|---|---|---|---|
| 100 | 315 | 167 | 1.9x遅い |
| 500 | 1,563 | 830 | 1.9x遅い |
| 1,000 | 3,031 | 1,680 | 1.8x遅い |
| 5,000 | 15,805 | 8,197 | 1.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) | ギャップ |
|---|---|---|---|
| 100 | 442 | 132 | 3.4x遅い |
| 500 | 2,230 | 704 | 3.2x遅い |
Celeryはリアルワーカーラウンドトリップで〜3.3x速い。ギャップは2箇所から来る: RedisのインメモリメッセージディスパッチはqlerのUPDATEによるクレームより速く、CeleryのWorkerはメッセージスループットに最適化されたバトルハードニングされたイベントループだ。
C6: Workerスループット
リアルWorkerを通じた持続的なシーケンシャルエンキュー+job.wait()。これが本番ワークロードで重要なスループット数値だ。
| ジョブ数 | qler(ops/s) | Celery(ops/s) | ギャップ |
|---|---|---|---|
| 100 | 74.6 | 600 | 8.0x遅い |
| 500 | 77.3 | 596 | 7.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/s | 74.6 ops/s | +49% |
| C6スループット(qler、500ジョブ) | 51.4 ops/s | 77.3 ops/s | +50% |
| CeleryとのC6ギャップ | 12.8x | 8.0x | 37%縮小 |
47行のモジュールがスループットギャップを3分の1削減した。残る8xは主にアーキテクチャ的: Redisはメモリでメッセージをディスパッチするがqlerはディスクに書く。そのギャップは実在し、ストレージモデルの根本的な変更なしには埋まらない。
コールドスタート(C7)
全ての初期化を含む、ゼロから最初の完了ジョブまでの時間。
| ジョブ数 | qler(ms) | Celery(ms) | ギャップ |
|---|---|---|---|
| 1 | 34 | 3,478 | 102x速い |
| 10 | 1,146 | 3,497 | 3.1x速い |
qlerは34msで初期化する: SQLiteファイルを作成し、スキーママイグレーションを実行し、asyncioタスクを開始する。Celeryは〜3.5秒かかる: サブプロセスをフォークし、Redisに接続し、タスクを登録し、イベントループを開始する。
102xのギャップが重要な3つのユースケース: ジョブを実行して終了するCLIツール、テストごとにWorkerを起動するテストスイート、コールドスタートが課金時間になるサーバーレス関数。
全結果テーブル
全7シナリオ、ラウンド1.3、中央値。スケール: small(100〜5,000ジョブ)、3イテレーション、1ウォームアップ。
| シナリオ | 指標 | qler | Celery | ギャップ | 勝者 |
|---|---|---|---|---|---|
| C1: エンキューレイテンシ | ops/s @ 5K | 1,916 | 1,789 | 1.1x | qler |
| C2: バッチエンキュー | ops/s @ 5K | 9,973 | 1,576 | 6.3x | qler |
| C3: Raw APIラウンドトリップ | ops/s @ 1K | 321 | 595 | 1.9x | Celery |
| C4: Raw APIスループット | ops/s @ 5K | 316 | 610 | 1.9x | Celery |
| C5: Workerラウンドトリップ | ops/s @ 500 | 224 | 711 | 3.2x | Celery |
| C6: Workerスループット | ops/s @ 500 | 77 | 596 | 7.7x | Celery |
| C7: コールドスタート(1ジョブ) | ms | 34 | 3,478 | 102x | qler |
どちらをいつ使うか
| シナリオ | 推奨 |
|---|---|
| シングルマシン、< 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分離、計測同等性を自動的に強制する。
