সরাসরি পর্বে যান
পর্ব ৬৭ · ৬৭-এর মধ্যে · মডিউল ১০

ডেটাবেস ডিজাইন ইন্টারভিউ — schema থেকে scaling

Database Design Interviews — A Nine-Step Framework, Back-of-the-Envelope Estimates and Eight Worked Problems from Schema to Scaling

Taught in Bangla, this page prepares database design interviews: what interviewers assess, a nine-step framework with back-of-the-envelope estimates, eight worked problems including a URL shortener, a university results system, a mobile wallet ledger, train tickets, e-commerce orders, ride sharing, a social feed and chat, choosing SQL or NoSQL, common mistakes that cost marks, and where to go after the course.

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

  • ডিজাইন ইন্টারভিউতে প্রশ্নকর্তা কী দেখেন, আর প্রশ্ন শোনার পর প্রথম পাঁচ মিনিট কীভাবে কাজে লাগাবেন
  • নয় ধাপের কাঠামো: requirement → মাপের হিসাব → ER → schema → কোয়েরি ও index → concurrency → scaling → ব্যর্থতা → trade-off
  • কাগজে-কলমে back-of-envelope হিসাব: দিনে কত সারি, বছরে কত GB, প্রতি সেকেন্ডে কত কোয়েরি
  • আটটি পরিচিত সিস্টেমের schema, কঠিন অংশ (double booking, idempotency, fan-out, inventory) আর scaling-এর পথ
  • কখন SQL, কখন NoSQL — আর যে ভুলগুলো ইন্টারভিউতে সবচেয়ে বেশি নম্বর কাটে

১ · ডিজাইন ইন্টারভিউ কেমন হয় — কী মাপা হয়

“একটি বাসের টিকিট বুকিং সিস্টেমের ডেটাবেস ডিজাইন করুন।” — এমন একটি বাক্য দিয়ে শুরু, তারপর ৩৫–৬০ মিনিট আলোচনা। একে বলে ডিজাইন ইন্টারভিউ। backend, data engineering বা অভিজ্ঞ software engineer পদে এ ধরনের প্রশ্ন প্রায়ই আসে; বিশ্ববিদ্যালয়ের ভাইভা আর লিখিত পরীক্ষায়ও “একটি লাইব্রেরি বা হাসপাতালের ER আর schema বানান” ধাঁচে আসে। কোডিং ইন্টারভিউর মতো এখানে একটি “সঠিক উত্তর” নেই। প্রশ্নকর্তা দেখেন আপনি কীভাবে ভাবেন।

একজন অভিজ্ঞ রাজমিস্ত্রির কথা ভাবুন। জমির মালিক বলেন, “একটা বাড়ি বানাবেন।” ভালো মিস্ত্রি তখনই ইট গাঁথা শুরু করেন না। জিজ্ঞেস করেন: কয়জন থাকবেন? কয়তলা? পরে তলা বাড়ানোর ইচ্ছা আছে? মাটি কেমন? তারপর একটি নকশা আঁকেন, ভিত কতটা গভীর হবে হিসাব করেন, আর বলেন কোথায় খরচ কমানো যায় আর কোথায় যায় না। ডিজাইন ইন্টারভিউ ঠিক এই কথোপকথন — শুধু বাড়ির জায়গায় ডেটাবেস।

প্রশ্নকর্তা সাধারণত এই বিষয়গুলো দেখেন — প্রতিটির পাশে কোর্সের কোন পর্বে তার ভিত্তি:

যা দেখা হয়ভালো উত্তরে যা থাকেফিরে দেখুন
Requirementঅস্পষ্ট প্রশ্নকে প্রশ্ন করে স্পষ্ট করা; কী আছে আর কী নেই, লিখে নেওয়াপর্ব ৭, ১১
Entity আর সম্পর্কঠিক entity, cardinality, weak বা associative entity চেনাপর্ব ৮, ১০
Schema আর constraintkey, NOT NULL, CHECK, UNIQUE, FK — নিয়ম ডেটাবেসেইপর্ব ৬, ১৩, ৩৬
Access patternপ্রধান কোয়েরিগুলোর তালিকা, কোনটি কত ঘন ঘনপর্ব ২৭
Indexপ্রতিটি প্রধান কোয়েরির জন্য কোন index, কেনপর্ব ৪০, ৪১
Consistency আর concurrencyকোথায় transaction, কোথায় race, কীভাবে ঠেকানোপর্ব ৪৪, ৪৭
Scalingcache, replica, partition, shard — প্রয়োজন হলে, হিসাব দেখিয়েপর্ব ৫২, ৫৪
Trade-offপ্রতিটি সিদ্ধান্তের দাম কী, বিকল্প কেন বাদপর্ব ৩৮, ৫৬

সবচেয়ে বেশি নম্বর কাটে তিনটি অভ্যাসে। এক, প্রশ্ন শুনেই টেবিল আঁকা শুরু করা — requirement না জেনে। দুই, প্রথম মিনিটেই “Kafka, Cassandra আর ১০০টি shard” বলা — মাপ না জেনে। তিন, চুপচাপ ভাবা। প্রশ্নকর্তা আপনার মাথার ভেতর দেখতে পান না; জোরে ভাবুন, সিদ্ধান্ত বলুন, আর বলুন কেন। ভুল অনুমান করলেও ক্ষতি কম, যদি অনুমানটি স্পষ্ট বলেন — প্রশ্নকর্তা শুধরে দেবেন।

সময় ভাগ (৪৫ মিনিটের একটি সেশন, মোটামুটি)

requirement আর মাপ ৫–৮ মিনিট · ER আর schema ১২–১৫ মিনিট · কোয়েরি, index আর concurrency ১০ মিনিট · scaling, ব্যর্থতা আর trade-off ১০ মিনিট · প্রশ্নকর্তার প্রশ্ন বাকি সময়। প্রশ্নকর্তা কোনো একটি দিকে গভীরে যেতে চাইলে তাঁকে অনুসরণ করুন — সেটিই তাঁর আসল প্রশ্ন।

২ · নয় ধাপের কাঠামো — হিসাবসহ একবার পুরো পথ

যেকোনো ডিজাইন প্রশ্নে একই নয়টি ধাপ। প্রথম দুটি ধাপ বাকি সবকিছু ঠিক করে দেয়, তাই সেগুলো একটু খুলে বলি।

ধাপ ১ — requirement দুই ভাগে। Functional requirement হলো সিস্টেম কী করবে: “লম্বা লিংক দিলে ছোট লিংক দেবে”, “ছোট লিংকে গেলে আসল পাতায় পাঠাবে”. Non-functional requirement হলো কতটা ভালোভাবে: কত দ্রুত, কতজন একসাথে, কতটা নির্ভরযোগ্য, ডেটা হারালে কতটা চলে, কোনটি বেশি জরুরি — consistency না availability. দ্বিতীয় তালিকাটিই ডিজাইন বদলায়। “টাকার হিসাব কখনো ভুল হবে না” আর “লাইকের সংখ্যা কয়েক সেকেন্ড পিছিয়ে থাকলে চলে” — দুটি সম্পূর্ণ আলাদা ডেটাবেসের দিকে নিয়ে যায়।

ধাপ ২ — মাপের হিসাব। Back-of-envelope estimation মানে খামের পেছনে লেখার মতো মোটা দাগের হিসাব: দিনে কত নতুন সারি, প্রতি সেকেন্ডে কত লেখা আর পড়া (QPS), বছরে কত GB. লক্ষ্য নিখুঁত সংখ্যা নয়, সঠিক “ঘর” — দশ, হাজার না লাখ। কয়েকটি সংখ্যা মনে রাখলে হিসাব দ্রুত হয়:

মনে রাখুনমানব্যবহার
এক দিনের সেকেন্ড৮৬,৪০০ ≈ ১০৫দিনে ১০ লাখ ঘটনা ≈ সেকেন্ডে ১২টি
বছরে দিন৩৬৫ ≈ ৪০০ (ওপরের দিকে গোল করলে নিরাপদ)দিনের GB × ৪০০ ≈ বছরের GB
সর্বোচ্চ চাপ (peak)গড়ের ২–১০ গুণ (ধরে নেওয়া)ঈদ, বেতনের দিন, ফলাফলের দিন
1 KB × ১০ লাখ1 GBসারির আকার × সারির সংখ্যা
replicaসাধারণত ২–৩ কপিমোট ডিস্ক = ডেটা × কপির সংখ্যা

নিচের stepper-এ নয়টি ধাপ একটি URL shortener-এ (bit.ly-ধাঁচের সেবা) প্রয়োগ করা হলো। সব সংখ্যা ধরে নেওয়া অনুমান — ইন্টারভিউতে প্রশ্নকর্তার সাথে এগুলো মিলিয়ে নেবেন। অংশ ৩-এ এই schema লাইভ এডিটরে চালাবেন।

হোয়াইটবোর্ডে যা লিখবেনলাল = এই ধাপের মূল সিদ্ধান্ত

            
  1. Requirement. প্রশ্ন করে দুটি তালিকা বানান। “custom নাম লাগবে?”, “লিংক কি মুছে যায়?”, “ক্লিকের হিসাব কতটা নিখুঁত?” — প্রতিটি উত্তর পরে একটি কলাম বা constraint হবে। non-functional তালিকার “পড়া অনেক বেশি” কথাটিই পুরো ডিজাইনের মূল সূত্র।
  2. মাপ। ৮৬,৪০০-কে ১০৫ ধরলে দিনে ১০ লাখ ≈ সেকেন্ডে ১০-এর ঘর। পড়া ১০০ গুণ, তাই হাজারের ঘর, peak-এ কয়েক হাজার। ৫ বছরের লিংক ≈ ১.৮ × ১০৯, আর ৬২৬ ≈ ৫.৭ × ১০১০ — তাই ৬ অক্ষরের code-এ প্রায় ৩১ গুণ জায়গা থাকে। সব সংখ্যা অনুমান, তাই জোরে বলুন।
  3. Entity আর ER. তিনটি entity. ক্লিক নিয়ে সিদ্ধান্তটি মাপ থেকে আসে: দিনে ১০ কোটি redirect-এর প্রতিটি সারি হলে OLTP ডেটাবেস ভরে যাবে। তাই দিনের মোট রাখা হলো; প্রতিটি ক্লিকের বিস্তারিত দরকার হলে সেটি আলাদা log বা analytics ডেটাবেসে যাবে।
  4. Schema. প্রতিটি নিয়ম একটি constraint: code অনন্য আর বাধ্যতামূলক, মেয়াদ তৈরির পরে, ক্লিক ঋণাত্মক নয়। ছোট surrogate key (link_id) রাখা হলো, কারণ code বদলানো যেতে পারে আর অন্য টেবিলে ছোট সংখ্যা দিয়ে join সস্তা।
  5. কোয়েরি আর index. প্রতিটি প্রধান কোয়েরির পাশে তার index. redirect-এর জন্য UNIQUE constraint নিজেই একটি B+ tree index বানায় — কয়েক শ কোটি সারিতেও কয়েকটি page পড়লেই উত্তর (পর্ব ৪০)।
  6. Concurrency. custom নামের প্রতিযোগিতা একটি ক্লাসিক check-then-act race. সমাধান ডেটাবেসে: UNIQUE constraint আর error ধরা। ক্লিক গোনায় upsert একটি সারিতেই যোগ করে — তবে জনপ্রিয় লিংকে সেই সারি hot row হতে পারে, তাই queue-তে জমিয়ে যোগ করা নিরাপদ।
  7. Scaling. পড়া-ভারী সিস্টেমের প্রথম ওষুধ cache. গরম লিংকের হিসাব (ধরে নেওয়া: ৩০ দিনে ৩ কোটি নতুন লিংকের ২০% গরম) কয়েক GB — একটি Redis নোডে আঁটে। shard তখনই, যখন হিসাব বলে একটি primary আর লেখা নিতে পারছে না।
  8. ব্যর্থতা। প্রতিটি অংশ ভাঙলে কী হয়, বলুন। cache হঠাৎ খালি হলে সব অনুরোধ ডেটাবেসে যায় — ডেটাবেস কি peak-এর পুরো চাপ নিতে পারে? না পারলে cache-কেও replica দিয়ে রাখুন। ভিন্ন ডেটার জন্য ভিন্ন RPO বলা পরিণত চিন্তার লক্ষণ।
  9. Trade-off. প্রতিটি পছন্দের দাম বলুন, আর requirement দিয়ে সিদ্ধান্ত নিন। ৩০১ আর ৩০২-এর পার্থক্য ছোট শোনায়, কিন্তু ৩০১ হলে ব্রাউজার পরের বার সার্ভারে না এসেই চলে যায় — ক্লিকের হিসাব ভুল হয়।

একটি সতর্কতা: হিসাবের প্রতিটি সংখ্যা মডেলের অনুমান। আসল সিস্টেমে সারির আকার, peak-এর গুণ আর পড়া-লেখার অনুপাত মেপে বের করতে হয় (capacity planning, পর্ব ৫৩)। ইন্টারভিউতে “ধরে নিচ্ছি গড় URL ১০০ অক্ষর” বলুন — ভুল হলে প্রশ্নকর্তা বলবেন, আর আপনি শুধু একটি সংখ্যা বদলে আবার হিসাব করবেন।

৩ · সমস্যা ১–২: URL shortener আর বিশ্ববিদ্যালয়ের ফলাফল সিস্টেম

প্রতিটি সমস্যা একই ছাঁচে: প্রয়োজন, আনুমানিক মাপ, ER, schema (লাইভ এডিটরে), প্রধান কোয়েরি ও index, কঠিন অংশ, আর কীভাবে বড় হবে। প্রথম এডিটরে schema নিজেই আছে — চালালে টেবিল তৈরি হয়, নমুনা সারি ঢোকে, তারপর কোয়েরি চলে। দ্বিতীয় এডিটর একই schema আগে থেকে লোড করে নেয় (“নমুনা ডেটাবেস” বাক্সে দেখা যায়)।

সমস্যা ১ · URL shortener

প্রয়োজন: অংশ ২-এর stepper-এ লেখা হয়েছে — ছোট code, redirect, custom নাম, মেয়াদ আর দিনে কত ক্লিক। মাপ (ধরে নেওয়া): দিনে ১০ লাখ নতুন লিংক, সেকেন্ডে গড়ে প্রায় ১,১৬০টি redirect. ER: user 1 — N links 1 — N click_daily. নিচের schema চালান:

-- URL shortener: schema আর নমুনা ডেটা
CREATE TABLE links (
  link_id    INTEGER PRIMARY KEY,
  code       TEXT NOT NULL UNIQUE CHECK (length(code) BETWEEN 4 AND 16),
  long_url   TEXT NOT NULL CHECK (long_url LIKE 'http%://%'),
  owner_id   INTEGER,                     -- NULL = লগইন ছাড়া বানানো
  created_at TEXT NOT NULL,               -- UTC, ISO-8601
  expires_at TEXT,                        -- NULL = মেয়াদ নেই
  CHECK (expires_at IS NULL OR expires_at > created_at)
);
CREATE TABLE click_daily (                -- প্রতিটি ক্লিক নয়, দিনের মোট
  link_id INTEGER NOT NULL REFERENCES links(link_id) ON DELETE CASCADE,
  day     TEXT NOT NULL,
  clicks  INTEGER NOT NULL DEFAULT 0 CHECK (clicks >= 0),
  PRIMARY KEY (link_id, day)
);
INSERT INTO links VALUES
 (1, 'abcd12',    'https://abcltech.com/dbms-sql/',  7,    '2026-10-01 04:00:00', NULL),
 (2, 'eid-offer', 'https://shop.example.com/eid',    9,    '2026-10-02 10:00:00', '2026-10-20 00:00:00'),
 (3, 'Zx9Q',      'https://example.org/notice.pdf',  NULL, '2026-10-03 08:30:00', NULL);
-- রিডাইরেক্ট (এখন ধরা হলো '2026-10-25 06:00:00' UTC)
SELECT code, long_url FROM links
WHERE code IN ('abcd12', 'eid-offer')
  AND (expires_at IS NULL OR expires_at > '2026-10-25 06:00:00');
