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

সময় আর ক্রম — NTP, Lamport clock আর vector clock

Time and Order — Clock Drift, NTP, Monotonic Clocks, Lamport & Vector Clocks, HLC, TrueTime and Snowflake IDs

Taught in Bangla, this lesson explains quartz drift and skew with worked numbers, how NTP derives offset and delay from four timestamps with a proof, wall-clock versus monotonic clocks, leap seconds and smearing, why timestamps cannot order events across machines and how last-write-wins loses writes, happened-before, Lamport clocks and total order, vector clocks for concurrency, hybrid logical clocks, TrueTime commit-wait and time-ordered IDs like Snowflake, ULID and UUIDv7.

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

  • Drift আর skew-এর হিসাব, আর NTP চারটি timestamp থেকে কীভাবে offset ও delay বের করে — প্রমাণসহ
  • Wall clock আর monotonic clock-এর পার্থক্য, leap second আর smearing
  • Happened-before সম্পর্ক, Lamport clock-এর দুই নিয়ম আর total order
  • Vector clock দিয়ে আগে-পরে-concurrent চেনা, তার খরচ, আর version vector-এর সাথে পার্থক্য
  • HLC, TrueTime-এর commit-wait আর সময়ক্রমের ID (Snowflake, ULID, UUIDv7)

১ · কোনটি আগে ঘটেছে — প্রশ্নটি কেন এত কঠিন

একটি কাল্পনিক mobile banking সেবার কথা ভাবুন। রাত ১০টায় একজন গ্রাহক দুটি কাজ করলেন: প্রথমে অ্যাকাউন্টে ৫,০০০ টাকা জমা, তার কয়েক মিলিসেকেন্ড পরে ৪,০০০ টাকা পাঠানো। জমার অনুরোধ গেল সার্ভার A-তে, পাঠানোর অনুরোধ সার্ভার B-তে। প্রতিটি সার্ভার নিজের ঘড়ি দেখে একটি timestamp বসাল। পরে হিসাব মেলানোর সময় দেখা গেল, B-এর timestamp A-এর চেয়ে আগের — যেন টাকা আসার আগেই পাঠানো হয়েছে। কারণ? B-এর ঘড়ি A-এর চেয়ে কয়েক মিলিসেকেন্ড পিছিয়ে ছিল।

এক যন্ত্রের ভেতরে “আগে-পরে” সহজ: একটি CPU একটির পর একটি নির্দেশ চালায়। কিন্তু পর্ব ৩৮ — Distributed system-এর কঠিন সত্য-এ দেখেছেন, আলাদা যন্ত্রের ঘড়ি আলাদা হারে চলে, আর একটি process যেকোনো মুহূর্তে কিছুক্ষণ থেমে যেতে পারে। তাই distributed system-এ “কোনটি আগে” প্রশ্নের দুটি আলাদা উত্তর আছে:

⏱️ Physical time

ঘড়ির কাঁটার সময় — “রাত ১০:০০:০০.০৪২”. মানুষের কাছে অর্থবহ, log আর হিসাবের জন্য দরকারি। কিন্তু প্রতিটি যন্ত্রের ঘড়ি একটু ভুল, তাই আলাদা যন্ত্রের সময় সরাসরি তুলনা করলে ভুল ক্রম আসে।

quartz, NTP, PTP, GPS

🔗 Logical time

ঘড়ি নয়, কারণ-কার্যের হিসাব — “এই ঘটনা ওই বার্তা দেখার পরে ঘটেছে”. মিলিসেকেন্ড জানায় না, কিন্তু যেটুকু জানায় তা নিশ্চিত সত্য।

Lamport clock, vector clock

পাড়ার মুদি দোকানের খাতার কথা ভাবুন। দোকানদার তারিখ না লিখে প্রতিটি লেনদেনের পাশে একটি ক্রমিক নম্বর লেখেন — ১, ২, ৩। একটি খাতার ভেতরে ক্রম নিয়ে কখনো বিভ্রান্তি হয় না। এবার একই মালিকের দুটি দোকান, দুটি খাতা। দুই খাতার নম্বর দেখে বলা যায় না কোন লেনদেন আগে ঘটেছে — যদি না একজন কর্মচারী একটি চিরকুট নিয়ে এক দোকান থেকে আরেক দোকানে যান। চিরকুট পৌঁছানোর পর দ্বিতীয় দোকানে যা ঘটে, তা নিশ্চিতভাবে প্রথম দোকানের চিরকুট লেখার পরে। এই “চিরকুট”-ই distributed system-এর বার্তা, আর Lamport clock এই যুক্তিকেই গণিতে রূপ দেয়।

এই পর্বের পথ: আগে physical ঘড়ি কতটা ভুল আর NTP কীভাবে সারায় (অংশ ২–৪), তারপর কেন timestamp দিয়ে ক্রম ঠিক করা বিপজ্জনক (অংশ ৫), তারপর logical clock (অংশ ৬–৭), শেষে দুই জগতের মিশ্রণ আর সময়ক্রমের ID (অংশ ৮–৯)।

২ · Physical ঘড়ি — quartz, drift আর skew

প্রায় প্রতিটি কম্পিউটারের ভেতরে সময় রাখে একটি ছোট quartz crystal, যা নির্দিষ্ট হারে কাঁপে; কম্পন গুনে সময় হিসাব হয়। কিন্তু কম্পনের হার কারখানা, তাপমাত্রা আর বয়সভেদে একটু আলাদা। এই হারের ভুলকে বলে clock drift, মাপা হয় ppm (parts per million — দশ লাখে কত ভাগ) এককে। সাধারণ সার্ভারের ঘড়ি মোটামুটি কয়েক থেকে কয়েক দশ ppm সরে — সঠিক সংখ্যা হার্ডওয়্যার আর তাপমাত্রাভেদে আলাদা।

দিনে সরে যাওয়া = drift × ৮৬,৪০০ সেকেন্ড
২০ ppm → 20 × 10−6 × 86,400 ≈ ১.৭৩ সেকেন্ড/দিন · ৫০ ppm → ৪.৩২ সেকেন্ড/দিন

দুটি শব্দ আলাদা রাখুন। Drift হলো হারের ভুল — একটি ঘড়ি প্রতি সেকেন্ডে কতটা সরে। Clock skew হলো কোনো এক মুহূর্তে দুটি ঘড়ির পার্থক্য। একটি ঘড়ি +২০ ppm আর আরেকটি −২০ ppm হলে দুজনের পার্থক্য বাড়ে ৪০ ppm হারে — দিনে প্রায় ৩.৫ সেকেন্ড। তাই ঘড়ি একবার মিলিয়ে দিলেই চলে না; বারবার মেলাতে হয়।

মূল ধারণা

কত ঘন ঘন মেলাতে হবে? লক্ষ্যমাত্রার skew ÷ drift. একটি ঘড়িকে আসল সময়ের ১০ ms-এর মধ্যে রাখতে চাইলে, আর drift ২০ ppm হলে: 10 ms ÷ (20 × 10−6) = ৫০০ সেকেন্ড, মানে প্রায় ৮ মিনিটে একবার। দুটি ঘড়িকে পরস্পরের ১০ ms-এর মধ্যে রাখতে (উল্টো দিকে সরলে ৪০ ppm) লাগে ২৫০ সেকেন্ডে একবার। অবশ্য মেলানোর পদ্ধতির নিজের ভুলও আছে — পরের অংশে।

ঘড়ির আরেক শত্রু process-এর থেমে যাওয়া — garbage collection, virtual machine-এর স্থানান্তর বা ল্যাপটপের sleep. এগুলোতে ঘড়ি ভুল হয় না, কিন্তু প্রোগ্রাম মনে করে “এইমাত্র সময় দেখলাম”, অথচ মাঝে কয়েক সেকেন্ড চলে গেছে। পর্ব ৩৮-এ এর উদাহরণ দেখেছেন; সময়ের ওপর ভরসা করা lease কেন তাই fencing token চায়, তা পর্ব ৪৩ — Coordination-এ।

৩ · NTP — চারটি timestamp থেকে offset আর delay

NTP (Network Time Protocol) ইন্টারনেটের সবচেয়ে পুরোনো চালু protocol-গুলোর একটি; David Mills ১৯৮০-এর দশকে এর কাজ শুরু করেন, আর বর্তমান সংস্করণ NTPv4 (RFC 5905, ২০১০)। এটি UDP port 123-এ চলে। মূল সমস্যাটি এমন: client জানতে চায় সার্ভারের ঘড়ি তার চেয়ে কতটা এগিয়ে বা পিছিয়ে — অথচ বার্তা আসা-যাওয়াতেই সময় লাগে, আর সেই সময় client জানে না।

সমাধান: একটি অনুরোধ-উত্তরে চারটি timestamp লেখা হয়।

  • t1 — client অনুরোধ পাঠাল (client-এর ঘড়িতে)
  • t2 — সার্ভার অনুরোধ পেল (সার্ভারের ঘড়িতে)
  • t3 — সার্ভার উত্তর পাঠাল (সার্ভারের ঘড়িতে)
  • t4 — client উত্তর পেল (client-এর ঘড়িতে)
  1. Clientt1 = 100অনুরোধ পাঠাল
  2. NTP সার্ভারt2 = 152 · t3 = 154পেল, উত্তর দিল
  3. Clientt4 = 122উত্তর পেল
Timestamp (ms) আর হিসাব
t1 (client)
t2 (server)
t3 (server)
t4 (client)
delay δ
offset θ
  1. t1 = ১০০. Client নিজের ঘড়িতে ১০০ ms দেখে অনুরোধ পাঠাল আর t1 মনে রাখল। (উদাহরণে আসলে সার্ভারের ঘড়ি client-এর চেয়ে ৪০ ms এগিয়ে — client তা জানে না, এটাই বের করতে হবে।)
  2. t2 = ১৫২. অনুরোধ যেতে লাগল ১২ ms. সার্ভারের ঘড়িতে তখন ১০০ + ১২ + ৪০ = ১৫২ ms. সার্ভার t2 লিখে রাখল।
  3. t3 = ১৫৪. সার্ভার ২ ms কাজ করে উত্তর পাঠাল; উত্তরের ভেতরে t1, t2 আর t3 — তিনটিই আছে।
  4. t4 = ১২২. ফেরার পথে লাগল ৮ ms — যাওয়ার চেয়ে কম। উত্তর পৌঁছাল সার্ভারের ঘড়িতে ১৬২-তে, মানে client-এর ঘড়িতে ১৬২ − ৪০ = ১২২-এ। এখন client-এর হাতে চারটি সংখ্যা।
  5. হিসাব। মোট round trip ২২ ms থেকে সার্ভারের ২ ms বাদ দিলে নেটওয়ার্কে delay δ = ২০ ms. Offset θ = (৫২ + ৩২) ÷ ২ = ৪২ ms. আসল offset ৪০, তাই ভুল +২ ms — যা ঠিক (১২ − ৮) ÷ ২, অর্থাৎ যাওয়া-আসার অসমতার অর্ধেক।
  6. ভুলের সীমা। যাওয়া আর আসা দুটিই ০ থেকে ২০ ms-এর মধ্যে যেকোনো ভাগে হতে পারত, তাই আসল offset নিশ্চিতভাবে ৪২ ± ১০ ms-এর মধ্যে — অর্থাৎ θ ± δ/২. এই নমুনায় ভুল মাত্র ২ ms, তবু client নিশ্চিত হতে পারে শুধু ১০ ms-এর সীমা পর্যন্ত।

