IlmHamroh
JavaScript Full-stack/26-qism. SQL va PostgreSQL63/64-dars19 daqiqa
Mundarija (25)

N+1 so'rov muammosi: aniqlash va JOIN, ANY va DataLoader bilan tuzatish

Qisqacha: N+1 — ro'yxatni bitta so'rov bilan olib, keyin har element uchun yana bitta so'rov yuborish. 50 ta vazifa uchun 1 + 50 + 50 = 101 so'rov. Har so'rov kichik bo'lsa ham, har biri tarmoq aylanishi va bazada rejalashtirish vaqtini oladi. Kanonda 101 so'rov 152 ms oldi; WHERE vazifa_id = ANY($1) bilan 3 so'rov — 5 ms; bitta so'rov (json_agg) — ham 5 ms. Natija uchala usulda bir xil.

Bu darsda

  • N+1 muammosini kodda va so'rovlar sonida taniysiz.
  • So'rovlarni sanab, muammoni o'lchov bilan isbotlaysiz.
  • = ANY($1) bilan batch yuklashni va json_agg bilan bitta so'rovni yozasiz.
  • DataLoader g'oyasini — bitta "tick" ichidagi so'rovlarni yig'ishni — o'zingiz amalga oshirasiz.
  • Qaysi yechim qachon to'g'ri ekanini tanlaysiz.

Oldin bilishingiz kerak: Sekin so'rovlarni topish, JOIN'lar, node-postgres (pg) asoslari, Parametrli so'rovlar va SQL injection, Agregatlar, GROUP BY va HAVING.

1. Nega bu kerak?

vazifalar ilovasining bosh sahifasi: Azizning 50 ta vazifasi, har birining yonida izohlari va teglari. Birinchi yozilgan kod odatda shunday ko'rinadi:

js
const vazifalar = await royxatniOl(egasiId);   // 1 so'rov
for (const v of vazifalar) {
  v.izohlar = await izohlarniOl(v.id);        // 50 so'rov
  v.teglar = await teglarniOl(v.id);          // 50 so'rov
}

Mantiqan to'g'ri, o'qish oson, testlar o'tadi. Lokal kompyuterda tez ishlaydi. Production'da esa sekin. Nega?

O'xshatish: bozorga 50 xil mahsulot uchun ro'yxat bilan borish o'rniga, har mahsulot uchun uydan alohida chiqib, bozorga borib, qaytib kelish. Har safar sotib olish 1 daqiqa — lekin yo'l 20 daqiqa. 50 × 21 = 17.5 soat. Ro'yxat bilan bitta borishda — 70 daqiqa.

Bazada "yo'l" — tarmoq aylanishi (round trip): so'rov serverga boradi, javob qaytadi. Kanon o'lchovida (Docker Desktop, bitta kompyuterda) har aylanish ~1 ms edi. Server boshqa ma'lumotlar markazida bo'lsa — 5–20 ms.

Bu muammoning nomi — N+1: 1 ta ro'yxat so'rovi + N ta element so'rovi. Bizda izoh va teg uchun ikki marta N — 2N+1, lekin nomi o'zgarmaydi.

Oldingi darsda pg_stat_statements ni o'rgandik. N+1 u yerda "sekin so'rov" bo'lib ko'rinmaydi — har bir so'rov 0.1 ms. Ko'rinadigan narsa — calls ustunidagi ulkan son.

2. Muammoni sanash

2.1 Soxta baza bilan

Avval muammoni bazasiz ko'ramiz. Har chaqiruvni sanaydigan "baza" funksiyasi:

js
// "Baza" o'rnida — har chaqiruvni sanaydigan soxta funksiyalar
let sorovlar = 0;
const vazifalar = Array.from(
  { length: 50 },
  (_, i) => ({ id: i + 1 }),
);
const izohlar = [
  { vazifa_id: 3, matn: "Zo'r" },
  { vazifa_id: 7, matn: "?" },
];

async function sorov(filtr) {
  sorovlar++;
  return izohlar.filter(filtr);
}

// N+1: har vazifa uchun alohida so'rov
sorovlar = 1; // ro'yxatning o'zi
for (const v of vazifalar) {
  v.izohlar = await sorov((i) => i.vazifa_id === v.id);
}
console.log("N+1:", sorovlar, "so'rov");

