IlmHamroh
JavaScript Full-stack/26-qism. SQL va PostgreSQL64/64-dars23 daqiqa
Mundarija (27)

PostgreSQL keyset pagination: katta OFFSET nega sekin va kursor bilan sahifalash

Qisqacha: OFFSET 1000000 — "birinchi million qatorni o'tkazib yubor" degani, lekin baza ularni baribir o'qiydi. Kanonda bu 350 ms oldi. Keyset pagination esa "oldingi sahifaning oxirgi qatoridan keyingilarni ber" deydi: WHERE (yaratilgan_vaqt, id) < ($1, $2). Mos indeks bo'lsa, baza to'g'ridan-to'g'ri kerakli joyga sakraydi — 0.2 ms, sahifa raqamidan qat'i nazar. Bonus: sahifalar orasida yangi yozuv qo'shilsa, OFFSET qatorni takrorlaydi, keyset — yo'q. Narxi: "17-sahifaga o'tish" imkoni yo'q, faqat "keyingi".

Bu darsda

  • Katta OFFSET nega sekin ekanini rejada ko'rsatasiz.
  • Row value taqqoslash (a, b) < (x, y) qanday ishlashini tushuntirasiz.
  • Keyset so'rovini va unga mos tarkibiy indeksni yozasiz.
  • OFFSET'ning takrorlash tuzog'ini va id'siz keyset'ning yo'qotish tuzog'ini ko'rasiz.
  • Kursorni kodlaysiz va keyset'ning cheklovlarini bilasiz.

Oldin bilishingiz kerak: Pagination: offset, cursor, keyset, ORDER BY, LIMIT va OFFSET, Qaysi ustunga va qanday tartibda indeks, EXPLAIN ANALYZE'ni o'qish, N+1 muammosi.

1. Nega bu kerak?

25-qismdagi pagination darsida API darajasida uch usulni ko'rgandik: offset, kursor, keyset. U yerda ma'lumotlar xotiradagi massivda edi — slice(boshi, boshi + limit) qaysi usulda ham tez. Bazada esa farq katta.

Aziz vazifalar ro'yxatini pastga aylantiryapti: 1-sahifa, 2-sahifa … 50-sahifa. Har sahifa LIMIT 20 OFFSET …. Birinchi sahifalar bir zumda ochiladi, keyingilari sekinlashadi. Yoki administrator butun jadvalni eksport qilyapti: 2 mln qatorni 1 000 talab, OFFSET 0, 1000, 2000, … oxirgi sahifalar soniyalab ketadi.

O'xshatish: kitob o'qish. OFFSET — har safar kitobni birinchi betdan ochib, 347-betgacha sanab varaqlash. Keyset — kitob orasiga xatcho'p qo'yish: keyingi safar to'g'ri o'sha joydan ochasiz.

Raqamlar kanon bazasidan (PostgreSQL 18.6, Docker, 2 mln vazifa; sxemaning 012-fayldagi holati, 64-keyset.txt). Kanon EXPLAIN chiqishlarida cost=… qavslari qisqartirilgan — raqamlar o'zgarmagan. Kichik misollar — biz ishga tushirdik (PostgreSQL 17.6, holat 40, sessiya zonasi UTC).

2. OFFSET qanday ishlaydi

2.1 Kichik bazada: hammasi to'g'ri

Madina (egasi 4) ning 9 ta faol vazifasi bor. Sahifa — 3 ta, yangilari birinchi:

sql
SELECT id, yaratilgan_vaqt, left(matn, 24) AS matn
FROM faol_vazifalar WHERE egasi_id = 4
ORDER BY yaratilgan_vaqt DESC, id DESC
LIMIT 3;

Natija:

text
 id |    yaratilgan_vaqt     |           matn           
----+------------------------+--------------------------
 37 | 2026-10-17 16:10:00+00 | Uyga qo'ng'iroq qilish #
 31 | 2026-10-12 18:40:00+00 | Python laboratoriya ishi
 18 | 2026-10-06 04:00:00+00 | Yotoqxona to'lovi #to'lo
(3 rows)

ORDER BY yaratilgan_vaqt DESC, id DESC — oxirida id (barqaror tartib): bir xil vaqtli ikki vazifa bo'lsa ham tartib aniq. Bu tafsilot keyset uchun hal qiluvchi bo'ladi.

2.2 Katta bazada: chuqur sahifa

Kanon eng ko'p vazifali foydalanuvchini (3015) oldi va ro'yxat indeksi (vazifalar_royxat_idx (egasi_id, yaratilgan_vaqt DESC, id DESC) WHERE ochirilgan_vaqt IS NULL) bilan ikki sahifani o'lchadi:

sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, yaratilgan_vaqt FROM faol_vazifalar
WHERE egasi_id = 3015
ORDER BY yaratilgan_vaqt DESC, id DESC LIMIT 20 OFFSET 400;

Natija (qisqartirildi):

text
 Limit (actual time=0.381..0.429 rows=20.00 loops=1)
   Buffers: shared hit=220 read=4
   ->  Index Only Scan using vazifalar_royxat_idx on vazifalar (actual time=0.029..0.406 rows=420.00 loops=1)
         Index Cond: (egasi_id = 3015)
         Heap Fetches: 97
 Execution Time: 0.444 ms

Kalit raqam: Index Only Scan … rows=420.00. 20 qator kerak edi, baza 420 ta o'qidi: 400 tasini o'tkazib yuborish uchun ham o'qish kerak. OFFSET 0 da — 20 qator, 0.100 ms; OFFSET 400 da — 420 qator, 0.444 ms. 4 marta sekin, lekin hali sezilmaydi.