সূত্রটি কোথা থেকে এল — ছোট একটি প্রমাণ

ধরা যাক সার্ভারের ঘড়ি client-এর চেয়ে θ এগিয়ে, যেতে লাগে d1, ফিরতে d2. তাহলে t2 = t1 + d1 + θ, আর t4 = t3 + d2 − θ. প্রথমটি থেকে t2 − t1 = d1 + θ, দ্বিতীয়টি থেকে t3 − t4 = θ − d2.

δ = (t4 − t1) − (t3 − t2) = d1 + d2
θ̂ = [(t2 − t1) + (t3 − t4)] ÷ 2 = θ + (d1 − d2) ÷ 2

অর্থাৎ হিসাবের offset θ̂ আসল θ-র সমান হয় কেবল যাওয়া আর আসা সমান হলে। d1 আর d2 দুটিই ঋণাত্মক নয় আর যোগফল δ, তাই |d1 − d2| ≤ δ — ফলে ভুল কখনো δ/২-এর বেশি নয়। এখান থেকেই NTP-র কৌশল: কয়েকটি নমুনা নিয়ে সবচেয়ে কম delay-এর নমুনাকে বেশি বিশ্বাস করা, কারণ তার ভুলের সীমা সবচেয়ে ছোট। নিচের প্রোগ্রাম ছয়টি নমুনা বানায় — যাওয়া-আসায় এলোমেলো queueing delay, seed নির্দিষ্ট।

// NTP: estimate clock offset and round-trip delay from four timestamps t1..t4
function rng(seed) {                                   // mulberry32: seeded, repeatable
  return () => { seed = seed + 0x6D2B79F5 | 0;
    let t = Math.imul(seed ^ seed >>> 15, 1 | seed);
    t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
    return ((t ^ t >>> 14) >>> 0) / 4294967296; };
}
const rand = rng(39);
const jitter = mean => -mean * Math.log(1 - rand());   // random queueing delay, average = mean
const TRUE_OFFSET = 37.0;      // server clock is 37.0 ms ahead of the client clock (unknown to client)
console.log("#  t1(c)    t2(s)    t3(s)    t4(c)    offset  delay  error");
let best = null;
for (let k = 1; k <= 6; k++) {
  const t1 = 1000 * k;                                 // client clock when request leaves
  const go = 8 + jitter(6), back = 8 + jitter(6);      // one-way delays differ (asymmetry)
  const t2 = t1 + go + TRUE_OFFSET;                    // server clock on arrival
  const t3 = t2 + 0.3 + jitter(0.5);                   // server processing time
  const t4 = t3 + back - TRUE_OFFSET;                  // client clock when reply arrives
  const offset = ((t2 - t1) + (t3 - t4)) / 2;
  const delay = (t4 - t1) - (t3 - t2);
  const s = { k, offset, delay, err: offset - TRUE_OFFSET };
  console.log([k, t1, t2, t3, t4].map((v, i) => i ? v.toFixed(2).padStart(8) : String(v)).join(" ") +
    offset.toFixed(2).padStart(8) + delay.toFixed(2).padStart(7) + s.err.toFixed(2).padStart(7));
  if (!best || delay < best.delay) best = s;
}
console.log("pick min-delay sample #" + best.k + ": offset " + best.offset.toFixed(2) +
  " ms, error " + best.err.toFixed(2) + " ms, bound +/-" + (best.delay / 2).toFixed(2) + " ms");

আউটপুট:

#  t1(c)    t2(s)    t3(s)    t4(c)    offset  delay  error
1  1000.00  1045.47  1046.16  1025.55   33.04  24.86  -3.96
2  2000.00  2045.23  2045.99  2028.36   31.43  27.60  -5.57
3  3000.00  3045.02  3046.81  3019.80   36.01  18.01  -0.99
4  4000.00  4054.08  4055.14  4040.43   34.39  39.37  -2.61
5  5000.00  5056.24  5056.71  5032.28   40.34  31.81   3.34
6  6000.00  6047.64  6048.41  6023.64   36.21  22.87  -0.79
pick min-delay sample #3: offset 36.01 ms, error -0.99 ms, bound +/-9.00 ms

আসল offset ৩৭ ms, কিন্তু নমুনাভেদে হিসাব ৩১.৪৩ থেকে ৪০.৩৪ — ভুল −৫.৫৭ থেকে +৩.৩৪ ms. প্রতিটি সারিতে |error| ≤ delay/২ মেনে চলছে (যেমন নমুনা ২-এ ৫.৫৭ ≤ ১৩.৮০)। সবচেয়ে কম delay নমুনা ৩-এর (১৮.০১ ms), তার ভুল মাত্র −০.৯৯ ms আর নিশ্চিত সীমা ±৯ ms. লক্ষ করুন, নমুনা ৬-এর ভুল আরও কম (−০.৭৯), কিন্তু client তা জানতে পারে না — সে শুধু সীমাটুকু জানে। আসল NTP software আরও অনেক কিছু করে: কয়েকটি সার্ভারের উত্তর মিলিয়ে ভুল সার্ভার বাদ দেয়, আর সময়ের সাথে drift-ও অনুমান করে।

Stratum, slew আর step

NTP সার্ভারগুলো স্তরে সাজানো — এই স্তরের নাম stratum. Stratum 0 হলো আসল সময়ের উৎস (GPS receiver, পারমাণবিক ঘড়ি); তার সাথে সরাসরি যুক্ত সার্ভার stratum 1; তাদের কাছ থেকে সময় নেওয়া সার্ভার stratum 2, এভাবে চলে। RFC 5905 অনুযায়ী ১৫ পর্যন্ত বৈধ, আর ১৬ মানে “সিঙ্ক হয়নি”। Stratum সংখ্যা নির্ভুলতার মাপ নয় — শুধু উৎস থেকে কত ধাপ দূরে, তার মাপ।

Offset জানার পর ঘড়ি সংশোধনের দুই উপায়। Slew — ঘড়িকে কিছুক্ষণ একটু দ্রুত বা ধীরে চালিয়ে আস্তে আস্তে মেলানো; সময় পেছায় না। Step — এক লাফে নতুন সময় বসানো; offset বড় হলে (ntpd-এর ডিফল্টে প্রায় ১২৮ ms-এর বেশি; chrony-তে setting-নির্ভর) এটি ঘটে, আর তখন ঘড়ি পেছাতেও পারে। কতটা নির্ভুল হয়? মোটামুটি হিসাবে: একই ডেটা সেন্টারের ভেতরে ১ ms-এর নিচে, ইন্টারনেটের দূরের সার্ভার থেকে কয়েক থেকে কয়েক দশ ms; পথ অসম বা জমজমাট হলে আরও খারাপ। আরও কড়া প্রয়োজনে PTP (IEEE 1588) hardware timestamp দিয়ে LAN-এ microsecond-এর নিচে পৌঁছাতে পারে।

Client-server ঘড়ি মেলানোর পুরোনো অ্যালগরিদম — Cristian-এর পদ্ধতি আর Berkeley অ্যালগরিদম — Operating Systems কোর্সের Distributed system-এর OS-দিক পর্বে আছে। NTP যে UDP-র ওপর চলে, তার স্বভাব Computer Networks কোর্সের UDP পর্বে।

৪ · Wall clock বনাম monotonic clock, আর leap second

অপারেটিং সিস্টেম আসলে দুই রকম ঘড়ি দেয়। Wall clock (দেয়ালঘড়ির সময়) বলে এখন ক্যালেন্ডারের কোন তারিখ আর কয়টা বাজে — সাধারণত Unix epoch থেকে কত সেকেন্ড, UTC-তে। NTP একে সংশোধন করে, তাই এটি লাফ দিতে, এমনকি পেছাতে পারে। Monotonic clock শুধু বলে কোনো এক নির্দিষ্ট মুহূর্ত (প্রায়ই যন্ত্র চালুর সময়) থেকে কত সময় গেছে; এটি কখনো পেছায় না। এর মানের কোনো ক্যালেন্ডার-অর্থ নেই, আর দুটি যন্ত্রের monotonic মান তুলনাও অর্থহীন।

ভাষা / OSWall clock — তারিখ, logMonotonic — সময়কাল, timeout
Linux (C)CLOCK_REALTIMECLOCK_MONOTONIC
JavaScriptDate.now()performance.now()
JavaSystem.currentTimeMillis()System.nanoTime()
Pythontime.time()time.monotonic()
Gotime.Now()-এর wall অংশtime.Since() (monotonic অংশ দিয়ে)

ভুল ঘড়ি বাছলে কী হয়, নিচে ধাপে ধাপে দেখুন। একটি সার্ভার একটি অনুরোধের সময় মাপছে, আর মাঝপথে NTP ঘড়িকে এক লাফে (step) ৩০০ ms পিছিয়ে দিল।

দুই ঘড়ির মান
ঘটনা
Wall clock
Monotonic
  1. শুরু। অনুরোধ এল; সার্ভার দুই ঘড়িই পড়ল — wall clock ১০:০০:০০.০০০, monotonic ৫০০০.০০০ সেকেন্ড (যন্ত্র চালুর পর থেকে)।
  2. ৫০ ms পরে NTP step. NTP দেখল ঘড়ি ৩০০ ms এগিয়ে আছে — slew-এর সীমার বাইরে, তাই এক লাফে পিছিয়ে দিল। Wall clock ১০:০০:০০.০৫০ থেকে হলো ০৯:৫৯:৫৯.৭৫০. Monotonic ঘড়ি এসব জানেই না — সে ৫০০০.০৫০.
  3. আরও ৩০ ms পরে অনুরোধ শেষ। Wall clock ০৯:৫৯:৫৯.৭৮০, monotonic ৫০০০.০৮০ সেকেন্ড। আসলে মোট সময় গেছে ৮০ ms.
  4. হিসাব। Wall clock দিয়ে মাপলে latency −২২০ ms — ঋণাত্মক! এমন সংখ্যা latency-র graph নষ্ট করে, timeout কখনো শেষ হতে দেয় না বা আগেভাগে শেষ করে, আর কোনো library ঋণাত্মক মান পেলে crash-ও করতে পারে। Monotonic দিয়ে ঠিক ৮০ ms. নিয়ম: সময়কাল মাপুন monotonic দিয়ে, তারিখ লিখুন wall clock দিয়ে।

Leap second আর smearing

পৃথিবীর ঘূর্ণন একদম সমান নয়, তাই পারমাণবিক ঘড়ির সময় আর সূর্যের সময় মেলাতে ১৯৭২ সাল থেকে UTC-তে মাঝে মাঝে একটি বাড়তি সেকেন্ড যোগ হয়েছে — leap second; সেদিন ঘড়িতে ২৩:৫৯:৫৯-এর পরে আসে ২৩:৫৯:৬০. এখন পর্যন্ত ২৭ বার যোগ হয়েছে, শেষবার ২০১৬ সালের ৩১ ডিসেম্বর। Unix time-এ ৬০ নম্বর সেকেন্ড রাখার জায়গা নেই, তাই অনেক সিস্টেমে একই সেকেন্ড দুবার আসে — অর্থাৎ wall clock এক সেকেন্ড পেছায়। ২০২২ সালে ওজন ও পরিমাপের আন্তর্জাতিক সম্মেলন (CGPM) সিদ্ধান্ত নেয় যে ২০৩৫ সালের মধ্যে leap second বন্ধ করা হবে; বিস্তারিত নিয়ম এখনো চূড়ান্ত হচ্ছে।