// Batch: hamma id'lar bitta so'rovda (= ANY($1))
sorovlar = 1;
const idlar = new Set(vazifalar.map((v) => v.id));
const hammasi = await sorov((i) => idlar.has(i.vazifa_id));
for (const v of vazifalar) {
  v.izohlar = hammasi.filter((i) => i.vazifa_id === v.id);
}
console.log("Batch:", sorovlar, "so'rov");
console.log(vazifalar[2].izohlar);

Konsolda:

text
N+1: 51 so'rov
Batch: 2 so'rov
[ { vazifa_id: 3, matn: "Zo'r" } ]

Natija bir xil, so'rovlar soni 25 marta kam. Tuzatishning mohiyati shu: "har biri uchun bittadan" o'rniga "hammasi uchun bitta", keyin JavaScript'da guruhlash.

2.2 Haqiqiy Postgres bilan: kanon o'lchovi

Kanon tajribasi katta bazada (2 mln vazifa) egasi 77 ning 50 ta vazifasini izohlari va teglari bilan oldi — uch usulda, har birini 7 marta, mediana. So'rovlar soni pg_stat_statements siz, oddiy hisoblagich bilan sanaldi: pool.query ni o'rab, har chaqiruvda sanoq++.

N+1 varianti (kanon kodidan, qatorlar telefonga moslab bo'lingan):

js
import pg from "pg";

const p = new pg.Pool({ connectionString: URL_("katta"), max: 5 });
let sanoq = 0;
const q = (text, values) => {
  sanoq++;
  return p.query(text, values);
};
const EGASI = 77, LIMIT = 50;

async function n1() {   // 1 + N + N
  const { rows } = await q(
    `SELECT id, matn FROM faol_vazifalar WHERE egasi_id = $1
     ORDER BY yaratilgan_vaqt DESC, id DESC LIMIT $2`,
    [EGASI, LIMIT]);
  for (const v of rows) {
    v.izohlar = (await q(
      `SELECT matn FROM izohlar WHERE vazifa_id = $1
       ORDER BY yaratilgan_vaqt`, [v.id])).rows;
    v.teglar = (await q(
      `SELECT t.nom FROM vazifa_teglari vt
       JOIN teglar t ON t.id = vt.teg_id
       WHERE vt.vazifa_id = $1`, [v.id])).rows.map((r) => r.nom);
  }
  return rows;
}

URL_ — kanon tajribalarining yordamchi funksiyasi (lokal trust ulanish satrini qaytaradi). faol_vazifalar — Savatda bo'lmagan vazifalar ko'rinishi (soft delete darsi).

Natija (Node 24.21.0, pg 8.23.1, PostgreSQL 18.6 Docker'da):

text
N+1                                  so'rovlar 101, mediana  152.0 ms, natija N+1 bilan bir xil: true
batch (= ANY)                        so'rovlar   3, mediana    5.0 ms, natija N+1 bilan bir xil: true
bitta so'rov (subquery + json_agg)   so'rovlar   1, mediana    5.0 ms, natija N+1 bilan bir xil: true
izoh: har so'rov ~1 ms tarmoq aylanishi (Docker Desktop, 127.0.0.1) — N+1 narxi asosan shu; uzoq server (masalan 20 ms) bilan 101 so'rov ≈ 2 s
50 vazifa + izohlar + teglar: uch usul
  • N+1 (101 so'rov)152 ms
  • Batch = ANY (3 so'rov)5 ms
  • Bitta so'rov (1)5 ms

Manba: IlmHamroh kanon o'lchovi, 2026-10-24: Node 24.21.0, pg 8.23.1, PostgreSQL 18.6 (Docker, WSL2), 2 mln vazifa; 7 marta, mediana

30 marta tez. Va bu eng yaxshi holat — baza shu kompyuterda. Oxirgi qatordagi hisob: server 20 ms uzoqlikda bo'lsa, 101 so'rov ≈ 2 soniya. Bu hisob, kanon uni o'lchamagan (uzoq server sinalmagan).

Tekshirib ko'ring: Sahifada 20 ta buyurtma, har birida mijoz ismi, mahsulotlar ro'yxati va yetkazib berish manzili alohida so'rov bilan olinadi. Nechta so'rov ketadi?

Javob

1 + 20 × 3 = 61. Ro'yxat bitta, har buyurtma uchun uchta. 20 ta emas, 100 ta buyurtma ko'rsatilsa — 301. N+1 sahifadagi element soni bilan chiziqli o'sadi; batch esa doim 4 (ro'yxat + uch toifa).

3. Yechim 1: batch — = ANY($1)

3.1 G'oya

Avval ro'yxatni olamiz, undan id'larni yig'amiz va bitta so'rov bilan hamma izohlarni olamiz. Parametrli so'rovlar darsida = ANY($1::bigint[]) ni ko'rgandik: massivni bitta parametr sifatida yuborish.

Kanon kodidagi batch varianti:

js
async function batch() {   // 1 + 1 + 1
  const { rows } = await q(
    `SELECT id, matn FROM faol_vazifalar WHERE egasi_id = $1
     ORDER BY yaratilgan_vaqt DESC, id DESC LIMIT $2`,
    [EGASI, LIMIT]);
  const idlar = rows.map((v) => v.id);
  const [iz, tg] = await Promise.all([
    q(`SELECT vazifa_id, matn FROM izohlar
       WHERE vazifa_id = ANY($1::bigint[])
       ORDER BY yaratilgan_vaqt`, [idlar]),
    q(`SELECT vt.vazifa_id, t.nom FROM vazifa_teglari vt
       JOIN teglar t ON t.id = vt.teg_id
       WHERE vt.vazifa_id = ANY($1::bigint[])`, [idlar]),
  ]);
  for (const v of rows) {
    v.izohlar = iz.rows.filter((r) => r.vazifa_id === v.id)
      .map(({ matn }) => ({ matn }));
    v.teglar = tg.rows.filter((r) => r.vazifa_id === v.id)
      .map((r) => r.nom);
  }
  return rows;
}

Uch nuqta:

  1. Promise.all — izohlar va teglar so'rovlari bir vaqtda ketadi (pool'dan ikki ulanish). Ikkalasi bir-biriga bog'liq emas.
  2. filter bilan guruhlash — JavaScript'da. 50 vazifa uchun bu arzon. Katta ro'yxatda Map bilan guruhlash tezroq (2-mashqda).
  3. v.id va r.vazifa_id ikkalasi ham string — bigint ustunlar pg da string bo'lib keladi (pg asoslari). Taqqoslash === ishlaydi, chunki ikkalasi ham bir xil turda. Biri setTypeParser bilan son, ikkinchisi string bo'lsa — hech narsa topilmaydi, xato ham chiqmaydi.

3.2 SQL tomoni

Biz ishga tushirdik (PostgreSQL 17.6, holat 40): Azizning to'rtta vazifasi uchun izohlar soni — bitta so'rov bilan:

sql
SELECT vazifa_id, count(*) AS izohlar
FROM izohlar
WHERE vazifa_id = ANY ('{1,2,9,17}'::bigint[])
GROUP BY vazifa_id ORDER BY vazifa_id;

Natija:

text
 vazifa_id | izohlar 
-----------+---------
         1 |       3
         2 |       1
(2 rows)

9 va 17 yo'q — ularda izoh yo'q. Bu batch'ning bitta nozik joyi: izohsiz vazifa uchun qator kelmaydi. JavaScript'da filter bo'sh massiv beradi — to'g'ri. Lekin SQL'da "hamma id va 0" kerak bo'lsa — unnest + LEFT JOIN:

sql
SELECT v.id, count(i.id) AS izohlar
FROM unnest('{1,2,9,17}'::bigint[]) AS v(id)
LEFT JOIN izohlar i ON i.vazifa_id = v.id
GROUP BY v.id ORDER BY v.id;

Natija:

text
 id | izohlar 
----+---------
  1 |       3
  2 |       1
  9 |       0
 17 |       0
(4 rows)

unnest massivni qatorlarga aylantiradi. count(i.id) — count(*) emas: LEFT JOIN'da mos izoh bo'lmasa i.id NULL, count uni sanamaydi (NULL mantiqi).

Diqqat: Massiv juda katta bo'lsa (o'n minglab id) — bitta parametr bo'lib qoladi, $1, $2, … cheklovi (ommaviy ma'lumot darsida kanon 65 535 chegarasini topgan) bu yerda yo'q. Lekin bunday ro'yxatni bitta sahifada ko'rsatish o'zi muammo — sahifalash kerak (keyingi dars).