Endi butun jadval bo'yicha (masalan, eksport yoki admin paneli):

sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM vazifalar ORDER BY id LIMIT 20 OFFSET 1000000;

Natija (qisqartirildi):

text
 Limit (actual time=349.815..349.821 rows=20.00 loops=1)
   Buffers: shared hit=344778 read=7697 dirtied=38 written=4658
   ->  Index Only Scan using vazifalar_pkey on vazifalar (actual time=0.051..301.821 rows=1000020.00 loops=1)
         Heap Fetches: 545512
 Execution Time: 349.837 ms

1 000 020 qator o'qildi, 352 475 sahifa, 350 ms — 20 ta qator uchun. OFFSET'ning narxi sahifa raqamiga chiziqli bog'liq: 2-millioninchi qatorlar ikki barobar sekin bo'ladi.

Tekshirib ko'ring: LIMIT 50 OFFSET 10000 so'rovida baza kamida nechta qatorni o'qiydi va nega?

Javob

Kamida 10 050. Baza "10 000-qator qayerda?" ni bilmaydi — indeks ham faqat tartibni biladi, raqamni emas. U boshidan sanab boradi: 10 000 tasini o'qib tashlaydi, keyingi 50 tasini qaytaradi.

3. Keyset: xatcho'pdan davom etish

3.1 G'oya

1-sahifaning oxirgi qatori — (2026-10-06 04:00:00+00, 18). 2-sahifa — "tartibda shundan keyin keladigan 3 ta qator". Tartib yaratilgan_vaqt DESC, id DESC — keyin degani kichikroq:

sql
-- 2-sahifa: OFFSET bilan
SELECT id, yaratilgan_vaqt FROM faol_vazifalar
WHERE egasi_id = 4
ORDER BY yaratilgan_vaqt DESC, id DESC
LIMIT 3 OFFSET 3;

-- 2-sahifa: keyset — 1-sahifaning oxirgi qatoridan keyin
SELECT id, yaratilgan_vaqt FROM faol_vazifalar
WHERE egasi_id = 4
  AND (yaratilgan_vaqt, id) < ('2026-10-06 04:00+00', 18)
ORDER BY yaratilgan_vaqt DESC, id DESC
LIMIT 3;

Natija:

text
 id |    yaratilgan_vaqt     
----+------------------------
 13 | 2026-10-04 05:20:00+00
 47 | 2026-10-03 05:01:00+00
 46 | 2026-10-03 05:00:00+00
(3 rows)

 id |    yaratilgan_vaqt     
----+------------------------
 13 | 2026-10-04 05:20:00+00
 47 | 2026-10-03 05:01:00+00
 46 | 2026-10-03 05:00:00+00
(3 rows)

Natija bir xil. Farqi — qanday topildi. OFFSET 3 ta qatorni o'qib tashladi. Keyset esa indeksda (4, 2026-10-06 04:00, 18) nuqtasiga to'g'ridan tushdi va o'sha joydan 3 qadam yurdi.

3.2 Row value taqqoslash

(yaratilgan_vaqt, id) < ($1, $2) — row value comparison (qator qiymatlarini taqqoslash). U lug'atdagi so'zlar kabi taqqoslaydi: avval birinchi elementlar; teng bo'lsa — ikkinchilar. Biz ishga tushirdik:

sql
SELECT (2, 5) < (3, 1) AS a,
       (3, 1) < (3, 2) AS b,
       (3, 2) < (3, 2) AS c,
       (2 < 3 AND 5 < 1) AS d;

Natija:

text
 a | b | c | d 
---+---+---+---
 t | t | f | f
