定期バッチ処理の『重複実行防止』機構が分散環境で機能しない理由──ロック戦略と実装コストの選択

単一サーバー時代の「ロック」が通用しなくなる瞬間

複数のサーバーやコンテナで同じバッチ処理が走るようになると、必ず起きる問題があります。それが「重複実行」です。

たとえば、毎日夜間に売上集計を走らせるバッチがあるとします。単一サーバーの時代なら、OSのcronで「この時刻に1回だけ実行」と指定すれば済みました。ファイルロックやプロセスロックで「既に走ってたら終わるまで待つ」という単純な仕組みも機能していました。

ところが、ロードバランサーの後ろに複数のアプリケーションサーバーがある環境、あるいはKubernetesで複数のPodが起動している環境では、この「単純なロック」がまったく機能しません。サーバーAがロックファイルを作成した直後、サーバーBは別のファイルシステムを見ているため、ロックの存在を知らずに同じ処理を開始してしまうのです。

結果として、同じデータが二重に計上される、在庫が重複して減少する、メール通知が2通送られるといった問題が発生します。こうした不具合は運用を始めた数ヶ月後、スケールアップのタイミングで初めて露見することが多く、修正時の影響範囲の判断が難しくなります。

分散環境での重複防止──3つの現実的な選択肢

重複実行を防ぐには、複数のサーバーから「見える」共有リソースでロックを管理する必要があります。現場でよく選ばれるのは以下の3つです。

RedisやMemcachedを使った分散ロック

メモリ型キャッシュサーバーに「このバッチは今走行中」というフラグを立てる方法です。

SET lock:batch_name "running" EX 3600 NX

このコマンドは「lockというキーが存在しなければ、’running’という値を1時間の有効期限付きで設定する」という意味です。複数サーバーから同時にこのコマンドを実行しても、最初の1つだけが成功し、残りは失敗します。失敗したサーバーは処理をスキップします。

メリット:

  • 実装が簡潔で、既にRedisを使っているなら追加コストが少ない
  • レスポンスが高速で、ロック競合時のオーバーヘッドが小さい

デメリット:

  • Redisそのものが単一障害点になる
  • 有効期限の設定が難しい(短すぎると重複実行、長すぎるとハング状態が続く)
  • ネットワーク遅延やRedisの再起動時に不整合が起こりやすい

データベースのロックテーブル

専用のロック管理テーブルを作成し、バッチ実行前にレコードをINSERTまたはUPDATEする方法です。

CREATE TABLE batch_locks (
  batch_name VARCHAR(100) PRIMARY KEY,
  locked_at TIMESTAMP,
  locked_by VARCHAR(255)
);

-- 実行時
INSERT INTO batch_locks (batch_name, locked_at, locked_by) 
VALUES ('daily_sales_aggregation', NOW(), 'server-001')
ON CONFLICT (batch_name) DO NOTHING;

INSERT後に影響行数をチェックし、0行なら別のサーバーが既にロックを取得していると判断します。

メリット:

  • データベースの永続性があるため、サーバー再起動時も状態が保持される
  • トランザクション機能で強い一貫性が得られる
  • 監査ログとして「誰が、いつ、どのバッチを実行したか」を記録できる

デメリット:

  • データベースへのアクセスが増え、バッチ実行前に必ず待機が発生する
  • テーブルロックの競合で、複数バッチが同時実行される環境ではボトルネックになる
  • 実行終了時にレコードを削除する処理が必要で、異常終了時の後始末が煩雑

分散合意アルゴリズム(etcd、Consul)

etcdやConsulといった分散キーバリューストアを使い、より堅牢なロック機構を実装する方法です。これらはRaftアルゴリズムで複数ノード間の状態同期を保証します。

メリット:

  • 単一障害点がなく、複数ノードでロック状態が冗長化される
  • ネットワーク分断時の振る舞いが予測可能

デメリット:

  • 導入と運用のコストが高い
  • 小規模チームでは習得コストが大きい
  • ロック機構のためだけに新しいミドルウェアを導入するのは過剰設計になりやすい

現場での判断──「完璧さ」より「運用可能性」

実務では、この3つの選択肢をシステムの規模と既存構成で判断します。

既にRedisを導入していて、バッチの種類が少なく、実行時間が短い場合は、Redisでのロックが最も現実的です。設定が単純で、障害時の影響範囲が限定的だからです。

一方、金融系や在庫管理など「重複実行が絶対に許されない」領域では、データベースロックを選ぶことが多いです。少し遅くても、監査証跡が残り、トランザクション保証がある方が、運用チームの安心感が大きいのです。

etcdやConsulは、複数の重要なバッチが常時走行し、スケーラビリティが本当に必要な大規模システムに向きます。中小規模の開発組織では、導入後の保守負担を考えるとお勧めしづらいです。

実装時の落とし穴

どの方式を選んでも、以下の3点は必ず詰めておく必要があります。

ロック取得失敗時の動作
ロックが取得できなかった場合、単に「処理をスキップ」するだけでは不十分です。ログに記録し、監視アラートで検知できるようにしておかないと、バッチが走っていないことに気づくのが翌日以降になります。

ロックの解放タイミング
正常終了時だけでなく、異常終了時にもロックを解放する仕組みが必須です。Redisの有効期限切れに頼るのではなく、例外発生時のfinallyブロックで確実に解放するコードを書きましょう。

ロック競合時のタイムアウト
複数サーバーがロック取得を待つ場合、無限に待つのではなく、一定時間後にタイムアウトして異常終了する仕組みが必要です。そうしないと、リソースが枯渇したり、デプロイ時にハングしたりします。

まとめ──スケール前に設計を見直す

「バッチが重複実行される問題」は、スケールアップのタイミングで初めて顕在化することが多いです。だからこそ、複数サーバー構成への移行を計画する段階で、早めに対策を講じておくことが大切です。

完璧な分散ロック機構よりも、チームが理解でき、運用できる仕組みを選ぶことが、長期的には最も安定した運用につながります。