সরাসরি পর্বে যান
পর্ব ৪২ · ৯৩-এর মধ্যে · মডিউল ৬

N+1 সমস্যা — include, select আর query মাপা

The N+1 Problem — include, select and Measuring Queries

Taught in Bangla, this lesson finds the queries hidden inside a loop, fixes them with include and select, compares Prisma's join and query relation load strategies, shows how network round trips multiply the cost, counts queries per request with AsyncLocalStorage and adds query-budget tests, combines pagination with relations, uses _count, groupBy and raw SQL for aggregates, reads EXPLAIN ANALYZE to add the right index, and gives an ORM review checklist.

এই পর্বে যা শিখবেন

  • আসল query log দেখে N+1 চেনা, আর include ও select দিয়ে সারানো
  • Prisma-র দুই relationLoadStrategy বেছে নেওয়া, generated SQL পড়ে
  • প্রতি request-এর query সংখ্যা মাপা, আর test দিয়ে budget ধরে রাখা
  • Pagination, _count, groupBy আর raw SQL ঠিক জায়গায় ব্যবহার করা
  • Slow query log আর EXPLAIN ANALYZE দিয়ে ধীর query খুঁজে সঠিক index বসানো

১ · লুপের ভেতরে লুকানো ৪১টি query

একটি রেস্তোরাঁর কথা ভাবুন। ওয়েটার ২০ টেবিলের অর্ডার খাতায় লিখলেন। এবার প্রতিটি টেবিলের ভাত আনতে একবার রান্নাঘরে গেলেন, ফিরলেন, আবার ডালের জন্য গেলেন। ২০ টেবিলে ৪০ বার হাঁটা। অথচ একবারে পুরো তালিকা নিয়ে গেলে একটি যাত্রাতেই কাজ হতো। রান্না ধীর নয়, যাতায়াতই সময় খাচ্ছে।

N+1 ঠিক এই হাঁটা: তালিকার জন্য ১টি query, তারপর তালিকার প্রতিটি সারির জন্য আরও query. পর্ব ৩৬-এ (GraphQL) resolver-এর ভেতরে এটি দেখেছেন, আর DataLoader দিয়ে কমিয়েছেন। এই পর্ব ORM-এর দিক থেকে দেখে। SQL join-এর তত্ত্ব আছে DBMS কোর্সের join পর্বে.

বাজারঘরের admin পাতায় সবশেষ ২০টি অর্ডার দেখাতে হবে, প্রতিটিতে ক্রেতার নাম আর পণ্যের তালিকা। একজন নতুন developer Prisma-তে সবচেয়ে সরল পথে লিখলেন:

export async function listOrdersLoop(take: number) {
  const orders = await prisma.order.findMany({ take, orderBy });
  const out = [];
  for (const o of orders) {
    const customer = await prisma.customer.findUnique({ where: { id: o.customerId } });
    const items = await prisma.orderItem.findMany({ where: { orderId: o.id } });
    out.push({ ...o, customer, items });
  }
  return out;
}

কোড পড়তে পরিষ্কার, type-ও ঠিক, ছোট ডেটায় দ্রুতও। এবার চালিয়ে দেখি। এই পর্বের সব মাপ একটি আসল project থেকে এসেছে: Prisma 7.10.1, PostgreSQL 18.6 (npm-এর বহনযোগ্য embedded-postgres দিয়ে), Node.js 24.15. লেখার সময়ে npm-এর latest tag ছিল 8.0.0-rc.29, একটি release candidate, তাই আমরা শেষ stable 7.10.1 ইচ্ছা করে বেছে নিয়েছি। Seed করা ডেটা: ১,০০০ ক্রেতা, ২,০০০ পণ্য, ১,০০,০০০ অর্ডার, ৩,০০,০০০ অর্ডার-সারি।

import 'dotenv/config';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from './generated/prisma/client.js';

const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
export const prisma = new PrismaClient({
  adapter,
  log: [{ emit: 'event', level: 'query' }],   // প্রতিটি SQL একটি event
});
prisma.$on('query', (e) => console.log(e.duration, e.query, e.params));

সত্যিকারের run · একটি local কম্পিউটারে · প্রথম ৫টি লাইন, বাকি ৩৬টি ছাঁটা · সময় গোল করা, "public". বাদ · ~/bazarghar

29.9ms SELECT "t0"."id", "t0"."status", "t0"."total", "t0"."createdAt", "t0"."customerId" FROM "Order" AS "t0" ORDER BY "t0"."createdAt" DESC, "t0"."id" DESC LIMIT $1 ["20"]
 0.3ms SELECT "t0"."id", "t0"."name", "t0"."city" FROM "Customer" AS "t0" WHERE ("t0"."id" = $1 AND 1=1) LIMIT $2 [1,"1"]
15.9ms SELECT "t0"."id", "t0"."qty", "t0"."price", "t0"."orderId", "t0"."productId" FROM "OrderItem" AS "t0" WHERE "t0"."orderId" = $1 [100000]
 0.6ms SELECT "t0"."id", "t0"."name", "t0"."city" FROM "Customer" AS "t0" WHERE ("t0"."id" = $1 AND 1=1) LIMIT $2 [82,"1"]
27.7ms SELECT "t0"."id", "t0"."qty", "t0"."price", "t0"."orderId", "t0"."productId" FROM "OrderItem" AS "t0" WHERE "t0"."orderId" = $1 [99999]
…
loop (N+1)   queries= 41   759.0 ms

একই আকৃতির SQL বারবার, শুধু parameter বদলাচ্ছে। Log-এ N+1 চেনার চিহ্ন এটাই। নিচে তিনটি অর্ডারে ছোট করে একই ঘটনা দেখুন।

1orders = await order.findMany({ take: 3 })2for (const o of orders) {3  await customer.findUnique(o.customerId)4  await orderItem.findMany(o.id)5}6return out
এই মুহূর্তের মান
o.id
SQL সংখ্যা
RTT 10 ms-এ অপেক্ষা
  1. ১টি তালিকা। তিনটি অর্ডার এলো। SQL ১টি। যত ডেটা দরকার তার সামান্য অংশই এখনো এসেছে, আর ঝামেলা শুরু হয়নি।
  2. প্রথম অর্ডারের ক্রেতা। অর্ডার 100000-এর ক্রেতা 1 আনতে আলাদা SQL. মোট ২টি।
  3. প্রথম অর্ডারের পণ্য। আরেকটি আলাদা SQL. মোট ৩টি। মাত্র একটি অর্ডার শেষ হলো।
  4. দ্বিতীয় অর্ডারের ক্রেতা। অর্ডার 99999-এর ক্রেতা 82. মোট ৪টি।
  5. দ্বিতীয় অর্ডারের পণ্য। মোট ৫টি। লক্ষ করুন, প্রতিটি await আগেরটি শেষ হওয়ার পরই যায়, তাই সবগুলো একটির পর একটি।
  6. তৃতীয় অর্ডারের ক্রেতা। অর্ডার 99998-এর ক্রেতা 163. মোট ৬টি।
  7. তৃতীয় অর্ডারের পণ্য। মোট ৭টি। RTT ১০ ms হলে শুধু যাতায়াতে ৭০ ms গেল।
  8. হিসাব। সূত্র: ১ + (সম্পর্কের সংখ্যা) × N = ১ + ২ × ৩ = ৭. N = ২০ হলে ৪১, ঠিক যা log-এ দেখেছি। query সংখ্যা N-এর সাথে বাড়ছে, এটিই সমস্যার মূল।

Prisma নিজে থেকে সম্পর্ক লোড করে না। যেসব ORM-এ order.customer লিখলেই গোপনে SQL যায় (lazy loading), সেখানে N+1 চোখে পড়ে না। Prisma-তে লুপটি নিজের হাতে লিখতে হয়, তাই কোড review-তে এটি ধরা তুলনামূলক সহজ।

২ · include আর select — একবারে আনা, যতটুকু দরকার ততটুকু

সমাধানের প্রথম ধাপ include: “অর্ডারের সাথে এই সম্পর্কগুলোও আনুন।” গভীরে যেতে include-এর ভেতরে আরেকটি include বসে। ক্রেতা, পণ্য আর পণ্যের ক্যাটাগরি একসাথে:

// nested include: অর্ডার → সারি → পণ্য → ক্যাটাগরি
const orders = await prisma.order.findMany({
  take: 20, orderBy,
  include: {
    customer: true,
    items: { include: { product: { include: { category: true } } } },
  },
});

লুপ চলে গেল, SQL সংখ্যা আর N-এর ওপর নির্ভর করে না। কিন্তু include প্রতিটি টেবিলের সব কলাম আনে। আমাদের Product.description প্রায় ২–৩ KB লম্বা, অথচ admin পাতায় লাগে শুধু পণ্যের নাম। একে বলে over-fetching. দর্জির কাছে শুধু হাতার মাপ চেয়ে পুরো গায়ের মাপ নেওয়ার মতো, কাজ হয়, সময় আর কাপড় নষ্ট হয়। সমাধান select:

const orders = await prisma.order.findMany({
  take: 20, orderBy,
  select: {
    id: true, total: true, status: true,
    customer: { select: { name: true } },
    items: { select: { qty: true, product: { select: { name: true } } } },
  },
});

একই স্তরে select আর include একসাথে চলে না, Prisma error দেয়। তবে include-এর ভেতরের সম্পর্কে select লেখা চলে, আবার select-এর ভেতরেও আরেকটি select চলে, যেমন ওপরে।

সত্যিকারের run · একটি local কম্পিউটারে · ৯ বার (১,০০০ অর্ডারে ৫ বার) চালিয়ে মধ্যমা · পুরো JSON.stringify-এর byte · relationJoins flag চালু · ২০ অর্ডারের সারিতে ক্যাটাগরিও আনা হয়েছে, ১,০০০ অর্ডারের সারিতে নয় · সাধারণ ফল নয়

