示例说明
OFFSET 分页的两宗罪:深页要扫过全部前序行性能崩塌,翻页间隙的新数据造成重页漏页。键集分页把 WHERE 锚定在上一页末条的复合键上——created_at 与 id 的元组比较保证全序无歧义,下一页永远从锚点之后开始,与数据增删解耦。复合索引与排序方向完全对应,锚点查询是索引定位而非扫描。
技术要点
- 复合键元组条件锚定下一页
- 多取一条判定 HasNext 免 count
- 复合索引与排序方向一致
代码解读
结构体的两个索引声明合成 idx_customer_created 复合索引;keysetPage 的锚点条件是 OR 组合的元组比较——时间更早,或时间相同但 ID 更小。limit 加一取出探测行后裁回页大小。Page 返回下一锚点坐标供调用方直接续查。
使用建议
排序键要单调且低重复,时间戳配自增 ID 是黄金组合。游标可编码签名防客户端篡改。超深翻页引导用户改用筛选。
代码示例
// PostgreSQL keyset pagination with gorm
package main
import (
"fmt"
"log"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
type Invoice struct {
ID uint `gorm:"primaryKey"`
Customer string `gorm:"index:idx_customer_created,priority:1"`
CreatedAt int64 `gorm:"index:idx_customer_created,priority:2;autoCreateTime:false"`
Amount int
}
type Page struct {
Items []Invoice
NextID uint
NextAt int64
HasNext bool
}
func keysetPage(db *gorm.DB, customer string, limit int,
afterID uint, afterAt int64,
) (Page, error) {
query := db.Where("customer = ?", customer).
Order("created_at DESC, id DESC")
if afterID != 0 {
// 复合键条件:严格晚于锚点 (created_at, id)
query = query.Where(
"(created_at < ?) OR (created_at = ? AND id < ?)",
afterAt, afterAt, afterID,
)
}
rows := make([]Invoice, 0, limit+1)
if err := query.Limit(limit + 1).Find(&rows).Error; err != nil {
return Page{}, fmt.Errorf("keyset query: %w", err)
}
hasNext := len(rows) > limit
if hasNext {
rows = rows[:limit]
}
page := Page{Items: rows, HasNext: hasNext}
if len(rows) > 0 {
last := rows[len(rows)-1]
page.NextID = last.ID
page.NextAt = last.CreatedAt
}
return page, nil
}
func main() {
db, err := gorm.Open(postgres.Open(
"host=localhost user=app dbname=billing"),
&gorm.Config{Logger: logger.Discard},
)
if err != nil {
log.Fatal(err)
}
page, err := keysetPage(db, "acme", 20, 0, 0)
if err != nil {
log.Fatal(err)
}
next, err := keysetPage(db, "acme", 20, page.NextID, page.NextAt)
fmt.Println(len(page.Items), page.HasNext, len(next.Items), next.HasNext, err)
}