4. Yechim 2: bitta so'rov

4.1 JOIN va uning tuzog'i

Birinchi fikr — hammasini JOIN bilan olish:

sql
SELECT v.id, v.matn, i.matn AS izoh, t.nom AS teg
FROM faol_vazifalar v
LEFT JOIN izohlar i ON i.vazifa_id = v.id
LEFT JOIN vazifa_teglari vt ON vt.vazifa_id = v.id
LEFT JOIN teglar t ON t.id = vt.teg_id
WHERE v.egasi_id = $1;

Muammo: 3 izohli va 2 tegli vazifa 3 × 2 = 6 qator bo'lib keladi — izohlar va teglar bir-biriga ko'payadi (JOIN darsida ko'rgan dekart ko'paytma). JavaScript'da ularni qayta "yig'ish" va takrorlarni tashlash kerak. Ikki xil "ko'p" bog'lanishni bitta tekis JOIN bilan olmang.

4.2 Subquery + json_agg

To'g'ri yo'l — har vazifa uchun izohlarni ichki so'rovda massivga yig'ish (agregatlar darsi — json_agg, array_agg). Biz ishga tushirdik (PostgreSQL 17.6, holat 40):

sql
SELECT v.id, left(v.matn, 25) AS matn,
       coalesce((SELECT json_agg(i.matn ORDER BY i.yaratilgan_vaqt)
                 FROM izohlar i WHERE i.vazifa_id = v.id), '[]')
         AS izohlar