QuerySQLসময়JSON
২০ অর্ডার, nested include12.6 ms150,160 byte
২০ অর্ডার, nested select11.7 ms6,437 byte
১,০০০ অর্ডার, include150.8 ms7,373,742 byte
১,০০০ অর্ডার, select116.9 ms202,155 byte

২০টি অর্ডারে পার্থক্য ১৫০,১৬০ ÷ ৬,৪৩৭ ≈ ২৩ গুণ. ১,০০০ অর্ডারে ৭,৩৭৩,৭৪২ ÷ ২০২,১৫৫ ≈ ৩৬ গুণ. লক্ষ করুন, SQL সংখ্যা সব সারিতে ১, অথচ খরচ আলাদা। তাই query গোনা যথেষ্ট নয়, কত ডেটা আসছে সেটিও দেখতে হবে। আর এই খরচ শেষে ক্রেতার মোবাইল ডেটায় পড়ে। পাতায় যত ঘর দেখাবেন, API-তে ততটুকুই আনুন।

৩ · দুই relation load strategy — join বনাম query

include ভেতরে দুভাবে চলতে পারে। Prisma-তে একে বলে relationLoadStrategy. query মানে টেবিলপ্রতি একটি SQL, আর জোড়া লাগানোর কাজ অ্যাপে। join মানে একটিমাত্র SQL, যেখানে PostgreSQL LATERAL JOIN দিয়ে JSON বানিয়ে দেয়। MySQL-এ correlated subquery ব্যবহার হয়।

এখনকার অবস্থা (Prisma 7 docs, লেখার সময়ে): এটি এখনো Preview. চালু করতে generator-এ previewFeatures = ["relationJoins"] লিখে prisma generate চালাতে হয়। PostgreSQL, CockroachDB আর MySQL-এ পাওয়া যায়। Flag চালু থাকলে ডিফল্ট join, না থাকলে query. Option-টি শুধু query-র সবচেয়ে ওপরের স্তরে বসে, আর নিচের সব সম্পর্কে একসাথে খাটে। আমাদের run-এ 7.10.1 দুই অবস্থাতেই মাপা হলো: flag চালু রেখে option না দিলে ১টি SQL, LATERAL-সহ। Flag ছাড়া একই include-এ ৩টি SQL, LATERAL নেই। এই পর্বের সব মাপে flag চালু, যদি না আলাদা করে query লেখা থাকে।

Version-এর সতর্কতা: এখানে যা লেখা, তা 7.10.1-এর আচরণ। prisma.io-র version ছাড়া docs এখন Prisma 8-এর API দেখায়, যেখানে include-কে এক SQL-এর কাজ হিসেবে বর্ণনা করা হয়েছে। Version 7-এর docs আছে /docs/orm/v7/-তে। Prisma 8 এখনো release candidate, তাই এই আচরণ সেখানে বদলাতে পারে। কোন docs পড়ছেন আর কোন version চালাচ্ছেন, মিলিয়ে নিন, আর শেষ কথা query log-এর।

generator client {
  provider        = "prisma-client"
  output          = "../src/generated/prisma"
  previewFeatures = ["relationJoins"]
}
prisma.order.findMany({
  take: 20, orderBy,
  relationLoadStrategy: 'join',   // অথবা 'query'
  include: { customer: true, items: true },
});
  1. আপনার কোডincludeএকই কল
  2. query৩টি SQL২ round trip
  3. join১টি SQL১ round trip
Prisma callফলের আকৃতি দুই পথে একই
orders = findMany({ take: 20, include: { customer, items } })
// ফল: [{ id, status, ..., customer: {...}, items: [...] }]
query · ধাপ ১সত্যিকারের SQL, কলাম ছাঁটা
SELECT "Order"."id", … FROM "Order" WHERE 1=1
ORDER BY "Order"."createdAt" DESC, "Order"."id" DESC
LIMIT $1 OFFSET $2                 ["20","0"]
query · ধাপ ২পাওয়া id দিয়ে IN
SELECT … FROM "Customer" WHERE "Customer"."id"
  IN ($1,$2,…,$20)       [1,82,163,…]
SELECT … FROM "OrderItem" WHERE "OrderItem"."orderId"
  IN ($1,$2,…,$20)       [100000,99999,…]
query · ধাপ ৩অ্যাপের ভেতরে জোড়া
customerId → customer   (২০টি অর্ডারের সাথে)
orderId    → items[]    (৬০টি সারি ভাগ করে)
database-এ join হয়নি, Node-এ হলো
join · একটি SQLসত্যিকারের SQL, ছাঁটা
SELECT "t0"."id", …,
  "Order_customer"."__prisma_data__" AS "customer",
  "Order_items"."__prisma_data__" AS "items"
FROM "Order" AS "t0"
LEFT JOIN LATERAL (SELECT JSONB_BUILD_OBJECT('id', … )
  FROM "Customer" AS "t1"
  WHERE "t0"."customerId" = "t1"."id" LIMIT $1) … ON true
LEFT JOIN LATERAL (SELECT COALESCE(JSONB_AGG(…), '[]')
  … WHERE "t0"."id" = "t2"."orderId" …) … ON true
ORDER BY "t0"."createdAt" DESC, "t0"."id" DESC LIMIT $2
join · PostgreSQL-এর planসত্যিকারের run, সরল করা query, ছাঁটা
Limit (rows=20)
  Nested Loop Left Join
    Index Scan using "Order_createdAt_id_idx" (rows=20)
    Index Scan using "Customer_pkey" (loops=20)
    Index Scan using "OrderItem_orderId_idx" (loops=20)
Execution Time: 0.585 ms
  1. একই কল। আপনার কোড আর ফলের আকৃতি একই থাকে। বদলায় শুধু ভেতরের পথ।
  2. query, প্রথম round trip. Prisma আগে ২০টি অর্ডার আনে। সম্পর্কের id এই ফল থেকেই জানা যাবে।
  3. query, দ্বিতীয় round trip. দুটি IN query একসাথে যায়। মোট SQL ৩টি, কিন্তু যাত্রা ২টি। সংখ্যাটি N-এর ওপর আর নির্ভর করে না।
  4. query, জোড়া লাগানো। Node.js নিজেই id মিলিয়ে সব জোড়ে। ডেটাবেসের কাজ কমে, অ্যাপের কাজ বাড়ে।
  5. join, একটি SQL. এক যাত্রায় সব। PostgreSQL প্রতিটি অর্ডারের জন্য LATERAL subquery চালায়, আর JSON বানিয়ে একটি কলামে দেয়।
  6. join-এর ভেতরে। Plan বলছে index দুটিতে ২০ বার করে খোঁজা হয়েছে। অর্থাৎ N+1-এর মতো কাজই ভেতরে হচ্ছে, কিন্তু ডেটাবেসের ঘরেই, network ছাড়া। ঠিক index না থাকলে এই ২০ বার খুব দামি হয়।

শেষ কথাটি সংখ্যায় দেখুন। নিচে একই পাতা, index ছাড়া আর index সহ। (Index কীভাবে বেছেছি, তা ৮ নম্বর অংশে।)

সত্যিকারের run · একটি local কম্পিউটারে, network ছাড়া · ৯ বার চালিয়ে মধ্যমা · সাধারণ ফল নয়

পথSQLindex ছাড়াindex সহ
লুপ (N+1)41759.0 ms21.7 ms
include, query332.7 ms1.8 ms
include, join1199.4 ms2.1 ms

তিনটি শিক্ষা। প্রথমত, index ছাড়া join query-র চেয়ে প্রায় ৬ গুণ ধীর হয়েছে, কারণ ২০ বার পুরো OrderItem টেবিল পড়েছে, আর query পথে IN একবারেই পড়েছে। দ্বিতীয়ত, “এক query মানেই দ্রুত”, এই ধারণা ভুল। তৃতীয়ত, ডেটা বড় হলে পাল্লা বদলাতে পারে। আমাদের run-এ ৫,০০০ অর্ডার (index সহ, পণ্য-সহ, network ছাড়া) join-এ প্রায় ২৩৩ ms, query-তে প্রায় ৬৯ ms লেগেছে। Docs-ও বলে, join বেশিরভাগ ক্ষেত্রে ভালো, তবে profile করে দেখুন।

এবার ছোট টেবিলে নিজে দেখুন। নিচের কোডে তিনটি পথের SQL একসাথে আছে, আর শেষ সারিতে কোন পথে কতটি SQL লাগে তার হিসাব।

-- ক) N+1: আগে তালিকা
SELECT id, customer_id FROM orders ORDER BY id DESC;
-- তারপর প্রতিটি অর্ডারের জন্য একটি করে SQL
SELECT name FROM customers WHERE id = 1;
SELECT name FROM customers WHERE id = 2;
SELECT name FROM customers WHERE id = 1;

-- খ) query strategy-র মতো: id জমিয়ে একটি IN
SELECT id, name FROM customers WHERE id IN (1, 2);

-- গ) join: এক SQL-এই সব
SELECT o.id, c.name, COUNT(i.id) AS item_rows
FROM orders o
JOIN customers c ON c.id = o.customer_id
LEFT JOIN items i ON i.order_id = o.id
GROUP BY o.id, c.name
ORDER BY o.id DESC;

-- প্রতিটি পথে কতটি SQL?
SELECT 1 + COUNT(*) AS n_plus_one, 2 AS with_in, 1 AS with_join FROM orders;