অনেক বড় প্রতিষ্ঠান তাই লাফের বদলে leap smear করে: বাড়তি সেকেন্ডটিকে কয়েক ঘণ্টা থেকে এক দিন জুড়ে ছড়িয়ে দেয় — ঘড়ি সেই সময় একটু ধীরে চলে। Google-এর প্রকাশিত নথি অনুযায়ী তাদের public NTP সেবা ২৪ ঘণ্টা ধরে সমান হারে smear করে (দুপুর থেকে দুপুর); প্রতি সেকেন্ডে পার্থক্য তখন ১ ÷ ৮৬,৪০০ ≈ ১১.৬ ppm — সাধারণ quartz drift-এর মাপে। সাবধান: smear করা আর না-করা সার্ভার মিশিয়ে ব্যবহার করলে সেই দিনে দুই দলের মধ্যে আধা সেকেন্ড পর্যন্ত পার্থক্য হতে পারে।

ঘড়ি পেছালে সত্যিই কী ভাঙে? ২০১২ সালের ৩০ জুনের leap second-এ Linux kernel-এর একটি ত্রুটিতে বহু সার্ভারে CPU আটকে ১০০% হয়ে যায় — কয়েকটি বড় ওয়েবসাইট আর অনেক Java-ভিত্তিক সেবা ভোগে। আর Cloudflare-এর প্রকাশিত engineering blog অনুযায়ী ২০১৭ সালের ১ জানুয়ারির leap second-এ তাদের DNS software-এর একটি অংশ দুটি wall-clock মানের বিয়োগফল ঋণাত্মক পায়; সেই মান একটি random-সংখ্যার ফাংশনে গিয়ে প্রোগ্রাম crash করায়, ফলে কিছু DNS অনুরোধ ব্যর্থ হয়। দুই ঘটনারই শিক্ষা এক: ধরে নেবেন না যে wall clock সবসময় সামনে চলে।

৫ · কেন timestamp দিয়ে আলাদা যন্ত্রের ঘটনা সাজানো যায় না

ধরা যাক NTP সব সার্ভারকে আসল সময়ের ±ε-এর মধ্যে রাখে। তাহলে দুটি সার্ভারের পার্থক্য সর্বোচ্চ ২ε. এখন দুটি ঘটনা আলাদা সার্ভারে ২ε-এর চেয়ে কম ব্যবধানে ঘটলে timestamp উল্টো ক্রম দেখাতেই পারে। আর ε নিজেও নিশ্চিত নয় — কোনো সার্ভারের NTP চুপচাপ বন্ধ হয়ে গেলে, ভুল config-এ, বা virtual machine থেমে থাকার পরে ঘড়ি অনেক দূরে সরে যেতে পারে, আর তা কেউ টেরও পায় না।

সবচেয়ে বিপজ্জনক জায়গা last-write-wins (LWW): একই key-তে দুটি লেখা এলে বড় timestamp-এর লেখাটি থাকে, অন্যটি নিঃশব্দে মুছে যায়। একটি উদাহরণ:

আসল সময়কী ঘটলযে timestamp বসল
১০:০০:০০.০০০সার্ভার A-তে (ঘড়ি ৫০ ms এগিয়ে) ঠিকানা = “মিরপুর”১০:০০:০০.০৫০
১০:০০:০০.০৩০গ্রাহক ভুল বুঝে সংশোধন করলেন; সার্ভার B-তে (ঘড়ি ঠিক) ঠিকানা = “উত্তরা”১০:০০:০০.০৩০
পরেLWW: .০৫০ > .০৩০, তাই “মিরপুর” থাকলপরের, আসল লেখাটি হারাল

কোনো error নেই, কোনো log-এ সতর্কবার্তা নেই — গ্রাহকের সংশোধন শুধু হারিয়ে গেল। Replication-এ এই ঝুঁকি পর্ব ২২-এ দেখেছেন; সমাধানের পথ — সংস্করণ রেখে মেলানো আর CRDT — পর্ব ৪৬ — Conflict resolution আর CRDT-এ।

মূল ধারণা

ক্রমের দরকার হলে তিনটি নির্ভরযোগ্য পথ আছে। (১) একজনই ক্রম দেবে — একটি leader বা একটি log partition, যেখানে sequence number বা offset সব ঘটনাকে এক সারিতে বসায় (পর্ব ৩৩, আর একটি aggregate-এর event-এ version নম্বর পর্ব ৩৫); database-এর ভেতরে MVCC-ও ঘড়ি নয়, transaction-এর ক্রমিক ID ব্যবহার করে (পর্ব ২৪)। (২) Logical clock — কারণ-কার্য মেনে ক্রম, পরের দুই অংশে। (৩) ঘড়ির অনিশ্চয়তা মেপে অপেক্ষা — TrueTime, অংশ ৮-এ।

৬ · Happened-before আর Lamport clock

১৯৭৮ সালে Leslie Lamport “Time, Clocks, and the Ordering of Events in a Distributed System” নামের paper-এ (Communications of the ACM) প্রশ্নটি নতুন করে সাজান: ঘড়ি কী বলে তা নয়, কোন ঘটনা কোন ঘটনাকে প্রভাবিত করতে পারত, সেটিই আসল ক্রম। তিনি সংজ্ঞা দেন happened-before সম্পর্কের, চিহ্ন a → b:

  1. একই process-এ a আগে আর b পরে ঘটলে a → b.
  2. a একটি বার্তা পাঠানো আর b সেই বার্তা পাওয়া হলে a → b.
  3. a → b আর b → c হলে a → c (একটির পর আরেকটি জুড়ে চলে)।

a → b বা b → a — কোনোটিই না হলে a আর b concurrent, চিহ্ন a ∥ b. এখানে “concurrent” মানে একই মুহূর্তে নয়; মানে একটি আরেকটির খবর জানত না, তাই একটি অন্যটির কারণ হতে পারে না। এই সম্পর্ক সব জোড়াকে সাজায় না — তাই একে বলে partial order বা আংশিক ক্রম।

Lamport clock এই সম্পর্ককে একটি সংখ্যায় ধরার সহজ উপায়। প্রতিটি process একটি counter রাখে, আর দুটি নিয়ম মানে:

নিয়ম ১: প্রতিটি ঘটনার আগে L = L + 1; বার্তা পাঠালে সাথে L যায়
নিয়ম ২: বার্তা পেলে L = max(L, বার্তার L) + 1

নিচের space-time diagram-এ তিনটি process (ওপর থেকে নিচে সময়) আর তিনটি বার্তা। প্রতিটি ঘটনার পাশে তার Lamport মান আর vector clock (অংশ ৭) লেখা।

তিন process, তিন বার্তা — Lamport আর vector stamp

Space-time diagram with Lamport (L) and vector timestamps

Space-time diagram: P1, P2, P3; বার্তা m1 (b→d), m2 (e→h), m3 (i→c); প্রতিটি ঘটনার Lamport মান আর vector clock P1 P2 P3 a · L1[1,0,0] b · L2[2,0,0] c · L7[3,2,3] e · L4[2,2,0] f · L5[2,3,0] d · L3[2,1,0] g · L1[0,0,1] h · L5[2,2,2] i · L6[2,2,3] m1 m2 m3 local send receive সময় ↓
ছবি ৩৯.১ — f আর c-র মধ্যে কোনো বার্তার পথ নেই: f ঘটেছে e-র পরে, কিন্তু m2-তে f-এর খবর যায়নি। তাই f ∥ c, যদিও L(f) = ৫ < L(c) = ৭.

নিচের প্রোগ্রাম ঠিক এই trace-এ দুই নিয়ম চালায়, তারপর (L, process নম্বর) দিয়ে সব ঘটনা এক সারিতে সাজায় — এটিই total order.

// Lamport clocks on a 3-process trace; then a total order using (L, process id)
// Each event: [name, process, kind, message]. Listed in one possible real-time order.
const trace = [
  ["a", 1, "local"], ["g", 3, "local"],
  ["b", 1, "send", "m1"], ["d", 2, "recv", "m1"],
  ["e", 2, "send", "m2"], ["f", 2, "local"],
  ["h", 3, "recv", "m2"], ["i", 3, "send", "m3"],
  ["c", 1, "recv", "m3"],
];
const L = { 1: 0, 2: 0, 3: 0 };          // one counter per process, starts at 0
const inFlight = {};                     // message -> timestamp carried with it
const stamp = {};
for (const [name, p, kind, msg] of trace) {
  if (kind === "recv") L[p] = Math.max(L[p], inFlight[msg]) + 1;  // rule 2: max, then +1
  else L[p] = L[p] + 1;                                          // rule 1: tick before each event
  if (kind === "send") inFlight[msg] = L[p];                     // the message carries L
  stamp[name] = [L[p], p];
  console.log(`${name}  P${p}  ${kind.padEnd(5)} ${(msg || "").padEnd(3)} L=${L[p]}`);
}
const order = Object.keys(stamp).sort((x, y) =>
  stamp[x][0] - stamp[y][0] || stamp[x][1] - stamp[y][1]);      // tie -> smaller process id
console.log("total order: " + order.map(n => `${n}(${stamp[n][0]}.${stamp[n][1]})`).join(" < "));
console.log("L(g)=" + stamp.g[0] + " < L(b)=" + stamp.b[0] + "   L(f)=" + stamp.f[0] +
  " < L(c)=" + stamp.c[0] + "   (does that mean g->b or f->c? check with vector clocks)");

আউটপুট:

a  P1  local     L=1
g  P3  local     L=1
b  P1  send  m1  L=2
d  P2  recv  m1  L=3
e  P2  send  m2  L=4
f  P2  local     L=5
h  P3  recv  m2  L=5
i  P3  send  m3  L=6
c  P1  recv  m3  L=7
total order: a(1.1) < g(1.3) < b(2.1) < d(3.2) < e(4.2) < f(5.2) < h(5.3) < i(6.3) < c(7.1)
L(g)=1 < L(b)=2   L(f)=5 < L(c)=7   (does that mean g->b or f->c? check with vector clocks)

d বার্তা m1 পেয়ে max(০, ২) + ১ = ৩ হলো; c পেল max(২, ৬) + ১ = ৭ — বার্তার মান স্থানীয় counter-কে সামনে টেনে আনে। f আর h দুজনেরই L = ৫; সমান হলে ছোট process নম্বর আগে, তাই f(৫.২) < h(৫.৩)। ফলে প্রতিটি জোড়ার একটি নির্দিষ্ট ক্রম আছে — সব node একই নিয়মে একই ক্রম পাবে, কোনো কথা না বলেই। Lamport-এর paper এই total order দিয়েই একটি distributed mutual exclusion দেখায়। কিন্তু শেষ লাইনের প্রশ্নের উত্তর “না” — g আর b, f আর c কোনোটিই কারণ-কার্যে জড়িত নয়।

নিশ্চয়তা এক দিকে — ছোট প্রমাণ

