弱点補強 — OFFSET ページングとキーセット:自分で消した行で半分飛ぶ × 同じ値の並びが動く × 深いページほど遅い × 長いスナップショット

2026-10-11 日曜(Day 150) 日曜弱点補強 ★★★★★ Seek Method / Transactional Checkpoint / 一意制約 PostgreSQL 14.11 で実測(会員 30 万人 × 3 回)

概要

✂️

自分の書き込みも「外の変化」

配った人が NOT EXISTS で集合から消え、次の OFFSET が未配布の人を読み飛ばす。対象 約 12 万人中 60,000 人にしか届かなかった(3 回とも)。

🔀

同じ値の並びは更新で動く

4 割が同じ created_at。ログインの更新だけで漏れ 289〜315 人・二重 289〜315 枚(漏れ=二重)。

📏

OFFSET は深さに比例して読む

1,000 件を読むのに 20 → 179,591 ブロック。キーセットは深さによらず 17 ブロック(ただし ANALYZE の後)。

🧾

「完了」は件数の照合で決める

Bad は 6 万人漏れて終了コード 0。Good は対象数 ≠ 付与済み数なら失敗で終わる。

数値は手元の PostgreSQL 14.11 での実測(乱数の種 3 つの範囲)。会員・問い合わせ件数は架空。Cloud SQL では測っていない。

問題

MOps 販促システムチームは 10月1日 02:00 に Argo Workflows の CronWorkflow で「休眠会員復帰クーポン」(comeback-2026-10)を一斉付与した。休眠=最終購入から 180 日超。前日のダッシュボードでは対象は約 12 万人(会員 30 万人の約 4 割)。会員の約 4 割は 2019-04-01 に旧システムから一括移行したため created_at が同じ秒に並ぶ。

観測(架空)値出どころ
バッチのログdone: 60000 coupons granted in 60 pages(終了コード 0)Argo
前日の対象数119,7xx 人Looker Studio
「友人には届いたのに自分には来ない」3 日で 214 件CS
運用が手で再実行2 回目 +30,000、3 回目 +15,000(「流すたびに増えるので、あと何回か流せば全員に届く」)運用チャンネル
ステージングの受け入れテスト全員に届いた(対象 845 人)QA

後輩A「最初に対象を全部 fetchall() してから配れば、ずれようがありません」
後輩B「全体を REPEATABLE READ の 1 トランザクションで囲めば、スナップショットが固定されて OFFSET のままでもずれません」
後輩C「半分飛ぶのは NOT EXISTS のせいなので外しましょう。再実行しなければ二重にはなりません」

設問

  1. Bad の問題点を 7 つ以上(誰に何が起きるか、関係する仕様)
  2. ステージングで全員に届いた理由と、「あと何回か流せば」が何回で終わるか
  3. 後輩A・B・C の提案を測って評価
  4. 改善後コード(落ちても何度再実行しても、対象の全員にちょうど 1 枚)
  5. Bad に流すと赤になるテスト(本物の PostgreSQL)と変異テスト
  6. 監視・止め方・既に漏れた約 6 万人への事後対応

制約・前提条件

  • PostgreSQL 14 以上(本番は Cloud SQL)、Python 3.12+、psycopg 3
  • 実行中もログイン・購入・新規登録が入り続ける(止められない)
  • 対象は実行を始めた時点で休眠だった会員。途中で購入した人にも配る(販促と合意済み)
  • 1 ページは数秒で終わる大きさ。Argo の retryStrategy は有効

Bad → Good(対比)

バッチに 8つの問題。左が Bad、右が Good の要点。
Bad: grant_bad.py
SELECT m.member_id FROM members m
WHERE m.last_purchase_at < now() - interval '180 days'
  AND NOT EXISTS (SELECT 1 FROM coupon_grants g
                  WHERE g.campaign_id = %(campaign)s
                    AND g.member_id = m.member_id)
ORDER BY m.created_at
LIMIT %(limit)s OFFSET %(offset)s

# ループ
rows = conn.execute(PAGE_SQL, {"offset": page * PAGE_SIZE})
INSERT INTO coupon_grants (campaign_id, member_id) VALUES ...
page += 1
print(f"done: {total} coupons granted")
Good: grant_good.py
# 1) 対象を開始時刻で一度だけ凍結(表に書き出す)
INSERT INTO coupon_grant_targets SELECT ... WHERE
  last_purchase_at < %(as_of)s - make_interval(days => 180)