EXPLAIN QUERY PLAN SELECT long_url FROM links WHERE code = 'abcd12';

দুটি CREATE আর একটি INSERT-এর (৩টি সারি) পরে প্রথম SELECT শুধু একটি সারি দেয়: abcd12 | https://abcltech.com/dbms-sql/. eid-offer-এর মেয়াদ ২০ অক্টোবর শেষ, তাই শর্তে বাদ — মেয়াদোত্তীর্ণ লিংক “নেই” হিসেবে আচরণ করবে (অ্যাপ 404 বা 410 দেখাবে)। দ্বিতীয় ফলটি plan: SEARCH links USING INDEX sqlite_autoindex_links_1 (code=?) — UNIQUE constraint নিজে থেকেই একটি index বানিয়েছে, আর redirect সেটি দিয়েই খোঁজে, পুরো টেবিল পড়ে না (EXPLAIN QUERY PLAN, পর্ব ৪১)।

কঠিন অংশ — code কোথা থেকে আসবে? তিনটি প্রচলিত পথ। (ক) ক্রমিক id-কে base62-এ লেখা: কখনো সংঘর্ষ নেই, কিন্তু code অনুমান করা যায় (abcd12-এর পরে abcd13), আর একটি কেন্দ্রীয় counter লাগে। (খ) এলোমেলো ৭ অক্ষর: অনুমান কঠিন; সংঘর্ষ হলে UNIQUE ধরবে, আবার চেষ্টা। (গ) URL-এর hash-এর প্রথম কয়েকটি অক্ষর: একই URL-এ একই code, কিন্তু সংঘর্ষ সামলাতে হয়। নিচে প্রথম পথ, দিনের ক্লিক upsert আর custom নামের সংঘর্ষ:

-- id থেকে base62 code: 0-9, a-z, A-Z — মোট ৬২টি অক্ষর
WITH RECURSIVE ids(id) AS (VALUES (61), (62), (1000000), (3521614606207)),
b62(id, n, code) AS (
  SELECT id, id, '' FROM ids
  UNION ALL
  SELECT id, n / 62,
    substr('0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', n % 62 + 1, 1) || code
  FROM b62 WHERE n > 0)
SELECT id, code FROM b62 WHERE n = 0 ORDER BY id;
-- প্রতিটি ক্লিকে দিনের গণনা এক বাড়ানো — upsert, দুবার
INSERT INTO click_daily VALUES (1, '2026-10-25', 1)
  ON CONFLICT (link_id, day) DO UPDATE SET clicks = clicks + 1;
INSERT INTO click_daily VALUES (1, '2026-10-25', 1)
  ON CONFLICT (link_id, day) DO UPDATE SET clicks = clicks + 1;
SELECT * FROM click_daily;
-- কেউ নিজের পছন্দের নাম (custom alias) চাইলেন, যা আগেই নেওয়া
INSERT INTO links VALUES (4, 'eid-offer', 'https://other.example.com', 12, '2026-10-25 06:00:00', NULL);

recursive CTE প্রতিটি ধাপে সংখ্যাকে ৬২ দিয়ে ভাগ করে, আর ভাগশেষের অক্ষরটি সামনে বসায় (পর্ব ২৫)। ফল: 61 → Z, 62 → 10, 1000000 → 4c92, আর 3521614606207 → ZZZZZZZ — অর্থাৎ ৬২৭ − ১ হলো ৭ অক্ষরের সবচেয়ে বড় code. দুটি upsert-এর প্রতিটি “১টি সারি বদলেছে”; প্রথমটি সারি বানায়, দ্বিতীয়টি সংঘর্ষে UPDATE করে, তাই 1 | 2026-10-25 | 2. শেষ INSERT থামে: UNIQUE constraint failed: links.code — দুজন একসাথে একই নাম চাইলেও ঠিক এভাবেই একজন হারবেন, অ্যাপের আগাম খোঁজাখুঁজি ছাড়াই।

কীভাবে বড় হবে: (১) redirect-এর সামনে cache-aside — code → long_url, মেয়াদসহ; (২) ব্যবহারকারীর ড্যাশবোর্ড read replica-তে; (৩) ক্লিক queue-তে, মিনিটে একবার click_daily-তে যোগ; (৪) একেবারে বড় মাপে hash(code) ধরে shard — প্রতিটি redirect ঠিক একটি shard-এ যায়, কারণ প্রশ্নটি সবসময় একটি code দিয়ে। ক্রমিক id ব্যবহার করলে counter-টিও ভাগ করতে হয় — যেমন প্রতিটি সার্ভার একবারে ১,০০০টি id-র একটি খণ্ড নিয়ে নেয়।

সমস্যা ২ · বিশ্ববিদ্যালয়ের ফলাফল সিস্টেম

প্রয়োজন: প্রতিটি term-এ প্রতিটি কোর্সের গ্রেড; credit-ভিত্তিক CGPA; প্রকাশের আগে শিক্ষার্থী ফল দেখবেন না; পুনর্নিরীক্ষায় (re-check) গ্রেড বদলালে কে, কখন, কী থেকে কী — তার হিসাব; আর মান-বহির্ভূত গ্রেড ঢুকবে না। মাপ (ধরে নেওয়া): ২০,০০০ শিক্ষার্থী × বছরে ১০টি কোর্স = বছরে ২ লাখ গ্রেড-সারি — ডেটা খুব ছোট। কিন্তু ফলের দিনে সবাই প্রথম এক ঘণ্টায় ১০ বার করে দেখলে ২,০০,০০০ ÷ ৩,৬০০ ≈ সেকেন্ডে ৫৬টি অনুরোধ; লাখো পরীক্ষার্থীর একটি পাবলিক পরীক্ষায় (ধরে নেওয়া ১৫ লাখ, প্রত্যেকে ৫ বার) ৭৫,০০,০০০ ÷ ৩,৬০০ ≈ সেকেন্ডে ২,০৮০টি। শিক্ষা: ডেটা ছোট, চাপ হঠাৎ আর তীব্র।

ER: কোর্সের নমুনা ডেটাবেস (db-uni) আমরা ব্যবহার করব — students(student_id, name, dept_id, batch, cgpa), courses(course_id, title, credits, …), আর associative entity enrollments(student_id, course_id, term, grade), যেখানে grade NULL মানে ফল আসেনি। নতুন যোগ: গ্রেড থেকে পয়েন্টের lookup টেবিল, গ্রেড বদলের audit টেবিল আর দুটি trigger. পয়েন্টের স্কেলটি বাংলাদেশের অনেক বিশ্ববিদ্যালয়ে প্রচলিত ধাঁচের (A+ = 4.00 … F = 0.00), এখানে উদাহরণ হিসেবে ধরে নেওয়া; F-এর credit হিসাবে ধরা হবে কি না প্রতিটি প্রতিষ্ঠানের নিয়মে আলাদা।

-- ফলাফল সিস্টেম: db-uni-এর ওপর গ্রেড-পয়েন্টের টেবিল আর গ্রেড বদলের হিসাব
CREATE TABLE grade_points (grade TEXT PRIMARY KEY,
  point REAL NOT NULL CHECK (point BETWEEN 0 AND 4));
INSERT INTO grade_points VALUES ('A+', 4.00), ('A', 3.75), ('A-', 3.50), ('B+', 3.25),
 ('B', 3.00), ('B-', 2.75), ('C+', 2.50), ('C', 2.25), ('D', 2.00), ('F', 0.00);
