এই পর্বে যা শিখবেন
- Normalization-এর পড়ার খরচ — একটি বহু-JOIN কোয়েরির খরচ বড় মাপে কীভাবে বাড়ে
- কখন denormalization যুক্তিযুক্ত আর কখন অকালপক্ব; পড়া-লেখার অনুপাত দিয়ে সিদ্ধান্তের হিসাব
- ছয়টি কৌশল — লাইভ SQL-এ orders.total, order_count, summary টেবিল আর JSON document
- কপি মিলিয়ে রাখার পাঁচ পথ, dual write-এর ব্যর্থতা, drift ধরা আর শোধরানো
- ইতিহাস বনাম redundancy, star schema, আর একটি সিদ্ধান্তের চেকলিস্ট
১ · Normalization-এর দাম — পড়তে গেলে অনেক JOIN
পর্ব ৩৬ আর ৩৭-এ আপনি টেবিল ভেঙেছেন 3NF আর BCNF পর্যন্ত। লাভটি স্পষ্ট: প্রতিটি তথ্য এক জায়গায় থাকে, তাই একটি গ্রাহকের ফোন নম্বর বদলাতে একটিই সারি বদলাতে হয়, আর update anomaly — এক কপি বদলে অন্য কপি ভুলে যাওয়া — ঘটতেই পারে না। Normalization আসলে লেখার সঠিকতার জন্য বানানো। কিন্তু পড়ার সময় উল্টো দাম দিতে হয়: যে তথ্য আলাদা টেবিলে ভাঙা হয়েছে, প্রশ্নের উত্তরে তাকে আবার JOIN দিয়ে জোড়া লাগাতে হয়।
পাড়ার মুদি দোকানের বাকির খাতা ভাবুন। প্রতিটি গ্রাহকের একটি পাতা, আর প্রতিটি কেনাকাটা সেই পাতায় একটি লাইন। মাসের শেষে কেউ জিজ্ঞেস করলেন, “আমার মোট বাকি কত?” দোকানি পুরো পাতা যোগ করেন। এটিই normalized পথ — উত্তর সবসময় সঠিক, কারণ লাইনগুলোই একমাত্র সত্য। অনেক দোকানি পাতার ওপরে পেনসিলে একটি “মোট বাকি” লিখে রাখেন, প্রতিটি লাইনের সাথে বদলে দেন — উত্তর এক নজরে। কিন্তু যেদিন ব্যস্ততায় সংখ্যাটি বদলাতে ভুলে গেলেন, সেদিন থেকে পাতাটি দুটি আলাদা কথা বলে। ওপরের ওই সংখ্যাটিই denormalization.
লাইভ SQL চলে দোকানের ডেটাবেসে (db-shop): customers (customer_id, name, city, phone, joined_on — ৮ জন), categories (category_id, name — ৪টি শ্রেণি), products (product_id, name, category_id, price, stock — ১০টি পণ্য), orders (order_id, customer_id, order_date, status — ১০টি অর্ডার; status হলো pending, shipped, delivered বা cancelled) আর order_items (order_id, product_id, quantity, unit_price — ১৬টি লাইন; unit_price হলো বিক্রির মুহূর্তের দাম)। নকশাটি normalized: অর্ডারের মোট টাকা কোথাও লেখা নেই, লাইন থেকে হিসাব করতে হয়। প্রতিবার চালালে ডেটাবেস নতুন করে তৈরি হয়, foreign key চালু থাকে। নমুনা ডেটা দেখতে এডিটরের নিচের বাক্সটি খুলুন।
একটি ছোট dashboard-এর প্রশ্ন: “শহর আর পণ্যের শ্রেণি অনুযায়ী মোট বিক্রি কত, বাতিল অর্ডার বাদে?” শহর থাকে customers-এ, শ্রেণির নাম categories-এ, টাকা order_items-এ — মাঝে orders আর products. পাঁচটি টেবিল, চারটি JOIN:
SELECT c.city, cat.name AS category,
COUNT(DISTINCT o.order_id) AS orders,
SUM(i.quantity * i.unit_price) AS revenue
FROM customers c
JOIN orders o ON o.customer_id = c.customer_id
JOIN order_items i ON i.order_id = o.order_id
JOIN products p ON p.product_id = i.product_id
JOIN categories cat ON cat.category_id = p.category_id
WHERE o.status <> 'cancelled'
GROUP BY c.city, cat.name
ORDER BY revenue DESC;১০টি সারি আসে; প্রথম তিনটি Dhaka | Electronics | 2 | 35400, Rajshahi | Clothing | 1 | 4400 আর Sylhet | Electronics | 1 | 3500; একটি সারির শহর NULL (NULL | Grocery | 1 | 1040 — Imran-এর শহর জানা নেই)। এই ছোট ডেটায় কোয়েরিটি চোখের পলকে চলে। কিন্তু ভুলের সুযোগ দুটি: orders গুনতে COUNT(DISTINCT …) লাগে, কারণ order_items-এর সাথে JOIN-এর পর একটি অর্ডার যতগুলো লাইন ততবার আসে — পর্ব ১৯-এর fan-out. আর বাতিলের শর্তটি ভুলে গেলে Sabbir-এর বাতিল Saree একটি নতুন সারি হয়ে (Dhaka | Clothing) ঢুকে যেত। বহু-JOIN কোয়েরি শুধু ধীর নয়, ভুল লেখাও সহজ।
এবার একই প্রশ্ন একটি বড় দোকানে। নিচের সংখ্যাগুলো কোনো আসল প্রতিষ্ঠানের নয়, হিসাবের জন্য ধরে নেওয়া। সাথে আরেকটি শর্ত: শুধু পরিশোধিত অর্ডার, তাই একটি payments টেবিলও লাগে — ছয়টি টেবিল, পাঁচটি JOIN:
| JOIN হয়েছে | |
| সারি চলছে | |
| index probe (মোট) |
- শুরু orders থেকে। optimizer সবচেয়ে বাছাই-করা শর্ত দিয়ে শুরু করে। order_date-এ index থাকলে একটি range scan-এ এই মাসের ৫ লাখ সারি — পুরো ১ কোটি নয়।
- প্রথম JOIN. প্রতিটি অর্ডারের জন্য payments-এর order_id index-এ একবার খোঁজ — একে বলে index probe. ৫ লাখ সারি, তাই ৫ লাখ probe.
- দ্বিতীয় JOIN. শহরের জন্য customers-এ আরও ৫ লাখ probe. সারি একই, কাজ বেড়েছে।
- Fan-out. order_items-এ গেলে প্রতিটি অর্ডার গড়ে ৩টি লাইন হয়ে যায় — এখন ১৫ লাখ সারি। এর পরের প্রতিটি JOIN এই বড় সংখ্যার ওপর চলে।
- শেষ দুটি JOIN. পণ্য থেকে শ্রেণি, শ্রেণি থেকে নাম — ১৫ লাখ + ১৫ লাখ probe. ছোট টেবিল দুটি একবার পড়ে hash join করলে (পর্ব ৪২) probe কমে, কিন্তু ১৫ লাখ order_items সারি পড়া কোনো পথেই এড়ানো যায় না।
- মোট খরচ। ৪৫ লাখ probe, প্রতিটি B+ tree-এর উচ্চতা h-সংখ্যক page ছোঁয় (পর্ব ৪০; বড় টেবিলে h সাধারণত ৩–৪) — প্রায় ১.৩৫ থেকে ১.৮ কোটি page-পড়া, অথচ ফল মাত্র কয়েক হাজার সারি।
- Summary টেবিলের পথ। ফল আগে থেকে জমা থাকলে dashboard পড়ে সর্বোচ্চ ২,৫৬০টি সারি, দিনে ১,০০০ বার খুললেও সামান্য কাজ। দাম সরে যায় লেখার দিকে: প্রতিটি নতুন অর্ডার-লাইনে summary-র একটি সারি হালনাগাদ। এই বিনিময়ই denormalization-এর মূল কথা।
ধাপ ৫-এর কথাটি একটি সাধারণ সত্য, প্রমাণসহ বলা যায়: base table থেকে একটি SUM-এর নির্ভুল উত্তর পেতে যোগফলে অবদান রাখা প্রতিটি সারি অন্তত একবার পড়তেই হবে। কারণ, কোনো একটি সারি না পড়লে সেই সারির মান যা-ই হোক, অ্যালগরিদমের উত্তর একই থাকত — অথচ সঠিক উত্তর বদলে যেত। তাই N সারির যোগফলের খরচ অন্তত N-এর সমানুপাতিক, কোনো index বা JOIN অ্যালগরিদম এর নিচে নামাতে পারে না। নিচে নামানোর একমাত্র পথ হলো হিসাবটি আগেই করে রাখা — অর্থাৎ redundancy.
২ · Denormalization কী — আর কী নয়
সংজ্ঞাটি সাবধানে বলা দরকার। Denormalization হলো একটি normalized নকশায়, নির্দিষ্ট পড়ার কাজ দ্রুত করতে, ইচ্ছে করে নিয়ন্ত্রিত redundancy যোগ করা — সাথে সেই কপিগুলো মিলিয়ে রাখার স্পষ্ট ব্যবস্থা। তিনটি শর্ত আছে, আর তিনটিই জরুরি:
১. শুরু normalized থেকে
আগে নকশাটি 3NF বা BCNF-এ, প্রতিটি তথ্যের একটি source of truth. কপিটি সেই সত্য থেকে বানানো, তার বদলি নয়।
২. সচেতন আর লিখিত
কোন কলাম কপি, কোথা থেকে আসে, কোন কোয়েরির জন্য — দলের ডকুমেন্টেশনে লেখা। না জেনে রাখা কপি denormalization নয়, অযত্ন।
৩. মিলিয়ে রাখার ব্যবস্থা
trigger, একই transaction, batch বা refresh — আর ভুল ধরার reconciliation কোয়েরি। ব্যবস্থা ছাড়া কপি মানেই একদিন inconsistency.
এ থেকে একটি জরুরি পার্থক্য: unnormalized বনাম denormalized. পর্ব ২-এর ফাইল সিস্টেমের যুগে প্রতিটি বিভাগ নিজের খাতায় গ্রাহকের ঠিকানা লিখত — unnormalized, কেউ ভাবেননি কোনটি আসল। দুটি টেবিল দেখতে একই হতে পারে, কিন্তু denormalized নকশার পেছনে একটি normalized মডেল আর রক্ষণাবেক্ষণের পরিকল্পনা থাকে। denormalization তাই মূলত একটি physical সিদ্ধান্ত; যৌক্তিক মডেল normalized-ই থাকে।
আসলে আপনি প্রতিদিন redundancy ব্যবহার করেন। একটি index হলো টেবিলের কিছু কলামের একটি সাজানো কপি; materialized view একটি কোয়েরির জমানো কপি। পার্থক্য হলো, index DBMS নিজে, প্রতিটি লেখার একই transaction-এ, নির্ভুলভাবে হালনাগাদ রাখে — তাই একে কেউ “নিয়ম ভাঙা” বলে না। Redundancy-র ঝুঁকি নির্ভর করে কে কপিটি মিলিয়ে রাখার দায়িত্ব নেয় তার ওপর: DBMS, আপনার trigger, না অ্যাপের কোড। এই সূত্রটি পুরো পর্ব জুড়ে কাজে লাগবে।
ইতিহাস এক নজরে। E. F. Codd ১৯৭০ সালে relational model প্রস্তাব করেন, আর ১৯৭১–৭২ সালে 1NF থেকে 3NF সংজ্ঞায়িত করেন। ১৯৮০-এর দশকে প্রথম বাণিজ্যিক relational DBMS-গুলো ধীর বলে সমালোচিত হয়, আর ডিজাইনারেরা গতির জন্য টেবিল জুড়ে দিতে শুরু করেন। ১৯৯০-এর দশকে Bill Inmon-এর Building the Data Warehouse (১৯৯২) আর Ralph Kimball-এর The Data Warehouse Toolkit (১৯৯৬) বিশ্লেষণের জন্য ইচ্ছাকৃত denormalized নকশা — বিশেষত star schema — জনপ্রিয় করে। আর Donald Knuth-এর ১৯৭৪ সালের প্রবন্ধের বিখ্যাত বাক্য “premature optimization is the root of all evil” এই আলোচনায় বারবার ফিরে আসে।
৩ · কখন যুক্তিযুক্ত, কখন অকালপক্ব
Denormalization একটি ওষুধ, খাবার নয়। প্রথম নিয়ম: আগে মাপুন। কোন কোয়েরি ধীর, কতটা ধীর, দিনে কতবার চলে — এগুলো slow query log, EXPLAIN আর সময় মেপে জানুন। চারটি পরিস্থিতিতে নিয়ম ভাঙা সাধারণত যুক্তিযুক্ত:
মাপা read bottleneck
একটি পাতা বা API বারবার একই ভারী হিসাব করে, index আর কোয়েরি শোধরানোর পরও ধীর — আর পড়া লেখার চেয়ে অনেক বেশি।
রিপোর্ট আর বিশ্লেষণ
মাসিক বিক্রি, শাখাভিত্তিক তুলনা — কয়েক ঘণ্টা পুরোনো ফলও চলে। এখানে summary টেবিল বা warehouse প্রায় নিয়ম।
দামি aggregate-এর cache
পণ্যের গড় রেটিং, গ্রাহকের মোট কেনাকাটা, পোস্টের লাইক — প্রতিবার লাখো সারি না গুনে একটি সংখ্যা রাখা।
Distributed সিস্টেম
ডেটা বহু সার্ভারে ভাগ (sharding, পর্ব ৫৪) হলে দুই shard-এর মধ্যে JOIN মানে নেটওয়ার্কের যাতায়াত। তাই দরকারি কলাম কপি করে একই shard-এ রাখা হয়।
উল্টো দিকে, সিদ্ধান্তটি অকালপক্ব যখন: কোনো মাপ নেই, শুধু “JOIN ধীর হয়” শোনা কথা; ডেটা কয়েক হাজার সারির; JOIN-এর কলামে index-ই নেই; বা কপি হালনাগাদের পরিকল্পনা নেই। সঠিক index থাকলে কয়েক লাখ সারির টেবিল থেকে অল্প কিছু সারি খোঁজার চার-পাঁচ-JOIN কোয়েরি সাধারণত মিলিসেকেন্ডের ঘরেই থাকে। নিয়ম ভাঙার আগে এই সিঁড়িটি পার হন: index (পর্ব ৪০–৪১) → কোয়েরি নতুন করে লেখা → সাধারণ view বা অ্যাপের cache → materialized view → তারপর হাতে রাখা কপি।
সিদ্ধান্তের একটি সহজ হিসাব
অনুভূতির বদলে একটি সরল সমীকরণ ভাবুন। ধরুন একটি হিসাব সেকেন্ডে r বার পড়া হয়, প্রতিবার খরচ C; জমানো মান পড়তে খরচ c (অনেক কম)। আর যে লেখাগুলো এই মান বদলায়, সেগুলো সেকেন্ডে w বার আসে, প্রতিবার কপি হালনাগাদের বাড়তি খরচ m. তাহলে লাভ হয় যদি
উপরের dashboard-এ C বিশাল (৪৫ লাখ probe), c প্রায় শূন্য, আর m একটি সারির UPDATE — তাই সামান্য r-এও লাভ। কিন্তু যে টেবিলে প্রতি সেকেন্ডে হাজারো লেখা আর দিনে দুবার পড়া, সেখানে একই কৌশল ক্ষতি। শেষ পদটি ভুলবেন না: প্রতিটি কপি নতুন কোড-পথ, নতুন test আর নতুন বাগ।
৪ · কৌশল ১ — derived মান রাখা: orders.total আর order_count
সবচেয়ে প্রচলিত কৌশল: যে মান অন্য সারি থেকে হিসাব করা যায় — পর্ব ৭-এর derived attribute — সেটি একটি কলামে রেখে দেওয়া। অর্ডারের মোট টাকা orders.total-এ, গ্রাহকের অর্ডারের সংখ্যা customers.order_count-এ। পর্ব ২৯-এ দেখেছেন, এ কাজে trigger সবচেয়ে নির্ভরযোগ্য, কারণ মূল লেখা আর কপির হালনাগাদ একই transaction-এ হয়। এই এডিটরে total কলাম যোগ আর পুরোনো সারিতে ভরা (backfill) আগে থেকেই করা আছে; এখানে তিনটি ঘটনার জন্য তিনটি trigger:
CREATE TRIGGER oi_ins AFTER INSERT ON order_items BEGIN
UPDATE orders SET total = total + NEW.quantity * NEW.unit_price
WHERE order_id = NEW.order_id;
END;
CREATE TRIGGER oi_del AFTER DELETE ON order_items BEGIN
UPDATE orders SET total = total - OLD.quantity * OLD.unit_price
WHERE order_id = OLD.order_id;
END;
CREATE TRIGGER oi_upd AFTER UPDATE ON order_items BEGIN
UPDATE orders SET total = total - OLD.quantity * OLD.unit_price
WHERE order_id = OLD.order_id;
UPDATE orders SET total = total + NEW.quantity * NEW.unit_price
WHERE order_id = NEW.order_id;
END;
INSERT INTO order_items VALUES (1006, 104, 2, 650);
UPDATE order_items SET quantity = 3 WHERE order_id = 1001 AND product_id = 102;
DELETE FROM order_items WHERE order_id = 1009 AND product_id = 110;
SELECT order_id, total FROM orders WHERE order_id IN (1001, 1006, 1009);তিনটি “✓ CREATE TRIGGER”, তারপর INSERT, UPDATE আর DELETE-এর প্রতিটিতে “১টি সারি বদলেছে”। ফল: 1001 | 18600 (17400 − 2 × 1200 + 3 × 1200), 1006 | 4800 (3500 + 2 × 650) আর 1009 | 14500 (18000 − 3500). UPDATE trigger-এ দুটি স্টেটমেন্ট কেন? পুরোনো লাইনের অবদান OLD-এর অর্ডার থেকে বাদ, নতুনটি NEW-এর অর্ডারে যোগ — তাহলে কেউ একটি লাইন অন্য অর্ডারে সরালেও (order_id বদল) দুটি অর্ডারই ঠিক থাকে। একটি বাস্তব সতর্কতা: চালু ডেটাবেসে আগে trigger বসান, তারপর backfill করুন (বা দুটি একই transaction-এ) — নইলে মাঝের সময়ে আসা লেখা হারিয়ে যায়।
এই trigger-গুলো increment পদ্ধতিতে কাজ করে: পুরো যোগফল আবার না গুনে শুধু পার্থক্যটুকু যোগ-বিয়োগ। একই সারির ওপর পরপর কয়েকটি ঘটনায় মানগুলো কীভাবে চলে, ধাপে ধাপে দেখুন:
| 1001 | |
| 1006 | |
| trigger |
- শুরু। backfill-এর পর দুটি total লাইনের যোগফলের সমান।
- INSERT. 1001-এ একটি SQL Book যোগ। oi_ins শুধু নতুন লাইনের মূল্য, 550, যোগ করে — বাকি লাইন ছুঁয়েও দেখে না। total 17950.
- পরিমাণ বদল। Headphone 2 থেকে 3. oi_upd আগে পুরোনো অবদান (2400) বাদ দেয়, তারপর নতুনটি (3600) যোগ করে — দুটিই একই অর্ডারে। total 19150.
- অন্য অর্ডারে সরানো। SQL Book-এর লাইনটির order_id 1001 থেকে 1006. এবার OLD আর NEW আলাদা অর্ডার: 1001 থেকে 550 বাদ (18600), 1006-এ 550 যোগ (4050). একটি UPDATE trigger শুধু NEW দেখলে 1001 ভুল থেকে যেত।
- DELETE. 1006 থেকে Smart Watch মোছা; oi_del 3500 বাদ দেয়। total 550.
- যাচাই। লাইন থেকে নতুন করে যোগ করলে দুটিই মেলে। increment দ্রুত — প্রতি লেখায় একটি সারির UPDATE — কিন্তু একবার ভুল হলে নিজে শোধরায় না (অংশ ৬)।
বিকল্প হলো trigger-এ প্রতিবার পুরো যোগফল আবার গোনা: SET total = (SELECT SUM(…) … WHERE order_id = NEW.order_id). অর্ডারে লাইন কম, তাই খরচ সামান্য, আর আগের কোনো ভুল পরের লেখাতেই শুধরে যায়। হাজারো সারির বড় দলে অবশ্য increment-ই বাস্তবসম্মত।
এবার দ্বিতীয় derived মান: গ্রাহকের অর্ডারের সংখ্যা। সংজ্ঞাটি আগে ঠিক করুন — এখানে বাতিলসহ সব অর্ডার গোনা হচ্ছে। “বাতিল বাদে” চাইলে status বদলের trigger-ও লাগত। সংজ্ঞা অস্পষ্ট থাকলে একই নামের সংখ্যা দুই রিপোর্টে দুই রকম আসে।
ALTER TABLE customers ADD COLUMN order_count INTEGER NOT NULL DEFAULT 0;
UPDATE customers SET order_count =
(SELECT COUNT(*) FROM orders o WHERE o.customer_id = customers.customer_id);
CREATE TRIGGER ord_ins AFTER INSERT ON orders BEGIN
UPDATE customers SET order_count = order_count + 1
WHERE customer_id = NEW.customer_id;
END;
CREATE TRIGGER ord_del AFTER DELETE ON orders BEGIN
UPDATE customers SET order_count = order_count - 1
WHERE customer_id = OLD.customer_id;
END;
INSERT INTO orders VALUES (1011, 5, '2025-10-12', 'pending');
INSERT INTO orders VALUES (1012, 1, '2025-10-12', 'pending');
DELETE FROM orders WHERE order_id = 1012;
SELECT customer_id, name, order_count FROM customers WHERE customer_id IN (1, 5);“✓ ALTER TABLE”, backfill-এ “৮টি সারি বদলেছে”, দুটি trigger, তারপর দুটি INSERT আর একটি DELETE-এ “১টি সারি বদলেছে”। ফল: 1 | Rahim Uddin | 3 আর 5 | Tanvir Ahmed | 1. Rahim-এর ৩টি পুরোনো অর্ডার, নতুন 1012 যোগ হয়ে মুছে গেছে — সংখ্যা আবার 3. Tanvir-এর আগে কোনো অর্ডার ছিল না, এখন 1. ফাঁকটি খুঁজে পান? কেউ যদি একটি অর্ডারের customer_id বদলান (ভুল গ্রাহকে লেখা অর্ডার সরানো), কোনো trigger জাগবে না। প্রতিটি derived কলামের জন্য প্রশ্ন করুন: কোন কোন লেখা এই মান বদলাতে পারে? — INSERT, DELETE, আর প্রতিটি প্রাসঙ্গিক কলামের UPDATE.
আগে আর পরে — কোয়েরি কতটা সরল হলো
এখন দুটি কপি একসাথে কাজে লাগান। “সবচেয়ে বেশি কেনাকাটা করা গ্রাহক” প্রশ্নে normalized পথে তিনটি টেবিল, দুটি LEFT JOIN আর COUNT(DISTINCT) লাগে। denormalized পথে order_items ছুঁতেই হয় না। এই এডিটরে orders.total আর customers.order_count দুটিই আগে থেকে ভরা; দ্বিতীয় কোয়েরিটি দুই পথের উত্তর সারি ধরে মিলিয়ে দেখে (পর্ব ২২-এর EXCEPT):
-- পরে: order_items ছোঁয়াই লাগে না
SELECT c.name, c.order_count, SUM(o.total) AS spent
FROM customers c JOIN orders o ON o.customer_id = c.customer_id
GROUP BY c.customer_id ORDER BY spent DESC LIMIT 3;
-- দুই পথের উত্তর কি হুবহু এক? (before = normalized পথ)
WITH before AS (
SELECT c.customer_id, COUNT(DISTINCT o.order_id) AS n,
COALESCE(SUM(i.quantity * i.unit_price), 0) AS spent
FROM customers c LEFT JOIN orders o ON o.customer_id = c.customer_id
LEFT JOIN order_items i ON i.order_id = o.order_id
GROUP BY c.customer_id),
after AS (
SELECT customer_id, order_count AS n,
(SELECT COALESCE(SUM(total), 0) FROM orders o
WHERE o.customer_id = c.customer_id) AS spent
FROM customers c)
SELECT (SELECT COUNT(*) FROM (SELECT * FROM before EXCEPT SELECT * FROM after)) AS only_before,
(SELECT COUNT(*) FROM (SELECT * FROM after EXCEPT SELECT * FROM before)) AS only_after;প্রথম কোয়েরি: Rahim Uddin | 3 | 37180, Nusrat Jahan | 1 | 5050, Karima Begum | 2 | 5000. দ্বিতীয়টি: 0 | 0 — দুই দিকের EXCEPT-ই খালি, আটজনের সংখ্যা আর টাকা দুই পথে হুবহু এক। “before” কোয়েরিটি কতটা সূক্ষ্ম, দেখুন: COUNT(DISTINCT o.order_id)-এর বদলে COUNT(o.order_id) লিখলে Rahim-এর সংখ্যা আসত 6 (তিন অর্ডারে মোট ছয় লাইন), আর LEFT JOIN-এর বদলে JOIN লিখলে অর্ডারহীন Tanvir হারিয়ে যেতেন। denormalization পড়ার কোয়েরি থেকে ভুলের ঝুঁকিও সরায় — ঝুঁকি যায় লেখার দিকে। আর এই EXCEPT-কোয়েরিটিই একটি পূর্ণ reconciliation.
টাকা আর REAL. এখানে নমুনা ডেটার সাথে মিল রাখতে total REAL, আর সব দাম পূর্ণ সংখ্যা বলে যোগ-বিয়োগ নির্ভুল। কিন্তু 0.1-এর মতো ভগ্নাংশ floating-point-এ হুবহু রাখা যায় না; লাখো increment-এর পর ছোট ভুল জমে, আর total <> SUM(…) তুলনা মিথ্যা অমিল দেখাতে পারে। বাস্তবে টাকা রাখুন পয়সায় INTEGER হিসেবে, বা DECIMAL(12,2)-এ (পর্ব ১৩)।
৫ · আরও কৌশল — কপি করা কলাম, summary টেবিল, materialized view আর JSON
কৌশল ২ · Attribute কপি করা
অর্ডারের তালিকায় গ্রাহকের নাম দেখাতে JOIN লাগে, তাই অনেকে orders-এ customer_name রাখেন। কিন্তু কপিটির উদ্দেশ্য কী? যদি এটি চালানে ছাপা নাম হয়, যা আইনত বদলানো যাবে না, তাহলে এটি আসলে ইতিহাস (অংশ ৭)। আর যদি উদ্দেশ্য শুধু JOIN বাঁচানো হয়, তাহলে গ্রাহক নাম বদলালে সব পুরোনো অর্ডারেও বদলাতে হবে — এটিই আসল redundancy, আর এর জন্য customers-এ একটি AFTER UPDATE OF name trigger লাগবে, যা একজন গ্রাহকের হাজারো অর্ডার একসাথে বদলাতে পারে।
কৌশল ৩ · Pre-joined রিপোর্ট টেবিল
প্রতিটি অর্ডার-লাইনের জন্য একটি “চওড়া” সারি — লাইনের তথ্যের পাশে গ্রাহকের শহর, পণ্যের নাম, শ্রেণির নাম, অর্ডারের তারিখ আর status — একটি টেবিলে আগে থেকে JOIN করে রাখা। একে বলে pre-joined টেবিল। বিশ্লেষক JOIN ছাড়াই যেকোনো দিক দিয়ে GROUP BY করেন। সাধারণত রাতে নতুন করে বানানো হয়; দিনের বিক্রি না থাকা রিপোর্টে প্রায়ই চলে।
কৌশল ৪ · Summary টেবিল
আরও এক ধাপ: সারিগুলো শুধু জোড়া নয়, আগে থেকে যোগ করে রাখা — summary (aggregate) টেবিল। অংশ ১-এর sales_city_cat_month এমনই। SQLite-এ materialized view নেই, তাই কাজটি হাতে করা হয়: একটি view-তে হিসাবের সংজ্ঞা, CREATE TABLE AS SELECT দিয়ে জমানো কপি, আর refresh মানে মুছে আবার ভরা:
CREATE VIEW v_cat_sales AS
SELECT cat.name AS category, SUM(i.quantity * i.unit_price) AS revenue
FROM order_items i
JOIN orders o ON o.order_id = i.order_id
JOIN products p ON p.product_id = i.product_id
JOIN categories cat ON cat.category_id = p.category_id
WHERE o.status <> 'cancelled'
GROUP BY cat.name;
CREATE TABLE cat_sales AS SELECT * FROM v_cat_sales; -- জমানো summary
INSERT INTO order_items VALUES (1006, 106, 1, 2200); -- নতুন বিক্রি: Panjabi
SELECT 'stored' AS src, revenue FROM cat_sales WHERE category = 'Clothing'
UNION ALL
SELECT 'live', revenue FROM v_cat_sales WHERE category = 'Clothing';
DELETE FROM cat_sales; -- refresh
INSERT INTO cat_sales SELECT * FROM v_cat_sales;
SELECT * FROM cat_sales ORDER BY revenue DESC;নতুন Panjabi বিক্রির পর তুলনা: stored | 4400 আর live | 6600 — জমানো টেবিলটি stale, এখনো পুরোনো যোগফল বলছে। refresh-এ DELETE আর INSERT দুটিতেই “৪টি সারি বদলেছে”, তারপর ফল: Electronics | 40050, Clothing | 6600, Grocery | 4920, Books | 2950. সংজ্ঞাটি view-তে রাখায় refresh আর যাচাই একই SQL থেকে আসে। আসল ডেটাবেসে DELETE আর INSERT একটি transaction-এ রাখুন, নইলে মাঝের মুহূর্তে কেউ খালি টেবিল দেখবেন।
কৌশল ৫ · Materialized view
একই কাজ DBMS নিজে করলে সেটিই পর্ব ২৪-এর materialized view: সংজ্ঞা আর জমানো ফল একসাথে, refresh একটি কমান্ডে। এটি এই পর্বের সূত্রের একটি সুন্দর উদাহরণ — কপি মিলিয়ে রাখার দায়িত্ব অ্যাপ থেকে DBMS-এ সরে গেল। নিচের কোড তুলনার জন্য, এই পাতায় চালানো যায় না:
CREATE MATERIALIZED VIEW cat_sales AS
SELECT cat.name AS category, SUM(i.quantity * i.unit_price) AS revenue
FROM order_items i JOIN orders o USING (order_id)
JOIN products p USING (product_id)
JOIN categories cat USING (category_id)
WHERE o.status <> 'cancelled'
GROUP BY cat.name;
CREATE UNIQUE INDEX ON cat_sales (category); -- CONCURRENTLY-র জন্য জরুরি
-- রাতে বা প্রতি ঘণ্টায় (cron / pg_cron):
REFRESH MATERIALIZED VIEW CONCURRENTLY cat_sales; -- চলার সময়ও পড়া যায়কৌশল ৬ · JSON document-এ child সারি
শেষ কৌশলটি সবচেয়ে চরম: একটি অর্ডার আর তার সব লাইন একটি JSON document-এ — অর্ডারের পাতা খুলতে একটিই সারি পড়া, কোনো JOIN নেই। এটিই document database-এর মূল ভাবনা, যা পর্ব ৫৬–৫৭-এ বিস্তারিত দেখবেন; এখানে পর্ব ৩০-এর JSON ফাংশন দিয়ে SQLite-এ embedding:
CREATE TABLE order_docs AS
SELECT o.order_id,
json_object('customer', c.name, 'status', o.status,
'items', json_group_array(json_object(
'product', p.name, 'qty', i.quantity, 'price', i.unit_price))) AS doc
FROM orders o
JOIN customers c ON c.customer_id = o.customer_id
JOIN order_items i ON i.order_id = o.order_id
JOIN products p ON p.product_id = i.product_id
GROUP BY o.order_id;
SELECT doc FROM order_docs WHERE order_id = 1009;
SELECT order_id, doc ->> '$.customer' AS customer,
json_array_length(doc, '$.items') AS n_items
FROM order_docs WHERE order_id IN (1001, 1010);CREATE TABLE-এর পর অর্ডার 1009-এর document: {"customer":"Rahim Uddin",. শেষ কোয়েরি: 1001 | Rahim Uddin | 2 আর 1010 | Imran Hossain | 1. গ্রাহক আর পণ্যের নাম এখন প্রতিটি document-এ কপি — Rahim-এর নাম তিনটি অর্ডারে তিনবার। পড়া এক ধাপে, কিন্তু নাম বদলালে প্রতিটি document খুলে বদলাতে হবে। (items-এর ভেতরের ক্রম SQLite নিশ্চিত করে না; দরকার হলে আলাদা করে সাজাতে হয়।)
৬ · কপি মিলিয়ে রাখা — trigger, dual write, batch, refresh আর CDC
প্রতিটি কপির দুটি প্রশ্ন: কীভাবে হালনাগাদ থাকবে, আর ভুল হলে কীভাবে ধরা পড়বে। প্রথমটির পাঁচটি প্রচলিত উত্তর:
- Trigger: একই transaction-এ, কপি সবসময় তাজা; লেখার খরচ বাড়ে, logic লুকানো (পর্ব ২৯)।
- অ্যাপে dual write: অ্যাপ নিজে দুটি জায়গায় লেখে — order_items-এ লাইন, orders-এ total. সহজ মনে হয়, কিন্তু ব্যর্থতার পথ অনেক (নিচের ডায়াগ্রাম)।
- Batch পুনর্গণনা: নির্ধারিত সময়ে পুরো কপি আবার হিসাব; মাঝে কপি পুরোনো, কিন্তু প্রতিটি রান আগের ভুলও মুছে দেয়।
- Materialized view refresh: batch-এরই DBMS-চালিত রূপ।
- CDC আর outbox: ডেটাবেসের transaction log (WAL বা binlog) পড়ে, বা একই transaction-এ একটি outbox টেবিলে “কী বদলেছে” লিখে, অন্য সিস্টেমে (search index, cache, warehouse) পরিবর্তন পাঠানো। কয়েক সেকেন্ড দেরিতে, কিন্তু নির্ভরযোগ্যভাবে — একে বলে eventual consistency.
Dual write-এর সমস্যাটি দেখতে সহজ, ধরতে কঠিন। অ্যাপ autocommit-এ — প্রতিটি স্টেটমেন্ট আলাদা transaction — দুটি লেখা পাঠাচ্ছে। দুটির মাঝে কিছু ঘটলে কী হয়:
| সংরক্ষিত total | |
| লাইনের যোগফল | |
| অমিল |
- শুরু। অর্ডার 1008-এ একটি লাইন, total 550 — দুটি মিলছে।
- লেখা ১ commit. autocommit-এ INSERT সাথে সাথে স্থায়ী। এই মুহূর্তে total পুরোনো, কিন্তু অ্যাপ এখনই দ্বিতীয় লেখা পাঠাবে — তাই অমিলটি “মাঝপথের”।
- Crash. দ্বিতীয় লেখার আগেই প্রক্রিয়া বন্ধ। ডেটাবেসের চোখে কিছুই ভুল হয়নি — একটি সফল INSERT. অমিলটি স্থায়ী, কোনো log-এ চিহ্ন নেই।
- দুটি সত্য। পরে গ্রাহক অভিযোগ করেন: চালানে 550, বাক্সে দুটি বই। এটিই drift.
- Retry-র ফাঁদ। উল্টো ঘটনাও ঘটে: UPDATE সফল, কিন্তু নেটওয়ার্কে উত্তর হারাল। অ্যাপ নিরাপদ ভেবে আবার পাঠাল — total 1850. “total = total + 650” দুবার চালালে ফল বদলায়, অর্থাৎ এটি idempotent নয়।
- সমাধান। দুটি লেখা একই ডেটাবেসে হলে একটি transaction যথেষ্ট — পর্ব ৪৪-এর atomicity: হয় দুটিই, নয় কোনোটিই না। trigger একই কাজ নিজে থেকে করে। কিন্তু কপিটি অন্য সিস্টেমে (Redis cache, search index) হলে একটি transaction দুই জায়গা জুড়তে পারে না; তখন outbox বা CDC.
এবার আসল SQLite-এ drift বানান — কোনো trigger ছাড়া, ঠিক উপরের মতো। এই এডিটরে orders.total যোগ আর backfill করা আছে, কিন্তু কোনো trigger নেই। শেষে একটি reconciliation কোয়েরি সংরক্ষিত total আর লাইন থেকে হিসাব করা মান মিলিয়ে দেখে:
-- অ্যাপের লেখা ১: নতুন লাইন — সফল
INSERT INTO order_items VALUES (1008, 104, 1, 650);
-- লেখা ২ পাঠানোর আগেই অ্যাপ crash করল:
-- UPDATE orders SET total = total + 650 WHERE order_id = 1008;
-- অ্যাডমিন হাতে একটি দাম শোধরালেন, total-এর কথা ভাবেননি
UPDATE order_items SET unit_price = 1100
WHERE order_id = 1007 AND product_id = 102;
-- reconciliation: সংরক্ষিত বনাম হিসাব করা
SELECT o.order_id, o.total AS stored,
COALESCE(SUM(i.quantity * i.unit_price), 0) AS actual
FROM orders o LEFT JOIN order_items i ON i.order_id = o.order_id
GROUP BY o.order_id, o.total
HAVING o.total <> COALESCE(SUM(i.quantity * i.unit_price), 0);INSERT আর UPDATE-এ “১টি সারি বদলেছে” — কোনো ত্রুটি নেই, কোনো সতর্কতাও নেই। reconciliation দুটি অমিল ধরে: 1007 | 3250 | 3200 (অ্যাডমিনের হাতের বদল, Headphone 1150 → 1100) আর 1008 | 550 | 1200 (হারিয়ে যাওয়া দ্বিতীয় লেখা)। LEFT JOIN আর COALESCE: যে অর্ডারের সব লাইন মুছে গেছে অথচ total শূন্য নয়, INNER JOIN হলে সেটি ফলেই আসত না — ঠিক সেই অমিলটি হারিয়ে যেত। HAVING: শুধু অমিলের সারি দেখায়, তাই সুস্থ ডেটাবেসে ফল খালি — একটি স্বয়ংক্রিয় কাজ “সারি এলেই সতর্কবার্তা” নিয়মে চালাতে পারে।
ধরা পড়ার পর শোধরানো। batch পুনর্গণনা সব সারি আবার লিখতে পারে, কিন্তু শুধু অমিলের সারি লিখলে লেখার চাপ কমে আর কয়টি শোধরানো হলো তাও জানা যায়। এই এডিটরে উপরের দুটি অমিল আগে থেকেই তৈরি:
-- রাতের batch: যেখানে অমিল, শুধু সেখানে আবার হিসাব
UPDATE orders
SET total = (SELECT COALESCE(SUM(quantity * unit_price), 0)
FROM order_items i WHERE i.order_id = orders.order_id)
WHERE total <> (SELECT COALESCE(SUM(quantity * unit_price), 0)
FROM order_items i WHERE i.order_id = orders.order_id);
-- আবার যাচাই: কয়টি অর্ডারে এখনো অমিল?
SELECT COUNT(*) AS drifted
FROM orders o
WHERE o.total <> (SELECT COALESCE(SUM(quantity * unit_price), 0)
FROM order_items i WHERE i.order_id = o.order_id);
SELECT order_id, total FROM orders WHERE order_id IN (1007, 1008);UPDATE-এ “২টি সারি বদলেছে” — ঠিক দুটি অমিল, বাকি আটটি অর্ডার ছোঁয়া হয়নি। তারপর drifted | 0, আর 1007 | 3200, 1008 | 1200. বাস্তবে শুধু চুপচাপ শুধরে দেবেন না: কয়টি সারি শোধরানো হলো তা log-এ লিখুন, আর সংখ্যাটি শূন্য না হলে কারণ খুঁজুন — প্রতিটি drift আসলে একটি বাগের চিহ্ন (হারানো trigger, ভুল কোড-পথ, হাতে লেখা SQL). চলমান ডেটাবেসে এই UPDATE চলার মুহূর্তে নতুন লেখা আসতে পারে; তাই হয় এটি একটি transaction-এ চালান, নয়তো trigger থাকা অবস্থায় শুধু সন্দেহজনক সারিগুলো একে একে শোধরান।
অ্যাপের কোডে dual write-এর সঠিক রূপ তাহলে কেমন? Python-এর sqlite3-এ with con: ব্লক একটি transaction: ভেতরে ত্রুটি হলে দুটি লেখাই ফিরে যায় (তুলনার জন্য, এখানে চালানো যায় না):
def add_line(con, order_id, product_id, qty, price):
with con: # BEGIN … COMMIT, ত্রুটি হলে ROLLBACK
con.execute("INSERT INTO order_items VALUES (?, ?, ?, ?)",
(order_id, product_id, qty, price))
con.execute("UPDATE orders SET total = total + ? WHERE order_id = ?",
(qty * price, order_id))৭ · ইতিহাস না redundancy — unit_price-এর শিক্ষা
order_items-এ unit_price আছে, products-এ price. একই পণ্যের দাম দুই জায়গায় — তাহলে কি নমুনা ডেটাবেসটি নিজেই denormalized? না — আর পার্থক্যটি বোঝা ডিজাইনের পরিপক্বতার চিহ্ন।
যুক্তিটি functional dependency দিয়ে (পর্ব ৩৫): products-এ product_id → price মানে “এই পণ্যের আজকের দাম”। order_items-এ (order_id, product_id) → unit_price মানে “এই অর্ডারে এই পণ্য কত টাকায় বিক্রি হয়েছিল”। দ্বিতীয়টি product_id একাই নির্ধারণ করে না — একই পণ্য দুই অর্ডারে দুই দামে বিক্রি হতে পারে, যেমন নমুনা ডেটায় Headphone 1001-এ 1200, 1007-এ 1150. তাই এটি কোনো partial বা transitive dependency নয়; টেবিলটি 3NF-এই আছে। এটি একটি আলাদা তথ্য — একটি ঐতিহাসিক snapshot. পরীক্ষাটি এক প্রশ্নে:
মূল মান বদলালে কপিটিও কি বদলানো উচিত? হ্যাঁ হলে সেটি redundancy — denormalization, যার হালনাগাদের ব্যবস্থা লাগবে। না হলে সেটি একটি নির্দিষ্ট মুহূর্তের তথ্য — ইতিহাস, normalized নকশারই অংশ, আর তাকে কখনো “মিলিয়ে” দেওয়া যাবে না।
| আজকের দাম | |
| unit_price (1007) | |
| সঠিক চালান | |
| আজকের দামে চালান |
- বিক্রির মুহূর্ত। অর্ডারের দিন দাম 1150 (একটি কল্পিত ছাড়), আর order_items দামটি নিজের কাছে লিখে রাখে।
- দাম বদলাল। products.price 1200 হলো, unit_price 1150-ই থাকল — আর এটিই সঠিক। কেউ যদি “দুটি কপি মিলিয়ে দিই” ভেবে unit_price হালনাগাদ করেন, তিনি একটি সত্য তথ্য মুছে দিলেন।
- ভুল নকশার ফল। unit_price না রেখে প্রতিবার products থেকে দাম নিলে পুরোনো চালান প্রতিবার দাম বদলের সাথে বদলায় — 3250, 3300, 3400. হিসাবরক্ষণ বা ভ্যাটের প্রশ্নে এটি গুরুতর ভুল।
- ঠিকানা। পাঠানোর ঠিকানাও একই রকম: পার্সেল যেখানে গিয়েছিল, সেটি একটি ঘটনা। গ্রাহক পরে কোথায় থাকেন, তাতে পুরোনো ঘটনা বদলায় না।
- ফোন নম্বর। কিন্তু “যোগাযোগের নম্বর” কপিটির উদ্দেশ্যই হলো এখনকার নম্বর। মূল নম্বর বদলালে কপিটি মিথ্যা হয়ে যায় — এটি আসল redundancy, আর এর জন্য trigger বা JOIN লাগবে।
- এক প্রশ্নের পরীক্ষা। দেখতে একই রকম তিনটি কপি, উত্তর আলাদা: দুটি ইতিহাস, একটি denormalization.
এবার আসল ডেটায়। প্রথম কোয়েরি খোঁজে কোন লাইনে বিক্রির দাম আজকের দাম থেকে আলাদা; তারপর Headphone-এর দাম বাড়িয়ে দুই রকম চালান তুলনা:
SELECT i.order_id, p.name, i.unit_price AS sold_at, p.price AS price_now
FROM order_items i JOIN products p ON p.product_id = i.product_id
WHERE i.unit_price <> p.price;
UPDATE products SET price = 1300 WHERE product_id = 102; -- Headphone-এর দাম বাড়ল
-- সঠিক: বিক্রির মুহূর্তের দাম থেকে চালান
SELECT order_id, SUM(quantity * unit_price) AS invoice
FROM order_items WHERE order_id IN (1001, 1007) GROUP BY order_id;
-- ভুল নকশা: আজকের দাম দিয়ে পুরোনো চালান
SELECT i.order_id, SUM(i.quantity * p.price) AS wrong_invoice
FROM order_items i JOIN products p ON p.product_id = i.product_id
WHERE i.order_id IN (1001, 1007) GROUP BY i.order_id;প্রথম কোয়েরি দুটি লাইন দেখায়: 1007 | Headphone | 1150 | 1200 আর 1009 | Mobile Phone | 14500 | 15000 — এগুলো “অমিল” নয়, ইতিহাস। দাম বদলের পর সঠিক চালান: 1001 | 17400, 1007 | 3250 — একটুও নড়েনি। ভুল নকশায়: 1001 | 17600, 1007 | 3400 — দুই গ্রাহকই এমন টাকা দেখছেন যা কখনো দেননি। তাই এই পর্বের reconciliation কোয়েরি কখনো unit_price আর products.price-এর মধ্যে চালাবেন না; “শোধরানো” মানে এখানে ইতিহাস মুছে ফেলা।
৮ · Star schema আর বড় সিস্টেমের প্যাটার্ন
প্রতিদিনের লেনদেনের ডেটাবেস — OLTP, যেখানে প্রতি সেকেন্ডে অসংখ্য ছোট লেখা — normalized থাকাই ভালো। কিন্তু বিশ্লেষণের ডেটাবেস — OLAP, যেখানে লেখা হয় রাতে একবারে আর পড়া হয় বিশাল GROUP BY দিয়ে — সেখানে denormalization ব্যতিক্রম নয়, নিয়ম। সেই নকশার নাম star schema, আর এটি থাকে data warehouse-এ (পর্ব ৫৫)।
মাঝখানে একটি fact টেবিল — প্রতিটি সারি একটি মাপা ঘটনা, যেমন একটি অর্ডার-লাইন (তার grain), সাথে সংখ্যা (quantity, amount) আর চারপাশের টেবিলের key. চারপাশে dimension টেবিল — তারিখ, পণ্য, গ্রাহক, শাখা — যাদের দিয়ে ভাগ আর ফিল্টার করা হয়। ছবিতে তারার মতো দেখায়, তাই নাম। ইচ্ছাকৃত denormalization-টি থাকে dimension-এ: পণ্যের dimension-এ শ্রেণির নাম সরাসরি একটি কলাম, আলাদা categories টেবিল নেই; তারিখের dimension-এ মাস, ত্রৈমাসিক, বছর, ছুটির দিন কি না — সব আগে থেকে লেখা। ফলে যেকোনো রিপোর্ট fact থেকে এক ধাপের JOIN. নিচের কোড তুলনার জন্য:
CREATE TABLE dim_product (
product_key INTEGER PRIMARY KEY, -- warehouse-এর নিজের surrogate key
product_id INTEGER, -- উৎস সিস্টেমের id
product_name TEXT,
category_name TEXT -- কপি: categories-এ JOIN লাগে না
);
CREATE TABLE fact_sales ( -- grain: একটি অর্ডার-লাইন
date_key INTEGER, product_key INTEGER, customer_key INTEGER,
quantity INTEGER, amount NUMERIC(12,2)
);
SELECT d.month, p.category_name, SUM(f.amount)
FROM fact_sales f
JOIN dim_date d ON d.date_key = f.date_key
JOIN dim_product p ON p.product_key = f.product_key
GROUP BY d.month, p.category_name;এখানে denormalization নিরাপদ কেন? কারণ অংশ ৬-এর দুটি প্রশ্নের উত্তর কাঠামোতেই আছে: warehouse-এ কেউ সরাসরি লেখেন না, সব ডেটা একটি নিয়ন্ত্রিত ETL প্রক্রিয়ায় উৎস থেকে আসে; আর dimension ছোট, তাই তাতে কপি করা নাম বদলানো সস্তা। dimension-কে আবার normalized করে ভাঙলে (পণ্য → শ্রেণি → বিভাগ আলাদা টেবিলে) নকশাটির নাম হয় snowflake schema — জায়গা কম, JOIN বেশি।
বড় প্রতিষ্ঠানগুলোতে যে প্যাটার্ন দেখা যায়
বড় সিস্টেমের প্রকাশ্য নকশায় কয়েকটি ধরন বারবার আসে (সাধারণভাবে বলা, কোনো সংখ্যা ছাড়া):
- Counter কলাম: সামাজিক মাধ্যমে লাইক বা অনুসারীর সংখ্যা সাধারণত আলাদা একটি সংখ্যা হিসেবে রাখা হয়। খুব জনপ্রিয় সারি hot row হয়ে গেলে সংখ্যাটি কয়েক ভাগে রাখা হয় (প্রশ্ন ০৫)।
- আগে থেকে সাজানো feed: কেউ পোস্ট করলে প্রত্যেক অনুসারীর timeline তালিকায় একটি রেফারেন্স ঢোকানো — পড়া সস্তা, লেখা বহুগুণ; বিপুল অনুসারীর অ্যাকাউন্টে তাই মিশ্র কৌশল।
- Search index: ই-কমার্সে পণ্যের খোঁজ সাধারণত Elasticsearch-এর মতো search engine থেকে আসে, যেখানে প্রতিটি পণ্য একটি denormalized document; হালনাগাদ আসে CDC বা event দিয়ে।
- কোয়েরি-প্রথম নকশা: Cassandra বা DynamoDB-র মতো ডেটাবেসে JOIN নেই বা সীমিত, তাই তাদের নির্মাতারাই প্রতিটি প্রশ্নের জন্য আলাদা করে ডেটা সাজাতে বলেন।
সবকটির সাধারণ সূত্র: কপি হালনাগাদ হয় লেখার পরে, অন্য প্রক্রিয়ায়, কিছুটা দেরিতে, আর নিয়মিত পুনর্নির্মাণে শোধরানো হয়। মূল সত্য থাকে একটি normalized জায়গায়।
আরও গভীরে: “DBMS নিজে কপি মিলিয়ে রাখুক” — এই স্বপ্নের গবেষণার নাম incremental view maintenance: base table-এর প্রতিটি পরিবর্তন থেকে শুধু পার্থক্যটুকু হিসাব করে materialized view হালনাগাদ করা। SUM আর COUNT-এর জন্য এটি সহজ (একটি সারি যোগ মানে যোগফলে যোগ), কিন্তু MIN বা MAX-এ বর্তমান সর্বনিম্ন সারিটি মুছলে পরেরটি জানতে আবার base table পড়তে হয়। SQL Server-এর indexed view প্রতিটি লেখার একই transaction-এ এভাবে হালনাগাদ হয়, আর GROUP BY থাকলে তাতে COUNT_BIG(*) রাখা বাধ্যতামূলক — শেষ সারি মুছলে দলটিই মুছতে হবে কি না, তা বোঝার জন্য। Oracle-এর fast refresh একই উদ্দেশ্যে materialized view log ব্যবহার করে; PostgreSQL-এ মূল সংস্করণে এটি নেই, extension দিয়ে পাওয়া যায়।
৯ · সিদ্ধান্তের চেকলিস্ট আর খরচের টেবিল
নিয়ম ভাঙার আগে এই প্রশ্নগুলোর লিখিত উত্তর দিন। একটিরও উত্তর না থাকলে এখনো সময় হয়নি:
- মাপ আছে? কোন কোয়েরি, কত সময়, দিনে কতবার — আর লক্ষ্য কত।
- সস্তা পথ চেষ্টা হয়েছে? index, কোয়েরি নতুন করে লেখা, অ্যাপের cache, materialized view.
- পড়া-লেখার অনুপাত? অংশ ৩-এর হিসাবে লাভ হয় কি না।
- কতটা তাজা চাই? সবসময় নির্ভুল (trigger, একই transaction), না কয়েক মিনিট বা ঘণ্টা পুরোনো চলবে (batch, refresh, CDC).
- কোন কোন লেখা মানটি বদলায়? প্রতিটি INSERT, DELETE আর প্রাসঙ্গিক UPDATE-এর তালিকা।
- ভুল ধরা আর শোধরানো? reconciliation কোয়েরি, কত দিন পরপর, অমিলে কে সতর্কবার্তা পাবেন; পুনর্গণনার স্ক্রিপ্ট।
- ইতিহাস না redundancy? অংশ ৭-এর প্রশ্ন — ইতিহাস হলে সেটি denormalization-ই নয়।
- লেখা আছে? কপির সংজ্ঞা, উৎস আর মালিক দলের ডকুমেন্টেশনে।
| কৌশল | কতটা তাজা | লেখার বাড়তি খরচ | drift-এর ঝুঁকি | কে মিলিয়ে রাখে |
|---|---|---|---|---|
| Index (তুলনার জন্য) | সবসময় | প্রতি লেখায় index হালনাগাদ | নেই | DBMS |
| Derived কলাম + trigger | সবসময় | প্রতি লেখায় এক বা কয়েকটি সারি | কম (হারানো trigger, বিশেষ পথ) | trigger |
| অ্যাপে dual write | সবসময় (সফল হলে) | প্রতি লেখায় একটি বাড়তি স্টেটমেন্ট | বেশি (crash, retry, অন্য কোড-পথ) | অ্যাপের সব কোড-পথ |
| Summary / pre-joined টেবিল | শেষ রান পর্যন্ত | লেখায় নেই; রানে বড় কাজ | শুধু stale, প্রতি রানে মুছে যায় | নির্ধারিত কাজ |
| Materialized view | শেষ refresh পর্যন্ত | লেখায় নেই (SQL Server-এর indexed view-এ আছে) | শুধু stale | DBMS + নির্ধারিত refresh |
| JSON document embedding | লেখার সময়ের | কপি থাকা প্রতিটি document | মাঝারি থেকে বেশি | অ্যাপ |
| CDC / outbox থেকে কপি | কয়েক সেকেন্ড দেরি | মূল লেখায় সামান্য (outbox-এ একটি সারি) | কম, তবে বাড়তি সিস্টেম চালাতে হয় | pipeline |
১০ · AI-এর যুগে denormalization
“কোয়েরি ধীর, schema ঠিক করে দাও” — AI সহজেই কলাম কপি করার পরামর্শ দেয়, অথচ আপনার ডেটার মাপ, লেখার হার আর index সে জানে না। সাধারণ ভুল:
| AI-এর ভুল | ফল | কীভাবে ধরবেন |
|---|---|---|
| index না দেখেই denormalization-এর পরামর্শ | অপ্রয়োজনীয় জটিলতা | আগে EXPLAIN চালান; JOIN-এর কলামে index আছে? |
| derived কলামে শুধু INSERT-এর trigger | UPDATE/DELETE-এ drift | প্রতিটি লেখার পথের তালিকা চান; reconciliation চালান |
| অ্যাপে দুটি আলাদা লেখা, transaction নেই | crash বা retry-তে drift | দুটি লেখা একটি transaction-এ আছে কি না দেখুন |
| reconciliation-এ INNER JOIN | লাইনহীন অর্ডারের অমিল ধরা পড়ে না | LEFT JOIN + COALESCE; লাইন মুছে test করুন |
| unit_price-কে “redundant” বলে মুছে দেওয়া বা “sync” করা | ইতিহাস হারানো, ভুল চালান | অংশ ৭-এর প্রশ্ন জিজ্ঞেস করুন |
| COUNT(DISTINCT) ছাড়া normalized কোয়েরি | fan-out-এ গুণিত সংখ্যা | পুরোনো আর নতুন পথের ফল EXCEPT দিয়ে মেলান |
AI-কে সমাধান নয়, সিদ্ধান্তের উপাদান দিন — schema, মাপ, লেখার হার আর কতটা তাজা চাই:
AI-এর দেওয়া denormalization চালু করার আগে — চেকলিস্ট
পরীক্ষা ও ইন্টারভিউতে এই বিষয়ে প্রায়ই যা জিজ্ঞেস করা হয়:
- Denormalization কী, কেন করা হয়? — normalized নকশায় পড়া দ্রুত করতে ইচ্ছাকৃত, নিয়ন্ত্রিত redundancy; দাম হলো লেখার খরচ আর কপি মিলিয়ে রাখার দায়।
- Denormalized আর unnormalized-এর পার্থক্য? — প্রথমটি normalized মডেল থেকে সচেতন সিদ্ধান্ত, রক্ষণাবেক্ষণের ব্যবস্থাসহ; দ্বিতীয়টি কখনো normalize-ই করা হয়নি।
- দুটি উদাহরণ দিন। — orders.total-এর মতো derived কলাম; data warehouse-এর star schema-য় dimension টেবিল।
- order_items.unit_price কি denormalization? — না; (order_id, product_id) → unit_price, বিক্রির মুহূর্তের তথ্য, আজকের দাম বদলালে বদলানো উচিত নয়।
- Redundant কপি মিলিয়ে রাখার উপায়? — trigger বা একই transaction, batch পুনর্গণনা, materialized view refresh, CDC/outbox — সাথে reconciliation.
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।
প্র ০১ডেটাবেস-জগতে একটি পুরোনো প্রবাদ আছে: “normalize until it hurts, denormalize until it works.” এই উপদেশ আজও কতটা খাটে, আর কোথায় এটি বিপজ্জনক?
প্রবাদটির প্রথম অর্ধেক এখনো খাঁটি: নকশা শুরু হবে normalized থেকে। normalized মডেলেই প্রতিটি তথ্যের একটি source of truth পাওয়া যায়, functional dependency-গুলো স্পষ্ট হয়, আর পরে কোন কপি কোথা থেকে আসে তা বলা যায়। যে দল সরাসরি “দ্রুত” নকশা দিয়ে শুরু করে, তারা আসলে unnormalized নকশা বানায় — কপিগুলোর কোনো মানচিত্র থাকে না।
দ্বিতীয় অর্ধেক এসেছে এমন এক সময় থেকে, যখন ডিস্ক ধীর, মেমোরি কম আর optimizer দুর্বল ছিল; ১৯৮০ আর ১৯৯০-এর দশকে একটি JOIN সত্যিই অনেক বাড়তি ডিস্ক-পড়া মানে হতো। আজ SSD-তে random পড়া অনেক দ্রুত, গরম ডেটা পুরোটাই buffer pool-এ থাকতে পারে, hash join আর cost-based optimizer উন্নত, আর covering index (পর্ব ৪১) বহু JOIN-এর খরচ নামিয়ে দেয়। যেখানে আগে denormalization অপরিহার্য ছিল, আজ প্রায়ই একটি সঠিক index-ই যথেষ্ট।
বিপদটি “কাজ করে” শব্দে। কপি যোগের দিন সিস্টেম দ্রুত হয় — সেটি দেখা যায়। খরচ আসে মাস পরে: নতুন একটি কোড-পথ কপিটি হালনাগাদ করে না, আমদানির স্ক্রিপ্ট trigger বন্ধ রেখে চলে, বা সংজ্ঞা নীরবে বদলে যায় (“order_count-এ কি বাতিল অর্ডার আছে?”)। এই খরচ গতির মাপে নয়, গ্রাহকের অভিযোগে ধরা পড়ে।
আধুনিক রূপটি তাই: normalize করুন, মাপুন, সস্তা পথ চেষ্টা করুন, তারপর একটি নির্দিষ্ট কোয়েরির জন্য denormalize করুন — মিলিয়ে রাখা আর ধরার ব্যবস্থাসহ। বিশ্লেষণের জগতে, যেখানে লেখা নিয়ন্ত্রিত ETL-এ হয়, প্রথম থেকেই star schema-র মতো নকশা স্বাভাবিক। প্রবাদটি তাই একটি দিক দেখায়, পদ্ধতি নয়।
মূল উপলব্ধি: normalization শুরুর বিন্দু, denormalization মেপে নেওয়া ব্যতিক্রম। “কাজ করে” মানে শুধু দ্রুত নয় — বছরের পর বছর সঠিকও।
প্র ০২Index আর materialized view-ও তো একই তথ্যের কপি। তাহলে এগুলোকে কেউ denormalization বলে না কেন, অথচ orders.total-কে বলে? পার্থক্যের মূল কোথায়?
পার্থক্যটি কপির অস্তিত্বে নয়, দায়িত্বে। B+ tree index টেবিলের কিছু কলামের সাজানো কপি, কিন্তু DBMS নিশ্চয়তা দেয় যে প্রতিটি INSERT, UPDATE আর DELETE একই transaction-এ index-ও বদলাবে, crash হলে WAL থেকে দুটিই একসাথে ফিরবে (পর্ব ৪৮), আর কোনো অ্যাপ সরাসরি index-এ লিখতে পারে না। কপিটি যৌক্তিক মডেলের অংশই নয় — physical স্তরের বিষয়, আর data independence-এর কারণে অ্যাপ জানেও না যে এটি আছে।
Materialized view মাঝামাঝি। সংজ্ঞা DBMS জানে, তাই DBMS নিজে ফল আবার হিসাব করতে পারে, আর কপিটির কোনো নিজস্ব লেখা নেই। কিন্তু PostgreSQL-এ কেউ REFRESH না চালালে ফল stale থাকে — প্রশ্নটি তখন “কপি ভুল হবে কি না” নয়, “কতটা পুরোনো হতে পারে”। SQL Server-এর indexed view প্রতিটি লেখার সাথেই হালনাগাদ হয়, আর Oracle-এর query rewrite সাধারণ কোয়েরিকেও চুপচাপ materialized view থেকে উত্তর দিতে পারে — তখন সেটি প্রায় index-এর মতোই স্বচ্ছ।
orders.total একেবারে আলাদা: একটি সাধারণ কলাম, যেকোনো অ্যাপ বা অ্যাডমিন এতে যেকোনো মান লিখতে পারেন, আর DBMS জানে না যে এটি একটি যোগফল হওয়ার কথা। trigger দায়িত্বের বড় অংশ ডেটাবেসে আনে, কিন্তু trigger মোছা, বন্ধ রাখা বা এড়ানো যায় (পর্ব ২৯)। dual write হলে দায়িত্ব ছড়িয়ে থাকে অ্যাপের প্রতিটি কোড-পথে।
এ থেকে একটি বর্ণালি পাওয়া যায়: index → materialized view → trigger-চালিত কলাম → অ্যাপ-চালিত কপি → অন্য সিস্টেমে কপি। যত ডানে, মিলের নিশ্চয়তা তত কম আর reconciliation-এর প্রয়োজন তত বেশি। ভালো নকশা কাজটি যতটা সম্ভব বাঁ দিকে রাখে।
মূল উপলব্ধি: redundancy নিজে সমস্যা নয়; সমস্যা এমন redundancy, যার মিলের নিশ্চয়তা কেউ দেয় না। “কে মিলিয়ে রাখে?” — এই প্রশ্নই ঝুঁকি মাপে।
প্র ০৩MongoDB-র মতো document ডেটাবেস অর্ডারের লাইনগুলো অর্ডারের document-এর ভেতরেই রাখতে উৎসাহ দেয়। এটি কি শুধু denormalization-এর আরেক নাম? কখন embedding ঠিক, কখন ভুল?
Embedding আসলে দুটি আলাদা জিনিসের মিশ্রণ। প্রথমটি মালিকানাধীন child সারি একসাথে রাখা: একটি অর্ডারের লাইনগুলো শুধু সেই অর্ডারের, অন্য কোথাও তাদের অর্থ নেই, আর প্রায় সবসময় অর্ডারের সাথেই পড়া হয়। এদের এক document-এ রাখায় কোনো redundancy নেই — প্রতিটি লাইন তবু একবারই আছে; এটি শুধু physical সাজানো। এখানে embedding প্রায় নিখুঁত।
দ্বিতীয়টি অন্য entity-র তথ্য কপি করা: লাইনের ভেতর পণ্যের নাম, অর্ডারের ভেতর গ্রাহকের নাম। এটিই আসল denormalization, আর অংশ ৭-এর প্রশ্নটি এখানেও খাটে: বিক্রির মুহূর্তের নাম আর দাম ইতিহাস, কিন্তু গ্রাহকের এখনকার ফোন নম্বর কপি করলে প্রতিটি বদলে হাজারো document খুঁজে বদলাতে হবে।
Embedding ভুল হয় তিন অবস্থায়। এক, child সীমাহীন: একটি জনপ্রিয় পণ্যের সব রিভিউ তার document-এ রাখলে সেটি অবিরাম বাড়ে — MongoDB-তে একটি document-এর আকারের নির্দিষ্ট সীমাও আছে (১৬ MB). দুই, child-কে আলাদাভাবে খোঁজা হয়: “Mobile Phone মোট কতবার বিক্রি হয়েছে” প্রশ্নে প্রতিটি অর্ডারের document খুলতে হয়। তিন, child অনেকের মধ্যে ভাগ করা: একটি পণ্যের পূর্ণ তথ্য হাজারো অর্ডারে কপি করলে হালনাগাদ দুঃস্বপ্ন।
MongoDB-র নিজস্ব নকশা-নির্দেশিকাও মোটামুটি এভাবে ভাবতে বলে: একসাথে পড়া, সীমিত আকারের আর মালিকানাধীন হলে embed; নইলে reference — একটি id রেখে আলাদা collection, অর্থাৎ relational ধারণাতেই ফেরা। বিশ্লেষণের প্রশ্নের জন্য অনেক দল আবার document ডেটাবেস থেকে ডেটা একটি warehouse-এ পাঠায়, যেখানে star schema — অর্থাৎ একই ডেটা দুই কাজের জন্য দুই রূপে। পর্ব ৫৬–৫৭-এ এগুলো হাতে-কলমে দেখবেন।
মূল উপলব্ধি: মালিকানাধীন child একসাথে রাখা শুধু সাজানো; অন্য entity-র তথ্য কপি করাই denormalization — document ডেটাবেসেও মিলিয়ে রাখার দায় একই।
প্র ০৪একটি ই-কমার্স সাইট পণ্যের তথ্য PostgreSQL-এ রাখে, আর খোঁজের জন্য একটি search engine-এ কপি করে। অ্যাপ প্রথমে PostgreSQL-এ, তারপর search engine-এ লেখে। কোন কোন পথে দুটি কপি আলাদা হয়ে যায়, আর outbox বা CDC কীভাবে সাহায্য করে?
এটি অংশ ৬-এর dual write, তবে দুটি আলাদা সিস্টেমে — তাই একটি transaction দিয়ে বাঁচার পথও নেই। ব্যর্থতার পথ: PostgreSQL-এ commit হলো, search engine-এ লেখার আগে অ্যাপ বন্ধ — খোঁজে পুরোনো দাম। উল্টো ক্রমে লিখলে search engine-এ লেখা হলো, কিন্তু PostgreSQL-এ rollback — খোঁজে এমন পণ্য, যা নেই। আর race: দুজন প্রায় একসাথে দাম বদলালেন; PostgreSQL-এ শেষ লেখা B-এর, কিন্তু নেটওয়ার্কের দেরিতে search engine-এ শেষে পৌঁছাল A-এর। দুই সিস্টেমই সুস্থ, অথচ চিরদিনের জন্য ভিন্ন মান।
পুরোনো সমাধান ছিল two-phase commit, কিন্তু অনেক search engine বা cache তা সমর্থনই করে না, আর coordinator ব্যর্থ হলে অংশগ্রহণকারীরা আটকে থাকতে পারে।
Outbox-এ অ্যাপ একই transaction-এ পণ্যের পরিবর্তন আর outbox টেবিলে “পণ্য 101 বদলেছে” লেখে — দুটি একসাথে commit বা rollback হয়। আলাদা প্রক্রিয়া outbox পড়ে search engine-এ পাঠায়, ব্যর্থ হলে আবার চেষ্টা করে; একই বার্তা দুবার যেতে পারে, তাই লেখাটি idempotent হতে হবে — “পূর্ণ document বসাও”, “দাম 50 বাড়াও” নয়। CDC একই কাজ করে ডেটাবেসের নিজের log (WAL বা binlog) পড়ে; Debezium-এর মতো টুল এ কাজে পরিচিত। অ্যাপ না বদলেই, অ্যাডমিনের হাতে লেখা SQL-সহ সব পরিবর্তন ধরা পড়ে। দুই পদ্ধতিতেই পরিবর্তন commit-এর ক্রমে আসে, তাই race-ও মেটে।
তবু কয়েক সেকেন্ডের দেরি থাকে — eventual consistency. তাই কেনার মুহূর্তে দাম PostgreSQL থেকে আবার পড়া হয়, আর নিয়মিত পূর্ণ পুনর্নির্মাণ হারানো পরিবর্তনও ধরে। কোন প্রশ্নের উত্তর কোন সিস্টেম দেবে, সেটিও নকশার অংশ: খোঁজ আর তালিকা search engine থেকে, টাকা-পয়সার হিসাব সবসময় মূল ডেটাবেস থেকে।
মূল উপলব্ধি: দুই সিস্টেমে আলাদা আলাদা লেখা মানেই একদিন অমিল। পরিবর্তন এক জায়গায় atomic-ভাবে লিখুন, তারপর সেখান থেকে বাকিদের কাছে পৌঁছান।
প্র ০৫একটি সামাজিক মাধ্যমে পোস্টের লাইক-সংখ্যা posts.like_count কলামে রাখা, প্রতিটি লাইকে trigger দিয়ে +1. একটি পোস্ট ভাইরাল হলো — হঠাৎ সাইট ধীর। কেন, আর বড় সিস্টেম এ সমস্যা কীভাবে সামলায়?
সাধারণ সময়ে নকশাটি চমৎকার: পোস্টের পাতা খুললে একটি কলাম পড়া, লক্ষ লাইক গোনা নয়। সমস্যা হয় যখন একটি সারিতে একসাথে অসংখ্য লেখা আসে। প্রতিটি লাইকের trigger UPDATE posts SET like_count = like_count + 1 … চালায়, যা সারিটিতে lock নেয় আর ছাড়ে commit-এর পরে (পর্ব ৪৬)। ফলে ভাইরাল পোস্টের প্রতিটি লাইক আগেরটির commit-এর অপেক্ষায় লাইনে দাঁড়ায় — হাজারো সমান্তরাল অনুরোধ কার্যত একটি একটি করে চলে। অপেক্ষমাণ transaction সংযোগ আটকে রাখে, connection pool ভরে যায়, আর একটি পোস্টের জন্য পুরো সাইট ধীর। একেই বলে hot row.
এটি denormalization-এর একটি লুকানো খরচ: normalized নকশায় প্রতিটি লাইক likes টেবিলে একটি আলাদা সারি, তাই এমন প্রতিযোগিতা প্রায় নেই। কপি করা একটি সংখ্যাই সব লেখাকে এক বিন্দুতে টেনে আনল।
প্রচলিত কৌশল কয়েকটি। এক, ভাগ করা counter: সংখ্যাটি ধরুন ১৬টি সারিতে; প্রতিটি লাইক এলোমেলোভাবে একটিতে +1, আর পড়ার সময় ১৬টি যোগ — প্রতিযোগিতা ১৬ ভাগে ভাঙে। দুই, দলবদ্ধ লেখা: লাইক আগে Redis-এর মতো in-memory counter-এ (পর্ব ৫৮) জমে, আর কয়েক সেকেন্ড পরপর একটি UPDATE-এ ডেটাবেসে যায়। তিন, নির্ভুলতার চাহিদা কমানো: পর্দায় “১২ হাজার” দেখালে শেষ কয়েকটি লাইক এক মুহূর্ত দেরিতে এলে কেউ টের পান না। আর likes টেবিল থাকে source of truth, যা থেকে সংখ্যাটি নিয়মিত আবার গোনা যায়।
একই সমস্যা আসে “আজকের মোট বিক্রি”-র একটি সারিতে, বা ফ্ল্যাশ সেলে একটি পণ্যের স্টকে।
মূল উপলব্ধি: derived কলাম পড়াকে সস্তা করে, কিন্তু লেখাগুলোকে একটি সারিতে কেন্দ্রীভূত করতে পারে। “একই সারিতে একসাথে কত লেখা আসবে” — এটিও মাপুন।
অনুশীলন
একটি কোয়েরি লেখা, একটি হাতে ফল বের করা, একটি সিদ্ধান্ত, একটি শ্রেণিবিভাগ।
-
কোয়েরি লিখুন: db-shop-এর customers-এ
order_countকলাম আছে ধরে নিন (অংশ ৪)। একটি reconciliation কোয়েরি লিখুন, যা যেসব গ্রাহকের সংরক্ষিত order_count আর orders টেবিলের আসল সংখ্যা মেলে না, তাঁদের customer_id, stored আর actual দেখায় — অর্ডারহীন গ্রাহকসহ। তারপর একটি স্টেটমেন্টে সব অমিল শোধরান। (অংশ ৪-এর order_count এডিটরে শেষে লিখে চালাতে পারেন।)SELECT c.customer_id, c.order_count AS stored, COUNT(o.order_id) AS actual FROM customers c LEFT JOIN orders o ON o.customer_id = c.customer_id GROUP BY c.customer_id, c.order_count HAVING c.order_count <> COUNT(o.order_id); UPDATE customers SET order_count = (SELECT COUNT(*) FROM orders o WHERE o.customer_id = customers.customer_id) WHERE order_count <> (SELECT COUNT(*) FROM orders o WHERE o.customer_id = customers.customer_id);দুটি খুঁটিনাটি:
COUNT(o.order_id),COUNT(*)নয় — LEFT JOIN-এ অর্ডারহীন গ্রাহকের জন্য একটি NULL-ভরা সারি আসে, COUNT(*) সেটিকেও 1 গুনত, আর order_count 0 থাকা Tanvir ভুল করে “অমিল” দেখাতেন। আর LEFT JOIN — INNER JOIN হলে, ধরুন কারও order_count ভুল করে 2 অথচ কোনো অর্ডারই নেই, তিনি ফলেই আসতেন না। সুস্থ ডেটাবেসে প্রথম কোয়েরির ফল খালি। -
ফল আগে বলুন (trace): orders.total রাখা আছে, কিন্তু trigger শুধু দুটি — INSERT-এ যোগ (oi_ins) আর DELETE-এ বিয়োগ (oi_del); UPDATE-এর trigger নেই। অর্ডার 1003-এর লাইন
(108, 3 × 420)আর(109, 2 × 260), total 1780. পরপর চলল: (ক)INSERT (1003, 105, 2, 550); (খ) 108-এর quantity 3 থেকে 5; (গ) 109-এর লাইন DELETE. প্রতিটির পর total কত, শেষে লাইনের আসল যোগফল কত, আর অংশ ৬-এর reconciliation কী দেখাবে?(ক) oi_ins: 1780 + 2 × 550 = 2880; আসল যোগফলও 2880. (খ) কোনো trigger জাগে না: total 2880-ই থাকে, অথচ আসল যোগফল 5 × 420 + 520 + 1100 = 3720. (গ) oi_del ঠিকঠাক 2 × 260 = 520 বাদ দেয়: total 2360; আসল যোগফল 2100 + 1100 = 3200.
reconciliation-এর ফল একটি সারি:
1003 | 2360 | 3200. অমিল 840 = 2 × 420 — ঠিক (খ)-এর বাড়তি দুটি চালের ব্যাগ। লক্ষ করুন, (গ)-এর DELETE trigger সঠিক কাজ করেও অমিলটি সারায়নি: increment পদ্ধতিতে একবার তৈরি হওয়া ভুল পরের সঠিক লেখাতেও বয়ে চলে। পুরো যোগফল আবার গোনার trigger হলে (গ)-এর পরই total 3200 হয়ে যেত। -
সিদ্ধান্ত নিন: প্রতিটির জন্য বলুন — denormalize করবেন কি না, করলে কোন কৌশল আর কীভাবে মিলিয়ে রাখবেন: (ক) ১০ হাজার অর্ডারের দোকান, dashboard ২ সেকেন্ডে খোলে, order_items.order_id-তে index নেই; (খ) পণ্যের পাতায় গড় রেটিং আর রিভিউ-সংখ্যা, পাতা দিনে লাখো বার খোলা, রিভিউ আসে দিনে কয়েকশো; (গ) পরিচালনা পর্ষদের মাসিক বিক্রির রিপোর্ট; (ঘ) bKash-এর মতো একটি মোবাইল ওয়ালেটে গ্রাহকের ব্যালান্স।
- (ক) না। আগে order_id-তে index; এই মাপে সমস্যা প্রায় নিশ্চিতভাবে সেখানেই। denormalization এখানে অকালপক্ব।
- (খ) হ্যাঁ — derived কলাম। products-এ rating_count আর rating_sum (গড় নয় — যোগফল আর সংখ্যা থেকে নতুন রিভিউ সহজে যোগ হয়), রিভিউ লেখার একই transaction-এ বা trigger-এ হালনাগাদ; রাতে reconciliation.
- (গ) Summary টেবিল বা materialized view, রাতে refresh — মাসিক রিপোর্টে কয়েক ঘণ্টার পুরোনো ডেটা সমস্যা নয়; বড় হলে warehouse-এ star schema.
- (ঘ) হ্যাঁ, কিন্তু সবচেয়ে কড়া নিয়মে। ব্যালান্স আসলে লেনদেনের খাতার (ledger) একটি derived মান। প্রতিটি লেনদেনে খাতার সারি আর ব্যালান্স একই transaction-এ, ঋণাত্মক ব্যালান্স ঠেকাতে constraint, আর নিয়মিত খাতা থেকে ব্যালান্স মিলিয়ে দেখা। এখানে stale বা dual write অগ্রহণযোগ্য।
-
শ্রেণিবিভাগ করুন: প্রতিটি কলাম ইতিহাস (ঐতিহাসিক তথ্য) না redundancy (denormalization)? (ক) order_items.unit_price; (খ) orders.ship_address — যেখানে পার্সেল পাঠানো হয়েছিল; (গ) orders.customer_phone — pending অর্ডারে কুরিয়ার যোগাযোগের জন্য; (ঘ) products.category_name, categories.name-এর কপি; (ঙ) invoices.vat_rate — চালানের দিনের ভ্যাটের হার; (চ) customers.order_count.
- (ক) ইতিহাস — বিক্রির মুহূর্তের দাম; আজকের দাম বদলালে বদলানো উচিত নয়।
- (খ) ইতিহাস — পার্সেল যেখানে গেছে, সেটি একটি ঘটনা।
- (গ) Redundancy — উদ্দেশ্যই এখনকার নম্বর; গ্রাহক নম্বর বদলালে কপিও বদলাতে হবে, নয়তো JOIN করে পড়ুন।
- (ঘ) Redundancy — শ্রেণির নাম বদলালে সব পণ্যে বদলাতে হবে; categories-এ AFTER UPDATE trigger লাগবে।
- (ঙ) ইতিহাস — পরে হার বদলালেও পুরোনো চালান তখনকার হারেই থাকবে।
- (চ) Redundancy (derived) — orders থেকে গোনা যায়; প্রতিটি অর্ডার যোগ, মোছা আর customer_id বদলে হালনাগাদ লাগে।
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমচালু একটি ডেটাবেসে orders.total-এর মতো derived কলাম নিরাপদে যোগ করার ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)
- ধীর কোয়েরিটির সময় মাপা আর EXPLAIN দেখা
- index আর কোয়েরি নতুন করে লেখার মতো সস্তা পথ চেষ্টা করা
- নতুন কলাম যোগ করা আর তার সংজ্ঞা লিখে রাখা
- প্রতিটি প্রাসঙ্গিক লেখার জন্য trigger বসানো
- পুরোনো সারিগুলো backfill করা
- reconciliation কোয়েরি নিয়মিত চালিয়ে অমিলে সতর্কবার্তা দেওয়া
নিচের কোনটি denormalization-এর উদাহরণ?
- order_items-এ বিক্রির মুহূর্তের দাম unit_price রাখা
- orders টেবিলে order_items-এর যোগফল total কলামে রাখা
- একটি টেবিলকে 3NF-এ আনতে দুটি টেবিলে ভাঙা
- products-এ category_id foreign key রাখা
একটি অ্যাপ autocommit-এ দুটি আলাদা স্টেটমেন্ট পাঠায়: আগে order_items-এ INSERT, তারপর orders.total-এ UPDATE. প্রথমটির পর অ্যাপ crash করল। ফল কী?
- DBMS দুটি লেখাই ফিরিয়ে দেয়
- DBMS একটি ত্রুটি-বার্তা দিয়ে অ্যাডমিনকে জানায়
- লাইন থেকে যায়, total পুরোনো থাকে — অমিল চুপচাপ স্থায়ী হয়
- DBMS পরের পড়ায় total নিজে থেকে শুধরে নেয়
একটি reconciliation কোয়েরি orders আর order_items-কে INNER JOIN করে, GROUP BY order_id, আর HAVING-এ o.total <> SUM(i.quantity * i.unit_price) দেখে। কোন অমিলটি এটি কখনো ধরবে না?
- একটি লাইনের quantity বদলেছে, total বদলায়নি
- নতুন লাইন যোগ হয়েছে, total বাড়েনি
- দুটি লাইনের একটি মুছেছে, total কমেনি
- অর্ডারের সব লাইন মুছে গেছে, অথচ total এখনো শূন্য নয়
order_items(order_id, product_id, quantity, unit_price)-এর unit_price আর products(product_id, price)-এর price একই পণ্যে আলাদা। এটি denormalization নয় কেন?
- unit_price বিক্রির মুহূর্তের দাম — (order_id, product_id)-এর ওপর নির্ভরশীল একটি আলাদা তথ্য
- কারণ দুটি কলামের নাম আলাদা
- এটি denormalization, আর মান আলাদা মানে drift — শোধরাতে হবে
- কারণ unit_price REAL টাইপের
Data warehouse-এর star schema সম্পর্কে কোন কথাটি ঠিক?
- dimension টেবিলগুলো সবচেয়ে বড়, fact টেবিল ছোট
- dimension টেবিল ইচ্ছে করে denormalized — যেমন পণ্যের dimension-এ শ্রেণির নাম সরাসরি কলাম
- অ্যাপের প্রতিটি লেনদেন সরাসরি fact টেবিলে লেখে
- dimension-গুলোকে 3NF-এ ভাঙা star schema-র মূল বৈশিষ্ট্য
একটি দোকানের ডেটাবেসে ২০ হাজার অর্ডার। একটি রিপোর্ট ৩ সেকেন্ড নেয়; EXPLAIN দেখায় order_items-এ full table scan, কারণ order_id-তে কোনো index নেই। প্রথম পদক্ষেপ কী হওয়া উচিত?
- orders-এ total কলাম যোগ করে trigger বসানো
- একটি data warehouse আর star schema বানানো
- order_items.order_id-তে index বানিয়ে আবার মাপা
- সব অর্ডার JSON document-এ রূপান্তর করা
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- Normalization লেখার সঠিকতা দেয়, পড়ায় JOIN চায়। base table থেকে N সারির যোগফল পেতে N সারি পড়তেই হয়; এর নিচে নামার একমাত্র পথ আগে থেকে হিসাব রাখা।
- Denormalization = normalized নকশা + সচেতন, লিখিত কপি + মিলিয়ে রাখার ব্যবস্থা। আগে মাপুন, index আর সস্তা পথ চেষ্টা করুন; লাভ হয় যখন r · (C − c) > w · m + রক্ষণাবেক্ষণ।
- কৌশল: derived কলাম, কপি করা attribute, pre-joined আর summary টেবিল, materialized view, JSON embedding, warehouse-এ star schema.
- মিলিয়ে রাখা: একই transaction বা trigger, batch, refresh, outbox/CDC. দুই আলাদা লেখার মাঝে crash বা retry মানেই drift — LEFT JOIN আর COALESCE দিয়ে reconciliation চালান।
- ইতিহাস redundancy নয়: মূল মান বদলালে কপিটি বদলানো উচিত না হলে (unit_price, পাঠানোর ঠিকানা, করের হার) সেটি একটি আলাদা তথ্য — কখনো “sync” করবেন না।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ৩৯ · স্টোরেজ — ডিস্ক, page, record আর buffer poolপরবর্তী পর্বপড়ার খরচ আসলে কোথা থেকে আসে।
- পর্ব ২৯ · Trigger — নিয়ম মেনে স্বয়ংক্রিয় কাজফিরে দেখুনderived কলাম হালনাগাদ রাখার যন্ত্র।
- পর্ব ২৪ · View আর materialized viewসম্পর্কিতDBMS-চালিত জমানো ফল আর refresh.
- ডেটাবেস (DBMS) ও SQL · কোর্স হোম৬৭টি পর্বপুরো কোর্সের রোডম্যাপ — ১০টি মডিউল।
- DSA · B-tree আর B+ treeসম্পর্কিত কোর্সindex probe-এর খরচ কেন গাছের উচ্চতার সমান।
- Python · SQLite ডেটাবেসহাতে-কলমেঅ্যাপ থেকে দুটি লেখা একটি transaction-এ।