← 中文资源

简体中文版本

为列表 API 构建稳定键集游标

把键集游标编码为不透明 base64 JSON,按创建时间和 ID 稳定翻页。

阅读英文原文

功能说明

键集分页(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")
	}
}