Mundarija (27)
- Bu darsda
- 1. Nega bu kerak?
- 2. OFFSET qanday ishlaydi
- 2.1 Kichik bazada: hammasi to'g'ri
- 2.2 Katta bazada: chuqur sahifa
- 3. Keyset: xatcho'pdan davom etish
- 3.1 G'oya
- 3.2 Row value taqqoslash
- 3.3 Mos indeks
- 4. Ikki tuzoq
- 4.1 OFFSET: sahifalar orasida yangi yozuv
- 4.2 Keyset: id'siz — yo'qotish
- 5. Kursorni kodlash
- 6. Keyset'ning cheklovlari
- 7. Ko'p uchraydigan xatolar
- 8. Mashqlar
- 1-mashq (oson): to'ldiring
- 2-mashq (o'rta): Madinaning hamma sahifalari
- 3-mashq (qiyin): kursor bilan sahifalovchi
- 9. Node qadami: API kursori indeks shartiga aylanadi
- 9.1 API kursori ichida nima bor
- 9.2 Topilma: yoyilgan OR — indeks sharti emas
- 9.3 Ulanish uzilsa: olingan client'ning error hodisasi
- 9.4 Tekshiruv va commit
- 10. Real ishda
- Xulosa
- Manbalar
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
OFFSETnega 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:
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:
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:
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):
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 msKalit 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):
EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM vazifalar ORDER BY id LIMIT 20 OFFSET 1000000;Natija (qisqartirildi):
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 ms1 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 10000so'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:
-- 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:
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:
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:
a | b | c | d
---+---+---+---
t | t | f | f
(1 row)a: 2 < 3 — birinchi elementlar hal qildi, ikkinchiga qaralmadi (5 < 1yolg'on bo'lsa ham).b: 3 = 3, keyin 1 < 2 — rost.c: to'liq teng —<yolg'on (qat'iy).d:ANDbilan yozilsa boshqa ma'no!(a, b) < (x, y)bua < x AND b < yemas.
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:
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):
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 msIndex 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:
EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM vazifalar WHERE id > 1000000 ORDER BY id LIMIT 20;Natija (qisqartirildi):
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- 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"]
endDiagrammada 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:
- Tenglik ustunlari (
egasi_id) — birinchi. - Keyin saralash ustunlari aynan
ORDER BYtartibida va yo'nalishida (yaratilgan_vaqt DESC, id DESC). - 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, idbilan 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):
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:
BEGIN
INSERT 0 1
id
----
18
13
47
(3 rows)
id
----
13
47
46
(3 rows)
ROLLBACKOFFSET 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):
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:
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
timestamptzni mikrosekund aniqlikda saqlaydi (kanondagi kursor qiymati:13:56:52.195546). JavaScriptDateesa millisekundgacha.pgvaqtniDateqilib qaytarsa, kursordan oxirgi 3 raqam tushib qoladi.
Biz buni brauzer/Node'da ko'rsatamiz:
const pgdan = "2025-01-18T13:56:52.195546Z";
console.log(new Date(pgdan).toISOString());Konsolda:
2025-01-18T13:56:52.195ZKursor .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):
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:
eyJ2IjpbIjIwMjYtMTAtMDZUMDQ6MDA6MDAuMDAwWiIsIjE4Il19
[ '2026-10-06T04:00:00.000Z', '18' ]
undefined
undefinedQatorma-qator:
base64url— URL'da xavfsiz (+va/o'rniga-va_,=siz). Kursor?cursor=…da yuradi.id: "18"— string:bigintpgdan sukut bo'yicha string bo'lib keladi va kursorda ham shunday qoladi.$2::bigintbilan so'rovga beriladi. (vazifalar-apihovuziINT8ni xavfsizNumberga aylantiradi — u yerda id son.)kursorOqi— mijozdan kelgan hamma narsa ishonchsiz. Regex,try/catch, turlarni tekshirish: buzuq kursorundefined— 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 1000da baza o'tkazib yuborilgan qatorlarni ham- Keyset shartining shakli:
(yaratilgan_vaqt, id) ($1, $2)(tartibDESC) (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. - **<** —
DESCtartibida "keyin" — kichikroq;ASCda>. - 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
-- 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):
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
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:
10, 9, 8, 7
6, 5, 4, 3
2, 1
10 10Uch 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:
const k = "eyJ2IjpbNCw5XSwiaSI6IltbXSx7fV0ifQ";
console.log(Buffer.from(k, "base64url").toString("utf8"));Konsolda:
{"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):
=== 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 msNima 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):
// 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 qoladiXuddi o'sha chuqur sahifa, holat 64 kodi (reja-64.txt):
=== 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 msRows 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):
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; xatobuzilganga yoziladi.c.release(buzilgan)— argument bilan chaqirilsa,pgulanishni 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):
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_tokenparametrlari shu g'oya (ularning hujjatlari bo'yicha). - Katta jadvalni bo'laklab qayta ishlash — migratsiya yoki eksport skripti
WHERE id > $oxirgi ORDER BY id LIMIT 1000siklida: 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; kanondaOFFSET 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 < ybilan bir xil emas. - Mos indeks: tenglik ustunlari, keyin aynan
ORDER BYustunlari va yo'nalishi; oxirida noyobid. - 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
ORkeyset shartiFilterbo'lib qoldi (chuqur sahifa 425 sahifa); row value bilan — indeks sharti, 24 sahifa.EXPLAINdaIndex Condni tekshiring.
Keyingi qism: 27-qism — ma'lumotlar bazasi chuqur: ORM'lar (Prisma, Drizzle), migratsiya vositalari, JSONB, MongoDB va Redis. Birinchi dars — JSONB.
Manbalar
- PostgreSQL 18 hujjati — Row Constructor Comparison: https://www.postgresql.org/docs/18/functions-comparisons.html#ROW-WISE-COMPARISON
- PostgreSQL 18 hujjati — LIMIT and OFFSET ("katta OFFSET samarasiz" eslatmasi): https://www.postgresql.org/docs/18/queries-limit.html
- PostgreSQL 18 hujjati — Date/Time Types (mikrosekund aniqlik): https://www.postgresql.org/docs/18/datatype-datetime.html
- Markus Winand — "We need tool support for keyset pagination" (use-the-index-luke.com): https://use-the-index-luke.com/no-offset
- IlmHamroh kanon o'lchovi (PostgreSQL 18.6, 2 mln vazifa, 2026-10-24); kichik misollar va mashqlar — biz ishga tushirdik (PostgreSQL 17.6, Node 24.21.0, Windows 11).
Izohlar (0)
Izoh yozish uchun kiring.
- Hozircha izoh yo'q. Birinchi bo'ling!