ANALYZE coupon_grant_targets

# 2) 一意キーの続きから読む(キーセット)
WHERE campaign_id = %(campaign)s AND member_id > %(after)s
ORDER BY member_id LIMIT %(limit)s

# 3) 1 ページ = 1 トランザクション
INSERT ... ON CONFLICT (campaign_id, member_id) DO NOTHING
UPDATE grant_runs SET last_member_id = ids[-1]

# 4) 照合
if granted != target_count: raise GrantReconciliationError

設計の図(SVG)

Bad: OFFSET + NOT EXISTS(配った人が集合から消える) ページ1 OFFSET 0 1–1000 配る 1001–2000 2001–3000 3001–4000 ページ2 OFFSET 1000 1001–2000 捨てる 2001–3000 配る 3001–4000 集合の先頭は「まだ配っていない人」 → OFFSET 1000 は未配布の 1,000 人を捨てる 結果: 119,719 人中 60,000 人(3 回とも) Good: 凍結した対象を、一意キーの「続き」から読む id ≤ 1203 済 id > 1203 の 1,000 人 残り(凍結済みなので増減しない) 1 トランザクション = 付与(ON CONFLICT DO NOTHING)+ 進捗 last_member_id 落ちたら進捗の続きから。何度流しても同じ 1,000 件を読むのに必要なブロック数(対数目盛、実測) 10 100 1k 10k 100k offset 029,00059,00089,000118,000 OFFSET 179,591 キーセット・統計なし(Sort、先頭 1,605) キーセット・ANALYZE 後 17

上: OFFSET の「捨てる位置」が自分の書き込みでずれる仕組み(模式図)。下: sim_out.txt の EXPLAIN (ANALYZE, BUFFERS) 5 回の中央値。OFFSET は先頭の 20 ブロックから約 9,000 倍に増える。キーセットは統計が無いと索引を使わず、先頭ほど遅い(逆向き)。

ヒント(段階的開示)

ヒント1(方向性)

OFFSET k は「条件に合う行を並べて、先頭から k 行捨てる」。ページの間に条件に合う行の集合や並びが変わると、捨てる k 行の中身が変わる。変わる原因を全部探す。他人の更新だけでなく、自分が今配った行も原因になる。

ヒント2(アプローチ)
  • 配った行は NOT EXISTS で集合から消える。ページ2の OFFSET 1000 は何を捨てるか
  • created_at の同じ値の中の順番は何で決まるか(B-tree は同じキーを TID 順に持つ。更新で TID が変わる)
  • now() はトランザクションの開始時刻。ページごとに別のトランザクションなら?
  • OFFSET で読み飛ばす行もサーバの中で読まれる(PostgreSQL 文書 7.6)
  • REPEATABLE READ のスナップショットから、自分の書き込みは見えるか
ヒント3(誘導)
# 1) 対象を実行開始時刻で一度だけ凍結する
INSERT INTO coupon_grant_targets (campaign_id, member_id)
SELECT %(campaign)s, member_id FROM members WHERE last_purchase_at < %(as_of)s - interval '180 days'

# 2) 一意キーの「続き」から読む(キーセット/シーク方式)
SELECT member_id FROM coupon_grant_targets
WHERE campaign_id = %(campaign)s AND member_id > %(after)s
ORDER BY member_id LIMIT %(limit)s

# 3) 1 ページ = 1 トランザクション: 付与(ON CONFLICT DO NOTHING)と進捗を一緒に確定
# 4) 最後に「対象数 = 付与済み数」を照合し、合わなければ失敗

問題点分析(8点)

