Dilshod.dev

Hech nima qilmaydigan indekslar

Index qo'shdingiz, query'ni ishlatdingiz — u hamon sekin. Planner sizni mensimayotgani yo'q — u sizga ma'lumotingiz, ustunlar tartibi yoki tiplaringiz haqida nimadir aytyapti. Uni qanday o'qishni ko'ramiz.

Muallif: Dilshod Abdullayev10 daqiqa o'qish

Buyurtmalar ro'yxati endpoint'i 900 ms olayotgan edi. Kimdir query'ga qarab, customer_id bo'yicha filtr borligini ko'rdi va customer_id ga index qo'shdi. Deploy, sahifani yangilash — hamon 900 ms. Shundan keyin yana bittasini qo'shdi, bu safar (customer_id, status) ga. Baribir 900 ms.

Hafta oxiriga borib o'sha jadvalda oltita index paydo bo'ldi, endpoint o'zgarmadi, insert'lar esa sezdirmasdan sekinlashdi.

Bu yerda mantiqsiz hech narsa yo'q. Index qo'shish — standart harakat, va ko'p hollarda u ishlaydi. Lekin ishlamaganda, "yana bittasini qo'shaman" instinkti aynan teskari yo'nalish. Planner ishlatishdan bosh tortayotgan index — bu chala yechim emas. Bu — xabar, va odatda juda aniq xabar.

Planner sizni mensimayotgani yo'q

Postgres index'larni "bor ekan" deb tanlamaydi. U qura oladigan har bir planning narxini baholaydi va eng arzonini oladi. Agar u yangi index'ingiz o'rniga sequential scan'ni tanlagan bo'lsa, uch narsadan biri rost: scan chindan ham arzonroq; statistika noto'g'ri bo'lgani uchun arzonroq ko'rinyapti; yoki index o'sha predikatga umuman xizmat qila olmaydi.

Bu uchtasining yechimi butunlay boshqacha, va ularni taxmin bilan ajratib bo'lmaydi. Shuning uchun birinchi qadam har doim bitta:

EXPLAIN (ANALYZE, BUFFERS)
SELECT id, status, total_cents, created_at
FROM orders
WHERE customer_id = 41207
  AND status = 'paid'
ORDER BY created_at DESC
LIMIT 20;

ANALYZE query'ni ishga tushiradi va real vaqt hamda qator sonlarini ko'rsatadi. BUFFERS esa nechta sahifa umumiy xotiradan, nechtasi diskdan kelganini aytadi. Oddiy EXPLAIN sizga planner'ning taxminini beradi, uni solishtiradigan hech narsasiz — uchtasidan eng foydasizi, va aynan ko'pchilik ishlatadigani.

Hamma narsadan oldin qidiradigan narsangiz — taxmin qilingan va haqiqiy qatorlar orasidagi tafovut:

Seq Scan on orders  (cost=0.00..48122.00 rows=12 width=44)
                    (actual time=0.031..812.4 rows=48219 loops=1)
  Filter: ((customer_id = 41207) AND (status = 'paid'::text))
  Rows Removed by Filter: 1951781
  Buffers: shared hit=1204 read=21918

Planner 12 ta qator kutgan edi. 48 219 ta chiqdi. O'sha bahodan keyingi har bir qaror yolg'on asosda qabul qilingan, va yomon bahoni hech qancha index qo'shish tuzatmaydi. Shu bitta solishtirish boshqa har qanday chiqishdan ko'ra ko'proq index jumbog'ini yechadi.

Buffers qatori — ikkinchi yarmi. read=21918 degani yigirma ikki ming sahifa bufer keshidan tashqaridan kelgan. Issiq keshda yaxshi ko'ringan plan sovuq keshda ancha yomon bo'lishi mumkin, va yolg'iz ANALYZE buni ko'rsatmaydi.

Ba'zan seq scan haq bo'ladi

Planner'ni ayblashdan oldin shuni o'ylang: u ko'pincha haq, va noto'g'ridek tuyuladigan tarzda haq.