(1 row)
  • a: 2 < 3 — birinchi elementlar hal qildi, ikkinchiga qaralmadi (5 < 1 yolg'on bo'lsa ham).
  • b: 3 = 3, keyin 1 < 2 — rost.
  • c: to'liq teng — < yolg'on (qat'iy).
  • d: AND bilan yozilsa boshqa ma'no! (a, b) < (x, y) bu a < x AND b < y emas.

To'g'ri ochilgan shakli: a < x OR (a = x AND b < y). Row value shakli qisqa va — eng muhimi — Postgres uni bitta indeks shartiga aylantira oladi.

Kanon katta bazada (egasi 3015, 401-qatordan keyingi sahifa). :'kv' va :ki — psql o'zgaruvchilari (psql darsi, \gset): kanon OFFSET 399 dagi qatorning vaqti va id'sini ularga yozgan. Natijada Index Cond qatoriga qarang:

sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, yaratilgan_vaqt FROM faol_vazifalar
WHERE egasi_id = 3015
  AND (yaratilgan_vaqt, id) < (:'kv', :ki)
ORDER BY yaratilgan_vaqt DESC, id DESC LIMIT 20;

Natija (qisqartirildi):

text
 Limit (actual time=0.016..0.066 rows=20.00 loops=1)
   Buffers: shared hit=27
   ->  Index Only Scan using vazifalar_royxat_idx on vazifalar (actual time=0.015..0.063 rows=20.00 loops=1)
         Index Cond: ((egasi_id = 3015) AND (ROW(yaratilgan_vaqt, id) < ROW('2025-01-18 13:56:52.195546+00'::timestamp with time zone, 316193)))
         Heap Fetches: 15
 Execution Time: 0.092 ms

Index Cond da ROW(yaratilgan_vaqt, id) < ROW(…) — butun shart indeks ichida. 20 qator o'qildi (420 emas), 0.092 ms. Kanon ikkala natijani solishtirdi: OFFSET 400 va keyset — bir_xil = t.

Butun jadval bo'yicha esa farq keskin:

sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM vazifalar WHERE id > 1000000 ORDER BY id LIMIT 20;

Natija (qisqartirildi):

text
 Limit (actual time=0.177..0.188 rows=20.00 loops=1)
   ->  Index Only Scan using vazifalar_pkey on vazifalar (actual time=0.174..0.182 rows=20.00 loops=1)
         Index Cond: (id > 1000000)
 Execution Time: 0.213 ms
Millioninchi qatordan keyingi 20 ta
  • OFFSET 1 000 0001 000 020 qator o'qildi350 ms
  • WHERE id > 1 000 00020 qator o'qildi0,21 ms

Manba: IlmHamroh kanon o'lchovi, 2026-10-24: PostgreSQL 18.6, Docker (WSL2), 2 mln vazifa

1 600 marta tez. Bu yerda tartib faqat id bo'yicha — oddiy id > $1 yetadi. Tartib bir nechta ustunli bo'lsa — row value.

flowchart LR
  subgraph O["OFFSET 400"]
    A1["Indeks boshi"] --> A2["400 qator o'qib tashlanadi"] --> A3["20 qator javob"]
  end
  subgraph K["Keyset"]
    B1["(vaqt, id) nuqtasiga sakrash"] --> B2["20 qator javob"]
  end

Diagrammada ikkala yo'l: OFFSET doim boshidan yuradi, keyset xatcho'p nuqtasidan boshlaydi.

3.3 Mos indeks

Keyset faqat mos tarkibiy indeks bilan tez. Qoida tarkibiy indeks darsidagi bilan bir xil:

  1. Tenglik ustunlari (egasi_id) — birinchi.
  2. Keyin saralash ustunlari aynan ORDER BY tartibida va yo'nalishida (yaratilgan_vaqt DESC, id DESC).
  3. Filtr doimiy bo'lsa — partial (WHERE ochirilgan_vaqt IS NULL).

Kanondagi vazifalar_royxat_idx aynan shunday. Indeks bo'lmasa keyset ham to'liq saralashga aylanadi va foydasi yo'qoladi.

Tekshirib ko'ring: So'rov ORDER BY muddat, id bilan sahifalanadi. Keyingi sahifa sharti qanday yoziladi va qaysi indeks kerak?

Javob

WHERE (muddat, id) > ($1, $2) ORDER BY muddat, id LIMIT 20 — o'sish tartibida "keyin" — katta. Indeks: (muddat, id) (egasi bo'yicha filtr bo'lsa — (egasi_id, muddat, id)). E'tibor bering: muddat NULL bo'lishi mumkin — NULL lar taqqoslashda "noma'lum" beradi va bunday qatorlar keyset'dan tushib qoladi. Bunday ustunni coalesce bilan yoki NULL'larni alohida sahifalab ishlatish kerak.

4. Ikki tuzoq

4.1 OFFSET: sahifalar orasida yangi yozuv

Madina 1-sahifani ko'rib o'tirganda yangi vazifa qo'shildi. Keyin u "keyingi sahifa" ni bosdi. Biz ishga tushirdik (tranzaksiyada, oxirida ROLLBACK):

sql
BEGIN;
-- Madina 1-sahifani ko'rib turganda yangi vazifa qo'shildi
INSERT INTO vazifalar (egasi_id, matn, yaratilgan_vaqt)
VALUES (4, 'Yangi vazifa', '2026-10-20 12:00+05');

SELECT id FROM faol_vazifalar WHERE egasi_id = 4
ORDER BY yaratilgan_vaqt DESC, id DESC
LIMIT 3 OFFSET 3;

SELECT id FROM faol_vazifalar WHERE egasi_id = 4
  AND (yaratilgan_vaqt, id) < ('2026-10-06 04:00+00', 18)
ORDER BY yaratilgan_vaqt DESC, id DESC
LIMIT 3;
ROLLBACK;

Natija:

text
BEGIN
INSERT 0 1
 id 
----
 18
 13
 47
(3 rows)

 id 
----
 13
 47
 46
(3 rows)

ROLLBACK

OFFSET bilan 2-sahifa 18 dan boshlandi — u 1-sahifada allaqachon ko'rsatilgan edi! Yangi vazifa ro'yxat boshiga qo'shildi va hamma qatorlarni bir pozitsiyaga surdi. Madina 18-vazifani ikki marta ko'radi. Teskari holatda (vazifa o'chirilsa) — bitta vazifa hech qachon ko'rinmaydi.

Keyset bilan 2-sahifa to'g'ri: 13, 47, 46. Xatcho'p qatorga bog'langan, pozitsiyaga emas. Kanon xuddi shuni katta bazada ko'rsatgan: OFFSET bilan 1986955 id'li vazifa ikkinchi sahifada takrorlangan.

Cheksiz aylantiriladigan lentalarda (Telegram kanali, yangiliklar) bu takrorlar foydalanuvchiga darhol ko'rinadi — shuning uchun ular keyset ishlatadi.

4.2 Keyset: id'siz — yo'qotish

Keyset faqat yaratilgan_vaqt bilan yozilsa-chi? Ikki vazifa bir xil vaqtda yozilgan bo'lsa (import, bitta tranzaksiya — tranzaksiya ichida now() o'zgarmaydi), ulardan biri yo'qoladi. Biz ishga tushirdik (sahifa — 1 ta qator, ko'rinsin uchun):

