Foundationপ্রথম নীতি থেকে
LEVEL 8লেসন ৬/১৯কঠিন৫৫ মিনিট

Query Processing — SQL text থেকে result rows

Query Processing Pipeline

আপনার SQL text database-এর ভেতরে ছোট্ট একটা program হয়ে যায় — parse, rewrite, plan, execute-এর চার-ধাপের যাত্রা, আর সেই যাত্রার X-ray হলো EXPLAIN।

এই লেসন শেষে আপনি পারবেন

  • SQL statement থেকে result rows পর্যন্ত পাঁচ-ধাপের pipeline — lex/parse, analyze, rewrite, plan, execute — প্রতিটা ধাপের input আর output বাক্যে বলতে পারবেন
  • Raw parse tree আর analyzed Query tree-এর পার্থক্ষ দেখিয়ে name resolution আর type coercion ঠিক কোন ধাপে ঘটে তা trace করতে পারবেন
  • Rewrite phase-এর চারটা rule-based transformation — view expansion, constant folding, predicate pushdown preview, outer-join simplification — উদাহরণসহ প্রয়োগ করতে পারবেন
  • Volcano iterator, vectorized batch আর materialization executor model তিনটা তুলনা করে PostgreSQL কোনটা কেন বেছেছে তার কারণ বলতে পারবেন
  • EXPLAIN (ANALYZE, BUFFERS, VERBOSE) output-এর প্রতিটা field — cost, rows, loops, buffers — পড়ে plan tree নিজের হাতে আঁকতে পারবেন

আগে যা বোঝা থাকা দরকার

আগে এটা বুঝি

আগের লেসনগুলোতে আমরা database-এর ভিত দেখেছি — slotted page-এ tuple কীভাবে বসে, buffer pool কীভাবে disk আর memory-র মাঝে দালালি করে। কিন্তু একটা প্রশ্ন এখনো ঝুলে আছে: আপনি SELECT ... FROM ... WHERE লিখলেন, আর মুহূর্তেই rows ফিরে এলো। মাঝের অন্ধকার কক্ষে ঠিক কী ঘটল?

উত্তরটা চমকপ্রদ: আপনার SQL text আসলে একটা program, আর database সেটাকে একটা compiler-এর মতোই সামলায়। Tokenize, parse, semantic analysis, optimization, execution — compilers module-এর সেই pipeline-এর প্রতিধ্বনি এখানেও শোনা যায় (informal করে বললে: SQL source code, plan tree তার machine code)।

আজকের journey:

  • পাঁচ ধাপের pipeline — প্রতিটা ধাপ ঠিক কী input নেয়, কী output দেয়
  • Raw parse tree বনাম analyzed Query tree — কেন দুইটা আলাদা জিনিস
  • Rewrite phase: view expansion, constant folding, predicate pushdown preview
  • Executor-এর তিন দর্শন — volcano, vectorized, materialization
  • EXPLAIN — পুরো pipeline-টার X-ray মেশিন, লাইনে-লাইনে পড়া শেখা

মূল ধারণা

পাঁচ ধাপের pipeline

PostgreSQL-এ একটা statement-এর যাত্রা:

ধাপInputOutputদায়িত্ব
Lex + ParseSQL textraw parse treegrammar check, token থেকে tree
Analyzeraw parse treeQuery treename resolution, type coercion
RewriteQuery treerewritten Query treerules apply — views, constraints
Planrewritten treePlan treeaccess path বাছাই, join order
ExecutePlan treetuplesbuffer pool থেকে rows pull

compilers module-এর pipeline-overview লেসনের সাথে মেলান: lexer+parser ↔ scanner/parser, analyze ↔ semantic analysis, planner ↔ optimizer+codegen, executor ↔ interpreter/runtime। পার্থক্য একটাই — SQL-এর “machine” হলো storage engine, আর প্রতিটা statement-ই fresh compile হয় (prepared statement cache ছাড়া)।

Raw parse tree vs Query tree — কেন দুইটা

Parser-এর কাজ শুধু grammar: SELECT name FROM users WHERE id = 5 বাক্যটা ঠিক shape-এর কি না। ফল হলো raw parse tree — column আর table শুধু string, id আসলে কোন relation-এর কলাম সে জানে না।