Index scan tekin emas. Postgres mos tuple ko'rsatkichlarini topish uchun index bo'ylab yuradi, keyin har bir qator uchun heap'ga sakraydi — bu tasodifiy I/O. Sequential scan sahifalarni ketma-ket o'qiydi, buni ham disk, ham OS readahead yaxshi ko'radi. "Bu query jadvalning sezilarli qismini qaytaradi" chegarasidan keyin hammasini tartib bilan o'qish qism uchun sakrab yurishdan tezroq bo'ladi.

Shuning uchun 40 qatorli plans jadvalida index hech qachon ishlatilmaydi, va bu normal. 2 million qatorli orders jadvalida esa quyidagi query nima qursangiz ham seq scan bo'lib qolaveradi:

-- Jadvalning ~30% i mos keladi. 600 ming qator uchun tasodifiy heap kirish
-- jadvalni fizik tartibda o'qishdan sekinroq.
SELECT * FROM orders WHERE status = 'paid';

Index'lar selektiv predikatlarda foyda beradi. Asosan to'langan buyurtmalardan iborat jadvalda status = 'paid' selektiv emas. customer_id = 41207 esa selektiv. Agar planner past selektivlikdagi ustundagi index'ingizni chetlab o'tayotgan bo'lsa, u sizning ma'lumotingizni index'dan yaxshiroq tushungan.

Bilib qo'yishga arziydigan istisno: agar baho yomon bo'lsa, planner predikatni xato ravishda noselektiv deb ishonishi mumkin. Odatdagi sabab — korrelyatsiyalangan ustunlar: country_code va currency mustaqil emas, Postgres esa siz kengaytirilgan statistika yaratib aytmaguningizcha ularni mustaqil deb hisoblaydi.

Chapdan boshlanuvchi prefiks va o'zingiz bor deb o'ylagan index

Kompozit index'lar — yaxshi niyatlar jimgina barbod bo'ladigan joy. (a, b, c) index'i avval a bo'yicha, teng a ichida b bo'yicha, keyin c bo'yicha saralanadi. Ana shu tartib — butun mexanizmning o'zi, va index nimaga javob bera olishini aynan u belgilaydi.

CREATE INDEX idx_orders_status_customer ON orders (status, customer_id);

Bu index WHERE status = 'paid' AND customer_id = 41207 ga, shuningdek yolg'iz WHERE status = 'paid' ga xizmat qiladi. Lekin yolg'iz WHERE customer_id = 41207 ga foydali tarzda xizmat qilmaydi — o'sha ID'lar har bir status guruhiga sochilib ketgan, ya'ni skanerlash uchun uzluksiz diapazon yo'q. Agar index jadvaldan ancha tor bo'lsa, Postgres baribir to'liq index scan tanlashi mumkin — shuning uchun ba'zan planda index "ishlatilgan"ini ko'rasiz, plan esa hamon sekin. Planda ko'rinish va samarali bo'lish — boshqa-boshqa narsalar.

Amaliy qoida: avval tenglik ustunlari, keyin diapazon yoki saralash ustuni. Shu postning boshidagi endpoint uchun:

-- customer_id va status — tenglik; created_at ORDER BY ni beradi.
CREATE INDEX idx_orders_customer_status_created
  ON orders (customer_id, status, created_at DESC);

Endi LIMIT 20 saralangan diapazon bo'ylab chegaralangan yurish bo'ladi, saralash esa plandan yo'qoladi. EXPLAIN da Sort tugunlariga e'tibor bering — katta oraliq natija ustidagi saralash ko'pincha ustunlari noto'g'ri tartibda joylashgan kompozit index degani.

Ustun ustidagi funksiya index'ni o'ldiradi

Bu — men boshqalarning kodida eng ko'p tuzatgan narsa, va qidirishni bilmaguningizcha u ko'rinmaydi.

CREATE INDEX idx_customers_email ON customers (email);
 