#箇所誰に何が起きるか(実測)仕様・挙動
1NOT EXISTS + OFFSET1 ページ配って 1 ページ飛ばす。対象 119,719〜119,931 人中 60,000 人、漏れ 49.9%OFFSET は並べた結果の先頭を捨てる。集合が縮めばずれる
2ORDER BY created_at(4 割同値)ログイン更新で同値の列の末尾へ移動。ガードを外した版で漏れ 289〜315・二重 289〜315文書 7.6「一意な順序でなければ予測できない部分集合」。B-tree は同キーを TID 順、非 HOT 更新で TID が変わる
3ページごとの now()途中購入で集合が縮む(ガード無しで漏れ 2,612〜2,641)。翌日の再開で対象外に配る(後輩A 案で 58 人)now() はトランザクション開始時刻
4OFFSET の深さ1,000 件に 20 → 179,591 ブロック、0.81 → 259〜376 ms。全ページで約 1,000 万ブロック(導出)文書 7.6「読み飛ばす行もサーバの中で計算される」
5一意制約なし二重付与を止めるのは NOT EXISTS だけ。ガード無しで 272〜315 枚確認と挿入の間に隙間。一意性は制約で
6進捗なし再実行は「残りの半分」だけ配り、運用には「進んでいる」ように見えるArgo の retry は最初からやり直す
7照合せず done6 万人漏れて終了コード 0。気づいたのは CS の 214 件完了=ループが終わった、になっている
8挿入への無防備(降順にした日)新規登録が先頭に入ると全員がずれて二重(導出・未測定)Winand "No Offset"

ステージングが緑だった理由・再実行は何回で終わるか

ステージングの対象 845 人は 1 ページ(1,000 件)に収まった。2 ページ目が無ければ #1 は起きない。対象が 1 ページを 1 人でも超えると漏れる(staging_out.txt)。

会員数対象届いた漏れ
2,0008458450
2,5001,0361,00036
5,0002,0271,0271,000
20,0007,9824,0003,982
再実行は 1 回ごとに残りの約半分を配るので、全員に届くまで 約 7〜8 回(log₂(119,8xx ÷ 1,000) ≒ 6.9、導出)。「流すたびに増える」は進んでいる証拠ではなく毎回半分捨てている証拠。しかも日をまたぐと対象外の人が混ざる。

3つの提案の評価(実測、会員 30 万人・3 回)

案漏れ二重他に起きること判定
Bad(外の更新あり)59,864〜59,9310深いページで最大 179,591 ブロック—
後輩C(ガードを外す)2,612〜2,641272〜301#2・#3 が表に出る。再実行すると全員に二重却下
後輩B(REPEATABLE READ)59,719〜59,9310自分の挿入は自分に見えるので直らない。VACUUM が古い行を 1 件も回収できない(回収 0/回収できない 19,191)。Good は 11,337 回収・0 残り却下
後輩A(全件 fetchall)00翌日の再開で対象外 58 人に配る(テスト)。二重を DB が止めない。メモリは件数に比例(12 万件 10.6 MiB/120 万件 106.3 MiB/300 万件 263.6 MiB)今の規模なら成立。採るなら as_of の固定とセット
Good00深さによらず 17 ブロック、ページの合間にトランザクションを持たない採用
後輩A の案は今日の規模(12 万人)では正しい答えにかなり近い。Good を採る理由は「今日の事故を直す」ではなく、Argo の retry を前提にした運用で、対象と回数を DB が保証すること。一度きりで再実行しない施策なら A で止めてよい。

模範解答(改善後コード)

全文は logs/2026-10-11-weakness-offset-keyset-pagination-code/grant_good.py(150 行)。インラインのコメントを黄色、効いている修正を緑で示す。

PAGE_SIZE = 1000                 # 1 トランザクションで確定する人数。数秒で終わる大きさにする
DORMANT_DAYS = 180               # 休眠の定義(販促チームの定義)


class GrantReconciliationError(RuntimeError):
    """対象数と付与済み数が合わない。黙って「完了」にしないために投げる。"""


@dataclass(frozen=True, slots=True)
class GrantSummary:
    """1 回の実行の結果(値オブジェクト)。"""
    campaign_id: str
    as_of: dt.datetime
    target_count: int
    inserted_this_run: int
    pages_this_run: int


FREEZE_RUN_SQL = """
    INSERT INTO grant_runs (campaign_id, as_of, target_count) VALUES (%(campaign)s, %(as_of)s, 0)
    ON CONFLICT (campaign_id) DO NOTHING          -- 再実行では最初の as_of を使い続ける
    RETURNING as_of
"""

FREEZE_TARGETS_SQL = """
    INSERT INTO coupon_grant_targets (campaign_id, member_id)
    SELECT %(campaign)s, m.member_id FROM members m
    WHERE m.last_purchase_at < %(as_of)s - make_interval(days => %(days)s)   -- now() を使わない
"""

