概要
自分の書き込みも「外の変化」
配った人が 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 のせいなので外しましょう。再実行しなければ二重にはなりません」
設問
- Bad の問題点を 7 つ以上(誰に何が起きるか、関係する仕様)
- ステージングで全員に届いた理由と、「あと何回か流せば」が何回で終わるか
- 後輩A・B・C の提案を測って評価
- 改善後コード(落ちても何度再実行しても、対象の全員にちょうど 1 枚)
- Bad に流すと赤になるテスト(本物の PostgreSQL)と変異テスト
- 監視・止め方・既に漏れた約 6 万人への事後対応
制約・前提条件
- PostgreSQL 14 以上(本番は Cloud SQL)、Python 3.12+、psycopg 3
- 実行中もログイン・購入・新規登録が入り続ける(止められない)
- 対象は実行を始めた時点で休眠だった会員。途中で購入した人にも配る(販促と合意済み)
- 1 ページは数秒で終わる大きさ。Argo の
retryStrategyは有効
Bad → Good(対比)
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")# 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)
上: 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点)
| # | 箇所 | 誰に何が起きるか(実測) | 仕様・挙動 |
|---|---|---|---|
| 1 | NOT EXISTS + OFFSET | 1 ページ配って 1 ページ飛ばす。対象 119,719〜119,931 人中 60,000 人、漏れ 49.9% | OFFSET は並べた結果の先頭を捨てる。集合が縮めばずれる |
| 2 | ORDER BY created_at(4 割同値) | ログイン更新で同値の列の末尾へ移動。ガードを外した版で漏れ 289〜315・二重 289〜315 | 文書 7.6「一意な順序でなければ予測できない部分集合」。B-tree は同キーを TID 順、非 HOT 更新で TID が変わる |
| 3 | ページごとの now() | 途中購入で集合が縮む(ガード無しで漏れ 2,612〜2,641)。翌日の再開で対象外に配る(後輩A 案で 58 人) | now() はトランザクション開始時刻 |
| 4 | OFFSET の深さ | 1,000 件に 20 → 179,591 ブロック、0.81 → 259〜376 ms。全ページで約 1,000 万ブロック(導出) | 文書 7.6「読み飛ばす行もサーバの中で計算される」 |
| 5 | 一意制約なし | 二重付与を止めるのは NOT EXISTS だけ。ガード無しで 272〜315 枚 | 確認と挿入の間に隙間。一意性は制約で |
| 6 | 進捗なし | 再実行は「残りの半分」だけ配り、運用には「進んでいる」ように見える | Argo の retry は最初からやり直す |
| 7 | 照合せず done | 6 万人漏れて終了コード 0。気づいたのは CS の 214 件 | 完了=ループが終わった、になっている |
| 8 | 挿入への無防備(降順にした日) | 新規登録が先頭に入ると全員がずれて二重(導出・未測定) | Winand "No Offset" |
ステージングが緑だった理由・再実行は何回で終わるか
ステージングの対象 845 人は 1 ページ(1,000 件)に収まった。2 ページ目が無ければ #1 は起きない。対象が 1 ページを 1 人でも超えると漏れる(staging_out.txt)。
| 会員数 | 対象 | 届いた | 漏れ |
|---|---|---|---|
| 2,000 | 845 | 845 | 0 |
| 2,500 | 1,036 | 1,000 | 36 |
| 5,000 | 2,027 | 1,027 | 1,000 |
| 20,000 | 7,982 | 4,000 | 3,982 |
3つの提案の評価(実測、会員 30 万人・3 回)
| 案 | 漏れ | 二重 | 他に起きること | 判定 |
|---|---|---|---|---|
| Bad(外の更新あり) | 59,864〜59,931 | 0 | 深いページで最大 179,591 ブロック | — |
| 後輩C(ガードを外す) | 2,612〜2,641 | 272〜301 | #2・#3 が表に出る。再実行すると全員に二重 | 却下 |
| 後輩B(REPEATABLE READ) | 59,719〜59,931 | 0 | 自分の挿入は自分に見えるので直らない。VACUUM が古い行を 1 件も回収できない(回収 0/回収できない 19,191)。Good は 11,337 回収・0 残り | 却下 |
| 後輩A(全件 fetchall) | 0 | 0 | 翌日の再開で対象外 58 人に配る(テスト)。二重を DB が止めない。メモリは件数に比例(12 万件 10.6 MiB/120 万件 106.3 MiB/300 万件 263.6 MiB) | 今の規模なら成立。採るなら as_of の固定とセット |
| Good | 0 | 0 | 深さによらず 17 ブロック、ページの合間にトランザクションを持たない | 採用 |
模範解答(改善後コード)
全文は 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 に流す)
| テスト | Good | Bad | 後輩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 進捗と付与を別トランザクションで | 緑(生存) | 赤 |
実行例(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で検索
ポイント解説
- OFFSET は「位置」、キーセットは「値」で続きを指す: 位置は集合や並びが変わると別の行を指す。読みながら書くバッチでは値で指す
- 自分の書き込みも外の変化: 6 万人の漏れは自分の INSERT のせい。REPEATABLE READ が効かないのも、自分の書き込みは自分に見えるから
- 「完了」は照合で定義する: 照合があれば 02:00 の時点で Failed になっていた
- 安全網は破れる状況を作ってテストする: 一意制約・同じトランザクション・行ロックは互いを隠す
- 書籍との対応: 第 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 枚。ただしキーセットも統計が無ければ索引を使わない。