শেষ সারি দেখাবে n_plus_one = 4, with_in = 2, with_join = 1. তিনটি অর্ডার, একটি সম্পর্ক, তাই ১ + ৩ = ৪. একই ক্রেতা 1-কে N+1 পথে দুবার চাওয়া হয়েছে, কিন্তু IN-এ একবার। Join-এ অর্ডার 103, 102, 101-এর নাম আসে Rafi, Mitu, Rafi, আর item_rows ১, ১, ২. এক SQL-এই পুরো তালিকা।

৪ · অনেক ছোট query আর network-এর হিসাব

এতক্ষণ অ্যাপ আর ডেটাবেস একই কম্পিউটারে ছিল। বাস্তবে তারা আলাদা মেশিনে থাকে। তখন প্রতিটি query-র সাথে একটি round trip যোগ হয়, যার সময়কে বলে RTT. মোটামুটি সূত্র:

মোট সময় ≈ (round trip সংখ্যা) × RTT + ডেটাবেসের কাজ

সূত্রটি সত্যি কি না মাপা হলো। Prisma আর PostgreSQL-এর মাঝে নিজের লেখা ছোট একটি TCP proxy বসানো হলো, যা প্রতি দিকে নির্দিষ্ট সময় ধরে রাখে। Index সহ ডেটা, ২০টি অর্ডার।

সত্যিকারের run · একটি local কম্পিউটারে · delay proxy দিয়ে RTT তৈরি করা, আসল network নয় · ৭ বার চালিয়ে মধ্যমা

RTTলুপ (41 SQL)Promise.all (22 SQL)query (3 SQL)join (1 SQL)
proxy ছাড়া21.2 ms6.7 ms1.8 ms2.1 ms
2 ms106.5 ms11.4 ms5.5 ms3.3 ms
10 ms440.3 ms44.1 ms21.8 ms11.5 ms
40 ms1673.4 ms165.3 ms82.3 ms41.7 ms

RTT ৪০ ms-এ একই পাতার round trip

প্রতিটি ছোট ঘর একটি round trip · দৈর্ঘ্য সময়ের সমানুপাতিক

চার পথে round trip-এর সংখ্যা আর সময় লুপ · 41 round trip · 1673 ms Promise.all · 4 round trip · 165 ms include + query · 2 round trip · 82 ms include + join · 1 round trip · 42 ms
ছবি ৪২.১ — এক local run-এর হিসাব। অপেক্ষার বড় অংশই ডেটাবেসের কাজ নয়, যাতায়াত।

হিসাব মেলান। RTT ৪০ ms-এ লুপ: ৪১ × ৪০ = ১,৬৪০ ms, সাথে ডেটাবেসের কাজ প্রায় ২১ ms, মোট ≈ ১,৬৬১ ms. মাপা ১,৬৭৩.৪ ms. query: ২ × ৪০ = ৮০, মাপা ৮২.৩. join: ১ × ৪০ = ৪০, মাপা ৪১.৭. সূত্র বেশ মিলছে।

এখন Promise.all সারিটি দেখুন। লুপের কলগুলো একসাথে পাঠালে SQL ৪১ থেকে ২২ হলো। কারণ Prisma-র docs বলে, একই tick-এর findUnique() কলগুলো নিজে থেকে batch হয়। সত্যিই log-এ ২০টি ক্রেতার জন্য একটি IN এসেছে। কিন্তু findMany()-এ এই সুবিধা নেই, তাই ২০টি থেকেই গেল। আর সময় ২ round trip নয়, ৪ round trip (১৬৫ ms). কেন? pg driver-এর connection pool-এ ডিফল্টে ১০টি সংযোগ, তাই ২১টি SQL তিন দফায় গেছে: ১ + ৩ = ৪. আমরা max: 30 দিয়ে আবার মাপলাম, সময় ১৬৪.৫ থেকে ৮৪.৩ ms হলো, হিসাব মিলে গেল।

Promise.all N+1-এর সমাধান নয়। SQL সংখ্যা N-এর সাথে বাড়তেই থাকে, আর এক request-ই পুরো pool দখল করে, ফলে অন্য request অপেক্ষায় পড়ে। এটি সমস্যা সারায় না, শুধু চোখের আড়ালে সরায়।

Capacity-র হিসাব — একটি পূর্ণ numerical

একটি request-এর SQL-গুলো পরপর চলে, তাই সেই সময়জুড়ে সে প্রায় সবসময় একটি সংযোগ ধরে রাখে। ধরুন pool-এ ১০টি সংযোগ, RTT ৪০ ms, আর সবকিছু শুধু ডেটাবেস সংযোগে আটকে আছে। সংযোগ-সেকেন্ডের সরল হিসাবে প্রতি সেকেন্ডে সর্বোচ্চ request = সংযোগ ÷ প্রতি request-এর সময়।

লুপ: ১০ ÷ ১.৬৭৩৪ s ≈ ৬ request/সেকেন্ড
join: ১০ ÷ ০.০৪১৭ s ≈ ২৪০ request/সেকেন্ড

একই server, একই ডেটাবেস, প্রায় ৪০ গুণ ধারণক্ষমতা। ক্যাম্পেইনের দিনে ৩০ জন একসাথে admin পাতা খুললেই লুপের পথে সারি জমবে। এটি হিসাবের অনুমান, মাপা ফল নয়। তবু N+1 কেন শুধু একজনের ধীরগতি নয়, পুরো অ্যাপের ধারণক্ষমতার প্রশ্ন, তা এখান থেকেই বোঝা যায়।

নিচের শেখানোর মডেলটি আসল ORM নয়, আর সময়ও আসল ঘড়ির নয়। RTT আর N বদলে নিজে সূত্রটি পরীক্ষা করুন।

// শেখানোর মডেল: নকল ORM, হিসাবের ঘড়ি। কোনো ডেটাবেস নেই।
const RTT = 10;     // ms, ধরে নেওয়া মান
const WORK = 0.3;   // প্রতি SQL-এ ডেটাবেসের কাজ, ms, ধরে নেওয়া মান

function fakeDb() {
  const db = { queries: 0, trips: 0, clock: 0 };
  // একসাথে পাঠানো SQL-গুলো এক round trip-এ যায়
  db.send = (...sql) => {
    db.queries += sql.length; db.trips += 1;
    db.clock += RTT + WORK * sql.length;
  };
  return db;
}
function loop(db, n) {
  db.send("orders");
  for (let i = 0; i < n; i++) {
    db.send("customer");
    db.send("items");
  }
}
const withIn = (db) => { db.send("orders"); db.send("customers IN", "items IN"); };
const withJoin = (db) => db.send("orders LATERAL JOIN");

function report(name, run) {
  const db = fakeDb();
  run(db);
  console.log(name.padEnd(12), String(db.queries).padStart(4), "queries",
    String(db.trips).padStart(4), "trips", db.clock.toFixed(1).padStart(8), "ms");
}
for (const n of [5, 20, 100]) {
  console.log("N =", n);
  report("loop", (db) => loop(db, n));
  report("IN", withIn);
  report("join", withJoin);
}

N = ২০-এ মডেল দেয় loop 41 queries 41 trips 422.3 ms, হিসাব ৪১ × ১০ + ৪১ × ০.৩. ওপরের মাপা ৪৪০.৩ ms-এর কাছাকাছি। N = ১০০-এ লুপ 201 queries, 2070.3 ms, কিন্তু IN আর join-এর সারি বদলায়নি। এই মডেল বড় ফল টানার খরচ ধরে না, তাই শুধু round trip-এর ধারণা দেখানোর জন্য ব্যবহার করুন।

৫ · প্রতি request-এ query গোনা, আর budget test

N+1 ছোট ডেটায় চুপ থাকে, production-এ জেগে ওঠে। তাই চোখের ভরসায় না থেকে প্রতিটি request কতটি SQL চালাল, তা মাপুন। পর্ব ৩১-এ (Log আর config) আমরা AsyncLocalStorage দিয়ে request id পৌঁছে দিয়েছি। এখানে একই বাক্সে একটি counter রাখব।

import { AsyncLocalStorage } from 'node:async_hooks';
import { prisma } from './db.ts';

type Stats = { queries: number; dbMs: number };
export const stats = new AsyncLocalStorage<Stats>();

prisma.$on('query', (e) => {
  const s = stats.getStore();
  if (!s) return;              // request-এর বাইরের query
  s.queries += 1;
  s.dbMs += e.duration;
});

export async function countQueries<T>(fn: () => Promise<T>) {
  const s = { queries: 0, dbMs: 0 };
  const result = await stats.run(s, fn);
  return { result, queries: s.queries, dbMs: s.dbMs };
}

Express-এর একটি middleware প্রতিটি request-এর জন্য stats.run(s, next) ডাকে, আর res.on('finish')-এ একটি JSON লাইন লেখে। চারটি request একসাথে পাঠিয়ে দেখা হলো গণনা মিশে যায় কি না:

সত্যিকারের run · Express 5.3.0, Prisma 7.10.1, 127.0.0.1 · একবার · ~/bazarghar

{"url":"/api/orders?limit=20","status":200,"queries":1,"dbMs":23.4,"totalMs":24}
{"url":"/api/orders-loop?limit=20","status":200,"queries":41,"dbMs":18.7,"totalMs":26.3}
{"url":"/api/orders?limit=5","status":200,"queries":1,"dbMs":24.8,"totalMs":25.8}
{"url":"/api/orders-loop?limit=5","status":200,"queries":11,"dbMs":42.3,"totalMs":44.5}

গণনা ঠিক: ১ + ২ × ২০ = ৪১, ১ + ২ × ৫ = ১১, আর ভালো পথে ১. এই version-এ $on handler request-এর context পেয়েছে। তবু এটি আমাদের পরীক্ষায় পাওয়া আচরণ, docs-এ লেখা নিশ্চয়তা নয়, তাই version বদলালে আবার test চালান। dbMs-কে শুদ্ধ ডেটাবেস সময় ভাববেন না। Docs বলে duration হলো client query পাঠানো থেকে উত্তর পাওয়া পর্যন্ত সময়, তাই ব্যস্ত সময়ে এটি বাড়ে।

