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

Consistent hashing — ring, virtual node আর rendezvous hashing

Consistent Hashing — Hash Ring, Virtual Nodes & Rendezvous Hashing

Taught in Bangla, this lesson shows why hash mod N remaps almost every key when servers change and proves the formula. It places servers and keys on a hash ring, shows why adding a server moves only about 1/(N+1) of keys, evens load with virtual nodes and weights, performs binary-search lookups and replication preference lists, and compares rendezvous, jump, Maglev and bounded-load hashing, plus hot keys and membership limits, with simulators.

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

  • hash mod N কেন সার্ভার বদলালেই প্রায় সব key সরায় — আর 1 − 1/(N + 1) সূত্রের প্রমাণ
  • hash ring-এ key-এর মালিক খোঁজা, আর সার্ভার যোগ-বাদে গড়ে কেন মাত্র 1/(N + 1) ভাগ সরে
  • virtual node দিয়ে অসম ভাগ কমানো, V বাছাইয়ের হিসাব, আর ভিন্ন ক্ষমতার সার্ভারে weight
  • binary search-এ lookup, wrap-around, আর replication-এর preference list
  • rendezvous, jump, Maglev আর bounded-load hashing — কোনটি কখন, আর hot key ও membership-এর সীমা

১ · সমস্যা — key কোন সার্ভারে যাবে, আর hash mod N-এর ফাঁদ

একটি কাল্পনিক ride-sharing অ্যাপের কথা ভাবুন। প্রতিবার অ্যাপ খুললে ব্যবহারকারীর profile লাগে, তাই সেটি database-এ না গিয়ে একটি cache-এ রাখা হয়। ব্যবহারকারী কোটি ছাড়িয়েছে, একটি cache সার্ভারে আর আঁটে না — তাই ৪টি cache সার্ভার, আর ডেটা তাদের মধ্যে ভাগ করা (sharding). এখন প্রতিটি অনুরোধে app server-কে এক মুহূর্তে বলতে হবে: user:42-এর profile কোন cache সার্ভারে আছে?

চারজনকেই জিজ্ঞেস করা যায় — কিন্তু তাতে চার গুণ অনুরোধ। একটি কেন্দ্রীয় তালিকা রাখা যায় (“কোন key কোথায়”) — কিন্তু কোটি key-এর তালিকা নিজেই একটি বড় সার্ভিস, আর প্রতিটি অনুরোধে তাকে জিজ্ঞেস করা মানে নতুন একটি bottleneck. সবচেয়ে সুন্দর উত্তর হলো এমন একটি নিয়ম, যা যে কেউ নিজেই হিসাব করে বের করতে পারে: node = f(key).

কে ঠিক করে key কোন cache-এ যাবে

Every cache client computes node = f(key) locally — no central lookup

ব্যবহারকারী Load balancer App server 1App server 2App server 3 node = f(key)node = f(key)node = f(key) user:42 Cache 0Cache 1Cache 2Cache 3 miss হলে Database
ছবি ১৬.১ — প্রতিটি app server-এর ভেতরের cache client একই নিয়ম f(key) দিয়ে নিজেই ঠিক করে কোন cache সার্ভারে যাবে; কাউকে জিজ্ঞেস করতে হয় না। শর্ত দুটি: নিয়মটি সব app server-এ হুবহু এক হতে হবে, আর cache সার্ভারের সংখ্যা বদলালে যত কম key-এর ঠিকানা বদলায় তত ভালো।

এমন নিয়মের কাছে চাওয়া চারটি: একই key-এ সবাই একই উত্তর পাবেন (deterministic); প্রতিটি সার্ভার মোটামুটি সমান key পাবে (balance); সার্ভার যোগ, বাদ বা বিকল হলে যত কম সম্ভব key-এর ঠিকানা বদলাবে; আর হিসাব হবে দ্রুত, অল্প memory-তে।

প্রথম দুটি সহজেই মেলে hash mod N দিয়ে: key-এর hash নিন, সার্ভারের সংখ্যা N দিয়ে ভাগ করুন, ভাগশেষই সার্ভারের নম্বর। Hash function কীভাবে key থেকে সংখ্যা বানায়, তা DSA কোর্সের Hash table পর্বে আছে।

একটি ডাকঘরে নিয়ম: প্রাপকের ফোন নম্বরকে ৪ দিয়ে ভাগ করে ভাগশেষ অনুযায়ী চারটি খোপের একটিতে চিঠি। ঈদের আগে পঞ্চম খোপ খোলা হলো, নিয়ম হলো “৫ দিয়ে ভাগ”। এবার দেখা যায় প্রায় সব পুরোনো চিঠির খোপ বদলে গেছে — পুরো ঘর আবার বাছাই, যদিও নতুন খোপের ভাগে পড়ার কথা মাত্র এক-পঞ্চমাংশ চিঠি।

সমস্যাটি তৃতীয় চাওয়ায় — নড়াচড়ায়। ছোট উদাহরণ: ছয়টি key-এর hash ধরা যাক ৫, ২০, ৩৮, ৫০, ৭৭ আর ৯৫।

keyhashmod 4 (৪টি সার্ভার)mod 5 (৫টি সার্ভার)ফল
k1510সরল
k22000থাকল
k33823সরল
k45020সরল
k57712সরল
k69530সরল

ছয়টির পাঁচটিই সরল, আর মজার ব্যাপার — একটিও নতুন সার্ভার ৪-এ যায়নি; সবাই পুরোনো সার্ভারদের মধ্যেই অদলবদল হলো। নতুন সার্ভারে একটিও না যাওয়াটা এই ছোট উদাহরণের কাকতাল (গড়ে ১/৫ ভাগ key নতুন সার্ভারে যায়); কিন্তু এত বেশি key সরা কাকতালীয় নয়, সাধারণ সূত্রেই আসে:

অঙ্কটি — N থেকে N + 1

একটি key জায়গায় থাকে কেবল যখন h mod N = h mod (N+1). N আর N + 1-এর ১ ছাড়া কোনো সাধারণ উৎপাদক নেই, তাই পরপর N(N + 1)টি hash-মানের মধ্যে (h mod N, h mod (N + 1)) জোড়াটি প্রতিটি সম্ভাব্য মান ঠিক একবার নেয় (Chinese remainder theorem). এর মধ্যে দুটি ভাগশেষ সমান এমন জোড়া (0, 0), (1, 1) … (N − 1, N − 1) — মোট Nটি। তাই জায়গায় থাকে N ÷ N(N + 1) = 1/(N + 1) ভাগ, আর

mod N-এ সরে = 1 − 1/(N + 1) = N/(N + 1)

৪ → ৫: ৮০%; ৯ → ১০: ৯০%; ৯৯ → ১০০: ৯৯%. সার্ভার যত বেশি, ক্ষতি তত বড়! উল্টো দিকে N + 1 থেকে N-এ নামলেও (একটি সার্ভার বিকল) একই ভগ্নাংশ সরে। অথচ ন্যূনতম প্রয়োজন মাত্র 1/(N + 1): নতুন সার্ভার যেন তার ন্যায্য ভাগ পায়, সেটুকু key তো সরাতেই হবে। mod N সরায় তার N গুণ।

Cache-এর জন্য এর মানে ভয়ংকর। ধরা যাক আগে hit ratio ৯৫% ছিল। ৪ থেকে ৫টি সার্ভার করার মুহূর্তে ৮০% key ভুল সার্ভারে খোঁজা হবে, তাই hit নেমে আসে ০.৯৫ × ০.২ = ১৯%-এ — cache miss ৫% থেকে ৮১%, অর্থাৎ database-এর চাপ হঠাৎ প্রায় ১৬ গুণ (০.৮১ ÷ ০.০৫ = ১৬.২)। ঠিক যখন বাড়তি traffic সামলাতে সার্ভার যোগ করলেন, তখনই database ডুবে যাওয়ার ঝুঁকি — একটি ক্লাসিক cache stampede.

একমাত্র ভালো ব্যতিক্রম — দ্বিগুণ করা: N থেকে 2N করলে h mod 2N হয় h mod N অথবা তার সাথে N যোগ; তাই ঠিক অর্ধেক key থাকে, আর যারা সরে তারা সবাই নতুন সার্ভারে যায়। কিন্তু প্রতিবার দ্বিগুণ করা বাস্তবসম্মত নয়, আর একটি সার্ভার বিকল হলে সেই সুবিধাও থাকে না। Hash table বড় করার সময়ও সব key নতুন করে বসে (rehashing) — memory-তে সেটি সস্তা, কিন্তু নেটওয়ার্কে ছড়ানো cache-এ প্রতিটি সরানো key মানে একটি miss.

২ · Hash ring — সার্ভার আর key একই বৃত্তে

Consistent hashing-এর মূল চাল একটাই: সার্ভারের নম্বর গোনা বাদ দিন। তার বদলে hash-এর সব সম্ভাব্য মান — 32-bit hash-এ 0 থেকে 2³² − 1 — একটি বৃত্তের ওপর সাজান, যেখানে সবচেয়ে বড় মানের পরে আবার 0 আসে। এই পুরো পরিসর হলো hash space, আর বৃত্তটি ring.

  • প্রতিটি সার্ভারের নাম বা ঠিকানার hash = ring-এ তার অবস্থান (একে অনেক সিস্টেমে token বলে)।
  • প্রতিটি key-এর hash = ring-এ key-এর অবস্থান।
  • নিয়ম: key-এর অবস্থান থেকে ঘড়ির কাঁটার দিকে এগোলে প্রথম যে সার্ভার পড়ে, সেই key-এর মালিক। শেষ মান পেরিয়ে গেলে বৃত্ত ঘুরে শুরু থেকে (wrap-around).

একটি গোল রাস্তার কথা ভাবুন, তার ধারে কয়েকটি রিকশা স্ট্যান্ড। যাত্রী রাস্তার যেখানেই থাকুন, একটি নিয়ম মেনে চলেন — সামনের দিকে (ঘড়ির কাঁটার দিকে) হেঁটে প্রথম যে স্ট্যান্ড পান, সেখানেই রিকশায় ওঠেন। নতুন একটি স্ট্যান্ড খুললে শুধু আগের স্ট্যান্ড আর নতুন স্ট্যান্ডের মাঝের যাত্রীদের অভ্যাস বদলায়; বাকি সবাই আগের স্ট্যান্ডেই যান।

নিচের stepper-এ ছোট একটি ring: hash-এর মান ধরা হলো মাত্র 0 থেকে 99, চারটি সার্ভার আর ছয়টি key — আগের টেবিলের সেই একই ছয়টি hash-মান।

0 k1·5 k2·20 k3·38 k4·50 k5·77 k6·95 AA·12 BB·40 CC·63 DD·88 hash space 0–99ঘড়ির কাঁটার দিকে বাড়ে
কোন সার্ভার কোন key পেল
A · ১২
B · ৪০
C · ৬৩
D · ৮৮
  1. খালি ring. ধরা যাক hash-এর মান মাত্র 0 থেকে 99 (বাস্তবে 32-bit hash-এ 0 থেকে প্রায় ৪৩০ কোটি)। মানগুলো বৃত্তের ওপর সাজানো: ওপরে 0, ঘড়ির কাঁটার দিকে বাড়ে, আর 99-এর ঠিক পরেই আবার 0.
  2. চারটি সার্ভার বসল। প্রতিটি সার্ভারের নামের hash তার অবস্থান: A = ১২, B = ৪০, C = ৬৩, D = ৮৮ (সংখ্যাগুলো ধরে নেওয়া)। প্রতিটি সার্ভার তার আগের সার্ভারের ঠিক পর থেকে নিজের অবস্থান পর্যন্ত বৃত্তাংশের মালিক।
  3. ছয়টি key বসল। একই hash function দিয়ে key-এর অবস্থান: k1 = ৫, k2 = ২০, k3 = ৩৮, k4 = ৫০, k5 = ৭৭, k6 = ৯৫. ধূসর রং মানে এখনো মালিক ঠিক হয়নি।
  4. নিয়ম: ঘড়ির কাঁটার দিকে প্রথম সার্ভার। k1 (৫) থেকে সামনে এগোলে প্রথম পড়ে A (১২)। k2 (২০) আর k3 (৩৮) দুটোই পৌঁছায় B (৪০)-এ। রঙিন দাগ দেখাচ্ছে key থেকে মালিক পর্যন্ত হাঁটার পথ।
  5. k4 যায় C-তে, k5 যায় D-তে। k4 (৫০)-এর পরে প্রথম সার্ভার C (৬৩); k5 (৭৭)-এর পরে D (৮৮)।
  6. Wrap-around: k6 যায় A-তে। k6 (৯৫)-এর পরে ৯৯ পর্যন্ত কোনো সার্ভার নেই; বৃত্ত ঘুরে 0-তে ফিরে প্রথম সার্ভার A (১২)। চূড়ান্ত ভাগ: A-র কাছে k1 আর k6, B-র কাছে k2 আর k3, C-র কাছে k4, D-র কাছে k5. বৃত্তাংশের মাপ: A = ২৪ (৮৮-এর পর থেকে ১২ পর্যন্ত), B = ২৮, C = ২৩, D = ২৫ — মোট ১০০।