Analyze stage (PostgreSQL-এ parse_analyze) এসে জাগলিক জগৎটা বসায়:

  1. Name resolution: users, name, id — প্রতিটাকে catalog (pg_class, pg_attribute) খুঁজে OID-তে bind করা। টাইপো করলে column "nmae" does not exist error এখানেই আসে, parser-এ না।
  2. Type coercion: '2024-01-01' = placed_at — string আর date-এর তুলনা; analyzer implicit cast ঢোকায়, operator family ঠিক করে। WHERE id = 'abc'-এর invalid input syntax ও এই ধাপের ফসল।

Rewrite phase — rule-ভিত্তিক রূপান্তর

Query tree এসে rewriter-এর টেবিলে, যে নিয়মগুলো অর্থ অক্ষুণ্ণ রেখে tree বদলায়:

  • View expansion: View মানে stored query না — একটা rewrite rule। SELECT * FROM active_users দিলে rewriter ভেতরের query tree-টা সরাসরি গেঁথে দেয়; আলাদা করে view “execute” হয় না।
  • Constant folding: WHERE amount > 500*2 → amount > 1000; expression-এর ভেতরের immutable হিসাব এখনই হয়ে যায়।
  • Predicate pushdown preview: WHERE condition-গুলো scan node-এর যত কাছে নেওয়া যায় তত কম tuple উপরে ওঠে — planner-কে সাহায্যের প্রথম প্যাঁচ।
  • Outer-join simplification: LEFT JOIN t ON true-এর মতো degenerate case গুলো inner join বা plain scan-এ নেমে আসে; অপ্রয়োজনীয় outer side মুছে যায়।

Executor-এর তিন দর্শন

Plan tree হাতে পেয়ে executor চালায় — কিন্তু কীভাবে data flow করবে, সেটা তিন স্কুল:

ModelUnitStrengthWeakness
Volcano / iterator১ tuple প্রতি next() callgeneric, sparse-result friendly, LIMIT short-circuitvirtual call overhead প্রতি tuple-এ
Vectorizedbatch (হাজার tuple)CPU cache + SIMD ভালো খায় — analytics-এ ঝড়complex operator-এ batch bookkeeping
Materializationপুরো input একসাথেসরল, sort/hash-এ naturalmemory খায়, intermediate বড় হলে বিপদ

PostgreSQL default-এ volcano — ExecInitNode দিয়ে tree init, ExecProcNode দিয়ে root-এ next() টানা। তবে আধুনিক PG একটা বাঁক নিয়েছে: যখন এক expression প্রায় লক্ষ tuple-এর ওপর চলবে (jit_above_cost), LLVM দিয়ে সেই expression loop-টাই compile হয়ে যায় — JIT expression compilation। Vectorization-এর পূর্ণ স্বাদ তাই না, কিন্তু per-tuple overhead-এর কিছুটা দাম কমানো।

ভেতরে কী ঘটছে

Parser-এর ভেতরে — bison grammar

PostgreSQL-এর parser হাতে লেখা না — gram.y নামের এক bison grammar file থেকে generate হয়, কয়েক লক্ষ state। আপনি যখন SELECT DISTINCT ON (...) লেখেন, আসলে একটা LALR parser সেই production rule গুনে tree বানাচ্ছে। এজন্যই SQL-এর অদ্ভুত syntax (যেমন GROUP BY-এর আগে HAVING লিখতে না পারা) ঠিক grammar-এর স্বভাব — language design না, yacc constraint।

Query tree-র নাড়ি-ধমনি

Analyzed Query tree-র প্রধান অঙ্গ-প্রত্যঙ্গ (C struct Query):

Query {
  commandType:   SELECT/INSERT/UPDATE/DELETE
  rtable:        range table — কোন কোন relation এসেছে (OID সহ)
  targetList:    কোন expression return হবে
  jointree:      FROM clause-এর boolean-algebra রূপ
  qual:          WHERE condition (implicit AND list)
  groupClause:   GROUP BY expressions
  sortClause:    ORDER BY
}

Planner এই struct-টাকে Path tree দিয়ে cost করে, শেষে Plan tree-তে নামায় — পরের লেসনে সেই ভেতরটা।

Volcano executor — হাতে লেখা skeleton