Client extension দিয়ে? prisma.$extends({ query: { $allOperations(…) } })-ও চলে, কিন্তু সেটি Prisma কল গোনে, SQL নয়। আমাদের পরীক্ষায় একটি request-এ ৩টি কল (২টি findUnique, ১টি include-সহ findMany) extension-এ গোনা হলো ৩, আর $on-এ ৫. তাই N+1 ধরতে $on('query') বেশি সৎ। আরেকটি মজার ফল: দুই request একই সময়ে একই id-র findUnique ডাকলে Prisma সেগুলো জুড়ে দেওয়ায় মোট SQL ২টি কমে গিয়েছিল।

এবার সংখ্যাটিকে নিয়ম বানাই। Query budget মানে “এই endpoint ৫টির বেশি SQL চালাবে না”, আর সেটি test-এ লেখা। সময় নিয়ে test করলে কম্পিউটার ভেদে ফল নড়ে, কিন্তু query সংখ্যা স্থির থাকে। তাই এই test নির্ভরযোগ্য। দ্বিতীয় test আরও শক্তিশালী: N বাড়লেও query সংখ্যা একই থাকে কি না।

import { afterAll, describe, expect, it } from 'vitest';
import { prisma } from '../src/db.ts';
import { countQueries } from '../src/query-stats.ts';
import { listOrders, listOrdersLoop } from '../src/orders.ts';

const BUDGET = 5;
afterAll(() => prisma.$disconnect());

describe('অর্ডার তালিকার query budget', () => {
  it('২০টি অর্ডার: budget মেনে চলে', async () => {
    const { queries, result } = await countQueries(() => listOrders(20));
    expect(result).toHaveLength(20);
    expect(queries).toBeLessThanOrEqual(BUDGET);
  });
  it('অর্ডার বাড়লে query বাড়ে না', async () => {
    const a = await countQueries(() => listOrders(5));
    const b = await countQueries(() => listOrders(50));
    expect(b.queries).toBe(a.queries);
  });
  it('পুরোনো loop: budget ভাঙে', async () => {
    const { queries } = await countQueries(() => listOrdersLoop(20));
    expect(queries).toBeLessThanOrEqual(BUDGET);
  });
});

সত্যিকারের run · Vitest 5.0.5 · আসল PostgreSQL-এ · আউটপুট ছাঁটা · ~/bazarghar

$ npx vitest run
 RUN  v5.0.5 ~/bazarghar

 ❯ test/query-budget.test.ts (3 tests | 1 failed) 125ms
     × পুরোনো loop: budget ভাঙে 26ms

 FAIL  test/query-budget.test.ts > অর্ডার তালিকার query budget > পুরোনো loop: budget ভাঙে
AssertionError: expected 41 to be less than or equal to 5
 ❯ test/query-budget.test.ts:22:21

 Test Files  1 failed (1)
      Tests  1 failed | 2 passed (3)

এই ব্যর্থতাই আমরা চেয়েছি: test বলছে ৪১ > ৫. ডেটাবেস ছাড়াই একই ধারণা নিচে চালিয়ে দেখুন। এখানে query() একটি নকল driver, আর দুটি request একসাথে চলে।

const { AsyncLocalStorage } = require("node:async_hooks");
const assert = require("node:assert");
const store = new AsyncLocalStorage();
const wait = (ms) => new Promise((done) => setTimeout(done, ms));

// নকল driver: প্রতিটি SQL বর্তমান request-এর গণনায় যোগ হয়
async function query(sql) { await wait(1); store.getStore().queries += 1; }
async function listLoop(n) {
  await query("orders");
  for (let i = 0; i < n; i++) {
    await query("customer");
    await query("items");
  }
}
async function listInclude() {
  await query("orders");
  await Promise.all([query("customers IN"), query("items IN")]);
}
async function countQueries(fn) {
  const s = { queries: 0 };
  await store.run(s, fn);
  return s.queries;
}
async function main() {
  const [bad, good] = await Promise.all([
    countQueries(() => listLoop(20)), countQueries(() => listInclude()),
  ]);
  console.log("loop:", bad, " include:", good);
  assert.ok(good <= 5);
  try { assert.ok(bad <= 5, `budget 5, got ${bad}`); }
  catch (e) { console.log("FAILED:", e.message); }
}
main();

আউটপুট loop: 41 include: 3, তারপর FAILED: budget 5, got 41. দুই কাজ একসাথে চললেও গণনা মেশেনি, কারণ প্রতিটি run()-এর নিজের বাক্স আছে। Budget-এর সীমা ভাঙলে assert error ছোড়ে, যা test runner-এ build ব্যর্থ করে। আসল test কীভাবে সাজাবেন তা পর্ব ৭৫-এ (API integration test) আসবে।

৬ · Pagination আর সম্পর্ক একসাথে

পর্ব ৩৪-এ (Pagination, filter আর sort) দেখেছেন, গভীর পাতায় cursor কেন ভালো। সম্পর্ক যোগ হলে দুটি নতুন কথা আসে।

প্রথমটি ভালো খবর। Prisma-তে take মূল টেবিলের সারি গোনে, সম্পর্কের নয়। হাতে লেখা সাধারণ JOIN … LIMIT 20-এ ঝামেলা হয়: এক অর্ডারে ৩টি পণ্য থাকলে join-এর ফলে তিনটি সারি হয়, তাই ২০টি সারি মানে মাত্র ৭টি অর্ডার। Prisma-র LATERAL আর IN, দুই পথেই আগে অর্ডার সীমিত হয়।

const page2 = await prisma.order.findMany({
  take: 3,
  skip: 1,                         // cursor সারিটি নিজে বাদ
  cursor: { id: lastId },
  orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
  include: { items: { select: { qty: true } } },
});

সত্যিকারের run · cursor 99998 · কলাম ছাঁটা

page 1  [ '100000:3', '99999:3', '99998:3' ]     page 2  [ '99997:3', '99996:3', '99995:3' ]

SELECT … FROM "Order" WHERE (("Order"."createdAt" = (SELECT "Order"."createdAt" FROM "Order" WHERE ("Order"."id") = ($1))
  AND "Order"."id" <= (SELECT "Order"."id" FROM "Order" WHERE ("Order"."id") = ($2)))
  OR ("Order"."createdAt" < (SELECT "Order"."createdAt" FROM "Order" WHERE ("Order"."id") = ($3))))
  ORDER BY "Order"."createdAt" DESC, "Order"."id" DESC LIMIT $4 OFFSET $5     [99998,99998,99998,"3","1"]
SELECT … FROM "OrderItem" WHERE "OrderItem"."orderId" IN ($1,$2,$3) OFFSET $4   [99997,99996,99995,"0"]

লক্ষ করুন, orderBy-এ শেষ কলাম id, তাই stable order বজায় আছে। আরেকটি খুঁটিনাটি: flag চালু থাকায় পাতা ১ এসেছে ১টি SQL-এ, কিন্তু cursor দেওয়া পাতা ২ আমাদের run-এ ২টি SQL-এ গেছে। তাই strategy ধরে না নিয়ে log দেখুন।

দ্বিতীয়টি ফাঁদ: সম্পর্কের ভেতরে take. “প্রতি ক্রেতার শেষ ৩টি অর্ডার” চাইলে:

prisma.customer.findMany({
  take: 20, orderBy: { id: 'asc' },
  relationLoadStrategy: 'query',   // এবং 'join'
  include: { orders: { take: 3, orderBy: { createdAt: 'desc' } } },
});

সত্যিকারের run · ৯ বার চালিয়ে মধ্যমা · SQL ছাঁটা · সাধারণ ফল নয়

query  queries=2  3.1 ms
  SELECT … FROM "Order" WHERE "Order"."customerId" IN ($1,…,$20) ORDER BY "Order"."createdAt" DESC OFFSET $21
join   queries=1  0.8 ms
  … LEFT JOIN LATERAL (… ORDER BY "t3"."createdAt" DESC LIMIT $1 …) AS "Customer_orders" ON true …

query পথের দ্বিতীয় SQL-এ কোনো LIMIT নেই! ২০ জন ক্রেতার প্রত্যেকের ১০০টি অর্ডার, মোট ২,০০০ সারি ডেটাবেস থেকে আসে, আর অ্যাপ প্রতিজনের ৩টি রেখে বাকিটা ফেলে দেয়। join পথে LIMIT প্রতি ক্রেতার ভেতরেই বসে। যে ক্রেতার ১ লাখ অর্ডার, তাঁর ক্ষেত্রে পার্থক্য বিশাল হবে।

৭ · গোনা আর যোগ: _count, groupBy আর raw SQL

“প্রতি ক্রেতার মোট অর্ডার” জানতে সব অর্ডার এনে .length দেখা মানে হিসাব জানতে পুরো খাতা বাড়ি নিয়ে যাওয়া। Aggregate হিসাবটি ডেটাবেসকেই করতে দিন।

// সম্পর্কের সংখ্যা: _count
prisma.customer.findMany({
  take: 20, orderBy: { id: 'asc' },
  select: { id: true, name: true, _count: { select: { orders: true } } },
});
// দলে ভাগ করে হিসাব: groupBy
prisma.order.groupBy({
  by: ['status'],
  _count: { _all: true },
  _sum: { total: true },
  orderBy: { _sum: { total: 'desc' } },
});

সত্যিকারের run · ৫ বার চালিয়ে মধ্যমা · আউটপুট ছাঁটা · সাধারণ ফল নয়

