Cursor-lapozás
Az offset lapozásnak két strukturális baja van nagy vagy gyakran módosuló táblákon: az OFFSET n minden kérésnél kiszámolja és eldobja az első n sort (O(n), a 400. oldal 8000 sor átfésülése), és konkurens beszúrások alatt a lapok elcsúsznak: ugyanaz a sor két oldalon jelenik meg, vagy egyiken sem. A cursor- (keyset-) lapozás mindkettőt megoldja: a kliens egy opak kurzort kap, ami az utoljára látott sor rendezőkulcsát kódolja, és a következő lap egy indexelt seekkel (WHERE (created_at, id) < (...)) folytatódik onnan, közel konstans költséggel, akármilyen mélyre lapozol.
func NormalizeCursor(cursor *string, limit *int, sort string, opts ...Option) (CursorParams, error)
type CursorParams struct{ Limit int /* + dekódolt kulcsok */ }
func (p CursorParams) HasCursor() bool // van-e cursor, azaz nem első oldal-e
func (p CursorParams) DecodeKey(dests ...any) error // kulcsértékek kiolvasása, Scan-szerűen
func (p CursorParams) FetchLimit() int // Limit+1, ebből tudjuk, van-e következő oldal
func (p CursorParams) NextCursor(keys ...any) (string, error)
func NewCursorPage[T any](rows []T, p CursorParams, key func(last T) []any) (CursorPage[T], error)
type CursorPage[T any] struct {
Items []T
NextCursor string // "" az utolsó oldalon
HasMore bool
}
var ErrInvalidCursor // errs definition: HTTP 400, kód: paginate_invalid_cursor
A NormalizeCursor a limit-et ugyanazokkal a WithDefaultPerPage/WithMaxPerPage opciókkal szorítja határok közé, mint a Normalize (a WithMaxPage-et ignorálja, itt nincs page number). A sort argumentum pontosan az az ORDER BY kifejezés legyen, amit a query használ: a kiadott cursorba ennek ujjlenyomata kerül, és egy más rendezéshez kiadott cursor később elutasításra kerül.
A shop terméklistája, végig
A GET /api/v1/shop/products?cursor=...&limit=... teljes útja a mintaalkalmazásban, handler → service → repository → válasz:
func (h *Handler) ListProducts(ctx context.Context, req gen.ListProductsRequest) (gen.ListProductsResponse, error) {
page, err := h.svc.ListProducts(ctx, req.Params.Cursor, req.Params.Limit)
if err != nil {
return nil, service.ErrListProducts.Wrap(err, "api: list products")
}
return mapper.ToProductList(page), nil // paginate.CursorPage[Product] → gen.CursorPageProduct
}
const productSort = "created_at DESC, id DESC" // egyezzen a repository ORDER BY-jával
func (s *Service) ListProducts(ctx context.Context, cursor *string, limit *int) (paginate.CursorPage[Product], error) {
p, err := paginate.NormalizeCursor(cursor, limit, productSort)
if err != nil {
return paginate.CursorPage[Product]{}, err // ErrInvalidCursor → HTTP 400
}
return s.products.List(ctx, p)
}
func (r *Products) List(ctx context.Context, p paginate.CursorParams) (paginate.CursorPage[Product], error) {
query := `SELECT id, name, price_cents, created_at FROM products`
args := []any{p.FetchLimit()}
if p.HasCursor() {
var createdAt time.Time
var id string
if err := p.DecodeKey(&createdAt, &id); err != nil {
return paginate.CursorPage[Product]{}, err
}
query += ` WHERE (created_at, id) < ($2, $3)`
args = append(args, createdAt, id)
}
query += ` ORDER BY created_at DESC, id DESC LIMIT $1`
rows, err := r.db.Query(ctx, query, args...)
if err != nil {
return paginate.CursorPage[Product]{}, errs.Wrap(err, "products: list")
}
products, err := pgx.CollectRows(rows, pgx.RowToStructByName[Product])
if err != nil {
return paginate.CursorPage[Product]{}, errs.Wrap(err, "products: scan")
}
return paginate.NewCursorPage(products, p, func(last Product) []any {
return []any{last.CreatedAt, last.ID}
})
}
A mechanika kulcsa a +1 sor: a query a FetchLimit()-et (= Limit+1) kéri, a NewCursorPage pedig levágja a lapot Limit elemre. Ha az extra sor megvolt, HasMore = true, és a NextCursor a ténylegesen visszaadott utolsó elem kulcsából épül: külön COUNT query nincs. A key függvény pontosan azokat az értékeket adja vissza, pontosan abban a sorrendben, amiket az ORDER BY / seek-predikátum használ.
A seek-predikátum feltételei
A Postgres sor-érték összehasonlítás ((created_at, id) < ($2, $3)) csak akkor helyes és gyors, ha:
- egyirányú a rendezés minden kulcsoszlopon: vegyes
ASC/DESCtuple-összehasonlítással nem fejezhető ki; NOT NULLminden kulcsoszlop: egyNULLtuple-elem az egész összehasonlítástNULL-lá teszi, ami észrevétlenül kihagyja a sort minden oldalról;- egyedi tiebreaker az utolsó kulcs: jellemzően a primary key, hogy egy nem egyedi vezető oszlop (pl.
created_at) ne okozzon kihagyott vagy duplikált sorokat az oldalhatárokon; - az
ORDER BY-nak megfelelő összetett index létezik (a shopban:CREATE INDEX ON products (created_at DESC, id DESC)), hogy a seek index-scan maradjon.
Az opak cursor
A kliens felé a cursor átlátszatlan string: base64url-kódolt, verziózott JSON boríték a kulcsértékekkel és a rendezési spec ujjlenyomatával. A NormalizeCursor ErrInvalidCursor-ral (HTTP 400, kód: paginate_invalid_cursor) utasítja el azt a cursort, ami nem dekódolható, más verzióhoz készült, vagy más rendezéshez lett kiadva. Sosem esik vissza némán az első oldalra, mert az elrejtené a kliens hibáját. Két következmény:
- egy végpont default rendezésének megváltoztatása dizájn szerint érvényteleníti a kint lévő cursorokat;
- a cursor nem titkosított és nincs aláírva: kulcsértékei (időbélyeg, id) nyíltan dekódolhatók, és semmi nem akadályozza meg a klienst abban, hogy kézzel átírjon egyet. Kezeld hatékony folytatási tokenként, ne manipuláció-biztos jogosultságként: ne tegyél bele olyat, amit a kliens nem láthat, és ne használd jogosultság-határként. A sort-ujjlenyomat csak azt védi, hogy a cursort ne lehessen egy másik
ORDER BYellen visszajátszani, azt nem, hogy a kliens meghamisítsa a kulcsértékeket; ha egy hamisított érték olyan sorra mutat, amit a hívó nem láthatna, azt a query saját jogosultsági szűrője (egy tenant- vagy tulajdonos-WHERE) kapja el, pontosan úgy, ahogy egy kézzel gyártottOFFSETesetén is kellene.
before paraméter visszafelé lapozáshoz. A CursorPage[T] a shared/paginator.tspCursorPage<T> modelljének tükre.Mikor melyiket
Offset (Normalize) | Cursor (NormalizeCursor) | |
|---|---|---|
| oldalszámos UI („3. oldal a 12-ből") | ✔ (Total-t ad) | ✘ (nincs oldalszám, se összdarabszám) |
| végtelen görgetés / „továbbiak" gomb | működik, de drift-veszélyes | ✔ (ez a natív esete) |
| nagy tábla, mély lapozás | O(n), degradálódik | ✔ (közel konstans) |
| konkurens insertek alatt stabil lapok | ✘ (csúszik) | ✔ |
| tetszőleges oldalra ugrás | ✔ | ✘ (csak szekvenciális) |
Ökölszabály a shopból: a publikus terméklista (nagy, nő, végtelen görgetés) cursorral megy; egy admin-oldali, pár száz soros lista oldalszámokkal kényelmesebb offsettel.
Használt patternek
- Opaque, manipuláció-védett cursor (base64url + sha256 sort-fingerprint): Design patternek. A „manipuláció-védett" itt kifejezetten a fenti sort-ujjlenyomat-ellenőrzésre vonatkozik, nem a kulcsértékekre, amik nyíltan utaznak; lásd a fenti integritási megjegyzést.
- Generikus eredmény-wrapper (
CursorPage[T]), az offsetPage[T]-jének testvére: lásd az Áttekintés oldalt.
Merre tovább
- Áttekintés: offset lapozás (
Normalize,Page[T]), telepítés és az önálló példa.