খুঁটিনাটি: key-এর hash কোনো সার্ভারের অবস্থানের সমান হলে সেই সার্ভারই মালিক — “প্রথম যে অবস্থান সমান বা বড়”। মালিক জানতে শুধু সার্ভারগুলোর অবস্থান জানলেই চলে; key-এর তালিকা কারও কাছে রাখতে হয় না।

ধারণাটি প্রকাশিত হয় ১৯৯৭ সালে, MIT-র David Karger, Eric Lehman, Tom Leighton, Rina Panigrahy, Matthew Levine ও Daniel Lewin-এর “Consistent Hashing and Random Trees” paper-এ (STOC সম্মেলন)। লক্ষ্য ছিল web-এর জনপ্রিয় পাতার ভিড় অনেক cache সার্ভারে ভাগ করা। পরের বছর Leighton ও Lewin Akamai প্রতিষ্ঠা করেন; CDN-এর ইতিহাস আছে Computer Networks কোর্সের Web cache, proxy আর CDN পর্বে।

৩ · সার্ভার যোগ বা বাদ — কেন মাত্র প্রায় K/N key সরে

এবার আসল পরীক্ষা: একই ring-এ একটি সার্ভার যোগ করুন, তারপর একটি সরিয়ে দিন। প্রতিটি ধাপে খেয়াল করুন কোন key নড়ল আর কোনটি নড়ল না।

0 k1·5 k2·20 k3·38 k4·50 k5·77 k6·95 AA·12 BB·40 CC·63 DD·88 EE·55 hash space 0–99ঘড়ির কাঁটার দিকে বাড়ে
একই ৬টি key, hash mod N দিয়ে০ = A, ১ = B, ২ = C, ৩ = D, ৪ = E
key  hash  mod 4  mod 5
k1      5    1 B    0 A   সরল
k2     20    0 A    0 A   থাকল
k3     38    2 C    3 D   সরল
k4     50    2 C    0 A   সরল
k5     77    1 B    2 C   সরল
k6     95    3 D    0 A   সরল
Ring-এ কার কাছে কোন key
A · ১২
B · ৪০
C · ৬৩
D · ৮৮
E · ৫৫
এই ধাপে সরল
  1. শুরুর অবস্থা। আগের ring: A·১২, B·৪০, C·৬৩, D·৮৮; ছয়টি key তাদের মালিকের রঙে।
  2. নতুন সার্ভার E এল। Traffic বাড়ায় পঞ্চম সার্ভার যোগ হলো; তার নামের hash ৫৫ — B (৪০) আর C (৬৩)-এর মাঝখানে।
  3. শুধু ৪০ আর ৫৫-এর মাঝের key সরে। যেসব key ৪০-এর বেশি আর ৫৫ বা তার কম, তারা আগে C-তে যেত; এখন পথে আগেই E পড়ে। এমন key একটিই — k4 (৫০)। বাকিদের পথে E পড়ে না, তারা জায়গায়। ৬টির ১টি সরল, আর সেটি গেল নতুন সার্ভারেই।
  4. একই কাজ hash mod N দিয়ে। mod 4 থেকে mod 5 করলে ৬টির ৫টি key-এর সার্ভার বদলায়, শুধু k2 থাকে — আর যে ৫টি সরল, সবাই গেল পুরোনো সার্ভারদের মধ্যেই; নতুন E একটিও পেল না।
  5. এবার B বিকল। B-র বৃত্তাংশ (১২-এর পর থেকে ৪০ পর্যন্ত) এখন ঘড়ির কাঁটার দিকে পরের জীবিত সার্ভার E (৫৫)-এর অংশ হয়ে গেল; তাই k2 আর k3 গেল E-তে। A, C, D আর E-র নিজের key একটুও নড়েনি।
  6. সমস্যা: সব ভার এক প্রতিবেশীর ঘাড়ে। B-র পুরো ভাগ গেল শুধু E-তে: E-র কাছে এখন ৩টি key, A-র ২টি, D-র ১টি, C-র একটিও নেই। বাস্তবে বিকল সার্ভারের সব traffic হঠাৎ একটি সার্ভারে — সেটিও ভেঙে পড়তে পারে। প্রতিকার virtual node, অংশ ৪-এ।

এটিই consistent hashing-এর মূল প্রতিশ্রুতি, Karger-দের paper-এ যার নাম monotonicity: যোগের সময় key শুধু পুরোনো সার্ভার থেকে নতুনে যায়, পুরোনোদের নিজেদের মধ্যে কখনো নয়। এবার গড়ের হিসাব।

অঙ্কটি — গড়ে কত ভাগ সরে

কোন key সরে: নতুন সার্ভার X বসল x-এ। একটি key-এর মালিক বদলায় কেবল যদি key থেকে পুরোনো মালিক পর্যন্ত হাঁটার পথে x পড়ে — অর্থাৎ key আছে X-এর আগের সার্ভার আর x-এর মাঝে। এগুলোই X-এর বৃত্তাংশ; আগে সবাই x-এর পরের সার্ভারে যেত, এখন X-এ।

বৃত্তাংশ কত বড়: ধরুন সব সার্ভারের অবস্থান স্বাধীন আর সমসম্ভাব্য (ভালো hash-এ তা-ই প্রায়)। X যোগ হওয়ার পরে N + 1টি অবস্থান, তাদের N + 1টি বৃত্তাংশ মিলে পুরো বৃত্ত (মাপ ১)। সবাই একই নিয়মে এলোমেলোভাবে বসেছে, তাই কারও বৃত্তাংশের প্রত্যাশিত মাপ অন্যের চেয়ে আলাদা হতে পারে না; সুতরাং প্রত্যেকেরটি 1/(N + 1). X-ও তাদের একজন।

যোগে প্রত্যাশিত সরে: K/(N + 1) ≈ K/N · একটি বাদে: K/N

এখানে K = মোট key. একটি সার্ভার বাদ পড়লে শুধু তার বৃত্তাংশ (প্রত্যাশিত 1/N) পরের সার্ভারে যায়। আর এটুকু সর্বনিম্নও: নতুন সার্ভারকে তার ন্যায্য ভাগ 1/(N + 1) দিতে হলে অন্তত ততগুলো key সরাতেই হবে। তাই ring গড়ে সবচেয়ে কম যতটা সম্ভব, ঠিক ততটাই সরায়।

বদলhash mod N-এ সরেring-এ প্রত্যাশিত সরেঅনুপাত
৪ → ৫ সার্ভার৮০%২০%৪ গুণ
৯ → ১০ সার্ভার৯০%১০%৯ গুণ
৯৯ → ১০০ সার্ভার৯৯%১%৯৯ গুণ

