WHERE updated_at > ? で取る差分は、コミットの遅い行を落とす
差分の境界は、動いているデータの中からではなく、外側の時計から取る
ある業務システムから外部のシステムへ、注文データを定期的に流し込む仕組みを作っているとします。全件を毎回まるごと送るのは重いので、前回の同期より後に変わった行だけを送りたいところです。多くの人がまず書くのは、次のようなクエリではないでしょうか。
SELECT * FROM orders WHERE updated_at > :last_sync_at
取れてきた行の中でいちばん新しい updated_at を、次回の :last_sync_at として覚えておく。これだけです。実装は数行で済み、テストを書けば件数もぴったり合います。
差分連携、外部 API からの定期取り込み、検索インデックスの更新、キャッシュの部分無効化、モバイルのオフライン同期。名前は違っても、中身はほとんどこの形をしています。動いているように見えるので、一度書いたらそれきり見直さない人も多いはずです。
ところが、この仕組みを動かし続けていると、ごくまれに1件だけ届かない行が出てきます。エラーは1つも出ていません。同じクエリを何度実行し直しても、その1件だけは二度と出てこない。いったい何が起きているのでしょうか。
目次
「前回から変わった分だけ」を線で区切る
まず言葉を揃えておきます。前回どこまで同期できたかを1つの値として覚えておき、次回はそこから先だけを取りに行くやり方を、ここではハイウォーターマーク方式と呼びます。データの抽出・変換・別の場所への読み込みを扱う世界でよく使われる呼び方で、updated_at のような「更新された時刻」を線引きの基準に使うのがいちばん多いパターンです。
この方式が支持される理由は単純です。全件を毎回舐め直す必要が無く、updated_at にインデックスさえ張っておけば、差分の取得は軽いクエリで済みます。テーブルに新しい列を追加する必要もほとんどありません。多くのアプリケーションが、もともと updated_at を持っているからです。
ただしこの方式には、静かに壊れる急所が1つあります。線引きに使う updated_at が、差分によって動くデータ自身の中にあるということです。物差しを、測ろうとしている対象そのものから借りてくると何が起きるのか。ここから具体的に見ていきます。
クエリの中の「今」は、いつの「今」か
updated_at は普通、行を書き込む瞬間にデータベース側の「今」でスタンプします。PostgreSQL なら now() や CURRENT_TIMESTAMP がその代表です。ところが公式ドキュメントには、見落としやすい一文があります。
Since these functions return the start time of the current transaction, their values do not change during the transaction.
now() が返すのは、そのクエリを実行した瞬間の時刻ではありません。そのトランザクションが始まった瞬間の時刻です。トランザクションの中でどれだけ時間が経っても、何度呼んでも同じ値が返り続けます。
本当にその瞬間の時刻が欲しいなら、clock_timestamp() を使う必要があります。同じドキュメントは、clock_timestamp() の値は1つの SQL 文の中でさえ変化すると明記して、now() とはっきり区別しています。文の開始時刻だけが欲しいなら statement_timestamp() という第三の選択肢もあります。
つまり同じ「今」でも、少なくとも3種類あるわけです。この違いがなぜ問題になるのか、次の場面を見るとはっきりします。
コミット順とスタンプされた時刻の順は、べつものだった
同期処理が読みに行けるのは、コミットされた行だけです。これは複雑な設定の話ではありません。コミットしていない行が他の接続から見えないのは、コミットという言葉の定義そのものだからです。手元で確かめても、そのとおりの結果になりました。
writer.exec('BEGIN IMMEDIATE');
writer.prepare('INSERT INTO orders VALUES(?,?)').run('A', '2026-09-14T10:00:00.000Z');
console.log('書き手のトランザクションを開いたまま -> 読み手が見た件数:', count());
writer.exec('COMMIT');
console.log('書き手がコミットした後 -> 読み手が見た件数:', count());
書き手のトランザクションを開いたまま -> 読み手が見た件数: 0
書き手がコミットした後 -> 読み手が見た件数: 1
ここまでは驚くことではありません。問題は、コミットされる順番と、updated_at に刻まれた時刻の順番が、必ずしも一致しないことです。now() がトランザクション開始時刻を返す以上、開始が早くてもコミットが遅いトランザクションは、若い時刻を持ったままずっと後になって現れます。
次の場面を考えてみてください。行 B は 10:00:05 に始まる短いトランザクションで、すぐにコミットされます。行 A は 10:00:00 に始まる長いトランザクションで、B より先に始まったのに、コミットは B よりずっと後になります。
function sync(label) {
const rows = db
.prepare('SELECT id, updated_at FROM orders WHERE updated_at > ? ORDER BY updated_at, id')
.all(watermark);
for (const r of rows) delivered.add(r.id);
if (rows.length > 0) watermark = rows[rows.length - 1].updated_at;
}
writeInTransaction('B', '2026-09-14T10:00:05.000Z'); // 短いトランザクション
sync('同期1回目 (10:00:10)');
writeInTransaction('A', '2026-09-14T10:00:00.000Z'); // 長いトランザクションが後からコミット
sync('同期2回目 (10:00:30)');
for (let i = 3; i <= 12; i++) sync('同期' + i + '回目');
同期1回目 (10:00:10) -> 取得: ["B"] / 次回の起点: 2026-09-14T10:00:05.000Z
同期2回目 (10:00:30) -> 取得: [] / 次回の起点: 2026-09-14T10:00:05.000Z
同期3回目 -> 取得: [] / 次回の起点: 2026-09-14T10:00:05.000Z
同期4回目 -> 取得: [] / 次回の起点: 2026-09-14T10:00:05.000Z
同期5回目 -> 取得: [] / 次回の起点: 2026-09-14T10:00:05.000Z
同期6回目 -> 取得: [] / 次回の起点: 2026-09-14T10:00:05.000Z
同期7回目 -> 取得: [] / 次回の起点: 2026-09-14T10:00:05.000Z
同期8回目 -> 取得: [] / 次回の起点: 2026-09-14T10:00:05.000Z
同期9回目 -> 取得: [] / 次回の起点: 2026-09-14T10:00:05.000Z
同期10回目 -> 取得: [] / 次回の起点: 2026-09-14T10:00:05.000Z
同期11回目 -> 取得: [] / 次回の起点: 2026-09-14T10:00:05.000Z
同期12回目 -> 取得: [] / 次回の起点: 2026-09-14T10:00:05.000Z
テーブルにある行 : ["A","B"]
同期が届けた行 : ["B"]
取りこぼした行 : ["A"] / 件数: 1
同期1回目で B が届き、起点は B の時刻 10:00:05 に進みます。その後 A がコミットされ、同期2回目以降を何度実行しても、A の時刻 10:00:00 は起点の 10:00:05 より古いので WHERE updated_at > 起点 に一致しません。
ここが肝心なところです。3回目以降、10回、12回と回しても結果は変わりません。テーブルには A と B の2行があるのに、届いたのは B だけ。A はこの先ずっと届きません。件数もエラーも何も教えてくれないまま、1件だけが静かに欠け続けます。
1点だけ補足します。この検証は書き込みを直列に行う SQLite の上で行ったので、2つのトランザクションを同時に開いたまま順番を入れ替えたわけではありません。実際に確かめたのは、B が先に見え、その後で A が古い時刻を連れて現れるという見え方の順序です。この順序が実運用でも起こる理由は、now() がトランザクション開始時刻を返すという仕様と、コミット前の行は見えないという事実の組み合わせにあります。
境界にちょうど乗った行は、比較演算子ひとつで生死が分かれる
もう1つ、境界の扱いにも注意が要ります。前回の同期で起点がある時刻に進んだとして、次に同じ時刻を持つ行が追加されたらどうなるでしょうか。
答えは、使う演算子次第です。updated_at > 起点 なら、同じ時刻の行は二度と一致しません。updated_at >= 起点 なら取りこぼしませんが、代わりに前回すでに送った行まで、もう一度送り直すことになります。
演算子 > の場合
同期1回目 -> 取得 5 件 ["r1","r2","r3","r4","r5"]
同期2回目 -> 取得 0 件 []
同期3回目 -> 取得 0 件 []
テーブル 8 件 / 届いた 5 件 / 取りこぼし 3 件 ["r6","r7","r8"]
演算子 >= の場合
同期1回目 -> 取得 5 件 ["r1","r2","r3","r4","r5"]
同期2回目 -> 取得 8 件 ["r1","r2","r3","r4","r5","r6","r7","r8"]
同期3回目 -> 取得 8 件 ["r1","r2","r3","r4","r5","r6","r7","r8"]
テーブル 8 件 / 届いた 8 件 / 取りこぼし 0 件 []
> を使えば同期先には二度と来ない行ができ、>= を使えば同じ行が何度も届きます。では >= に変えれば万事解決でしょうか。一方に切り替えただけでは終わらず、>= を選ぶなら同期先は同じ行を何度受け取っても結果が変わらない作り(べき等な適用)にしておく必要があります。
> は境界の行を落とし、>= は境界の行を配り直す同じ時刻を持つ行は、思ったより簡単に生まれる
境界に同じ時刻が乗るなんて、よほど特殊な高負荷でもないと起きないのではと思うかもしれません。同じ時刻を持つ行がそんなに簡単に生まれるものでしょうか。試しに、1000行をまとめて1回のトランザクションで書き込んでみました。
書き込み : 1000 行 / 13 ms
相異なる時刻値 : 7 個
1 つの時刻値に載った行の最大 : 258 行
最大の時刻値を共有する行 : 140 行 (時刻値: 2026-09-13T23:51:05.724Z)
Node.js: v22.12.0 / platform: win32
Node.js の Date.now() は1ミリ秒刻みです。経過時間が E ミリ秒なら、相異なる時刻値は多くても E + 1 個にしかなり得ません。今回は書き込みに13ミリ秒かかったので上限は14個、実測は7個で上限の内側に収まっています。
7個しかない時刻値に1000行が押し込まれた結果、いちばん混み合った時刻値には258行が乗りました。ただし同期にとって危ないのは、いちばん混み合った時刻値ではありません。境界に乗るのはいつでも末尾なので、危ないのはいちばん新しい時刻値を共有している140行のほうです。ちょうどこの瞬間に同期が走って起点をそこまで進めてしまえば、先ほどの > の話がそのまま効いて、その後に同じ時刻で書かれた行が丸ごと落ちることになります。
ただしこれらの数字は、この環境・この負荷・この時計の分解能で測った1回きりの結果にすぎません。台数や書き込み速度、時計の刻みが変われば数字は変わります。変わらないのは、「書き込みの速度が時計の刻みを上回れば、同じ時刻を持つ行は必ず積み上がる」という関係だけです。
なお SQL Server の datetime 型は、公式ドキュメントによれば粗い刻みに丸められます。
Rounded to increments of .000, .003, or .007 seconds
刻みがミリ秒より粗い分だけ、同じ時刻を持つ行はこの型を使うデータベースのほうがさらに生まれやすくなります。
スタンプを押しているのは、どちらの時計か
ここまでは1台のデータベースの中の話でした。もう1つ、境界をずらす要因があります。updated_at を、アプリケーションサーバー側の時計(new Date() など)で打つのか、データベースサーバー側の時計(now() など)で打つのか、という選択です。
2台の時計を使う構成では、その2台がわずかにずれているだけで境界はさらに不安定になります。この記事ではこの点を数値では確かめていません。検証環境が1台だったためです。ただ、「時計は1つとは限らない」という前提は、設計の初めに持っておく価値があります。どちらの時計でスタンプを押しているか、即答できるでしょうか。
消えた行は、ここでは一度も見えない
もう1つ、この方式が原理的に運べないものがあります。削除です。行が物理的に削除されると、updated_at を検索するどんなクエリにも、その行はもう現れません。
同期1回目 -> 取得: ["A","B","C"] / 同期先: ["A","B","C"]
B を削除
同期2回目 -> 取得: [] / 同期先: ["A","B","C"]
同期3回目 -> 取得: [] / 同期先: ["A","B","C"]
元テーブル : ["A","C"]
同期先 : ["A","B","C"]
余計に残った行 : ["B"]
元のテーブルから B はすでに消えているのに、同期先には B が残ったままです。B が消えたことを知る手立てが、この検索方式には無いからです。
論理削除(削除フラグを立てて updated_at を更新するやり方)を使えば、削除は更新として流れてきます。ただしこれは「削除を更新という形に作り直した」だけです。updated_at を見るこの方式そのものが、削除を扱えるようになったわけではありません。
対処の地図 — 何を諦め、何を得るか
ここまでの話をまとめると、素朴なハイウォーターマーク方式には少なくとも3つの穴があります。長いトランザクションによる恒久的な取りこぼし、境界に乗った行の扱い、そして削除の不可視性です。それぞれへの対処には、必ず何かの代償が付きます。
| 方式 | 長いトランザクションへの耐性 | 削除の可視性 | 適用側に求めること | この記事での確認区分 |
|---|---|---|---|---|
素朴な > ウォーターマーク |
無し(恒久的に欠落) | 無し | 特になし | 実測 |
>= ウォーターマーク |
無し(同じ穴が残る) | 無し | べき等な適用 | 実測 |
| 重なりを持つ時間窓 + べき等な適用 | 窓の幅までは耐える | 無し | べき等な適用 | 実測 |
| 単調増加の連番 + 観測した最大値を起点 | 無し(同じ穴が形を変えて残る) | 無し | 特になし | 仕様の記述 |
| 単調増加の連番 + アクティブな最小値を起点 | 有り | 無し | 特になし | 仕様の記述 |
| 変更フィード(論理レプリケーション等) | 有り | 有り | フィードを消費できる作り | 未検証(設計の整理のみ) |
重なりを持つ時間窓は、取りこぼしを遅らせるだけ
いちばん手軽な緩和策は、起点から少し手前まで重ねて読み直す「時間窓」です。起点よりN秒前から読み直せば、Nより短いトランザクションはすべて窓の内側に収まります。
重なり 60 秒 / トランザクションの長さ 20 秒(開始 10:00:00・コミット 10:00:20)
同期1回目 -> ["B"] / 起点: 2026-09-14T10:00:05.000Z
A は起点 10:00:05 より 5 秒だけ古い -> 窓の内側
同期2回目 -> ["A","B"]
取りこぼし: [] / 0 件
重なり 60 秒 / トランザクションの長さ 90 秒(開始 09:58:35・コミット 10:00:05 以降)
同期1回目 -> ["B"] / 起点: 2026-09-14T10:00:05.000Z
A は起点 10:00:05 より 90 秒古い -> 窓の外側
同期2回目 -> ["B"]
取りこぼし: ["A"] / 1 件
重なり60秒に対してトランザクションの長さが20秒なら、取りこぼしはゼロでした。90秒なら1件取りこぼしています。窓は取りこぼしを無くすのではなく、遅らせているだけだとわかります。窓の幅は、実質的に「これより長いトランザクションは許容しない」という宣言そのものです。
もう1つ見逃せないのが、2回目の同期で B がもう一度届いていることです。窓を使う以上、同じ行が繰り返し配信されるのは避けられません。適用側が同じ行を何度受け取っても結果が変わらない作りになっていることが、この方式の前提になります。
しかも窓を広げれば広げるほど、毎回読み直す量も増えていきます。「不安だから窓を長めに」と延ばしていくと、軽さを理由に選んだはずの方式が、少しずつ全件同期に近づいていく。どこまで許容するかを決めずに広げられる調整つまみではありません。
連番に変えても、「観測した最大値」を使えば同じ穴が開く
時刻ではなく、単調に増える連番を使えば解決するのでは、と考える人もいるでしょう。SQL Server の rowversion 型は、公式ドキュメントによれば時刻ではなく、データベース単位で増え続けるただの連番です。
The rowversion data type is just an incrementing number and does not preserve a date or a time. … This tracks a relative time within a database, not an actual time that can be associated with a clock.
連番なら同じ値を2つの行が持つことはなく、時計の刻みも関係ありません。境界に乗る行の悩み(先ほどの > と >= の話)は消えます。ですが、それだけでは長いトランザクションの問題は消えません。本当に連番に変えるだけで解決するのでしょうか。
同じ発想の罠が、連番でもそのまま起きます。「これまでに観測した最大の連番」を次回の起点にすると、その瞬間にまだコミットされていない、より小さい連番を持つトランザクションを永久に取りこぼします。Microsoft のドキュメントは、この罠をはっきり名指ししています。
If an application uses @@DBTS rather than MIN_ACTIVE_ROWVERSION, it is possible to miss changes that are active when synchronization occurs.
@@DBTS はデータベース全体で最後に払い出した連番の最大値、つまり「観測した最大値」に相当します。これを起点にすると、先ほどの A のような長いトランザクションをまた取りこぼします。MIN_ACTIVE_ROWVERSION() は逆に、まだコミットされていないトランザクションが使っている、いちばん小さい連番を返します。次回の起点をこの値にしておけば、コミット前のトランザクションを追い越して境界を進めてしまうことがありません。
つまり連番方式が本当に安全になるのは、「連番に変えたから」ではなく、「観測した最大値ではなく、アクティブな最小値を起点にしたから」です。この切り替えを見落とすと、時計をやめただけで、同じ形の穴がそのまま開いた状態になります。この段落は公式ドキュメントの記述に基づく整理で、SQL Server 実機での再現は行っていません。
変更フィード(データベースへの書き込みそのものの記録を、後から読み直すのではなく順番に読み取っていく仕組み)まで踏み込めば、削除もコミット順も、書き込みの記録そのものから追えるようになります。ただしこの記事では、その具体的な挙動までは確かめていません。何を保証する設計かの整理にとどめます。
この記事の検証の範囲
本文の実測はすべて、Node.js v22.12.0(win32)と、Node.js に組み込まれた実験的な SQLite (node:sqlite) を使って手元で確かめたものです。外部のライブラリは使っていません。
PostgreSQL の now() の挙動、SQL Server の datetime の丸め、rowversion や MIN_ACTIVE_ROWVERSION() の性質は、いずれも公式ドキュメントの記述として引用しており、実機での再現は行っていません。アプリケーションサーバーとデータベースサーバーで時計がずれるケースも、検証環境が1台だったため数値としては確かめていません。
同一時刻に乗る行の数(140行)は、この検証環境・この負荷での1回の観測です。数値そのものではなく、「書き込み速度が時計の刻みを上回れば同じ時刻が積み上がる」という関係だけを、一般的な事実として読んでください。
「取りこぼしゼロ」は件数では確かめられない
最後に、確かめ方そのものについて触れておきます。ここまでの実験はどれも、同期先に届いた行のIDの集合と、元のテーブルにある行のIDの集合を突き合わせ、その差を見る形で確かめています。件数の一致だけでは判定していません。
なぜかというと、件数の一致は簡単に嘘をつくからです。1件を取りこぼして、別の1件を重複して数えれば、件数はたまたま一致します。同期が1件も進まなかった回でも、送信元と送信先の件数がすでに一致していることもあります。件数だけを見ていたら、どちらのケースも異常なしと判定してしまうはずです。
本当に確かめたいのは「何件届いたか」ではなく「どのIDが届いていないか」です。同期のたびに、対象期間の元テーブル側のID一覧と、実際に届いたID一覧を突き合わせて差分を取る。この記事の実験で使ったのと同じやり方を、自分の手元にある同期処理にもそのまま当てはめられます。
差分の境界を、差分によって動くデータ自身から借りてくると、境界はデータの都合でいつでもねじれます。物差しは、測ろうとしている対象の外に置く。件数ではなく集合の差で確かめる。この2つだけは、どんな同期の実装にも持ち越せる教訓だと思います。
参考にした一次情報
- PostgreSQL 公式ドキュメント — Date/Time Functions and Operators(
now()/CURRENT_TIMESTAMPがトランザクション開始時刻を返すこと、clock_timestamp()との違い) - datetime (Transact-SQL) — Microsoft Learn(SQL Server の
datetime型が.000/.003/.007秒に丸められること) - rowversion (Transact-SQL) — Microsoft Learn(
rowversionが時刻ではなく単調増加の連番であること) - MIN_ACTIVE_ROWVERSION (Transact-SQL) — Microsoft Learn(
@@DBTSを起点にすると同期時にアクティブな変更を取りこぼす可能性があること)








