分页问题本质是“要跳过多少行”
offset 分页写起来最省事,代价却随页码线性增长:数据库必须先定位并跳过 offset 行,再返回 limit 行。第 1 页和第 5000 页的 SQL 长得一样,执行成本差三个数量级。keyset 分页把“第几页”换成“上一页最后一行的排序键”,用 (created_at, id) < (?, ?) 直接定位起点,成本变成 log(n) + limit。
本节在 arch-lab 的内存订单数据集上真实跑这两种分页,报告扫描行数和耗时。需要先说明:内存切片没有磁盘页、网络往返和行解码,所以本地耗时只反映切片复制与二分查找,不能直接外推成数据库延迟;可迁移的结论是扫描行数比例。本节没有连接 PostgreSQL,所有数据库行为都是模型描述。
Offset 分页:第二返回值是扫描行数
OffsetPage 返回 (Page, scanned)。scanned 是数据库需要检查的行数,不是复制进页面的行数:offset=100000, limit=20 时它是 100020,即使页面只有 20 条。这个数字是后续容量估算和接口限额的依据,必须和响应体分开记录。
// file: internal/perf/paging/pagination.go
// tier: L1
// verify: go test ./internal/perf/paging/ -count=1 -v
package paging
import "time"
// Item is one order row. CreatedAt+ID is the unique sort key: two orders can
// share a timestamp, so ID breaks the tie and keeps the cursor total.
type Item struct {
ID int64
CustomerID int64
CreatedAt time.Time
TotalCents int64
}
type Cursor struct {
CreatedAt time.Time
ID int64
}
type Page struct {
Items []Item
Cursor Cursor
More bool
}
// NewDataset returns items sorted newest first. The generator is deterministic
// so scan counts in tests do not depend on time or randomness.
func NewDataset(n int) []Item {
base := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
out := make([]Item, n)
for i := range out {
out[i] = Item{
ID: int64(n - i),
CustomerID: int64(i%1000 + 1),
CreatedAt: base.Add(-time.Duration(i) * time.Second),
TotalCents: int64(1000 + i%500),
}
}
return out
}
// OffsetPage must skip offset rows before returning anything. The second
// return value is the number of rows a database would examine, not the number
// copied into the page.
func OffsetPage(items []Item, offset, limit int) (Page, int) {
if offset < 0 {
offset = 0
}
if limit < 1 {
limit = 1
}
start := min(offset, len(items))
end := min(start+limit, len(items))
p := Page{Items: append([]Item(nil), items[start:end]...), More: end < len(items)}
if end > start {
last := items[end-1]
p.Cursor = Cursor{CreatedAt: last.CreatedAt, ID: last.ID}
}
return p, start + (end - start)
}
// KeysetPage binary-searches the first row older than the cursor, so its cost
// grows with log(n)+limit instead of offset+limit.
func KeysetPage(items []Item, after Cursor, limit int) (Page, int) {
if limit < 1 {
limit = 1
}
lo, hi, scanned := 0, len(items), 0
for lo < hi {
mid := (lo + hi) / 2
scanned++
if older(items[mid], after) {
hi = mid
} else {
lo = mid + 1
}
}
end := min(lo+limit, len(items))
p := Page{Items: append([]Item(nil), items[lo:end]...), More: end < len(items)}
if end > lo {
last := items[end-1]
p.Cursor = Cursor{CreatedAt: last.CreatedAt, ID: last.ID}
}
scanned += end - lo
return p, scanned
}
func older(it Item, c Cursor) bool {
if c.CreatedAt.IsZero() {
return true // zero cursor means "before the first page"
}
if it.CreatedAt.Equal(c.CreatedAt) {
return it.ID < c.ID
}
return it.CreatedAt.Before(c.CreatedAt)
}
游标必须包含排序的最后一列。只传 created_at 时,同一秒创建的多条订单会在页边界重复或丢失;Cursor{CreatedAt, ID} 用 ID 做决胜列,排序键才唯一。测试里的 TestOffsetAndKeysetAgree 正是逐页比较两种实现返回的 ID,确保翻页不重不漏。
深分页真实对照
数据集 200,000 行,取 offset=100000, limit=20。offset 路径扫描 100,020 行,keyset 路径从上一页最后一行开始,只做 18 次二分比较加 20 行读取,共 37 行。命令为 go test ./internal/perf/paging/ -run TestDeepPaginationScanCount -count=1 -v,原始输出在 .local/reports/be-arch/be-arch-perf-11-2-paging-tests.txt。
n=200000 offset=100000 limit=20 offset_scanned=100020 keyset_scanned=37
| 指标 | offset | keyset | 比例 |
|---|---|---|---|
| 扫描行数(模型) | 100,020 | 37 | 约 2,703 倍 |
| 返回行数 | 20 | 20 | 相同 |
| 内存耗时(3 次运行) | 137.1–137.8 ns/op | 259.2–267.2 ns/op | keyset 约 1.9 倍慢 |
| 分配 | 1024 B/op, 1 alloc | 1024 B/op, 1 alloc | 相同 |
内存基准里 keyset 反而更慢,因为 offset 实现只是切了 20 个元素的切片,并没有真的逐行读取被跳过的 10 万行;keyset 却要做 18 次比较。这恰好说明本地基准测的是 Go 切片成本,不是数据库成本。真实数据库里被跳过的行要读页、解码、检查可见性,扫描行数才会转化成延迟。于是可迁移的结论只有扫描行数比例;要证明 keyset 在数据库上更快,必须用 PostgreSQL 的 EXPLAIN (ANALYZE, BUFFERS) 对比,本节没有做,也不声称做过。基准命令为 go test ./internal/perf/paging/ -run '^$' -bench . -benchmem -count=3,机器为 macOS darwin/arm64、Apple M1 Pro、Go 1.26.0,原始输出在 .local/reports/be-arch/be-arch-perf-11-2-paging-bench.txt。三次运行只代表本机单次会话,不是稳定基准。
N+1 检测与批量加载
订单详情需要客户信息时,最直接的写法是查出 20 个订单,再为每个订单查一次客户:1+20=21 次查询。数据量一大,往返延迟就把接口拖垮。检测器把字面量归一化成 ?,统计同一形状在当前请求内出现的次数;批量加载把 N 次客户查询换成一次 IN 查询,总共 2 次。
// file: internal/perf/paging/batch.go
// tier: L1
// verify: go test ./internal/perf/paging/ -count=1 -v
package paging
import (
"errors"
"fmt"
"regexp"
"strings"
"time"
)
type Customer struct {
ID int64
Name string
}
// Store is an in-memory stand-in for the order and customer tables. Queries
// counts logical round trips so an N+1 pattern is measurable without a DB.
type Store struct {
items []Item
customers map[int64]Customer
Queries int
}
func NewStore(items []Item) *Store {
customers := make(map[int64]Customer, 1000)
for i := int64(1); i <= 1000; i++ {
customers[i] = Customer{ID: i, Name: fmt.Sprintf("customer-%d", i)}
}
return &Store{items: items, customers: customers}
}
func (s *Store) OrdersByCustomer(cid int64) []Item {
s.Queries++
var out []Item
for _, it := range s.items {
if it.CustomerID == cid {
out = append(out, it)
}
}
return out
}
func (s *Store) Customer(id int64) Customer {
s.Queries++
return s.customers[id]
}
func (s *Store) CustomersByIDs(ids []int64) map[int64]Customer {
s.Queries++
out := make(map[int64]Customer, len(ids))
for _, id := range ids {
if c, ok := s.customers[id]; ok {
out[id] = c
}
}
return out
}
type OrderView struct {
OrderID int64
Customer Customer
}
// LoadNaive issues one customer query per order: the classic N+1.
func LoadNaive(s *Store, cid int64) []OrderView {
orders := s.OrdersByCustomer(cid)
out := make([]OrderView, 0, len(orders))
for _, o := range orders {
out = append(out, OrderView{OrderID: o.ID, Customer: s.Customer(o.CustomerID)})
}
return out
}
// LoadBatch keeps the order query and replaces N customer queries with one
// IN-style lookup. Two round trips regardless of N.
func LoadBatch(s *Store, cid int64) []OrderView {
orders := s.OrdersByCustomer(cid)
ids := make([]int64, 0, len(orders))
for _, o := range orders {
ids = append(ids, o.CustomerID)
}
byID := s.CustomersByIDs(ids)
out := make([]OrderView, 0, len(orders))
for _, o := range orders {
out = append(out, OrderView{OrderID: o.ID, Customer: byID[o.CustomerID]})
}
return out
}
var reValue = regexp.MustCompile(`'[^']*'|\b\d+\b`)
// Normalize replaces literals so "WHERE id = 7" and "WHERE id = 8" share a
// shape. It is intentionally simple and does not parse SQL.
func Normalize(query string) string { return reValue.ReplaceAllString(query, "?") }
type Detector struct {
limit int
count map[string]int
order []string
}
func NewDetector(limit int) *Detector {
if limit < 1 {
limit = 1
}
return &Detector{limit: limit, count: make(map[string]int)}
}
func (d *Detector) Record(query string) {
k := Normalize(query)
if _, ok := d.count[k]; !ok {
d.order = append(d.order, k)
}
d.count[k]++
}
// Repeated returns shapes seen at least limit times in the current request.
func (d *Detector) Repeated() map[string]int {
out := make(map[string]int)
for _, k := range d.order {
if d.count[k] >= d.limit {
out[k] = d.count[k]
}
}
return out
}
var (
ErrLimitTooLarge = errors.New("paging: limit exceeds 100")
ErrOffsetTooDeep = errors.New("paging: offset exceeds 10000, use keyset")
)
// ValidateListParams is the order-list API contract. Deep offsets are rejected
// instead of silently becoming slower every page.
func ValidateListParams(limit, offset int) error {
if limit < 1 || limit > 100 {
return ErrLimitTooLarge
}
if offset < 0 || offset > 10000 {
return ErrOffsetTooDeep
}
return nil
}
// Budget ties batch size, connection pool, timeout and retries together. A
// valid plan keeps retries inside the request budget and every concurrent
// batch inside the pool.
type Budget struct {
PoolSize int
Workers int
QueryTimeout time.Duration
RequestBudget time.Duration
Retries int
BatchSize int
Concurrency int
}
func (b Budget) Validate() []error {
var errs []error
if b.PoolSize < 1 {
errs = append(errs, errors.New("paging: pool size must be positive"))
}
if b.Workers < 1 || (b.PoolSize > 0 && b.Workers > 4*b.PoolSize) {
errs = append(errs, errors.New("paging: workers exceed 4x pool size"))
}
if b.QueryTimeout <= 0 || b.RequestBudget <= 0 {
errs = append(errs, errors.New("paging: timeouts must be positive"))
}
if b.Retries < 0 || b.Retries > 3 {
errs = append(errs, errors.New("paging: retries must be within 0..3"))
}
if b.QueryTimeout*time.Duration(b.Retries+1) > b.RequestBudget {
errs = append(errs, errors.New("paging: retries cannot fit request budget"))
}
if b.BatchSize < 1 || b.BatchSize > 500 {
errs = append(errs, errors.New("paging: batch size must be within 1..500"))
}
if b.Concurrency > 0 && b.BatchSize > 0 && b.Concurrency*b.BatchSize > b.PoolSize {
errs = append(errs, errors.New("paging: concurrent batches exceed pool"))
}
return errs
}
// Chunks splits IDs into batches; the last batch may be shorter.
func Chunks(ids []int64, size int) [][]int64 {
if size < 1 {
size = 1
}
var out [][]int64
for len(ids) > 0 {
n := min(size, len(ids))
out = append(out, append([]int64(nil), ids[:n]...))
ids = ids[n:]
}
return out
}
func JoinQuery(ids []int64) string {
parts := make([]string, len(ids))
for i, id := range ids {
parts[i] = fmt.Sprint(id)
}
return "SELECT id, name FROM customers WHERE id IN (" + strings.Join(parts, ",") + ")"
}
测试结果:20 个订单的朴素加载 naive_queries=21,批量加载 batch_queries=2;检测器把 20 条只有 ID 不同的查询归并为一个形状并计数 20。原始输出在 .local/reports/be-arch/be-arch-perf-11-2-paging-tests.txt,命令为 go test ./internal/perf/paging/ -count=1 -v。
批大小、连接池、超时与重试的约束
四个参数不能分别调。设查询超时 T、重试次数 R、请求预算 B、连接池 P、并发批数 C、批大小 S,必须同时满足:
T × (R + 1) ≤ B 重试链不能超过上游预算
C × S ≤ P 每个并发批占用一个连接
S ≤ 500 IN 列表和单次事务的上限
R ≤ 3 重试预算的上限
第一条最容易被忽略:T=300ms, R=2, B=500ms 时,最坏情况 900ms,请求注定在上游超时后才返回,还占着一个连接。第二条决定并发批不会把连接池打满;C=3, S=10, P=20 就是无效配置。Validate 把这两条写成测试断言,配置加载时直接拒绝,而不是等线上排队。工作线程数是另一层:Workers 远大于 P 只会把等待从数据库搬到应用队列。建议目标 Workers ≤ P,代码里的 4×P 只是拒绝病态配置的硬护栏,不是推荐值。数据库总连接数还要按实例分摊:数据库 max_connections=100、应用 4 个实例、预留 20%,则每实例池上限约 100×0.8/4=20。
订单列表接口的参数上限
arch-lab 的 GET /orders?limit=&offset= 采用硬上限,而不是“尽量支持”:limit 1–100(默认 20),offset 0–10000;超过 10000 返回参数错误并提示改用 before_created_at + before_id 游标。10000 不是理论最优值,而是当前 SLO 下“最坏扫描 10020 行仍能在 50ms 查询预算内完成”的示例值,必须在目标数据库上压测确认,不能照抄。批量接口的上限独立于分页:单次最多 500 个 ID,超过就由 Chunks 拆分;每个批次仍然受 C×S≤P 约束。重试只用于幂等读,订单写入的重试必须依赖幂等键,不能套用同一套预算。
常见错误与边界
- 只传
created_at做游标。同一时间戳的多条订单会重复或丢失,必须带唯一决胜列。 - 把内存基准当数据库基准。本地 137ns 对 260ns 的差异不说明 keyset 更慢;扫描行数才是可迁移指标。
- 把
offset上限设成 100 万。深分页的真正成本是扫描,不是返回;上限应和查询预算绑定。 - 重试次数和请求预算脱钩。
T×(R+1)>B的配置必须在启动时拒绝。
小结
本节把分页和批处理都落到可运行代码:offset 与 keyset 在 20 万行数据上分别扫描 100,020 和 37 行,后者少约 2,703 倍;内存耗时反而是 keyset 更慢,说明本地基准不能替代数据库测量。N+1 检测器把 20 条同形状查询归并计数,批量加载把 21 次查询降到 2 次。批大小、连接池、超时和重试通过 T×(R+1)≤B 与 C×S≤P 绑定,订单列表的 limit/offset 上限则必须由查询预算反推。
阅读导航:上一节:11.1 索引、查询与慢 SQL · 下一节:11.3 读写分离的延迟预算与一致性兜底 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。