Database Design: Table, Relation, Index
ভালো schema কীভাবে বানায়, EXPLAIN দিয়ে query বোঝা
Code বদলানো সহজ, database এর গঠন বদলানো কঠিন। একবার লাখ লাখ row জমে গেলে একটা column এর type বদলাতে বা একটা table কে দুই ভাগ করতে ঘণ্টার পর ঘণ্টা লাগে, আর ভুল হলে data হারায়। তাই শুরুতেই ভালো schema design করা backend developer এর সবচেয়ে মূল্যবান দক্ষতার একটা। এই অধ্যায়ে দেখবো table আর সম্পর্ক কীভাবে সাজাতে হয়, normalization কী, আর index কীভাবে query দ্রুত করে।
#ধাপ ১: জিনিসগুলো খুঁজে বের করো
Requirement পড়ে noun (বিশেষ্য) গুলো দাগাও। একটা food delivery app এর জন্য:
"Customer কোনো restaurant এর menu থেকে কয়েকটা item বেছে order দেয়। Order একটা address এ যায়, একজন rider নিয়ে যায়। Customer payment করে আর পরে review দিতে পারে।"
প্রতিটা noun সম্ভাব্য একটা table: customers, restaurants, menu_items, orders, order_items, addresses, riders, payments, reviews।
#ধাপ ২: সম্পর্ক ঠিক করো
| সম্পর্ক | মানে | উদাহরণ | কীভাবে রাখবে |
|---|---|---|---|
| One-to-one | একটার সাথে একটা | User ↔ Profile | Foreign key + UNIQUE |
| One-to-many | একটার অনেকগুলো | Restaurant → অনেক menu item | "many" দিকে foreign key |
| Many-to-many | অনেকের সাথে অনেক | Order ↔ Menu item | মাঝে আলাদা table |
order_items table। এখানে qty আর সেই সময়ের দামও থাকে।#order_items এ দাম কেন আবার রাখি?
menu_items এ তো দাম আছেই। তবু order_items.unit_price রাখি কারণ দাম বদলায়। আজ burger ২৫০ টাকা, আগামী মাসে ২৮০। পুরনো order এর হিসাব যেন বদলে না যায়, তাই order এর সময়ের দাম আলাদা রেখে দাও। একই কারণে delivery address এর একটা copy order এ রাখা হয়: user পরে ঠিকানা বদলালেও পুরনো order এর ঠিকানা ঠিক থাকা উচিত।
এটা একটা গুরুত্বপূর্ণ নীতি: যে data একটা মুহূর্তের রেকর্ড (order, invoice, transaction), সেটা তখনকার মতো করে রাখো।
#Normalization: একই তথ্য একবার
খারাপ design:
| order_id | customer_name | customer_phone | restaurant_name | restaurant_area | items |
|---|---|---|---|---|---|
| 1 | Rahim | 01711... | Kacchi House | Dhanmondi | Kacchi x2, Borhani x1 |
| 2 | Rahim | 01711... | Burger Lab | Gulshan | Burger x1 |
সমস্যা:
- Rahim phone বদলালে প্রতিটা order এ বদলাতে হবে। একটায় ভুলে গেলে data অসঙ্গত।
itemsএকটা লেখা, এখান থেকে "এই মাসে কয়টা Kacchi বিক্রি হলো" বের করা প্রায় অসম্ভব।- নতুন restaurant যোগ করা যায় না যতক্ষণ না তার কোনো order হয়।
Normalization মানে data কে এমনভাবে ভাগ করা যাতে প্রতিটা তথ্য এক জায়গায় থাকে। সহজ নিয়ম তিনটা (1NF, 2NF, 3NF এর সারাংশ):
- প্রতিটা ঘরে একটাই value। "Kacchi x2, Borhani x1" না, আলাদা row।
- প্রতিটা table একটা জিনিস নিয়ে। Customer এর তথ্য customer table এ, order table এ শুধু
customer_id। - একই তথ্য কয়েক জায়গায় না (উপরের "মুহূর্তের রেকর্ড" ব্যতিক্রম ছাড়া)।
#Denormalization: জেনেবুঝে নিয়ম ভাঙা
পুরোপুরি normalized database এ পড়ার সময় অনেক JOIN লাগে, যা বড় data তে ধীর। তাই কখনো কখনো ইচ্ছা করে কিছু তথ্য দ্বিতীয়বার রাখা হয়:
restaurants.rating_avgআরrating_count: প্রতিবার সব review গুনে গড় না করে আগে থেকে হিসাব করে রাখাposts.like_count: প্রতিবারCOUNT(*)না করে
দাম হলো: দুই জায়গা মিলিয়ে রাখার দায়িত্ব এখন তোমার (review যোগ হলে rating আপডেট করা)। নিয়ম: প্রথমে normalized design করো, মেপে দেখো কোথায় ধীর, তারপর সেখানে denormalize করো।
#ধাপ ৩: Column এর type ঠিকভাবে বাছাই
| Data | Type | কেন |
|---|---|---|
| Id | BIGINT GENERATED ALWAYS AS IDENTITY বা UUID |
INT ২১০ কোটিতে শেষ হয়ে যায় |
| টাকা | BIGINT (পয়সায়) বা NUMERIC(12,2) |
কখনো FLOAT না |
| সময় | TIMESTAMPTZ |
Timezone সহ |
| শুধু তারিখ | DATE |
জন্মদিন, ছুটির দিন |
| Status | TEXT + CHECK, বা enum |
নির্দিষ্ট কয়েকটা value |
| Phone | TEXT |
সংখ্যা না, শুরুর 0 হারাবে না, হিসাব হয় না |
| ঐচ্ছিক, অগঠিত data | JSONB |
যেমন payment gateway এর পুরো response |
#Id: সংখ্যা নাকি UUID?
- ক্রমিক সংখ্যা (১, ২, ৩...): ছোট, দ্রুত, পড়তে সহজ। কিন্তু URL এ
/orders/1042দেখে বোঝা যায় মোট কত order, আর পাশের সংখ্যা দিয়ে অন্যের order আন্দাজ করা যায় (permission check না থাকলে বিপদ)। - UUID (
7f9c...): অনুমান করা যায় না, client নিজেই আগে থেকে বানাতে পারে (offline-first app এ কাজের)। কিন্তু বড়, আর এলোমেলো UUID index কে কিছুটা ধীর করে। UUIDv7 সময়ের ক্রমে বাড়ে, তাই এই সমস্যা অনেক কম।
প্রচলিত সমাধান: ভেতরে সংখ্যা, বাইরে (API তে) UUID বা অন্য public id।
#Soft delete
User "delete" চাপলে সত্যিই মুছে ফেলবে, নাকি deleted_at column এ সময় লিখে লুকিয়ে রাখবে?
- Soft delete (
deleted_at TIMESTAMPTZ NULL): ভুল করে মুছলে ফেরানো যায়, পুরনো রিপোর্ট ঠিক থাকে। কিন্তু প্রতিটা query তেWHERE deleted_at IS NULLমনে রাখতে হয়। - আইনগত কারণে (user তার data মুছতে চাইলে) সত্যিকারের মুছে ফেলার ব্যবস্থাও রাখতে হয়।
#প্রতিটা table এ যা রাখা ভালো
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()কোনো bug খুঁজতে গেলে "এটা কখন তৈরি বা বদলেছিল" প্রায় সবসময় লাগে।
#Index: query দ্রুত করার চাবি
Index ছাড়া "phone = 01711111111 এর customer খোঁজো" মানে database কে প্রতিটা row একটা একটা দেখতে হবে (sequential scan)। ১ কোটি row তে সেকেন্ড লেগে যায়। Index হলো বইয়ের শেষের নির্ঘণ্ট (index): শব্দ খুঁজে সরাসরি পৃষ্ঠা নম্বর।
CREATE INDEX idx_orders_customer ON orders (customer_id);
CREATE INDEX idx_orders_status_created ON orders (status, created_at DESC);
CREATE UNIQUE INDEX idx_customers_phone ON customers (phone);#কোথায় index দেবে
- Foreign key column (
orders.customer_id): JOIN আর "এই customer এর order" query তে লাগে। PostgreSQL এটা নিজে বানায় না! WHEREএ বারবার আসা columnORDER BYএর column, বিশেষ করেLIMITসহ (সর্বশেষ ২০টা order)- Unique হতে হবে এমন column (phone, email)
#Composite index এর ক্রম
(status, created_at) index কাজে লাগে:
WHERE status = 'pending'✓WHERE status = 'pending' ORDER BY created_at DESC✓WHERE created_at > ...(শুধু) ✗ বেশিরভাগ ক্ষেত্রে কাজে লাগে না
ফোনবুকের মতো: আগে পদবি, তারপর নাম দিয়ে সাজানো। পদবি জানলে দ্রুত পাবে, শুধু নাম জানলে পুরো বই ঘাঁটতে হবে। বাম থেকে ডানে ব্যবহার হয়।
#Index এর দাম
- প্রতিটা
INSERT/UPDATEএ index ও আপডেট হয়, তাই লেখা ধীর হয় - Disk এর জায়গা লাগে
- তাই "সব column এ index" ভুল। যে query আসলে চলে, তার জন্য index।
Index ভেতরে কীভাবে কাজ করে (B-Tree) সেটা System Design এর Index অধ্যায়ে।
#EXPLAIN: database কী ভাবছে দেখো
EXPLAIN ANALYZE
SELECT * FROM orders WHERE customer_id = 42 ORDER BY created_at DESC LIMIT 20;Index এর আগে:
Limit (actual time=812.4..812.5 rows=20)
-> Sort (actual time=812.4..812.4 rows=20)
-> Seq Scan on orders (actual time=0.03..790.1 rows=1840 loops=1)
Filter: (customer_id = 42)
Rows Removed by Filter: 4998160
Execution Time: 812.6 msSeq Scan আর Rows Removed by Filter: 4998160 মানে ৫০ লাখ row পড়ে বাদ দিয়েছে। Index দাও:
CREATE INDEX idx_orders_customer_created ON orders (customer_id, created_at DESC);Limit (actual time=0.05..0.09 rows=20)
-> Index Scan using idx_orders_customer_created on orders (actual time=0.04..0.08 rows=20)
Index Cond: (customer_id = 42)
Execution Time: 0.12 ms৮১২ ms থেকে ০.১২ ms। প্রায় ৭০০০ গুণ দ্রুত, এক লাইনে।
#পুরো schema: food delivery
CREATE TABLE customers (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL,
phone TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE restaurants (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL,
area TEXT NOT NULL,
rating_avg NUMERIC(3,2) NOT NULL DEFAULT 0, -- denormalized
rating_count INT NOT NULL DEFAULT 0,
is_open BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE INDEX idx_restaurants_area ON restaurants (area) WHERE is_open; -- partial index
CREATE TABLE menu_items (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
restaurant_id BIGINT NOT NULL REFERENCES restaurants(id),
name TEXT NOT NULL,
price BIGINT NOT NULL CHECK (price > 0),
is_available BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE INDEX idx_menu_restaurant ON menu_items (restaurant_id);
CREATE TABLE orders (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
public_id UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(),
customer_id BIGINT NOT NULL REFERENCES customers(id),
restaurant_id BIGINT NOT NULL REFERENCES restaurants(id),
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','confirmed','preparing','on_the_way','delivered','cancelled')),
delivery_address TEXT NOT NULL, -- order এর সময়ের copy
subtotal BIGINT NOT NULL,
delivery_fee BIGINT NOT NULL,
total BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_orders_customer ON orders (customer_id, created_at DESC);
CREATE INDEX idx_orders_restaurant_status ON orders (restaurant_id, status);
CREATE TABLE order_items (
order_id BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
menu_item_id BIGINT NOT NULL REFERENCES menu_items(id),
qty INT NOT NULL CHECK (qty > 0),
unit_price BIGINT NOT NULL, -- order এর সময়ের দাম
PRIMARY KEY (order_id, menu_item_id)
);#অনুশীলন
#নিজেকে যাচাই করো
Order এর মধ্যে item এর দাম আবার কেন রাখা হয়, menu তে তো দাম আছেই?
Menu এর দাম পরে বদলাতে পারে। Order হলো সেই মুহূর্তের রেকর্ড, তাই তখনকার দাম আলাদা রেখে দিতে হয়, না হলে পুরনো order এর হিসাব বদলে যাবে।
Many-to-many সম্পর্ক কীভাবে রাখা হয়?
মাঝে একটা আলাদা table দিয়ে (যেমন order_items), যেখানে দুই দিকের foreign key থাকে, আর সম্পর্ক নিয়ে বাড়তি তথ্য (qty, unit_price) ও রাখা যায়।
`(status, created_at)` index কি `WHERE created_at > X` query তে সাহায্য করবে?
সাধারণত না। Composite index বাম থেকে ডানে ব্যবহার হয়। প্রথম column (status) এর শর্ত না থাকলে index টা ভালোভাবে ব্যবহার করা যায় না।
প্রতিটা column এ index দেওয়া কেন ভালো না?
প্রতিটা insert/update এ সব index আপডেট করতে হয়, ফলে লেখা ধীর হয়, আর disk এর জায়গা লাগে। শুধু যে query গুলো আসলে চলে আর ধীর, তাদের জন্য index দেওয়া উচিত।
EXPLAIN এ `Seq Scan` আর বিশাল `Rows Removed by Filter` দেখলে কী বুঝবে?
Database পুরো table পড়ে বেশিরভাগ row বাদ দিচ্ছে, মানে উপযুক্ত index নেই। WHERE (আর ORDER BY) এর column গুলোর উপর index দিলে সম্ভবত অনেক দ্রুত হবে।
চিহ্ন দিলে sidebar আর রোডম্যাপে এই অধ্যায়ের station ভরে যাবে।