প্রতিটা plan node একটা object তিনটা method সহ:

class Node:
    def open(self): pass                    # children open + own state reset
    def next(self):
        while True:
            row = self.child.next()         # pull model — child টানা হয়
            if row is None: return None     # exhaustion
            if self.passes(row):            # own predicate
                return self.project(row)    # own projection
    def close(self): pass                   # resource release

class SeqScan(Node):
    def __init__(self, table, cond):
        self.pages, self.i = table.pages(), 0
        self.cond = cond
    def next(self):
        while self.i < len(self.pages):         # page-by-page
            row = self.pages[self.i].tuple_at(self.cursor)
            self.advance()
            if row and self.cond(row): return row
        return None

Root-এ next() ডাকলে চাই পুরো subtree নিজে নিজে সাজে — উপরের node কিছুই জানে না নিচে কী ঘটছে। <Term id="recursion">recursion</Term> না থাকলেও এটা তার চেতনা: ছোট সরল interface, গভীর composite tree।

LIMIT-এর জাদু এখানেই: root যখন ১০-তম tuple পেয়ে থামে, নিচের Seq Scan মাঝপথেই থেমে যায় — কাজ নষ্ট হয় না।

উদাহরণ

একটা query-র পূর্ণ জীবন

Schema ছোট, প্রশ্নটা বাস্তব — কোন customer কত item অর্ডার করেছে, শুধু বড় order-এর জন্য:

SELECT c.name, SUM(i.qty)
FROM items i
JOIN orders o ON i.order_id = o.id
JOIN customers c ON o.customer_id = c.id
WHERE o.amount > 500
GROUP BY c.name;

ধাপে ধাপে কী হয়:

১. Parse: পাঁচটা clause চেনা tree-তে বসে; c.name এখনো দুইটা string।

২. Analyze: items, orders, customers OID-resolve; alias binding; SUM(qty)-র overload ঠিক; o.amount > 500 numeric comparison-এ cast।

৩. Rewrite: এখানে কিছুই বদলাল না (view নেই) — কিন্তু big_orders view দিয়ে লেখা হলে ঠিক এই মুহূর্তে সেটা খুলে গেঁথে যেত।

৪. Plan: নিচের মতো একটা tree দাঁড়ায় (cost সংখ্যা মেশিনভেদে):

HashAggregate  (cost=8123.10..8123.20 rows=400)
  -> Hash Join  (cost=1200.00..6900.00 rows=98000)   c ⟕ o
       Hash Cond: (o.customer_id = c.id)
       -> Seq Scan on orders  Filter: (amount > 500)
       -> Hash
            -> Seq Scan on customers

৫. Execute: Volcano pull — HashAggregate টানছে, Hash Join probe করছে, buffer pool page আনছে; SUM accumulate হয়ে client-এ একটা result set।

লক্ষ্য করুন: WHERE টা orders-এর নিজের node-এ নেমে গেছে — pushdown-এর হাতে-কলমে রূপ। items এখনো tree-তে নেই কেন ভাবছেন? সহজ রাখতে উদাহরণে two-table দেখালাম; পরের experiment-এ পূর্ণ three-table version নিজেই পড়বেন।

ছবিতে

Pipeline map — compiler-এর পাশে

SQL text ──▶ ┌─────────────┐    raw parse tree
             │ Lex + Parse │───────────────────┐
             └─────────────┘                   ▼
source code ─▶ Lexer/Parser ══▶ AST        ┌────────────┐
(compilers)                                │  Analyze   │   Query tree
                                           │ (semantic) │───────────┐
                                           └────────────┘           ▼
                                                    ┌───────────────┐
AST ═════════▶ Sema + IRGen ══▶ typed IR           │    Rewrite    │
(compilers)                                        │ (rule engine) │
                                                   └───────┬───────┘
                                                           ▼
typed IR ───▶ Optimizer ══▶ optimized IR           ┌───────────────┐
(compilers)                                        │    Planner    │  Plan tree
                                                   └───────┬───────┘
                                                           ▼
optimized IR ▶ Codegen ══▶ machine code            ┌───────────────┐
(compilers)                                        │   Executor    │  tuples
                                                   └───────────────┘
SQL-এর যাত্রা compilers-এর pipeline-এর পাশে পাশে — ভাষা আলাদা, architecture এক।

