📚
go-keyset — ORM に依存しない型安全な Keyset Pagination を Go で
この記事は ChatGPT を用いて書かれた
はじめに
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