load all orders, count in JS   queries=1   10.5 ms   { id: 1, n: 100 }
_count                         queries=1    0.8 ms   { id: 1, name: 'Customer 1', _count: { orders: 100 } }

groupBy status                 queries=1   11.2 ms
  SELECT COUNT(*) AS "_count$_all", SUM("Order"."total") AS "_sum$total", "Order"."status"
  FROM "Order" WHERE 1=1 GROUP BY "Order"."status" ORDER BY SUM("Order"."total") DESC OFFSET $1
[ { _count: { _all: 30000 }, _sum: { total: 579437500 }, status: 'PAID' },
  { _count: { _all: 30000 }, _sum: { total: 554585000 }, status: 'DELIVERED' }, … ]

একই উত্তর, ১৩ গুণ দ্রুত, আর পথে অপ্রয়োজনীয় ডেটা নেই। GROUP BY-এর তত্ত্ব DBMS কোর্সে আছে।

কিন্তু একাধিক টেবিল জুড়ে রিপোর্টে ORM-এর API সীমিত। “জুন থেকে কোন ক্যাটাগরিতে কত আয়” চার টেবিলের join চায়। Prisma-র groupBy সম্পর্কের কলামে দল বাঁধতে পারে না, আর ORM জোর করে চালাতে গেলে কোড জটিল হয়। এখানে raw SQL সৎ পথ:

const from = new Date('2026-06-01T00:00:00Z');
const rows = await prisma.$queryRaw`
  SELECT c.name, COUNT(DISTINCT o.id) AS orders, SUM(i.qty * i.price) AS revenue
  FROM "OrderItem" i
  JOIN "Order" o    ON o.id = i."orderId"
  JOIN "Product" p  ON p.id = i."productId"
  JOIN "Category" c ON c.id = p."categoryId"
  WHERE o."createdAt" >= ${from} AND o.status <> 'CANCELLED'
  GROUP BY c.name ORDER BY revenue DESC LIMIT 3`;

সত্যিকারের run · ৩ বার চালিয়ে মধ্যমা · query=1 · 160.7 ms

[ { name: 'Category 9',  orders: 8477n, revenue: 85585728n },
  { name: 'Category 13', orders: 8477n, revenue: 85568072n },
  { name: 'Category 1',  orders: 8477n, revenue: 85533400n } ]

res.json(rows) → TypeError: Do not know how to serialize a BigInt

দুটি কথা মনে রাখুন। ${from} কোনো string জোড়া নয়। $queryRaw একটি tagged template, তাই মানটি parameter হয়ে যায়, আর SQL injection আটকায়। $queryRawUnsafe-এ string জুড়লে এই সুরক্ষা থাকে না। তত্ত্ব আছে Injection প্রতিরোধ পর্বে। আর দ্বিতীয়টি: PostgreSQL-এ COUNT আর বড় SUM-এর ফল BigInt হয়ে আসে, যা res.json() সরাসরি লিখতে পারে না। পাঠানোর আগে Number() বা string-এ বদলান, অথবা SQL-এ ::int cast করুন, তবে সংখ্যা সীমার মধ্যে আছে তা নিশ্চিত হয়ে।

৮ · ধীর query ধরা, EXPLAIN ANALYZE আর সঠিক index

প্রথম migration-এর SQL দেখলে একটি জিনিস চোখে পড়ে: foreign key আছে, কিন্তু তার index নেই। Prisma লিখেছিল শুধু এটুকু:

সত্যিকারের run · prisma/migrations/…_init/migration.sql থেকে · ছাঁটা

CREATE UNIQUE INDEX "Category_name_key" ON "Category"("name");
ALTER TABLE "OrderItem" ADD CONSTRAINT "OrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") …;

PostgreSQL foreign key-র কলামে নিজে থেকে index বানায় না, তার docs-ই এ কথা বলে। MySQL (InnoDB) বানায়। তাই PostgreSQL-এ এটি আপনার দায়িত্ব। এই ফাঁকই ১ নম্বর অংশের ৭৫৯ ms-এর বড় কারণ। ধরার পথ দুটি।

ডেটাবেসের দিক থেকে: Slow query log. PostgreSQL-এ log_min_duration_statement সীমার বেশি সময় নেওয়া প্রতিটি query log-এ লেখে। আমাদের run-এ সীমা ছিল ২০ ms. অ্যাপের দিক থেকে: ৫ নম্বর অংশের $on('query')-এ e.duration > 100 হলে একটি warning লেখা। একই আকৃতির query কতবার চলছে আর মোট কত সময় নিচ্ছে, তা দেখতে Performance tuning পর্বের pg_stat_statements দেখুন।

Slow log-এ parameter-এর মানও থাকে, যেমন $1 = '99995'. আসল অ্যাপে সেখানে ফোন নম্বর বা email আসতে পারে। Log-এর প্রবেশাধিকার সীমিত রাখুন, আর PostgreSQL 13 থেকে পাওয়া log_parameter_max_length ছোট করার কথা ভাবুন। EXPLAIN ANALYZE-ও সাবধানে: এটি query সত্যিই চালায়। UPDATE বা DELETE-এ ব্যবহার করলে BEGIN … ROLLBACK-এর ভেতরে চালান।

PostgreSQL logসত্যিকারের run, query ছাঁটা
LOG:  duration: 27.901 ms  execute <unnamed>: SELECT "t0"."id", …
  FROM "public"."OrderItem" AS "t0" WHERE "t0"."orderId" = $1
DETAIL:  Parameters: $1 = '99995'
LOG:  duration: 28.551 ms  execute <unnamed>: SELECT "t0"."id", …
DETAIL:  Parameters: $1 = '99994'
EXPLAIN (ANALYZE, BUFFERS)আগে · cost অংশ ছাঁটা
Gather (actual time=17.291..25.988 rows=3.00 loops=1)
  Workers Launched: 2
  Buffers: shared hit=3822
  ->  Parallel Seq Scan on "OrderItem" (rows=1.00 loops=3)
        Filter: ("orderId" = 99999)
        Rows Removed by Filter: 99999
Execution Time: 26.008 ms
schema.prismaএকটি লাইন
model OrderItem {
  …
  orderId   Int
  order     Order @relation(fields: [orderId], references: [id])

  @@index([orderId])
}
Terminalmigration
$ npx prisma migrate dev --name fk_indexes
Applying migration `…_fk_indexes`

-- CreateIndex
CREATE INDEX "OrderItem_orderId_idx" ON "OrderItem"("orderId");
EXPLAIN (ANALYZE, BUFFERS)পরে · cost অংশ ছাঁটা
Index Scan using "OrderItem_orderId_idx" on "OrderItem"
  (actual time=0.016..0.017 rows=3.00 loops=1)
  Index Cond: ("orderId" = 99999)
  Buffers: shared hit=4
Execution Time: 0.033 ms
পুরো পাতাআগে → পরে, একটি local run
লুপ (N+1)      759.0 ms  →  21.7 ms
include query   32.7 ms  →   1.8 ms
include join   199.4 ms  →   2.1 ms
Slow log-এ আর এই query নেই।
  1. লক্ষণ। Slow log-এ একই আকৃতির query বারবার, প্রতিবার ২৮ ms-এর মতো। শুধু parameter আলাদা। একই সাথে দুটি রোগের চিহ্ন: ধীর query, আর N+1.
  2. রোগ নির্ণয়। EXPLAIN ANALYZE বলছে Seq Scan. তিনটি প্রক্রিয়া প্রত্যেকে গড়ে ৯৯,৯৯৯টি সারি ফেলে দিয়েছে, মানে প্রায় ৩ লাখ সারি পড়ে মিলল ৩টি। Buffers ৩,৮২২ page. সময় ২৬.০০৮ ms.
  3. ওষুধ। WHERE-এর কলামটি orderId, তাই সেটিতে index. Schema-তে একটি লাইন।
  4. প্রয়োগ। Prisma migration SQL ফাইল বানিয়ে চালায়। এই ফাইলটিও কোডের সাথে থাকে। নিয়ম পর্ব ৪০-এ (Migration আর seed).
  5. যাচাই। এবার Index Scan. Buffers ৩,৮২২ থেকে ৪, সময় ২৬.০০৮ থেকে ০.০৩৩ ms, প্রায় ৭৮৮ গুণ কম। শুধু “দ্রুত লাগছে” বলবেন না, এই দুটি সংখ্যাই প্রমাণ।
  6. পুরো পাতা। Index লুপটিকেও দ্রুত করল, কিন্তু লুপ এখনো ৪১টি SQL, আর RTT ৪০ ms-এ এখনো ১.৬ সেকেন্ড। তাই index আর include, দুটিই লাগে। একটি অন্যটির বিকল্প নয়।

কোন index “সঠিক”?

একই query-তে তিন রকম index আলাদা ফল দেয়। ক্রেতার পাতায় চাই: where: { customerId: 77 }, নতুন আগে, ১০টি। ওই ক্রেতার ১০০টি অর্ডার আছে।

সত্যিকারের run · PostgreSQL 18.6 · ১ লাখ অর্ডার · সব ডেটা মেমরিতে · একবার · সাধারণ ফল নয়

IndexPlanPage পড়াসময়
নেইSeq Scan + top-N sort15513.105 ms
[customerId]Bitmap Heap Scan + top-N sort, ১০০ সারি পড়া1020.060 ms
[customerId, createdAt(sort: Desc)]Index Scan, ঠিক ১০ সারি, sort নেই130.017 ms