CREATE TABLE grade_audit (
  audit_id   INTEGER PRIMARY KEY,
  student_id INTEGER NOT NULL, course_id TEXT NOT NULL, term TEXT NOT NULL,
  old_grade  TEXT, new_grade TEXT,
  changed_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TRIGGER grade_must_exist BEFORE UPDATE OF grade ON enrollments
WHEN NEW.grade IS NOT NULL AND NEW.grade NOT IN (SELECT grade FROM grade_points)
BEGIN SELECT RAISE(ABORT, 'unknown grade'); END;
CREATE TRIGGER grade_log AFTER UPDATE OF grade ON enrollments
WHEN OLD.grade IS NOT NEW.grade
BEGIN
  INSERT INTO grade_audit (student_id, course_id, term, old_grade, new_grade)
  VALUES (OLD.student_id, OLD.course_id, OLD.term, OLD.grade, NEW.grade);
END;
-- CGPA = Σ(ক্রেডিট × পয়েন্ট) ÷ Σ ক্রেডিট; F-এর পয়েন্ট ০, NULL গ্রেড বাদ
SELECT s.student_id, s.name, s.cgpa AS stored_cgpa,
  ROUND(SUM(c.credits * g.point) / SUM(c.credits), 2) AS computed_cgpa,
  SUM(c.credits) AS credits
FROM students s
JOIN enrollments e  ON e.student_id = s.student_id
JOIN courses c      ON c.course_id = e.course_id
JOIN grade_points g ON g.grade = e.grade
GROUP BY s.student_id ORDER BY s.student_id;

সাতজনের CGPA হিসাব হলো; Habib Ullah (2302) বাদ, কারণ তাঁর কোনো enrollment নেই — INNER JOIN. দেখুন দুই কলাম মেলে না: Arif Hossain-এর stored 3.75, হিসাবে 3.65 (৭.৫ ক্রেডিটে); Bristy Saha 3.4 বনাম 3.13; Fahim Karim-এর stored NULL, অথচ হিসাবে 0 (একটি F). নমুনা ডেটায় cgpa কলামটি আলাদাভাবে লেখা হয়েছিল, enrollments থেকে গুনে নয় — বাস্তবেও derived attribute ঠিক এভাবেই নিঃশব্দে সরে যায়। ইন্টারভিউর উত্তর: CGPA হিসাব করুন, বা জমা রাখলে কোন ঘটনায় আবার হিসাব হবে আর রাতে একটি মিলিয়ে দেখার কোয়েরি চলবে — দুটিই বলুন।

কঠিন অংশ — বদল আর তার হিসাব। প্রকাশিত ফল বদলানো আইনি আর নৈতিক প্রশ্ন। তাই প্রতিটি বদল একটি audit সারি লেখে (অ্যাপ নয়, trigger — যাতে কোনো স্ক্রিপ্টও ফাঁকি দিতে না পারে), আর স্কেলের বাইরের গ্রেড আটকানো হয়:

-- পুনর্নিরীক্ষায় Bristy Saha-র CSE301 গ্রেড B থেকে B+
UPDATE enrollments SET grade = 'B+'
WHERE student_id = 2102 AND course_id = 'CSE301' AND term = '2023-Fall';
SELECT student_id, course_id, term, old_grade, new_grade FROM grade_audit;
SELECT ROUND(SUM(c.credits * g.point) / SUM(c.credits), 2) AS cgpa_2102
FROM enrollments e JOIN courses c ON c.course_id = e.course_id
JOIN grade_points g ON g.grade = e.grade WHERE e.student_id = 2102;
-- টাইপের ভুল: স্কেলে নেই এমন গ্রেড
UPDATE enrollments SET grade = 'A++'
WHERE student_id = 2101 AND course_id = 'CSE201' AND term = '2022-Spring';

UPDATE-এ ১টি সারি বদলায়, আর AFTER trigger audit-এ লেখে: 2102 | CSE301 | 2023-Fall | B | B+. Bristy-র নতুন CGPA 3.25 — দুটি কোর্সই এখন B+, তাই (৩.২৫ × ৩ + ৩.২৫ × ৩) ÷ ৬. শেষ UPDATE থামে: unknown grade — BEFORE trigger স্কেলে না থাকা A++ ঢুকতে দেয়নি। আসল নকশায় enrollments.grade-কে সরাসরি REFERENCES grade_points(grade) করাই সবচেয়ে সরল; এখানে নমুনা টেবিল বদলানো যায় না বলে trigger.

আরও যা বলবেন: প্রকাশের নিয়ন্ত্রণ — result_publications(course_id, term, published_at) টেবিল, আর শিক্ষার্থীর view-এ শুধু প্রকাশিত সারি (পর্ব ২৪-এর view, পর্ব ৫১-এর privilege). Retake নীতি — “সেরা গ্রেড গোনা হবে” হলে প্রতিটি (student, course)-এ ROW_NUMBER() OVER (PARTITION BY student_id, course_id ORDER BY point DESC) = ১ নেওয়া (পর্ব ২৬)। Scaling: ফল প্রকাশের মুহূর্তে প্রতিটি শিক্ষার্থীর পুরো ফল আগে থেকে হিসাব করে একটি টেবিলে বা cache-এ রাখুন — প্রকাশের পর ফল প্রায় বদলায় না, তাই একে স্থির পাতা বা CDN-এও রাখা যায়; মূল ডেটাবেস শুধু শিক্ষকদের লেখার কাজ পায়।

ফিরে দেখুন: base62-এর recursive CTE — পর্ব ২৫; upsert — পর্ব ১৪; UNIQUE আর index — পর্ব ৬, ৪০; derived attribute — পর্ব ৭; audit trigger — পর্ব ২৯; cache — পর্ব ৫৮.

৪ · সমস্যা ৩–৪: মোবাইল ওয়ালেটের খাতা আর ট্রেনের টিকিট

সমস্যা ৩ · bKash-ধাঁচের মোবাইল ওয়ালেট — ledger

প্রয়োজন: cash-in, send money, দোকানে পেমেন্ট, ফি; গ্রাহকের ব্যালান্স কখনো ঋণাত্মক নয়; নেটওয়ার্ক কেটে অ্যাপ একই অনুরোধ আবার পাঠালে টাকা দুবার কাটবে না; প্রতিটি পয়সার পূর্ণ ইতিহাস; আর প্রতিদিন হিসাব মেলানো (reconciliation). Non-functional: এখানে consistency সবার আগে — একটি commit হওয়া লেনদেনও হারানো চলবে না, তাই RPO শূন্য, synchronous replica (পর্ব ৫২)। এটি একটি কাল্পনিক নকশা; কোনো আসল প্রতিষ্ঠানের ভেতরের ব্যবস্থা নয়।

মাপ (ধরে নেওয়া): দিনে ১ কোটি লেনদেন → ১,০০,০০,০০০ ÷ ৮৬,৪০০ ≈ সেকেন্ডে ১১৬টি; মাসের শুরু বা ঈদের আগে peak ১০ গুণ ধরলে ≈ ১,১৬০টি। প্রতিটি লেনদেনে ১টি transfer, ২–৩টি entry আর ২টি ব্যালান্সের UPDATE; index-সহ মোটামুটি ১ KB ধরলে দিনে ১০ GB, বছরে প্রায় ৩.৭ TB — সব সারি চিরকাল রাখতে হয়।

মূল ধারণা — double-entry: প্রতিটি লেনদেন অন্তত দুটি ledger লাইন: এক account থেকে যত বিয়োগ, অন্য account-এ ঠিক তত যোগ — যোগফল সবসময় শূন্য। টাকা তৈরি হয় না, হারায় না, শুধু সরে। “Cash-in pool” একটি system account, যা এজেন্টের হাতে নেওয়া নগদের বিপরীতে ঋণাত্মক থাকে। ER: account 1 — N entry N — 1 transfer. টাকা রাখা হলো পয়সায়, INTEGER-এ — কখনো floating point নয়।

CREATE TABLE accounts (                   -- সব টাকা পয়সায়, INTEGER
  account_id INTEGER PRIMARY KEY, owner TEXT NOT NULL,
  kind       TEXT NOT NULL CHECK (kind IN ('customer', 'merchant', 'system')),
  balance    INTEGER NOT NULL DEFAULT 0,   -- entries-এর যোগফলের কপি
  CHECK (kind = 'system' OR balance >= 0)
);
CREATE TABLE transfers (
  transfer_id     INTEGER PRIMARY KEY,
  idempotency_key TEXT NOT NULL UNIQUE,    -- অ্যাপের পাঠানো অনন্য অনুরোধ-id
  kind TEXT NOT NULL, created_at TEXT NOT NULL
);
CREATE TABLE entries (
  entry_id    INTEGER PRIMARY KEY,
  transfer_id INTEGER NOT NULL REFERENCES transfers(transfer_id),
  account_id  INTEGER NOT NULL REFERENCES accounts(account_id),
  amount      INTEGER NOT NULL CHECK (amount <> 0)   -- + জমা, − খরচ
);
CREATE INDEX entries_by_account ON entries(account_id, entry_id);
CREATE TRIGGER entries_append_only BEFORE UPDATE ON entries
BEGIN SELECT RAISE(ABORT, 'ledger entries are append-only'); END;
INSERT INTO accounts VALUES (1, 'Cash-in pool', 'system', -100000),
 (2, 'Rahim', 'customer', 100000), (3, 'Karima', 'customer', 0), (4, 'Mudi Dokan', 'merchant', 0);
INSERT INTO transfers VALUES (1, 'agent-cashin-0001', 'cash_in', '2026-10-25 03:00:00');
INSERT INTO entries (transfer_id, account_id, amount) VALUES (1, 1, -100000), (1, 2, 100000);
SELECT transfer_id, SUM(amount) AS net FROM entries GROUP BY transfer_id;
SELECT a.account_id, a.owner, a.balance, COALESCE(SUM(e.amount), 0) AS ledger_sum
FROM accounts a LEFT JOIN entries e ON e.account_id = a.account_id
GROUP BY a.account_id;
UPDATE entries SET amount = 500000 WHERE entry_id = 2;

প্রথম যাচাই: 1 | 0 — একমাত্র transfer-এর যোগফল শূন্য। দ্বিতীয়: প্রতিটি account-এর balance আর ledger_sum সমান (−১,০০,০০০, ১,০০,০০০, ০, ০ পয়সা)। এই দুটি কোয়েরিই রাতের reconciliation-এর হৃদয়: কোনো সারি না মিললে সঙ্গে সঙ্গে alert. শেষ UPDATE থামে: ledger entries are append-only — ভুল হলে পুরোনো লাইন বদলানো হয় না, একটি বিপরীত (reversal) transfer লেখা হয়, ঠিক যেমন ব্যাংকের খাতায়।

কঠিন অংশ ১ — ব্যালান্স অন্ধভাবে বদলানো নয়। সবচেয়ে প্রচলিত ভুল: অ্যাপ ব্যালান্স পড়ে, নিজে হিসাব করে, তারপর নতুন সংখ্যাটি লিখে দেয়। দুটি অনুরোধ একসাথে এলে কী হয়, দেখুন:

  1. দুটি অনুরোধ৭০০ আর ৫০০ টাকা
  2. দুজনই পড়েbalance = 1000
  3. অন্ধ লেখাSET balance = …
  4. শর্তসহ লেখাbalance − x WHERE …
Rahim-এর account, টাকায়লাল = এই ধাপের পরিবর্তন

            
  1. দুটি অনুরোধ। একই account-এ প্রায় একসাথে দুটি খরচ, দুটি আলাদা অ্যাপ সার্ভারে। দুটির যোগফল ব্যালান্সের চেয়ে বেশি — অন্তত একটির ব্যর্থ হওয়া উচিত।
  2. দুজনই পড়ে। READ COMMITTED-এ দুটি SELECT-ই ১০০০ দেখে। কোনো lock নেই, কারণ শুধু পড়া হয়েছে। এখানেই ফাঁক তৈরি।
  3. A অন্ধভাবে লেখে। A নিজের হিসাবের ফল ৩০০ লিখে দেয় — সংখ্যাটি একটি “ধ্রুব”, আগের মানের সাথে এর কোনো সম্পর্ক ডেটাবেস জানে না।
  4. B অন্ধভাবে লেখে। B-এর পুরোনো মান ১০০০ থেকে হিসাব ৫০০, আর সেটি A-এর লেখার ওপর বসে যায়। এটিই lost update (পর্ব ৪৪, ৪৭): ১২০০ টাকা খরচ, ব্যালান্সে ৫০০। খাতা আর কলাম আর মেলে না।
  5. শর্তসহ লেখা। হিসাব ডেটাবেসের ভেতরে (balance - 700) আর শর্ত WHERE-এ। UPDATE সারিটি lock করে, তাই B অপেক্ষা করে, তারপর নতুন মান ৩০০ দেখে শর্ত মিথ্যা পায় — ০ সারি বদলায়। অ্যাপ “কয়টি সারি বদলেছে” দেখে সিদ্ধান্ত নেয়। CHECK (balance >= 0) শেষ পাহারাদার।

কঠিন অংশ ২ — retry আর idempotency key. মোবাইল নেটওয়ার্কে “অনুরোধ গেল, উত্তর এল না” খুব স্বাভাবিক। অ্যাপ আবার পাঠালে সার্ভার যেন বোঝে এটি একই অনুরোধ। তাই অ্যাপ প্রতিটি লেনদেনের জন্য একটি অনন্য key বানায়, আর ডেটাবেসে সেটি UNIQUE:

-- Rahim → Karima ২৫০ টাকা; অ্যাপের idempotency key 'app-7f3a-0001'
BEGIN;
INSERT INTO transfers VALUES (2, 'app-7f3a-0001', 'send_money', '2026-10-25 05:10:00');
INSERT INTO entries (transfer_id, account_id, amount) VALUES (2, 2, -25000), (2, 3, 25000);
UPDATE accounts SET balance = balance - 25000 WHERE account_id = 2;
UPDATE accounts SET balance = balance + 25000 WHERE account_id = 3;
COMMIT;
-- নেটওয়ার্ক কেটে যাওয়ায় অ্যাপ একই অনুরোধ আবার পাঠাল
INSERT INTO transfers VALUES (3, 'app-7f3a-0001', 'send_money', '2026-10-25 05:10:07')
  ON CONFLICT (idempotency_key) DO NOTHING;
SELECT transfer_id, created_at FROM transfers WHERE idempotency_key = 'app-7f3a-0001';
SELECT account_id, owner, balance FROM accounts WHERE account_id IN (2, 3);
-- Karima-র আছে ২৫০ টাকা, তিনি ৫০০ টাকা পাঠাতে চান
BEGIN;
UPDATE accounts SET balance = balance - 50000 WHERE account_id = 3;

প্রথম transaction পুরোটা commit হয়: transfer ১টি, entry ২টি, দুটি UPDATE-এ ১টি করে সারি। একই key-তে দ্বিতীয় INSERT “০টি সারি বদলেছে” — ON CONFLICT DO NOTHING. অ্যাপ তখন আগের transfer-টি খুঁজে (2 | 2026-10-25 05:10:00) সেই ফলই ফেরত দেয়; টাকা দ্বিতীয়বার কাটে না। ব্যালান্স: Rahim 75000, Karima 25000 পয়সা। শেষে Karima ৫০০ টাকা পাঠাতে চাইলে UPDATE থামে: CHECK constraint failed: kind = 'system' OR balance >= 0 — স্টেটমেন্টটি বাতিল হয়, আর অ্যাপ তখন পুরো transaction ROLLBACK করে। বাস্তবে আগে শর্তসহ UPDATE (stepper-এর ধাপ ৫) থামায়, CHECK শুধু শেষ নিরাপত্তা।

কীভাবে বড় হবে: লেনদেনে সবসময় দুটি account, তাই account ধরে shard করলে বহু লেনদেন দুই shard জুড়ে যাবে — তখন two-phase commit বা saga লাগে, দুটিই জটিল (পর্ব ৫৪)। তাই বহুদিন একটি শক্তিশালী primary-তে (vertical scaling) থাকা, entries-কে মাস ধরে range partition করা, আর লেনদেনের ইতিহাস replica থেকে দেখানো যুক্তিসংগত — তবে ব্যালান্স primary থেকে, কারণ replica পিছিয়ে থাকতে পারে। বড় দোকানের account একটি hot spot: প্রতি সেকেন্ডে শত শত জমা একটি সারিতে। সমাধান: দোকানের জন্য কয়েকটি উপ-account (জমা এলোমেলোভাবে ভাগ হয়, ব্যালান্স = যোগফল), বা জমাগুলো কয়েক সেকেন্ডে একসাথে একটি লেনদেনে।

সমস্যা ৪ · ট্রেনের টিকিট বুকিং — একটি আসন, একজন যাত্রী

প্রয়োজন: ট্রেন আর তারিখ ধরে খালি আসন দেখা; একটি আসন বেছে ১০ মিনিট ধরে রাখা (hold), এর মধ্যে পেমেন্ট; পেমেন্ট সফল হলে বিক্রি; সময় পেরোলে আসন আবার খালি; আর কোনো অবস্থাতেই একটি আসন দুজনকে নয় (double booking). মাপ (ধরে নেওয়া): দিনে ৩০০টি ট্রেন-যাত্রা × ৮০০ আসন = ২,৪০,০০০টি আসন-সারি; ১০ দিন আগে বিক্রি শুরু হলে সক্রিয় ইনভেন্টরি প্রায় ২৪ লাখ সারি — ছোট। কিন্তু ঈদের আগে বিক্রি খোলার প্রথম ১০ মিনিটে ১০ লাখ অনুরোধ এলে ১০,০০,০০০ ÷ ৬০০ ≈ সেকেন্ডে ১,৬৭০টি — আর সবাই চান মাত্র কয়েকটি ট্রেনের একই আসনগুলো।

ER: train 1 — N trip (একটি নির্দিষ্ট দিনের যাত্রা) 1 — N trip_seat. প্রতিটি যাত্রার প্রতিটি আসন একটি সারি — এই “ইনভেন্টরি” মডেলে আসনের অবস্থা একটি জায়গায় থাকে, আর কাড়াকাড়ি সেই সারিতেই মেটে:

-- ট্রেনের টিকিট: প্রতিটি যাত্রার প্রতিটি আসন একটি সারি (inventory)
CREATE TABLE trips (
  trip_id    INTEGER PRIMARY KEY,
  train_no   TEXT NOT NULL,
  journey_on TEXT NOT NULL,              -- ঢাকার স্থানীয় তারিখ
  UNIQUE (train_no, journey_on)
);
CREATE TABLE trip_seats (
  trip_id    INTEGER NOT NULL REFERENCES trips(trip_id),
  seat_no    TEXT NOT NULL,              -- যেমন 'KA-3'
  status     TEXT NOT NULL DEFAULT 'free' CHECK (status IN ('free', 'held', 'sold')),
  holder_id  INTEGER,
  hold_until TEXT,                       -- UTC
  PRIMARY KEY (trip_id, seat_no),
  CHECK ((status = 'free') = (holder_id IS NULL)),
  CHECK ((status = 'held') = (hold_until IS NOT NULL))
);
INSERT INTO trips VALUES (1, 'EXP-701', '2026-11-05'), (2, 'EXP-702', '2026-11-05');
WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 6)
INSERT INTO trip_seats (trip_id, seat_no) SELECT t.trip_id, 'KA-' || n.i FROM trips t, n;
UPDATE trip_seats SET status = 'sold', holder_id = 400 WHERE trip_id = 1 AND seat_no IN ('KA-1', 'KA-2');
SELECT t.train_no, t.journey_on,
  SUM(s.status = 'free') AS free_seats, SUM(s.status = 'sold') AS sold
FROM trips t JOIN trip_seats s ON s.trip_id = t.trip_id
GROUP BY t.trip_id ORDER BY t.trip_id;

recursive CTE দুটি যাত্রার প্রতিটিতে ৬টি করে আসন বানায় (২ × ৬ = ১২ সারি, CROSS JOIN), তারপর EXP-701-এর KA-1 আর KA-2 বিক্রি হলো। ফল: EXP-701 | 2026-11-05 | 4 | 2 আর EXP-702 | 2026-11-05 | 6 | 0. SQLite-এ তুলনার ফল ১ বা ০, তাই SUM(status = 'free') গোনে; PostgreSQL-এ COUNT(*) FILTER (WHERE status = 'free') লিখবেন (পর্ব ১৮)। দুটি CHECK অবস্থা আর ধারকের সম্পর্ক বাঁধে: খালি আসনের ধারক নেই, hold-এর মেয়াদ শুধু held অবস্থায়।

কঠিন অংশ — একসাথে দুজন। সমাধান একটি শর্তসহ UPDATE, যাকে compare-and-set বলে: “আসনটি খালি থাকলে (বা পুরোনো hold-এর মেয়াদ শেষ হলে) আমার নামে লেখো।” দেখা আর লেখা একটিই statement, তাই মাঝে ফাঁক নেই:

-- 06:00 UTC: যাত্রী 501 আসন KA-3 ধরলেন (১০ মিনিটের hold)
UPDATE trip_seats SET status = 'held', holder_id = 501, hold_until = '2026-10-25 06:10:00'
WHERE trip_id = 1 AND seat_no = 'KA-3'
  AND (status = 'free' OR (status = 'held' AND hold_until < '2026-10-25 06:00:00'));
-- একই মুহূর্তে যাত্রী 502 একই আসন চাইলেন
UPDATE trip_seats SET status = 'held', holder_id = 502, hold_until = '2026-10-25 06:10:00'
WHERE trip_id = 1 AND seat_no = 'KA-3'
  AND (status = 'free' OR (status = 'held' AND hold_until < '2026-10-25 06:00:00'));
-- 06:15: 501 টাকা দেননি, hold-এর মেয়াদ শেষ — এবার 502 পারেন
UPDATE trip_seats SET status = 'held', holder_id = 502, hold_until = '2026-10-25 06:25:00'
WHERE trip_id = 1 AND seat_no = 'KA-3'
  AND (status = 'free' OR (status = 'held' AND hold_until < '2026-10-25 06:15:00'));
-- 06:18: পেমেন্ট সফল — শুধু নিজের hold থাকলেই বিক্রি
UPDATE trip_seats SET status = 'sold', hold_until = NULL
WHERE trip_id = 1 AND seat_no = 'KA-3' AND status = 'held' AND holder_id = 502
  AND hold_until >= '2026-10-25 06:18:00';
-- 06:20: দেরিতে আসা 501-এর পেমেন্ট — আসন আর তাঁর নয়
UPDATE trip_seats SET status = 'sold', hold_until = NULL
WHERE trip_id = 1 AND seat_no = 'KA-3' AND status = 'held' AND holder_id = 501
  AND hold_until >= '2026-10-25 06:20:00';
SELECT seat_no, status, holder_id, hold_until FROM trip_seats WHERE trip_id = 1;

পাঁচটি UPDATE-এর ফল: ১, ০, ১, ১, ০ সারি। ৫০১ আসনটি পান; ৫০২-এর একই statement ০ সারি — অ্যাপ বলে “আসনটি এইমাত্র অন্য কেউ নিয়েছেন”. ১৫ মিনিট পরে ৫০১-এর hold-এর মেয়াদ শেষ, তাই ৫০২ পান, আর ০৬:১৮-এ পেমেন্টে বিক্রি। ০৬:২০-এ ৫০১-এর দেরিতে আসা পেমেন্ট ০ সারি বদলায় — আসন তাঁর নয়, তাই অ্যাপ টাকা ফেরত পাঠাবে। শেষ SELECT: KA-3 | sold | 502 | NULL. মেয়াদ শেষের জন্য আলাদা “পরিষ্কার করার” job লাগে না — শর্তেই hold_until < now আছে।

PostgreSQL-এ দুটি উন্নত কৌশল ইন্টারভিউতে বলার মতো (চালানো যাবে না, SQLite-এ এগুলো নেই):

-- ১. "যেকোনো একটি খালি আসন দাও" — অন্যদের লক করা সারিতে অপেক্ষা না করে
WITH pick AS (
  SELECT trip_id, seat_no FROM trip_seats
  WHERE trip_id = $1 AND status = 'free'
  ORDER BY seat_no LIMIT 1
  FOR UPDATE SKIP LOCKED)
UPDATE trip_seats s
SET status = 'held', holder_id = $2, hold_until = now() + interval '10 minutes'
FROM pick WHERE s.trip_id = pick.trip_id AND s.seat_no = pick.seat_no
RETURNING s.seat_no;