-- Ishlatilmaydi. Index email ni saqlaydi, lower(email) ni emas.
SELECT * FROM customers WHERE lower(email) = 'dilshod@example.com';

Index ustundagi aniq qiymatlarni saqlaydi. lower(email) — boshqa qiymat, shuning uchun index uni topa olmaydi. Xuddi shu tuzoq ustunni biror narsaga o'raganingizning hamma joyida chiqadi:

-- Bularning barchasi created_at index'ini chetlab o'tadi:
WHERE date(created_at) = '2026-09-01'
WHERE created_at::date = '2026-09-01'
WHERE extract(year FROM created_at) = 2026

Ikki yechim bor. Ifodani index qilish:

CREATE INDEX idx_customers_email_lower ON customers (lower(email));

Yoki — odatda yaxshiroq yo'l — predikatni ustun yalang'och qoladigan qilib qayta yozish:

-- Sargable: oddiy created_at index'i bo'ylab diapazon scan.
WHERE created_at >= '2026-09-01' AND created_at < '2026-09-02'

Ikkinchisi yaxshiroq, chunki qo'shimcha index talab qilmaydi, va sessiyaning TimeZone sozlamasiga bog'liq cast'ni yashirish o'rniga vaqt zonalari bilan halol muomala qiladi.

Ifoda index'i ham query'ga aynan mos kelishi kerak: lower(email) ustidagi index WHERE upper(email) = ... uchun hech nima qilmaydi. Registrga bog'liq bo'lmagan email uchun yozishda normallashtiring va bitta kanonik shaklni saqlang — ilova darajasidagi qoida qo'llansa bo'ladigan har bir holatda index'dan ustun turadi.

Ko'rinmaydigan tip nomuvofiqliklari

ORM parametrni text sifatida yuboradi. Ustun esa varchar(64). Bunisi joyida — Postgres'da varchar va text bir xil collation va solishtirish xatti-harakatiga ega, index ishlayveradi.

Tishlaydigani — raqamlilar, va ko'pincha aynan bu:

-- legacy_code — varchar. Bu ustunni raqam deb biladigan ORM ustun tomonida
-- aniq cast chiqaradi va legacy_code ustidagi index o'yindan chiqadi.
SELECT * FROM orders WHERE legacy_code::bigint = 88213;

Postgres tiplarni solishtiriladigan holga keltirishi kerak. Cast literal emas, ustun tomonga tushganda, har bir qator solishtirishdan oldin konvertatsiya qilinishi shart va index o'yindan chiqadi. EXPLAIN da bu quyidagicha ko'rinadi, va uni sezmasdan o'tib ketish oson:

Filter: ((legacy_code)::bigint = 88213)

Yonidagi holatni ham bilib qo'ygan ma'qul: varchar ustunni yalang'och butun son bilan solishtirish (WHERE legacy_code = 88213) umuman jimgina cast qilmaydi — Postgres'da varchar = integer operatori yo'q va u operator does not exist xatosini beradi. Baland ovozli xato — yaxshiroq natija. Xavflisi yuqoridagisi: kimdir ustunni cast qilib, tiplarni zo'rma-zo'raki moslashtirgan.

Filter yoki Index Cond ichida ustun atrofiga o'ralgan cast ko'rsangiz — muammo o'sha. Uni chaqiruv joyida tuzating:

// Xato: tiplarni moslashtirish uchun ustun cast qilinadi, index ishlamaydi.
await db.query('SELECT * FROM orders WHERE legacy_code::bigint = $1', [ref]);
 
// To'g'ri: ustunga tegmang, parametrni uning o'z tipida yuboring.
await db.query('SELECT * FROM orders WHERE legacy_code = $1', [String(ref)]);

Partial va covering index'lar

Bergan foydasiga nisbatan juda kam ishlatiladigan ikki usul.