দ্বিতীয় index সারি খুঁজে দেয়, কিন্তু সাজায় না। তৃতীয়টি (composite index) আগে ক্রেতা, তারপর সময় অনুযায়ী সাজানো, তাই ডেটাবেস প্রথম ১০টি পড়েই থামে। নিয়মটি: সমান-শর্তের কলাম আগে, তারপর sort-এর কলাম। নকশার পুরো আলোচনা Index design পর্বে। নিচে SQLite-এ নিজে চালিয়ে দেখুন।

-- ১) কোনো index নেই
EXPLAIN QUERY PLAN
SELECT id, total FROM orders
WHERE customer_id = 77 ORDER BY created_at DESC LIMIT 10;

-- ২) শুধু customer_id
CREATE INDEX o_cust ON orders(customer_id);
EXPLAIN QUERY PLAN
SELECT id, total FROM orders
WHERE customer_id = 77 ORDER BY created_at DESC LIMIT 10;

-- ৩) সমান-শর্ত আগে, sort পরে
DROP INDEX o_cust;
CREATE INDEX o_cust_new ON orders(customer_id, created_at DESC);
EXPLAIN QUERY PLAN
SELECT id, total FROM orders
WHERE customer_id = 77 ORDER BY created_at DESC LIMIT 10;

প্রথম plan: SCAN orders আর USE TEMP B-TREE FOR ORDER BY, মানে পুরো টেবিল হাঁটা, তারপর আলাদা sort. দ্বিতীয়: SEARCH orders USING INDEX o_cust (customer_id=?), কিন্তু USE TEMP B-TREE থেকেই গেছে। তৃতীয়: SEARCH orders USING INDEX o_cust_new (customer_id=?), আর temp B-tree নেই। PostgreSQL-এর টেবিলটির সাথে গল্প একই।

Index বিনামূল্যে নয়। আমাদের ৩০ MB-র OrderItem টেবিলে orderId index প্রায় ৪.৮ MB, আর প্রতিটি INSERT-এ সেটিও হালনাগাদ হয়। বড় চালু টেবিলে সাধারণ CREATE INDEX লেখা আটকে রাখে। তখন CREATE INDEX CONCURRENTLY লাগে, যা transaction-এর ভেতরে চলে না। আমাদের run-এ prisma migrate dev --create-only দিয়ে ফাইলটি বানিয়ে, তাতে শুধু ওই একটি statement রেখে, পরে apply করলে সেটি সফলভাবে চলেছে। তাই ভালো অভ্যাস হলো আগে slow log আর plan দেখা, তারপর index বসানো, আন্দাজে প্রতিটি কলামে নয়।

৯ · ORM PR review-এর চেকলিস্ট

এতক্ষণের সব শিক্ষা এক জায়গায়। ডেটাবেস ছোঁয়া যেকোনো PR-এ, নিজের হোক বা সহকর্মীর, এই প্রশ্নগুলো করুন। সাধারণ review-এর নিয়ম আছে Code review পর্বে।

সব লুপই কি অপরাধ?

না। চেকলিস্ট চিন্তার জন্য, অন্ধ নিয়মের জন্য নয়। প্রতিদিন রাতে চলা একটি background job যদি ৫০০টি অর্ডার একটি করে সারায়, আর প্রতিটির পর email পাঠায়, সেখানে লুপ স্বাভাবিক। কেউ অপেক্ষায় নেই, আর একসাথে সব করলে একটি ব্যর্থতায় পুরো কাজ আটকে যেতে পারে। আবার যে সম্পর্ক সবসময় দু-তিনটি সারির, আর পাতাটি দিনে কয়েকবার খোলে, সেখানে দু-একটি বাড়তি query সরল কোডের দাম হিসেবে মেনে নেওয়া যায়। প্রশ্নটি হলো: N কে ঠিক করে, আর N কত বড় হতে পারে? N যদি ব্যবহারকারী বা ডেটার সাথে বাড়ে, তাহলে আজই সারান।

আরও গভীরে: PostgreSQL-এর LATERAL এসেছে ৯.৩ সংস্করণে, ২০১৩ সালে। join strategy-র plan-এ Memoize node-ও দেখা যায়, যা একই id-র ভেতরের ফল মনে রাখে (আমাদের ২০টি আলাদা ক্রেতায় Hits: 0, তাই লাভ হয়নি)। সাধারণ JOIN-এ দুটি to-many সম্পর্ক জুড়লে সারির গুণফল হয় (৫ × ৫ = ২৫), যাকে অনেকে Cartesian explosion বলেন। JSON aggregation আর আলাদা query, দুটিই এটি এড়ায়। Network-এ pipeline বা batch করে round trip কমানোর পথও আছে, তবে সেটি driver-এর ওপর নির্ভর করে।

পরীক্ষা ও ইন্টারভিউতে:

  • N+1 কী? তালিকার ১টি query, তারপর প্রতিটি সারির জন্য আরও query; মোট ১ + (সম্পর্ক) × N.
  • include আর select-এর পার্থক্য? include সব কলামের সাথে সম্পর্ক যোগ করে; select শুধু নামে বলা ঘরগুলো আনে।
  • এক query কি সবসময় দ্রুত? না। Index ছাড়া LATERAL-এ প্রতিটি সারিতে টেবিল পড়া হতে পারে; মেপে দেখতে হয়।
  • PostgreSQL কি foreign key-তে index দেয়? না, হাতে যোগ করতে হয়।
  • Query budget test কেন সময়ের test-এর চেয়ে স্থির? SQL সংখ্যা কম্পিউটারের গতির ওপর নির্ভর করে না।

১০ · AI-এর যুগে: ORM কোড যাচাই

AI যে কোড লেখে তা প্রায়ই ছোট ডেটায় নিখুঁত চলে। N+1 ঠিক সেখানেই লুকায়। তাই AI-এর উত্তর না মেপে মেনে নেবেন না।

AI-এর ভুলকীভাবে ধরবেন
লুপের N+1 সারাতে Promise.all বসিয়ে দেয়Query counter চালান; SQL এখনো N-এর সাথে বাড়ে
পুরোনো Prisma schema বা client তৈরির কোড লেখেআপনার version-এর docs আর npx prisma --version মেলান
অস্তিত্বহীন option বানায়, অথবা flag ছাড়া relationLoadStrategy লেখেType error পড়ুন, docs-এ option খুঁজুন
Foreign key-তে index দেয় নাMigration SQL আর EXPLAIN ANALYZE দেখুন
রিপোর্টে $queryRawUnsafe-এ string জোড়ে$queryRaw tagged template ব্যবহার করুন; injection-এর test লিখুন
“এটি দ্রুত হবে” বলে, কিন্তু কোনো মাপ দেয় নানিজের ডেটায় আগে-পরে মেপে সংখ্যা রাখুন

AI-কে কোড নয়, আগে মাপ দিন। সে তখন অনুমান না করে প্রমাণ নিয়ে কথা বলবে:

আমি Prisma 7.10.1 আর PostgreSQL ব্যবহার করি। নিচে একটি Express route, তার schema আর EXPLAIN (ANALYZE, BUFFERS) আউটপুট দিলাম। একটি request-এ query counter দেখাচ্ছে [সংখ্যা]টি SQL. [কোড, schema আর plan পেস্ট করো। কোনো secret বা আসল ক্রেতার ডেটা দিও না।] ১. N+1 কোথায় আছে, লাইন ধরে বলো। ২. শুধু দরকারি অংশ বদলাও: include বা select দিয়ে। নতুন package যোগ করবে না। ৩. কোন @@index লাগবে, আর plan-এর কোন লাইন দেখে বলছ, সেটা জানাও। ৪. কোনো option-এ নিশ্চিত না হলে বলো, আমার version-এর docs-এ দেখে নেব। ৫. একটি Vitest query budget test লিখে দাও।

AI-এর ORM কোড হাতে পেলে

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন, তারপর “→ উত্তর” চাপুন।

প্র ০১N+1 এত পুরোনো সমস্যা, তবু ORM ব্যবহারকারীদের মধ্যে আজও কেন এত সাধারণ?

মূল কারণ, ORM-এর সবচেয়ে বড় সুবিধাটিই। ORM ডেটাবেসকে সাধারণ object-এর মতো দেখায়। আর object-এ একটি field পড়া প্রায় বিনামূল্যের কাজ। ডেটাবেসে সেই একই পড়া হতে পারে একটি network যাত্রা। Lazy loading যুক্ত ORM-এ order.customer.name লিখলেই গোপনে SQL চলে যায়, কোড দেখে তা বোঝার উপায় থাকে না।

এই কারণে Hibernate আর ActiveRecord-এর মতো ORM-এর ব্যবহারকারীরা বহু বছর ধরে “N+1 selects” নিয়ে লিখে আসছেন। প্রতিটি framework নিজের মতো ওষুধ দিয়েছে। Rails-এ includes আর ৬.১ সংস্করণের strict_loading, Laravel-এ with আর preventLazyLoading, Django-তে select_related ও prefetch_related, Hibernate-এ JOIN FETCH. লক্ষণীয় হলো, নতুন টুলগুলো lazy loading-কে নিষিদ্ধ করার পথ দিচ্ছে। অর্থাৎ দীর্ঘ অভিজ্ঞতার পর community মেনেছে যে, “গোপন SQL”-ই আসল বিপদ।

Prisma এই পথে গোড়াতেই আলাদা। এতে lazy loading নেই, তাই সম্পর্ক না চাইলে আসে না। ফলে লুপটি হাতে লিখতে হয়, আর চোখেও পড়ে। তবু সমস্যা হারায়নি, কারণ আসল কারণ টুল নয়, পরিবেশ। Dev ডেটায় ৫টি সারি, অ্যাপ আর ডেটাবেস একই ল্যাপটপে, RTT প্রায় শূন্য। ১১টি query সেখানে এক ms-এ শেষ। Production-এ ডেটা হাজার গুণ, RTT বহু গুণ বেশি, তখন একই কোড সেকেন্ড ছোঁয়। পরিবেশের এই ফারাক না মাপলে সমস্যাটি dev-এ অদৃশ্যই থাকে।

