© 2026 Koder.ai. All rights reserved.
ホーム › ブログ › Cron + Database パターン:キューを使わないバックグラウンドジョブ
問題:追加インフラなしでスケジュール処理を行う\n\nほとんどのアプリは、後で実行する仕事や定期的に動かす処理が必要です:フォローアップメールの送信、夜間の請求チェック、古いレコードのクリーンアップ、レポートの再生成、キャッシュの更新など。\n\n初期段階では、背景処理は「キューを導入するのが正解」と感じて、フルなキューシステムを入れたくなります。しかしキューは別のサービスを運用・監視・デプロイ・デバッグする必要があり、特に小さなチームや創業者1人だと余計な負担になります。\n\nそこで本当に問うべきは:追加のインフラを立てずに、どうやって信頼性のあるスケジュール処理を実行するか、です。\n\nよくある最初の試みは単純です:cronでエンドポイントを叩き、そのエンドポイントが仕事を実行する。うまくいくこともありますが、うまくいかなくなると厄介です。サーバーが複数台になったり、デプロイのタイミングが悪かったり、ジョブが予想より長くかかると、混乱した障害が出始めます。\n\nスケジュールされた仕事はたいてい、次のような分かりやすい失敗で壊れます:\n\n- 二重実行:2台のサーバーが同じタスクを両方実行してしまい、請求書が2重に生成されたりメールが重複送信されたりする。\n- 実行漏れ:デプロイ中にcron呼び出しが失敗して、ユーザーからの不満で初めて気づく。\n- 無音の失敗:ジョブが一度エラーを起こして以降、再試行の仕組みがないため二度と実行されない。\n- 部分的な作業完了:ジョブが途中でクラッシュしてデータが中途半端な状態に残る。\n- 監査証跡がない:最後にいつ実行されたか、昨夜何が起きたか答えられない。\n\ncron + database パターンはその中間の道です。cronで「起床」だけを行い、ジョブの意図と状態はデータベースに保存します。データベースが調整、再試行、実行履歴の記録を担います。\n\nデータベースが既に1つ(多くは PostgreSQL)あり、ジョブの種類が少なく、最低限の運用で予測可能な挙動が欲しい場合に適しています。React + Go + PostgreSQL のようなモダンなスタックで素早く構築したアプリにも自然に合います。\n\n逆に、非常に高いスループットが必要な場合、進行状況をストリームする長時間ジョブ、数多くのジョブタイプ間で厳密な順序付けが必要な場合、あるいは大量のファンアウト(毎分何千というサブタスク)が必要な場合は、専用のキューとワーカーの方が向きます。\n\n## コアアイデア(平易に)\n\ncron + database パターンは、フルなキューシステムを立てずにスケジュール処理を実行する方法です。cron(または任意のスケジューラ)は使いますが、cronが何を実行するかは決めません。cronは頻繁に(分毎など)ワーカーを起こすだけで、データベースがどの仕事が期限切れかを判断し、1つだけを確実に取り出します。\n\nホワイトボードに張られた共有のチェックリストのように考えてください。cronは毎分部屋に入って「今やるべきことある?」と聞く人で、データベースは何が期限で、誰が取ったか、何が終わったかを示すホワイトボードです。\n\n主要な要素は単純です:\n\n- 単一のスケジューラトリガーが頻繁に実行される。\n- ジョブを表すテーブルが「何をいつやるか(due time)」、ステータス、試行回数を保持する。\n- 1つ以上のワーカーがテーブルをポーリングし、ジョブをクレームして作業を実行する。\n- クレームはデータベースのロック(リース)で行い、複数のワーカーが同じ行を取れないようにする。\n- データベースが何が実行されたか、失敗したか、再試行すべきかの真実の源になる。\n\n例:毎朝請求リマインダーを送る、キャッシュを10分ごとに更新する、古いセッションを夜間にクリーンアップする。個別のcronコマンドを3つ作る代わりに、ジョブを1か所に保存します。cronは同じワーカープロセスを起動し、ワーカーがPostgresに「今実行すべきものは?」と尋ね、Postgresが1件ずつ安全にジョブをクレームさせます。\n\nこの方式は段階的にスケールします。最初は1台のサーバー上で1つのワーカーから始められ、後で複数サーバーに5つのワーカーを走らせても契約(テーブル)が同じままです。\n\nマインドセットの切り替えは簡単です:cronは目覚ましでしかなく、データベースが交通整理をして実行の可否を決め、何が起きたかの履歴を残す役目です。\n\n## ジョブテーブルの設計(実用スキーマ)\n\nこのパターンは、データベースを「いつ何を実行すべきか」「最後に何が起きたか」の真実の源にすることで最も効果を発揮します。スキーマ自体は派手ではありませんが、ロック用フィールドや適切なインデックスといった小さな配慮が負荷増加時に大きな差を生みます。\n\n### 1テーブルにするか2テーブルにするか\n\nよくある2つのアプローチ:\n\n- :各ジョブの最新状態だけ気にする場合(シンプルで結合が少ない)。\n- :ジョブの定義(what)と実行履歴(each run)を分離する場合(履歴が残り、デバッグが楽)。\n\n頻繁に失敗をデバッグする見込みがあるなら履歴を残しましょう。最小構成で始めたいなら1テーブルで始め、あとで履歴を追加するのも良いです。\n\n### 実用的なスキーマ(2テーブル版)\n\n以下はPostgreSQL向けのレイアウトです。GoでPostgreSQLを使う場合、これらの列は構造体に素直にマッピングできます。\n\n \n\nいくつか後で助けになる細かな点:\n\n- は のように短い文字列にしてルーティングしやすくする。\n- は にして、マイグレーションなしで柔軟に進化できるようにする。\n- は「次に実行すべき時刻」。cron(あるいはスケジューラ)が設定し、ワーカーが消費する。\n- と はワーカーが互いに干渉しないようにジョブをクレームするために使う。\n- は管理画面で見せられる短く分かりやすいメッセージを入れる。スタックトレースは別に保管してもよい。\n\n### 欲しいインデックス\n\nインデックスがないとワーカーが不要なスキャンをすることになります。最低限:\n\n- 期限切れの仕事を速く見つけるためのインデックス: \n- 期限切れリースを検出するためのインデックス: \n- 任意:アクティブな作業だけに効く部分インデックス(例: が や の場合)\n\nこれらでテーブルが大きくなっても「次に実行可能なジョブを探す」クエリが速くなります。\n\n## 安全にロックしてジョブをクレームする\n\n目標は単純:複数のワーカーが走っても特定のジョブを1つしか取らないようにすること。複数のワーカーが同じ行を処理すると、二重メールや二重課金、データの混乱が起きます。\n\n安全な方法は、ジョブのクレームを「リース(lease)」として扱うことです。ワーカーは短い間だけジョブをロックします。ワーカーがクラッシュしたらリースが切れて別のワーカーがそのジョブを取れます。これが の役目です。\n\n### クラッシュで永遠にブロックしないようリースを使う\n\nリースがないと、ワーカーがジョブをロックしたままアンロックされない(プロセスが殺された、サーバー再起動、デプロイ失敗など)ことがあります。 を使えば時間経過でジョブが再び利用可能になります。\n\n典型ルールは、 が か のときにジョブをクレームできる、というものです。\n\n### ジョブのクレームは1つの原子更新で行う\n\n重要な点は、ジョブのクレームを単一のステートメント(またはトランザクション)で行うことです。データベースに審判を任せたいからです。\n\n以下はPostgreSQLでよく使われるパターン:期限切れのジョブを1件選んでロックし、ワーカーに返す(この例は単一の テーブルを使っていますが、 でも同様です)。\n\n \n\nなぜこれが機能するか:\n\n- により複数ワーカーが競合してもお互いを待ち合わせずに進める。\n- クレーム時にリースが設定されるので他のワーカーはその間無視する。\n- でそのレースに勝ったワーカーに行が渡される。\n\n### リースはどれくらいにし、どう更新するか?\n\nリースは通常の実行時間より長めに、しかしクラッシュからの回復が速くなる程度に短めに設定します。多くのジョブが10秒で終わるなら2分のリースで十分です。\n\n長時間かかるタスクは、処理中にリースを延長(ハートビート)してください。単純な方法:30秒ごとに を延長する。\n\n- リース長:典型的なジョブ時間の5倍〜20倍\n- ハートビート間隔:リースの1/4〜1/2\n- 更新時は を付ける\n\n最後の条件が重要です。自分が所有していないジョブのリースを他人が伸ばすのを防ぎます。\n\n## 予測可能に動くリトライとバックオフ\n\nリトライは、このパターンが落ち着いて見えるか、騒がしい混乱になるかを決めるポイントです。目標はシンプル:ジョブが失敗したら、後で分かりやすく、測定可能で、止められる形で再試行すること。\n\nまずジョブ状態を明示的かつ有限にします: , , , , 。実務では を「失敗したが再試行する」、 を「失敗して再試行を諦める」と使い分けるのが一般的です。これにより無限ループを防ぎます。\n\n試行回数のカウントも重要です。 (試行回数)と (許容最大試行回数)を保存します。ワーカーがエラーをキャッチしたら:\n\n- を増やす\n- の場合は に設定、さもなければ にする\n- 次の試行用に を計算して設定( の場合のみ)\n\nバックオフは次の を決めるルールです。1つ選んで文書化し、一貫して運用しましょう:\n\n- 固定遅延:常に1分待つ\n- 指数関数的:1m, 2m, 4m, 8m\n- 上限つき指数:指数で増えるが最大30分などで止める\n- ジッターを加える:少しランダム化して全ジョブが同じ秒に再試行しないようにする\n\n依存サービスが落ちて復帰したときにジッターが重要になります。ジッターがないと大量のジョブが一斉に再試行してまた失敗します。\n\n失敗を可視化してデバッグできるだけのエラー情報は必須です。フルなロギングシステムは要らなくても最低限:\n\n- (管理画面で見せられる短いメッセージ)\n- や (グルーピングに便利)\n- と \n- 任意で (サイズを制御できるなら)\n\n実用的なルール:10回の試行で にし、バックオフはジッター付き指数にする、という方針はトランジェントな失敗を再試行させつつ、壊れたジョブが永遠にCPUを浪費するのを防ぎます。\n\n## 冪等性:ジョブが繰り返されても重複を防ぐ\n\n冪等性とは、ジョブを2回実行しても最終的に同じ結果になることを意味します。このパターンでは、同じ行がクラッシュやタイムアウト、再試行で何度も拾われる可能性があるので重要です。例えば「請求書メールを送る」ジョブは2回実行すると問題になります。\n\n実践的な考え方は、各ジョブを (1) 作業を行うフェーズ と (2) 効果を適用するフェーズ に分けることです。効果(メール送信や支払いなど)は一度だけ行われるようにします。\n\n### ビジネスイベントに紐づく冪等キーを使う\n\n冪等キーはワーカーの試行ごとに変わるものではなく、ジョブが表すビジネスイベントから導きます。良いキーは安定して説明しやすいものです: 、 、 など。同じ実世界イベントを指すジョブ試行は同じキーを持つべきです。\n\n例:「2026-01-14 の日次売上レポート」は 、「請求書 812 の課金」は のようなキーにできます。\n\n### データベース制約で「一度だけ」を保証する\n\n最も単純な防護は PostgreSQL に重複を拒否させることです。冪等キーをインデックス可能な場所に保存し、一意制約を付けます。\n\n \n\nこの制約により、同じキーを持つ行が同時に複数存在することを防げます。もし履歴用に複数行を許す設計なら、効果を記録する別テーブル(例: や )に一意制約を置きます。\n\n保護すべき一般的な副作用:\n\n- メール:送信前に テーブルにユニークキーで行を作る、あるいは送信後にプロバイダのメッセージIDを記録する。\n- Webhook: を保存して既にあるならスキップする。\n- 支払い:支払いプロバイダの冪等機能を使いつつ、自分のデータベースでも一意キーを持つ。\n- ファイル書き出し:一時名で書いてからリネームする、または でキー付けした レコードを使う。\n\nPostgresを使ったスタック(例:Go + PostgreSQL)なら、これらの一意チェックは高速でデータに近い場所に置けます。要点は簡単:再試行は普通のこと、重複は制御する、です。\n\n## ステップバイステップ:最小ワーカーとスケジューラを作る\n\n退屈なランタイムを1つ決めてそれに固執してください。cron + database パターンは動くパーツを減らすことが目的なので、PostgreSQLに接続する小さなGo、Node、Pythonのプロセスで十分なことが多いです。\n\n### 5つの小さなステップで作る\n\n1) テーブル(とあとで使う参照テーブル)を作り、 にインデックスを付け、ワーカーが利用可能なジョブを速く見つけられるよう のようなインデックスを追加する。\n\n2) アプリは を か将来時刻にして行を挿入する。payloadは小さく(IDやジョブタイプだけ)して、巨大な BLOB を入れない。\n\n \n\n3) トランザクション内で動かす。期限切れのジョブを数件選び、他のワーカーがスキップするようロックして、同じトランザクションで にする。\n\n \n\n4) クレームした各ジョブについて作業を実行し、成功なら として を更新する。失敗したらエラーメッセージを記録して に戻し(バックオフ付きで を更新)、 を超えたら にする。最終化更新は小さく保ち、プロセスが終了する際にも必ず実行する。\n\n5) 例えば のような単純な式を使い、 を超えたら にする。\n\n### 基本的な可視化を追加\n\n初日からフルダッシュボードは要りませんが、問題に気づくための最低限は必要です。\n\n- ジョブごとに1行ログを残す:クレーム、成功、失敗、再試行、dead。\n- 「dead jobs」や「古い running ジョブ」を見る単純な管理ビューを作る。\n- 増加する失敗数をアラートする。\n\nGo + PostgreSQL のスタックなら、単一のワーカーバイナリと cron で素直にマッピングできます。\n\n## そのまま使える現実的な例\n\n小さなSaaSアプリを想像してください。スケジュール処理は2つだけ:\n\n- 夜間のクリーンアップ(期限切れセッションやテンポラリファイルの削除)。\n- 毎週月曜朝にユーザーごとに送る「活動レポート」メール。\n\nシンプルに保つ:ジョブを保持する1つのPostgreSQLテーブルと、1分ごとに起動されるワーカー(cronでトリガー)を用意します。ワーカーは期限切れのジョブをクレームして実行し、成功か失敗かを記録します。\n\n### 何をいつエンキューするか\n\nジョブは複数の場所からエンキューできます:\n\n- 毎日02:00に1つの ジョブをその日のためにエンキュー。\n- サインアップ時に、そのユーザーの次の月曜に をエンキュー。\n- イベント後(例:「ユーザーがレポートをExportした」)に、特定の期間について即時に をエンキュー。\n\npayload はワーカーに最低限必要な情報だけにしておくと、再試行が楽になります。\n\n \n\n### 冪等性で重複送信を防ぐ方法\n\nワーカーが最悪のタイミングでクラッシュすることはあり得ます:メールを送った直後に落ちて、ジョブを「done」にマークする前に再起動すると、同じジョブをもう一度処理するかもしれません。\n\n二重送信を防ぐには、業務的に自然な重複排除キーを与え、データベースでそれを強制する場所に保存します。週次レポートならキーは が良いでしょう。送信前に「報告書Xを送る」と記録し、そのレコードが既にあれば送信をスキップします。\n\nこれは テーブルに の一意制約を付けるか、ジョブ自体に一意の を持たせるだけで実現できます。\n\n### 障害の例と回復の流れ\n\nメールプロバイダがタイムアウトしたとします。ワーカーは:\n\n- を増やす\n- デバッグ用にエラーメッセージを保存する\n- バックオフで次回をスケジュールする(例:+1分、+5分、+30分、+2時間)\n\nもし規定回数(例:10回)を超えても失敗し続けるなら、そのジョブは にして再試行を止めます。ジョブは一度成功するか、明確なスケジュールで再試行され、冪等性があれば再試行は安全です。\n\n## よくある間違いと落とし穴\n\ncron + database パターンは単純ですが、小さなミスが重複、スタック、想定外の負荷を招きます。最初のクラッシュ、デプロイ、トラフィックスパイクで問題が顕在化します。\n\n### 重複やスタックを招くミス\n\n現場で起きるインシデントの多くは次の罠から来ます:\n\n- 同じジョブを複数のcronエントリから起動しているのにリースがない。複数サーバーが同じ分に起動すると、クレーム手順が原子的にロックを設定しない限り両方が実行してしまう。\n- を使わない。ワーカーがクレームした後にクラッシュするとその行が永遠に「進行中」になりうる。リースのタイムスタンプがあれば他のワーカーが安全に回収できる。\n- 失敗時に即時再試行する。APIが落ちていると即時再試行がスパイクを生み、レート制限に引っかかって失敗し続ける。必ず次回を未来にスケジュールする。\n- 「少なくとも一度(at least once)」を「ちょうど一度(exactly once)」と扱う。タイムアウトやワーカー再起動でジョブは2回実行される可能性がある。2回実行すると問題になる処理には冪等性を付ける。\n- ジョブ行に大きなペイロードを保存する。巨大なJSONはテーブルを膨らませ、インデックスを遅くし、ロックの負荷を増やす。 や のような参照を保存し、実行時に必要なデータを取得する。\n\n例:週次請求メールを送るとき、送信後にジョブを にマークする前にタイムアウトすると、同じジョブが再実行され二重送信になる。これはこのパターンでは普通に起こり得るため、メール送信の一意チェック(例えば invoice_id に基づく レコード)を入れる必要がある。\n\n### あまり明白でない落とし穴\n\nスケジューリングと実行を同じ長時間トランザクションで混ぜないでください。ネットワーク呼び出し中にトランザクションを開いたままにすると、ロックが長く保持され他のワーカーがブロックされます。\n\nマシン間の時計差にも注意。 や の基準時刻はアプリサーバの時計ではなくデータベース時刻(PostgreSQLの )を使う。\n\n最大実行時間を明確に設定する。ジョブが30分かかるならリースはそれより長くし、必要なら更新する。さもないと別のワーカーが途中でそのジョブを拾ってしまう。\n\nジョブテーブルを健全に保つ。完了したジョブが永遠に溜まるとクエリが遅くなりロック競合が増える。古い行をアーカイブまたは削除する単純な保持ルールを決めておく。\n\n## クイックチェックリストと次の一手\n\n### クイックチェックリスト\n\n本番投入前に次を確認してください。ここを怠るとスタックや重複、DBに負荷をかけることになります。\n\n- ジョブテーブルに必須項目がある: , , , , (および 等で何が起きたか見えるように)。\n- 各ジョブは2回実行されても安全である。確信が持てないなら冪等キーや副作用に対する一意ルールを追加する(例:invoice_idごとに1つ)。\n- 障害を観察して対処する場所がある:失敗ジョブを見て再実行したり、ジョブを にしたりできる。\n- リース(ロック)タイムアウトが作業に対して妥当である。通常の実行より長めだが、クラッシュが進捗を阻害しない程度に短い。\n- 再試行バックオフは予測可能で、繰り返しの失敗を抑制し を超えたら停止する。\n\nこれらが満たされていれば、cron + database パターンは実用的なワークロードに十分耐えられます。\n\n### 次の一手\n\nチェックリストが整ったら、日常運用に注力します。\n\n- 「今すぐ再試行」( にしてロックをクリア)と「キャンセル」(ターミナルステータスに移す)の2つの管理操作を追加する。インシデント時に役立ちます。\n- ワーカーがジョブごとに1行ログを出すようにする:ジョブタイプ、ジョブID、試行回数、結果。失敗数の増加でアラートを上げる。\n- 実際のスパイクを想定した負荷テストを行う:同じ分に大量のジョブがスケジュールされる場合を再現する。クレームが遅くなるなら正しいインデックス(たいていは )を追加する。\n\nこのようなセットアップを素早く作りたいなら、Koder.ai (koder.ai) はスキーマからデプロイ済みの Go + PostgreSQL アプリまで、ロッキング、リトライ、冪等性のルールに注力しながら手助けできます。\n\n将来的にこの構成を使い続けられなくなっても、ジョブライフサイクルについての知見は残り、同じ考え方はフルなキューシステムにも自然に移行できます。\n 目次
問題:追加インフラなしでスケジュール処理を行う\n\nほとんどのアプリは、後で実行する仕事や定期的に動かす処理が必要です:フォローアップメールの送信、夜間の請求チェック、古いレコードのクリーンアップ、レポートの再生成、キャッシュの更新など。\n\n初期段階では、背景処理は「キューを導入するのが正解」と感じて、フルなキューシステムを入れたくなります。しかしキューは別のサービスを運用・監視・デプロイ・デバッグする必要があり、特に小さなチームや創業者1人だと余計な負担になります。\n\nそこで本当に問うべきは:追加のインフラを立てずに、どうやって信頼性のあるスケジュール処理を実行するか、です。\n\nよくある最初の試みは単純です:cronでエンドポイントを叩き、そのエンドポイントが仕事を実行する。うまくいくこともありますが、うまくいかなくなると厄介です。サーバーが複数台になったり、デプロイのタイミングが悪かったり、ジョブが予想より長くかかると、混乱した障害が出始めます。\n\nスケジュールされた仕事はたいてい、次のような分かりやすい失敗で壊れます:\n\n- 二重実行:2台のサーバーが同じタスクを両方実行してしまい、請求書が2重に生成されたりメールが重複送信されたりする。\n- 実行漏れ:デプロイ中にcron呼び出しが失敗して、ユーザーからの不満で初めて気づく。\n- 無音の失敗:ジョブが一度エラーを起こして以降、再試行の仕組みがないため二度と実行されない。\n- 部分的な作業完了:ジョブが途中でクラッシュしてデータが中途半端な状態に残る。\n- 監査証跡がない:最後にいつ実行されたか、昨夜何が起きたか答えられない。\n\ncron + database パターンはその中間の道です。cronで「起床」だけを行い、ジョブの意図と状態はデータベースに保存します。データベースが調整、再試行、実行履歴の記録を担います。\n\nデータベースが既に1つ(多くは PostgreSQL)あり、ジョブの種類が少なく、最低限の運用で予測可能な挙動が欲しい場合に適しています。React + Go + PostgreSQL のようなモダンなスタックで素早く構築したアプリにも自然に合います。\n\n逆に、非常に高いスループットが必要な場合、進行状況をストリームする長時間ジョブ、数多くのジョブタイプ間で厳密な順序付けが必要な場合、あるいは大量のファンアウト(毎分何千というサブタスク)が必要な場合は、専用のキューとワーカーの方が向きます。\n\n## コアアイデア(平易に)\n\ncron + database パターンは、フルなキューシステムを立てずにスケジュール処理を実行する方法です。cron(または任意のスケジューラ)は使いますが、cronが何を実行するかは決めません。cronは頻繁に(分毎など)ワーカーを起こすだけで、データベースがどの仕事が期限切れかを判断し、1つだけを確実に取り出します。\n\nホワイトボードに張られた共有のチェックリストのように考えてください。cronは毎分部屋に入って「今やるべきことある?」と聞く人で、データベースは何が期限で、誰が取ったか、何が終わったかを示すホワイトボードです。\n\n主要な要素は単純です:\n\n- 単一のスケジューラトリガーが頻繁に実行される。\n- ジョブを表すテーブルが「何をいつやるか(due time)」、ステータス、試行回数を保持する。\n- 1つ以上のワーカーがテーブルをポーリングし、ジョブをクレームして作業を実行する。\n- クレームはデータベースのロック(リース)で行い、複数のワーカーが同じ行を取れないようにする。\n- データベースが何が実行されたか、失敗したか、再試行すべきかの真実の源になる。\n\n例:毎朝請求リマインダーを送る、キャッシュを10分ごとに更新する、古いセッションを夜間にクリーンアップする。個別のcronコマンドを3つ作る代わりに、ジョブを1か所に保存します。cronは同じワーカープロセスを起動し、ワーカーがPostgresに「今実行すべきものは?」と尋ね、Postgresが1件ずつ安全にジョブをクレームさせます。\n\nこの方式は段階的にスケールします。最初は1台のサーバー上で1つのワーカーから始められ、後で複数サーバーに5つのワーカーを走らせても契約(テーブル)が同じままです。\n\nマインドセットの切り替えは簡単です:cronは目覚ましでしかなく、データベースが交通整理をして実行の可否を決め、何が起きたかの履歴を残す役目です。\n\n## ジョブテーブルの設計(実用スキーマ)\n\nこのパターンは、データベースを「いつ何を実行すべきか」「最後に何が起きたか」の真実の源にすることで最も効果を発揮します。スキーマ自体は派手ではありませんが、ロック用フィールドや適切なインデックスといった小さな配慮が負荷増加時に大きな差を生みます。\n\n### 1テーブルにするか2テーブルにするか\n\nよくある2つのアプローチ:\n\n- **1つの統合テーブル**:各ジョブの最新状態だけ気にする場合(シンプルで結合が少ない)。\n- **2つのテーブル**:ジョブの定義(what)と実行履歴(each run)を分離する場合(履歴が残り、デバッグが楽)。\n\n頻繁に失敗をデバッグする見込みがあるなら履歴を残しましょう。最小構成で始めたいなら1テーブルで始め、あとで履歴を追加するのも良いです。\n\n### 実用的なスキーマ(2テーブル版)\n\n以下はPostgreSQL向けのレイアウトです。GoでPostgreSQLを使う場合、これらの列は構造体に素直にマッピングできます。\n\n```sql\n-- What should exist (the definition)\ncreate table job_definitions (\n id bigserial primary key,\n job_type text not null,\n payload jsonb not null default '{}'::jsonb,\n schedule text, -- optional: cron-like text if you store it\n max_attempts int not null default 5,\n created_at timestamptz not null default now(),\n updated_at timestamptz not null default now()\n);\n\n-- What should run (each run / attempt group)\ncreate table job_runs (\n id bigserial primary key,\n definition_id bigint references job_definitions(id),\n job_type text not null,\n payload jsonb not null default '{}'::jsonb,\n run_at timestamptz not null,\n status text not null, -- queued | running | succeeded | failed | dead\n attempts int not null default 0,\n max_attempts int not null default 5,\n\n locked_by text,\n locked_until timestamptz,\n\n last_error text,\n created_at timestamptz not null default now(),\n updated_at timestamptz not null default now()\n);\n```\n\nいくつか後で助けになる細かな点:\n\n- **job_type** は `send_invoice_emails` のように短い文字列にしてルーティングしやすくする。\n- **payload** は `jsonb` にして、マイグレーションなしで柔軟に進化できるようにする。\n- **run_at** は「次に実行すべき時刻」。cron(あるいはスケジューラ)が設定し、ワーカーが消費する。\n- **locked_by** と **locked_until** はワーカーが互いに干渉しないようにジョブをクレームするために使う。\n- **last_error** は管理画面で見せられる短く分かりやすいメッセージを入れる。スタックトレースは別に保管してもよい。\n\n### 欲しいインデックス\n\nインデックスがないとワーカーが不要なスキャンをすることになります。最低限:\n\n- 期限切れの仕事を速く見つけるためのインデックス:`(status, run_at)`\n- 期限切れリースを検出するためのインデックス:`(locked_until)`\n- 任意:アクティブな作業だけに効く部分インデックス(例:`status` が `queued` や `failed` の場合)\n\nこれらでテーブルが大きくなっても「次に実行可能なジョブを探す」クエリが速くなります。\n\n## 安全にロックしてジョブをクレームする\n\n目標は単純:複数のワーカーが走っても特定のジョブを1つしか取らないようにすること。複数のワーカーが同じ行を処理すると、二重メールや二重課金、データの混乱が起きます。\n\n安全な方法は、ジョブのクレームを「リース(lease)」として扱うことです。ワーカーは短い間だけジョブをロックします。ワーカーがクラッシュしたらリースが切れて別のワーカーがそのジョブを取れます。これが `locked_until` の役目です。\n\n### クラッシュで永遠にブロックしないようリースを使う\n\nリースがないと、ワーカーがジョブをロックしたままアンロックされない(プロセスが殺された、サーバー再起動、デプロイ失敗など)ことがあります。`locked_until` を使えば時間経過でジョブが再び利用可能になります。\n\n典型ルールは、`locked_until` が `NULL` か `locked_until \u003c= now()` のときにジョブをクレームできる、というものです。\n\n### ジョブのクレームは1つの原子更新で行う\n\n重要な点は、ジョブのクレームを単一のステートメント(またはトランザクション)で行うことです。データベースに審判を任せたいからです。\n\n以下はPostgreSQLでよく使われるパターン:期限切れのジョブを1件選んでロックし、ワーカーに返す(この例は単一の `jobs` テーブルを使っていますが、`job_runs` でも同様です)。\n\n```sql\nWITH next_job AS (\n SELECT id\n FROM jobs\n WHERE status = 'queued'\n AND run_at \u003c= now()\n AND (locked_until IS NULL OR locked_until \u003c= now())\n ORDER BY run_at ASC\n LIMIT 1\n FOR UPDATE SKIP LOCKED\n)\nUPDATE jobs j\nSET status = 'running',\n locked_until = now() + interval '2 minutes',\n locked_by = $1,\n attempts = attempts + 1,\n updated_at = now()\nFROM next_job\nWHERE j.id = next_job.id\nRETURNING j.*;\n```\n\nなぜこれが機能するか:\n\n- `FOR UPDATE SKIP LOCKED` により複数ワーカーが競合してもお互いを待ち合わせずに進める。\n- クレーム時にリースが設定されるので他のワーカーはその間無視する。\n- `RETURNING` でそのレースに勝ったワーカーに行が渡される。\n\n### リースはどれくらいにし、どう更新するか?\n\nリースは通常の実行時間より長めに、しかしクラッシュからの回復が速くなる程度に短めに設定します。多くのジョブが10秒で終わるなら2分のリースで十分です。\n\n長時間かかるタスクは、処理中にリースを延長(ハートビート)してください。単純な方法:30秒ごとに `locked_until` を延長する。\n\n- リース長:典型的なジョブ時間の5倍〜20倍\n- ハートビート間隔:リースの1/4〜1/2\n- 更新時は `WHERE id = $job_id AND locked_by = $worker_id` を付ける\n\n最後の条件が重要です。自分が所有していないジョブのリースを他人が伸ばすのを防ぎます。\n\n## 予測可能に動くリトライとバックオフ\n\nリトライは、このパターンが落ち着いて見えるか、騒がしい混乱になるかを決めるポイントです。目標はシンプル:ジョブが失敗したら、後で分かりやすく、測定可能で、止められる形で再試行すること。\n\nまずジョブ状態を明示的かつ有限にします:`queued`, `running`, `succeeded`, `failed`, `dead`。実務では `failed` を「失敗したが再試行する」、`dead` を「失敗して再試行を諦める」と使い分けるのが一般的です。これにより無限ループを防ぎます。\n\n試行回数のカウントも重要です。`attempts`(試行回数)と `max_attempts`(許容最大試行回数)を保存します。ワーカーがエラーをキャッチしたら:\n\n- `attempts` を増やす\n- `attempts \u003c max_attempts` の場合は `failed` に設定、さもなければ `dead` にする\n- 次の試行用に `run_at` を計算して設定(`failed` の場合のみ)\n\nバックオフは次の `run_at` を決めるルールです。1つ選んで文書化し、一貫して運用しましょう:\n\n- 固定遅延:常に1分待つ\n- 指数関数的:1m, 2m, 4m, 8m\n- 上限つき指数:指数で増えるが最大30分などで止める\n- ジッターを加える:少しランダム化して全ジョブが同じ秒に再試行しないようにする\n\n依存サービスが落ちて復帰したときにジッターが重要になります。ジッターがないと大量のジョブが一斉に再試行してまた失敗します。\n\n失敗を可視化してデバッグできるだけのエラー情報は必須です。フルなロギングシステムは要らなくても最低限:\n\n- `last_error`(管理画面で見せられる短いメッセージ)\n- `error_code` や `error_type`(グルーピングに便利)\n- `failed_at` と `next_run_at`\n- 任意で `last_stack`(サイズを制御できるなら)\n\n実用的なルール:10回の試行で `dead` にし、バックオフはジッター付き指数にする、という方針はトランジェントな失敗を再試行させつつ、壊れたジョブが永遠にCPUを浪費するのを防ぎます。\n\n## 冪等性:ジョブが繰り返されても重複を防ぐ\n\n冪等性とは、ジョブを2回実行しても最終的に同じ結果になることを意味します。このパターンでは、同じ行がクラッシュやタイムアウト、再試行で何度も拾われる可能性があるので重要です。例えば「請求書メールを送る」ジョブは2回実行すると問題になります。\n\n実践的な考え方は、各ジョブを (1) 作業を行うフェーズ と (2) 効果を適用するフェーズ に分けることです。効果(メール送信や支払いなど)は一度だけ行われるようにします。\n\n### ビジネスイベントに紐づく冪等キーを使う\n\n冪等キーはワーカーの試行ごとに変わるものではなく、ジョブが表すビジネスイベントから導きます。良いキーは安定して説明しやすいものです:`invoice_id`、`user_id + day`、`report_name + report_date` など。同じ実世界イベントを指すジョブ試行は同じキーを持つべきです。\n\n例:「2026-01-14 の日次売上レポート」は `sales_report:2026-01-14`、「請求書 812 の課金」は `invoice_charge:812` のようなキーにできます。\n\n### データベース制約で「一度だけ」を保証する\n\n最も単純な防護は PostgreSQL に重複を拒否させることです。冪等キーをインデックス可能な場所に保存し、一意制約を付けます。\n\n```sql\n-- Example: ensure one logical job/effect per business key\nALTER TABLE jobs\nADD COLUMN idempotency_key text;\n\nCREATE UNIQUE INDEX jobs_idempotency_key_uniq\nON jobs (idempotency_key)\nWHERE idempotency_key IS NOT NULL;\n```\n\nこの制約により、同じキーを持つ行が同時に複数存在することを防げます。もし履歴用に複数行を許す設計なら、効果を記録する別テーブル(例:`sent_emails(idempotency_key)` や `payments(idempotency_key)`)に一意制約を置きます。\n\n保護すべき一般的な副作用:\n\n- メール:送信前に `sent_emails` テーブルにユニークキーで行を作る、あるいは送信後にプロバイダのメッセージIDを記録する。\n- Webhook:`delivered_webhooks(event_id)` を保存して既にあるならスキップする。\n- 支払い:支払いプロバイダの冪等機能を使いつつ、自分のデータベースでも一意キーを持つ。\n- ファイル書き出し:一時名で書いてからリネームする、または `(type, date)` でキー付けした `file_generated` レコードを使う。\n\nPostgresを使ったスタック(例:Go + PostgreSQL)なら、これらの一意チェックは高速でデータに近い場所に置けます。要点は簡単:再試行は普通のこと、重複は制御する、です。\n\n## ステップバイステップ:最小ワーカーとスケジューラを作る\n\n退屈なランタイムを1つ決めてそれに固執してください。cron + database パターンは動くパーツを減らすことが目的なので、PostgreSQLに接続する小さなGo、Node、Pythonのプロセスで十分なことが多いです。\n\n### 5つの小さなステップで作る\n\n1) **テーブルとインデックスを作る。** `jobs` テーブル(とあとで使う参照テーブル)を作り、`run_at` にインデックスを付け、ワーカーが利用可能なジョブを速く見つけられるよう `(status, run_at)` のようなインデックスを追加する。\n\n2) **小さなenqueue関数を書く。** アプリは `run_at` を `now` か将来時刻にして行を挿入する。payloadは小さく(IDやジョブタイプだけ)して、巨大な BLOB を入れない。\n\n```sql\nINSERT INTO jobs (type, payload, status, run_at, attempts, max_attempts)\nVALUES ($1, $2::jsonb, 'queued', $3, 0, 10);\n```\n\n3) **クレームループを実装する。** トランザクション内で動かす。期限切れのジョブを数件選び、他のワーカーがスキップするようロックして、同じトランザクションで `running` にする。\n\n```sql\nWITH picked AS (\n SELECT id\n FROM jobs\n WHERE status = 'queued' AND run_at \u003c= now()\n ORDER BY run_at\n FOR UPDATE SKIP LOCKED\n LIMIT 10\n)\nUPDATE jobs\nSET status = 'running', started_at = now()\nWHERE id IN (SELECT id FROM picked)\nRETURNING *;\n```\n\n4) **処理と最終化。** クレームした各ジョブについて作業を実行し、成功なら `done` として `finished_at` を更新する。失敗したらエラーメッセージを記録して `queued` に戻し(バックオフ付きで `run_at` を更新)、`max_attempts` を超えたら `dead` にする。最終化更新は小さく保ち、プロセスが終了する際にも必ず実行する。\n\n5) **説明できるリトライ規則を追加。** 例えば `run_at = now() + (attempts^2) * interval '10 seconds'` のような単純な式を使い、`max_attempts` を超えたら `status = 'dead'` にする。\n\n### 基本的な可視化を追加\n\n初日からフルダッシュボードは要りませんが、問題に気づくための最低限は必要です。\n\n- ジョブごとに1行ログを残す:クレーム、成功、失敗、再試行、dead。\n- 「dead jobs」や「古い running ジョブ」を見る単純な管理ビューを作る。\n- 増加する失敗数をアラートする。\n\nGo + PostgreSQL のスタックなら、単一のワーカーバイナリと cron で素直にマッピングできます。\n\n## そのまま使える現実的な例\n\n小さなSaaSアプリを想像してください。スケジュール処理は2つだけ:\n\n- 夜間のクリーンアップ(期限切れセッションやテンポラリファイルの削除)。\n- 毎週月曜朝にユーザーごとに送る「活動レポート」メール。\n\nシンプルに保つ:ジョブを保持する1つのPostgreSQLテーブルと、1分ごとに起動されるワーカー(cronでトリガー)を用意します。ワーカーは期限切れのジョブをクレームして実行し、成功か失敗かを記録します。\n\n### 何をいつエンキューするか\n\nジョブは複数の場所からエンキューできます:\n\n- 毎日02:00に1つの `cleanup_nightly` ジョブをその日のためにエンキュー。\n- サインアップ時に、そのユーザーの次の月曜に `send_weekly_report` をエンキュー。\n- イベント後(例:「ユーザーがレポートをExportした」)に、特定の期間について即時に `send_weekly_report` をエンキュー。\n\npayload はワーカーに最低限必要な情報だけにしておくと、再試行が楽になります。\n\n```json\n{\n \"type\": \"send_weekly_report\",\n \"payload\": {\n \"user_id\": 12345,\n \"date_range\": {\n \"from\": \"2026-01-01\",\n \"to\": \"2026-01-07\"\n }\n }\n}\n```\n\n### 冪等性で重複送信を防ぐ方法\n\nワーカーが最悪のタイミングでクラッシュすることはあり得ます:メールを送った直後に落ちて、ジョブを「done」にマークする前に再起動すると、同じジョブをもう一度処理するかもしれません。\n\n二重送信を防ぐには、業務的に自然な重複排除キーを与え、データベースでそれを強制する場所に保存します。週次レポートならキーは `(user_id, week_start_date)` が良いでしょう。送信前に「報告書Xを送る」と記録し、そのレコードが既にあれば送信をスキップします。\n\nこれは `sent_reports` テーブルに `(user_id, week_start_date)` の一意制約を付けるか、ジョブ自体に一意の `idempotency_key` を持たせるだけで実現できます。\n\n### 障害の例と回復の流れ\n\nメールプロバイダがタイムアウトしたとします。ワーカーは:\n\n- `attempts` を増やす\n- デバッグ用にエラーメッセージを保存する\n- バックオフで次回をスケジュールする(例:+1分、+5分、+30分、+2時間)\n\nもし規定回数(例:10回)を超えても失敗し続けるなら、そのジョブは `dead` にして再試行を止めます。ジョブは一度成功するか、明確なスケジュールで再試行され、冪等性があれば再試行は安全です。\n\n## よくある間違いと落とし穴\n\ncron + database パターンは単純ですが、小さなミスが重複、スタック、想定外の負荷を招きます。最初のクラッシュ、デプロイ、トラフィックスパイクで問題が顕在化します。\n\n### 重複やスタックを招くミス\n\n現場で起きるインシデントの多くは次の罠から来ます:\n\n- 同じジョブを複数のcronエントリから起動しているのにリースがない。複数サーバーが同じ分に起動すると、クレーム手順が原子的にロックを設定しない限り両方が実行してしまう。\n- `locked_until` を使わない。ワーカーがクレームした後にクラッシュするとその行が永遠に「進行中」になりうる。リースのタイムスタンプがあれば他のワーカーが安全に回収できる。\n- 失敗時に即時再試行する。APIが落ちていると即時再試行がスパイクを生み、レート制限に引っかかって失敗し続ける。必ず次回を未来にスケジュールする。\n- 「少なくとも一度(at least once)」を「ちょうど一度(exactly once)」と扱う。タイムアウトやワーカー再起動でジョブは2回実行される可能性がある。2回実行すると問題になる処理には冪等性を付ける。\n- ジョブ行に大きなペイロードを保存する。巨大なJSONはテーブルを膨らませ、インデックスを遅くし、ロックの負荷を増やす。`user_id` や `invoice_id` のような参照を保存し、実行時に必要なデータを取得する。\n\n例:週次請求メールを送るとき、送信後にジョブを `done` にマークする前にタイムアウトすると、同じジョブが再実行され二重送信になる。これはこのパターンでは普通に起こり得るため、メール送信の一意チェック(例えば invoice_id に基づく `email_sent` レコード)を入れる必要がある。\n\n### あまり明白でない落とし穴\n\nスケジューリングと実行を同じ長時間トランザクションで混ぜないでください。ネットワーク呼び出し中にトランザクションを開いたままにすると、ロックが長く保持され他のワーカーがブロックされます。\n\nマシン間の時計差にも注意。`run_at` や `locked_until` の基準時刻はアプリサーバの時計ではなくデータベース時刻(PostgreSQLの `NOW()`)を使う。\n\n最大実行時間を明確に設定する。ジョブが30分かかるならリースはそれより長くし、必要なら更新する。さもないと別のワーカーが途中でそのジョブを拾ってしまう。\n\nジョブテーブルを健全に保つ。完了したジョブが永遠に溜まるとクエリが遅くなりロック競合が増える。古い行をアーカイブまたは削除する単純な保持ルールを決めておく。\n\n## クイックチェックリストと次の一手\n\n### クイックチェックリスト\n\n本番投入前に次を確認してください。ここを怠るとスタックや重複、DBに負荷をかけることになります。\n\n- ジョブテーブルに必須項目がある:`run_at`, `status`, `attempts`, `locked_until`, `max_attempts`(および `last_error` 等で何が起きたか見えるように)。\n- 各ジョブは2回実行されても安全である。確信が持てないなら冪等キーや副作用に対する一意ルールを追加する(例:invoice_idごとに1つ)。\n- 障害を観察して対処する場所がある:失敗ジョブを見て再実行したり、ジョブを `dead` にしたりできる。\n- リース(ロック)タイムアウトが作業に対して妥当である。通常の実行より長めだが、クラッシュが進捗を阻害しない程度に短い。\n- 再試行バックオフは予測可能で、繰り返しの失敗を抑制し `max_attempts` を超えたら停止する。\n\nこれらが満たされていれば、cron + database パターンは実用的なワークロードに十分耐えられます。\n\n### 次の一手\n\nチェックリストが整ったら、日常運用に注力します。\n\n- 「今すぐ再試行」(`run_at = now()` にしてロックをクリア)と「キャンセル」(ターミナルステータスに移す)の2つの管理操作を追加する。インシデント時に役立ちます。\n- ワーカーがジョブごとに1行ログを出すようにする:ジョブタイプ、ジョブID、試行回数、結果。失敗数の増加でアラートを上げる。\n- 実際のスパイクを想定した負荷テストを行う:同じ分に大量のジョブがスケジュールされる場合を再現する。クレームが遅くなるなら正しいインデックス(たいていは `status, run_at`)を追加する。\n\nこのようなセットアップを素早く作りたいなら、Koder.ai (koder.ai) はスキーマからデプロイ済みの Go + PostgreSQL アプリまで、ロッキング、リトライ、冪等性のルールに注力しながら手助けできます。\n\n将来的にこの構成を使い続けられなくなっても、ジョブライフサイクルについての知見は残り、同じ考え方はフルなキューシステムにも自然に移行できます。\n Cron + Database パターン:キューを使わないバックグラウンドジョブ | Koder.ai 1つの統合テーブル
2つのテーブル
sql\n-- What should exist (the definition)\ncreate table job_definitions (\n id bigserial primary key,\n job_type text not null,\n payload jsonb not null default '{}'::jsonb,\n schedule text, -- optional: cron-like text if you store it\n max_attempts int not null default 5,\n created_at timestamptz not null default now(),\n updated_at timestamptz not null default now()\n);\n\n-- What should run (each run / attempt group)\ncreate table job_runs (\n id bigserial primary key,\n definition_id bigint references job_definitions(id),\n job_type text not null,\n payload jsonb not null default '{}'::jsonb,\n run_at timestamptz not null,\n status text not null, -- queued | running | succeeded | failed | dead\n attempts int not null default 0,\n max_attempts int not null default 5,\n\n locked_by text,\n locked_until timestamptz,\n\n last_error text,\n created_at timestamptz not null default now(),\n updated_at timestamptz not null default now()\n);\n
job_type
send_invoice_emails
payload
jsonb
run_at
locked_by
locked_until
last_error
(status, run_at)
(locked_until)
status
queued
failed
locked_until
locked_until
locked_until
NULL
locked_until \u003c= now()
jobs
job_runs
sql\nWITH next_job AS (\n SELECT id\n FROM jobs\n WHERE status = 'queued'\n AND run_at \u003c= now()\n AND (locked_until IS NULL OR locked_until \u003c= now())\n ORDER BY run_at ASC\n LIMIT 1\n FOR UPDATE SKIP LOCKED\n)\nUPDATE jobs j\nSET status = 'running',\n locked_until = now() + interval '2 minutes',\n locked_by = $1,\n attempts = attempts + 1,\n updated_at = now()\nFROM next_job\nWHERE j.id = next_job.id\nRETURNING j.*;\n
FOR UPDATE SKIP LOCKED
RETURNING
locked_until
WHERE id = $job_id AND locked_by = $worker_id
queued
running
succeeded
failed
dead
failed
dead
attempts
max_attempts
attempts
attempts \u003c max_attempts
failed
dead
run_at
failed
run_at
last_error
error_code
error_type
failed_at
next_run_at
last_stack
dead
invoice_id
user_id + day
report_name + report_date
sales_report:2026-01-14
invoice_charge:812
sql\n-- Example: ensure one logical job/effect per business key\nALTER TABLE jobs\nADD COLUMN idempotency_key text;\n\nCREATE UNIQUE INDEX jobs_idempotency_key_uniq\nON jobs (idempotency_key)\nWHERE idempotency_key IS NOT NULL;\n
sent_emails(idempotency_key)
payments(idempotency_key)
sent_emails
delivered_webhooks(event_id)
(type, date)
file_generated
テーブルとインデックスを作る。
jobs
run_at
(status, run_at)
小さなenqueue関数を書く。
run_at
now
sql\nINSERT INTO jobs (type, payload, status, run_at, attempts, max_attempts)\nVALUES ($1, $2::jsonb, 'queued', $3, 0, 10);\n
クレームループを実装する。
running
sql\nWITH picked AS (\n SELECT id\n FROM jobs\n WHERE status = 'queued' AND run_at \u003c= now()\n ORDER BY run_at\n FOR UPDATE SKIP LOCKED\n LIMIT 10\n)\nUPDATE jobs\nSET status = 'running', started_at = now()\nWHERE id IN (SELECT id FROM picked)\nRETURNING *;\n
処理と最終化。
done
finished_at
queued
run_at
max_attempts
dead
説明できるリトライ規則を追加。
run_at = now() + (attempts^2) * interval '10 seconds'
max_attempts
status = 'dead'
cleanup_nightly
send_weekly_report
send_weekly_report
json\n{\n \"type\": \"send_weekly_report\",\n \"payload\": {\n \"user_id\": 12345,\n \"date_range\": {\n \"from\": \"2026-01-01\",\n \"to\": \"2026-01-07\"\n }\n }\n}\n
(user_id, week_start_date)
sent_reports
(user_id, week_start_date)
idempotency_key
attempts
dead
locked_until
user_id
invoice_id
done
email_sent
run_at
locked_until
NOW()
run_at
status
attempts
locked_until
max_attempts
last_error
dead
max_attempts
run_at = now()
status, run_at