Clock condition: a → b হলে L(a) < L(b). কারণ, happened-before-এর প্রতিটি ধাপ — একই process-এ পরের ঘটনা, অথবা বার্তা পাঠানো থেকে পাওয়া — নিয়ম ১ বা ২ অনুযায়ী L অন্তত ১ বাড়ায়; একাধিক ধাপ জুড়লে বৃদ্ধিও জোড়া লাগে। কিন্তু উল্টোটি সত্য নয়: L(g) = ১ < L(b) = ২, অথচ g ∥ b. দুই আলাদা process-এর counter স্বাধীনভাবে বাড়ে, তাই ছোট সংখ্যা শুধু বলে “b, g-এর আগে ঘটেনি” — এর বেশি কিছু নয়। Total order তাই কারণ-কার্যের সাথে সামঞ্জস্যপূর্ণ একটি ক্রম, কিন্তু concurrent জোড়ার ক্রম সেখানে ইচ্ছামতো বাছা — বাস্তব সময়ের সাথে তার মিল নাও থাকতে পারে।

৭ · Vector clock — আগে, পরে, না একসাথে

Lamport-এর সীমা কাটাতে প্রতিটি process শুধু নিজের counter নয়, সবার counter-এর একটি তালিকা রাখে — vector clock. ১৯৮৮ সালের দিকে Colin Fidge আর Friedemann Mattern আলাদাভাবে ধারণাটি প্রকাশ করেন। N-টি process থাকলে vector-এ N-টি ঘর; Pi-এর V[j] মানে “Pj-এর কতগুলো ঘটনার খবর আমি জানি”.

প্রতিটি ঘটনায়: V[নিজ] = V[নিজ] + 1 · বার্তার সাথে পুরো V যায়
বার্তা পেলে: প্রতিটি ঘর V[j] = max(V[j], বার্তার V[j]), তারপর V[নিজ] + 1
তিন process-এর vector (আর Lamport মান)
ঘটনা
P1
P2
P3
পথে বার্তা
  1. a: P1-এর local ঘটনা। P1 নিজের ঘর বাড়াল: [১,০,০]. Lamport মান ১.
  2. g: P3-এর local ঘটনা। P3 নিজের (তৃতীয়) ঘর বাড়াল: [০,০,১]. P1 আর P3 একে অপরের খবর জানে না।
  3. b: P1 বার্তা m1 পাঠাল। আগে নিজের ঘর বাড়াল — [২,০,০] — তারপর পুরো vector-এর একটি কপি m1-এর সাথে গেল।
  4. d: P2 m1 পেল। নিজের [০,০,০] আর বার্তার [২,০,০]-এর প্রতিটি ঘরের বড়টি নিল → [২,০,০], তারপর নিজের ঘর +১ → [২,১,০]. এখন P2 জানে P1-এর দুটি ঘটনার কথা।
  5. e: P2 m2 পাঠাল। নিজের ঘর বাড়ল: [২,২,০]; m2 এই কপি নিয়ে P3-এর দিকে চলল।
  6. f: P2-এর local ঘটনা। [২,৩,০]. খেয়াল করুন, m2 আগেই বেরিয়ে গেছে — তাই f-এর খবর m2-তে নেই।
  7. h: P3 m2 পেল। max([০,০,১], [২,২,০]) = [২,২,১], তারপর নিজের ঘর +১ → [২,২,২]. P3 এখন P1-এর ২টি আর P2-এর ২টি ঘটনা জানে, কিন্তু f জানে না।
  8. i: P3 m3 পাঠাল। [২,২,৩] যায় P1-এর দিকে।
  9. c: P1 m3 পেল। max([২,০,০], [২,২,৩]) = [২,২,৩], তারপর +১ → [৩,২,৩]. Lamport মান max(২, ৬) + ১ = ৭.
  10. তুলনা: f = [২,৩,০], c = [৩,২,৩]. P2-এর ঘরে f বড় (৩ > ২), P1 আর P3-এর ঘরে c বড়। কেউ কারও সব ঘরে ছোট-সমান নয় — তাই f ∥ c. Lamport-এর ৫ < ৭ যা লুকিয়েছিল, vector তা ধরে ফেলল।

দুটি vector তুলনার নিয়ম: সব ঘরে V(a) ≤ V(b) আর অন্তত একটিতে ছোট হলে a → b; উল্টো হলে b → a; কোনোটিই না হলে a ∥ b. এর শক্তি এক বাক্যে — V(a) < V(b) হলে এবং কেবল তখনই a → b, দুই দিকেই সত্য। কারণ V(b)[j] ঠিক সেই সংখ্যা, Pj-এর যতগুলো ঘটনা বার্তার কোনো শিকল ধরে b পর্যন্ত পৌঁছেছে; a সেই তালিকায় থাকলে তবেই V(a)-র প্রতিটি ঘর V(b)-র ঘরের সমান বা ছোট হয়। নিচের প্রোগ্রাম একই trace-এ কয়েকটি জোড়া যাচাই করে।

// Vector clocks on the same 3-process trace; classify pairs of events
const trace = [
  ["a", 0, "local"], ["g", 2, "local"],
  ["b", 0, "send", "m1"], ["d", 1, "recv", "m1"],
  ["e", 1, "send", "m2"], ["f", 1, "local"],
  ["h", 2, "recv", "m2"], ["i", 2, "send", "m3"],
  ["c", 0, "recv", "m3"],
];
const V = [[0, 0, 0], [0, 0, 0], [0, 0, 0]];  // V[p] = what process p knows of each process
const inFlight = {}, vc = {};
for (const [name, p, kind, msg] of trace) {
  if (kind === "recv") V[p] = V[p].map((x, j) => Math.max(x, inFlight[msg][j])); // merge
  V[p][p] += 1;                                  // tick own entry on every event
  if (kind === "send") inFlight[msg] = [...V[p]];   // message carries a copy
  vc[name] = [...V[p]];
}
console.log(Object.entries(vc).map(([n, v]) => `${n}=[${v}]`).join(" "));
function compare(x, y) {
  const le = x.every((v, j) => v <= y[j]), ge = x.every((v, j) => v >= y[j]);
  if (le && ge) return "same";
  if (le) return "before (->)";
  if (ge) return "after (<-)";
  return "concurrent (||)";
}
for (const [x, y] of [["a", "f"], ["b", "c"], ["e", "h"], ["c", "d"],
                      ["g", "b"], ["f", "h"], ["f", "c"]]) {
  console.log(`${x}=[${vc[x]}] vs ${y}=[${vc[y]}]  ${x} ${compare(vc[x], vc[y])} ${y}`);
}

আউটপুট:

a=[1,0,0] g=[0,0,1] b=[2,0,0] d=[2,1,0] e=[2,2,0] f=[2,3,0] h=[2,2,2] i=[2,2,3] c=[3,2,3]
a=[1,0,0] vs f=[2,3,0]  a before (->) f
b=[2,0,0] vs c=[3,2,3]  b before (->) c
e=[2,2,0] vs h=[2,2,2]  e before (->) h
c=[3,2,3] vs d=[2,1,0]  c after (<-) d
g=[0,0,1] vs b=[2,0,0]  g concurrent (||) b
f=[2,3,0] vs h=[2,2,2]  f concurrent (||) h
f=[2,3,0] vs c=[3,2,3]  f concurrent (||) c

প্রথম চারটি জোড়া কারণ-কার্যে জড়িত: a আর f-এর মধ্যে শিকল a → b → (m1) → d → e → f; c-র ক্ষেত্রে উল্টো দিকে d → e → (m2) → h → i → (m3) → c, তাই “c after d”. শেষ তিনটি concurrent — অথচ Lamport মানে g(১) < b(২), f(৫) = h(৫), f(৫) < c(৭)। অর্থাৎ Lamport মান সমান, ছোট বা বড় — কোনোটি দেখেই concurrency ধরা যায় না; vector দেখে যায়। কোডে vector-এর ঘর ০ থেকে গোনা, তাই প্রথম ঘর P1, দ্বিতীয় P2, তৃতীয় P3.

দাম: vector-এর আকার

প্রতিটি বার্তা আর প্রতিটি সংরক্ষিত সংস্করণে N-টি সংখ্যা যায়। ১০টি সার্ভারে সমস্যা নেই; কিন্তু যদি প্রতিটি mobile client বা প্রতিটি অস্থায়ী container নিজের ঘর পায়, vector অসীমভাবে বাড়ে। বাস্তব সিস্টেম তাই ঘর সীমিত রাখে — শুধু সার্ভার বা replica-দের ঘর দেয়, অথবা পুরোনো ঘর ছাঁটে। Amazon-এর Dynamo paper (২০০৭) অনুযায়ী তারা নির্দিষ্ট সংখ্যার (উদাহরণে ১০টি) ঘর ছাড়ালে সবচেয়ে পুরোনোটি বাদ দিত — দাম হলো মাঝে মাঝে concurrency ভুল চেনা।

Version vector বনাম vector clock

নাম দুটি প্রায়ই অদলবদল করে ব্যবহৃত হয়, কিন্তু উদ্দেশ্য আলাদা। Vector clock প্রতিটি ঘটনার কারণ-কার্য ধরে — প্রতিটি process, প্রতিটি ঘটনায় ঘর বাড়ায়। Version vector লাগানো থাকে একটি ডেটার সংস্করণে: ঘর থাকে প্রতিটি replica-র, আর ঘর বাড়ে শুধু সেই replica ওই ডেটায় নতুন লেখা নিলে; replica-রা মেলার সময় vector মেলায়। তুলনার নিয়ম একই — একটি সংস্করণ আরেকটিকে ঢেকে দিলে পুরোনোটি বাদ, concurrent হলে দুটিই রাখা হয়, যাদের বলে siblings. কে কোন নাম কোন অর্থে ব্যবহার করে, তা সিস্টেমভেদে আলাদা — Dynamo paper নিজেই “vector clock” বলে যা আসলে প্রতিটি object-এর version vector-এর কাছাকাছি। পুরো conflict-এর নকশা পর্ব ৪৬-এ, আর quorum-এ পড়ার সময় সংস্করণ মেলানো পর্ব ৪১-এ।

৮ · দুই জগতের মিলন — Hybrid logical clock আর TrueTime

Logical clock কারণ-কার্য রাখে, কিন্তু “গতকাল বিকেল ৩টার ডেটা দেখান” বলা যায় না। Physical clock সময় বলে, কিন্তু ক্রমে ভরসা নেই। দুটির সুবিধা একসাথে পাওয়ার দুটি প্রচলিত পথ আছে।

Hybrid logical clock (HLC)

Sandeep Kulkarni, Murat Demirbas ও সহকর্মীদের ২০১৪ সালের paper (“Logical Physical Clocks…”) hybrid logical clock প্রস্তাব করে। প্রতিটি timestamp দুই অংশের: l — এ পর্যন্ত দেখা সবচেয়ে বড় physical সময়, আর c — একই l-এ ক্রম রাখার ছোট counter. নিয়মগুলো মোটামুটি এমন:

  • নিজের ঘটনা বা পাঠানো: l = max(l, নিজের ঘড়ি)। l না বদলালে c + ১, বদলালে c = ০.
  • বার্তা পাওয়া: l = max(l, বার্তার l, নিজের ঘড়ি); যে উৎস থেকে বড় l এল, তার c থেকে + ১ (সমান হলে দুই c-র বড়টি + ১), আর নিজের ঘড়িই সবচেয়ে বড় হলে c = ০.