sql
BEGIN;
-- Bir xil vaqtli ikki vazifa (import yoki bir tranzaksiyada yozilgan)
INSERT INTO vazifalar (egasi_id, matn, yaratilgan_vaqt) VALUES
  (4, 'Egizak A', '2026-10-20 12:00+05'),
  (4, 'Egizak B', '2026-10-20 12:00+05')
RETURNING id, matn;

-- 1-sahifa (LIMIT 1)
SELECT id, matn FROM faol_vazifalar WHERE egasi_id = 4
ORDER BY yaratilgan_vaqt DESC, id DESC LIMIT 1;

-- Noto'g'ri keyset: faqat vaqt bo'yicha
SELECT id, matn FROM faol_vazifalar WHERE egasi_id = 4
  AND yaratilgan_vaqt < '2026-10-20 12:00+05'
ORDER BY yaratilgan_vaqt DESC, id DESC LIMIT 1;

-- To'g'ri keyset: (vaqt, id)
SELECT id, matn FROM faol_vazifalar WHERE egasi_id = 4
  AND (yaratilgan_vaqt, id) < ('2026-10-20 12:00+05', 51)
ORDER BY yaratilgan_vaqt DESC, id DESC LIMIT 1;
ROLLBACK;

Natija:

text
BEGIN
 id |   matn   
----+----------
 50 | Egizak A
 51 | Egizak B
(2 rows)

INSERT 0 2
 id |   matn   
----+----------
 51 | Egizak B
(1 row)

 id |             matn             
----+------------------------------
 37 | Uyga qo'ng'iroq qilish #oila
(1 row)

 id |   matn   
----+----------
 50 | Egizak A
(1 row)

ROLLBACK

"Faqat vaqt" sharti < 12:00 — 12:00 dagi hamma qatorlarni chiqarib tashladi, jumladan hali ko'rsatilmagan Egizak A ni. U hech qachon ko'rinmaydi. (vaqt, id) sharti — to'g'ri: Egizak A keyingi sahifada.

(Id 50 va 51 — oldingi bloklardagi ROLLBACK qilingan INSERT'lar 49 ni "yeb" qo'ygan: ketma-ketlik qiymati qaytmaydi.)

Kanon ham buni tekshirgan: katta bazada bir xil yaratilgan_vaqt li juftlar 0 ta — u yerda id'siz keyset "ishlab ko'rinadi". Lekin bu kafolat emas, tasodif. Saralashga doim noyob ustun (id) qo'shing.

Diqqat: Saralash ustunining aniqligi bilan ham ehtiyot bo'ling. Postgres timestamptz ni mikrosekund aniqlikda saqlaydi (kanondagi kursor qiymati: 13:56:52.195546). JavaScript Date esa millisekundgacha. pg vaqtni Date qilib qaytarsa, kursordan oxirgi 3 raqam tushib qoladi.

Biz buni brauzer/Node'da ko'rsatamiz:

js
const pgdan = "2025-01-18T13:56:52.195546Z";
console.log(new Date(pgdan).toISOString());

Konsolda:

text
2025-01-18T13:56:52.195Z

Kursor .195 bo'lib qoldi. Endi (vaqt, id) < ('…52.195', 316193) sharti .195000 va .195546 orasidagi qatorlarni — agar shunday bo'lsa — tashlab yuboradi. Yechim: kursorga vaqtni matn sifatida olish (yaratilgan_vaqt::text so'rovda, yoki pg da 1184-tur uchun setTypeParser — pg asoslari darsi). Bu tuzoq nazariya emas: kanon uni API'ni Postgres'ga ulaganda haqiqatan uchratdi — o'sish tartibida (>) kursor oxirgi qatorni qayta qaytardi (.123Z < .123456). Kanon boshqa yechimni tanladi: 013-api-tartib.sql vaqtlarni bazaning o'zida millisekundgacha qisqartiradi (date_trunc('milliseconds', now()) — DEFAULT va trigger'da, eski qatorlar ham). Shunda JS Date va baza bir xil aniqlikda.

5. Kursorni kodlash

API mijozga (vaqt, id) ni ochiq bermaydi. U shaffof bo'lmagan kursor (opaque cursor) beradi: mijoz uni o'qimaydi, faqat keyingi so'rovda qaytaradi. 25-qismda vazifalar-api buni base64url(JSON) bilan qilgan. Postgres keyset uchun soddalashtirilgan variant (Node'ning Buffer i bilan):

js
function kursorYoz(oxirgi) {
  const ichki = { v: [oxirgi.yaratilgan_vaqt, oxirgi.id] };
  return Buffer.from(JSON.stringify(ichki)).toString("base64url");
}

function kursorOqi(kursor) {
  if (!/^[\w-]{1,500}$/.test(kursor)) return undefined;
  try {
    const { v } = JSON.parse(
      Buffer.from(kursor, "base64url").toString("utf8"),
    );
    const [vaqt, id] = v;
    const togri = typeof vaqt === "string" && /^\d+$/.test(id);
    if (!togri) return undefined;
    return [vaqt, id];
  } catch {
    return undefined;
  }
}

const oxirgi = {
  yaratilgan_vaqt: "2026-10-06T04:00:00.000Z",
  id: "18",
};
const k = kursorYoz(oxirgi);
console.log(k);
console.log(kursorOqi(k));
console.log(kursorOqi("buzilgan!"));
console.log(kursorOqi(Buffer.from("[1]").toString("base64url")));