-- ২. অংশের টিকিট: একই আসন ঢাকা→ভৈরব আর ভৈরব→চট্টগ্রাম, কিন্তু ছেদ নিষেধ
CREATE EXTENSION IF NOT EXISTS btree_gist;
ALTER TABLE tickets ADD CONSTRAINT no_overlap EXCLUDE USING gist
  (trip_id WITH =, seat_no WITH =, int4range(from_stop, to_stop) WITH &&)
  WHERE (status <> 'cancelled');

প্রথমটি SELECT … FOR UPDATE SKIP LOCKED — অন্য কেউ যে সারি lock করে রেখেছে, তা বাদ দিয়ে পরেরটি নেয়, তাই হাজার অনুরোধ এক সারিতে লাইন দেয় না (পর্ব ৪৬)। দ্বিতীয়টি exclusion constraint: স্টেশনের ক্রমিক নম্বরের অর্ধ-খোলা পরিসর [from, to) দুটি সক্রিয় টিকিটে ছেদ করলে INSERT ব্যর্থ — একসাথে চলা transaction-এও নিরাপদ।

কীভাবে বড় হবে: trip_id ধরে shard — একটি যাত্রার সব আসন এক shard-এ, তাই বুকিং কখনো দুই shard জুড়ে নয়। খোঁজার পাতায় “খালি আসন ১২৪টি” cache থেকে, কয়েক সেকেন্ড পুরোনো হলেও চলে (eventual consistency) — কিন্তু hold সবসময় ডেটাবেসে। বিক্রি খোলার মুহূর্তের জন্য একটি ভার্চুয়াল অপেক্ষার সারি (waiting room): একসাথে নির্দিষ্টসংখ্যক মানুষকে বুকিংয়ের পাতায় ঢুকতে দেওয়া, যাতে ডেটাবেস কখনো তার সীমার বাইরে চাপ না পায়।

ফিরে দেখুন: ACID আর lost update — পর্ব ৪৪; lock আর SKIP LOCKED — পর্ব ৪৬; isolation — পর্ব ৪৭; ক্যাপস্টোনের double booking — পর্ব ৬২; distributed transaction — পর্ব ৫৪.

৫ · সমস্যা ৫–৬: ই-কমার্সের cart-অর্ডার আর রাইড-শেয়ারিং

সমস্যা ৫ · ই-কমার্স — cart, অর্ডার আর মজুত

প্রয়োজন: cart থাকবে লগইন করা যেকোনো ডিভাইসে; checkout-এ cart থেকে অর্ডার — সব একসাথে বা কিছুই না; কেনার দিনের দাম অর্ডারে স্থায়ী থাকবে; মজুতের চেয়ে বেশি বিক্রি (oversell) নয়; অর্ডারের অবস্থা pending → shipped → delivered বা cancelled; বাতিলে মজুত ফেরত। মাপ (ধরে নেওয়া): দিনে ৫০,০০০ অর্ডার, গড়ে ৩টি পণ্য → দিনে দেড় লাখ order_items, checkout গড়ে সেকেন্ডে ১-এরও কম। কিন্তু ফ্ল্যাশ সেলে একটি ফোনের ১০,০০০ পিস, আর ২ লাখ মানুষ এক মিনিটে চাপলে ২,০০,০০০ ÷ ৬০ ≈ সেকেন্ডে ৩,৩৩০টি checkout — সবগুলো একটি পণ্যের একটি সারিতে।

ER: কোর্সের দোকান-ডেটাবেস (db-shop) ব্যবহার করব — customers, products(product_id, name, price, stock), orders(order_id, customer_id, order_date, status) আর order_items(order_id, product_id, quantity, unit_price), যেখানে stock-এ CHECK (stock >= 0) আছে। যোগ করি customer 1 — 1 cart 1 — N cart_items. একটি স্বীকারোক্তি: নমুনা ডেটাবেসে price সরলতার জন্য REAL; আসল নকশায় পয়সায় INTEGER বা NUMERIC(12,2) (পর্ব ১৩)।

-- ই-কমার্স: db-shop-এর ওপর cart
CREATE TABLE carts (
  cart_id     INTEGER PRIMARY KEY,
  customer_id INTEGER NOT NULL UNIQUE REFERENCES customers(customer_id),
  updated_at  TEXT NOT NULL
);
CREATE TABLE cart_items (
  cart_id    INTEGER NOT NULL REFERENCES carts(cart_id) ON DELETE CASCADE,
  product_id INTEGER NOT NULL REFERENCES products(product_id),
  quantity   INTEGER NOT NULL CHECK (quantity BETWEEN 1 AND 10),
  PRIMARY KEY (cart_id, product_id)
);
INSERT INTO carts VALUES (1, 3, '2026-10-25 05:00:00'), (2, 4, '2026-10-25 05:05:00');
INSERT INTO cart_items VALUES (1, 102, 2), (1, 104, 1), (2, 103, 1), (2, 108, 2);
-- cart দেখানো: আজকের দাম আর মজুত আছে কি না
SELECT c.customer_id, p.name, ci.quantity, p.price,
  ci.quantity * p.price AS line_total,
  CASE WHEN p.stock >= ci.quantity THEN 'ok' ELSE 'out of stock' END AS availability
FROM carts c JOIN cart_items ci ON ci.cart_id = c.cart_id
JOIN products p ON p.product_id = ci.product_id
ORDER BY c.cart_id, p.product_id;

দুটি cart, চারটি লাইন। Sabbir Hasan (customer 3): Headphone 2 × 1200 = 2400 আর DSA Book 650, দুটিই ok. Nusrat Jahan (customer 4): Power Bank out of stock — মজুত ০, আর Rice 5kg 840. লক্ষ করুন cart-এ দাম রাখা হয়নি: cart দেখায় আজকের দাম, কারণ এটি এখনো কেনা নয়। carts.customer_id UNIQUE বলে একজন গ্রাহকের একটিই সক্রিয় cart — তাই যেকোনো ডিভাইসে একই cart.

কঠিন অংশ — checkout-এ oversell নয়, দাম স্থির। পুরো checkout একটি transaction: অর্ডার, আজকের দামের snapshot-সহ order_items, মজুত কমানো, cart খালি। মজুত কমানো হয় ডেটাবেসের ভেতরে (stock - quantity), আর CHECK ঋণাত্মক মজুত আটকায়:

-- Sabbir Hasan (customer 3, cart 1)-এর checkout — সব বা কিছুই না
BEGIN;
INSERT INTO orders VALUES (1011, 3, '2026-10-25', 'pending');
INSERT INTO order_items (order_id, product_id, quantity, unit_price)
SELECT 1011, ci.product_id, ci.quantity, p.price            -- আজকের দামের snapshot
FROM cart_items ci JOIN products p ON p.product_id = ci.product_id WHERE ci.cart_id = 1;
UPDATE products SET stock = stock - (SELECT quantity FROM cart_items
  WHERE cart_id = 1 AND product_id = products.product_id)
WHERE product_id IN (SELECT product_id FROM cart_items WHERE cart_id = 1);
DELETE FROM cart_items WHERE cart_id = 1;
COMMIT;
SELECT product_id, name, stock FROM products WHERE product_id IN (102, 104);
SELECT * FROM order_items WHERE order_id = 1011;
-- Nusrat Jahan-এর cart-এ Power Bank (মজুত ০) — একই checkout
BEGIN;
UPDATE products SET stock = stock - 1 WHERE product_id = 103;

Sabbir-এর checkout পুরোটা commit: অর্ডার ১০১১, order_items-এ ২টি সারি (102 | 2 | 1200 আর 104 | 1 | 650), products-এ ২টি সারি বদলায় — Headphone 40 → 38, DSA Book 25 → 24 — আর cart-এর ২টি লাইন মুছে যায়। Nusrat-এর Power Bank-এ UPDATE থামে: CHECK constraint failed: stock >= 0, তাই তাঁর transaction rollback — অর্ডারের অর্ধেকও লেখা হবে না। unit_price কেন আলাদা কলাম, তার প্রমাণ নমুনা ডেটাতেই আছে: অর্ডার ১০০৭-এ Headphone বিক্রি হয়েছিল ১১৫০ টাকায়, আজ দাম ১২০০ — পুরোনো অর্ডারের হিসাব বদলায় না (historical snapshot, পর্ব ৩৮)।

কীভাবে বড় হবে: (১) cart-এ রাখার সময় মজুত আটকাবেন না — বেশিরভাগ cart কখনো কেনা হয় না; আটকান checkout-এ, অল্প সময়ের reservation দিয়ে। (২) ফ্ল্যাশ সেলের hot row: মজুতকে ১০টি “বালতি” সারিতে ভাগ (product_id, bucket, stock) — প্রতিটি অনুরোধ এলোমেলো একটি বালতি থেকে নেয়, lock-এর প্রতিযোগিতা দশ ভাগের এক ভাগ; বা অনুরোধগুলো একটি queue-তে সাজিয়ে একে একে। (৩) পণ্যের তালিকা আর খোঁজ cache আর search engine-এ (পর্ব ৫৯), অর্ডারের ইতিহাসে orders(customer_id, order_date) index, আর অর্ডার বাড়লে customer_id ধরে shard — একজন গ্রাহকের সব অর্ডার এক জায়গায়। (৪) “অর্ডার হলো” ইমেইল বা SMS checkout-এর transaction-এ নয় — একই transaction-এ একটি outbox সারি, পরে আলাদা কর্মী পাঠায় (outbox pattern).

সমস্যা ৬ · রাইড-শেয়ারিং বা ডেলিভারি অ্যাপ

প্রয়োজন: যাত্রী অনুরোধ করেন; কাছের খালি চালক খোঁজা; একটি যাত্রা ঠিক একজন চালক নেবেন; একজন চালকের একসাথে একটিই সক্রিয় যাত্রা; ভাড়া; যাত্রার ইতিহাস। মাপ (ধরে নেওয়া): ৫০,০০০ চালক একসাথে অনলাইন, প্রত্যেকে প্রতি ৪ সেকেন্ডে অবস্থান পাঠান → ৫০,০০০ ÷ ৪ = সেকেন্ডে ১২,৫০০টি লেখা। অথচ যাত্রা দিনে ৫ লাখ → সেকেন্ডে মাত্র ৬টির মতো। অবস্থানের পুরো ইতিহাস রাখলে: দিনে ৮ ঘণ্টা = ২৮,৮০০ সেকেন্ড ÷ ৪ = ৭,২০০টি ping × ৫০,০০০ = দিনে ৩৬ কোটি সারি। হিসাবটিই বলে দেয় দুটি ডেটা দুই জায়গায় যাবে।

ER: driver 1 — 1 driver_location (শুধু শেষ অবস্থান, প্রতিবার UPDATE), driver 1 — N trip, rider 1 — N trip. Trip-এর অবস্থাগুলো একটি state machine: requested → accepted → started → completed, বা cancelled. যাত্রী দাঁড়িয়ে আছেন মহাখালীতে:

CREATE TABLE drivers (
  driver_id INTEGER PRIMARY KEY, name TEXT NOT NULL,
  vehicle   TEXT NOT NULL CHECK (vehicle IN ('bike', 'car', 'cng'))
);
CREATE TABLE driver_locations (           -- শুধু শেষ অবস্থান; পুরো ইতিহাস অন্য জায়গায়
  driver_id  INTEGER PRIMARY KEY REFERENCES drivers(driver_id),
  lat REAL NOT NULL, lng REAL NOT NULL,
  is_free    INTEGER NOT NULL CHECK (is_free IN (0, 1)), updated_at TEXT NOT NULL
);
CREATE INDEX free_by_lat ON driver_locations(is_free, lat);
CREATE TABLE trips (
  trip_id    INTEGER PRIMARY KEY, rider_id INTEGER NOT NULL,
  driver_id  INTEGER REFERENCES drivers(driver_id), fare_paisa INTEGER CHECK (fare_paisa >= 0),
  status     TEXT NOT NULL CHECK (status IN ('requested', 'accepted', 'started', 'completed', 'cancelled')),
  CHECK (status IN ('requested', 'cancelled') OR driver_id IS NOT NULL)
);
CREATE UNIQUE INDEX one_active_trip ON trips(driver_id) WHERE status IN ('accepted', 'started');
INSERT INTO drivers VALUES (1, 'Jamal', 'bike'), (2, 'Rubel', 'car'), (3, 'Sumon', 'bike'), (4, 'Liton', 'cng'), (5, 'Shirin', 'bike');
INSERT INTO driver_locations VALUES
 (1, 23.7937, 90.4066, 1, '2026-10-25 05:59:58'), (2, 23.7800, 90.4070, 1, '2026-10-25 05:59:55'),
 (3, 23.7639, 90.3925, 1, '2026-10-25 05:59:57'), (4, 23.7790, 90.4040, 1, '2026-10-25 05:59:50'),
 (5, 23.7461, 90.3742, 1, '2026-10-25 05:59:59');
-- যাত্রী মহাখালীতে; ১° অক্ষাংশ ≈ ১১১.৩ কিমি, এখানে ১° দ্রাঘিমা ≈ ১০১.৯ কিমি
SELECT d.name, l.lat, l.lng,
  ROUND(abs(l.lat - 23.7780) * 111.3 + abs(l.lng - 90.4050) * 101.9, 2) AS approx_km
FROM driver_locations l JOIN drivers d ON d.driver_id = l.driver_id
WHERE l.is_free = 1 AND d.vehicle = 'bike'
  AND l.lat BETWEEN 23.7780 - 0.027 AND 23.7780 + 0.027      -- ±৩ কিমি
  AND l.lng BETWEEN 90.4050 - 0.029 AND 90.4050 + 0.029
ORDER BY approx_km LIMIT 3;

ফল দুটি সারি: Jamal | 23.7937 | 90.4066 | 1.91 আর Sumon | 23.7639 | 90.3925 | 2.84. Rubel (car) আর Liton (cng) বাহনের কারণে বাদ; Shirin বাইক চালান, কিন্তু তিনি ধানমন্ডিতে — ±৩ কিমির বাক্সের বাইরে। প্রথমে একটি “বাক্স” (bounding box) — শুধু range শর্ত, যা index ব্যবহার করতে পারে — তারপর বাক্সের ভেতরের অল্প কয়েকজনের দূরত্ব। approx_km রাস্তার দূরত্বের মোটা অনুমান: অক্ষাংশ আর দ্রাঘিমার পার্থক্যকে কিমিতে বদলে যোগ (Manhattan distance); শহরের রাস্তায় সরলরেখার চেয়ে এটি প্রায়ই বাস্তবের কাছাকাছি।

আসল সিস্টেমে স্থানের জন্য বিশেষ index: PostgreSQL-এর PostGIS-এ geography কলাম, GiST index আর ST_DWithin; বা পৃথিবীকে ছোট ছোট ঘরে ভাগ করা (geohash, বা Uber-এর প্রকাশ করা ষড়ভুজের H3) আর ঘরের id দিয়ে খোঁজা; চলমান অবস্থানের জন্য প্রায়ই Redis-এর GEO কমান্ড (Redis-এর পরিচয়: পর্ব ৫৮)। এবার কঠিন অংশ — দুজন চালক একসাথে একই যাত্রা নিতে চাইলে:

INSERT INTO trips (trip_id, rider_id, status) VALUES (9001, 77, 'requested'), (9002, 78, 'requested');
-- Jamal 9001 নিলেন — শুধু 'requested' অবস্থায় থাকলেই
UPDATE trips SET driver_id = 1, status = 'accepted' WHERE trip_id = 9001 AND status = 'requested';
-- একই সেকেন্ডে Sumon-ও 9001 নিতে চাইলেন
UPDATE trips SET driver_id = 3, status = 'accepted' WHERE trip_id = 9001 AND status = 'requested';
UPDATE driver_locations SET is_free = 0 WHERE driver_id = 1;
SELECT trip_id, driver_id, status FROM trips;
-- অ্যাপের বাগে Jamal-কে 9002-ও দেওয়া হলো
UPDATE trips SET driver_id = 1, status = 'accepted' WHERE trip_id = 9002 AND status = 'requested';

