功能说明
键集分页(keyset pagination)用“上一页最后一条记录的排序键”作为下一页起点,避免传统 OFFSET 在数据插入或删除时出现重复、漏项。这段代码把游标编码成 base64url 的 JSON,对客户端呈现为不透明字符串,并按 CreatedAt、ID 两个字段稳定排序。
它适合信息流、审计日志、订单列表等持续有新数据写入的接口。ID 作为第二排序键,可以在时间戳相同时打破平局,保证顺序确定。
关键代码
encodeCursor 把 Cursor 序列化成 JSON,再用 base64.RawURLEncoding 去掉填充字符,得到可直接放进查询参数的字符串。decodeCursor 反向解码,并检查 ID 和 CreatedAt 是否存在,防止空游标被误当作有效起点。
pageAfter 先复制并排序输入,避免修改调用方切片;随后跳过不满足 afterCursor 的记录,收集到 limit 条为止。afterCursor 先比较时间,再在时间相等时比较 ID。
使用注意
游标只是编码,不是签名,客户端可以解码甚至篡改内容。服务端必须重新校验字段范围和权限,敏感信息不能放进游标。排序字段一旦改变,旧游标就失去意义,应通过版本号或拒绝策略处理。
ID 必须单调且唯一,否则平局逻辑不成立。分页大小需要设置上限,避免一次拉取过多数据。pageAfter 每次都会复制并排序整个集合,生产环境应让数据库按相同顺序执行 WHERE (created_at, id) > (?, ?) ORDER BY ... LIMIT ?。
验证方式
示例按时间生成四条记录,第一页返回 ID 1、2;用第二条记录构造游标后,第二页返回 3、4。代码断言两页长度和首条 ID,并验证非法 base64 游标会返回错误。可以把两条记录的创建时间改成相同,确认 ID 仍能保证顺序稳定。
代码示例
package main
import (
"encoding/base64"
"encoding/json"
"fmt"
"sort"
"time"
)
type Cursor struct {
CreatedAt time.Time
ID int
}
type Item struct {
ID int
CreatedAt time.Time
Name string
}
func encodeCursor(cursor Cursor) string {
payload, err := json.Marshal(cursor)
if err != nil {
panic(err)
}
return base64.RawURLEncoding.EncodeToString(payload)
}
func decodeCursor(raw string) (Cursor, error) {
var cursor Cursor
payload, err := base64.RawURLEncoding.DecodeString(raw)
if err != nil {
return cursor, fmt.Errorf("decode cursor: %w", err)
}
if err := json.Unmarshal(payload, &cursor); err != nil {
return cursor, fmt.Errorf("parse cursor: %w", err)
}
if cursor.ID <= 0 || cursor.CreatedAt.IsZero() {
return cursor, fmt.Errorf("cursor is missing fields")
}
return cursor, nil
}
func afterCursor(item Item, cursor Cursor) bool {
if item.CreatedAt.After(cursor.CreatedAt) {
return true
}
return item.CreatedAt.Equal(cursor.CreatedAt) && item.ID > cursor.ID
}
func pageAfter(items []Item, cursor *Cursor, limit int) []Item {
if limit <= 0 {
return nil
}
ordered := append([]Item(nil), items...)
sort.Slice(ordered, func(left, right int) bool {
if ordered[left].CreatedAt.Equal(ordered[right].CreatedAt) {
return ordered[left].ID < ordered[right].ID
}
return ordered[left].CreatedAt.Before(ordered[right].CreatedAt)
})
page := make([]Item, 0, limit)
for _, item := range ordered {
if cursor != nil && !afterCursor(item, *cursor) {
continue
}
page = append(page, item)
if len(page) == limit {
break
}
}
return page
}
func main() {
base := time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC)
items := []Item{
{ID: 1, CreatedAt: base.Add(1 * time.Minute), Name: "alpha"},
{ID: 2, CreatedAt: base.Add(2 * time.Minute), Name: "beta"},
{ID: 3, CreatedAt: base.Add(3 * time.Minute), Name: "gamma"},
{ID: 4, CreatedAt: base.Add(4 * time.Minute), Name: "delta"},
}
first := pageAfter(items, nil, 2)
encoded := encodeCursor(Cursor{CreatedAt: first[1].CreatedAt, ID: first[1].ID})
cursor, err := decodeCursor(encoded)
if err != nil {
panic(err)
}
second := pageAfter(items, &cursor, 2)
fmt.Println(encoded)
fmt.Println(first[0].ID, first[1].ID, second[0].ID, second[1].ID)
if len(first) != 2 || len(second) != 2 || first[0].ID != 1 || second[0].ID != 3 {
panic("unexpected page contents")
}
if _, err := decodeCursor("not-base64!"); err == nil {
panic("invalid cursor should fail")
}
}