Partial index faqat siz haqiqatan so'rov yuboradigan qatorlarni qamrab oladi. Agar buyurtmalar jadvalingizning 98% i yakuniy holatda bo'lsa va har bir issiq query kutilayotganlarga qarasa, qolgan 98% ni index qilish — sof ortiqcha yuk:

CREATE INDEX idx_orders_pending
  ON orders (created_at)
  WHERE status IN ('pending', 'awaiting_payment');

Kichikroq index, uning ko'proq qismi keshda, yozishda arzonroq xizmat. Shartli tomoni shuki, planner uni faqat sizning WHERE ingiz index predikatini keltirib chiqarishini isbotlay olgandagina ishlatadi — ya'ni status = 'pending' ishlaydi, o'zgaruvchi status = $1 esa yo'q. Partial index'lar barqaror, qattiq yozilgan predikatlarni mukofotlaydi.

Ular shartli unikallikni ham ta'minlaydi — foydali qo'shimcha ta'sir:

-- Har bir mijozga bitta aktiv eSIM profil; bekor qilinganlari cheklanmagan.
CREATE UNIQUE INDEX idx_one_active_profile
  ON esim_profiles (customer_id)
  WHERE status = 'active';

Covering index query'ga kerak bo'lgan har bir ustunni o'z ichiga oladi, shuning uchun Postgres faqat index'dan javob bera oladi — Index Only Scan, heap'ga kirmasdan:

CREATE INDEX idx_orders_customer_created
  ON orders (customer_id, created_at DESC)
  INCLUDE (status, total_cents);

Ogohlantirish: "index only" — shartli narsa. Postgres baribir har bir qator sizning tranzaksiyangizga ko'rinadimi-yo'qmi tekshiradi, va bu tekshiruvni faqat visibility map'da "hammaga ko'rinadi" deb belgilangan sahifalar uchun o'tkazib yuboradi — o'sha map'ni esa VACUUM yuritadi. So'nggi paytda ko'p yozuv bo'layotgan jadvalda map eskirgan bo'ladi va siz baribir heap fetch olasiz. EXPLAIN (ANALYZE) buni to'g'ridan-to'g'ri aytadi:

Index Only Scan using idx_orders_customer_created on orders
  (actual time=0.02..1.9 rows=20 loops=1)
  Heap Fetches: 18

20 qatordan Heap Fetches: 18 degani — foydaning deyarli hech qanchasini olmayapsiz. Bu endi vacuum va autovacuum sozlash haqidagi suhbat, index haqidagi emas.

Hech kim ishlatmaydigan index'larni toping

Saqlab turgan har bir index'ingiz uchun har bir yozuvda haq to'laysiz. orders ga insert jadvalni ham, uning har bir index'ini ham yangilaydi: oltita index degani — har bir insert va indexlangan ustunga tegadigan har qanday update'da oltita B-tree xizmat operatsiyasi hamda ularni tavsiflovchi qo'shimcha WAL.

Postgres o'z haqini kim oqlayotganini aytib beradi:

SELECT
  relname            AS table_name,
  indexrelname       AS index_name,
  idx_scan           AS times_used,
  pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
JOIN pg_index USING (indexrelid)
WHERE idx_scan = 0
  AND NOT indisunique          -- unique index'lar cheklovni ta'minlaydi, ularni qoldiring
ORDER BY pg_relation_size(indexrelid) DESC;

idx_scan = 0 bo'lgan index statistika oxirgi marta tozalangandan beri birorta scan'ga xizmat qilmagan. Biror narsani o'chirishdan oldin tekshiring: hisoblagichlar qancha vaqtdan beri to'planyapti (pg_stat_reset va katta yangilanishlar ularni nolga tushiradi), va index shu oyna ichida ishga tushmagan oylik vazifaga xizmat qiladimi. Keyin uni trafikni bloklamasdan o'chiring:

DROP INDEX CONCURRENTLY idx_orders_status;