Executor-এর গাছটাও ঠিক plan tree-রই ছায়া:

Execute মুহূর্তে plan tree-র ভেতরে কে টানছে কাকে
  1. HashAggregateroot — GROUP BY key-তে hash bucket ধরে SUM জমায়
  2. Hash Join (inner)probe side: orders-এর filtered tuples টেনে hash table-এ খোঁজে
  3. Seq Scan orders + Filterpage-by-page read — amount threshold পার করেনি এমন tuple ফেলে দেয়, buffer pool-এর কাছ থেকে
  4. Hash build ← Seq Scan customersbuild side: ছোট table-এর tuple দিয়ে hash table তৈরি

নিজে চালিয়ে দেখুন

EXPERIMENT

EXPLAIN VERBOSE লাইনে-লাইনে dissect + auto_explain + view rewrite

psql (Docker: docker run --name pg-lab -e POSTGRES_PASSWORD=secret -p 5432:5432 -d postgres:16)· ২৫ মিনিট

ধাপ ১ — three-table dataset বানান:

CREATE TABLE customers (id serial PRIMARY KEY, name text);
CREATE TABLE orders (
  id serial PRIMARY KEY,
  customer_id int REFERENCES customers(id),
  amount numeric(10,2)
);
CREATE TABLE items (
  id serial PRIMARY KEY,
  order_id int REFERENCES orders(id),
  qty int
);

INSERT INTO customers (name)
SELECT 'cust_' || g FROM generate_series(1, 2000) g;

INSERT INTO orders (customer_id, amount)
SELECT (random()*1999)::int + 1, round((random()*2000)::numeric, 2)
FROM generate_series(1, 60000);

INSERT INTO items (order_id, qty)
SELECT (random()*59999)::int + 1, (random()*5)::int + 1
FROM generate_series(1, 150000);

ANALYZE customers; ANALYZE orders; ANALYZE items;

ধাপ ২ — VERBOSE output dissect করুন:

EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT c.name, SUM(i.qty)
FROM items i JOIN orders o ON i.order_id = o.id
JOIN customers c ON o.customer_id = c.id
WHERE o.amount > 500
GROUP BY c.name;

Output-এর একটা representative slice, annotation সহ:

Finalize GroupAggregate  (cost=.. rows=..) (actual time=95.0 rows=2000 loops=1)
  Output: c.name, sum(i.qty)                 ← VERBOSE-এর অবদান: node-এর output columns
  -> Sort ...
     -> Hash Join  (actual rows=45000 loops=1)
        Hash Cond: (o.customer_id = c.id)     ← join algorithm-এর সুইচবোর্ড
        -> Seq Scan on public.orders o
              Output: o.id, o.customer_id, o.amount
              Filter: (o.amount > '500'::numeric)
              Rows Removed by Filter: 15123   ← filter-এর শিকার — waste-এর মাপ
              Buffers: shared hit=520 read=13 ← buffer pool lesson live!
        -> Hash
              Buckets: 4096  Batches: 1  Memory Usage: 180kB
              -> Seq Scan on public.customers c (actual rows=2000 loops=1)

তিনটা reading-rule: (১) rows= estimate, (actual rows=N) সত্যি — gap বড় হলে statistics suspect (পরের লেসনের বিষয়)। (২) actual rows per-loop average — loops=1000 হলে মোট = rows × loops। (৩) Rows Removed by Filter বড় মানে index-এর দরকার হতে পারে।

ধাপ ৩ — auto_explain দিয়ে ধীর query log করুন:

LOAD 'auto_explain';
SET auto_explain.log_min_duration = '0';   -- সব লগ করো (production-এ '500ms')
SET auto_explain.log_analyze = true;
SET auto_explain.log_buffers = true;

-- এখন থেকে session-এর প্রতিটা statement server log-এ নিজের plan লিখে যাবে
SELECT count(*) FROM items i JOIN orders o ON i.order_id = o.id;

Log পড়ুন: Docker-এ docker logs pg-lab 2>&1 | grep -A 15 'Query Text'। Cluster-wide চাইলে shared_preload_libraries = 'auto_explain' restart সহ — production slow-query hunting-এর স্ট্যান্ডার্ড অস্ত্র।