Konsolda:

text
eyJ2IjpbIjIwMjYtMTAtMDZUMDQ6MDA6MDAuMDAwWiIsIjE4Il19
[ '2026-10-06T04:00:00.000Z', '18' ]
undefined
undefined

Qatorma-qator:

  • base64url — URL'da xavfsiz (+ va / o'rniga - va _, = siz). Kursor ?cursor=… da yuradi.
  • id: "18" — string: bigint pg dan sukut bo'yicha string bo'lib keladi va kursorda ham shunday qoladi. $2::bigint bilan so'rovga beriladi. (vazifalar-api hovuzi INT8 ni xavfsiz Number ga aylantiradi — u yerda id son.)
  • kursorOqi — mijozdan kelgan hamma narsa ishonchsiz. Regex, try/catch, turlarni tekshirish: buzuq kursor undefined — API 400 qaytaradi, 500 emas.
  • Qiymatlar baribir parametr sifatida ($1, $2) beriladi, SQL matniga yopishtirilmaydi (SQL injection darsi).

25-qism kanonidagi kursorda yana bitta maydon bor — imzo: saralash va filtr. Mijoz ?sort=muddat kursorini ?sort=matn bilan yuborsa, server uni rad etadi — kursor boshqa tartibga tegishli.

6. Keyset'ning cheklovlari

Imkoniyat OFFSET Keyset
"17-sahifaga o'tish" bor yo'q — faqat oldinga (va orqaga)
Jami sahifalar soni count(*) bilan (qimmat) odatda ko'rsatilmaydi
Chuqur sahifa tezligi sekinlashadi doim tez
Yangi yozuv qo'shilganda takror yoki tushib qolish barqaror
Ixtiyoriy saralash oson har saralashga indeks va kursor

Qachon qaysi biri:

  • OFFSET — kichik ro'yxatlar, admin jadvallari "1 2 3 … 10" tugmalari bilan, foydalanuvchi kamdan-kam 5-sahifadan nariga o'tadi.
  • Keyset — cheksiz lenta, mobil ilova, API orqali eksport, katta jadvalni bo'laklab qayta ishlash (masalan, 2 mln qatorni 1 000 talab yangilash).

Aralash yo'nalishli saralash (ORDER BY ustuvorlik DESC, id ASC) row value bilan to'g'ridan yozilmaydi — u bitta yo'nalishni nazarda tutadi. Ochiq shakl kerak: ustuvorlik < $1 OR (ustuvorlik = $1 AND id > $2). Bunday shart indeksdan yomonroq foydalanadi — kanon buni API'da o'lchadi (pastdagi "Node qadami": Filter, 425 sahifa). Iloji bo'lsa, saralash yo'nalishlarini bir xil qiling.

7. Ko'p uchraydigan xatolar