FROM faol_vazifalar v
WHERE v.egasi_id = 1
ORDER BY v.id LIMIT 4;

Natija:

text
 id |           matn            |                                                      izohlar                                                      
----+---------------------------+-------------------------------------------------------------------------------------------------------------------
  1 | JavaScript kursining 26-q | ["Docker darslaridan boshladim", "psql buyruqlari esda qolmayapti — \\? yordam beradi", "JOIN tushunarli bo'ldi"]
  2 | Postgres'ni Docker'da ko' | ["Port 5432 band ekan, 5499 ga o'tdim"]
  9 | Onamga dori olib borish # | []
 17 | Portfolio saytiga blog yo | []
(4 rows)

Har vazifa — bitta qator. Izohlar — tayyor JSON massivi. pg uni JavaScript massiviga o'zi aylantiradi. coalesce(…, '[]') — izohsiz vazifada json_agg NULL qaytaradi, biz uni bo'sh massivga almashtiramiz. \\? — psql JSON ichidagi teskari chiziqni qochirib ko'rsatgan (asl matnda \?). 8-vazifa yo'q — u Savatda.

Kanon varianti teglar uchun array_agg ham qo'shadi (1 so'rov, 5.0 ms):

js
async function bittaSorov() {   // 1
  const { rows } = await q(`SELECT v.id, v.matn,
      coalesce((SELECT json_agg(json_build_object('matn', i.matn)
                  ORDER BY i.yaratilgan_vaqt)
                FROM izohlar i WHERE i.vazifa_id = v.id), '[]')
        AS izohlar,
      coalesce((SELECT array_agg(t.nom) FROM vazifa_teglari vt
                JOIN teglar t ON t.id = vt.teg_id
                WHERE vt.vazifa_id = v.id), '{}') AS teglar
    FROM faol_vazifalar v WHERE v.egasi_id = $1
    ORDER BY v.yaratilgan_vaqt DESC, v.id DESC LIMIT $2`,
    [EGASI, LIMIT]);
  return rows;
}

Bu yerdagi ichki so'rovlar ham har qator uchun bajariladi — N+1 ga o'xshaydi! Farqi: ular baza ichida, tarmoq aylanishisiz va bitta rejada. Indeks bo'lsa (izohlar (vazifa_id, yaratilgan_vaqt) — kanon sxemasida bor), har biri mikrosekundlar oladi. Natija: 1 so'rov, 5 ms.

4.3 Qaysi biri yaxshi