দুটি অনুরোধ তৈরি হলো (২ সারি)। Jamal-এর UPDATE ১টি সারি বদলায়; Sumon-এর একই UPDATE ০টি — status = 'requested' শর্ত আর সত্য নয়, তাই তাঁর অ্যাপ বলে “যাত্রাটি অন্য কেউ নিয়েছেন”. ফল: 9001 | 1 | accepted, 9002 | NULL | requested. শেষে অ্যাপের একটি বাগ Jamal-কে দ্বিতীয় যাত্রা দিতে চাইলে partial unique index থামায়: UNIQUE constraint failed: trips.driver_id — সক্রিয় (accepted বা started) যাত্রায় একজন চালক একবারই থাকতে পারেন, পুরোনো completed যাত্রা যত খুশি।

কীভাবে বড় হবে: শহর বা অঞ্চল ধরে partition — ঢাকার যাত্রী সিলেটের চালক খোঁজেন না, তাই প্রায় সব কোয়েরি এক অঞ্চলে থাকে; খুব ব্যস্ত শহর আরও ছোট ঘরে ভাগ। সেকেন্ডে ১২,৫০০টি অবস্থান memory-তে (Redis), শুধু শেষ মান; ইতিহাস দরকার হলে ব্যাচে time-series ডেটাবেস বা ফাইলে (পর্ব ৫৯)। যাত্রা আর ভাড়া — টাকার সাথে যুক্ত — relational ডেটাবেসে, transaction-সহ।

ফিরে দেখুন: transaction আর CHECK — পর্ব ৪৪, ১৩; partial index — পর্ব ৪১; denormalization আর snapshot — পর্ব ৩৮; outbox — পর্ব ৩৮, ৫৪; Redis — পর্ব ৫৮; time-series — পর্ব ৫৯.

৬ · সমস্যা ৭–৮: সোশ্যাল ফিড আর চ্যাট

সমস্যা ৭ · সোশ্যাল মিডিয়ার হোম ফিড

প্রয়োজন: অনুসরণ (follow) আর বাতিল; পোস্ট; হোম ফিডে অনুসরণ করা মানুষের পোস্ট, নতুনগুলো আগে, পাতায় পাতায়; লাইকের সংখ্যা কয়েক সেকেন্ড পিছিয়ে থাকলে চলে। মাপ (ধরে নেওয়া): দৈনিক ১ কোটি ব্যবহারকারী, প্রত্যেকে দিনে ১০ বার ফিড খোলেন → ১০ কোটি ÷ ৮৬,৪০০ ≈ সেকেন্ডে ১,১৬০টি ফিড-পড়া; দিনে ১০ লাখ পোস্ট, গড়ে ২০০ অনুসারী। ER: user N — N user (follows, একটি self-referencing junction table), user 1 — N post, আর প্রতিটি ব্যবহারকারীর ফিডের “ডাকবাক্স” feed_items.

মূল সিদ্ধান্ত: ফিড কখন বানানো হবে? পড়ার মুহূর্তে (fan-out on read), নাকি পোস্টের মুহূর্তে প্রতিটি অনুসারীর ডাকবাক্সে (fan-out on write)? নিচের stepper দুটির হিসাব দেখায়:

প্রতি পোস্ট বা প্রতি ফিড-পড়ায় কত কাজলাল = এই ধাপের হিসাব

            
  1. গ্রাফ। একই ফিডের দুটি উৎস — অল্প অনুসারীর একজন আর বিপুল অনুসারীর একজন। দুটির জন্য একই কৌশল সেরা নয়, সেটিই এই stepper-এর গল্প।
  2. Pull. লেখা সস্তা, পড়া দামি। পড়া যেখানে লেখার চেয়ে অনেক বেশি (এখানে প্রতি পোস্টে বহু পড়া), সেখানে প্রতিটি পড়ায় বহু index খোঁজা ভালো সওদা নয়।
  3. Push. পোস্টের মুহূর্তে কাজ করে রাখা, যাতে পড়া একটি range scan হয়। এটি এক ধরনের denormalization: একই তথ্য (কে কী পোস্ট করেছেন) বহু জায়গায় কপি।
  4. তারকা। push-এর খরচ অনুসারীর সংখ্যার সমানুপাতিক। ২৫ লাখ সারি লিখতে কয়েক মিনিট, আর প্রতিটি পোস্টে বিপুল ডিস্ক। গড় সংখ্যা (২০০) এই লেজটি লুকিয়ে রাখে — তাই হিসাবে সবসময় চরম মানও দেখুন।
  5. Hybrid. বেশিরভাগের জন্য push, অল্প কয়েকজন তারকার জন্য pull, পড়ার সময় দুটি মিলিয়ে। Twitter-এর প্রকৌশলীরা প্রকাশ্য আলোচনায় এ ধরনের মিশ্র পদ্ধতির কথা বলেছেন (নিচের ভাবনার প্রশ্ন ৩ দেখুন)।
CREATE TABLE users (user_id INTEGER PRIMARY KEY, handle TEXT NOT NULL UNIQUE,
  followers_count INTEGER NOT NULL DEFAULT 0);    -- ক্যাশ করা গণনা
CREATE TABLE follows (
  follower_id INTEGER NOT NULL REFERENCES users(user_id),
  followee_id INTEGER NOT NULL REFERENCES users(user_id),
  PRIMARY KEY (follower_id, followee_id), CHECK (follower_id <> followee_id)
);
CREATE INDEX follows_by_followee ON follows(followee_id, follower_id);
CREATE TABLE posts (
  post_id   INTEGER PRIMARY KEY,                 -- সময়-ক্রমের id: বড় id = নতুন
  author_id INTEGER NOT NULL REFERENCES users(user_id),
  body      TEXT NOT NULL CHECK (length(body) <= 500)
);
CREATE INDEX posts_by_author ON posts(author_id, post_id);
CREATE TABLE feed_items (                       -- প্রতিটি ব্যবহারকারীর ফিডের "ডাকবাক্স"
  user_id INTEGER NOT NULL REFERENCES users(user_id),
  post_id INTEGER NOT NULL REFERENCES posts(post_id), PRIMARY KEY (user_id, post_id));
INSERT INTO users VALUES (1, 'rahim', 0), (2, 'karima', 2), (3, 'sabbir', 0), (4, 'cricket_star', 2500000);
INSERT INTO follows VALUES (1, 2), (3, 2), (1, 4), (3, 4), (2, 4);
INSERT INTO posts VALUES (101, 2, 'মিরপুরে আজ খুব জ্যাম'), (102, 4, 'আজ ম্যাচ, সবাই দোয়া করবেন'),
 (103, 2, 'নতুন বই পড়া শুরু'), (104, 3, 'প্রথম পোস্ট');
INSERT INTO feed_items VALUES (1, 101), (3, 101), (1, 103), (3, 103);   -- karima-র পোস্ট আগেই ছড়ানো
SELECT p.post_id, u.handle, p.body            -- fan-out on read: Rahim (1)-এর ফিড
FROM posts p JOIN users u ON u.user_id = p.author_id
WHERE p.author_id IN (SELECT followee_id FROM follows WHERE follower_id = 1)
ORDER BY p.post_id DESC LIMIT 2;
-- পরের পাতা (keyset): শেষ দেখা post_id 102-এর চেয়ে পুরোনো
SELECT p.post_id, p.body FROM posts p WHERE p.post_id < 102
  AND p.author_id IN (SELECT followee_id FROM follows WHERE follower_id = 1)
ORDER BY p.post_id DESC LIMIT 2;

Rahim অনুসরণ করেন karima আর cricket_star-কে। প্রথম পাতা (LIMIT 2): 103 | karima আর 102 | cricket_star. পরের পাতায় keyset pagination: “শেষ দেখা id ১০২-এর চেয়ে ছোট” — ফল 101 | মিরপুরে আজ খুব জ্যাম. OFFSET-এর মতো আগের সারিগুলো গুনে বাদ দিতে হয় না, আর মাঝে নতুন পোস্ট এলে পাতা সরে না। post_id সময়-ক্রমের (বড় id = নতুন), তাই আলাদা সময়ের কলাম ছাড়াই posts_by_author(author_id, post_id) index দুটি কাজই করে।

-- karima নতুন পোস্ট দিলেন: fan-out on write — প্রতিটি অনুসারীর ডাকবাক্সে একটি সারি
INSERT INTO posts VALUES (105, 2, 'সন্ধ্যায় বৃষ্টি হবে?');
INSERT INTO feed_items (user_id, post_id)
SELECT follower_id, 105 FROM follows WHERE followee_id = 2;
EXPLAIN QUERY PLAN SELECT follower_id FROM follows WHERE followee_id = 2;
-- Sabbir (3)-এর ফিড: ডাকবাক্স + অনুসরণ করা তারকার পোস্ট (hybrid)
SELECT post_id FROM feed_items WHERE user_id = 3
UNION
SELECT p.post_id FROM posts p
JOIN follows f ON f.followee_id = p.author_id AND f.follower_id = 3
JOIN users u ON u.user_id = p.author_id AND u.followers_count >= 1000000
ORDER BY post_id DESC LIMIT 20;

পোস্ট ১০৫ লেখা হলো, আর fan-out INSERT ২টি সারি দিল — karima-র অনুসারী Rahim আর Sabbir. Plan: SEARCH follows USING COVERING INDEX follows_by_followee (followee_id=?). এটিই একটি ক্লাসিক ফাঁদ: primary key (follower_id, followee_id) “আমি কাকে অনুসরণ করি” প্রশ্নের জন্য, কিন্তু fan-out-এর প্রশ্ন উল্টো — “কে আমাকে অনুসরণ করে”. দ্বিতীয় index ছাড়া প্রতিটি পোস্টে পুরো follows টেবিল পড়া হতো। Hybrid ফিডে Sabbir পান 105, 103, 102, 101 — ডাকবাক্সের তিনটি আর তারকার ১০২, যা কখনো ডাকবাক্সে লেখা হয়নি। UNION duplicate মুছে দেয়।

কীভাবে বড় হবে: feed_items-এর প্রতিটি ব্যবহারকারীর শুধু শেষ ৫০০–১,০০০টি রাখুন (পুরোনো ফিড pull দিয়ে); ডাকবাক্স প্রায়ই memory-তে (Redis-এর list বা sorted set) বা wide-column store-এ, user_id partition key. posts author_id ধরে shard. লাইকের সংখ্যা followers_count-এর মতো ক্যাশ করা গণনা — প্রতিটি লাইকে একটি সারি UPDATE করলে জনপ্রিয় পোস্ট hot row হয়; তাই ভাগ করা counter বা কয়েক সেকেন্ডে একবার যোগ।

সমস্যা ৮ · চ্যাট বা মেসেজিং অ্যাপ

প্রয়োজন: একজনের সাথে একজন আর গ্রুপ চ্যাট; একটি conversation-এর ভেতরে বার্তার স্থির ক্রম; দুর্বল নেটওয়ার্কে আবার পাঠালেও duplicate নয়; প্রতিটি চ্যাটে না-পড়া বার্তার সংখ্যা; “Seen by” (read receipt); পুরোনো বার্তায় পেছনে যাওয়া। মাপ (ধরে নেওয়া): দৈনিক ৫০ লাখ ব্যবহারকারী × ৪০টি বার্তা = দিনে ২০ কোটি ≈ সেকেন্ডে ২,৩১৫টি; ২০০ বাইট ধরে দিনে ৪০ GB, বছরে প্রায় ১৪.৬ TB. পড়া প্রায় সবসময় “এই conversation-এর শেষ ৫০টি”.

ER: conversation 1 — N member (user-এর সাথে associative), conversation 1 — N message. দুটি নকশার সিদ্ধান্ত: (১) message-এর key (conv_id, seq) — প্রতিটি conversation-এর নিজের ক্রমিক নম্বর, যাতে ক্রম পরিষ্কার আর “seq ৪০-এর আগের ৫০টি” একটি range; (২) read receipt প্রতিটি বার্তা × প্রতিটি সদস্যের সারি নয় — ১০০ জনের গ্রুপে প্রতিটি বার্তায় ১০০ সারি হতো — বরং প্রতিটি সদস্যের একটি পয়েন্টার last_read_seq. composite foreign key (conv_id, sender_id) → members নিশ্চিত করে যে প্রেরক সেই চ্যাটের সদস্য।

CREATE TABLE conversations (conv_id INTEGER PRIMARY KEY, title TEXT,
  kind TEXT NOT NULL CHECK (kind IN ('direct', 'group')));
CREATE TABLE members (
  conv_id INTEGER NOT NULL REFERENCES conversations(conv_id), user_id INTEGER NOT NULL,
  last_read_seq INTEGER NOT NULL DEFAULT 0,      -- কোন বার্তা পর্যন্ত পড়েছেন
  PRIMARY KEY (conv_id, user_id));
CREATE INDEX members_by_user ON members(user_id);
CREATE TABLE messages (
  conv_id       INTEGER NOT NULL, seq INTEGER NOT NULL,   -- seq: প্রতিটি চ্যাটে ১, ২, ৩ …
  sender_id     INTEGER NOT NULL,
  client_msg_id TEXT NOT NULL,                   -- ফোনে বানানো id
  body          TEXT NOT NULL, sent_at TEXT NOT NULL,
  PRIMARY KEY (conv_id, seq), UNIQUE (sender_id, client_msg_id),
  FOREIGN KEY (conv_id, sender_id) REFERENCES members(conv_id, user_id)
);
INSERT INTO conversations VALUES (1, NULL, 'direct'), (2, 'Family', 'group');
INSERT INTO members VALUES (1, 1, 2), (1, 2, 1), (2, 1, 1), (2, 2, 3), (2, 3, 3);
INSERT INTO messages VALUES
 (1, 1, 1, 'c1-001', 'কাল দেখা হবে?', '2026-10-25 04:00:00'),
 (1, 2, 2, 'c2-001', 'হ্যাঁ, বিকেলে', '2026-10-25 04:02:00'),
 (2, 1, 2, 'c2-002', 'শুক্রবার দাওয়াত', '2026-10-25 04:10:00'),
 (2, 2, 3, 'c3-001', 'কয়টায়?', '2026-10-25 04:11:00'),
 (2, 3, 2, 'c2-003', 'দুপুর ১টা', '2026-10-25 04:12:00');
-- user 1-এর inbox: শেষ বার্তা আর না-পড়া বার্তার সংখ্যা
SELECT m.conv_id, COALESCE(c.title, 'direct') AS chat,
  (SELECT body FROM messages x WHERE x.conv_id = m.conv_id ORDER BY seq DESC LIMIT 1) AS last_msg,
  (SELECT COUNT(*) FROM messages x WHERE x.conv_id = m.conv_id
     AND x.seq > m.last_read_seq AND x.sender_id <> m.user_id) AS unread
FROM members m JOIN conversations c ON c.conv_id = m.conv_id
WHERE m.user_id = 1 ORDER BY m.conv_id;

User 1-এর inbox: 1 | direct | হ্যাঁ, বিকেলে | 0 আর 2 | Family | দুপুর ১টা | 2. Family-তে তিনি seq ১ পর্যন্ত পড়েছেন; এর পরে seq ২ (sender 3) আর seq ৩ (sender 2) — দুটিই অন্যের, তাই ২টি না-পড়া। নিজের পাঠানো বার্তা না-পড়ার হিসাবে আসে না (sender_id <> m.user_id). দুটি subquery-ই PK (conv_id, seq) ধরে চলে, আর inbox-এর প্রশ্ন “আমি কোন কোন চ্যাটে” members_by_user index দিয়ে।