মূল উপলব্ধি: Abstraction খরচ লুকায়, মুছে দেয় না। ORM ব্যবহার করলে SQL জানার দরকার কমে না, বরং বড় ডেটায় মাপার অভ্যাস আরও জরুরি হয়।

প্র ০২join strategy এক query-তে কাজ সারে। তাহলে Prisma query strategy রাখল কেন? সবসময় join নয় কেন?

কারণ দুটি পথ দুটি ভিন্ন সম্পদ বাঁচায়। join বাঁচায় round trip, query বাঁচায় ডেটাবেসের CPU. আমাদের মাপা সংখ্যা দুই দিকই দেখিয়েছে। RTT ৪০ ms-এ ২০ অর্ডারে join ৪১.৭ ms আর query ৮২.৩ ms, তাই দূরের ডেটাবেসে join জিতেছে। কিন্তু একই কম্পিউটারে ৫,০০০ অর্ডার আর পণ্যসহ join প্রায় ২৩৩ ms, query প্রায় ৬৯ ms. কারণ join পথে PostgreSQL-কে প্রতিটি অর্ডারের জন্য JSON বানাতে হয়, আর সেই কাজ ডেটাবেসের ভেতরে হয়। Index না থাকলে তো কথাই নেই, ১৯৯ ms বনাম ৩৩ ms.

আরেকটি বিষয় স্কেল। অ্যাপ server যোগ করা সাধারণত সহজ, কারণ সেগুলো stateless. ডেটাবেস বড় করা কঠিন আর দামি। তাই ভারী কাজ অ্যাপে সরানোর একটি যুক্তি আছে, Prisma-র docs-ও এ কথা বলে। উল্টো দিকে, query পথের আলাদা SQL-গুলো ভিন্ন মুহূর্তে চলে, তাই মাঝখানে ডেটা বদলালে ফল সামান্য অসঙ্গত হতে পারে। আর সম্পর্কের ভেতরের take-এ আমরা দেখেছি ২,০০০ সারি এনে ফেলে দেওয়া হচ্ছে।

লক্ষ করুন, ইতিহাসেও এই দ্বন্দ্ব আছে। Prisma দীর্ঘদিন শুধু আলাদা query-র পথে চলেছে, পরে ৫.৭.০ থেকে relationJoins Preview আকারে আসে। Version 7-এর docs অনুযায়ী flag চালু থাকলে join-ই ডিফল্ট, আর লেখার সময়ে Prisma 8-এর docs-এ include-কে এক SQL-এর কাজ হিসেবে বর্ণনা করা হয়েছে। তবু Community-তে এ নিয়ে মতভেদ আছে, কেউ কেউ নির্দিষ্ট query-তে join-এ ধীর হওয়ার অভিজ্ঞতা জানিয়েছেন।

মূল উপলব্ধি: কোনো strategy চিরকাল সেরা নয়। নিজের ডেটার আকার, RTT আর index নিয়ে দুই পথই মাপুন, তারপর দরকার হলে query ধরে বদলান।

প্র ০৩PostgreSQL প্রতিটি foreign key-তে নিজে index বানায় না কেন? আপনি কি সব foreign key-তে বসাবেন?

Foreign key একটি নিয়ম: “এই মানটি ওই টেবিলে থাকতে হবে।” নিয়ম যাচাইয়ের জন্য PostgreSQL-কে দেখতে হয় লক্ষ্য টেবিলে, যেখানে primary key-র index আগেই আছে। অর্থাৎ OrderItem-এ নতুন সারি বসাতে orderId-এর index লাগে না। সেই index কাজে আসে উল্টো দিকের কাজে: “এই অর্ডারের সব সারি দিন”, অথবা Order মুছলে যাচাই। PostgreSQL ধরে নেয় না যে আপনি উল্টো দিকে পড়বেনই, কারণ index-এর দাম আছে। সিদ্ধান্ত আপনার হাতে ছেড়ে দেয়। MySQL-এর InnoDB উল্টো নকশা নিয়েছে, সেখানে নিজে থেকে index বসে।

দাম কত? আমাদের ৩ লাখ সারির OrderItem টেবিল ৩০ MB, orderId index ৪.৮ MB, productId index ২ MB. প্রতিটি INSERT-এ দুটিই হালনাগাদ হয়। আর পড়ায় লাভ? ২৬ ms থেকে ০.০৩৩ ms. বেশিরভাগ ওয়েব অ্যাপে পড়ার কাজই বেশি, তাই বাজারঘরের জন্য পাল্লা স্পষ্ট লাভের দিকে।

তবু “সব জায়গায়” বলা ঠিক নয়। ছোট টেবিলে (২০টি ক্যাটাগরি) Seq Scan-ই দ্রুত হতে পারে। দিনের কোটি লাইনের log-ধরনের টেবিলে, যেখানে কখনো উল্টো দিকে পড়া হয় না, index শুধু লেখা ধীর করে। আবার [customerId, createdAt]-এর মতো composite index থাকলে তার বাঁ অংশ দিয়েই customerId-এর কাজ চলে, আলাদা index লাগে না। আমরা সেই কারণে migration-এ আগের index মুছে দিয়েছি।

মূল উপলব্ধি: ডিফল্ট হিসেবে উল্টো দিকে পড়া foreign key-তে index রাখুন, তবে প্রতিটি index-কে slow log, plan আর লেখার চাপ দিয়ে যাচাই করুন।

প্র ০৪রিপোর্টের জন্য ORM না raw SQL? “সব ORM-এ লিখব” নিয়ম কি ভালো?

এই বিতর্ক পুরোনো, আর সৎ উত্তর হলো: কাজ ভাগ করে নিন। সাধারণ CRUD আর সম্পর্কযুক্ত read-এ ORM সত্যিই শক্তিশালী। Type মিলে যায়, কলামের নাম বদলালে compiler ধরে, আর select লিখে ফলের আকৃতি পাওয়া যায়। কিন্তু রিপোর্ট আলাদা জাতের কাজ। চার টেবিলের join, COUNT(DISTINCT), window function, তারিখ ধরে দল বাঁধা: এগুলো SQL-এর স্বাভাবিক ভাষা। ORM-এ জোর করলে দুটির একটি ঘটে। হয় কাজটি হয় না, নয়তো সব সারি অ্যাপে এনে JavaScript-এ যোগ করতে হয়, যা _count-এর উদাহরণের মতো অনেক ধীর।

Raw SQL-এর দাম কী? প্রথমত type হারায়। $queryRaw ফলকে আপনার বলা type-এ বিশ্বাস করে, যাচাই করে না, আর আমরা BigInt-এর আচমকা error-ও দেখেছি। দ্বিতীয়ত schema বদলালে compiler সাবধান করে না। তৃতীয়ত string জুড়লে injection-এর ঝুঁকি। এই দামগুলো কমানোর পথ আছে: সব মান tagged template-এ, ফল Zod দিয়ে যাচাই, আর প্রতিটি রিপোর্টের জন্য একটি integration test. Prisma-তে typedSql নামে একটি Preview সুবিধাও আছে, যা .sql ফাইল থেকে type তৈরি করে।

আর ভারী রিপোর্ট কোথায় চলছে সেটিও ভাবুন। আমাদের রিপোর্টটি ১৬০ ms নিয়েছে, যা ক্রেতার request-এর জন্য বেশি। তাই এমন query সাধারণত আলাদা read replica-তে, background job-এ, অথবা আগে থেকে হিসাব করা টেবিলে যায়। Cache নিয়ে কথা হবে পর্ব ৪৪-এ।

মূল উপলব্ধি: ORM আর SQL প্রতিদ্বন্দ্বী নয়। সাধারণ কাজ ORM-এ, রিপোর্টে সচেতন raw SQL, দুটিই parameter দিয়ে নিরাপদ রাখা, এটি একটি যুক্তিসঙ্গত নীতি।

প্র ০৫Query budget test কি সত্যিই লাভজনক? এর সীমাবদ্ধতা কী, আর production-এ কী লাগে?

ধারণাটি নতুন নয়। Django-র test framework অনেক বছর ধরে assertNumQueries দিচ্ছে। Rails জগতে bullet gem জনপ্রিয়। কারণ query সংখ্যা একটি নির্ধারিত (deterministic) মান। একই কোড, একই ডেটা, তাই একই সংখ্যা, CI কম্পিউটার যত ধীরই হোক। সময়ের test ঠিক উল্টো, flaky হয়।

তবে সীমা আছে। প্রথমত, budget-এর সংখ্যা ঠিক করা কঠিন। খুব ঢিলা হলে N+1 পেরিয়ে যায়, খুব কড়া হলে প্রতিটি নতুন সম্পর্কেই test ভাঙে। তাই আমাদের দ্বিতীয় test, “N বাড়লেও সংখ্যা একই”, বেশি মূল্যবান, কারণ সেটি N+1-এর আসল সংজ্ঞাই ধরে। দ্বিতীয়ত, query সংখ্যা ১ হলেও সেটি ধীর হতে পারে। Index ছাড়া আমাদের ১৯৯ ms-এর join ঠিক এমন। তাই budget একা যথেষ্ট নয়। তৃতীয়ত, test ডেটা ছোট হলে অনেক সমস্যা ধরা পড়ে না, plan-ও আলাদা হয়। PostgreSQL-এর query planner ছোট টেবিলে index ব্যবহার না-ও করতে পারে।