Takrorlanishlarga alohida qarashga arziydi. (customer_id, status) paydo bo'lgach, (customer_id) ustidagi index ortiqcha — ikkinchisi birinchisi xizmat qiladigan har bir query'ga xizmat qiladi. Men uchratadigan eng keng tarqalgan index bloat'i shu, va u aynan shu post boshlangan hikoyadan kelib chiqadi: kimdir index qo'shdi, u yordam bermadi, kengrog'ini qo'shdi, birinchisini esa hech kim olib tashlamadi.

Men qat'iy deb bilmaydigan narsalar

Quyidagilarning hech birini hal qilingan masala deb hisoblamayman.

Yosh jadvaldagi ishlatilmayotgan index'larni o'chirmayman. Birinchi oy yakunini hali ko'rmagan funksiyada ikki haftalik pg_stat_user_indexes dalil emas.

INCLUDE ustunlarini standart yechim sifatida ishlatishga ehtiyotkorman. Ular index'ni kengaytiradi, kengroq index esa keshga kamroq sig'adi — bitta issiq query uchun haqiqiy yutuq, hamma joyga qo'llanganda sekin oqib turadigan zarar.

SET enable_seqscan = off ni faqat diagnostika sifatida — "index bilan plan qancha turardi?" deb so'rash uchun ishlataman. Bu yechim emas, va uni konfigda qoldirish keyinchalik hech kim tushuntira olmaydigan plan olishning yo'li.

Va idx_scan ishlatilmayapti desa ham, o'chirish bo'ladigan jadvalda foreign key'ni qo'llab-quvvatlayotgan index'ni saqlab qolaman. Postgres murojaat qiluvchi ustunda index bo'lishini talab qilmaydi, index bo'lmasa esa ota jadvaldagi delete bola jadvalni skanerlaydi.

Umumlashadigan qismi

Ishlatilmagan index — bu ma'lumotingiz haqida siz ishonadigan narsa bilan Postgres o'lchagan narsa orasidagi kelishmovchilik. Bunday index topsangiz, foydali harakat uni kattalashtirish emas. Ikkovingizdan qaysi biringiz xato ekanini aniqlash.

Ko'p hollarda bu — kichik, tekshirsa bo'ladigan narsa: siz yozmagan cast, noto'g'ri o'rindagi ustun, index saqlamaydigan qiymat atrofiga o'ralgan funksiya. Bularning barchasi EXPLAIN (ANALYZE, BUFFERS) da ko'rinadi — agar uni raqamlar devori emas, qarorlar tavsifi sifatida o'qisangiz.

Asosiy mahorat ham shu. Index qo'yish — qaysi ustunlarni index qilish haqidagi qoidalar to'plami emas. Bu — bazadan nima qilgani va nega qilganini so'rash odati, hamda javobni chetlab o'tiladigan to'siq emas, tizim haqidagi ma'lumot sifatida qabul qilish.

O'xshash maqolalar

postgresqlperformance

Postgres 18: asinxron I/O va UUIDv7 menda nimani o'zgartirdi

Asinxron I/O va ichki `uuidv7()` — asosiy yangiliklar shular edi. Ulardan biri men ikki yil chetlab o'tib kelgan muammoni jimgina hal qildi; ikkinchisi esa benchmarklar va'da qilganidan kamroq ish qildi.

5 daqiqa o'qish
system-designbackend

Ikki marta yechilgan to'lov

Qayta urinish takroriy so'rov emas — to'lov ikki marta o'tib ketguncha. Idempotentlik kalitlari aslida qanday ishlaydi, sodda variant nega baribir ikki marta yechadi va qaysi Postgres cheklovi kafolatni haqiqiy qiladi.

7 daqiqa o'qish
backendredis

Keshni bekor qilish — amaliyotda

Hazil bu ikkita qiyin muammodan biri deydi. Haqiqat esa shuki, ko'p jamoalar bekor qilish strategiyasini umuman yozmaydi — ular TTL yozib, umid qiladi. Muqobillar aslida nimaga tushishini ko'ramiz.

8 daqiqa o'qish