তবে “প্রত্যাশিত” শব্দটি গুরুত্বপূর্ণ: একটিমাত্র অবস্থানে নতুন সার্ভারের বৃত্তাংশ ভাগ্যক্রমে খুব ছোট বা খুব বড় হতে পারে। নিচের প্রোগ্রাম নির্দিষ্ট seed থেকে ১০,০০০টি key বানিয়ে দুই পদ্ধতিতেই ৪ থেকে ৫টি সার্ভারে যায়; ring-এ প্রতিটি সার্ভার ১০০টি জায়গায় (virtual node — অংশ ৪)। Hash হলো FNV-1a 32-bit, তারপর MurmurHash3-এর fmix32 নামের একটি mixing ধাপ, যাতে প্রায় একই রকম নাম (S0#1, S0#2) থেকেও ring-এ দূরে দূরে অবস্থান আসে।

// 10,000 keys, servers 4 -> 5: how many keys change server? mod-N vs ring.
function h32(s) {                // FNV-1a 32-bit, then MurmurHash3's fmix32 to mix bits
  let h = 0x811c9dc5;
  for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193); }
  h ^= h >>> 16; h = Math.imul(h, 0x85ebca6b); h ^= h >>> 13; h = Math.imul(h, 0xc2b2ae35);
  return (h ^ (h >>> 16)) >>> 0;
}
let seed = 16; function rand() { // mulberry32: small seeded PRNG for the keys
  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 keys = Array.from({ length: 10000 }, () => "user:" + Math.floor(rand() * 1e9));
function ring(n, v) {            // n servers, v virtual nodes each, sorted by position
  const pts = [];
  for (let s = 0; s < n; s++) for (let i = 0; i < v; i++) pts.push([h32("S" + s + "#" + i), s]);
  return pts.sort((a, b) => a[0] - b[0]);
}
function owner(pts, x) {         // first point clockwise from x (binary search, wrap)
  let lo = 0, hi = pts.length;
  while (lo < hi) { const m = (lo + hi) >> 1; if (pts[m][0] < x) lo = m + 1; else hi = m; }
  return pts[lo % pts.length][1];
}
const r4 = ring(4, 100), r5 = ring(5, 100);
let mod = 0, modNew = 0, rg = 0, rgNew = 0;
for (const k of keys) {
  const x = h32(k);
  if (x % 4 !== x % 5) { mod++; if (x % 5 === 4) modNew++; }
  const a = owner(r4, x), b = owner(r5, x);
  if (a !== b) { rg++; if (b === 4) rgNew++; }
}
const pct = (c) => (100 * c / keys.length).toFixed(1) + "%";
console.log("mod-N  moved " + pct(mod) + "   to new server " + pct(modNew) + "   between old servers " + pct(mod - modNew));
console.log("ring   moved " + pct(rg) + "   to new server " + pct(rgNew) + "   between old servers " + pct(rg - rgNew));
console.log("theory mod-N 1 - 1/5 = 80.0%, minimum possible 1/5 = 20.0%");

আউটপুট:

mod-N  moved 80.1%   to new server 20.1%   between old servers 59.9%
ring   moved 21.9%   to new server 21.9%   between old servers 0.0%
theory mod-N 1 - 1/5 = 80.0%, minimum possible 1/5 = 20.0%

mod N: ৮০.১% key সরেছে — সূত্রের ৮০%-এর সাথে মেলে। তার মধ্যে নতুন সার্ভারে গেছে মাত্র ২০.১% (যতটা দরকার ছিল); বাকি ৫৯.৯% শুধু পুরোনো সার্ভারদের মধ্যে অদলবদল — পুরোটাই অকারণ cache miss. Ring: ২১.৯% সরেছে, সবটাই নতুন সার্ভারে; পুরোনোদের মধ্যে ০.০%. ২০%-এর চেয়ে সামান্য বেশি, কারণ নতুন সার্ভারের ১০০টি বিন্দু মিলে ring-এর ২১.৯% পেয়েছে। দুই ring(…, 100)-এ ১০০-এর বদলে ১ লিখলে সরে মাত্র ২.২% — শুনতে ভালো, কিন্তু মানে নতুন সার্ভারটি প্রায় বেকার; ১,০০০ লিখলে আসে ১৮.৯%.

৪ · Virtual node — অসম ভাগকে সমান করা

প্রতিটি সার্ভারের একটিমাত্র অবস্থান থাকলে দুটি সমস্যা। প্রথমত, এলোমেলো বিন্দুর মাঝের ফাঁক খুব অসমান: N-টি এলোমেলো বিন্দুতে সবচেয়ে বড় বৃত্তাংশের প্রত্যাশিত মাপ গড়ের প্রায় HN = 1 + 1/2 + … + 1/N গুণ; N = ১০-এ প্রায় ২.৯ গুণ, আর সবচেয়ে ছোটটি গড়ের মাত্র ১/N ভাগ। দ্বিতীয়ত, বিকল সার্ভারের পুরো ভার যায় একটিমাত্র প্রতিবেশীতে।

সমাধান: প্রতিটি আসল সার্ভারকে ring-এ V জায়গায় বসান — নাম বদলে বদলে hash নিন (S3#0, S3#1, …), প্রতিটি একটি virtual node. Key-এর নিয়ম একই: ঘড়ির কাঁটার দিকে প্রথম বিন্দু, আর সেই বিন্দু যে আসল সার্ভারের, key তার।

Virtual node-সহ ring

3 servers × 3 virtual nodes; coloured arcs show who owns which part (assumed positions 0–99)

0 AA0·8 BB0·20 CC0·30 AA1·41 CC1·50 BB1·62 AA2·74 BB2·86 CC2·95 k1·15 k2·45 k3·70 k4·90 k5·99 ৩টি সার্ভার × ৩টি vnodeরং = কার ভাগ
ছবি ১৬.২ — তিনটি সার্ভারের প্রতিটি ring-এ তিন জায়গায় বসেছে (A0, A1, A2 …; অবস্থান ধরে নেওয়া)। রঙিন বৃত্তাংশে মালিকানা: A-র মোট ভাগ ১৩ + ১১ + ১২ = ৩৬, B-র ১২ + ১২ + ১২ = ৩৬, C-র ১০ + ৯ + ৯ = ২৮. B বিকল হলে তার তিনটি টুকরো যায় তিনটি আলাদা পরের বিন্দুতে — C0, A2 আর C2 — অর্থাৎ ভার এবার একজনের ঘাড়ে নয়, A আর C-র মধ্যে ভাগ হয় (C পায় ২৪, A পায় ১২)।

কেন কাজ করে: এখন প্রতিটি সার্ভারের ভাগ একটি নয়, V-টি বৃত্তাংশের যোগফল। মোট N·V-টি বিন্দু, তাই একেকটি বৃত্তাংশের গড় মাপ 1/(N·V), আর এলোমেলো ফাঁকের ছড়ানোও মোটামুটি গড়ের সমান। স্বাধীন V-টি টুকরো যোগ করলে গড় বাড়ে V গুণ, কিন্তু ছড়ানো বাড়ে মাত্র √V গুণ — ক্রিকেটে একটি ইনিংস দেখে ব্যাটারকে বিচার করা আর ১০০ ইনিংসের গড় দেখার মতো। ফলে:

ভাগের ছড়ানো ÷ গড় ≈ √((N − 1)/(N·V + 1)) ≈ 1/√V

এখানে ছড়ানোর মাপ standard deviation (sd). N = ১০-এ সূত্রটি দেয়: V = ১-এ প্রায় ৯০%, V = ১০-এ ৩০%, V = ১০০-এ ৯.৫%, V = ১,০০০-এ ৩.০%. অর্থাৎ ভাগ ১০ গুণ বেশি সমান করতে বিন্দু লাগে ১০০ গুণ। নিচের প্রোগ্রাম ১০টি সার্ভার আর ১,০০,০০০টি key দিয়ে চারটি V-এর জন্য সত্যিকারের ভাগ মাপে।

// 10 servers, 100,000 keys: how even is the load with V virtual nodes per server?
function h32(s) {                // FNV-1a 32-bit, then MurmurHash3's fmix32 to mix bits
  let h = 0x811c9dc5;
  for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193); }
  h ^= h >>> 16; h = Math.imul(h, 0x85ebca6b); h ^= h >>> 13; h = Math.imul(h, 0xc2b2ae35);
  return (h ^ (h >>> 16)) >>> 0;
}
let seed = 16; function rand() { // mulberry32: small seeded PRNG for the keys
  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 N = 10, K = 100000;
const xs = Array.from({ length: K }, () => h32("user:" + Math.floor(rand() * 1e9)));
console.log("V      points    min    max  std dev  max/avg  sd/avg");
for (const V of [1, 10, 100, 1000]) {
  const pts = [];
  for (let s = 0; s < N; s++) for (let i = 0; i < V; i++) pts.push([h32("S" + s + "#" + i), s]);
  pts.sort((a, b) => a[0] - b[0]);
  const cnt = new Array(N).fill(0);
  for (const x of xs) {          // owner = first point clockwise (binary search)
    let lo = 0, hi = pts.length;
    while (lo < hi) { const m = (lo + hi) >> 1; if (pts[m][0] < x) lo = m + 1; else hi = m; }
    cnt[pts[lo % pts.length][1]]++;
  }
  const avg = K / N;
  const sd = Math.sqrt(cnt.reduce((s, c) => s + (c - avg) ** 2, 0) / N);
  console.log(String(V).padEnd(6) + String(N * V).padStart(7) + String(Math.min(...cnt)).padStart(7) +
    String(Math.max(...cnt)).padStart(7) + sd.toFixed(0).padStart(9) +
    (Math.max(...cnt) / avg).toFixed(2).padStart(9) + ((100 * sd / avg).toFixed(1) + "%").padStart(8));
}
console.log("average per server = " + K / N + " keys");

আউটপুট:

V      points    min    max  std dev  max/avg  sd/avg
1          10    224  24526     8472     2.45   84.7%
10        100   5376  14921     2665     1.49   26.7%
100      1000   8387  11381      843     1.14    8.4%
1000    10000   9394  10539      323     1.05    3.2%
average per server = 10000 keys

V = ১-এ একটি সার্ভার পেয়েছে মাত্র ২২৪টি key, আরেকটি ২৪,৫২৬টি — গড়ের প্রায় আড়াই গুণ; এমন cluster-এর ক্ষমতা ঠিক করে সবচেয়ে ব্যস্ত সার্ভারটি। V = ১০০-এ সর্বোচ্চ গড়ের ১.১৪ গুণ, V = ১,০০০-এ ১.০৫ গুণ। sd/avg সূত্রের সাথে মেলে: ৮৪.৭% (সূত্রে প্রায় ৯০%), ২৬.৭% (৩০%), ৮.৪% (৯.৫%), ৩.২% (৩.০%) — একটিমাত্র ring বলে সামান্য এদিক-ওদিক, আর V = ১,০০০-এ key-এর নমুনার নিজস্ব প্রায় ১% ছড়ানোও যোগ হয়েছে। দাম points কলামে: বিন্দু ১০ থেকে ১০,০০০।

V কত হবে — trade-off

  • Memory: ring-এ N·V-টি (token, সার্ভার) জোড়া; ১০০ × ২৫৬ = ২৫,৬০০টি, প্রতিটি ১৬ byte ধরলে প্রায় ৪০০ KB — তুচ্ছ।
  • Lookup: log₂(N·V) তুলনা (অংশ ৫); V দশ গুণ করলে মাত্র প্রায় ৩.৩টি তুলনা বাড়ে।
  • Metadata আর টুকরো: পুরো ring সবাইকে জানাতে বার্তা বড় হয়, আর database-এ প্রতিটি ছোট বৃত্তাংশ আলাদাভাবে কপি বা মেলাতে কাজ বাড়ে।

তাই লক্ষ্য থেকে উল্টো হিসাব করুন: sd গড়ের c ভাগের মধ্যে রাখতে V ≈ (N − 1)/(N·c²) ≈ 1/c². ৫% চাইলে V ≈ ৪০০, ১০% চাইলে ১০০। বাস্তব সিস্টেমে V প্রায়ই কয়েক ডজন থেকে কয়েকশো — সংস্করণ আর ব্যবহারভেদে আলাদা।

ভিন্ন ক্ষমতার সার্ভার — weight

Virtual node-এর একটি বাড়তি উপহার: ক্ষমতা অনুযায়ী ভাগ। যে মেশিনের RAM দ্বিগুণ, তাকে দিন 2V-টি বিন্দু — সে প্রায় দ্বিগুণ বৃত্তাংশ পাবে। নতুন সার্ভারকে ধীরে ধীরে চাপে আনতেও এটি কাজে লাগে: শুরুতে অল্প কয়েকটি বিন্দু, cache গরম হতে থাকলে (cache warming) ধাপে ধাপে বাড়ানো, যাতে হঠাৎ একসাথে অনেক miss না হয়।

৫ · Lookup খরচ, replication আর preference list

কোডে ring কোনো বৃত্ত নয় — একটি সাজানো array: প্রতিটি ঘরে (token, আসল সার্ভার)। Key-এর মালিক খোঁজা মানে “প্রথম যে token key-এর hash-এর সমান বা বড়” খোঁজা — এটি binary search, প্রতি ধাপে অর্ধেক বাদ (বিস্তারিত DSA কোর্সের Linear আর binary search পর্বে)। খুঁজতে খুঁজতে array-এর শেষ পেরিয়ে গেলে, অর্থাৎ key-এর hash সব token-এর চেয়ে বড় হলে, উত্তর প্রথম ঘর — এটিই wrap-around.

Lookup O(log(N·V)) — ১০০ × ২৫৬ = ২৫,৬০০ token-এ ⌈log₂ ২৫,৬০০⌉ = ১৫টি তুলনা; memory O(N·V); আর সার্ভার যোগ-বাদে V-টি token যোগ বা মুছে array আবার সাজানো, যা কালেভদ্রে হয়।

নিচের stepper-এ ছোট একটি ring (hash ধরা হলো 0–999): তিনটি সার্ভার, প্রত্যেকের দুটি virtual node — A: ১০৫ আর ৯৩০, B: ২৯০ আর ৬১০, C: ৪৫০ আর ৮৪৫.

105 · A#0
290 · B#1
450 · C#2
610 · B#3
845 · C#4
930 · A#5
Binary search: প্রথম token ≥ hash
key-এর hash
lo
hi
mid → token
সিদ্ধান্ত
  1. Token-গুলো সাজানো। ছয়টি token ছোট থেকে বড়: ১০৫ (A), ২৯০ (B), ৪৫০ (C), ৬১০ (B), ৮৪৫ (C), ৯৩০ (A). খোঁজা হবে hash ৪০০-এর key. শুরুতে lo = ০, hi = ৬ (array-এর দৈর্ঘ্য)।
  2. প্রথম তুলনা। mid = (০ + ৬) ÷ ২ = ৩, সেখানে token ৬১০. ৬১০ ≥ ৪০০, তাই উত্তর ৩ বা তার বাঁয়ে: hi = ৩.
  3. দ্বিতীয় তুলনা। mid = (০ + ৩) ÷ ২ = ১ (ভাগফলের পূর্ণ অংশ), token ২৯০. ২৯০ ছোট ৪০০-এর চেয়ে, তাই উত্তর তার ডানে: lo = ২.
  4. তৃতীয় তুলনা। mid = (২ + ৩) ÷ ২ = ২, token ৪৫০ ≥ ৪০০, তাই hi = ২.
  5. উত্তর। lo আর hi মিলে গেল ২-এ: token ৪৫০, তার মালিক C. ছয়টি token-এ লাগল ৩টি তুলনা = ⌈log₂ ৬⌉. ২৫,৬০০ token-এ লাগত ১৫টি।
  6. Wrap-around. এবার hash ৯৫০-এর key. mid ৩-এ ৬১০ ছোট, তাই lo = ৪; mid ৫-এ ৯৩০-ও ছোট, তাই lo = ৬. lo এখন array-এর দৈর্ঘ্যের সমান — কোনো token ৯৫০-এর সমান বা বড় নয়। তাই ঘুরে প্রথম ঘর #০: token ১০৫, মালিক A. কোডে এটিই lo % length.
  7. Preference list (RF = ৩). hash ৪০০-এর key-এর তিনটি কপি চাই। #২ (C) থেকে শুরু করে ঘড়ির কাঁটার দিকে হাঁটি: #৩ B — নতুন, নিলাম; #৪ C — আগেই আছে, বাদ; #৫ A — নতুন, নিলাম। তালিকা: C, B, A. শুধু “পরের তিনটি token” নিলে C দুবার আসত — দুই কপি একই মেশিনে!

Replication: পরের কয়েকটি ভিন্ন সার্ভার

একটি key-এর একটিমাত্র কপি রাখলে সেই সার্ভার বিকল হলেই ডেটা হারায়। তাই replication: key-এর প্রথম মালিক থেকে ঘড়ির কাঁটার দিকে হেঁটে প্রথম RF-টি ভিন্ন আসল সার্ভারে কপি রাখা, যেখানে RF হলো replication factor. এই ক্রমিক তালিকাই preference list — Amazon-এর Dynamo paper (DeCandia ও সহকর্মীরা, SOSP ২০০৭) এই নামটিই ব্যবহার করে। বাস্তবে তালিকা বানানোর সময় একই rack বা একই availability zone-এর সার্ভারও বাদ দেওয়া হয়, যাতে একটি বিদ্যুৎ-বিভ্রাটে সব কপি একসাথে না হারায়।

Virtual node-এ প্রতিটি বৃত্তাংশের preference list-এ ভিন্ন ভিন্ন প্রতিবেশী, তাই একটি সার্ভার বিকল হলে নতুন কপি বানানোর কাজ অনেক সার্ভার একসাথে ভাগ করে নেয় — মেরামত দ্রুত হয়।

নামের গোলমাল: Dynamo paper আর পর্ব ৪১ (Quorum আর leaderless replication)-এর R + W > N সূত্রে N মানে কপির সংখ্যা, R মানে কয়টি কপি থেকে পড়া হবে। এই পর্বে N মানে সার্ভারের সংখ্যা, তাই কপির সংখ্যা লিখছি RF. Quorum আর সাময়িক বিকল সার্ভারের লেখা অন্যজনের রেখে দেওয়া (hinted handoff) পর্ব ৪১-এ; ডেটা সরানোর বিস্তারিত পর্ব ২৩ (Partitioning আর sharding)-এ।

৬ · Rendezvous hashing — প্রতিটি সার্ভারকে নম্বর, সর্বোচ্চ জেতে

Ring ছাড়াও একই সমস্যার একটি চমৎকার সরল সমাধান আছে। প্রতিটি key-এর জন্য প্রতিটি সার্ভারকে একটি নম্বর (score) দিন — score = hash(key, server), অর্থাৎ key আর সার্ভারের নাম একসাথে জুড়ে hash. যে সার্ভারের score সবচেয়ে বড়, key তার। এর নাম rendezvous hashing, বা highest random weight (HRW) hashing. David Thaler ও Chinya Ravishankar (University of Michigan) এটি প্রস্তাব করেন — ১৯৯৬ সালের একটি technical report-এ, পরে ১৯৯৮ সালে IEEE/ACM Transactions on Networking-এ (সাল আর শিরোনাম সূত্রভেদে সামান্য আলাদা লেখা হয়)।

পাড়ার ক্রিকেট টুর্নামেন্টে প্রতিটি ম্যাচের জন্য প্রত্যেক আম্পায়ার একটি লটারি-নম্বর পান, ম্যাচ আর আম্পায়ারের নাম মিলিয়ে নির্দিষ্ট নিয়মে বানানো — যে কেউ হিসাব করলে একই নম্বর। যাঁর নম্বর সবচেয়ে বড়, তিনিই আম্পায়ার। একজন অসুস্থ হলে শুধু তাঁর ম্যাচগুলোতে দ্বিতীয় বড় নম্বরধারী দায়িত্ব নেন; অন্য কোনো ম্যাচে কিছুই বদলায় না।

নিচের stepper-এ key user:42 আর চারটি সার্ভার। Score হিসাব করা হয়েছে অংশ ৭-এর প্রোগ্রামের একই h32 দিয়ে: h32("user:42|S0") ইত্যাদি, তারপর 2³² দিয়ে ভাগ করে ০ থেকে ১-এর মধ্যে আনা।

key = user:42 · score = h32(key + "|" + server) ÷ 2³²
S0
S1
S2
S3
S4
এখন পর্যন্ত সর্বোচ্চ
মালিক
  1. একটি key, চারটি সার্ভার। key = user:42; সার্ভার S0, S1, S2, S3. প্রতিটি সার্ভারের score আলাদাভাবে হিসাব হবে; কোনো ring বা table লাগবে না।
  2. S0-এর score ০.৩৪৪. প্রথমজন, তাই এখন পর্যন্ত সর্বোচ্চ।
  3. S1-এর score ০.৭৩৪. আগের চেয়ে বড়, তাই সর্বোচ্চ এখন S1.
  4. S2-এর score ০.৮৯৩. আরও বড়, সর্বোচ্চ এখন S2.
  5. S3-এর score ০.৫৬৫. ছোট। চারটি দেখা শেষ, সর্বোচ্চ S2 — তাই user:42 যায় S2-তে। খরচ: N-টি hash.
  6. Replication বিনা খরচে। score বড় থেকে ছোট সাজালে S2 (০.৮৯৩), S1 (০.৭৩৪), S3 (০.৫৬৫), S0 (০.৩৪৪)। তিনটি কপি চাইলে preference list: S2, S1, S3 — আলাদা করে ভিন্ন সার্ভার বাছার ঝামেলাই নেই।
  7. S2 বিকল। বাকিদের score একই আছে; সর্বোচ্চ এখন S1 (০.৭৩৪), তাই user:42 গেল S1-এ। যে key-গুলোর সর্বোচ্চ S2 ছিল না, তাদের সর্বোচ্চ বদলায়নি, তাই তারা নড়ে না। আর S2-এর key-গুলোর দ্বিতীয় সর্বোচ্চ বাকিদের যে কেউ হতে পারে সমান সম্ভাবনায় — বিকল সার্ভারের ভার সবার মধ্যে ছড়িয়ে পড়ে, virtual node ছাড়াই।
  8. চার সার্ভারে ফিরে, নতুন S4 যোগ। S4-এর score ০.১৯২, যা ০.৮৯৩-এর চেয়ে ছোট — user:42 S2-তেই থাকে। কোনো key সরে কেবল তখন, যখন নতুন সার্ভারের score আগের সর্বোচ্চকেও ছাড়ায়; পাঁচজন সমান সুযোগের প্রতিযোগীর মধ্যে নতুনজনের সর্বোচ্চ হওয়ার সম্ভাবনা ১/৫ — ঠিক 1/(N + 1). আর যারা সরে, সবাই যায় নতুন সার্ভারেই।

প্রতিটি key-এ প্রতিটি সার্ভারের জেতার সম্ভাবনা 1/N, স্বাধীনভাবে — তাই ভাগের অসমতা আসে শুধু key-এর এলোমেলো নমুনা থেকে, ring-এর ফাঁক থেকে নয়। দুটি বাড়তি কথা:

  • Weight: একটি পরিচিত রূপে score = −w ÷ ln(u), যেখানে u = hash ÷ 2³² আর w সার্ভারের weight; তখন জেতার সম্ভাবনা weight-এর সমানুপাতিক।
  • দাম — O(N) lookup: প্রতিটি key-এ সব সার্ভারের hash; ১০০ সার্ভারে তুচ্ছ, হাজার হাজার সার্ভারে ধীর — তখন সার্ভারদের দলে ভাগ করে দুই স্তরে rendezvous চালানো হয়।
দিকRing + virtual nodeRendezvous (HRW)
LookupO(log(N·V)), binary searchO(N), সব score
MemoryN·V-টি tokenশুধু সার্ভারের তালিকা
ভাগ সমান করতেV বড় করতে হয়আপনা থেকেই
বিকল সার্ভারের ভারV-টি প্রতিবেশীতে ছড়ায়সবার মধ্যে ছড়ায়
Replication তালিকাহেঁটে ভিন্ন সার্ভার বাছাপ্রথম RF-টি score
ভালো খাটেঅনেক সার্ভার, খুব ঘন lookupকয়েক ডজন থেকে কয়েকশো সার্ভার, সরল কোড

৭ · Jump hash, Maglev আর bounded load — বিশেষ প্রয়োজনের বিকল্প

Jump consistent hash — memory ছাড়াই

২০১৪ সালে Google-এর John Lamping ও Eric Veach একটি ছোট paper প্রকাশ করেন: “A Fast, Minimal Memory, Consistent Hash Algorithm” (arXiv). তাঁদের jump consistent hash একটি 64-bit key আর bucket-এর সংখ্যা n নিয়ে 0 থেকে n − 1-এর একটি bucket ফেরত দেয় — কোনো ring নেই, table নেই, মাত্র কয়েক লাইনের loop.

ধারণাটি: ভাবুন bucket একটি একটি করে বাড়ছে — ১, ২, … n. প্রতিবার n থেকে n + 1 হলে key 1/(n + 1) সম্ভাবনায় নতুন bucket-এ “লাফ” দেয়, নইলে জায়গায় থাকে — ঠিক ন্যূনতম নড়াচড়া। কোন ধাপে লাফ, তা ঠিক করে key দিয়ে seed করা একটি ছোট pseudo-random generator; algorithm সরাসরি পরের লাফের জায়গায় চলে যায়, তাই গড়ে প্রায় ln n-বার loop ঘোরে।

সীমা: যোগ বা বাদ করা যায় শুধু শেষের bucket; মাঝের bucket ৩-এর সার্ভার বিকল হলে তাকে বাদ দেওয়ার উপায় নেই। তাই এটি মানায় যেখানে প্রতিটি bucket নিজেই একটি replicated shard-দল — যেকোনো node যখন-তখন হারাতে পারে এমন cache cluster-এ নয়।

নিচের প্রোগ্রাম দুটি দাবি একসাথে যাচাই করে: ৫টি সার্ভার থেকে S2 বাদ দিলে rendezvous hashing-এ কী সরে, আর jump hash-এ bucket ১০ থেকে ১১ করলে কী সরে। Jump hash-এর 64-bit গুণ JavaScript-এ নির্ভুল রাখতে BigInt ব্যবহার করা হয়েছে, আর hash, seed ও key আগের প্রোগ্রামগুলোর মতোই।

// Rendezvous (HRW): remove 1 of 5 servers. Jump hash: grow 10 -> 11 buckets.
function h32(s) {                // FNV-1a 32-bit, then MurmurHash3's fmix32 to mix bits
  let h = 0x811c9dc5;
  for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193); }
  h ^= h >>> 16; h = Math.imul(h, 0x85ebca6b); h ^= h >>> 13; h = Math.imul(h, 0xc2b2ae35);
  return (h ^ (h >>> 16)) >>> 0;
}
let seed = 16; function rand() { // mulberry32: small seeded PRNG for the keys
  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 keys = Array.from({ length: 10000 }, () => "user:" + Math.floor(rand() * 1e9));
function hrw(key, servers) {     // score = hash(key, server); the highest score wins
  const sc = servers.map((s) => h32(key + "|" + s));
  return servers[sc.indexOf(Math.max(...sc))];
}
function jump(key, n) {          // Lamping & Veach (2014); key is a 64-bit BigInt
  let b = -1, j = 0;
  while (j < n) {
    b = j; key = BigInt.asUintN(64, key * 2862933555777941757n + 1n);
    j = Math.floor((b + 1) * (2147483648 / (Number(key >> 33n) + 1)));
  }
  return b;
}
const all = ["S0", "S1", "S2", "S3", "S4"], four = ["S0", "S1", "S3", "S4"];
const count = {}; let moved = 0, notFromS2 = 0, jMoved = 0, notToNew = 0;
for (const k of keys) {
  const a = hrw(k, all); count[a] = (count[a] || 0) + 1;
  if (a !== hrw(k, four)) { moved++; if (a !== "S2") notFromS2++; }
  const x = BigInt(h32(k)), j11 = jump(x, 11);
  if (jump(x, 10) !== j11) { jMoved++; if (j11 !== 10) notToNew++; }
}
console.log("HRW, 5 servers: " + all.map((s) => s + "=" + count[s]).join(" "));
console.log("HRW, S2 removed: moved " + (moved / 100).toFixed(1) + "%, moved but not from S2: " + notFromS2);
console.log("jump 10 -> 11: moved " + (jMoved / 100).toFixed(1) + "% (ideal 1/11 = 9.1%), not to bucket 10: " + notToNew);

আউটপুট:

HRW, 5 servers: S0=2031 S1=1959 S2=2014 S3=2046 S4=1950
HRW, S2 removed: moved 20.1%, moved but not from S2: 0
jump 10 -> 11: moved 8.8% (ideal 1/11 = 9.1%), not to bucket 10: 0

Rendezvous: virtual node ছাড়াই পাঁচটি সার্ভার পেয়েছে ১,৯৫০ থেকে ২,০৪৬টি key — গড় ২,০০০-এর ২.৫%-এর মধ্যে; এটুকু ছড়ানো শুধু ১০,০০০টি এলোমেলো key-এর নমুনা থেকে (প্রত্যাশিত sd = √(১০,০০০ × ০.২ × ০.৮) = ৪০)। S2 বাদ দিলে সরেছে ২০.১% — ঠিক S2-এর ২,০১৪টি key, আর অন্য কোনো সার্ভারের একটিও নয় (০)। Jump hash: ১০ থেকে ১১ bucket-এ সরেছে ৮.৮%, আদর্শ ৯.১%-এর কাছে; আর যারা সরেছে, সবাই গেছে নতুন bucket ১০-এ (“not to bucket 10: 0”). দুটিতেই ন্যূনতম নড়াচড়া — কিন্তু jump hash-এ four-এর মতো মাঝের একটি bucket বাদ দেওয়ার কোনো উপায় নেই।

Maglev hashing — প্রতিটি packet-এ এক ধাপে উত্তর

Google-এর software load balancer Maglev-এর paper (Eisenbud ও সহকর্মীরা, NSDI ২০১৬) একটি ভিন্ন পথ দেখায়। L4 load balancer-কে প্রতিটি packet-এর জন্য backend বাছতে হয়, তাই binary search-ও তার কাছে ধীর। Maglev hashing আগে থেকেই M ঘরের একটি lookup table ভরে রাখে, যেখানে M একটি prime আর N-এর চেয়ে অনেক বড় (paper-এর উদাহরণে ৬৫,৫৩৭)। প্রতিটি backend-এর নিজের একটি পছন্দের ক্রম থাকে, আর backend-রা পালা করে পরের পছন্দের খালি ঘর দখল করে — ফলে প্রত্যেকে প্রায় ঠিক M/N ঘর পায়। Lookup মানে শুধু table[hash mod M]. দাম: backend বদলালে ring-এর চেয়ে কিছুটা বেশি ঘর বদলায়, তাই চলমান সংযোগ ঠিক রাখতে আলাদা connection-tracking-ও রাখা হয় (paper অনুযায়ী)। Envoy-র মতো proxy-তে ring hash আর Maglev দুটোই load balancing policy হিসেবে পাওয়া যায় (সংস্করণভেদে আলাদা)।

Bounded load — কেউ যেন গড়ের বেশি না পায়

২০১৬ সালে Vahab Mirrokni, Mikkel Thorup ও Morteza Zadimoghaddam “Consistent Hashing with Bounded Loads” প্রকাশ করেন (arXiv; পরে SODA ২০১৮)। Bounded-load hashing-এ প্রতিটি সার্ভারের একটি ধারণক্ষমতা থাকে: এই মুহূর্তের মোট কাজ m আর সার্ভার n হলে ⌈(1 + ε)·m/n⌉, যেমন ε = ০.২৫ মানে গড়ের সোয়া গুণ। Ring-এ key-এর প্রথম সার্ভার ভরা থাকলে কাজটি যায় পরের সার্ভারে, সেটিও ভরা হলে তার পরেরটিতে। ফলে কোনো সার্ভারের চাপ গড়ের (1 + ε) গুণ ছাড়ায় না, অথচ বেশিরভাগ key নিজের সার্ভারেই থাকে। ε যত ছোট, ভাগ তত সমান, কিন্তু নড়াচড়া তত বেশি। এটি সবচেয়ে কাজের request বা connection ভাগে, যেখানে যেকোনো সার্ভার কাজটি করতে পারে।

পদ্ধতিLookupMemoryযোগে নড়াচড়াযেকোনো সার্ভার বাদযেখানে মানায়
hash mod NO(1)নেইN/(N + 1)হ্যাঁ, কিন্তু প্রায় সব সরেN কখনো বদলায় না এমন জায়গা
Ring + virtual nodeO(log(N·V))N·V≈ 1/(N + 1)হ্যাঁdistributed cache, Dynamo-ধাঁচের store
Rendezvous (HRW)O(N)নেই≈ 1/(N + 1)হ্যাঁকম-বেশি কয়েকশো সার্ভার, weight সহজ
Jump hashO(log n)নেই≈ 1/(n + 1)না, শুধু শেষেরটিনম্বর দেওয়া shard-দল
MaglevO(1)M ঘরের tableন্যূনতমের চেয়ে কিছুটা বেশিহ্যাঁপ্রতি packet-এ সিদ্ধান্ত, L4 load balancer
Bounded loadring + ধারণক্ষমতা যাচাইN·V + গণনাε ছোট হলে বেশিহ্যাঁhot key-সহ request ভাগ

৮ · বাস্তবে কোথায়, আর সীমা — hot key আর membership

যেখানে দেখা যায়

  • Distributed cache: Memcached-এর সার্ভারগুলো একে অপরকে চেনে না; কোন key কোথায় যাবে তা ঠিক করে client library (client-side সিদ্ধান্ত) — যেমন Last.fm-এর প্রকাশিত libketama (২০০৭ নাগাদ), যার “ketama” নামটি অনেক library আর proxy-তে রয়ে গেছে। বহু-স্তরের cache পর্ব ৩০ (Distributed cache)-এ।
  • Dynamo-ধাঁচের key-value store: Dynamo paper (২০০৭) ring, virtual node আর preference list একসাথে ব্যবহার করে; Apache Cassandra-তেও token ring আছে, প্রতি node-এর token-সংখ্যা num_tokens setting-এ (ডিফল্ট সংস্করণভেদে বদলেছে)। পর্ব ৬১-র ক্যাপস্টোনে আপনি নিজেই এমন একটি store নকশা করবেন।
  • CDN: একটি edge location-এর অনেকগুলো cache সার্ভারের মধ্যে কোন URL কে রাখবে, তা ঠিক করতে (পর্ব ১৭ — CDN আর edge).
  • Load balancer: একই ব্যবহারকারী বা URL-কে বারবার একই backend-এ পাঠাতে (cache-এর সুবিধা বা sticky session); algorithm-গুলোর তুলনা পর্ব ১৪ (Load balancing)-এ, session-এর প্রশ্ন পর্ব ১৫ (Stateless বনাম stateful)-এ।
# Illustrative only: Nginx picks an upstream by consistent hashing of the URI.
# "consistent" switches hash from mod-N style to the ketama ring (see the docs).
upstream image_cache {
    hash $request_uri consistent;
    server 10.0.3.11:8080;
    server 10.0.3.12:8080;
    server 10.0.3.13:8080 weight=2;   # bigger machine: more points on the ring
}

সবাই ring ব্যবহার করে না। Redis Cluster প্রতিটি key-কে ফেলে ১৬,৩৮৪টি hash slot-এর একটিতে (key-এর CRC16 mod 16384, অফিসিয়াল documentation অনুযায়ী), আর কোন slot কোন node-এ তা একটি table-এ থাকে; নতুন node এলে কয়েকটি slot পুরোপুরি সরানো হয়। Kafka-র ডিফল্ট partitioner key-যুক্ত message-এ hash mod partition-সংখ্যা ব্যবহার করে, তাই partition বাড়ালে বেশিরভাগ key-এর partition বদলে যায় — তার documentation-এ এ নিয়ে সাবধান করা আছে। দুটির মিল: key থেকে ঘর (slot বা partition)-এর নিয়ম কখনো বদলায় না, বদলায় শুধু ঘরের মালিক। এই “নির্দিষ্ট অনেক ঘর” কৌশল আর rebalancing পর্ব ২৩-এ।

সীমা ১ — hot key

Consistent hashing key-এর সংখ্যা সমান করে; প্রতিটি key-তে সমান traffic এলে তবেই চাপও সমান। বাস্তবে traffic খুব অসমান — অল্প কিছু key-তেই বেশিরভাগ অনুরোধ (hot set). একটি কাল্পনিক খবরের সাইটে ক্রিকেট ফাইনালের live score-এর key-তে হঠাৎ মোট অনুরোধের বড় অংশ এলে, সেই একটি hot key যে সার্ভারে আছে সেটিই ডোবে। V বাড়িয়ে লাভ নেই — key তো একটাই। প্রতিকার: app server-এ অল্প TTL-এর local cache, hot key-এর কয়েকটি কপি (score#1 … score#4) আলাদা সার্ভারে, একসাথে আসা miss জুড়ে একটি অনুরোধ, আর request-ভাগে bounded load — প্রতিটির দাম ভাবনার প্রশ্ন ৪-এ।

সীমা ২ — সবাই একই তালিকা দেখছে তো?

সব নিয়মই ধরে নেয় যে প্রতিটি client-এর কাছে একই সার্ভার-তালিকা, অর্থাৎ একই membership. একটি app server নতুন সার্ভার D-র খবর পেয়েছে, আরেকটি পায়নি — তখন একই key দুই জায়গায় যায়, একটির invalidation অন্যটিতে পৌঁছায় না। আবার health check একবার ব্যর্থ হলেই সার্ভার বাদ, পরের বার আবার যোগ — এমন দোলাচলে বারবার key সরে। প্রতিকার আর বিস্তারিত আছে নিচের ভাবনার প্রশ্ন ৫-এ।

সীমা ৩ — সরানোর দাম আর hash-এর মান

Cache-এ একটি key সরা মানে একটি miss; database-এ মানে আসল ডেটা কপি — যা পেছনে, গতি বেঁধে করতে হয়। আর hash function হতে হবে সব process আর সংস্করণে হুবহু এক। ভাষার নিজস্ব hash প্রায়ই চলে না: Python-এ string-এর hash() ডিফল্টভাবে প্রতিটি process-এ আলাদা salt নেয়; আর Java-র String.hashCode সব জায়গায় এক হলেও server1 আর server2-এর hash পাশাপাশি — ring-এ গুচ্ছ হয়ে বসে।

আরও গভীরে: Ring-এর ধারণা peer-to-peer জগতে গিয়ে হয় DHT — যেমন Chord (Stoica ও সহকর্মীরা, SIGCOMM ২০০১), যেখানে কোনো node-ই পুরো ring জানে না; প্রতিটি node শুধু ring-এ দ্বিগুণ দূরত্বের কয়েকজন প্রতিবেশীর ঠিকানা (finger table) রাখে, আর বড়জোর প্রায় log₂ N লাফে (গড়ে তার অর্ধেক) যেকোনো key-এর মালিকে পৌঁছানো যায়। Google-এর প্রকৌশলী Ben Appleton ও Michael O’Reilly-র multi-probe consistent hashing (arXiv, ২০১৫) আবার উল্টো পথে গেছে: virtual node-এর বদলে প্রতিটি key-কে কয়েকবার hash করে সবচেয়ে কাছের সার্ভার বাছা, যাতে কম memory-তে ভাগ সমান হয়।

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

  • hash mod N-এ N থেকে N + 1 করলে কত ভাগ key সরে? প্রমাণ দিন। N/(N + 1); N(N + 1)টি পরপর মানে দুই ভাগশেষ সমান এমন জোড়া মাত্র Nটি।
  • Ring-এ একটি সার্ভার যোগ করলে প্রত্যাশিত কত key সরে, আর কেন এটি সর্বনিম্ন? K/(N + 1) — শুধু নতুন সার্ভারের বৃত্তাংশ; তার ন্যায্য ভাগ দিতে এটুকু সরাতেই হয়।
  • Virtual node কেন, V কীভাবে বাছবেন? sd/গড় ≈ 1/√V আর বিকল সার্ভারের ভার ছড়ানো; লক্ষ্য c হলে V ≈ 1/c².
  • Ring, rendezvous আর jump hash-এর পার্থক্য? O(log(N·V)) আর vnode; O(N), vnode ছাড়াই; memory ছাড়া, কিন্তু শুধু শেষের bucket.
  • একটি hot key কীভাবে সামলাবেন? Local cache, কয়েকটি কপি, request coalescing, bounded load — V বাড়িয়ে নয়।

৯ · AI-এর যুগে — consistent hashing-এর কোড আর দাবি যাচাই

AI কয়েক সেকেন্ডে গোছানো ring-কোড দেয়, সাথে প্রায়ই “Redis, Kafka আর microservice”-এর পরামর্শ। তার ভুলগুলো খুব নির্দিষ্ট, আর প্রায় সবই একটি ছোট simulation চালালেই ধরা পড়ে:

AI-এর ভুলকেন ভুলকীভাবে ধরবেন
“Consistent hashing মানেই সমান ভার”Virtual node ছাড়া সবচেয়ে ব্যস্ত সার্ভার গড়ের কয়েক গুণ পায়; vnode থাকলেও hot key থাকেপ্রতি সার্ভারের key গুনে sd/গড় আর সর্বোচ্চ/গড় মাপুন (অংশ ৪)
কোডে hash(key) % N, নাম consistent hashingN বদলালে N/(N + 1) ভাগ সরেসার্ভার যোগ করে সরানো key গুনুন (অংশ ৩-এর পরীক্ষা)
ভাষার নিজস্ব hash (hash(), hashCode)Process-ভেদে আলাদা, বা কাছাকাছি নামে গুচ্ছদুটি আলাদা process-এ একই key-এর ফল মেলান; নির্দিষ্ট algorithm চেয়ে নিন
Binary search-এ wrap-around বাদশেষ token-এর পরের key-তে index array-এর বাইরেসবচেয়ে বড় hash-মানের কাছের key দিয়ে test
Replication-এ “পরের RF-টি token”Virtual node-এ দুই কপি একই মেশিনে পড়তে পারেPreference list-এ সার্ভারের নাম অনন্য কি না দেখুন
পণ্যের ভেতরের খবর বানানো (“Redis Cluster ring চালায়”)Redis Cluster hash slot ব্যবহার করে; setting আর ডিফল্ট সংস্করণভেদে বদলায়অফিসিয়াল documentation
সব জায়গায় consistent hashingStateless সার্ভিসে key-কে নির্দিষ্ট সার্ভারে পাঠানোর দরকারই নেইপ্রশ্ন করুন: কোনো সার্ভারে key-নির্দিষ্ট অবস্থা আছে কি?

AI-কে কাজটি এমনভাবে দিন যাতে প্রতিটি দাবি পরীক্ষা দিয়ে প্রমাণ করতে হয়:

তুমি একজন অভিজ্ঞ backend ইঞ্জিনিয়ার। JavaScript-এ একটি consistent hashing ring লিখে দাও। ১. Hash: FNV-1a 32-bit, তারপর MurmurHash3-এর fmix32. ভাষার built-in hash ব্যবহার করবে না। ২. প্রতিটি সার্ভারের weight অনুযায়ী V × weight টি virtual node; token-গুলো সাজানো array-তে, lookup binary search দিয়ে, শেষ পেরোলে wrap-around. ৩. getPreferenceList(key, rf): ঘড়ির কাঁটার দিকে rf-টি ভিন্ন আসল সার্ভার। ৪. পরীক্ষা লেখো: ১০ থেকে ১১টি সার্ভার করলে কত ভাগ key সরে (প্রত্যাশা প্রায় ১/১১), কোনো key পুরোনো দুই সার্ভারের মধ্যে সরে কি না (হওয়া উচিত শূন্য), আর V = 1, 10, 100-এ প্রতি সার্ভারের key-সংখ্যার standard deviation. ৫. প্রতিটি অনুমান আর সীমা (hot key, membership) আলাদা করে লেখো। কোনো পণ্যের ভেতরের তথ্য বললে সূত্র দাও; না জানলে স্পষ্ট বলো "জানি না"।

AI-এর ring-কোড হাতে পেলে — যাচাইয়ের চেকলিস্ট

AI দেয় দ্রুত খসড়া, যাচাইয়ের দায় আপনার।
সরানো key গুনুন, ভাগের ছড়ানো মাপুন, ব্যর্থতার পথ ভাবুন।

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

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন। সূত্র মুখস্থ নয়, কেন কোন নকশা বেছে নেওয়া হয় সেটাই আসল।

প্র ০১১৯৯৭ সালে consistent hashing কোন সমস্যার সমাধান হিসেবে এসেছিল, আর paper-এর “balance, monotonicity, spread, load” বৈশিষ্ট্যগুলো আজও কেন প্রাসঙ্গিক?

১৯৯০-এর দশকের মাঝামাঝি web দ্রুত বাড়ছিল। কোনো পাতা হঠাৎ জনপ্রিয় হলে লাখো মানুষ একসাথে একটি সার্ভারে চাপ দিতেন, আর সেটি ভেঙে পড়ত — একে বলা হতো hot spot. স্বাভাবিক সমাধান: অনেক cache সার্ভারে ভার ভাগ করা। কিন্তু কোন URL কোন cache-এ? সব cache-এ সব রাখলে memory-র অপচয়; hash mod N-এ একটি cache যোগ বা বাদ দিলেই প্রায় সব URL নতুন জায়গায়; আর বিভিন্ন browser ও proxy-র কাছে cache-এর তালিকা একই সময়ে আলাদা থাকতে পারে।

MIT-র David Karger, Eric Lehman, Tom Leighton, Rina Panigrahy, Matthew Levine ও Daniel Lewin ১৯৯৭ সালের STOC সম্মেলনে “Consistent Hashing and Random Trees” paper-এ এর গাণিতিক উত্তর দেন। তাঁরা ভালো mapping-এর কয়েকটি বৈশিষ্ট্য সংজ্ঞায়িত করেন:

  • Balance: প্রতিটি cache মোটামুটি সমান ভাগ পায়।
  • Monotonicity: নতুন cache যোগ হলে key শুধু পুরোনো থেকে নতুনে যায়, পুরোনোদের মধ্যে নয় — অংশ ৩-এর পরীক্ষায় “between old servers 0.0%”.
  • Spread: ভিন্ন ভিন্ন client ভিন্ন তালিকা (“view”) দেখলেও একটি key মোট কয়টি আলাদা cache-এ পৌঁছাতে পারে, তার সীমা।
  • Load: সব view মিলিয়ে একটি cache-এ মোট কতগুলো আলাদা key আসতে পারে, তার সীমা।

প্রথম দুটি আজ সবাই জানেন; শেষ দুটি প্রায় ভুলে যাওয়া, অথচ আজকের autoscaling-এর যুগে খুব প্রাসঙ্গিক — সার্ভার মিনিটে মিনিটে আসে-যায়, আর সব client একই মুহূর্তে খবর পায় না। Paper-এর আরেকটি অংশ, “random trees”, জনপ্রিয় পাতার অনুরোধ কয়েক স্তরের cache-এ ছড়িয়ে দেওয়ার কৌশল — ধারণাগতভাবে আজকের বহু-স্তরের cache-এর কাছাকাছি। পরের বছর Leighton ও Lewin Akamai প্রতিষ্ঠা করেন, যা CDN শিল্পের পথিকৃৎদের একটি।

মূল উপলব্ধি: একটি ভালো mapping-এর মান শুধু “সমান ভাগ” দিয়ে মাপা হয় না — “বদলের সময় কতটা নড়ে” আর “সবাই একমত না হলে কতটা বিশৃঙ্খলা হয়” দিয়েও মাপা হয়।

প্র ০২Dynamo-তে virtual node থাকা সত্ত্বেও কেন “সমান মাপের নির্দিষ্ট partition”-এর দিকে যাওয়া হয়েছিল?

Amazon-এর Dynamo paper (DeCandia ও সহকর্মীরা, SOSP ২০০৭) ring আর virtual node-এর সবচেয়ে বিখ্যাত বাস্তব বর্ণনা। কিন্তু paper-এর একটি অংশ খুব শিক্ষণীয়: ভার সমানভাবে ছড়াতে তাঁরা তিনটি কৌশল তুলনা করেন (paper অনুযায়ী):

  • কৌশল ১: প্রতিটি node-এর T-টি এলোমেলো token; partition মানে দুটি token-এর মাঝের অংশ — এই পর্বের ring.
  • কৌশল ২: এলোমেলো token থাকে, কিন্তু ring আগে থেকেই Q-টি সমান partition-এ ভাগ।
  • কৌশল ৩: Q-টি সমান partition, আর প্রতিটি node পায় মোটামুটি Q/S-টি partition (S = node-এর সংখ্যা)।

কৌশল ১-এর সমস্যা কাগজে নয়, চালানোয়। নতুন node এলে সে অন্যদের key-range-এর টুকরো নেয়; যাদের range ভাঙল, তাদের নিজের ডেটা scan করে আলাদা করতে হয় — ব্যস্ত সময়ে ভারী কাজ। প্রতিটি range বদলালে replica মেলানোর জন্য রাখা Merkle tree (ডেটার hash-গাছ) নতুন করে বানাতে হয়। আর range-গুলো এলোমেলো বলে পুরো ডেটার snapshot বা archive নেওয়াও জটিল।

কৌশল ৩-এ partition-এর সীমা কখনো বদলায় না; বদলায় শুধু কোন partition কোন node-এ। ডেটা সরানো মানে পুরো partition-এর ফাইল কপি, Merkle tree স্থির, archive সহজ — আর paper-এর মাপে ভার ছড়ানোর দক্ষতাও এটিরই সবচেয়ে ভালো ছিল। Apache Cassandra-ও এক সময় প্রতি node-এ অনেক এলোমেলো token ডিফল্ট রাখত, পরে কম token আর একটি token-বণ্টন algorithm-এর দিকে গেছে (সংস্করণভেদে আলাদা)।

এর সাধারণ রূপ আপনি পর্ব ২৩-এ দেখবেন: key থেকে অনেকগুলো নির্দিষ্ট ঘর, আর ঘর থেকে node-এর একটি table. Redis Cluster-এর hash slot একই চিন্তার আরেকটি রূপ — গাণিতিক সৌন্দর্যের চেয়ে চালানোর সরলতাকে বেশি দাম দেওয়া।

মূল উপলব্ধি: তাত্ত্বিকভাবে ন্যূনতম নড়াচড়া যথেষ্ট নয়। ডেটা সরানোর একক কত বড়, metadata কত ছোট, মেরামত আর backup কত সরল — এগুলোও নকশা ঠিক করে।

প্র ০৩Redis Cluster কেন ring নয়, ১৬,৩৮৪টি hash slot ব্যবহার করে? আর Kafka কেন hash mod N রেখেই চলে — mod N কখন যথেষ্ট?

Redis Cluster-এর অফিসিয়াল documentation অনুযায়ী প্রতিটি key-এর slot = CRC16(key) mod 16384. প্রতিটি master node কিছু slot-এর মালিক; নতুন node এলে প্রশাসক বা tool কয়েকটি slot পুরোপুরি সরায়, আর client-রা slot থেকে node-এর তালিকা মনে রাখে — ভুল node-এ গেলে উত্তর আসে “অমুক node-এ যান” (MOVED). সুবিধাগুলো:

  • নিয়ন্ত্রণ: ঠিক কোন slot কোথায় যাবে, মানুষ বা tool নিজে ঠিক করে — ভার হিসাব করে, ধীরে ধীরে।
  • ছোট metadata: ১৬,৩৮৪টি slot-এর মালিকানা একটি ছোট bitmap-এ ধরে, যা node-গুলো heartbeat-এ বিনিময় করে; Redis-এর স্রষ্টা Salvatore Sanfilippo-র প্রকাশ্য ব্যাখ্যায় সংখ্যাটি বাছাইয়ের কারণ হিসেবে এই বার্তার আকারের কথাই আসে।
  • সম্পর্কিত key একসাথে: key-এর নামের {…} অংশ (hash tag) দিয়ে কয়েকটি key-কে একই slot-এ রাখা যায়, যাতে একসাথে কয়েকটি key-এর কাজ এক node-এ হয়।

গাণিতিকভাবে এটি “একটি বড়, কখনো-না-বদলানো Q দিয়ে mod, তারপর একটি table” — Q স্থির বলে key-এর slot কখনো বদলায় না, তাই mod N-এর নড়াচড়ার সমস্যাই থাকে না।

Kafka-র ডিফল্ট partitioner key-যুক্ত message-এ key-এর hash mod partition-সংখ্যা ব্যবহার করে, যাতে একই key সবসময় একই partition-এ যায় আর তার message-এর ক্রম ঠিক থাকে। Partition বাড়ালে বেশিরভাগ key-এর partition বদলে যায়, পুরোনো আর নতুন message দুই partition-এ ছড়িয়ে পড়ে — documentation-এ এ নিয়ে সতর্ক করা আছে। তাই প্রচলিত অভ্যাস হলো শুরুতেই পর্যাপ্ত partition রাখা আর কালেভদ্রে বদলানো।

mod N যথেষ্ট যখন: N প্রায় কখনো বদলায় না; অথবা প্রতিটি সরানো key-এর দাম প্রায় শূন্য (যেমন stateless সার্ভিস, যেখানে যেকোনো সার্ভার যেকোনো অনুরোধ সামলায়); অথবা আপনি সবসময় দ্বিগুণ করেন, যখন ঠিক অর্ধেক key সরে আর সবাই নতুন সার্ভারে যায়।

মূল উপলব্ধি: প্রশ্নটা “কোন algorithm সেরা” নয়; প্রশ্ন হলো “N কত ঘন ঘন বদলায়, আর প্রতিটি সরানো key-এর দাম কত”।

প্র ০৪Consistent hashing তো ভার সমান করে — তাহলে একটি ভাইরাল পোস্ট বা হঠাৎ জনপ্রিয় key একটি cache সার্ভারকে কেন ডুবিয়ে দেয়, আর bounded-load hashing কী করে?

Consistent hashing সমান করে key-এর সংখ্যা। প্রতিটি key-এ সমান traffic এলে চাপও সমান হতো, কিন্তু বাস্তব traffic প্রায় সবসময় খুব অসমান: অল্প কয়েকটি key-তে বেশিরভাগ অনুরোধ, বাকি লাখো key প্রায় চুপ। একটি কাল্পনিক খবরের সাইটে ক্রিকেট ফাইনালের রাতে live score-এর key-তে সেকেন্ডে বিপুল অনুরোধ এলে সেই key যে সার্ভারেই থাকুক, পুরো চাপ একটি সার্ভারের ঘাড়ে। V বাড়ালে লাভ নেই — key একটাই, তাকে ভাগ করা যায় না।

প্রতিকারগুলোর প্রতিটির নিজস্ব দাম আছে:

  • Local cache: প্রতিটি app server-এ কয়েক সেকেন্ডের TTL-সহ ছোট cache; বেশিরভাগ অনুরোধ নেটওয়ার্কেই যায় না। দাম: কয়েক সেকেন্ড পুরোনো তথ্য।
  • Key ভাগ করে কপি: score#1 … score#r আলাদা সার্ভারে; লেখার সময় সব কপিতে, পড়ার সময় যেকোনো একটিতে — r গুণ ক্ষমতা। দাম: লেখা r গুণ, আর কপিগুলোর মধ্যে সাময়িক অমিল।
  • Request coalescing: একই key-এর একসাথে আসা miss-গুলো জুড়ে মূল উৎসে একটি অনুরোধ, যাতে cache stampede না হয়।
  • Bounded load: Mirrokni, Thorup ও Zadimoghaddam-এর পদ্ধতি (arXiv ২০১৬, SODA ২০১৮): প্রতিটি সার্ভারের চাপ গড়ের (1 + ε) গুণের বেশি হবে না; ভরা সার্ভারের কাজ ring-এ পরের সার্ভারে যায়। Vimeo-র প্রকাশিত engineering blog (২০১৬) অনুযায়ী তাঁরা এটি HAProxy-তে যোগ করেছিলেন, আর HAProxy-র documentation-এ hash-balance-factor নামে এমন একটি option পাওয়া যায় (সংস্করণভেদে আলাদা)।

খেয়াল করুন, bounded load খাটে request বা connection ভাগে — যেখানে যেকোনো সার্ভার কাজটি করতে পারে। Database shard-এ ডেটা যেখানে আছে সেখান থেকেই পড়তে হয়; সেখানে সমাধান hot partition ভেঙে ছোট করা বা replica থেকে পড়া (পর্ব ২৩ আর ৩০)।

মূল উপলব্ধি: key-এর ভাগ সমান মানেই কাজের ভাগ সমান নয়। Hot key সবসময় আলাদা নকশা চায়, আর সেটি আগে থেকেই পরিকল্পনায় রাখতে হয়।

প্র ০৫Ring, rendezvous, jump hash, Maglev — নতুন একটি সিস্টেমে কোনটি বাছবেন? আর সব client-এর কাছে সার্ভার-তালিকা এক না থাকলে কী ঘটে?

সিদ্ধান্ত নিন কয়েকটি প্রশ্নের উত্তর দিয়ে:

  • যেকোনো সার্ভার যখন-তখন বিকল হতে পারে? হ্যাঁ হলে (cache cluster, ছোট key-value store) ring বা rendezvous; jump hash তখনই, যখন প্রতিটি bucket নিজেই replicated একটি দল।
  • সার্ভার কয়টি, lookup কত ঘন? কয়েক ডজন থেকে কয়েকশো সার্ভার আর সরল কোড চাইলে rendezvous — কোনো vnode নেই, weight সহজ। খুব বেশি সার্ভার বা প্রতিটি অনুরোধে খুব কম সময়ের বাজেট হলে ring-এর O(log(N·V)). প্রতিটি packet-এ সিদ্ধান্ত লাগলে Maglev-এর O(1) table.
  • Replication লাগবে? Ring আর rendezvous দুটোই স্বাভাবিকভাবে preference list দেয়।
  • Memory আর metadata: jump আর rendezvous-এ প্রায় শূন্য; ring-এ N·V; Maglev-এ M ঘর।

কিন্তু যেটিই বাছুন, সবগুলো একটি অদৃশ্য শর্তে দাঁড়িয়ে: সবার কাছে একই সার্ভার-তালিকা। ধরুন client X জানে {A, B, C, D}, আর client Y এখনো জানে {A, B, C} — D মাত্র যোগ হয়েছে। D-র ভাগের key X পাঠায় D-তে, Y পাঠায় অন্য কোথাও। Cache-এ ফল: একই key-এর দুই কপি, একটিকে মুছলে (invalidation) অন্যটি থেকে যায়, পুরোনো ডেটা দেখা যায়। Storage-এ ফল আরও গুরুতর — লেখা ভিন্ন node-এ পড়ে, পরে মেলাতে quorum আর মেরামত লাগে (পর্ব ৪১)। Karger-দের paper-এর “spread” বৈশিষ্ট্য ঠিক এই বিশৃঙ্খলার সীমা মাপে। আর autoscaling যত ঘন ঘন সার্ভার আনে-সরায়, এমন মতভেদের জানালাও তত বেশিবার খোলে।

বাস্তব প্রতিকার: membership-এর একটি নির্ভরযোগ্য উৎস (etcd বা ZooKeeper-এর মতো coordination service, পর্ব ৪৩) অথবা gossip-এ ছড়ানো তালিকা (পর্ব ৪৪); তালিকার সাথে একটি version নম্বর, যাতে পুরোনো view চেনা যায়; আর একটি health check ব্যর্থ হলেই সার্ভার বাদ না দিয়ে কয়েকবার নিশ্চিত হওয়া, যাতে দোলাচলে বারবার key না সরে।

মূল উপলব্ধি: algorithm বাছাই সহজ অংশ; কঠিন অংশ হলো সবাইকে একই সময়ে একই তালিকায় রাখা, আর না থাকলে কী ভাঙে তা আগে থেকে জানা।

অনুশীলন

কাগজ-কলমে হিসাব করুন, তারপর উত্তর মেলান। শেষ প্রশ্নে আগে অনুমান, তারপর অংশ ৪-এর simulator চালিয়ে যাচাই।

  1. সরানো key আর database-এর চাপ: একটি cache cluster-এ ৯টি সার্ভার, ৫০ লাখ key, peak-এ সেকেন্ডে ১,০০,০০০টি পড়ার অনুরোধ, hit ratio ৯৫%. সার্ভার ৯ থেকে ১০টি করা হলো। (ক) hash mod N আর (খ) consistent hashing-এ প্রত্যাশিত কতগুলো key-এর ঠিকানা বদলাবে? (গ) সরানো key-গুলো প্রথমবার miss হবে ধরে নিয়ে, বদলের ঠিক পরে database-এ সেকেন্ডে কত অনুরোধ যাবে, দুই ক্ষেত্রে? (ঘ) ১০টি থেকে একটি বিকল হলে?
    • (ক) mod N: N = ৯ থেকে ১০, সরে N/(N + 1) = ৯/১০ = ৯০% — ৪৫ লাখ key.
    • (খ) Ring: প্রত্যাশিত 1/(N + 1) = ১/১০ = ১০% — ৫ লাখ key, সবাই নতুন সার্ভারে।
    • (গ) Database-এর চাপ: আগে miss ৫%, অর্থাৎ সেকেন্ডে ৫,০০০টি। mod N-এ miss = ৫% + ৯৫% × ৯০% = ৫% + ৮৫.৫% = ৯০.৫% — সেকেন্ডে ৯০,৫০০টি, প্রায় ১৮ গুণ। Ring-এ miss = ৫% + ৯৫% × ১০% = ১৪.৫% — সেকেন্ডে ১৪,৫০০টি, প্রায় ২.৯ গুণ; তাই বাস্তবে নতুন সার্ভারকে ধীরে ধীরে weight দেওয়া হয়।
    • (ঘ) একটি বিকল (১০ থেকে ৯): mod N-এ আবার ৯/১০ = ৯০% সরে; ring-এ শুধু বিকল সার্ভারের ভাগ, প্রত্যাশিত ১০%, virtual node থাকলে বাকি ৯টির মধ্যে ছড়িয়ে।
  2. নকশা আর সমালোচনা — V বাছাই: ৫০টি সার্ভারের একটি cache cluster; ৪০টি সাধারণ, ১০টির RAM দ্বিগুণ। একজন সহকর্মী বললেন, “৫০টি সার্ভার তো অনেক, virtual node লাগবে না — প্রতি সার্ভারে একটি বিন্দুই যথেষ্ট।” (ক) প্রস্তাবটি সমালোচনা করুন। (খ) সাধারণ সার্ভারে ভাগের sd গড়ের ৫%-এর মধ্যে রাখতে V কত? (গ) বড় মেশিনে কত বিন্দু, প্রত্যেকে মোট key-এর কত ভাগ পাবে? (ঘ) Ring-এ মোট কত token, lookup-এ সবচেয়ে বেশি কত তুলনা, আর memory মোটামুটি কত?
    • (ক) V = ১-এ sd/গড় ≈ √((N − 1)/(N + 1)) = √(৪৯/৫১) ≈ ৯৮%. সবচেয়ে বড় বৃত্তাংশের প্রত্যাশিত মাপ গড়ের H৫০ ≈ ৪.৫ গুণ — cluster-এর ক্ষমতা ঠিক করবে সেই একটি সার্ভার। বড় মেশিনের বাড়তি RAM কাজে লাগানোর উপায় নেই, আর একটি বিকল হলে পুরো ভার যাবে একজন প্রতিবেশীতে। সার্ভার বেশি হলে সমস্যা কমে না, বাড়ে।
    • (খ) c = ০.০৫: V ≈ (N − 1)/(N·c²) = (৪৯/৫০) ÷ ০.০০২৫ ≈ ৩৯২, অর্থাৎ প্রায় ৪০০। আমাদের simulation-এ (৩০টি আলাদা ring-এর গড়) N = ৫০, V = ৪০০-এ sd/গড় এসেছে ৪.৯%, আর সবচেয়ে ব্যস্ত সার্ভার গড়ের প্রায় ১.১১ গুণ।
    • (গ) weight ২, তাই ৮০০টি বিন্দু। মোট weight = ৪০ × ১ + ১০ × ২ = ৬০; সাধারণ সার্ভার পায় ১/৬০ ≈ ১.৬৭%, বড়টি ২/৬০ ≈ ৩.৩৩% (simulation-এ ১.৬৭% আর ৩.৩৩%, বড়দের sd প্রায় ৩.৩%, সূত্রে ১/√৮০০ ≈ ৩.৫%).
    • (ঘ) token = ৪০ × ৪০০ + ১০ × ৮০০ = ২৪,০০০; ⌈log₂ ২৪,০০০⌉ = ১৫টি তুলনা (২¹⁴ = ১৬,৩৮৪ < ২৪,০০০ ≤ ২¹⁵ = ৩২,৭৬৮)। প্রতিটি entry ৮ থেকে ১৬ byte ধরলে মোটামুটি ১৯০ থেকে ৩৮০ KB — প্রতিটি app server-এ রাখা সহজ।
  3. একটি ring-এ lookup trace করুন: hash-এর পরিসর 0–999; token: A = ১০৫ ও ৯৩০, B = ২৯০ ও ৬১০, C = ৪৫০ ও ৮৪৫. নিয়ম: প্রথম যে token key-এর hash-এর সমান বা বড়, শেষ পেরোলে প্রথমটিতে ফেরা। (ক) hash ৭৭, ৩০০, ৭০০, ৮৪৫ আর ৯৯০-এর key-এর মালিক কে? (খ) ৭০০-এর জন্য সাজানো array-তে binary search-এর প্রতিটি ধাপ লিখুন। (গ) RF = ২ হলে ৩০০ আর ৯৯০-এর preference list কী? (ঘ) C বিকল হলে কোন key কোথায় যাবে?

    সাজানো array: #০ ১০৫ A, #১ ২৯০ B, #২ ৪৫০ C, #৩ ৬১০ B, #৪ ৮৪৫ C, #৫ ৯৩০ A.

    • (ক) ৭৭ → ১০৫ A; ৩০০ → ৪৫০ C; ৭০০ → ৮৪৫ C; ৮৪৫ → ঠিক ৮৪৫, সমান বলে C; ৯৯০ → কোনো token বড় নয়, ঘুরে ১০৫ A.
    • (খ) lo = ০, hi = ৬. mid = ৩: ৬১০ ছোট ৭০০-এর চেয়ে, তাই lo = ৪. mid = ৫: ৯৩০ ≥ ৭০০, তাই hi = ৫. mid = ৪: ৮৪৫ ≥ ৭০০, তাই hi = ৪. lo = hi = ৪ → ৮৪৫, C. মোট ৩টি তুলনা।
    • (গ) ৩০০: শুরু #২ C, পরের #৩ B নতুন → [C, B]. ৯৯০: শুরু #০ A, পরের #১ B নতুন → [A, B].
    • (ঘ) C-র দুটি token মুছলে array: ১০৫ A, ২৯০ B, ৬১০ B, ৯৩০ A. ৩০০ যায় ৬১০ B-তে; ৭০০ আর ৮৪৫ যায় ৯৩০ A-তে। ৭৭ আর ৯৯০ নড়ে না। C-র ভার ভাগ হলো B আর A-র মধ্যে; আর ৩০০-এর পুরোনো preference list [C, B]-এর দ্বিতীয়জন B-ই এখন মালিক — কপি আগেই সেখানে ছিল।
  4. আউটপুট অনুমান করুন: অংশ ৪-এর vnode_balance.js-এ const N = 10-কে const N = 20 করলে (key আগের মতোই ১,০০,০০০টি) চারটি V-এর জন্য sd/avg আর V = ১-এ max/avg মোটামুটি কত আসবে? আগে সূত্র দিয়ে অনুমান লিখুন, তারপর চালিয়ে মেলান।

    অনুমান: sd/গড় ≈ √(১৯/(২০V + ১)): V = ১-এ প্রায় ৯৫%, ১০-এ ৩১%, ১০০-এ ৯.৭%, ১,০০০-এ ৩.১%. প্রতি সার্ভারে এখন গড়ে ৫,০০০টি key, তাই key-এর নমুনার নিজস্ব ছড়ানো প্রায় ১/√৫,০০০ ≈ ১.৪% যোগ হয় — V = ১,০০০-এ মোট প্রায় ৩.৪%. V = ১-এ max/avg আশা করা যায় H২০ ≈ ৩.৬-এর কাছে। গড় প্রতি সার্ভারে ৫,০০০.

    আসল আউটপুট:

    V      points    min    max  std dev  max/avg  sd/avg
    1          20    161  17240     4550     3.45   91.0%
    10        200   2509   8727     1217     1.75   24.3%
    100      2000   3653   5817      480     1.16    9.6%
    1000    20000   4628   5254      148     1.05    3.0%
    average per server = 5000 keys

    অনুমানের সাথে ভালোই মেলে: ৯১.০% (অনুমান ৯৫%), ২৪.৩% (৩১%), ৯.৬% (৯.৭%), ৩.০% (প্রায় ৩.৪%) — সূত্র বলে অনেক ring-এর গড়, একটিমাত্র ring-এ ফল এদিক-ওদিক হয়। V = ১-এ max/avg ৩.৪৫. N দ্বিগুণ হলেও V = ১,০০০-এ ভাগ প্রায় একই রকম সমান: অসমতা ঠিক করে মূলত V, N নয়।

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

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

ক্রমVirtual node-সহ ring-এ একটি key-এর তিনটি কপির (RF = ৩) সার্ভার খোঁজার ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. 🔑 key-এর hash হিসাব করে ring-এ তার অবস্থান x বের করা
  2. 🔎 সাজানো token array-তে binary search: প্রথম token ≥ x
  3. ↩️ array-এর শেষ পেরিয়ে গেলে প্রথম token-এ ফিরে আসা
  4. 🖥️ সেই token যে আসল সার্ভারের, তাকে প্রথম কপির মালিক ধরা
  5. ➡️ ঘড়ির কাঁটার দিকে এগিয়ে, আগে নেওয়া সার্ভারের token বাদ দিয়ে আরও ২টি ভিন্ন সার্ভার নেওয়া

একটি cache cluster-এ key-এর সার্ভার ঠিক হয় hash(key) mod N দিয়ে। সার্ভার ৪টি থেকে ৫টি করা হলো। Hash ভালোভাবে ছড়ানো ধরে নিলে মোটামুটি কত ভাগ key-এর সার্ভার বদলাবে?

  • ২০%
  • ২৫%
  • ৮০%
  • ১০০%

একটি consistent hashing ring-এ ৯টি সার্ভার, প্রতিটির একটি এলোমেলো অবস্থান। দশম সার্ভার যোগ করলে প্রত্যাশিতভাবে মোট key-এর কত ভাগ নতুন সার্ভারে যাবে?

  • ১/৯
  • ১/১০
  • ৯/১০
  • ১/২

Consistent hashing ring-এ প্রতিটি সার্ভারকে অনেকগুলো virtual node দেওয়ার মূল কারণ কোনটি?

  • Lookup দ্রুত করা
  • Replication-এর প্রয়োজন দূর করা
  • Hot key-এর সমস্যা দূর করা
  • ভাগের অসমতা কমানো আর বিকল সার্ভারের ভার অনেকের মধ্যে ছড়ানো

একটি ring-এ ১০০টি সার্ভার, প্রতিটির ১৬০টি virtual node; সব token একটি সাজানো array-তে। Binary search দিয়ে একটি key-এর মালিক খুঁজতে সবচেয়ে বেশি মোটামুটি কয়টি তুলনা লাগবে?

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

Rendezvous (highest random weight) hashing-এ পাঁচটি সার্ভারের একটি বিকল হলে কী ঘটে?

  • সব key নতুন করে ভাগ হয়
  • বিকল সার্ভারের সব key তার পাশের একটি সার্ভারে যায়
  • শুধু বিকল সার্ভারের key সরে, আর সেগুলো বাকি চারটিতে মোটামুটি সমানভাবে ছড়ায়
  • কোনো key সরে না

একটি cache cluster-এ virtual node-সহ consistent hashing (প্রতি সার্ভারে ২০০টি বিন্দু) চলছে। একটি ভাইরাল ভিডিওর metadata key-তে হঠাৎ মোট অনুরোধের প্রায় ৩০% আসছে, আর সেই key-এর সার্ভার overload. কোন পদক্ষেপ সবচেয়ে কার্যকর?

  • প্রতি সার্ভারে ১,০০০টি বিন্দু করা
  • Hot key-টি কয়েকটি সার্ভারে কপি রাখা, আর app server-এ অল্প TTL-এর local cache
  • Rendezvous hashing-এ বদলে ফেলা
  • আরও ৫টি সার্ভার যোগ করা

শব্দকোষ

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

মনে রাখুন

  1. hash mod N-এ সার্ভার N থেকে N + 1 করলে সরে N/(N + 1) ভাগ key, অথচ ন্যূনতম দরকার 1/(N + 1); cache-এ এর মানে হঠাৎ miss-এর ঢল।
  2. Ring: key যায় ঘড়ির কাঁটার দিকে প্রথম সার্ভারে (শেষে wrap); যোগে শুধু নতুন সার্ভারের বৃত্তাংশ সরে — প্রত্যাশিত K/(N + 1), পুরোনোদের মধ্যে কিছুই নয়।
  3. Virtual node: sd/গড় ≈ 1/√V; লক্ষ্য c হলে V ≈ 1/c²; বিকল সার্ভারের ভার ছড়ায়, weight-এর জন্য বেশি বিন্দু; দাম N·V memory আর log₂(N·V) lookup.
  4. Replication: ঘড়ির কাঁটার দিকে প্রথম RF-টি ভিন্ন সার্ভার = preference list. Rendezvous: সর্বোচ্চ score জেতে, O(N), vnode লাগে না; jump hash: memory নেই, শুধু শেষের bucket; Maglev: O(1) table; bounded load: চাপ ≤ (1 + ε) × গড়।
  5. সীমা: এটি key-এর সংখ্যা সমান করে, traffic নয় — hot key-এর জন্য আলাদা নকশা; আর সবার কাছে একই membership আর একই, স্থির hash function চাই।

পরের পর্বে: ডেটাকে ব্যবহারকারীর কাছাকাছি আনা — CDN আর edge: static ও dynamic content, cache-এর নিয়ম, origin shield, আর edge-এ কোড চালানো।

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

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