RDBMS

コミット遅延による抽出漏れ(更新日時・連番で差分を取るバッチの落とし穴)

更新日時を見て、前回から変わった行を抜き出すバッチ。よくある作りですが、ごくまれに、行が永久に抜き出されなくなることがあります。

この現象は昔から知られていて、とても大事なものです。ところが調べてみると、いまのところ、世間で統一された用語はないようです。日本語では「取りこぼし」「抽出漏れ」「連携漏れ」のように、起きた結果だけで呼ばれることがほとんどです。英語でも、late-arriving data(遅れて届くデータ)や in-flight transactions(実行中のトランザクション)の問題として、それぞれの文脈で説明されているだけです。

名前がないと、設計レビューで「ここ、あの漏れるやつ大丈夫?」のような言い方しかできません。話がかみ合わず、チェック項目にも入れられません。そこでこのブログでは、この現象を「コミット遅延による抽出漏れ」と呼ぶことにします(仕組みの名前は「コミット追い越し」)。

書いた時刻と、見えるようになる時刻は違う

更新日時(updated_at)は、COMMIT より前に書くしかありません。一方、ほかの人から見えるようになるのは COMMIT した時点です。この2つの時刻の差が、問題の原因です。

何が起きるか

前回の実行時刻より後に更新された行を抜き出すバッチを考えます。

SELECT * FROM t WHERE updated_at > :前回の実行時刻;
-- 終わったら「今回の実行時刻」を記録して、次回の基準にする
時刻オンライン処理差分バッチ
20:31.00updated_at = 20:31.00 で UPDATE
(まだ COMMIT していない)
20:31.10WHERE updated_at > 20:30.00
→ まだ見えないので取れない
今回の実行時刻 20:31.10 を記録
20:31.20COMMIT
(ここで見えるようになる)
20:41.10WHERE updated_at > 20:31.10
→ 20:31.00 は条件に合わない
★ 永久に漏れる

1回目のバッチのときはまだ見えないので取れず、2回目のバッチのときは基準より前の時刻なので取れません。どちらのバッチからも漏れてしまいます。

連番(シーケンス)でも同じ

順番トランザクションAトランザクションB差分バッチ
1id=100 を取る
2id=101 を取る
3COMMIT
4id > 99 で抜き出す
→ 101 だけ取れる。基準を 101 にする
5COMMIT
6id > 101 で抜き出す
★ id=100 は永久に漏れる

シーケンスの番号も COMMIT より前に決まるので、時刻とまったく同じことが起きます。「時刻をやめて連番にすれば大丈夫」ではありません。

定義(このブログでの呼び方)

  • コミット遅延による抽出漏れ:更新日時や連番の値が決まってから COMMIT されるまでのあいだ、その行はほかの人から見えない。そのため、「前回の値より後」で差分を取るバッチが、その行を永久に抜き出せなくなること。
  • コミット追い越し:上の現象の仕組み。値を先に取ったトランザクションを、あとから値を取ったトランザクションが COMMIT で追い越すこと。

特徴

  • 時計の精度では防げません。NTP で合わせても、DBの時計を使っても起きます。
  • トランザクションが長いほど起きやすくなります。値を取ってから COMMIT するまでの時間が長いほど、漏れる時間帯が広がります。
  • PostgreSQL の CURRENT_TIMESTAMP、now() は特に起きやすいです。トランザクション開始時刻が入るので、COMMIT までの時間がさらに長くなります(→ PostgreSQL 時刻を返す関数の一覧)。
  • テストではまず見つかりません。更新とバッチがたまたま同じ瞬間に動いたときだけ起きます。本番で「ごくまれに連携漏れがある」という形で表に出ます。
  • Db2 は、設定や分離レベルによっては、COMMIT されていない行のロックが外れるまで待つことがあります。そのときは漏れませんが、代わりにバッチが待たされます。PostgreSQL・Oracle・MySQL(InnoDB)は待たずに「見えないもの」として通り過ぎるので、漏れます(→ 分離レベル(Isolation Level))。

対策

対策は、実質 ① か ② のどちらかです。