ফল: Lamport-এর মতোই a → b হলে HLC(a) < HLC(b), কিন্তু l আসল সময় থেকে বেশি দূরে যায় না — যতটুকু ঘড়ির পার্থক্য, প্রায় ততটুকু। তাই 64 bit-এর একটি সংখ্যায় বসানো যায়, আর “একটি সময়ের snapshot” চাওয়া যায়। Documentation অনুযায়ী CockroachDB HLC ব্যবহার করে; তবে সেখানেও সব node-এর ঘড়ির পার্থক্যের একটি সর্বোচ্চ সীমা ধরে নেওয়া হয়, আর কোনো node সেই সীমা ছাড়ালে নিজেকে বন্ধ করে — খুঁটিনাটি সংস্করণভেদে আলাদা। HLC vector clock-এর মতো concurrency চেনে না; শুধু কারণ-কার্যের সাথে সামঞ্জস্যপূর্ণ ক্রম দেয়।

TrueTime আর commit-wait

Google-এর Spanner paper (Corbett ও সহকর্মীরা, OSDI ২০১২) উল্টো পথে যায়: ঘড়ির ভুল লুকানোর বদলে তা মেপে API-তেই প্রকাশ করে। TrueTime-এর TT.now() একটি সংখ্যা নয়, একটি ব্যবধান দেয় — [earliest, latest]; প্রতিশ্রুতি হলো আসল সময় এর ভেতরে আছে। Paper অনুযায়ী ডেটা সেন্টারগুলোতে GPS আর পারমাণবিক ঘড়ির time master থাকে; অনিশ্চয়তা ε তখন প্রায় ১ থেকে ৭ ms-এর মধ্যে ওঠানামা করত, গড়ে প্রায় ৪ ms. এরপর commit-wait: transaction-এর timestamp s = TT.now().latest বসিয়ে, আর ততক্ষণ অপেক্ষা যতক্ষণ না TT.now().earliest > s হয়।

আসল সময় আর TrueTime-এর ব্যবধান (ms)
আসল সময়
TT.now()
T1
T2
  1. Timestamp বাছাই। আসল সময় ১০০ ms-এ (যা কেউ জানে না) T1 সব lock নিয়ে commit করতে প্রস্তুত। TT.now() = [৯৬, ১০৪]. Spanner-ধাঁচে s1 = latest = ১০৪ — নিশ্চিতভাবে আসল সময়ের সমান বা পরে।
  2. Commit-wait. আসল ১০৪-এ TT.now() = [১০০, ১০৮]; earliest ১০০ এখনো ১০৪-এর বেশি নয় — অপেক্ষা চলছে। Lock ধরা থাকে, তাই এই ডেটায় অন্য কেউ লিখতে পারে না।
  3. অপেক্ষা শেষ। আসল ১০৮.০১-এ earliest ১০৪.০১ > ১০৪. এখন নিশ্চিত যে আসল সময় s1 পেরিয়ে গেছে। Commit দৃশ্যমান হলো, lock ছাড়া হলো, client “OK” পেল। অপেক্ষা ≈ ৮ ms = ২ε.
  4. পরের transaction. Client OK পাওয়ার পর T2 শুরু করল আসল ১১০-এ: TT.now() = [১০৬, ১১৪], s2 = ১১৪. যেকোনো transaction যা বাস্তবে T1 শেষ হওয়ার পরে শুরু হয়, তার timestamp T1-এর চেয়ে বড় হবেই — timestamp-এর ক্রম আর বাস্তব সময়ের ক্রম মিলে গেল। Paper একে বলে external consistency; ধারণাটি strict serializability-র সমতুল্য।
  5. দাম ঘড়ির মানের ওপর। একই নকশা সাধারণ ইন্টারনেট-NTP-তে, ধরা যাক ε = ৫০ ms হলে, প্রতিটি লেখার commit-এ প্রায় ১০০ ms অপেক্ষা। Google এজন্যই GPS আর পারমাণবিক ঘড়িতে বিনিয়োগ করে; আর ε-র সীমা ভাঙলে (খারাপ ঘড়ি) পুরো নিশ্চয়তাই ভাঙে, তাই paper অনুযায়ী বেশি সরে যাওয়া যন্ত্রকে বাদ দেওয়া হয়।

আরও গভীরে: Spanner paper-এ প্রতিটি যন্ত্রের time daemon প্রতি ৩০ সেকেন্ডে time master-দের কাছ থেকে সময় নেয় আর মাঝে সবচেয়ে খারাপ drift ধরা হয় ২০০ µs/s — ৩০ সেকেন্ডে ৬ ms; সাথে প্রায় ১ ms যোগাযোগের অনিশ্চয়তা যোগ হয়ে ε-র ১ থেকে ৭ ms-এর করাতের দাঁতের মতো রূপ আসে। পরে কিছু cloud প্রদানকারী, যেমন AWS, microsecond-মাপের ঘড়ি-সেবা আর ভুলের সীমা জানানোর open-source টুল (ClockBound) ঘোষণা করেছে — খুঁটিনাটি সময়ের সাথে বদলায়, প্রদানকারীর documentation দেখুন। Causal consistency-র গবেষণায় (যেমন COPS, SOSP ২০১১) dependency-ট্র্যাকিং দিয়ে vector-এর খরচ কমানোর চেষ্টা আছে।

৯ · সময়ক্রমের ID — Snowflake, ULID আর UUIDv7

অনেক সিস্টেমে ক্রমের সবচেয়ে চোখে পড়া রূপ হলো ID: নতুন tweet, order বা message-এর ID যদি মোটামুটি সময়ক্রমে বাড়ে, তাহলে “নতুনগুলো আগে” query সস্তা আর B-tree index-এ লেখা এক প্রান্তে জমে। কেন্দ্রীয় counter ছাড়া ID বানানোর পুরো তুলনা — UUID v4, ticket server, ব্লক — পর্ব ২৩ — Partitioning আর sharding-এ আছে। এখানে দেখব ID-র ভেতরে সময় কীভাবে বসে আর তার ক্রমের নিশ্চয়তা কতটুকু।

প্রকাশিত engineering blog আর open-source কোড অনুযায়ী Twitter ২০১০ সালে Snowflake চালু করে; তার README-তে গঠন: 41 bit millisecond-এ সময় (নিজের বাছা একটি epoch থেকে), 10 bit যন্ত্রের নম্বর, 12 bit ক্রমিক সংখ্যা — ওপরের 1 bit খালি রাখা হয়, যাতে signed 64-bit সংখ্যা ধনাত্মক থাকে। নিচের প্রোগ্রাম একটি ID বানিয়ে bit-গুলো ভাগ করে দেখায়, ফেরত পড়ে, আর এক millisecond-এ এক যন্ত্র কয়টি ID দিতে পারে তা গুনে যাচাই করে।

// Snowflake-style 64-bit ID: 1 unused | 41-bit ms since epoch | 10-bit machine | 12-bit sequence
const EPOCH = 1288834974657n;          // the custom epoch in Twitter's published code (Nov 2010)
const encode = (ms, machine, seq) =>
  ((BigInt(ms) - EPOCH) << 22n) | (BigInt(machine) << 12n) | BigInt(seq);
const decode = id => ({ ms: (id >> 22n) + EPOCH, machine: (id >> 12n) & 0x3FFn, seq: id & 0xFFFn });
const ms = Date.UTC(2026, 9, 28, 4, 0, 0, 123);   // fixed clock reading for the demo
const id = encode(ms, 39, 5);
const bits = id.toString(2).padStart(64, "0");
console.log("id      = " + id + "  (" + id.toString().length + " decimal digits)");
console.log("sign    = " + bits.slice(0, 1));
console.log("time    = " + bits.slice(1, 42) + "  (41 bits)");
console.log("machine = " + bits.slice(42, 52) + "  (10 bits)");
console.log("seq     = " + bits.slice(52) + "  (12 bits)");
const d = decode(id);
console.log("decoded: " + new Date(Number(d.ms)).toISOString() + " machine=" + d.machine + " seq=" + d.seq);
let last = -1, seq = 0, made = 0, refused = 0;   // a generator on one machine
function next(now) {
  if (now < last) { refused++; return null; }    // clock went backwards: refuse (or wait)
  if (now === last) { seq = (seq + 1) & 0xFFF; if (seq === 0) return null; } // sequence full
  else seq = 0;
  last = now; made++; return encode(now, 39, seq);
}
while (next(ms) !== null) {}                      // keep asking within the SAME millisecond
console.log("IDs made in one ms on one machine: " + made + " = 2^12 = " + 2 ** 12);
next(ms - 3);                                    // NTP stepped the clock back by 3 ms
console.log("refused after clock moved back 3 ms: " + refused);
console.log("41-bit time lasts " + (2 ** 41 / (365.25 * 864e5)).toFixed(1) + " years; max per machine " +
  (4096 * 1000).toLocaleString("en-US") + "/s");

আউটপুট:

id      = 2115292471612305413  (19 decimal digits)
sign    = 0
time    = 00111010101101100000111010010111010111010  (41 bits)
machine = 0000100111  (10 bits)
seq     = 000000000101  (12 bits)
decoded: 2026-10-28T04:00:00.123Z machine=39 seq=5
IDs made in one ms on one machine: 4096 = 2^12 = 4096
refused after clock moved back 3 ms: 1
41-bit time lasts 69.7 years; max per machine 4,096,000/s

Machine-এর ১০টি bit 0000100111 = ৩৯, sequence-এর 000000000101 = ৫ — ফেরত পড়লে হুবহু মিলল। একই millisecond-এ চাইতে থাকলে ঠিক ৪,০৯৬টি ID হয় (212); ৪,০৯৭তম চাইলে generator-কে পরের millisecond-এর অপেক্ষা করতে হয়। তাই এক যন্ত্রের সর্বোচ্চ হার সেকেন্ডে ৪০,৯৬,০০০. ঘড়ি ৩ ms পেছালে generator ID দিতে অস্বীকার করল — না করলে একই সময়, একই যন্ত্র আর একই sequence আবার এসে হুবহু একই ID দুবার তৈরি হতে পারত। ২০১০-এর epoch থেকে 41 bit সময় চলবে প্রায় ৬৯.৭ বছর। ID ১৯ অঙ্কের, যা JavaScript-এর Number-এ নির্ভুল ধরে না — তাই কোডে BigInt, আর JSON-এ এমন ID string হিসেবে পাঠানো নিরাপদ।

ক্রমের নিশ্চয়তা কতটুকু? একই যন্ত্রের ID কঠোরভাবে বাড়ে (ঘড়ি পেছালে অপেক্ষা করলে)। আলাদা যন্ত্রের ID-র ক্রম শুধু তাদের ঘড়ির মতো ভালো — ১০ ms skew থাকলে ১০ ms-এর মধ্যে তৈরি ID উল্টো ক্রমে আসতে পারে; এই “প্রায় সাজানো” অবস্থার নাম k-sorted. তাই ID-র ক্রম দিয়ে “কে আগে টিকিট চাপল” ধরনের ন্যায্যতার সিদ্ধান্ত নেওয়া চলে না; তার জন্য একটি জায়গায় ক্রম দরকার — database transaction বা একটি log partition.

128 bit-এর দুটি জনপ্রিয় বিকল্প: ULID — community spec অনুযায়ী 48 bit millisecond সময় + 80 bit এলোমেলো অংশ, Crockford base32-এ ২৬ অক্ষরে লেখা, যাতে লেখা অবস্থাতেই সময়ক্রমে সাজে; আর UUIDv7 — RFC 9562 (২০২৪)-এ মানসম্মত, প্রথম 48 bit-এ Unix millisecond. দুটিতেই যন্ত্রের নম্বর বিলি করতে হয় না, কারণ এলোমেলো অংশ যথেষ্ট বড়; দাম হলো দ্বিগুণ আকার। তিনটিতেই ID থেকে সৃষ্টির সময় পড়া যায় — গোপনীয়তার প্রশ্ন থাকলে মনে রাখুন।