# キーセット(シーク)方式: OFFSET で読み飛ばさず、一意キーの「続き」から索引で引く
PAGE_SQL = """
    SELECT t.member_id FROM coupon_grant_targets t
    WHERE t.campaign_id = %(campaign)s
      AND t.member_id > %(after)s                 -- 前ページの最後の次から(>= にすると重なる)
    ORDER BY t.member_id                          -- 一意キーで並べる。同値があると続きが決まらない
    LIMIT %(limit)s
"""

GRANT_SQL = """
    INSERT INTO coupon_grants (campaign_id, member_id)
    SELECT %(campaign)s, unnest(%(ids)s::bigint[])
    ON CONFLICT (campaign_id, member_id) DO NOTHING   -- 一意制約が「1 人 1 枚」を最後に守る
"""


def freeze_targets(conn, campaign_id, as_of) -> dt.datetime:
    """対象者を as_of 時点で確定し、表に書き出す(2 回目以降は何もしない)。

    Args:
        conn: autocommit の接続。
        campaign_id: 施策 ID。
        as_of: 休眠判定の基準時刻。初回だけ使われる。

    Returns:
        実際に使われている基準時刻(再実行なら初回の値)。
    """
    with conn.transaction():
        row = conn.execute(FREEZE_RUN_SQL, {...}).fetchone()
        if row is not None:  # 初回: 同じトランザクションなので、半端な凍結は残らない
            cur = conn.execute(FREEZE_TARGETS_SQL, {...})
            conn.execute("UPDATE grant_runs SET target_count = %s ...", (cur.rowcount, campaign_id))
    if row is not None:
        # 書き出した直後は統計が空で、プランナが索引を使わず「全件読んで並べ替え」を選ぶ(実測 120 ms/ページ)
        conn.execute("ANALYZE coupon_grant_targets")
    return conn.execute("SELECT as_of FROM grant_runs WHERE campaign_id = %s", ...).fetchone()[0]


def run(conn, campaign_id, as_of, *, between_pages=None, before_commit=None,
        page_size=PAGE_SIZE) -> GrantSummary:
    """休眠会員に復帰クーポンを 1 人 1 枚配る。落ちたら同じ引数で呼び直せば続きから再開する。

    Raises:
        GrantReconciliationError: 最後の照合で対象数と付与済み数が合わないとき。
    """
    frozen_as_of = freeze_targets(conn, campaign_id, as_of)
    inserted = pages = 0
    while True:
        with conn.transaction():
            # 進捗の行をロックしてから読む: 同じ施策のバッチが 2 本走ってもページを取り合わない
            after = conn.execute("SELECT last_member_id FROM grant_runs WHERE campaign_id = %s FOR UPDATE", ...)
            ids = [r[0] for r in conn.execute(PAGE_SQL, {...})]
            if not ids:
                break
            if ids[-1] <= after:  # 続きが前に進まない=読み方が壊れている。無限ループにせず止める
                raise RuntimeError(...)
            inserted += conn.execute(GRANT_SQL, {...}).rowcount
            # 付与と進捗を同じトランザクションで確定する(片方だけ残る瞬間を作らない)
            conn.execute("UPDATE grant_runs SET last_member_id = %s ...", (ids[-1], campaign_id))
        pages += 1
    target_count = _reconcile(conn, campaign_id)   # 対象数 ≠ 付与済み数 なら GrantReconciliationError
    return GrantSummary(campaign_id, frozen_as_of, target_count, inserted, pages)

created_at の順で読みたいときは、主キーを足して行値で比べる: WHERE (created_at, member_id) > (%(c)s, %(id)s) ORDER BY created_at, member_id(索引 (created_at, member_id)。向きが混ざると行値の比較は使えない)。一意制約の追加は CREATE UNIQUE INDEX CONCURRENTLY → ADD CONSTRAINT ... USING INDEX で書き込みを止めずに。

テストと変異(本物の PostgreSQL に流す)

