📚

go-keyset — ORM に依存しない型安全な Keyset Pagination を Go で

に公開

この記事は ChatGPT を用いて書かれた

はじめに

https://github.com/mickamy/go-keyset

Offset ページネーション (OFFSET n LIMIT m) は単純だけど、データ量が増えるとすぐ破綻する。大きなオフセットはスキャンコストが高く、同時更新があるとページの境界がずれていく。

それに対して、Keyset Pagination(カーソルページネーション)は「安定したキー(id や created_at)」を使って次ページを決定する仕組み。つまり「前回の最後のレコードを覚えておいて、その続きから取る」。


問題意識

Keyset ページネーションは概念としては単純でも、実装が地味に難しい。

  • WHERE 句の条件(> or <)が ORDER と Dir の組み合わせで変わる
  • ASC/DESC を反転した場合に結果を再ソートし直す必要がある
  • 複合キー(time + id)の安定条件 (t < ?) OR (t = ? AND id < ?)
  • カーソルをオペークにしたい(base64url、BigEndian、HMACなど)

これらを毎回手で書くのは苦痛。その「毎回バグる定型処理」だけを抜き出して部品化したのが go-keyset


ライブラリ構成

go-keyset/
  keyset/  — コア。Page 構造体、カーソル Encode/Decode、Order など。
  kgorm/   — GORM 用スコープ。PageByTimeAndID など。
  ksql/    — database/sql 用。SQL 文字列生成と引数展開。
  examples/— PostgreSQL 実行例。

使い方(GORM 編)

tx := kgorm.FindPage(
    kgorm.PageByTimeAndID(
        db.Model(&Post{}),
        page,
        keyset.Descending,
        "created_at", "id",
    ),
    page,
    &posts,
)
  • PageByTimeAndID が WHERE / ORDER を自動生成
  • FindPage が DirPrev のとき結果を自動で反転してくれる
  • カーソルは base64url で安全に URL / JSON に渡せる

使い方(Raw SQL 編)

base := `SELECT id, title, created_at FROM posts`
query, args := ksql.QueryByTimeAndID(base, page, keyset.Descending, "created_at", "id", ksql.PlaceholderDollar)
rows, _ := db.QueryContext(ctx, query, args...)

ORM に縛られない、純粋な SQL 向けユーティリティ。sqlx, pgx, あるいは内部的に SQL を動的組み立てするような場面にもそのまま使える。


双方向ページング

  • DirNext → 次ページを取得(ORDER そのまま)
  • DirPrev → 前ページを取得(ORDER 反転 + 表示時 Reverse)
posts = keyset.NormalizePageResult(page, posts)

これを呼ぶだけで表示順が常に一定に整う。


カーソルの仕組み

Type Encode Decode 備考
int64 EncodeInt64Cursor DecodeInt64Cursor 8byte BigEndian
time EncodeTimeCursor DecodeTimeCursor UTC 固定
(time,id) EncodeTimeAndInt64Cursor DecodeTimeAndInt64Cursor 安定複合キー用

Cursors は base64url 形式で、URL や JSON に安全に埋め込める。


今後の展望

  • ent / sqlc / uuid / ulid サポート
  • 署名付きカーソル (HMAC + TTL)

まとめ

go-keyset は “シンプルな箇所を安定化する” ための小さなツールキット。大量データや双方向スクロールを扱う Go アプリなら、これ一枚噛ませておくとだいぶ安心できる。

Discussion