পরীক্ষা ও ইন্টারভিউতে — বিশ্ববিদ্যালয়, চাকরি বা system design ইন্টারভিউর পরীক্ষায় এই ধরনের প্রশ্ন আসতে পারে:

  • t1..t4 দেওয়া, NTP offset আর delay বের করুন। θ = [(t2 − t1) + (t3 − t4)] ÷ ২, δ = (t4 − t1) − (t3 − t2); ভুলের সীমা ± δ/২.
  • Lamport clock-এ L(a) < L(b) হলে কি a → b? না; শুধু উল্টো দিক সত্য। Concurrency চিনতে vector clock লাগে।
  • দুটি vector দেখে সম্পর্ক বলুন। সব ঘরে ≤ আর অন্তত একটিতে < হলে আগে; মিশ্র হলে concurrent.
  • Latency বা timeout কোন ঘড়িতে মাপবেন? Monotonic; wall clock NTP step বা leap second-এ পেছাতে পারে।
  • Spanner কেন commit-wait করে, আর তার দাম কত? Timestamp-এর ক্রম যেন বাস্তব সময়ের ক্রম মানে; অপেক্ষা প্রায় ২ε — ε ৪ ms হলে প্রায় ৮ ms.

১০ · AI-এর যুগে — সময় আর ক্রমের নকশায় AI-কে যাচাই

“Distributed system-এ event ordering কীভাবে করব?” জিজ্ঞেস করলে AI প্রায়ই দ্রুত উত্তর দেয়: “সব সার্ভারে NTP চালান আর timestamp দিয়ে sort করুন” — সাথে হয়তো “Kafka আর Redis যোগ করুন”। শুনতে সহজ, কিন্তু এই বিষয়ে AI-এর ভুলগুলো বেশ নিয়মিত:

AI-এর ভুলকেন ভুলকীভাবে ধরবেন
“NTP থাকলে timestamp দিয়ে ক্রম ঠিক থাকে”Skew-এর চেয়ে কাছাকাছি ঘটনা উল্টো আসে; NTP বন্ধ হলে কেউ টেরও পায় নাঅংশ ৫-এর LWW উদাহরণে নিজের skew বসিয়ে হিসাব করুন
“Lamport clock দিয়ে concurrent লেখা চেনা যায়”L(a) < L(b) কারণ-কার্য প্রমাণ করে নাকোড ২ আর ৩ পাশাপাশি চালিয়ে f আর c জোড়াটি দেখুন
Date.now() বা time.time() দিয়ে timeout, lease বা latencyWall clock পেছালে ঋণাত্মক সময়কাল, আটকে থাকা timeoutকোডে প্রতিটি সময়-বিয়োগ খুঁজে দেখুন কোন ঘড়ি
বানানো সংখ্যা: “NTP সবসময় ১ ms-এর মধ্যে”, TrueTime-এর ε, ঘড়ি-সেবার নির্ভুলতানির্ভুলতা নেটওয়ার্ক, hardware আর প্রদানকারীভেদে আলাদামূল paper, RFC আর প্রদানকারীর documentation; নিজের যন্ত্রে offset মাপুন
Version vector আর vector clock গুলিয়ে ফেলা; ACID-এর consistency আর ক্রমের নিশ্চয়তা মিশিয়ে ফেলাএকটি ডেটার সংস্করণ, অন্যটি ঘটনার কারণ-কার্য; ACID-এর C ডেটার নিয়ম রক্ষাজিজ্ঞেস করুন: vector-এর ঘর কার, আর কখন বাড়ে?
ছোট সেবায় TrueTime, vector clock আর consensus একসাথে — অতিরিক্ত নকশাএকটি leader বা log partition-এর sequence number প্রায়ই যথেষ্টপ্রশ্ন করুন: সত্যিই কি বহু জায়গায় একসাথে লেখা হয়?

AI-কে পরিস্থিতি, সংখ্যা আর শর্ত দিন, আর প্রতিটি সিদ্ধান্তের failure mode চান:

তুমি একজন অভিজ্ঞ distributed systems ইঞ্জিনিয়ার। একটি কাল্পনিক ride-sharing অ্যাপের জন্য ঘটনার ক্রম রাখার নকশা করো। তথ্য: তিনটি region-এ সার্ভার; চালকের অবস্থান প্রতি ৪ সেকেন্ডে আসে; ভাড়া আর trip-এর অবস্থা (requested, accepted, started, finished) একাধিক সার্ভারে বদলাতে পারে। NTP-তে সার্ভারগুলোর skew ধরো সর্বোচ্চ ২০ ms. ১. কোন ডেটায় wall-clock timestamp যথেষ্ট, কোথায় একটি জায়গার sequence number, কোথায় vector বা version vector লাগবে — কারণসহ। ২. দুই সার্ভারে প্রায় একসাথে trip বাতিল আর trip শুরু এলে কী ঘটবে, last-write-wins হলে কী হারাতে পারে। ৩. Timeout আর lease কোন ঘড়িতে মাপবে, NTP ঘড়ি পেছালে কী হবে। ৪. প্রতিটি হিসাব ধাপে ধাপে দেখাও (যেমন ২০ ms skew-এ কোন ঘটনা উল্টো ক্রমে আসতে পারে)। ৫. কোনো পণ্যের নির্ভুলতা, default বা দাম নিজে থেকে লিখবে না; দরকার হলে লিখবে "documentation-এ যাচাই করতে হবে"। সব অনুমান আলাদা তালিকায় দাও।

AI-এর দেওয়া ক্রম-নকশা হাতে পেলে — যাচাইয়ের চেকলিস্ট

ঘড়ি বলে কখন, বার্তা বলে কার পরে।
ক্রম চাইলে আগে জিজ্ঞেস করুন — কে কার খবর জানত?

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

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

প্র ০১Lamport-এর ১৯৭৮ সালের paper-কে কেন distributed systems-এর ভিত্তি বলা হয়? শুধু আরও ভালো ঘড়ি বানালেই কি চলত না?

Lamport নিজে বলেছেন, ধারণাটি তিনি পেয়েছিলেন আপেক্ষিকতার বিশেষ তত্ত্ব থেকে। সেখানে দুটি দূরের ঘটনার “একই সময়ে” ঘটা দর্শকভেদে আলাদা; কিন্তু একটি থেকে আরেকটিতে আলো বা কোনো সংকেত পৌঁছাতে পারলে তাদের ক্রম সবার কাছে এক। Distributed system-এ বার্তাই সেই সংকেত। তাই “কোনটা আগে” প্রশ্নের নিশ্চিত উত্তর আছে কেবল সেই জোড়াগুলোর, যাদের মধ্যে বার্তার শিকল আছে — বাকিগুলোর কোনো “সত্যিকারের” ক্রমই নেই, যত ভালো ঘড়িই থাকুক।

“ভালো ঘড়ি” যথেষ্ট নয়, কারণ ঘড়ি যত ভালোই হোক, ভুলের একটি সীমা থাকে, আর সেই সীমার ভেতরের ঘটনার ক্রম ঘড়ি বলতে পারে না। তার চেয়েও বড় কথা, ঘড়ি ভুল হলে সেটি নিঃশব্দে ভুল — সিস্টেম জানতেই পারে না। Logical clock-এর নিশ্চয়তা কোনো hardware-এর ওপর নির্ভর করে না; শুধু বার্তার সাথে একটি সংখ্যা পাঠালেই হয়।

একই paper আরও দুটি দীর্ঘস্থায়ী ধারণা দেয়। এক, total order দিয়ে distributed mutual exclusion — কেন্দ্রীয় lock সার্ভার ছাড়াই। দুই, state machine replication-এর বীজ: সব replica যদি একই ক্রমে একই command চালায়, সবাই একই অবস্থায় থাকবে। আজকের Raft আর Paxos-ভিত্তিক সিস্টেম (পর্ব ৪২) এই ধারণারই উত্তরসূরি — তারা consensus দিয়ে সেই একক ক্রম ঠিক করে। Lamport পরে Paxos-ও আবিষ্কার করেন, আর ২০১৩ সালের ACM Turing Award পান; পুরস্কারের ঘোষণায় distributed system-এর তত্ত্বে তাঁর অবদানের কথাই প্রধান।

ব্যবহারিক দিক থেকে শিক্ষা হলো: নকশায় “সময়” শব্দটি দেখলে জিজ্ঞেস করুন, এখানে আসলে কী চাই — মানুষের কাছে অর্থবহ তারিখ, নাকি কারণ-কার্যের ক্রম? প্রথমটির জন্য physical clock, দ্বিতীয়টির জন্য logical clock বা একক sequencer.

মূল উপলব্ধি: distributed system-এ ক্রম আসে তথ্যের প্রবাহ থেকে, ঘড়ি থেকে নয়। কে কার খবর জানত — সেটিই “আগে-পরে”-র আসল সংজ্ঞা।

প্র ০২একটি মাত্র বাড়তি সেকেন্ড কীভাবে বড় বড় সেবা বন্ধ করে দিয়েছিল? Leap second বন্ধের সিদ্ধান্ত কেন?

Leap second-এর সমস্যা সেকেন্ডটি নয়, তার বিরলতা। কয়েক বছরে একবার আসে, তাই সেই পথের কোড প্রায় কখনো চলে না, পরীক্ষাও হয় না। ২০১২ সালের ৩০ জুনের leap second-এ Linux kernel-এর timer-সংক্রান্ত একটি ত্রুটিতে অনেক সার্ভারে প্রোগ্রাম অবিরাম জেগে উঠে CPU ১০০% করে ফেলে; Java-ভিত্তিক বহু সেবা আর কয়েকটি বড় ওয়েবসাইট ক্ষতিগ্রস্ত হয়। সমাধান ছিল অদ্ভুত সরল — ঘড়ি আবার সেট করা।

২০১৭ সালের ১ জানুয়ারিতে Cloudflare-এর ঘটনা, তাদের প্রকাশিত বিশ্লেষণ অনুযায়ী, আরও শিক্ষণীয়। তাদের DNS সার্ভারের Go কোডে দুটি wall-clock মানের বিয়োগফল ধরে নেওয়া হয়েছিল কখনো ঋণাত্মক হবে না। Leap second-এ ঘড়ি এক সেকেন্ড পেছাল, বিয়োগফল ঋণাত্মক হলো, আর সেই মান একটি random-সংখ্যার ফাংশনে গিয়ে panic ঘটাল। সেই সময় Go-র standard time library-তে সময়কাল মাপার জন্য আলাদা monotonic মান ছিল না; পরে, ২০১৭ সালেরই Go 1.9-এ time.Time-এর ভেতরে monotonic মান যোগ হয় — অনেকটা এই ধরনের ঘটনার কারণেই।

দুটি প্রতিকার প্রচলিত হয়েছে। এক, smearing — বাড়তি সেকেন্ডটিকে কয়েক ঘণ্টা থেকে এক দিনে ছড়িয়ে দেওয়া, যাতে ঘড়ি কখনো পেছায় না; Google, পরে আরও কিছু cloud প্রদানকারী এটি করে, যদিও smear-এর সময়কাল আর আকার প্রদানকারীভেদে আলাদা। দুই, মূল সমস্যা দূর করা: ২০২২ সালের নভেম্বরে CGPM সিদ্ধান্ত নেয়, ২০৩৫ সালের মধ্যে UTC-তে leap second যোগ বন্ধ হবে, আর সূর্যের সময়ের সাথে পার্থক্য অনেক বড় সীমা পর্যন্ত জমতে দেওয়া হবে। বড় প্রযুক্তি প্রতিষ্ঠানগুলোর engineering blog-ও প্রকাশ্যে এই দাবি তুলেছিল।