-- Sabbir (3) Family গ্রুপে লিখলেন; পরের seq = সর্বোচ্চ + ১
INSERT INTO messages (conv_id, seq, sender_id, client_msg_id, body, sent_at)
SELECT 2, COALESCE(MAX(seq), 0) + 1, 3, 'c3-002', 'আমি মিষ্টি আনব', '2026-10-25 04:20:00'
FROM messages WHERE conv_id = 2
ON CONFLICT (sender_id, client_msg_id) DO NOTHING;
-- নেটওয়ার্ক দুর্বল, ফোন একই বার্তা আবার পাঠাল
INSERT INTO messages (conv_id, seq, sender_id, client_msg_id, body, sent_at)
SELECT 2, COALESCE(MAX(seq), 0) + 1, 3, 'c3-002', 'আমি মিষ্টি আনব', '2026-10-25 04:20:05'
FROM messages WHERE conv_id = 2
ON CONFLICT (sender_id, client_msg_id) DO NOTHING;
-- Rahim (1) গ্রুপটি খুলে seq 4 পর্যন্ত পড়লেন; পয়েন্টার কখনো পেছায় না
UPDATE members SET last_read_seq = max(last_read_seq, 4) WHERE conv_id = 2 AND user_id = 1;
-- seq 4-এর "Seen by" — প্রেরক বাদে
SELECT user_id FROM members
WHERE conv_id = 2 AND last_read_seq >= 4 AND user_id <> 3;

প্রথম INSERT ১টি সারি — Family-র seq ৪, “আমি মিষ্টি আনব”. একই client_msg_id-তে দ্বিতীয়বার ০টি সারি: ফোন বার্তাটি আবার পাঠালেও চ্যাটে দুবার দেখাবে না — এখানে client_msg_id-ই idempotency key. Rahim-এর পয়েন্টার UPDATE ১টি সারি; max(last_read_seq, 4) নিশ্চিত করে যে পুরোনো ডিভাইস থেকে দেরিতে আসা “seq ২ পড়েছি” পয়েন্টার পেছাবে না (PostgreSQL-এ GREATEST). “Seen by” seq ৪: শুধু 1 — user 2 আছেন seq ৩-এ। একটি সতর্কতা: MAX(seq) + 1 SQLite-এ নিরাপদ (একসাথে একজনই লেখে), কিন্তু PostgreSQL-এ দুজন একসাথে একই seq পেতে পারেন; তখন PK একজনকে আটকায়, আর অ্যাপ আবার চেষ্টা করে — বা conversation-এর সারিতে একটি counter UPDATE … RETURNING দিয়ে বাড়ানো।

কীভাবে বড় হবে: conv_id ধরে partition — একটি conversation-এর সব বার্তা এক জায়গায়, seq-এর ক্রমে সাজানো। এটি হুবহু wide-column store-এর partition key + clustering key মডেল (পর্ব ৫৬), আর বিশাল মাপের চ্যাট সিস্টেম প্রায়ই ঠিক এভাবে বার্তা রাখে (ভাবনার প্রশ্ন ৫)। খুব বড় গ্রুপ বা বহু বছরের চ্যাটে partition-কে সময়ের খণ্ডে (bucket) ভাগ করুন, যাতে একটি partition অসীম না হয়। বার্তা পৌঁছানো (WebSocket) আর push notification ডেটাবেসের বাইরে — বার্তা লেখার পর একটি queue-তে ঘটনা।

ফিরে দেখুন: self-referencing (recursive) relationship — পর্ব ৭; keyset pagination — পর্ব ১৫; covering index — পর্ব ৪১; UNION — পর্ব ২২; partition key — পর্ব ৫৬; sharding — পর্ব ৫৪.

৭ · SQL না NoSQL — বাছাইয়ের প্রশ্ন আর উত্তর

“এখানে কোন ডেটাবেস নেবেন?” — প্রায় প্রতিটি ডিজাইন ইন্টারভিউতে আসে। ভালো উত্তর কোনো প্রযুক্তির নাম দিয়ে শুরু হয় না; শুরু হয় access pattern, consistency-র প্রয়োজন আর মাপ দিয়ে (পর্ব ৫৬)। একটি কার্যকর ক্রম: (১) সবচেয়ে কঠিন নিয়মটি কী — একাধিক সারি জুড়ে transaction লাগে? (২) প্রধান প্রশ্নগুলো কি সবসময় একটি key দিয়ে, নাকি নানা দিক থেকে join আর aggregation? (৩) লেখার চাপ কি একটি primary-র সীমা ছাড়াবে? প্রতিটি প্রশ্নে আগে নিজের উত্তর ভাবুন, তারপর খুলুন।

প্র ক(সহজ) URL shortener-এর লিংক টেবিল — relational, না key-value store?

প্রধান প্রশ্ন একটিই: একটি code দিয়ে একটি URL. join নেই, একাধিক সারির transaction নেই — key-value store-এর জন্য আদর্শ আকৃতি, আর বিশাল মাপে সেগুলো সহজে অনুভূমিকভাবে ছড়ায়। তবে relational ডেটাবেসও এখানে স্বচ্ছন্দ: একটি UNIQUE index, আর হিসাব বলে পড়ার চাপের বেশিরভাগ cache নেবে। ভালো উত্তর: “দুটিই চলবে। আমি শুরু করব PostgreSQL দিয়ে — দলের পরিচিত, ব্যবহারকারীর ড্যাশবোর্ডের কোয়েরি সহজ, constraint আছে। লেখা যদি কখনো একটি primary-র সীমা ছাড়ায়, লিংক-টেবিলটি একটি key-value store-এ সরানো সহজ, কারণ access pattern সরল।” মূল কথা: সিদ্ধান্তের কারণ বলুন, আর কোন সংকেত দেখে মত বদলাবেন, সেটিও।

প্র খ(সহজ) মোবাইল ওয়ালেটের ledger — কোনটি, আর কেন?

Relational. একটি লেনদেন একাধিক সারি আর একাধিক account জুড়ে, আর “সব বা কিছুই না” এখানে আইনের মতো। CHECK (ঋণাত্মক নয়), UNIQUE (idempotency key), foreign key আর শর্তসহ UPDATE — সবই ডেটাবেস নিজে রক্ষা করে। reconciliation আর নিয়ন্ত্রকের রিপোর্ট join আর GROUP BY-তে ভরা। কিছু document ডেটাবেসেও এখন বহু-document transaction আছে (MongoDB 4.0, ২০১৮ থেকে), তবু composite constraint আর SQL-এর হিসাবের শক্তি relational-কে স্বাভাবিক পছন্দ রাখে। মাপ একটি অঞ্চলের বাইরে গেলে distributed SQL (Google Spanner, CockroachDB-ধাঁচের) — সেখানেও মডেল relational আর transaction শক্ত।

প্র গ(মাঝারি) একটি মার্কেটপ্লেসের পণ্যে নানা রকম বৈশিষ্ট্য — ফোনের RAM, শাড়ির কাপড়, বইয়ের লেখক। প্রতিটির জন্য কলাম, না অন্য কিছু?

প্রতিটি বৈশিষ্ট্যের জন্য কলাম হলে শত শত কলাম, বেশিরভাগ NULL. সাধারণ “EAV” টেবিল (entity, attribute, value) নমনীয় কিন্তু কোয়েরি কঠিন আর টাইপ হারায় — একটি পরিচিত anti-pattern. এখানে document মডেল সত্যিই মানায়: প্রতিটি পণ্য একটি document, বৈশিষ্ট্য তার ভেতরে। মাঝামাঝি সেরা পথ প্রায়ই PostgreSQL-এর jsonb: নাম, দাম, মজুত, বিক্রেতা — যেগুলোতে constraint আর join লাগে — সাধারণ কলাম; পরিবর্তনশীল বৈশিষ্ট্য একটি jsonb কলামে, GIN index-সহ (পর্ব ৩০)। এক ডেটাবেস, এক transaction, এক backup.

প্র ঘ(মাঝারি) দিনে শত কোটি চ্যাট বার্তা — relational টেবিলেই থাকবে?

অংশ ৬-এর schema মাঝারি মাপে চমৎকার চলে। বিশাল মাপে সমস্যা লেখার পরিমাণ আর ডেটার বৃদ্ধি: প্রতিদিন শত GB নতুন, প্রায় কখনো বদলায় না, আর পড়া সবসময় “একটি conversation-এর সাম্প্রতিক বার্তা”. এই আকৃতি wide-column store-এর (Cassandra, ScyllaDB) জন্য তৈরি: partition key = conv_id (প্রয়োজনে সময়ের bucket-সহ), clustering key = seq বা সময়; লেখা append, আর নোড যোগ করলেই ক্ষমতা বাড়ে। দাম: join নেই, একাধিক partition জুড়ে transaction নেই, আর প্রতিটি প্রশ্নের জন্য আলাদা টেবিল আগে থেকে নকশা করতে হয় (query-first design). তাই প্রায়ই মিশ্র: সদস্য, account, বিলিং relational-এ; বার্তা wide-column-এ।

প্র ঙ(মাঝারি) “আপনার বন্ধুর বন্ধু, যাঁকে আপনি চেনেন না” — graph database লাগবেই?

দুই ধাপের প্রশ্ন SQL-এ সহজ: follows-এর self join, বা recursive CTE-তে গভীরতা ২ (পর্ব ২০, ২৫) — সঠিক index থাকলে দ্রুত। Graph database-এর সুবিধা স্পষ্ট হয় যখন গভীরতা অনির্দিষ্ট বা বড় (৪–৬ ধাপ), পথের আকৃতি নিজেই প্রশ্ন (“দুজনের মধ্যে সবচেয়ে ছোট পথ”), আর প্রতিটি ধাপে বহু শাখা — তখন সম্পর্কের ওপর সরাসরি হাঁটা join-এর চেয়ে স্বাভাবিক (পর্ব ৫৯)। উল্টো উদাহরণও আছে: Facebook ২০১৩ সালের USENIX ATC সম্মেলনে TAO নামের যে ব্যবস্থা প্রকাশ করে, তা সামাজিক গ্রাফের জন্য একটি graph-ধাঁচের API, কিন্তু স্থায়ী ডেটা রাখত MySQL-এ, সামনে বিশাল cache. শিক্ষা: “graph” প্রশ্নের আকৃতি, ডেটাবেসের ধরন নয় — দুটি আলাদা সিদ্ধান্ত।

প্র চ(কঠিন) প্রতিটি ক্লিক আর পাতা দেখার ঘটনা রেখে মাসের রিপোর্ট — একই OLTP ডেটাবেসে?

না। ঘটনা-ডেটা বিশাল, শুধু যোগ হয়, আর প্রশ্ন হলো “গত ৩০ দিনে শহরভিত্তিক ক্লিক” — কোটি সারির কয়েকটি কলামের aggregation. Row store-এ এটি প্রতিটি সারির পুরোটা পড়ে, আর লেনদেনের ডেটাবেসের cache আর I/O কেড়ে নেয়। এর জন্য column store-ভিত্তিক OLAP ব্যবস্থা (ClickHouse, BigQuery, Redshift-ধাঁচের): শুধু দরকারি কলাম পড়ে, ভালোভাবে সংকুচিত, সমান্তরালে (পর্ব ৫৫)। নকশা: ঘটনা প্রথমে একটি log বা queue-তে, সেখান থেকে warehouse-এ; OLTP থেকে বদলগুলো change data capture দিয়ে সেখানে পাঠানো। ইন্টারভিউতে বলুন: “লেনদেন আর বিশ্লেষণ আলাদা রাখছি, যাতে রিপোর্ট কখনো checkout ধীর না করে।”

প্র ছ(কঠিন) “আমাদের অ্যাপ বড় হবে, তাই শুরু থেকেই সবকিছু NoSQL আর shard করা” — কীভাবে উত্তর দেবেন?

হিসাব দিয়ে। অংশ ২-এর URL shortener-এ peak প্রায় ৫,৮০০ পড়া আর ৬০টি লেখা প্রতি সেকেন্ডে — cache-সহ একটি ভালো primary আর দুটি replica সহজেই নেয়। অধিকাংশ অ্যাপ বহু বছর এই সীমার নিচে থাকে। শুরুতে shard করার দাম আসল আর তাৎক্ষণিক: shard জুড়ে join আর transaction কঠিন, ভুল shard key পরে বদলানো ব্যয়বহুল, প্রতিটি migration সব shard-এ। এটি premature optimization. পরিণত উত্তর: “এক relational primary, সঠিক index, cache আর read replica দিয়ে শুরু; shard key এখনই ভেবে রাখব — যেমন user_id — আর সব কোয়েরিতে সেটি রাখব, যাতে দরকার হলে ভাগ করা সহজ হয়। লেখার QPS বা ডেটার আকার যখন একটি মেশিনের সীমার কাছে যাবে, তখন ভাগ।” আর এক অ্যাপে বহু ধরনের ডেটাবেস (polyglot) নিন শুধু তখন, যখন একটি নির্দিষ্ট access pattern সত্যিই দাবি করে — প্রতিটি নতুন ডেটাবেস মানে নতুন backup, monitoring আর অন-কল দায়িত্ব।

৮ · সাধারণ ভুল — যেগুলো ইন্টারভিউতে নম্বর কাটে

আটটি সমস্যায় বারবার একই কয়েকটি ফাঁদ দেখা গেছে। প্রশ্নকর্তারা প্রায়ই এগুলোই খোঁজেন — আর আসল সিস্টেমেও এগুলোই সবচেয়ে বেশি ক্ষতি করে:

ভুলকী ক্ষতিঠিক পথ
constraint নেই, “অ্যাপ দেখবে”অন্য স্ক্রিপ্ট, পুরোনো অ্যাপ বা একসাথে দুটি অনুরোধে বাজে ডেটাNOT NULL, CHECK, UNIQUE, FK, partial unique index — নিয়ম ডেটাবেসে
টাকা FLOAT বা REAL-এSQLite-এ SELECT 0.1 + 0.2 = 0.3-এর ফল 0; যোগফলে পয়সার ভুল জমেপয়সায় INTEGER, বা NUMERIC(p, s)
foreign key কলামে index নেইjoin আর parent মোছার সময় পুরো child টেবিল পড়া; PostgreSQL FK-তে নিজে index বানায় না (MySQL InnoDB বানায়)প্রতিটি FK আর “উল্টো দিকের” প্রশ্নে index — যেমন follows_by_followee
time zone উপেক্ষাসার্ভার UTC-তে, ঢাকা UTC+6: “আজকের অর্ডার” ৬ ঘণ্টা সরে যায়ঘটে যাওয়া মুহূর্ত UTC বা timestamptz-এ; ভবিষ্যতের সময় স্থানীয় সময় + zone-এর নাম
অকালে shardingshard জুড়ে join, transaction, rebalancing — দিন ১ থেকে জটিলতাআগে index, কোয়েরি, cache, replica; হিসাব দেখিয়ে তারপর shard
idempotency নেইretry-তে দুবার টাকা কাটা, দুবার বার্তাঅনুরোধের অনন্য key, UNIQUE, আর ON CONFLICT DO NOTHING
“আগে দেখো, তারপর লেখো”check-then-act race: double booking, oversellশর্তসহ UPDATE, unique constraint, প্রয়োজনে SELECT … FOR UPDATE
derived মান রেখে মিলিয়ে না দেখাCGPA, ব্যালান্স, গণনা নিঃশব্দে সরে যায়হিসাব করুন, বা রাখলে নিয়মিত reconciliation কোয়েরি
ইতিহাস মুছে ফেলাবাতিল অর্ডার, পুরোনো দাম, লেনদেনের প্রমাণ হারায়status বা soft delete, append-only ledger, audit

আরেকটি সূক্ষ্ম ভুল — গড় দিয়ে নকশা: “গড়ে ২০০ অনুসারী” হিসাবে fan-out সস্তা, কিন্তু একজন তারকার ২৫ লাখ। “গড়ে সেকেন্ডে ১১৬টি লেনদেন” — অথচ মাসের প্রথম দিনে দশ গুণ। প্রতিটি হিসাবে গড়ের পাশে চরম মানও বলুন: peak, সবচেয়ে বড় গ্রুপ, সবচেয়ে জনপ্রিয় পণ্য। Tail latency আর hot spot গড়ে লুকিয়ে থাকে।

৯ · AI-এর যুগে ডিজাইন ইন্টারভিউর প্রস্তুতি

