読んでいる間にも元データは変わり続ける
一言でいうと
絶えず変わる元データをページに分けて受け取るとき、位置を番号(offset)で指定すると、行が黙って消えますが、位置を最後に見た値(cursor)で指定すれば消えません。その値は、時刻1つではなく、(updated_at, id)の2つの欄でなければなりません。
なぜ必要なのか
「顧客一覧を毎日全部受け取ります」という同期があります。60万件を、1000件ずつ600回に分けて受け取ります。ところが、その600回を回っている間にも、元データは変わり続けます。
ORDER BY updated_atで並べ替えて、LIMIT 1000 OFFSET 10000ずつ受け取るとしましょう。こちらが11ページ目を受け取る直前に、すでに通り過ぎたページにあった行3件が更新され、updated_atが現在の時刻に変わりました。並び順で、その3件は一番後ろに行きます。すると、後ろにあったすべての行が、前へ3つずつ引き寄せられます。オフセット10000は、もとは10000行目の行を指していましたが、今は10003行目の行を指します。その間の3件は、誰にも読まれないまま通り過ぎます。
この事故の特徴は、3つです。エラーが出ません。件数は、ほぼ合います。毎回、違う行が抜けます。そのため、再現できず、数か月後に「この顧客がそちらにいないのですが」という報告になって戻ってきます。
どう動くのか
直す方法は、位置を番号ではなく値で指定することです。最後に読んだ行の並べ替えキーを覚えておき、次のリクエストで「その値より大きいものから」くださいと頼みます。一般に、キーセットページネーション、またはカーソルページネーションと呼びます。
오프셋: "10000번째부터 1000개" ← 앞이 밀리면 가리키는 곳이 달라진다
커서: "이 값보다 큰 것 1000개" ← 앞이 밀려도 이 값보다 큰 것은 그대로다
このコードブロックの韓国語は、オフセットは「10000行目から1000件」で、前がずれると指す場所が変わる、カーソルは「この値より大きいものを1000件」で、前がずれてもこの値より大きいものはそのまま、という意味です。
ここで、2つ目の落とし穴が出てきます。並べ替えキーが一意ではないことです。updated_atは秒単位なので、同じ値を持つ行が複数あるのがふつうです。カーソルをupdated_at1つだけで指定すると、2つのうちどちらかになります。
WHERE updated_at > :lastにすると、同じ時刻を持つ残りの行が、まるごと消えます。WHERE updated_at >= :lastにすると、すでに読んだ行をもう一度読みます。同じ時刻の行がページサイズより多ければ、永遠に同じ場所を回り続けます。
そのため、カーソルは、一意になるまで欄を増やします。ふつうは(updated_at, id)の2つの欄で足り、比較も、2つの欄を一緒に行います。(updated_at, id) > (:last_at, :last_id)です。
ウォーターマークは、そのカーソルを、次の実行まで持っていくものです。中断されたときに、最初から受け取り直さないためには、ウォーターマークがディスクになければなりません。そして、ウォーターマークをいつ保存するかが重要です。受け取った行を処理する前に保存すると、中断時にそのページを失い、処理したあとに保存すると、中断時にそのページを受け取り直します。失うよりは、受け取り直すほうがましです。そのため、ほとんどの同期は、少なくとも1回(at-least-once)として設計し、受け取る側を冪等にします。
そして、この方式では、重複は正常です。こちらが通り過ぎた行が更新されると、その行はカーソルの後ろに来て、もう一度捕まります。それはバグではなく、「その間に変わった」という事実そのままです。コピー側で、idをキーにして上書きすれば、結果は正しくなります。
最後は、突合(reconciliation)です。全部受け取ったと信じず、数えてみます。元データの件数とコピーの件数、そして両方にある行の値が同じかまで見ます。件数だけが合っているのは、合っているのではありません。1件が抜けて1件が重複しても、件数は変わらないからです。
時刻をキーに使うときは、表記も約束しなければなりません。RFC 3339が、インターネットで使う日付・時刻の表記を定義しています。文字列で比較するつもりなら、桁数とタイムゾーンの表記が、すべての行で同じでなければなりません。2026-09-10T00:00:00Zと2026-09-10T00:00:00+00:00は、同じ瞬間ですが、文字列の比較では異なります。ページを送る方式そのものは、RFC 8288のLinkヘッダーでnextを渡すAPIも多いので、つなぐ前に、その側が何を渡すのかを確認します。
現場での姿
1つ目、「全体をもう一度受け取ればよいのではないですか」という声が、よく出ます。データが小さければ、そのとおりです。ただ、全体の再収集も、回っている間に変わるので、スナップショットの時点が、一瞬ではなく、巡回した時間の全体にまたがっているという点は、同じです。
2つ目、updated_atを更新しない元データがあります。どのフィールドを直しても時刻がそのままなら、カーソル方式は、その変更を永遠に見られません。つなぐ前に、「何を変えると、この時刻が変わるのか」を、必ず尋ねます。削除も同様です。行が消えると、カーソルではわからないので、削除を知らせる方法が、別に必要です。
3つ目、時計が後戻りします。元データのサーバーが複数台あって、時計が少しずつ違うと、あとで書いた行のupdated_atが、先の行より小さくなることがあります。そうすると、カーソルがすでに通り過ぎた場所なので、永遠に捕まりません。そのため、ウォーターマークを少し後ろに戻して(たとえば数秒前に)指定し、重複を受け入れる実装が多いです。
4つ目、最初の実行が、最も危険です。60万件を初めて受け取る間にも、元データは変わり続けます。最初の実行を静かな時間に行い、終わったらすぐにもう一度行って、その間の変更に追いつくのが、安全です。
次のラボですること
絶えず変わる元データのサーバーを起動して、全体60行のスナップショットを取ります。オフセット方式で巡回しながら、途中で元データを変えて、行が抜けると同時に重複することを、数字で確認します。そのあと、(updated_at, id)のカーソルに移して、同じ状況で欠落が0になることを見ます。ウォーターマークをファイルに残して、2回目の実行が、変わったものだけを受け取るようにし、同じ時刻を持つ5行の境界を、ページが分けるようにして、カーソルが耐えるかを見ます。最後に、中断された同期を引き継ぎ、元データとコピーを照合して、値まで合っているかを確認します。