① さかのぼって重ねて抜き出す② 処理済みフラグ(キューの表)
やり方前回の基準から一定時間さかのぼって抜き出す。重なった分は送り先で UPSERT するなどして吸収する連携が必要な行を「未処理」としてキューの表に入れ、バッチが処理したら「処理済み」にする。時刻も番号も使わない
変えるところバッチ側だけ更新する側(またはトリガー)
前提さかのぼる幅が一番長いトランザクションより長いこと更新する経路すべてで、キューの表に入れること
漏れる場合さかのぼる幅より長いトランザクションがあったときキューに入れ忘れた更新の経路があったとき(手作業の修正など)。トリガーで入れれば防げる
削除物理削除は見つけられない(行がなくなるので、更新日時で探せない)キューの表に「削除」として入れれば連携できる
向いている場面元のシステムに手を入れられない。まずは手軽に直したい確実さが必要。削除も連携したい

① の例(PostgreSQL)

SELECT * FROM t
WHERE updated_at > :前回の実行時刻 - interval '10 minutes';

② の補足

  • 元の表にフラグの列を足して、バッチがそれを更新すると、元の表への更新やロックが増えます。キューの表を別に作るほうが、オンライン処理への影響が少なくて済みます。
  • 「トリガーで変更ログの表を書く」方式も ② の仲間です。ただし、その表から id > 前回の id で抜き出すと、また同じ漏れが起きます(上の連番の例と同じ理由)。処理済みにするか、処理した行を消す形にします。
「一番古いトランザクションの開始時刻を基準にする」案は?

③として、「基準を、実行中のトランザクションのうち一番古い開始時刻にする」(PostgreSQL なら pg_stat_activity の xact_start の最小値を使う)という案も考えられます。漏れは防げそうに見えますが、現実的ではない気がします。あったとしても、ごく限られた場面でしょうねぇ、、。

  • まったく関係ない長時間の処理(月次集計、pg_dump など)や、BEGIN したまま放置された接続(idle in transaction)があると、基準が進まなくなります。抜き出す範囲が毎回広がり、バッチがどんどん重くなります。
  • 更新日時をアプリ(APサーバー)の時計で入れていると、「更新日時 ≧ トランザクションの開始時刻」という前提が崩れて、結局漏れます。
  • 対象のアプリだけに絞って数えると、今度は手作業のデータ修正や、ほかのシステムからの更新を見落とします。

おまけ:COMMIT した時刻をあとから調べる

列の値として COMMIT した時刻を入れることはできませんが、DB の機能を使えば、あとから調べられるものもあります。

PostgreSQL(PostgreSQL 15 で確認済み)

postgresql.conf で track_commit_timestamp = on にして、再起動が必要です。on にする前に COMMIT された行は調べられません。

CREATE TABLE t (id int, updated_at timestamptz);

BEGIN;
INSERT INTO t VALUES (1, clock_timestamp());
SELECT pg_sleep(2);
COMMIT;

SELECT id, updated_at, pg_xact_commit_timestamp(xmin) AS committed_at FROM t;

--  id |          updated_at           |         committed_at
-- ----+-------------------------------+-------------------------------
--   1 | 2026-10-12 03:53:37.521015+09 | 2026-10-12 03:53:39.532197+09

更新日時(updated_at)と COMMIT した時刻(committed_at)のあいだに、ちょうど2秒の差が出ています。この2秒が、ほかの人からは見えない時間です。

Oracle(未検証)

こちらは検証できていません。ネットで調べたら出てきた情報です、、。ORA_ROWSCN(行が最後に COMMIT されたときの SCN)を SCN_TO_TIMESTAMP() で時刻に変換すると、おおよその COMMIT 時刻が分かるようです。ただし、表の作り方によってはブロック単位の値になったり、古すぎる SCN は変換できなかったりと、制限があるようです。

-- 未検証
SELECT SCN_TO_TIMESTAMP(ORA_ROWSCN), t.* FROM t;

どちらも、あとから調べるためのものです。バッチ設計では、上の ① か ② を使うのが一般的です。

関連記事

スポンサーリンク
タイトルとURLをコピーしました