ধাপ ৪ — view rewrite চোখে দেখুন:

CREATE VIEW big_orders AS
  SELECT * FROM orders WHERE amount > 1000;

EXPLAIN SELECT customer_id FROM big_orders WHERE customer_id = 42;
Index Only Scan using ?? on orders        -- view-এর WHERE + আপনার WHERE মিশে গেছে!
  Index Cond: ((customer_id = 42) AND (amount > '1000'))

View-এর condition আর আপনার condition এক node-এ গলে গেছে — কোথাও আলাদা “view execution” নেই। এটাই rewrite phase-এর view expansion, plan-এর ভাষায় সাক্ষ্য।

এটা কী প্রমাণ করে

EXPLAIN output-এর প্রতিটা field plan tree-র এক-একটা node-এর প্রতিচ্ছবি — cost/rows/loops/buffers পড়েই executor-এর আসল আচরণ reconstruct করা যায়, আর view আসলে rewrite-time-এ inline হয় তা plan-এই চোখে পড়ে।

নিজে বানান

BUILD IT

PlanTree pretty-printer — JSON plan থেকে indented tree + estimate warning

Python · ●●○○○
  1. EXPLAIN (FORMAT JSON)-এর সরলীকৃত subset ধরে node class model করুন
  2. Recursive render — indent guides (├─ └─ │) দিয়ে tree আঁকুন
  3. Node-এর extras (Filter, Hash Cond, Sort Key...) আলাদা লাইনে ছাপুন
  4. Estimated-vs-actual ratio হিসাব করে ১০× পার হলে ⚠ flag দিন
  5. loops গুণ ধরে total-এ তুলনা — per-loop average-এর ফাঁদ এড়ান

লক্ষ্য: EXPLAIN (FORMAT JSON) যা দেয় তার সরল রূপ consume করে terminal-এ মানবপাঠ্য tree — ঠিক যেভাবে আপনি psql-এ text output পড়েন, কিন্তু estimate-drift warning সহ।

import json

WARN_X = 10.0

def _pad(prefix, last):
    return prefix + ('   ' if last else '│  ')

def fmt_head(n):
    label = n['Node Type']
    if 'Relation Name' in n:
        label += f" on {n['Relation Name']}"
        if n.get('Alias') != n.get('Relation Name') and n.get('Alias'):
            label += f" as {n['Alias']}"
    if 'Join Type' in n:
        label += f" ({n['Join Type']})"
    su, tc = n['Startup Cost'], n['Total Cost']
    head = f"{label}  (cost={su:.2f}..{tc:.2f} rows={n['Plan Rows']} width={n['Plan Width']})"
    if 'Actual Rows' in n:
        head += f" (actual rows={n['Actual Rows']} loops={n.get('Actual Loops', 1)})"
    return head

def fmt_extras(n):
    out = []
    for k in ('Sort Key', 'Group Key', 'Hash Cond', 'Join Filter',
              'Index Cond', 'Filter', 'Strategy'):
        if k in n:
            out.append(f"{k}: {n[k]}")
    return out

def render(n, prefix='', last=True, lines=None):
    lines = lines if lines is not None else []
    branch = '└─ ' if last else '├─ '
    lines.append(f"{prefix}{branch}{fmt_head(n)}")
    pad = _pad(prefix, last)
    for e in fmt_extras(n):
        lines.append(f"{pad}   {e}")
    if 'Actual Rows' in n:
        loops = n.get('Actual Loops', 1)
        est_t, act_t = n['Plan Rows'] * loops, n['Actual Rows'] * loops
        hi, lo = max(est_t, act_t), max(min(est_t, act_t), 1)
        if hi / lo >= WARN_X:
            lines.append(f"{pad}   ⚠ estimate off by {hi/lo:.0f}x "
                         f"(planned {n['Plan Rows']}, actual {n['Actual Rows']}, loops={loops})")
    kids = n.get('Plans', [])
    for i, k in enumerate(kids):
        render(k, pad, i == len(kids) - 1, lines)
    return lines

def print_plan(doc):
    root = doc['Plan'] if isinstance(doc, dict) and 'Plan' in doc else doc
    for line in render(root, '', True):
        print(line)