AI একটি চমৎকার অনুশীলন-সঙ্গী হতে পারে — প্রশ্নকর্তার ভূমিকায়, যে আপনার নকশায় প্রশ্ন তোলে। কিন্তু AI-কে দিয়ে নকশা লিখিয়ে নিলে দুটি ক্ষতি: আপনি নিজে ভাবার অনুশীলন হারান, আর AI-এর নকশায় প্রায়ই এমন ভুল থাকে যা দেখতে নির্ভুল লাগে। এ ধরনের কাজে AI-এর প্রচলিত ভুল:

AI প্রায়ই যা করেকীভাবে ধরবেন
মাপ না জেনেই Kafka, Cassandra, shard — চটকদার স্থাপত্যজিজ্ঞেস করুন: কোন সংখ্যার জন্য এটি দরকার? হিসাব চান
double booking বা oversell শুধু অ্যাপের “আগে দেখো” কোডেদুটি একসাথে চলা অনুরোধে কী হয় — নিজে trace করুন
টাকা DECIMAL বলে, অথচ উদাহরণে FLOAT; সময়ের zone নেইপ্রতিটি কলামের টাইপ আলাদা করে পড়ুন
কোয়েরি চলে কিন্তু ভুল — NULL, duplicate বা fan-out-এনমুনা ডেটায় চালিয়ে সারির সংখ্যা মেলান
নিজে বানানো কলাম বা টেবিলের নাম, FK-তে index নেইআসল schema দিন; EXPLAIN দেখুন
“X কোম্পানি এভাবে করে” — যাচাই না করা দাবিপ্রকাশিত উৎস চান; না পেলে দাবিটি বাদ দিন

AI-কে নকশাকার নয়, কঠোর প্রশ্নকর্তা বানান। নিজের নকশা আগে লিখুন, তারপর এই prompt:

তুমি একজন অভিজ্ঞ backend ইন্টারভিউয়ার। আমি "একটি ট্রেনের টিকিট বুকিং সিস্টেমের ডেটাবেস ডিজাইন" প্রশ্নের উত্তর দিচ্ছি। আমার নকশা: trips(trip_id, train_no, journey_on), trip_seats(trip_id, seat_no, status free/held/sold, holder_id, hold_until), PK (trip_id, seat_no); hold করি শর্তসহ UPDATE দিয়ে। আমার মাপের অনুমান: দিনে ২,৪০,০০০ আসন-সারি; বিক্রি খোলার ১০ মিনিটে ১০ লাখ অনুরোধ। ১. আমাকে একবারে একটি করে প্রশ্ন করো, যেমন আসল ইন্টারভিউতে — উত্তর নিজে দিয়ে দেবে না। ২. আমার হিসাবের কোনো ধাপ ভুল হলে কোন ধাপ, শুধু সেটি বলো। ৩. দুটি একসাথে চলা বুকিং, hold-এর মেয়াদ, পেমেন্ট দেরিতে আসা, আর একটি সার্ভার নষ্ট হওয়া — এই চারটি দিক থেকে আমার নকশায় চাপ দাও। ৪. কোনো কোম্পানির ভেতরের ব্যবস্থার কথা বললে প্রকাশিত উৎস বলবে; নিশ্চিত না হলে বলবে "নিশ্চিত নই". ৫. শেষে requirement, মাপ, schema, concurrency, scaling আর trade-off — প্রতিটিতে ১–৫ নম্বর আর একটি করে উন্নতির পরামর্শ দাও।

AI-এর দেওয়া schema বা উত্তর মেনে নেওয়ার আগে — চেকলিস্ট

১০ · কোর্স সম্পূর্ণ — ৬৭টি পর্বের পরে আপনি কোথায়

এটি দশটি মডিউল আর ৬৭টি পর্বের এই কোর্সের শেষ পর্ব। একবার পেছনে তাকান। পর্ব ০১-এ প্রশ্ন ছিল, “ডেটাবেস কেন — একটি খাতা বা Excel ফাইল কেন যথেষ্ট নয়?” আজ আপনি একটি অস্পষ্ট ব্যবসার বর্ণনা থেকে requirement বের করতে পারেন, ER আঁকতে পারেন, নিয়মগুলো constraint-এ লিখতে পারেন, কোয়েরির plan পড়ে index বাছতে পারেন, একসাথে চলা transaction-এর ফাঁক ধরতে পারেন, আর হিসাব দেখিয়ে বলতে পারেন কখন একটি ডেটাবেস আর যথেষ্ট নয়। আন্তরিক অভিনন্দন — এটি সত্যিই বড় অর্জন।

কোর্স সম্পূর্ণ — এরপর কী

পরীক্ষা ও সার্টিফিকেট: লগইন করলে ড্যাশবোর্ডে শেখার নয়টি মডিউলের (পর্ব ১–৬২) প্রতিটির একটি মডিউল টেস্ট আর একটি ফাইনাল পরীক্ষা পাবেন। শেখার সব পর্ব পড়া শেষ করে ফাইনালে ৬০% বা তার বেশি পেলে কোর্স-সমাপ্তির সার্টিফিকেট পাবেন। প্রতিটি পর্বের “পড়েছি” বোতাম চাপা থাকলে অগ্রগতি সেখানে দেখা যাবে — অগ্রগতি সেভ হয় শুধু লগইন করা অবস্থায়। ফাইনালের আগে পর্ব ৬৩–৬৬-এর প্রশ্নব্যাংক একবার ঘুরে আসুন।

এখন আপনি যা পারেন

  • ভিত্তি (পর্ব ১–৬): DBMS কেন, three-schema architecture, data model, relational model, key আর integrity constraint.
  • ডিজাইন (পর্ব ৭–১১): ER আর EER, cardinality, weak entity, ER থেকে টেবিল, আর পূর্ণ কেস স্টাডি।
  • SQL (পর্ব ১২–৩১): CREATE থেকে JOIN, subquery, set operation, view, CTE, window function, প্রচলিত প্যাটার্ন, procedure, trigger, JSON আর উপভাষার পার্থক্য।
  • তত্ত্ব (পর্ব ৩২–৩৮): relational algebra আর calculus, functional dependency, 1NF থেকে BCNF ও তার ওপরে, আর denormalization-এর বিচার।
  • ভেতরের কাজ (পর্ব ৩৯–৪৮): page আর buffer pool, B+ tree, EXPLAIN, join অ্যালগরিদম, optimizer, ACID, serializability, lock, MVCC আর recovery.
  • বাস্তব প্রয়োগ (পর্ব ৪৯–৬২): driver আর ORM, নিরাপত্তা, backup আর replication, tuning, distributed database, warehouse, NoSQL, Redis, vector database, cloud আর ক্যাপস্টোন।
  • প্রস্তুতি (পর্ব ৬৩–৬৭): output prediction, MCQ, লিখিত ও ভাইভা, SQL ইন্টারভিউ আর এই পর্বের ডিজাইন ইন্টারভিউ।

এরপর কোন পথে

Backend ডেভেলপার

একটি ভাষা থেকে ডেটাবেস ব্যবহার — connection, transaction, ORM আর migration — হাতে-কলমে। এই পর্বের যেকোনো একটি সমস্যা ছোট API হিসেবে বানিয়ে ফেলুন।

Python · Java · Node.js

ডেটা অ্যানালিস্ট

window function, CTE আর aggregation প্রতিদিনের হাতিয়ার। পর্ব ২৬–২৭ আর ৬৬ বারবার অনুশীলন করুন, তারপর একটি আসল খোলা ডেটাসেটে প্রশ্ন করুন।

পর্ব ৬৬ · Python-এ ডেটা

ভেতরের গভীরে

B+ tree, hash table আর sort — ডেটাবেসের ভেতরের ডেটা স্ট্রাকচার — অ্যালগরিদমের দিক থেকে আরও গভীরে।

B-tree · Hash table

পুরো রোডম্যাপ আবার

যে মডিউল দুর্বল লাগে, কোর্স হোম থেকে সেখানে ফিরে যান — বিশেষ করে normalization আর transaction, যা পরীক্ষা আর ইন্টারভিউ দুটিতেই সবচেয়ে বেশি আসে।

১০টি মডিউল

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

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

প্র ০১Luca Pacioli ১৪৯৪ সালে ভেনিসে প্রকাশিত “Summa de arithmetica” বইয়ে double-entry হিসাবরক্ষণের পদ্ধতি বিস্তারিত লেখেন। পাঁচ শতাব্দী পরে আধুনিক পেমেন্ট সিস্টেম কেন এখনো সেই নিয়মে ledger রাখে — একটি balance কলামই কি যথেষ্ট নয়?

Pacioli নিজে পদ্ধতিটি আবিষ্কার করেননি; ইতালির বণিকেরা আগে থেকেই এটি ব্যবহার করতেন। তাঁর অবদান ছিল ছাপা বইয়ে প্রথম পূর্ণাঙ্গ বিবরণ, যা পদ্ধতিটিকে ইউরোপজুড়ে ছড়িয়ে দেয়। মূল ধারণা সরল: প্রতিটি লেনদেন অন্তত দুটি হিসাবে লেখা হয় — এক দিকে debit, অন্য দিকে সমান credit. তাই খাতার যোগফল সবসময় মেলে, আর না মিললে বোঝা যায় কোথাও ভুল হয়েছে। এটি আসলে একটি অন্তর্নির্মিত ভুল-ধরা ব্যবস্থা — প্রোগ্রামিংয়ের ভাষায় একটি invariant.

শুধু একটি balance কলাম রাখলে কী হারায়? প্রথমত ইতিহাস: “আজ ব্যালান্স ৭৫০ টাকা” বলে, কিন্তু কেন, তা বলে না। গ্রাহক অভিযোগ করলে, নিয়ন্ত্রক প্রশ্ন করলে, বা একটি বাগ খুঁজতে হলে প্রতিটি পয়সার পথ লাগে। দ্বিতীয়ত যাচাই: একটি বাগ যদি ব্যালান্সে ভুল সংখ্যা লেখে, সেটি ধরার কোনো দ্বিতীয় উৎস নেই। ledger থাকলে balance একটি ক্যাশ — entries-এর যোগফলের সাথে প্রতি রাতে মিলিয়ে দেখা যায়, যেমন অংশ ৪-এর এডিটরে। তৃতীয়ত টাকার সংরক্ষণ: প্রতিটি transfer-এর যোগফল শূন্য, তাই সিস্টেমে টাকা “তৈরি” হওয়া একটি সহজে ধরা পড়া অসঙ্গতি।

append-only নিয়মটিও সেই পুরোনো খাতার উত্তরাধিকার: হিসাবরক্ষকেরা ভুল লাইন মুছতেন না, একটি সংশোধনী লাইন লিখতেন। ডেটাবেসে এর মানে UPDATE বা DELETE নিষেধ (trigger বা privilege দিয়ে), ভুল শুধরাতে reversal transfer. এর সাথে idempotency key মিলিয়ে আধুনিক রূপ: অনুরোধটি একবারই খাতায় ওঠে, যতবারই আসুক। Stripe-এর মতো পেমেন্ট প্রতিষ্ঠান তাদের API-তে প্রকাশ্যে idempotency key সমর্থন করে, ঠিক এই সমস্যার জন্য।

মূল উপলব্ধি: balance একটি উত্তর, ledger তার প্রমাণ। টাকার সিস্টেমে প্রমাণ রাখুন, উত্তরকে প্রমাণ থেকে হিসাব বা যাচাই করুন — পাঁচ শ বছরের পুরোনো এই নিয়মটি আসলে একটি চমৎকার ডেটাবেস নকশা।

প্র ০২Twitter ২০১০ সালে “Snowflake” নামের id-জেনারেটর প্রকাশ করে, আর Instagram-এর প্রকৌশলীরা ২০১২ সালে নিজেদের ব্লগে PostgreSQL-এর ভেতরেই id বানানোর একটি পদ্ধতি লেখেন। ভাগ করা (sharded) সিস্টেমে সাধারণ AUTO_INCREMENT বা এলোমেলো UUID কেন যথেষ্ট নয়?

একটি ডেটাবেসে AUTO_INCREMENT সরল আর ছোট। কিন্তু টেবিল কয়েকটি shard-এ ভাগ হলে দুই shard একই id দিতে পারে, আর একটি কেন্দ্রীয় counter প্রতিটি INSERT-এ একটি নেটওয়ার্ক-ডাক আর একটি single point of failure. এলোমেলো UUID (version 4) সংঘর্ষ প্রায় অসম্ভব করে, কিন্তু ১২৮ বিট বড়, আর এলোমেলো হওয়ায় B+ tree index-এর এলোমেলো জায়গায় ঢোকে — page split আর cache-এর অপচয় বাড়ে; সময়ের ক্রমও হারায়।

Snowflake-এর সমাধান ৬৪ বিটের একটি সংখ্যা: শুরুর একটি অব্যবহৃত চিহ্ন-বিটের পরে ৪১ বিট মিলিসেকেন্ডে সময় (একটি নিজস্ব শুরুর মুহূর্ত থেকে), তারপর ১০ বিট যন্ত্রের নম্বর, শেষ ১২ বিট সেই মিলিসেকেন্ডের ক্রমিক সংখ্যা। প্রতিটি যন্ত্র স্বাধীনভাবে id বানায়, কাউকে জিজ্ঞেস না করে; আর id মোটামুটি সময়ের ক্রমে বাড়ে। Instagram-এর ২০১২ সালের লেখায় একই ধাঁচ: ৪১ বিট সময়, ১৩ বিট logical shard-এর নম্বর, ১০ বিট ক্রমিক — PL/pgSQL ফাংশনে, প্রতিটি shard-এর নিজের sequence থেকে। তারা কয়েক হাজার logical shard কয়েকটি মাত্র physical সার্ভারে রাখত, যাতে পরে শুধু shard সরিয়ে ক্ষমতা বাড়ানো যায় — ডেটা নতুন করে ভাগ না করে।

এই নকশার প্রভাব অংশ ৬-এর ফিডে দেখেছেন: post_id সময়-ক্রমের হলে ORDER BY post_id DESC আর keyset pagination আলাদা সময়ের কলাম ছাড়াই চলে। ২০২৪ সালের মে মাসে প্রকাশিত RFC 9562 UUID version 7 মানসম্মত করেছে — শুরুতে মিলিসেকেন্ডের সময়, বাকিটা এলোমেলো — অর্থাৎ UUID-এর অনন্যতা আর Snowflake-এর সময়-ক্রম একসাথে। দাম: ঘড়ির ওপর নির্ভরতা — একটি যন্ত্রের ঘড়ি পেছালে duplicate বা ক্রম ভাঙার ঝুঁকি, তাই এমন জেনারেটর ঘড়ি পেছানো ধরলে অপেক্ষা করে।

মূল উপলব্ধি: distributed সিস্টেমে id-ও একটি নকশার প্রশ্ন — অনন্যতা, আকার, index-এর জন্য ক্রম আর কেন্দ্রীয় নির্ভরতা — চারটির মধ্যে ভারসাম্য। ইন্টারভিউতে “id কোথা থেকে আসবে?” প্রশ্নের জন্য তৈরি থাকুন।

প্র ০৩Martin Kleppmann-এর “Designing Data-Intensive Applications” (২০১৭) বইয়ের প্রথম অধ্যায়ে Twitter-এর হোম timeline-এর উদাহরণ আছে, ২০১২ সালের প্রকাশিত সংখ্যা দিয়ে। সেখান থেকে fan-out-এর সিদ্ধান্ত সম্পর্কে কী শেখা যায়?