নকশার শিক্ষা দুটি। প্রথমত, যে ঘটনা বছরে একবারও ঘটে না, তার জন্য আগেই পরীক্ষা চালাতে হয় — যেমন test পরিবেশে ঘড়ি ইচ্ছা করে পিছিয়ে দেওয়া। দ্বিতীয়ত, সময়কাল আর তারিখের জন্য আলাদা ঘড়ি — এটি শুধু “ভালো অভ্যাস” নয়, বাস্তব দুর্ঘটনা থেকে শেখা নিয়ম।

মূল উপলব্ধি: wall clock পেছাতে পারে — এটি ধরে নিয়েই কোড লিখুন। সময়কাল monotonic ঘড়িতে, আর বিরল ঘটনার পথ আগেভাগে পরীক্ষা করুন।

প্র ০৩Spanner GPS আর পারমাণবিক ঘড়িতে বিনিয়োগ করল, আর অনেক open-source database HLC দিয়ে চলে। দুই পথের trade-off কী?

দুই পথই একই প্রশ্নের উত্তর খোঁজে: transaction-গুলোকে এমন timestamp কীভাবে দেওয়া যায়, যাতে timestamp-এর ক্রম মানলেই consistent snapshot পড়া যায়, আর বাস্তবে আগে শেষ হওয়া transaction-এর timestamp ছোট হয়।

Spanner-এর পথ: ঘড়ির অনিশ্চয়তা ε ছোট আর নিশ্চিত করা, তারপর প্রতিটি লেখার commit-এ প্রায় ২ε অপেক্ষা। Paper অনুযায়ী ε গড়ে প্রায় ৪ ms, তাই অপেক্ষা কয়েক ms — আর সেই অপেক্ষা প্রায়ই replication-এর consensus round-এর সাথে একসাথে চলে, ফলে বাড়তি দাম আরও কম। বিনিময়ে পাওয়া যায় external consistency: বাস্তব সময়ে T1 শেষ হওয়ার পরে T2 শুরু হলে T2-র timestamp বড়ই হবে — বিশ্বের যেকোনো দুই ডেটা সেন্টারে। দাম হলো বিশেষ hardware, তার পরিচালনা, আর ঘড়ির সীমা ভাঙলে নিশ্চয়তা ভাঙার ঝুঁকি।

HLC-এর পথ: সাধারণ NTP ঘড়ি, তার ওপর HLC — কারণ-কার্য মানা হয়, timestamp আসল সময়ের কাছে থাকে। কিন্তু ε বড় (দশ বা শত ms), তাই প্রতিটি লেখায় ২ε অপেক্ষা অসম্ভব রকম ধীর। CockroachDB-র মতো সিস্টেম তাই documentation অনুযায়ী একটি “uncertainty interval” রাখে: পড়ার সময় কোনো মান যদি নিজের timestamp-এর একটু পরে, কিন্তু অনিশ্চয়তার সীমার ভেতরে লেখা হয়ে থাকে, তাহলে পড়াটি বড় timestamp নিয়ে আবার চালানো হয়। অর্থাৎ অপেক্ষার দাম লেখার বদলে মাঝে মাঝে পড়ায়। তাদের প্রকাশিত লেখা অনুযায়ী এতে serializable isolation পাওয়া যায়, কিন্তু সব ক্ষেত্রে Spanner-এর মতো external consistency-র দাবি নেই; খুঁটিনাটি সংস্করণভেদে আলাদা।

এখন কিছু cloud প্রদানকারী microsecond-মাপের ঘড়ি-সেবা আর ভুলের সীমা জানানোর টুল দিচ্ছে, ফলে দুই পথের দূরত্ব কমছে: ভুলের সীমা জানা থাকলে যেকোনো সিস্টেম TrueTime-ধাঁচের অপেক্ষা ছোট খরচে করতে পারে।

মূল উপলব্ধি: ঘড়ির অনিশ্চয়তা কোথাও না কোথাও দাম নেয় — hardware-এ, লেখার অপেক্ষায়, বা পড়ার পুনরাবৃত্তিতে। নকশার প্রশ্ন হলো দামটা কোথায় দিলে আপনার workload-এর সবচেয়ে কম ক্ষতি।

প্র ০৪Vector clock concurrency ধরতে পারে, Lamport পারে না। তাহলে সবাই কেন vector clock ব্যবহার করে না?

কারণ তিনটি — আকার, পরিচয় আর প্রয়োজন।

আকার: vector-এ প্রতিটি অংশগ্রহণকারীর একটি ঘর। Dynamo-ধাঁচের database-এ শুরুতে ঘর দেওয়া হতো যে node লেখা নিল তার নামে, কিন্তু client-ভিত্তিক বা অস্থায়ী node-ভিত্তিক নকশায় ঘর বাড়তেই থাকে। Dynamo paper (২০০৭) নির্দিষ্ট সংখ্যার বেশি ঘর হলে পুরোনোটি ছেঁটে ফেলার কথা বলে — এতে কখনো দুটি সংস্করণকে concurrent ভাবা হয়, যদিও একটি আরেকটির পরের। Riak-এর মতো সিস্টেম পরে আরও উন্নত রূপ (dotted version vector) গ্রহণ করে বলে তাদের documentation-এ পাওয়া যায়, যাতে একই client-এর পরপর লেখা ভুল করে sibling না হয়।

পরিচয়: একটি ঘর মানে একটি স্থায়ী পরিচয়। Container আর autoscaling-এর যুগে node আসে-যায়; চলে যাওয়া node-এর ঘর কখন মোছা নিরাপদ, তা ঠিক করাও একটি coordination সমস্যা (পর্ব ৪৪)।

প্রয়োজন: concurrency চেনা দরকার কেবল সেখানে, যেখানে একাধিক জায়গায় একসাথে লেখা হয় আর পরে মেলাতে হয় — multi-leader বা leaderless replication, offline-সক্ষম অ্যাপ, collaborative editing. বেশিরভাগ সিস্টেম লেখা এক জায়গায় পাঠিয়ে সমস্যাটিই এড়ায়: একটি leader বা একটি log partition একটি ক্রমিক নম্বর দেয়, কোনো concurrency-ই তৈরি হয় না (পর্ব ৩৩, পর্ব ৪২)। দাম হলো সেই এক জায়গার latency আর availability.

তাই প্রকৌশলের বাস্তব চিত্র: একটি sequencer যেখানে চলে, সেখানে sequencer; একসাথে লেখা অনিবার্য হলে version vector আর মেলানোর নিয়ম, বা এমন data type যা মেলাতে কোনো নিয়মই লাগে না — CRDT (পর্ব ৪৬)।

মূল উপলব্ধি: concurrency চেনার ক্ষমতার দাম আছে। আগে জিজ্ঞেস করুন concurrency তৈরি হতেই দেবেন কি না; দিলে তবেই vector-এর খরচ বহন করুন।

প্র ০৫ID-র ভেতরে সময় রাখা — Snowflake, ULID, UUIDv7 — এর সুবিধা কী, আর কোথায় এই ক্রমের ওপর ভরসা করা ভুল?

সময়-প্রথম ID-র বড় সুবিধা storage-এ। B-tree index-এ নতুন key ডান প্রান্তে বসে, তাই memory-তে থাকা শেষের পাতাগুলোতেই লেখা জমে; এলোমেলো UUID v4-এ প্রতিটি লেখা কোনো এলোমেলো পাতায় যায় (পর্ব ১৯)। “সাম্প্রতিক ১০০টি” query-ও সস্তা, কারণ ID-র ক্রমই মোটামুটি সময়ের ক্রম। Twitter Snowflake প্রকাশ করার পর বহু প্রতিষ্ঠান একই ধাঁচ নেয়; যেমন Instagram-এর প্রকাশিত engineering blog (২০১২) অনুযায়ী তারা PostgreSQL-এর ভেতরেই ৪১ bit সময়, ১৩ bit logical shard আর ১০ bit ক্রমিক সংখ্যার ID বানাত।

কিন্তু ক্রমের নিশ্চয়তা দুর্বল। এক, আলাদা যন্ত্রের ID-র ক্রম তাদের ঘড়ির skew-এর সমান অনিশ্চিত — ১০ ms skew থাকলে কাছাকাছি সময়ের ID উল্টো আসতে পারে। দুই, একই millisecond-এ আলাদা যন্ত্রের ক্রম ঠিক করে যন্ত্রের নম্বর, সময় নয়। তিন, ঘড়ি পেছালে generator হয় অপেক্ষা করে, নয় অস্বীকার করে; না করলে ID মিলে যেতে পারে। ULID-এর spec-এ একই millisecond-এ ক্রম রক্ষার জন্য এলোমেলো অংশ বাড়িয়ে নেওয়ার একটি “monotonic” বিকল্প আছে, আর RFC 9562-ও একই millisecond-এ ক্রম রক্ষার কয়েকটি পদ্ধতি বর্ণনা করে — কিন্তু দুটিই শুধু এক generator-এর ভেতরে কাজ করে।

তাই ID-র ক্রম ব্যবহার করুন সুবিধার জন্য (index, মোটামুটি সাজানো তালিকা), সঠিকতার জন্য নয়। ঈদের আগে বাসের টিকিটের ভিড়ে “কে আগে চাপল” ঠিক করতে ID-র ক্রম নয়, একটি database transaction বা একটি partition-এর ক্রম লাগবে (পর্ব ৬০)। আরেকটি দিক গোপনীয়তা: ID থেকে সৃষ্টির সময় পড়া যায়, আর ক্রমিক অংশ থেকে ব্যবসার পরিমাণ আন্দাজ করা যায় — বাইরে দেখানো ID-তে এটি বিবেচনা করুন (পর্ব ৫৪)।

মূল উপলব্ধি: সময়ক্রমের ID “প্রায় সাজানো” — storage-এর জন্য চমৎকার, ন্যায্যতা বা হিসাবের ক্রমের জন্য অপর্যাপ্ত। নিখুঁত ক্রম চাইলে একটি জায়গায় ক্রম দিন।

অনুশীলন