# ---- sample plans (EXPLAIN FORMAT JSON-এর সরলীকৃত রূপ) ----
HASH_AGG_PLAN = {
  'Node Type': 'Finalize GroupAggregate', 'Startup Cost': 9000, 'Total Cost': 9500,
  'Plan Rows': 40, 'Plan Width': 24, 'Actual Rows': 2000, 'Actual Loops': 1,
  'Group Key': ['c.name'],
  'Plans': [{
    'Node Type': 'Gather Merge', 'Startup Cost': 8500, 'Total Cost': 9400,
    'Plan Rows': 60, 'Plan Width': 24, 'Actual Rows': 4000, 'Actual Loops': 1,
    'Plans': [{
      'Node Type': 'Partial HashAggregate', 'Startup Cost': 7400, 'Total Cost': 7600,
      'Plan Rows': 30, 'Plan Width': 24, 'Actual Rows': 2000, 'Actual Loops': 2,
      'Group Key': ['c.name'],
      'Plans': [{
        'Node Type': 'Hash Join', 'Startup Cost': 300, 'Total Cost': 6800,
        'Plan Rows': 98000, 'Plan Width': 32, 'Actual Rows': 45000, 'Actual Loops': 2,
        'Hash Cond': '(o.customer_id = c.id)',
        'Plans': [
          {'Node Type': 'Seq Scan', 'Relation Name': 'orders', 'Alias': 'o',
           'Startup Cost': 0, 'Total Cost': 3200, 'Plan Rows': 30000, 'Plan Width': 16,
           'Actual Rows': 44877, 'Actual Loops': 2, 'Filter': "(amount > '500'::numeric)"},
          {'Node Type': 'Hash', 'Startup Cost': 250, 'Total Cost': 280,
           'Plan Rows': 2000, 'Plan Width': 16, 'Actual Rows': 2000, 'Actual Loops': 2,
           'Plans': [
             {'Node Type': 'Seq Scan', 'Relation Name': 'customers', 'Alias': 'c',
              'Startup Cost': 0, 'Total Cost': 25, 'Plan Rows': 2000, 'Plan Width': 16,
              'Actual Rows': 2000, 'Actual Loops': 2}]}]}]}]}]}

if __name__ == '__main__':
    print_plan(HASH_AGG_PLAN)

প্রত্যাশিত output (renderer-এর হিসাব অনুযায়ী):

└─ Finalize GroupAggregate  (cost=9000.00..9500.00 rows=40 width=24) (actual rows=2000 loops=1)
      Group Key: ['c.name']
      ⚠ estimate off by 50x (planned 40, actual 2000, loops=1)
   └─ Gather Merge  (cost=8500.00..9400.00 rows=60 width=24) (actual rows=4000 loops=1)
         ⚠ estimate off by 67x (planned 60, actual 4000, loops=1)
      └─ Partial HashAggregate  (cost=7400.00..7600.00 rows=30 width=24) (actual rows=2000 loops=2)
            Group Key: ['c.name']
            ⚠ estimate off by 67x (planned 30, actual 2000, loops=2)
         └─ Hash Join  (cost=300.00..6800.00 rows=98000 width=32) (actual rows=45000 loops=2)
               Hash Cond: (o.customer_id = c.id)
            ├─ Seq Scan on orders as o  (cost=0.00..3200.00 rows=30000 width=16) (actual rows=44877 loops=2)
            │     Filter: (amount > '500'::numeric)
            └─ Hash  (cost=250.00..280.00 rows=2000 width=16) (actual rows=2000 loops=2)
               └─ Seq Scan on customers as c  (cost=0.00..25.00 rows=2000 width=16) (actual rows=2000 loops=2)

⚠ লাইনগুলোই এই tool-এর প্রাণ — estimate drift চেনে না মানুষ, চেনে সংখ্যার অনুপাত।

নিজে বাড়ান:

  1. EXPLAIN (FORMAT JSON)-এর আসল output paste করে সরাসরি চালান — json.load-এর পরে একই renderer কাজ করবে
  2. Nested Loop + Index Scan-সহ second plan sample যোগ করে দুই plan পাশাপাশি diff করুন
  3. ANSI color যোগ করুন — warning লাল, scans নীল, joins হলুদ

বাস্তব সিস্টেমে

বাস্তব জগতে query processing