Production-এ তাই আলাদা স্তর লাগে। প্রতিটি request-এর JSON log-এ queries আর dbMs, যেমন আমরা বানিয়েছি। ডেটাবেসে slow query log আর pg_stat_statements. আর সতর্কবার্তা: কোনো route-এ গড় query সংখ্যা হঠাৎ লাফ দিলে জানানো। এগুলো একসাথে N+1-কে “কেউ অভিযোগ করলে ধরব” থেকে “merge-এর আগেই ধরব, ফাঁক গেলে production-এ দেখব”-তে নিয়ে আসে। Monitoring নিয়ে বিস্তারিত পর্ব ৮৩-তে।

মূল উপলব্ধি: ভালো test সংখ্যা নয়, স্বভাব পরীক্ষা করে। “N-এর সাথে query বাড়ে না”, এই একটি বাক্য ৪১ > ৫-এর চেয়ে বেশি কিছু বলে।

অনুশীলন

হিসাব, পূর্বাভাস, কোড পড়া আর একটি হাতে-কলমে কাজ।

  1. পূর্ণ numerical: একটি পাতায় ৫০টি পণ্য, প্রতিটিতে ক্যাটাগরি, বিক্রেতা আর রিভিউ, তিনটি সম্পর্ক লুপে আনা হয়। RTT ৫ ms, প্রতিটি SQL-এ ডেটাবেসের কাজ ০.৪ ms. (ক) মোট SQL আর সময় কত? (খ) query strategy-তে, যেখানে দ্বিতীয় যাত্রায় তিনটি IN একসাথে যায়? (গ) join-এ, ধরে নিন একটি SQL-এ কাজ ৩ ms? (ঘ) সার্ভারে প্রতি সেকেন্ডে ২০টি এমন request এলে লুপের পথে সেকেন্ডে কতটি SQL?

    (ক) SQL = ১ + ৩ × ৫০ = ১৫১. সময় ≈ ১৫১ × ৫ + ১৫১ × ০.৪ = ৭৫৫ + ৬০.৪ = ৮১৫.৪ ms.

    (খ) SQL = ১ + ৩ = ৪, round trip ২. সময় ≈ ২ × ৫ + ৪ × ০.৪ = ১০ + ১.৬ = ১১.৬ ms.

    (গ) ১ × ৫ + ৩ = ৮ ms.

    (ঘ) ২০ × ১৫১ = ৩,০২০ SQL/সেকেন্ড, অথচ query পথে মাত্র ২০ × ৪ = ৮০. অর্থাৎ শুধু পাতার গতি নয়, ডেটাবেসের ভারও প্রায় ৩৮ গুণ।

  2. Output অনুমান করুন: Prisma 7-এ ID 1, 2, 3 আগে থেকে আছে। একই tick-এ await Promise.all([1, 2, 3].map((id) => prisma.customer.findUnique({ where: { id } }))) চালালে কয়টি SQL যাবে? আর for (const id of [1, 2, 3]) await prisma.customer.findUnique({ where: { id } }) চালালে? প্রথম কোডে findUnique-এর বদলে findMany({ where: { id } }) হলে?

    আমাদের আসল run-এ প্রথমটিতে ১টি SQL: WHERE "t0"."id" IN ($1,$2,$3). Prisma-র dataloader একই tick-এর findUnique() জুড়ে দেয়।

    দ্বিতীয়টিতে ৩টি SQL, কারণ প্রতিটি await পরের কলটিকে পরের tick-এ ঠেলে দেয়, জমানোর সুযোগ থাকে না।

    findMany-তে batching-এর এই সুবিধা নেই, তাই ৩টি SQL. আমাদের ২২ SQL-এর Promise.all run-এও এটি দেখা গেছে: ২০টি findMany আলাদাই ছিল।

  3. PR critique: নিচের কোডে কী কী সমস্যা? const users = await prisma.customer.findMany(); const res = await Promise.all(users.map(async (u) => ({ ...u, orders: await prisma.order.findMany({ where: { customerId: u.id } }) }))); return res.filter((u) => u.orders.length > 10).map((u) => u.name);
    • কোনো take নেই: সব ক্রেতা আসে, ১,০০০ জনে ১,০০১টি SQL.
    • Promise.all-এর ভেতরে query: N+1, আর পুরো connection pool এক request-এ দখল।
    • সব অর্ডারের সব কলাম আনা হচ্ছে শুধু গোনার জন্য: over-fetching.
    • customerId-তে index আছে কি না দেখা হয়নি।

    ভালো রূপ: prisma.order.groupBy({ by: ['customerId'], _count: { _all: true }, having: { customerId: { _count: { gt: 10 } } } }), তারপর সেই id দিয়ে নাম আনা, অথবা take-সহ একটি ছোট $queryRaw. একটি query budget test-ও যোগ করা উচিত। ডেটাবেস থেকে বেশি সারি নয়, উত্তরটিই আনুন।

  4. কমান্ডের কাজ: নিজের যেকোনো Prisma project-এ log: [{ emit: 'event', level: 'query' }] চালু করে একটি তালিকা endpoint চালান। তারপর log-এর ধীরতম SQL-টি নিয়ে EXPLAIN (ANALYZE, BUFFERS) চালান। Plan-এ কী কী দেখবেন, আর পরের সিদ্ধান্ত কীভাবে নেবেন?
    1. Query সংখ্যা কি N-এর সাথে বাড়ে? বাড়লে আগে include বা select.
    2. Plan-এ Seq Scan আর বড় Rows Removed by Filter থাকলে WHERE-এর কলামে index লাগতে পারে।
    3. Sort থাকলে আর LIMIT-ও থাকলে composite index ভাবুন: সমান-শর্ত আগে, sort পরে।
    4. Index দিয়ে migration চালান, আবার একই EXPLAIN চালান, Buffers আর Execution Time আগে-পরে লিখে রাখুন।
    5. ছোট dev টেবিলে Seq Scan দেখলেই ভয় পাবেন না। PostgreSQL ছোট টেবিলে ঠিক কারণেই সেটি বেছে নিতে পারে। বড় seed ডেটায় পরীক্ষা করুন।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমএকটি ধীর ORM query সারানোর ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. Slow query log বা query counter থেকে query-টি খুঁজে বের করা
  2. EXPLAIN ANALYZE চালিয়ে plan পড়া
  3. Query সংখ্যা বা index, আসল কারণটি ঠিক করা
  4. Migration দিয়ে পরিবর্তন প্রয়োগ করা
  5. আবার মেপে আগে-পরের সংখ্যা তুলনা করা
  6. Query budget test যোগ করা, যাতে সমস্যা ফিরে না আসে

একটি ORM কোড ৩০টি পণ্য আনে, তারপর লুপে প্রতিটির ক্যাটাগরি আর রিভিউ আলাদা query-তে আনে। মোট কতটি SQL চলবে?

  • ৩০
  • ৩১
  • ৬১
  • ৯১

Prisma-তে include আর select-এর মূল পার্থক্য কী?

  • শুধু include সম্পর্ক আনতে পারে
  • include সব কলামের সাথে সম্পর্ক যোগ করে, select শুধু নাম করা ঘর আনে
  • select সবসময় বেশি SQL চালায়
  • একই স্তরে দুটি একসাথে লিখলে ফল সবচেয়ে ভালো হয়

PostgreSQL-এ relationLoadStrategy: 'join' ব্যবহার করে Prisma কোন ধরনের SQL পাঠায়?

  • টেবিলপ্রতি একটি করে SELECT
  • সাধারণ INNER JOIN, যেখানে সম্পর্কের সারি গুণ হয়ে ফেরে
  • একটি সাময়িক টেবিল বানিয়ে তাতে insert
  • একটি SQL, যেখানে LEFT JOIN LATERAL JSON বানিয়ে দেয়

একটি পাতায় ৪১টি SQL চলে, RTT ২০ ms. প্রতিটি SQL একটির পর একটি যায়। শুধু network-এ অপেক্ষা কতটুকু?

  • ৮২০ ms
  • ৪১০ ms
  • ২০ ms
  • ৬১ ms

Query: WHERE customer_id = ? ORDER BY created_at DESC LIMIT 10. PostgreSQL-এ কোন index সবচেয়ে উপযুক্ত?

  • (customer_id)
  • (created_at DESC, customer_id)
  • (customer_id, created_at DESC)
  • (total)

Query budget test-এ সময়ের বদলে SQL সংখ্যা assert করা কেন বেশি নির্ভরযোগ্য?

  • SQL সংখ্যা কম মানেই query দ্রুত
  • একই কোড আর ডেটায় সংখ্যাটি স্থির, সময় কম্পিউটারভেদে নড়ে
  • PostgreSQL সংখ্যার সীমা ভাঙলে query থামিয়ে দেয়
  • এই test-এ কোনো database লাগে না

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন, অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. N+1 = লুপের ভেতরে query: সংখ্যা ১ + (সম্পর্ক) × N. include বা select লুপ সরায়; Promise.all শুধু আড়াল করে।
  2. select payload কমায়, আর join বনাম query round trip বনাম ডেটাবেসের কাজের লেনদেন। Prisma 7-এ এটি Preview, তাই নিজের version মিলিয়ে নিন।
  3. মোট সময় ≈ round trip × RTT + কাজ. অনেক ছোট query network-এ গুণ হয়।
  4. মাপুন: $on('query') + AsyncLocalStorage প্রতি request-এ, আর test-এ “N বাড়লেও query বাড়ে না”।
  5. PostgreSQL-এ foreign key-তে index নিজে দিন। Slow log → EXPLAIN ANALYZE → সমান-শর্ত আগে, sort পরে → আবার মাপা।

পরের পর্বে: পড়া এখন দ্রুত। কিন্তু একই শেষ পণ্য দুজন একসাথে কিনলে? পর্ব ৪৩-এ transaction, stock আর race condition.

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান