← 中文资源

简体中文版本

GORM 键集分页查询 PostgreSQL

用 GORM 实现键集分页:复合索引排序、锚点元组条件过滤、多取一条判定续页,插入并发下页结果依然稳定。

阅读英文原文

示例说明

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)
}