Production monitoring। auto_explain + log aggregation-ই slow-query triage-এর প্রথম স্তর; pganalyze, pgMustard-এর মতো tool সেই plan-গুলো পড়ে suggestion দেয় — মূলত আজ যা শিখলেন তারই automated রূপ।

ORM debugging। Django/Prisma/Hibernate যে SQL বানায় তার N+1 বা monster-join ধরার একমাত্র নির্ভরযোগ্য পথ EXPLAIN ANALYZE — ORM-এর abstraction ভেতরে ঠিক এই pipeline-ই চালায়।

Vectorized engines। DuckDB, ClickHouse, Snowflake-এর analytic path vectorized/batch execution — MonetDB/X100 paper (2005) থেকে শুরু; OLAP workload-এ per-tuple volcano overhead-ই সেখানে প্রথম শিকার। PostgreSQL-এর JIT সেই ব্যথার আধা-প্রতিকার।

Plan caching। Prepared statement-এ parse-analyze-plan একবারই; extended protocol (PQexecParams) বারবার execute — high-QPS service-এ latency budget-এর বড় অংশ এখান থেকে বাঁচে।

যে ভুলগুলো সবাই করে

“EXPLAIN লিখলেই query চলে যায়, তাই EXPLAIN দেখা ব্যয়বহুল।”

শুধু EXPLAIN কিছুই execute করে না — শুধু plan দেখায়, মিলিসেকেন্ডে। Execute হয় EXPLAIN ANALYZE দিলে — তখন সত্যিই সব data scan হয়। আর সতর্ক: UPDATE/DELETE-এর সামনে EXPLAIN ANALYZE দিলে data সত্যিই বদলে যায় — transaction wrap করে ROLLBACK করুন।

“EXPLAIN-এর cost number মিলিসেকেন্ড — cost=100 মানে ১০০ms।”

Cost একটা arbitrary unit-এর relative score: seq_page_cost=1 ধরে সব কিছু calibrate। একই query দুই machine-এ ভিন্ন cost, একই machine-এ দুই plan-এর তুলনাতেই cost-এর একমাত্র বৈধ ব্যবহার। Actual time দেখতে ANALYZE option — cost কখনো clock না।

“View slow, কারণ প্রতিবার stored query আলাদা করে চলে।”

View কোনো executable entity না — rewrite phase-এ তার tree সরাসরি গেঁথে যায় (experiment ধাপ ৪-এর প্রমাণ)। Optimizer গেঁথে-যাওয়া অংশটাকেও পূর্ণ স্বাধীনতায় optimize করে। ধীর view মানে ভেতরের query-ই ধীর — আলাদা penalty নেই। ব্যতিক্রম: materialized view, সেটা আসলে stored table।

“Column-এর বানান ভুল হলে parser error দেয় — তাহলে parsing-ই schema-aware।”

Parser শুধু grammar দেখে — SELECT nosuchcol FROM t perfectly valid sentence, তাই parser pass করে। Error আসে analyze stage-এ, catalog lookup ব্যর্থ হলে (column "nosuchcol" does not exist)। Error message-এর tone-ই বলে দেয় কোন ধাপে আটকালেন — এটা debugging-এর shortcut।

বুঝেছেন কি না দেখুন

1

পাঁচ-ধাপের pipeline-এ প্রতিটা ধাপের input আর output এক লাইনে বলুন।

স্মরণ

Text → raw parse tree (lex+parse); raw tree → Query tree (analyze: name resolution + coercion); Query tree → rewritten Query tree (rewrite rules); rewritten tree → Plan tree (planner); Plan tree → tuples (executor)।

2

EXPLAIN ANALYZE-এ এক node দেখাচ্ছে (actual rows=3 loops=1000)। ঐ node মোট কত row produce করেছে? শুধু rows=3 পড়ে কী ভুল conclusion হতো?

প্রয়োগ

মোট = 3 × 1000 = 3000। শুধু 3 পড়লে মনে হতো index lookup প্রায় ফাঁপা — অথচ প্রতিটা outer tuple-এ গড়ে ৩-row ফলে ৩০০০ row উপরে গেছে; nested-loop-এর ভেতরের node পড়তে loops-গুণ বাধ্যতামূলক।

3

