paginate

Cursor-lapozás

Keyset-lapozás opak cursorral és Postgres tuple-összehasonlításos seekkel, nagy vagy gyorsan növő táblákhoz.

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:

internal/modules/shop/surfaces/api/http/handler.go
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
}
internal/modules/shop/surfaces/api/service/service.go
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)
}
internal/modules/shop/repository/products.go
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/DESC tuple-összehasonlítással nem fejezhető ki;
  • NOT NULL minden kulcsoszlop: egy NULL tuple-elem az egész összehasonlítást NULL-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 BY ellen 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ártott OFFSET esetén is kellene.
A cursor-lapozás ebben a verzióban csak előre halad: nincs 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" gombműködik, de drift-veszélyes✔ (ez a natív esete)
nagy tábla, mély lapozásO(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 offset Page[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.
Copyright © 2026