হাতে হিসাব করুন, তারপর উত্তর মিলিয়ে নিন — সিমুলেটরেও যাচাই করতে পারেন।

  1. হিসাব — NTP আর drift: একটি client পেল t1 = ১০:০০:০০.০০০ (client), t2 = ১০:০০:০০.০৭৫ (server), t3 = ১০:০০:০০.০৭৭ (server), t4 = ১০:০০:০০.০৩০ (client) পেল। (ক) Offset আর delay কত, ভুলের সীমা কত? (খ) Client-এর ঘড়ির drift ২০ ppm হলে, ঘড়িকে আসল সময়ের ৫ ms-এর মধ্যে রাখতে কত ঘন ঘন sync লাগবে (NTP-র নিজের ভুল বাদ দিয়ে)?

    (ক) t2 − t1 = ৭৫ ms, t3 − t4 = ৭৭ − ৩০ = ৪৭ ms. Offset θ = (৭৫ + ৪৭) ÷ ২ = ৬১ ms — সার্ভারের ঘড়ি client-এর চেয়ে প্রায় ৬১ ms এগিয়ে, তাই client-কে ঘড়ি ৬১ ms এগোতে হবে। Delay δ = (৩০ − ০) − (৭৭ − ৭৫) = ২৮ ms. ভুলের সীমা ± δ/২ = ± ১৪ ms, মানে আসল offset ৪৭ থেকে ৭৫ ms-এর মধ্যে। ৬১ ms-এর offset ntpd-এর ডিফল্ট step-সীমা (প্রায় ১২৮ ms)-এর নিচে, তাই সাধারণত slew করে মেলানো হবে।

    (খ) 5 ms ÷ (20 × 10−6) = ২৫০ সেকেন্ড — প্রায় ৪ মিনিটে একবার। বাস্তবে NTP-র নিজের ভুল (এখানে ±১৪ ms!) এই সীমার চেয়ে বড়, তাই কম delay-এর কাছের সার্ভার বেছে নেওয়াই আসল সমাধান; শুধু ঘন ঘন sync করলে চলবে না।

  2. আউটপুট অনুমান করুন: দুটি process. P1-এ পরপর: x (local), y (P2-কে বার্তা m পাঠানো)। P2-এ পরপর: u (local), v (local), w (m পাওয়া)। Lamport আর vector clock-এর নিয়ম মেনে চললে নিচের লাইনগুলোতে কী ছাপা হবে? L: x y u v w, V(y), V(v), V(w), আর (y, v) ও (x, w) জোড়ার সম্পর্ক। (L-এ সমান হলে ছোট process নম্বর আগে ধরে total order-ও লিখুন।)

    Lamport: x = ১, y = ২ (m বহন করে ২), u = ১, v = ২, w = max(২, ২) + ১ = ৩. অর্থাৎ L: 1 2 1 2 3.

    Vector [P1, P2]: x = [১,০], y = [২,০], u = [০,১], v = [০,২], w = max([০,২], [২,০]) = [২,২], তারপর নিজের ঘর +১ → [২,৩]. অর্থাৎ V(y)=[2,0] V(v)=[0,2] V(w)=[2,3].

    সম্পর্ক: y = [২,০] আর v = [০,২] — একটিতে বড়, অন্যটিতে ছোট, তাই y ∥ v, যদিও দুজনেরই L = ২. x = [১,০] ≤ w = [২,৩] সব ঘরে, তাই x → w — শিকলটি x → y → m → w. Total order (L, process): x(১.১) < u(১.২) < y(২.১) < v(২.২) < w(৩.২)। প্রোগ্রাম ২ আর ৩-এর trace বদলে চালালে একই ফল পাবেন।

  3. নকশা সমালোচনা: একটি কাল্পনিক অনলাইন মুদি-বাজার সেবায় cart দুই region-এর দুটি সার্ভারে লেখা যায়, অর্থাৎ multi-leader. প্রতিটি পরিবর্তনে পুরো cart লেখা হয়, সাথে সার্ভারের Date.now(); replica মেলানোর সময় বড় timestamp-এর cart থাকে। একই সার্ভিসে session-এর মেয়াদ হিসাব হয় Date.now() − loginTime দিয়ে। কী কী ভুল হতে পারে, আর আপনি কী বদলাবেন?
    • LWW-এ জিনিস হারায়: একজন ফোনে ডিম যোগ করলেন (region A), প্রায় একসাথে পরিবারের আরেকজন ল্যাপটপে চাল যোগ করলেন — region B-তে। দুই cart concurrent, কিন্তু LWW একটিকে পুরো ফেলে দেয়। আর A-এর ঘড়ি এগিয়ে থাকলে পরের লেখাও হারায় (অংশ ৫)।
    • প্রতিকার: cart-এ version vector রাখুন, concurrent হলে দুটিই রেখে মেলান (যোগ করা জিনিসের union); অথবা cart-কে এমন data type বানান যা নিজেই মেলে — যেমন যোগ আর বাদ আলাদা ট্র্যাক করা set (পর্ব ৪৬)। সহজতর বিকল্প: প্রতিটি ব্যবহারকারীর cart একটি “home” region-এ লেখা — concurrency-ই তৈরি হয় না।
    • Session-এর মেয়াদ: wall clock পেছালে মেয়াদ বেড়ে যায়, এগোলে আগেই শেষ; আর আলাদা সার্ভারে loginTime লেখা হলে skew-ও যোগ হয়। একই process-এর ভেতরে সময়কাল মাপুন monotonic ঘড়িতে; সার্ভার পেরোনো মেয়াদের জন্য token-এ মেয়াদ শেষের সময় লিখে দিন আর skew-এর জন্য ছোট ছাড় (leeway) রাখুন।
    • পর্যবেক্ষণ: প্রতিটি সার্ভারের NTP offset মেপে alert রাখুন — offset একটি সীমা ছাড়ালে সার্ভারকে লেখা থেকে সরিয়ে দিন।
  4. হিসাব আর নকশা — ID generator: একটি কাল্পনিক notification সেবা পিকে সেকেন্ডে ১ কোটি ID চায়। Snowflake-ধাঁচ (41 bit সময়, 10 bit যন্ত্র, 12 bit sequence) ব্যবহার করলে (ক) সর্বনিম্ন কয়টি যন্ত্র-নম্বর লাগবে, (খ) সর্বোচ্চ কত যন্ত্র সম্ভব আর মোট ক্ষমতা কত, (গ) NTP একটি যন্ত্রের ঘড়ি ২ সেকেন্ড পিছিয়ে দিলে কী করবেন?

    (ক) এক যন্ত্র প্রতি ms-এ ৪,০৯৬, সেকেন্ডে ৪০,৯৬,০০০. ১,০০,০০,০০০ ÷ ৪০,৯৬,০০০ ≈ ২.৪৪, তাই কমপক্ষে ৩টি যন্ত্র-নম্বর; বাস্তবে ভার অসমান আর যন্ত্র বিকল হয়, তাই আরও কয়েকটি বেশি রাখা ভালো।

    (খ) 210 = ১,০২৪টি যন্ত্র; মোট ১,০২৪ × ৪০,৯৬,০০০ ≈ সেকেন্ডে ৪১৯ কোটি. সময়ের অংশ চলবে প্রায় ৬৯.৭ বছর, নিজের বাছা epoch থেকে।

    (গ) শেষ ব্যবহৃত millisecond মনে রাখা generator ঘড়িকে পেছনে দেখলে ID দেবে না (প্রোগ্রাম ৪-এর মতো)। ছোট পেছানো (কয়েক ms) হলে অপেক্ষা করা চলে; ২ সেকেন্ড অপেক্ষা মানে এই যন্ত্র থেকে ২ সেকেন্ড কোনো ID নেই — তাই load balancer-কে জানিয়ে যন্ত্রটিকে সাময়িক সরিয়ে দিন, alert দিন, আর NTP-কে step নয়, slew করতে কনফিগার করুন। যন্ত্র-নম্বর দুটি যন্ত্র যেন একসাথে না পায়, সেজন্য নম্বর একটি lease হিসেবে বিলি করুন (পর্ব ৪৩)।

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

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

ক্রমTrueTime-ধাঁচের একটি লেখার transaction-এর ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. 🔒 দরকারি সব lock নেওয়া
  2. 🕰️ timestamp s = TT.now().latest বাছাই
  3. ⏳ অপেক্ষা, যতক্ষণ না TT.now().earliest > s
  4. ✅ commit দৃশ্যমান করা, lock ছাড়া আর client-কে “OK” জানানো
  5. 🆕 এরপর শুরু হওয়া transaction-এর timestamp s-এর চেয়ে বড় হওয়া

NTP-র একটি আদান-প্রদানে t1 = 0, t2 = 60, t3 = 62, t4 = 22 (ms; t1, t4 client-এর ঘড়িতে, t2, t3 সার্ভারের ঘড়িতে)। সার্ভারের ঘড়ি client-এর চেয়ে কতটা এগিয়ে বলে অনুমান হবে?

  • ৪০ ms
  • ৫০ ms
  • ২০ ms
  • ৬০ ms

একটি সার্ভিস অনুরোধের latency মাপে আর timeout ঠিক করে। কোন ঘড়ি ব্যবহার করা উচিত?

  • Wall clock, যেমন Date.now() — কারণ এটি UTC-তে থাকে
  • প্রতিবার NTP সার্ভার থেকে সময় এনে
  • Monotonic clock, যেমন performance.now() বা CLOCK_MONOTONIC
  • Lamport clock — কারণ এটি সবসময় বাড়ে

আলাদা দুটি process-এ ঘটনা a-র Lamport মান 3 আর ঘটনা b-র 7. নিশ্চিতভাবে কী বলা যায়?

  • a → b, অর্থাৎ a অবশ্যই b-র কারণ হতে পারে
  • a আর b অবশ্যই concurrent
  • b ঘটেছে a-র ৪ সেকেন্ড পরে
  • b, a-র আগে ঘটেনি (b → a নয়) — এর বেশি নিশ্চিত নয়

তিন process-এর vector clock-এ ঘটনা x = [2,1,0] আর y = [1,2,0]. x আর y-র সম্পর্ক কী?

  • x ∥ y (concurrent)
  • x → y
  • y → x
  • x আর y একই ঘটনা, কারণ যোগফল সমান

তিন process-এর সিস্টেমে P2-এর vector [1,3,0]. সে একটি বার্তা পেল, যার সাথে এসেছে [4,1,2]. বার্তা পাওয়ার পর P2-এর vector কত?

  • [4,3,2]
  • [4,4,2]
  • [5,4,2]
  • [4,1,3]

একটি Snowflake-ধাঁচের ID-তে 41 bit সময় (ms), 10 bit যন্ত্রের নম্বর আর 12 bit ক্রমিক সংখ্যা। একটি যন্ত্র এক millisecond-এ সর্বোচ্চ কয়টি আলাদা ID দিতে পারে?

  • ১,০২৪টি
  • ৪০,৯৬,০০০টি
  • ৪,০৯৬টি
  • ৪১,৯৪,৩০৪টি

শব্দকোষ

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

মনে রাখুন

  1. Quartz ঘড়ি কয়েক থেকে কয়েক দশ ppm সরে (২০ ppm ≈ দিনে ১.৭ সেকেন্ড); drift হারের ভুল, skew দুই ঘড়ির পার্থক্য।
  2. NTP: θ = [(t2 − t1) + (t3 − t4)] ÷ ২, δ = (t4 − t1) − (t3 − t2), ভুল ≤ δ/২ — তাই কম delay-এর নমুনা সবচেয়ে বিশ্বাসযোগ্য।
  3. সময়কাল, timeout আর lease মাপুন monotonic ঘড়িতে; wall clock step আর leap second-এ পেছাতে পারে।
  4. a → b হলে L(a) < L(b), উল্টোটি নয়; V(a) < V(b) হলে এবং কেবল তখনই a → b — vector concurrency ধরে, দাম N-টি ঘর।
  5. আলাদা যন্ত্রের timestamp দিয়ে ক্রম নয়: একক sequencer, logical clock, বা TrueTime-এর মতো জানা অনিশ্চয়তায় commit-wait; Snowflake ID শুধু প্রায় সাজানো।

পরের পর্বে: ক্রম বোঝার পর প্রশ্ন আসে — replica-রা ব্যবহারকারীকে কোন ক্রম আর কতটা টাটকা ডেটা দেখানোর প্রতিশ্রুতি দেবে? CAP, PACELC আর linearizable থেকে eventual পর্যন্ত consistency model.

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

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