Xato Oqibat To'g'risi
Chuqur sahifaga OFFSET Chiziqli sekinlashish (kanonda 350 ms) Keyset
ORDER BY yaratilgan_vaqt (id'siz) Bir xil vaqtli qator yo'qoladi Oxiriga noyob id
a < $1 AND b < $2 Noto'g'ri natija (a, b) < ($1, $2)
Indeks tartibi ORDER BY ga mos emas Keyset ham saralaydi (filtr, saralash…, id) aynan shu yo'nalishda
Vaqt kursori Date orqali Mikrosekundlar yo'qoladi Vaqtni matn sifatida
Kursorni tekshirmasdan ishlatish 500 xato yoki noto'g'ri sahifa Dekodlash + validatsiya, 400

8. Mashqlar

Mashqlar kurs/mashqlar/26/64-keyset/ da. SQL yechimlar holat 40 bazasida, JS — Node 24 da tekshirilgan (biz ishga tushirdik).

1-mashq (oson): to'ldiring

  • OFFSET 1000 da baza o'tkazib yuborilgan qatorlarni ham
  • Keyset shartining shakli: (yaratilgan_vaqt, id) ($1, $2) (tartib DESC)
  • (3, 1) < (3, 2) natijasi —
  • Bir xil vaqtli qatorlar yo'qolmasligi uchun saralashga qo'shiladigan ustun —
  • URL uchun xavfsiz Base64 varianti —
Yechim
  • o'qiydi — kanonda OFFSET 1 000 000 — 1 000 020 qator.
  • **<** — DESC tartibida "keyin" — kichikroq; ASC da >.
  • true — birinchilar teng, 1 < 2.
  • id — noyob, tartibni to'liq aniqlaydi.
  • base64url — -, _, = siz.

2-mashq (o'rta): Madinaning hamma sahifalari

Madinaning (egasi 4) faol vazifalarini 4 tadan keyset bilan sahifalang: 1-sahifa (shartsiz), 2-sahifa (1-sahifaning oxirgi qatoridan keyin), 3-sahifa. Har sahifada id va yaratilgan_vaqt ni chiqaring. Oxirida hamma sahifalardagi id'lar soni 9 ekanini va takror yo'qligini bitta so'rov bilan tekshiring.

Yechim
sql
-- 1-sahifa
SELECT id, yaratilgan_vaqt FROM faol_vazifalar
WHERE egasi_id = 4
ORDER BY yaratilgan_vaqt DESC, id DESC LIMIT 4;

-- 2-sahifa: 1-sahifaning oxirgi qatori (13, 2026-10-04 05:20+00)
SELECT id, yaratilgan_vaqt FROM faol_vazifalar
WHERE egasi_id = 4
  AND (yaratilgan_vaqt, id) < ('2026-10-04 05:20+00', 13)
ORDER BY yaratilgan_vaqt DESC, id DESC LIMIT 4;

-- 3-sahifa: 2-sahifaning oxirgi qatori (41, 2026-10-02 10:05+00)
SELECT id, yaratilgan_vaqt FROM faol_vazifalar
WHERE egasi_id = 4
  AND (yaratilgan_vaqt, id) < ('2026-10-02 10:05+00', 41)
ORDER BY yaratilgan_vaqt DESC, id DESC LIMIT 4;

-- Tekshiruv: hamma sahifalar birga
SELECT count(*) AS jami, count(DISTINCT id) AS noyob
FROM faol_vazifalar WHERE egasi_id = 4;

Natija (psql -q, sessiya zonasi UTC):

text
 id |    yaratilgan_vaqt     
----+------------------------
 37 | 2026-10-17 16:10:00+00
 31 | 2026-10-12 18:40:00+00
 18 | 2026-10-06 04:00:00+00
 13 | 2026-10-04 05:20:00+00
(4 rows)

 id |    yaratilgan_vaqt     
----+------------------------
 47 | 2026-10-03 05:01:00+00
 46 | 2026-10-03 05:00:00+00
 42 | 2026-10-02 10:06:00+00
 41 | 2026-10-02 10:05:00+00
(4 rows)

 id |    yaratilgan_vaqt     
----+------------------------
  7 | 2026-10-02 10:00:00+00
(1 row)

 jami | noyob 
------+-------
    9 |     9
(1 row)

4 + 4 + 1 = 9 ta, takror yo'q. 3-sahifada 4 tadan kam qator — bu "keyingi sahifa yo'q" belgisi. Amalda LIMIT 5 (sahifa + 1) so'rab, 5-qator bor-yo'qligiga qarab borKeyingi ni aniqlash qulay — 25-qism API'si shunday qiladi.

3-mashq (qiyin): kursor bilan sahifalovchi

Bazasiz JS: 10 ta vazifadan iborat massiv (ba'zilarining vaqti bir xil) berilgan. sahifa(royxat, kursor, limit) funksiyasini yozing: yaratilgan_vaqt DESC, id DESC tartibida keyset bilan sahifa qaytarsin — { elementlar, keyingi }, keyingi — kursor (keyingi sahifa bo'lmasa null). Kursor — base64url(JSON). Hamma sahifalarni ketma-ket olib, id'lar takrorsiz va to'liq chiqishini tekshiring.

Yechim
js
const royxat = [
  { id: 1, vaqt: "2026-10-01T10:00:00Z" },
  { id: 2, vaqt: "2026-10-01T10:00:00Z" },
  { id: 3, vaqt: "2026-10-02T09:00:00Z" },
  { id: 4, vaqt: "2026-10-02T09:00:00Z" },
  { id: 5, vaqt: "2026-10-02T09:00:00Z" },
  { id: 6, vaqt: "2026-10-03T08:00:00Z" },
  { id: 7, vaqt: "2026-10-04T12:00:00Z" },
  { id: 8, vaqt: "2026-10-04T12:00:00Z" },
  { id: 9, vaqt: "2026-10-05T07:00:00Z" },
  { id: 10, vaqt: "2026-10-06T06:00:00Z" },
];

// (vaqt, id) DESC tartibida a b dan oldinmi
function oldinmi(a, b) {
  if (a.vaqt !== b.vaqt) return a.vaqt > b.vaqt;
  return a.id > b.id;
}

const yoz = (x) =>
  Buffer.from(JSON.stringify([x.vaqt, x.id])).toString("base64url");
const oqi = (k) => {
  const matn = Buffer.from(k, "base64url").toString();
  const [vaqt, id] = JSON.parse(matn);
  return { vaqt, id };
};

function sahifa(royxat, kursor, limit) {
  const tartibli = [...royxat].sort((a, b) =>
    oldinmi(a, b) ? -1 : 1,
  );
  const xatchop = kursor ? oqi(kursor) : null;
  // Keyset: xatcho'pdan qat'iy keyin keladiganlar
  // (SQL'dagi (vaqt, id) < kursor)
  const qolgan = xatchop
    ? tartibli.filter((x) => oldinmi(xatchop, x))
    : tartibli;
  const elementlar = qolgan.slice(0, limit);
  const keyingi =
    qolgan.length > limit ? yoz(elementlar.at(-1)) : null;
  return { elementlar, keyingi };
}

const korilgan = [];
let kursor = null;
do {
  const s = sahifa(royxat, kursor, 4);
  console.log(s.elementlar.map((x) => x.id).join(", "));
  korilgan.push(...s.elementlar.map((x) => x.id));
  kursor = s.keyingi;
} while (kursor);
console.log(korilgan.length, new Set(korilgan).size);

Konsolda:

text
10, 9, 8, 7
6, 5, 4, 3
2, 1
10 10

Uch sahifa, 10 ta id, takror yo'q. Bu bir xil vaqtli 7/8, 3/4/5 va 1/2 juftlari sahifa chegarasida bo'linganda ham. oldinmi(xatchop, x) — "kursor x dan oldin turadimi", ya'ni x kursordan keyinmi: SQL'dagi (vaqt, id) < kursor ning JS nusxasi. Vaqt ISO satr sifatida taqqoslanadi — bir xil formatdagi ISO satrlar tartibi vaqt tartibi bilan mos. Kursor ichida vaqt matn — mikrosekund muammosi yo'q. Haqiqiy API'da kursor yana validatsiya qilinadi («Kursorni kodlash» bo'limidagi kursorOqi).

9. Node qadami: API kursori indeks shartiga aylanadi

vazifalar-api ning ro'yxati boshidan keyset bilan ishlaydi — 25-qismda kursor shunday yozilgan edi, PgOmbori uni SQL'ga ko'chirdi. Bu darsda kanon katta bazada uning rejasini tekshirdi va ikki haqiqiy xatoni tuzatdi. Natija — holat 64, butun 26-qismning yakuniy commit'i.

9.1 API kursori ichida nima bor

Kanonning yakuniy tekshiruvida GET /v1/vazifalar?limit=3 javobidagi kursor (tekshiruv-64.txt): eyJ2IjpbNCw5XSwiaSI6IltbXSx7fV0ifQ. «Kursorni kodlash» bo'limidagi kabi ochamiz:

js
const k = "eyJ2IjpbNCw5XSwiaSI6IltbXSx7fV0ifQ";
console.log(Buffer.from(k, "base64url").toString("utf8"));

Konsolda:

text
{"v":[4,9],"i":"[[],{}]"}

v — oxirgi qatorning saralash kalitlari: sukut tartib tartib, id bo'lgani uchun [4, 9] (tartib 4, id 9). i — imzo: saralash va filtr (bu yerda bo'sh). Sahifa uchun SQL LIMIT 4 so'raydi (limit + 1) — to'rtinchi qator bor bo'lsa, keyingi kursor beriladi, COUNT siz.

9.2 Topilma: yoyilgan OR — indeks sharti emas

25-qism API'sida saralash yo'nalishlari aralash bo'lishi mumkin. Shuning uchun PgOmbori dastlab hamma holatda «Keyset'ning cheklovlari» bo'limidagi ochiq shaklni yozardi: k1 > $1 OR (k1 = $1 AND k2 > $2). Kanon reja-olchov.ts bilan (EXPLAIN ANALYZE'ni o'qish darsida) chuqur sahifani — 400-qatordan keyingi kursorni — o'lchadi. Holat 53 kodi (reja-53.txt, katta baza 013/014 bilan qayta qurilgan):

text
=== sukut, kursor 400-qatordan keyin (chuqur sahifa): royxat() 7.2 ms, so'rovlar 2
Limit (actual time=0.491..0.517 rows=21.00 loops=1)
  Buffers: shared hit=425
  ->  Index Scan using vazifalar_tartib_idx on vazifalar v (actual time=0.488..0.512 rows=21.00 loops=1)
        Rows Removed by Filter: 400
        Buffers: shared hit=425
  Buffers: shared hit=1
Execution Time: 0.608 ms

Nima bo'ldi? Rows Removed by Filter: 400 — Postgres OR shartini indeks sharti qila olmadi. U egasining vazifalarini boshidan o'qib, 400 tasini filtr bilan tashladi: 425 sahifa. Xuddi shu faylda OFFSET 400 ham 425 sahifa. Kursor bor, lekin narxi OFFSET'niki!

Tuzatish: yo'nalishlar bir xil bo'lsa (sukut tartib, -yaratildi emas) — row value (kanondan, qatorlarga bo'lingan):

ts
// Holat 64: yo'nalishlar bir xil — row value (a, b) > ($1, $2)
const birXil = k.every((s) => s.kamayish === k[0]?.kamayish);
if (birXil) {
  const chap = k.map((s) => ustun(s.maydon).sql).join(", ");
  const ong = k
    .map((s, i) => `${p.qosh(keyin[i])}::${ustun(s.maydon).tur}`)
    .join(", ");
  return `(${chap}) ${k[0]?.kamayish === true ? "<" : ">"} (${ong})`;
}
// aralash yo'nalish — eski yoyilgan OR shakli qoladi

Xuddi o'sha chuqur sahifa, holat 64 kodi (reja-64.txt):

text
=== sukut, kursor 400-qatordan keyin (chuqur sahifa): royxat() 3.3 ms, so'rovlar 2
Limit (actual time=0.014..0.033 rows=21.00 loops=1)
  Buffers: shared hit=24
  ->  Index Scan using vazifalar_tartib_idx on vazifalar v (actual time=0.013..0.030 rows=21.00 loops=1)
        Buffers: shared hit=24
  Buffers: shared hit=1
Execution Time: 0.045 ms

Rows Removed by Filter yo'q — shart indeksga tushdi. 425 → 24 sahifa, 0.608 → 0.045 ms; 1-sahifa (24 sahifa, 0.039 ms) bilan deyarli bir xil. Keyset va'dasi — "sahifa raqamidan qat'i nazar" — endi API'da ham bajarildi. Saboq: shart to'g'ri natija bersa ham, EXPLAIN da Index Cond mi yoki Filter mi — tekshiring.

9.3 Ulanish uzilsa: olingan client'ning error hodisasi

Commit'dagi ikkinchi tuzatish Tranzaksiya asoslari darsidagi #tranzaksiyada yordamchisiga tegishli. Kanon t48 ni qayta ishga tushirganda tranzaksiya paytida backend o'ldirildi (pg_terminate_backend). Natija — Node jarayoni yiqildi (Emitted 'error' event on Client instance — kanon qadamlar yozuvidan; xom chiqish fayli saqlanmagan).

Sabab: pool.connect() bilan olingan (checked-out) client'ning "error" hodisasi pool.on("error") ga bormaydi — u faqat bo'sh (idle) ulanishlar uchun (Connection pooling). Node'da tinglovchisiz "error" hodisasi jarayonni to'xtatadi. Tuzatilgan yordamchi (kanondan, qisqartirildi):

ts
async #tranzaksiyada<T>(fn: (c: PoolClient) => Promise<T>) {
  const c = await this.#pool.connect();
  let buzilgan: Error | undefined;
  // olingan client'ning "error" i pool.on("error") ga bormaydi
  const xatoTinglovchi = (x: Error): void => { buzilgan = x; };
  c.on("error", xatoTinglovchi);
  try {
    await c.query("BEGIN");
    const natija = await fn(c);
    await c.query("COMMIT");
    return natija;
  } catch (x) {
    try {
      await c.query("ROLLBACK");
    } catch (r) {
      buzilgan = r instanceof Error ? r : new Error(String(r));
    }
    throw takrorXatosi(x);
  } finally {
    c.off("error", xatoTinglovchi);
    c.release(buzilgan); // xato bo'lsa — ulanish hovuzga qaytmaydi
  }
}

Uch nuqta:

  • c.on("error", …) — hodisa endi tinglanadi, jarayon yiqilmaydi; xato buzilgan ga yoziladi.
  • c.release(buzilgan) — argument bilan chaqirilsa, pg ulanishni yopadi, hovuzga qaytarmaydi. Keyingi so'rov yangi ulanish oladi.
  • c.off(…) — client hovuzga qaytsa, uning tinglovchisi keyingi egasiga "meros" qolmasin.

takrorXatosi — takror matn xatosini 409 ga aylantiradi. API holat 53 dan beri 014 sxemasida: takror matn — 23P01 (vazifalar_egasi_id_matn_excl); eski 23505 ham tanib olinadi.

Yangi Postgres testi buni isbotlaydi: hovuzda bitta ulanish (max: 1), uni boshqa hovuzdan pg_terminate_backend bilan o'ldiradi. Keyingi yarat xato beradi (jarayon tirik), undan keyingisi — yangi ulanishda muvaffaqiyatli.

9.4 Tekshiruv va commit

Kanon yakuniy tekshiruvi (tekshiruv-64.txt): npm run tip va npm run lint — 0 xato; testlar 84/84 (25-qismdan 67 + Postgres'ga xos 17); jonli server (SAQLASH=postgres) bilan 9 ta curl so'rovi kutilgan kodlar bilan. Qamrov (qamrov-64.txt): pg-ombor.ts — satrlar 98.85 %. README'ga "PostgreSQL bilan ishga tushirish", TESTLAR.md ga Postgres testlari bo'limi qo'shildi.

Holat 64 — 26-qismning Node qadamlari shu yerda tugaydi (#55–#64 darslari bitta holatni bo'lishadi, commit — oxirgisida):

bash
git add src/vazifalar/pg-ombor.ts test/pg-ombor.test.ts \
  README.md TESTLAR.md
M="perf: kursor bir yo'nalishda row value bilan (indeks sharti)"
git commit -m "$M" -m "Olingan client'ga error tinglovchisi."

10. Real ishda

  • Ijtimoiy tarmoqlar va messenjerlar (lenta, xabarlar tarixi) — deyarli hammasi keyset/kursor: GitHub, Slack, Stripe API'larida starting_after, cursor, next_page_token parametrlari shu g'oya (ularning hujjatlari bo'yicha).
  • Katta jadvalni bo'laklab qayta ishlash — migratsiya yoki eksport skripti WHERE id > $oxirgi ORDER BY id LIMIT 1000 siklida: har bo'lak bir xil tez.
  • ORM'larda (27-qism) — Prisma cursor + skip: 1, boshqalarida ham shunga o'xshash; ichida shu SQL.
  • Intervyuda: "Nega katta OFFSET sekin?", "Keyset pagination qanday ishlaydi?", "Kursor ichida nima bor?"

Xulosa

  • OFFSET N — N qatorni o'qib tashlash; kanonda OFFSET 1 000 000 — 1 000 020 qator, 350 ms.
  • Keyset — WHERE (yaratilgan_vaqt, id) < ($1, $2): xatcho'pdan davom etish, sahifa raqamidan qat'i nazar tez (kanon: 0.09–0.21 ms).
  • Row value taqqoslash lug'at tartibida ishlaydi; a < x AND b < y bilan bir xil emas.
  • Mos indeks: tenglik ustunlari, keyin aynan ORDER BY ustunlari va yo'nalishi; oxirida noyob id.
  • OFFSET yangi yozuvda qatorni takrorlaydi; id'siz keyset bir xil vaqtli qatorni yo'qotadi.
  • Kursor — base64url(JSON), ishonchsiz kirish sifatida tekshiriladi; vaqtni matn sifatida saqlang.
  • Narxi: "N-sahifaga o'tish" yo'q.
  • API'da yoyilgan OR keyset sharti Filter bo'lib qoldi (chuqur sahifa 425 sahifa); row value bilan — indeks sharti, 24 sahifa. EXPLAIN da Index Cond ni tekshiring.

Keyingi qism: 27-qism — ma'lumotlar bazasi chuqur: ORM'lar (Prisma, Drizzle), migratsiya vositalari, JSONB, MongoDB va Redis. Birinchi dars — JSONB.

Manbalar

Ulashish:Telegram'da

Izohlar (0)

Izoh yozish uchun kiring.

  • Hozircha izoh yo'q. Birinchi bo'ling!
PostgreSQL keyset pagination: katta OFFSET nega sekin va kursor bilan sahifalash — IlmHamroh