column "nmae" does not exist আর syntax error at or near ")" — দুটো কোন কোন ধাপে জন্ম? কেন দুই ধাপ আলাদা রাখা হয়েছে?

যুক্তি

প্রথমটা analyze (catalog binding ব্যর্থ), দ্বিতীয়টা parse (grammar violation)। আলাদা রাখার কারণ: grammar শুধু language-এর ব্যাপার, context-free; name/type context-dependent — catalog দরকার। Separation মানে parser স্বয়ংসম্পূর্ণ testable, আর extension (custom type/operator) শুধু analyze/planner layer ছুঁয়ে যোগ হয়।

4

Volcano model LIMIT-এ দুর্দান্ত কিন্তু full-scan aggregate-এ ধীর কেন? PostgreSQL তবু কেন volcano রেখেছে?

যুক্তি

Volcano-র প্রতি-tuple virtual-call overhead constant, কিন্তু aggregate-এ কোটি tuple পেরোলে সেই constant কোটিবার — measurable। LIMIT-এ আবার early-stop লাভ দেয়। PG-র পছন্দ: generic plan node interface (FDW, custom scan, parallel workers সব এই contract-এ), sparse OLTP result-এ খরচ নগণ্য — trade-off সচেতন।

5

WHERE created_at > now() - interval '7 days' — constant folding এখানে কি হবে? now()-কে কেন fold করা বিপজ্জনক হতো?

প্রয়োগ

হবে না — now() stable, immutable না; এক statement-এর ভেতরে constant, statement-এর মাঝে variable। Fold করলে prepared statement cache-এ পুরনো timestamp আটকে থাকত — প্রতিটা execution ভুল সময়সীমা ধরত। Folding শুধু pure function-এ (যেমন '5'::int * 2)।

6

আপনার report query-তে view-in-view-in-view — তিন স্তরের nesting। Debugging-এ কোন tool দিয়ে বুঝবেন rewriter ঠিক কী বানাল, আর কোন signal বলবে pushdown কাজ করেনি?

ডিজাইন

EXPLAIN (VERBOSE) — প্রতিটা node-এর Output list আর merged conditions দেখায়; গভীরতম base relation-এ আপনার outermost WHERE-এর predicates নেমে এসেছে কি না সেটাই pushdown-এর সাক্ষ্য। Signal of failure: উপরের node-এ বড় Filter আর নিচে Rows Removed by Filter-এর স্তূপ — অর্থাৎ বেশি tuple উঠে এসে পরে ফেলা হচ্ছে; non-pushable construct (volatile function, window) খুঁজুন।

এরপর কী

এরপর কী

Plan tree-র node-গুলোর মধ্যে সবচেয়ে দামি — join nodes। আজ আমরা Hash Join, Nested Loop-এর নাম দেখেছি; পরের লেসনে তিনটা join algorithm ভেতর-থেকে খুলব:

  • Nested loop: naive O(N·M) থেকে block-nested — buffer-sized chunk-এর IO গণিত
  • Index-nested loop: ছোট outer + indexed inner-এর জাদু
  • Hash join: build + probe, আর memory ছোট হলে grace-hash partitioning
  • Sort-merge: pre-sorted input-এর রাজত্ব, duplicate handling সহ
  • PostgreSQL-এ enable_* switches আর work_mem দিয়ে প্রতিটাকে জোর করে চালিয়ে timing মাপা

আজ EXPLAIN পড়া শিখলেন; পরের লেসনে সেই দক্ষতাই join algorithm-এর microscope হবে।

আরও পড়ুন

  • PostgreSQL Documentation — Performance Tips: Using EXPLAIN — PostgreSQL Global Development Group · EXPLAIN output-এর প্রতিটা field-এর official ব্যাখ্যা — cost, rows, loops, buffers
  • PostgreSQL Documentation — Overview of PostgreSQL Internals: The Query Processor Stage — PostgreSQL Global Development Group · Parse → analyze → rewrite → plan → execute pipeline-এর সংক্ষিপ্ত প্রামাণ্য map
  • Architecture of a Database System, Section 4 — Query Processing — J. M. Hellerstein, M. Stonebraker, J. Hamilton · Iterator model আর plan representation-এর classic treatment — এই লেসনের মেরুদণ্ড