Batch (= ANY) Bitta so'rov
So'rovlar 1 + toifalar soni 1
SQL murakkabligi Oddiy O'rtacha (subquery, JSON)
Guruhlash JavaScript'da Bazada
Qayta ishlatish Har toifa — alohida funksiya Bitta katta so'rov
Kanon vaqti 5.0 ms 5.0 ms

Kanonda ikkalasi teng. Amalda: batch — kodni bo'laklarga ajratish oson (izohlar moduli, teglar moduli), ORM va DataLoader shunday ishlaydi. Bitta so'rov — eng kam aylanish, lekin so'rov kattalashib boradi. Ikkalasi ham N+1 dan o'nlab marta yaxshi.

Tekshirib ko'ring: json_agg li bitta so'rovda ichki subquery har vazifa uchun bajariladi. Nega bu N+1 emas?

Javob

N+1 ning narxi — har so'rov uchun alohida tarmoq aylanishi, rejalashtirish va ilova tomonidagi ishlov. Ichki subquery esa bitta so'rov rejasining ichida, bazaning o'zida, indeks orqali bajariladi: tarmoq yo'q, rejalashtirish bir marta. Ichki tomonda indeks bo'lmasa esa haqiqatan sekin bo'lardi — har qator uchun Seq Scan (EXPLAIN darsida ko'rgan Nested Loop xavfi).

5. DataLoader g'oyasi

5.1 Muammo: kod tuzilmasi

Batch yechimi bitta funksiyada hammasini biladi: "avval ro'yxat, keyin hamma izohlar". Katta ilovada esa kod bo'laklarga bo'lingan. Masalan, GraphQL serverida yoki komponentlar daraxtida har vazifa "o'z izohlarini" so'raydi — ular bir-birini bilmaydi. Har biri izohlarniOl(id) chaqiradi va N+1 qaytadi.

DataLoader (Facebook yaratgan kutubxona, keyin hamma tillarga ko'chgan) buni shunday hal qiladi: yukla(id) darhol so'rov yubormaydi. U id'ni navbatga qo'yadi va joriy sinxron kod tugaguncha kutadi (microtask navbati). Keyin to'plangan hamma id'lar uchun bitta batch so'rov yuboradi va har chaqiruvchiga o'z natijasini qaytaradi.

sequenceDiagram
  participant V1 as Vazifa 1
  participant V2 as Vazifa 2
  participant L as DataLoader
  participant DB as Baza
  V1->>L: yukla(1)
  V2->>L: yukla(2)
  Note over L: microtask: navbat [1, 2]
  L->>DB: izohlar WHERE vazifa_id = ANY('{1,2}')
  DB-->>L: hammasi
  L-->>V1: 1 ning izohlari
  L-->>V2: 2 ning izohlari

Diagrammaga qarang: ikki vazifa bir-birini bilmaydi, lekin bazaga bitta so'rov ketdi. Mustaqil kod va batch so'rov — ikkalasi bir vaqtda.

3-mashqda DataLoader'ning soddalashtirilgan versiyasini o'zingiz yozasiz (20 qatorga yaqin). Haqiqiy dataloader npm paketi qo'shimcha imkoniyatlarga ega: kesh (bir so'rov ichida bir id ikki marta yuklanmaydi), batch hajmi chegarasi. U GraphQL chuqur darsida ishlatiladi; bu darsda o'rnatilmagan va sinalmagan.

5.2 ORM'larda

ORM'lar (27-qismda: Prisma, Drizzle) bog'liq ma'lumotni "include" bilan so'rashga imkon beradi: vazifalar.findMany({ include: { izohlar: true } }). Ichida ular batch yoki JOIN so'rovi yuboradi. Lekin ORM bilan ham N+1 yozish oson: for ichida findMany chaqirish. ORM muammoni yashirmaydi — faqat to'g'ri vositani beradi. ORM'lar bu darsda sinalmagan, 27-qismda o'lchanadi.

6. N+1 ni qanday aniqlash

  1. So'rovlarni sanash — kanon usuli: pool.query ni o'rab, har so'rov uchun hisoblagich. Testda: "bu endpoint ko'pi bilan 3 ta so'rov yuborsin" degan tekshiruv. So'rovlar soni element soniga bog'liq bo'lsa — N+1.
  2. pg_stat_statements (oldingi dars) — calls juda katta, mean_exec_time juda kichik so'rov shakli: SELECT … FROM izohlar WHERE vazifa_id = $1, calls = 2 000 000.
  3. Log — log_min_duration_statement = 0 qisqa muddatga: bitta sahifa ochilganda logda 100 ta bir xil so'rov ketma-ket.
  4. APM vositalari (Sentry, Datadog) — so'rov "sharshara"sini chizadi: bitta HTTP so'rov ichida 100 ta mayda DB chaqiruvi (hujjat bo'yicha, sinalmagan).

7. Node qadami: API ro'yxatida N+1 yo'q

GET /v1/vazifalar har vazifani teglari bilan qaytaradi. N+1 xavfi aynan shu yerda edi: "har vazifa uchun teglarini olish". PgOmbori buni bazaga bormasdan hal qiladi — teglar vazifa matnidan ajratiladi, 25-qismdagi xotira ombori bilan bir xil qoida (kanondan, qisqartirildi):

ts
function vazifa(q: VazifaQatori): Vazifa {
  return {
    id: q.id,
    matn: q.matn,
    // teglar matndan — xotira ombori bilan aynan bir xil qoida
    teglar: teglariniOl(q.matn),
    // … vaqtlar, tartib, versiya
  };
}

vazifa_teglari jadvali (uni trigger to'ldiradi) faqat filtr (?teg=ish) va /v1/teglar hisobi uchun ishlatiladi. Izohlar esa API'da hali yo'q.

So'rovlar sonini kanon "So'rovlarni sanash" usuli bilan tekshirdi. EXPLAIN ANALYZE'ni o'qish darsidagi reja-olchov.ts ulanishning query metodini o'raydi va har royxat() chaqiruvida nechta so'rov ketganini yozadi. Katta bazada (013/014 bilan qayta qurilgan) har holatning sarlavha qatori (reja-64.txt):

text
=== sukut (tartib, id), 1-sahifa: royxat() 3.1 ms, so'rovlar 2
=== sukut, kursor bilan 2-sahifa: royxat() 3.2 ms, so'rovlar 2
=== teg=ish + bajarildi=false: royxat() 5.2 ms, so'rovlar 2
=== sort=matn (COLLATE "C"): royxat() 2.8 ms, so'rovlar 2
=== jami=true: royxat() 2.9 ms, so'rovlar 3

Nima bo'ldi? So'rovlar soni sahifadagi vazifalar soniga (20) bog'liq emas: ro'yxatning o'zi + ro'yxat versiyasi (SELECT vazifalar_versiyasi …), jami=true bo'lsa yana count(*). "So'rovlar soni element soniga bog'liq emas" — N+1 yo'qligining mezoni shu. Teg filtri esa SQL ichidagi correlated ichki so'rov — bu N+1 emas, chunki u bazaning ichida bajariladi («Qaysi biri yaxshi» bo'limidagi savolga qarang).

Yana bitta tuzoq loyihada yo'q. Batch'dagi "bigint id: biri son, biri string" muammosi PgOmbori da uchramaydi. Hovuz o'z TURLAR parseri bilan ochiladi: INT8 → xavfsiz Number (node-postgres asoslari). Shart bitta — hamma so'rov shu hovuzdan o'tsin.

Bu darsda loyiha kodi o'zgarmadi. Holat 64 ning commit'i — Keyset pagination darsida.

8. Ko'p uchraydigan xatolar

Xato Oqibat To'g'risi
for ichida await query N+1 Id'larni yig'ib, = ANY($1)
Ikki "ko'p" bog'lanish bitta tekis JOIN'da Qatorlar ko'payadi (3 × 2) Har biri uchun subquery + json_agg
Batch'da izohsiz element unutiladi undefined yoki xato ?? [], SQL'da unnest + LEFT JOIN
bigint id: biri son, biri string filter hech narsa topmaydi Bir xil tur (setTypeParser hamma joyda)
json_agg NULL Ilovada null.map xatosi coalesce(…, '[]')
"Lokalda tez ishlayapti" Production'da tarmoq aylanishi 10–20 marta katta So'rovlar sonini o'lchang

9. Mashqlar

Mashqlar kurs/mashqlar/26/63-n1/ da. 2-mashq holat 40 bazasida, 3-mashq — Node 24 da tekshirilgan (biz ishga tushirdik).

1-mashq (oson): to'ldiring

  • 1 ta ro'yxat + har element uchun 1 ta so'rov — muammosi
  • Id'lar massivini bitta parametr qilib yuborish — WHERE vazifa_id = ($1::bigint[])
  • Ichki so'rov natijasini JSON massivga yig'adigan funksiya —
  • Bir "tick" ichidagi so'rovlarni bittaga yig'adigan naqsh —
  • 50 vazifa + izohlar + teglar: N+1 da nechta so'rov?
Yechim
  • N+1 — 1 ro'yxat + N element.
  • ANY — = ANY($1::bigint[]), bo'sh massivda 0 qator (xato emas).
  • json_agg — bo'sh bo'lsa NULL, coalesce bilan '[]'.
  • DataLoader — microtask'gacha kutib, batch yuboradi.
  • 101 — 1 + 50 + 50.

2-mashq (o'rta): N+1 ni SQL bilan almashtiring

Holat 40 bazasida uchta so'rov yozing. (a) Azizning 1, 2, 9, 17-vazifalari uchun izohlar sonini bitta so'rov bilan chiqaring. (b) Izohsiz vazifalar ham 0 bilan chiqsin. (c) Azizning birinchi 4 ta faol vazifasini (id bo'yicha) izohlari JSON massivi bilan bitta so'rovda oling.

Yechim

So'rovlar «Batch» va «Bitta so'rov» bo'limlaridagilar bilan bir xil:

sql
-- a) id'lar ro'yxati bo'yicha izohlar — bitta so'rovda
SELECT vazifa_id, count(*) AS izohlar
FROM izohlar
WHERE vazifa_id = ANY ('{1,2,9,17}'::bigint[])
GROUP BY vazifa_id ORDER BY vazifa_id;

-- b) izohsiz vazifalar ham chiqsin (0) — unnest + LEFT JOIN
SELECT v.id, count(i.id) AS izohlar
FROM unnest('{1,2,9,17}'::bigint[]) AS v(id)
LEFT JOIN izohlar i ON i.vazifa_id = v.id
GROUP BY v.id ORDER BY v.id;

-- c) Azizning birinchi 4 vazifasi izohlari bilan — bitta so'rov
SELECT v.id, left(v.matn, 25) AS matn,
       coalesce((SELECT json_agg(i.matn ORDER BY i.yaratilgan_vaqt)
                 FROM izohlar i WHERE i.vazifa_id = v.id), '[]')
         AS izohlar
FROM faol_vazifalar v
WHERE v.egasi_id = 1
ORDER BY v.id LIMIT 4;

Natijalar — yuqoridagi bo'limlarda. (a) da faqat 2 qator — izohsiz vazifalar yo'qoladi; (b) to'rttasini ham beradi. Ilova uchun (a) yetarli: JavaScript'da Map bilan guruhlab, topilmaganiga 0 qo'yiladi:

js
const qatorlar = [
  { vazifa_id: "1", izohlar: "3" },
  { vazifa_id: "2", izohlar: "1" },
];
const soni = new Map(
  qatorlar.map((r) => [r.vazifa_id, Number(r.izohlar)]),
);
const idlar = ["1", "2", "9", "17"];
console.log(idlar.map((id) => `${id}: ${soni.get(id) ?? 0}`));

Konsolda:

text
[ '1: 3', '2: 1', '9: 0', '17: 0' ]

qatorlar — pg qaytaradigan shaklda: bigint va count string bo'lib keladi, shuning uchun id'lar ham string, soni Number() bilan.

3-mashq (qiyin): mini DataLoader

yuklovchiYarat(toplamFn) funksiyasini yozing. U yukla(id) funksiyasini qaytaradi: chaqiruv Promise beradi, lekin so'rov darhol ketmaydi. Bir sinxron qism ichidagi hamma yukla chaqiruvlari yig'ilib, queueMicrotask da bitta toplamFn(idlar) chaqiriladi; har Promise o'z natijasini oladi. Xato bo'lsa — hamma kutayotganlar reject bo'lsin. Soxta "baza" bilan sinang: 4 ta vazifa — 1 ta so'rov.

Yechim
js
let sorovlar = 0;
const izohlarBazasi = {
  1: ["Zo'r", "Ertaga"], 2: [], 3: ["Bajarildi"], 4: ["?"],
};

// "Baza": id lar massivi -> har id uchun izohlar (= ANY($1) kabi)
async function izohlarniOl(idlar) {
  sorovlar++;
  return idlar.map((id) => izohlarBazasi[id] ?? []);
}

function yuklovchiYarat(toplamFn) {
  let navbat = [];
  return function yukla(id) {
    return new Promise((resolve, reject) => {
      navbat.push({ id, resolve, reject });
      if (navbat.length > 1) return;
      queueMicrotask(async () => {
        const joriy = navbat;
        navbat = [];
        try {
          const natija = await toplamFn(joriy.map((n) => n.id));
          joriy.forEach((n, i) => n.resolve(natija[i]));
        } catch (xato) {
          joriy.forEach((n) => n.reject(xato));
        }
      });
    });
  };
}

const izohYukla = yuklovchiYarat(izohlarniOl);
const vazifalar = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];

const natija = await Promise.all(
  vazifalar.map(async (v) => ({
    ...v,
    izohlar: await izohYukla(v.id),
  })),
);
console.log(JSON.stringify(natija));
console.log("so'rovlar:", sorovlar);

Konsolda:

text
[{"id":1,"izohlar":["Zo'r","Ertaga"]},{"id":2,"izohlar":[]},{"id":3,"izohlar":["Bajarildi"]},{"id":4,"izohlar":["?"]}]
so'rovlar: 1

Qanday ishlaydi:

  • Birinchi yukla navbatni bo'sh topadi va queueMicrotask bilan "tozalovchi" ni rejalashtiradi. Keyingilari faqat navbatga qo'shiladi (navbat.length > 1 — rejalashtirilgan).
  • Promise.all(vazifalar.map(…)) — to'rtta yukla bitta sinxron qismda chaqiriladi. Microtask ular hammasidan keyin ishlaydi.
  • toplamFn natijasi id'lar tartibida bo'lishi shart — DataLoader'ning asosiy shartnomasi. SQL'dan kelgan qatorlar tartibsiz bo'lsa, ularni Map bilan id bo'yicha qayta tartiblash kerak.
  • navbat = [] microtask boshida — tozalash paytida kelgan yangi yukla lar keyingi batch'ga tushadi.