テストGoodBad後輩A後輩B
外の更新なしで全員に 1 枚緑赤(7,982 中 4,000)緑赤
ログイン・購入・登録が挟まっても開始時点の対象に 1 枚緑赤緑赤
確定直前に落ちて再開緑赤緑赤
2 回流しても同じ緑赤(6,000)緑赤
翌日の再開で対象が変わらない緑赤赤(対象外 58)赤
ページの合間に開いたトランザクションが無い緑緑緑赤
基準時刻 30 日前を渡したらその時点の対象緑赤赤赤
進捗を 0 に戻して流し直しても二重にならない(Good のみ)緑———
1 件消えたら照合で失敗(Good のみ)緑———
2 本同時に起動してもページを二重に処理しない(Good のみ)緑———

変異テスト(grant_good.py を 1 か所ずつ壊す)

変異1 回目(6 テスト)2 回目(10 テスト+番人)
M1 続きを >= で読む赤(終わらない、120 秒で打ち切り)赤(番人が止める)
M2 ORDER BY を消す緑(生存)緑(生存)
M3 ON CONFLICT を消す緑(生存)赤
M4 進捗に先頭の id を書く赤(終わらない)赤(終わらない)
M5 再実行のたびに対象を作り直す赤赤
M6 凍結で now() を使う緑(生存)赤
M7 照合で失敗しない緑(生存)赤
M8 進捗の行をロックしない緑(生存)赤
M9 進捗と付与を別トランザクションで緑(生存)赤
1 回目は 9 種中 6 種が生き残った。安全網が二重の箇所(一意制約と進捗、同じトランザクションと一意制約)は、片方を外しても赤にならない。「進捗を手で戻す」「2 本同時に起動する」という片方が破れる状況を足して赤にした。M2 は PostgreSQL がたまたま索引順に返すのでテストでは捕まらない。文書が「ORDER BY が無ければ順序は不定」と書いている以上、レビューで守る。

実行例(input → output、実測)

# Good のテスト(会員 2 万人、1 ページ 500 件)
$ IMPL=good uv run --with 'psycopg[binary]' --with pytest pytest -q test_grant.py
10 passed in 6.92s

# 会員 30 万人、乱数の種 1
Bad 静かな夜               targets=119719 granted= 60000 missed= 59719 dup=   0 pages= 60
D1 ガード無し ログインだけ   targets=119719 granted=119404 missed=   315 dup= 315 pages=120
D1 ガード無し 外の更新あり   targets=119719 granted=117078 missed=  2641 dup= 301 pages=118
後輩A fetchall             targets=119719 granted=119719 missed=     0 dup=   0 pages=120
後輩B REPEATABLE READ      targets=119719 granted= 60000 missed= 59719 dup=   0 pages= 60
Good 外の更新あり           targets=119719 granted=119719 missed=     0 dup=   0 pages=120

# 長いトランザクションと VACUUM
後輩B(1 トランザクション)  回収できた古い行=     0  回収できなかった古い行= 19191
Good(ページごとに確定)     回収できた古い行= 11337  回収できなかった古い行=     0

今日いちばん意外だった実測: 凍結表を書き出した直後は統計が空で、キーセットなのに先頭ページで 1,605 ブロック・約 112 ms(OFFSET の先頭は 20 ブロック)。ANALYZE 後は深さによらず 17 ブロック・1 ms 未満。キーセットは「書けば速い」のではなく「索引で引ける計画になって初めて速い」。

迷った案と採らなかった理由

案採らなかった理由前提が変わったら
後輩A(全件 fetchall)再実行で対象が動く・二重を DB が止めない・メモリが件数に比例一度きりで再実行しない・100 万人未満なら A + as_of 固定で十分
サーバ側カーソル(WITH HOLD)Pod が落ちるとカーソルごと消え、進捗が DB に残らない書き込みの無い CSV エクスポートならこれが最も単純
対象表を作らず members を直接キーセット(as_of 固定)途中購入で条件から外れる人に配れない。対象数の照合ができない「途中で外れた人には配らない」が合意ならこちら
(created_at, member_id) の行値キーセット順番に業務上の意味が無い。索引が 1 本増える「先着 5 万人」など途中で打ち切る施策なら順番に意味が出る
Pub/Sub で 1 人 1 メッセージ12 万件の一斉付与には部品が多い。重複配信は結局一意制約で止める付与が外部 API になり 1 件ずつ再試行したい場合