বইটি Twitter-এর প্রকৌশলীদের ২০১২ সালের একটি প্রকাশ্য উপস্থাপনার সংখ্যা উদ্ধৃত করে: টুইট লেখা গড়ে সেকেন্ডে প্রায় ৪.৬ হাজার, peak-এ ১২ হাজারের বেশি; অথচ হোম timeline পড়া সেকেন্ডে প্রায় ৩ লাখ। পড়া লেখার চেয়ে প্রায় ৬৫ গুণ বেশি। প্রথম দিকে Twitter পড়ার মুহূর্তে timeline বানাত — অংশ ৬-এর pull, অনুসরণ করা সবার টুইট join করে। পড়ার চাপ বাড়লে এটি টিকছিল না, তাই তারা push-এ যায়: টুইট লেখার সময় প্রতিটি অনুসারীর timeline cache-এ ঢুকিয়ে দেওয়া। বইয়ের হিসাবে গড়ে একটি টুইট প্রায় ৭৫ জন অনুসারীর কাছে যেত, তাই সেকেন্ডে ৪.৬ হাজার টুইট মানে timeline cache-এ সেকেন্ডে প্রায় ৩.৪৫ লাখ লেখা।

তারপর তারকার সমস্যা: কয়েক কোটি অনুসারীর একজনের একটি টুইটে কয়েক কোটি লেখা। বই অনুযায়ী Twitter শেষে একটি মিশ্র পদ্ধতিতে যায় — বেশিরভাগ ব্যবহারকারীর টুইট push, খুব বেশি অনুসারীর অল্প কয়েকজনের টুইট আলাদাভাবে পড়ার সময় এনে মেশানো — ঠিক অংশ ৬-এর hybrid এডিটর।

শিক্ষাগুলো ইন্টারভিউতে সরাসরি কাজে লাগে। এক, সিদ্ধান্তের ভিত্তি একটি অনুপাত: পড়া আর লেখার হার, আর অনুসারীর বণ্টন — শুধু গড় নয়, লম্বা লেজও। দুই, “load parameter” আগে ঠিক করুন — এখানে প্রতি সেকেন্ডের টুইট, timeline-পড়া আর প্রতি ব্যবহারকারীর অনুসারী; নকশা তাদের ওপর দাঁড়ায়। তিন, push মানে denormalization — একই তথ্যের লক্ষ কপি, যা দ্রুত পড়ার জন্য লেখার সময় আর জায়গা খরচ করে। চার, কোনো একক কৌশল সব ক্ষেত্রে সেরা নয়; পরিণত সিস্টেম প্রায়ই দুটি কৌশল ভিন্ন ক্ষেত্রে ব্যবহার করে।

মূল উপলব্ধি: fan-out-এর প্রশ্ন আসলে “কাজটি কখন করব — লেখার সময় না পড়ার সময়?” উত্তর আসে সংখ্যা থেকে: কোনটি বেশি ঘটে, আর বণ্টনের লেজ কতটা লম্বা।

প্র ০৪২০১৮ সালের ২১ অক্টোবর GitHub-এর একটি data center-এর নেটওয়ার্ক মাত্র ৪৩ সেকেন্ডের জন্য বিচ্ছিন্ন হয়; তাদের প্রকাশিত বিশ্লেষণ অনুযায়ী সেবা প্রায় ২৪ ঘণ্টা ১১ মিনিট ব্যাহত ছিল। ডিজাইন ইন্টারভিউর “ব্যর্থতা” ধাপে এ থেকে কী বলবেন?

GitHub-এর post-incident বিশ্লেষণ অনুযায়ী, যুক্তরাষ্ট্রের পূর্ব উপকূলের একটি সংযোগ-যন্ত্র বদলের সময় পূর্ব উপকূলের data center আর মূল নেটওয়ার্ক-কেন্দ্রের মধ্যে সংযোগ ৪৩ সেকেন্ড বিচ্ছিন্ন ছিল। এই অল্প সময়েই তাদের স্বয়ংক্রিয় failover ব্যবস্থা (Orchestrator) MySQL-এর কয়েকটি cluster-এর primary পশ্চিম উপকূলে সরিয়ে দেয়। সংযোগ ফিরলে দেখা যায় পূর্ব উপকূলের পুরোনো primary-তে কিছু লেখা ছিল, যা পশ্চিমে পৌঁছায়নি, আর পশ্চিমের নতুন primary-তে নতুন লেখা — দুই দিকে আলাদা ইতিহাস। প্রতিষ্ঠানটি ডেটার অখণ্ডতাকে সবার আগে রেখে সিদ্ধান্ত নেয়: সেবা ধীর বা আংশিক রেখে backup থেকে ফিরিয়ে, তারপর না-মেলা লেখাগুলো আলাদা করে মিলিয়ে নেওয়া। এতেই প্রায় এক দিন লেগে যায়।

ইন্টারভিউর জন্য চারটি শিক্ষা। এক, failover একটি ট্রেড-অফ: খুব দ্রুত স্বয়ংক্রিয় failover availability বাড়ায়, কিন্তু অল্প সময়ের নেটওয়ার্ক বিচ্ছিন্নতাকেও “primary মারা গেছে” ভেবে split brain-এর ঝুঁকি আনে; বেশি অপেক্ষা করলে ডাউনটাইম বাড়ে। দুই, asynchronous replication-এ failover মানে কিছু commit হওয়া লেখা হারানোর সম্ভাবনা — RPO শূন্য নয় (পর্ব ৫২)। তিন, অঞ্চল জুড়ে latency: primary অন্য উপকূলে গেলে প্রতিটি লেখায় বাড়তি ভ্রমণ — অ্যাপ ধীর হয়ে যায়, ব্যর্থ না হয়েও। চার, পুনরুদ্ধারের পরিকল্পনা আগে থেকে: কোন ডেটা কতটা গুরুত্বপূর্ণ, আর অসঙ্গতি হলে কী মিলিয়ে দেখা হবে।

অংশ ২-এর ধাপ ৮ ঠিক এই প্রশ্ন: “primary নষ্ট হলে কী?” ভালো উত্তর বলে কে failover ঠিক করে, কত দ্রুত, কতটা ডেটা হারাতে পারে, আর পুরোনো primary ফিরে এলে তাকে কীভাবে আটকানো হয় (fencing).

মূল উপলব্ধি: ব্যর্থতা প্রায়ই নাটকীয় নয় — ৪৩ সেকেন্ডের নেটওয়ার্ক-ঝাঁকুনিই যথেষ্ট। নকশায় শুধু “replica আছে” নয়, বলুন replica কখন, কীভাবে আর কোন দামে primary হয়।

প্র ০৫Discord ২০১৭ সালে লেখে কীভাবে তারা বার্তা MongoDB থেকে Cassandra-য় সরিয়েছিল, আর ২০২৩ সালে লেখে “ট্রিলিয়ন” বার্তা ScyllaDB-তে সরানোর কথা। শুরুতে MongoDB নেওয়া কি ভুল ছিল?

Discord-এর ২০১৭ সালের প্রকৌশল-ব্লগ খোলাখুলি বলে: শুরুতে তারা দ্রুত পণ্য বাজারে আনতে বার্তাগুলো একটি MongoDB replica set-এ রেখেছিল, জেনেশুনে যে পরে সরাতে হবে। বার্তার সংখ্যা কয়েক কোটিতে পৌঁছালে ডেটা আর index আর RAM-এ আঁটছিল না, latency অনিশ্চিত হয়ে উঠছিল। তারা তাদের access pattern লেখে — একটি channel-এর সাম্প্রতিক বার্তা পড়া, লেখা বিপুল, পড়া এলোমেলো — আর বেছে নেয় Cassandra: channel আর সময়ের bucket মিলে partition key, বার্তার id দিয়ে ক্রম — অংশ ৬-এর চ্যাট-schema-র wide-column রূপ। id ছিল Snowflake-ধাঁচের, তাই সময়-ক্রম id থেকেই আসত।

২০২৩ সালের লেখায় নতুন সমস্যা: খুব জনপ্রিয় channel-এর partition-গুলো hot spot, আর garbage collection-জনিত বিরতিতে latency-র লেজ লম্বা। তারা ScyllaDB-তে যায় (Cassandra-সামঞ্জস্যপূর্ণ, C++-এ লেখা), আর ডেটাবেসের সামনে একটি “data service” স্তর বসায়, যা একই বার্তার একসাথে আসা বহু অনুরোধকে একটিতে মিলিয়ে দেয়।

তাহলে MongoDB কি ভুল ছিল? প্রেক্ষাপটে না। ছোট দল, অনিশ্চিত পণ্য, দ্রুত চলা জরুরি — তখন পরিচিত ও সহজ ডেটাবেস যুক্তিসংগত, যদি জানা থাকে কোন সংকেতে সরতে হবে। ভুল হতো প্রথম দিনেই কয়েকশো নোডের Cassandra চালানো, যখন বার্তা কয়েক লাখ — সেটি অংশ ৭-এর “অকালে sharding”. শিক্ষা হলো ধাপে ধাপে: মাপ বাড়লে access pattern স্পষ্ট হয়, আর তখন সেই প্যাটার্নের জন্য তৈরি ব্যবস্থায় যাওয়া। প্রতিটি সরানোর দাম বিশাল — কোটি সারি স্থানান্তর, দুই ডেটাবেসে একসাথে লেখা, যাচাই — তাই সরানো সহজ রাখতে প্রথম থেকেই ডেটাবেসকে একটি পরিষ্কার স্তরের পেছনে রাখা ভালো।

মূল উপলব্ধি: ডেটাবেস বাছাই একবারের সিদ্ধান্ত নয়, একটি যাত্রা। আজকের মাপে সরলতম যা কাজ করে তা নিন, কোন সংখ্যায় সেটি আর চলবে না তা লিখে রাখুন, আর সরানোর পথ খোলা রাখুন।

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

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

ক্রমএকটি ডেটাবেস ডিজাইন ইন্টারভিউর ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. functional আর non-functional requirement স্পষ্ট করা
  2. মাপের হিসাব — দিনে কত সারি, QPS, স্টোরেজ
  3. entity, ER আর constraint-সহ schema
  4. প্রধান কোয়েরি আর তাদের index
  5. transaction আর concurrency — race কোথায়, কীভাবে ঠেকানো
  6. scaling, ব্যর্থতা আর trade-off

(সহজ) একটি URL shortener-এর code-এ ৬২টি অক্ষর ব্যবহার হয় (0–9, a–z, A–Z). ৭ অক্ষরের code দিয়ে সর্বোচ্চ কতগুলো আলাদা লিংক চেনানো যায়?

  • 62 × 7 = 434
  • 762
  • 627 ≈ 3.5 × 1012
  • 262

(সহজ) একটি মোবাইল ওয়ালেটের ledger-এ লেনদেনের পরিমাণ রাখার জন্য কোন কলাম-টাইপ সবচেয়ে উপযুক্ত?

  • INTEGER, পয়সাকে একক ধরে
  • REAL বা DOUBLE PRECISION
  • FLOAT
  • TEXT, যেমন '1250.50'

(মাঝারি) একটি পেমেন্ট অ্যাপ টাকা পাঠানোর অনুরোধ পাঠায়, কিন্তু নেটওয়ার্ক কেটে যাওয়ায় উত্তর পায় না, তাই একই অনুরোধ আবার পাঠায়। টাকা যাতে দুবার না কাটে, তার সবচেয়ে নির্ভরযোগ্য ব্যবস্থা কোনটি?

  • অ্যাপ আবার পাঠানোর আগে ৫ সেকেন্ড অপেক্ষা করবে
  • transaction-গুলো SERIALIZABLE isolation-এ চালানো
  • লেখার আগে ব্যালান্স যথেষ্ট কি না দেখা
  • অনুরোধের সাথে অনন্য idempotency key, ডেটাবেসে UNIQUE

(মাঝারি) trip_seats(trip_id, seat_no, status) টেবিলে status 'free', 'held' বা 'sold'. দুজন যাত্রী একই মুহূর্তে একই আসন চাইলে কোনটি double booking নিশ্চিতভাবে ঠেকায়?

  • আগে SELECT করে status দেখা, 'free' হলে UPDATE
  • UPDATE … SET status = 'held' … WHERE … AND status = 'free', তারপর কয়টি সারি বদলেছে দেখা
  • খালি আসনের তালিকা cache-এ রাখা
  • ক্লিকের পর অ্যাপের বোতাম নিষ্ক্রিয় করা

(মাঝারি) একটি সোশ্যাল অ্যাপ fan-out on write ব্যবহার করে — প্রতিটি পোস্ট সব অনুসারীর ফিড-টেবিলে কপি হয়। গড় ব্যবহারকারীর অনুসারী ২০০, কিন্তু কয়েকজন তারকার ২৫ লাখ। সাধারণত কোন নকশা নেওয়া হয়?

  • সবার জন্য fan-out on write, তারকাসহ
  • Hybrid: সাধারণ ব্যবহারকারীর জন্য push, তারকাদের পোস্ট পড়ার সময় pull করে মেশানো
  • সবার জন্য fan-out on read
  • তারকাদের পোস্ট দেওয়ার সংখ্যা সীমিত করা

(সহজ) একটি সিস্টেমে দিনে ৮৬,৪০,০০০টি নতুন সারি লেখা হয়, সারা দিনে সমানভাবে। গড়ে প্রতি সেকেন্ডে কতগুলো লেখা?

  • ১০
  • ১,০০০
  • ৮৬৪
  • ১০০

(মাঝারি) একটি চ্যাট অ্যাপে ১০০ জনের একটি গ্রুপ। প্রতিটি বার্তার নিচে “Seen by” দেখাতে হবে। কোন নকশায় সবচেয়ে কম সারি লাগে, অথচ প্রশ্নের উত্তর দেওয়া যায়?

  • প্রতিটি সদস্যের একটি last_read_seq — কোন ক্রমিক বার্তা পর্যন্ত পড়েছেন
  • প্রতিটি (বার্তা, সদস্য) জোড়ার জন্য একটি সারি
  • প্রতিটি বার্তার সারিতে পড়েছেন এমন user_id-গুলোর JSON array
  • প্রতিবার পড়লে গ্রুপে একটি স্বয়ংক্রিয় বার্তা

(মাঝারি) follows(follower_id, followee_id) টেবিলের primary key (follower_id, followee_id). নতুন পোস্টে fan-out করতে চলে SELECT follower_id FROM follows WHERE followee_id = ?. দ্রুত করার জন্য কী লাগবে?

  • কিছু না — primary key-ই যথেষ্ট
  • follower_id-তে একটি আলাদা index
  • (followee_id, follower_id)-এ একটি index
  • কোনো index লাগবে না, কারণ follows টেবিল ছোট

শব্দকোষ

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

মনে রাখুন

  1. কাঠামো আগে: requirement (functional আর non-functional) → মাপ → ER → schema → কোয়েরি ও index → concurrency → scaling → ব্যর্থতা → trade-off. জোরে ভাবুন, অনুমান স্পষ্ট বলুন।
  2. হিসাব খামের পেছনে: দিনে ১০ লাখ ≈ সেকেন্ডে ১২; peak গড়ের কয়েক গুণ; সারির আকার × সংখ্যা × কপি = ডিস্ক। গড়ের পাশে চরম মানও দেখুন।
  3. নিয়ম ডেটাবেসে: UNIQUE, CHECK, FK, partial unique index আর শর্তসহ UPDATE — double booking, oversell আর lost update ঠেকায়; “আগে দেখো, তারপর লেখো” নয়।
  4. টাকা আর retry: পয়সায় INTEGER, append-only double-entry ledger, অনুরোধের idempotency key; balance একটি ক্যাশ, ledger তার প্রমাণ।
  5. Scaling হিসাব দেখিয়ে: আগে index, cache, read replica; তারপর partition আর shard — shard key = প্রধান প্রশ্নের key. SQL বা NoSQL বাছুন access pattern আর consistency দেখে, নাম দেখে নয়।

এরপর: এটিই কোর্সের শেষ পর্ব — আর কোনো পরের পর্ব নেই। কোর্স হোম-এ ফিরে পুরো রোডম্যাপ দেখুন, ড্যাশবোর্ডে মডিউল টেস্ট ও ফাইনাল পরীক্ষা দিন, আর এই পাতার আটটি সমস্যার যেকোনো একটি ঘড়ি ধরে ৪৫ মিনিটে একজন বন্ধুকে প্রশ্নকর্তা বানিয়ে আবার করে দেখুন।

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

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