10. Real ishda

  • Eng ko'p uchraydigan sekinlik — ayniqsa ORM bilan yozilgan loyihalarda. Intervyuda deyarli har doim so'raladi: "N+1 nima va qanday tuzatasiz?"
  • GraphQL serverlari DataLoader'siz deyarli ishlamaydi: har maydon o'z resolver'ida ma'lumot so'raydi.
  • REST API'da ro'yxat endpoint'i uchun test: "so'rovlar soni elementlar soniga bog'liq emas" — N+1 qaytib kelmasligi kafolati.
  • Mikroservislarda xuddi shu muammo HTTP darajasida: har buyurtma uchun "mijoz servisiga" alohida so'rov. Yechim ham shu — batch endpoint (GET /mijozlar?id=1,2,3).

Xulosa

  • N+1 — ro'yxat + har element uchun so'rov; kanonda 101 so'rov, 152 ms.
  • Narxning asosi — har so'rovning tarmoq aylanishi; uzoq serverda farq yanada katta.
  • Batch: id'larni yig'ib = ANY($1::bigint[]) — 3 so'rov, 5 ms; izohsiz elementlarni unutmang.
  • Bitta so'rov: subquery + json_agg/array_agg — 1 so'rov, 5 ms; ikki "ko'p" bog'lanishni tekis JOIN qilmang.
  • DataLoader — mustaqil kod qismlari so'ragan id'larni microtask'gacha yig'ib, bitta batch yuboradi.
  • Aniqlash: so'rovlarni sanash, pg_stat_statements dagi ulkan calls.

Keyingi dars: Keyset pagination va katta OFFSET — 100 000-sahifani ochish nega sekin (kanonda 350 ms) va qanday qilib 0.2 ms ga tushiriladi.

Manbalar

Ulashish:Telegram'da

Izohlar (0)

Izoh yozish uchun kiring.

  • Hozircha izoh yo'q. Birinchi bo'ling!
N+1 so'rov muammosi: aniqlash va JOIN, ANY va DataLoader bilan tuzatish — IlmHamroh