直した後に見るもの・止め方・事後対応

  • 見るもの: 対象数と付与済み数の差(0 でなければ Argo が Failed)/1 ページの処理時間の推移(深さに比例して伸びたら ANALYZE 忘れか索引の不在)/idle in transaction の時間と backend_xmin の古さ/付与数 ÷ 前日の対象数
  • やめる条件: 付与が外部 API 呼び出しになったら(外部の成功と DB の確定が別になる)outbox へ。対象が数千万人で凍結の INSERT 自体が長くなるなら、凍結も範囲で分割
  • 事後対応: 当初の対象を as_of = 2026-10-01 02:00 JST で凍結し直し、未付与の約 6 万人にだけ配る(期限は今回の配布日から)。日をまたいだ手動再実行で対象外に配った人を特定し、取り消すかは販促が金額影響を見て判断。問い合わせた 214 人への回答文を CS に。同じ形のバッチを OFFSET と NOT EXISTS で検索

ポイント解説

  1. OFFSET は「位置」、キーセットは「値」で続きを指す: 位置は集合や並びが変わると別の行を指す。読みながら書くバッチでは値で指す
  2. 自分の書き込みも外の変化: 6 万人の漏れは自分の INSERT のせい。REPEATABLE READ が効かないのも、自分の書き込みは自分に見えるから
  3. 「完了」は照合で定義する: 照合があれば 02:00 の時点で Failed になっていた
  4. 安全網は破れる状況を作ってテストする: 一意制約・同じトランザクション・行ロックは互いを隠す
  5. 書籍との対応: 第 4 章 不変の活用(対象の凍結)、第 3 章 値オブジェクト(GrantSummary)、第 10 章 例外を握り潰さない(照合・進まないループ)、第 13 章 コマンドとクエリの分離。パターン: Seek Method/Snapshot Target Set/Transactional Checkpoint(Spring Batch の ItemStream と同じ考え方)/Idempotent Receiver

実務への応用

  • メール配信・一斉付与: 配信リストを as_of で凍結、一意制約、照合。外部 API なら outbox
  • エクスポート: 読むだけなら 1 本のスナップショットで十分。ただし長いスナップショットは VACUUM を止める(19,191 行)ので読み取りレプリカで
  • 管理画面の「もっと見る」: Stripe の starting_after と同じ形のカーソル。カーソルは利用者の入力なので検証する
  • Argo の retryStrategy: 何度流しても同じ結果になるステップが前提。Bad は流すたびに結果が変わった
  • dbt incremental: 「前回の最大値より後」もキーセット。同じ秒の境界で取りこぼすので >= で取り直して一意キーで merge

扱わなかった範囲・一次資料

  • Cloud SQL 上での実測 — 手元の PostgreSQL 14.11 のみ。ディスク読みが遅いぶん OFFSET の差は開くはず(未測定)
  • 本当の並行実行 — 外の更新はページの合間に差し込んだ(決定的に再現するため)
  • 並びが不定になる他の原因(並列スキャン・synchronize_seqscans・索引の作り直し)— 再現していない。M2 が生き残った理由
  • 画面のページ番号・総件数 — バッチに絞った
  • 景品表示法・クーポン規約上の扱い — 判断は販促と法務

参照日 2026-10-11。
PostgreSQL 7.6 LIMIT and OFFSET https://www.postgresql.org/docs/current/queries-limit.html("The rows skipped by an OFFSET clause still have to be computed inside the server")
PostgreSQL 9.9.5 Current Date/Time https://www.postgresql.org/docs/current/functions-datetime.html
PostgreSQL 13.2.2 Repeatable Read https://www.postgresql.org/docs/current/transaction-iso.html
PostgreSQL 25.1 Routine Vacuuming https://www.postgresql.org/docs/current/routine-vacuuming.html
Markus Winand, No Offset https://use-the-index-luke.com/no-offset
Stripe API Pagination https://docs.stripe.com/api/pagination
Spring Batch ItemStream https://docs.spring.io/spring-batch/reference/readers-and-writers/item-stream.html

今日のまとめ

読みながら書くバッチで OFFSET を使うと、自分が配った行が集合から消えるだけで半分飛ぶ(12 万人中 6 万人)。対象を開始時刻で凍結し、一意キーの続きから読み、付与と進捗を同じトランザクションで確定し、最後に件数を照合すれば、外の更新があっても・落ちても・何度流しても全員にちょうど 1 枚。ただしキーセットも統計が無ければ索引を使わない。

自己評価(あとで記入)