পাঠ ১৩ · ৫১-এর মধ্যে · মডিউল ৩
Home / Courses / System Design / কনসিস্টেন্ট হ্যাশিং

কনসিস্টেন্ট হ্যাশিং

Consistent hashing
৯ মিনিট পড়া মধ্যম · Intermediate Python কোডসহ সম্পূর্ণ বাংলায়

এই পাঠে যা শিখবেন

  • naive hash % N পদ্ধতির মৌলিক সমস্যা
  • হ্যাশ রিং ও ভার্চুয়াল নোড দিয়ে কনসিস্টেন্ট হ্যাশিং কীভাবে কাজ করে
  • দুটো পদ্ধতিরই রিম্যাপ ফ্র্যাকশন সিমুলেট করে হিসাব করা — শুধু সংখ্যা মুখস্থ করা নয়
  • Python দিয়ে hashlib ও bisect ব্যবহার করে একটি প্রকৃত হ্যাশ রিং বাস্তবায়ন

১ · সমস্যা — naive mod hashing

একটি ডিস্ট্রিবিউটেড ক্যাশ বা ডেটাবেসে N-টি সার্ভার থাকলে, একটি সহজ পদ্ধতি হলো Mod HashingMod Hashingএকটি কী কোন সার্ভারে যাবে তা ঠিক করতে hash(key) % N (N = সার্ভার সংখ্যা) ব্যবহার করা — সহজ, কিন্তু N বদলালে প্রায় সব ম্যাপিং ভেঙে যায়। — প্রতিটি কী-এর হ্যাশ মান নিয়ে সার্ভার সংখ্যা দিয়ে মড করে ফেলা, ফলাফল যা আসবে সেই সার্ভারে কী-টি সংরক্ষণ করা। এটি সহজ, কিন্তু একটি মারাত্মক দুর্বলতা আছে — যখনই N বদলায় (একটি সার্ভার যোগ বা বাদ দেওয়া হয়), প্রায় প্রতিটি কী-এর জন্য hash(key) % N-এর মান বদলে যায়, ফলে প্রায় সব কী ভিন্ন সার্ভারে "সরে যায়"।

কেন এটা ভয়ানক

একটি ডিস্ট্রিবিউটেড ক্যাশে যদি একটি সার্ভার যোগ করার সাথে সাথে ৭৫% কী ভুল সার্ভারে "পয়েন্ট" করে, তাহলে সেই ৭৫% রিকোয়েস্টই ক্যাশ-মিস হয়ে সরাসরি ডেটাবেসে আছড়ে পড়বে — একটি সাধারণ স্কেলিং অপারেশন (সার্ভার যোগ করা) হঠাৎ পুরো সিস্টেমকে ওভারলোড করে ফেলতে পারে।

২ · সমাধান — হ্যাশ রিং ও কনসিস্টেন্ট হ্যাশিং

Consistent HashingConsistent Hashingসার্ভার ও কী উভয়কেই একটি কাল্পনিক বৃত্তাকার হ্যাশ-স্পেস ("রিং")-এ ম্যাপ করার কৌশল, যেখানে প্রতিটি কী রিং-এ তার অবস্থান থেকে ক্লকওয়াইজ দিকে প্রথম যে সার্ভার পায় সেখানে সংরক্ষিত হয় — একটি সার্ভার যোগ/বাদ দিলে শুধু সংলগ্ন অংশের কীগুলোই প্রভাবিত হয়। পদ্ধতিতে একটি বিশাল বৃত্তাকার নাম্বার-স্পেস (রিং) কল্পনা করা হয়। প্রতিটি সার্ভারকে (তার নাম হ্যাশ করে) রিং-এর একটি পজিশনে বসানো হয়। একটি কী সংরক্ষণ করতে, সেই কী-টিকেও হ্যাশ করে রিং-এ বসানো হয়, এবং সেখান থেকে ক্লকওয়াইজ দিকে ঘুরে প্রথম যে সার্ভার পাওয়া যায় সেটিই তার মালিক।

একটি নতুন সার্ভার যোগ করলে, সে শুধু রিং-এর তার নিজের অবস্থান ও তার ঠিক আগের সার্ভারের মাঝের কী-গুলোকেই "দখল" করে — বাকি সব সার্ভারের মালিকানাধীন কী অপরিবর্তিত থাকে। এটাই মূল কারণ কেন এটি এত কম কী রিম্যাপ করে।

হ্যাশ রিং
০ থেকে সর্বোচ্চ হ্যাশ মান পর্যন্ত একটি বৃত্তাকার স্পেস — শেষ প্রান্ত থেকে আবার শুরুতে "wrap around" করে।
Virtual Nodes
প্রতিটি ফিজিক্যাল সার্ভারকে রিং-এ একাধিক (যেমন ১০০-২০০টি) পজিশনে বসানো হয়, যাতে লোড অনেক বেশি সমানভাবে ভাগ হয়।
রিম্যাপ ক্ষতি
একটি সার্ভার যোগ/বাদ দিলে শুধু তার সংলগ্ন অংশের কী-ই সরে — বাকি রিং অক্ষত থাকে।
S1 S2 S3 K (কী)
কী K রিং-এ তার অবস্থান থেকে ক্লকওয়াইজ দিকে ঘুরে প্রথম যে সার্ভার পায় (এখানে S3) তার মালিক হয়ে যায়।

৩ · ওয়ার্কড এক্সাম্পল — ৩ থেকে ৪ সার্ভার

ধরুন $n$ সংখ্যক সার্ভার আছে এবং একটি নতুন সার্ভার যোগ করা হচ্ছে। naive mod hashing-এ গড়ে প্রায় $\dfrac{n}{n+1}$ ভগ্নাংশ কী রিম্যাপ হয়, আর কনসিস্টেন্ট হ্যাশিং-এ মাত্র $\dfrac{1}{n+1}$ ভগ্নাংশ কী রিম্যাপ হয়। $n=3$ হলে:

  • naive mod hashing: $\dfrac{3}{4} = 75\%$ কী রিম্যাপ হয়
  • কনসিস্টেন্ট হ্যাশিং: $\dfrac{1}{4} = 25\%$ কী রিম্যাপ হয়

এটি শুধু তত্ত্ব নয় — নিচের কোড সেলে উভয় পদ্ধতিই বাস্তবে সিমুলেট করে হিসাব করা হয়েছে, ধরে নেওয়া বা মুখস্থ করা নয়।

Python
import hashlib
import bisect

def hash_val(text):
    return int(hashlib.md5(text.encode()).hexdigest(), 16)

# ---------- ১) Naive mod hashing ----------
def naive_server(key, num_servers):
    return hash_val(key) % num_servers

keys = [f"key-{i}" for i in range(5000)]

naive_changed = sum(
    1 for k in keys if naive_server(k, 3) != naive_server(k, 4)
)
naive_fraction = naive_changed / len(keys)

# ---------- ২) কনসিস্টেন্ট হ্যাশিং (হ্যাশ রিং + ভার্চুয়াল নোড) ----------
class HashRing:
    def __init__(self, servers, vnodes=150):
        self.vnodes = vnodes
        self.ring = {}
        self.sorted_positions = []
        for server in servers:
            self.add_server(server)

    def add_server(self, server):
        for i in range(self.vnodes):
            pos = hash_val(f"{server}#vnode{i}")
            self.ring[pos] = server
            bisect.insort(self.sorted_positions, pos)

    def get_server(self, key):
        pos = hash_val(key)
        idx = bisect.bisect(self.sorted_positions, pos)
        if idx == len(self.sorted_positions):
            idx = 0
        return self.ring[self.sorted_positions[idx]]

servers_3 = ["server-0", "server-1", "server-2"]
servers_4 = servers_3 + ["server-3"]

ring_3 = HashRing(servers_3)
ring_4 = HashRing(servers_4)

ch_changed = sum(
    1 for k in keys if ring_3.get_server(k) != ring_4.get_server(k)
)
ch_fraction = ch_changed / len(keys)

print(f"মোট কী: {len(keys):,}")
print(f"Naive mod hashing (৩→৪ সার্ভার) রিম্যাপ হয়েছে: {naive_changed:,} টি কী ({naive_fraction:.1%})")
print(f"কনসিস্টেন্ট হ্যাশিং (৩→৪ সার্ভার) রিম্যাপ হয়েছে: {ch_changed:,} টি কী ({ch_fraction:.1%})")

    
সিমুলেশনের ফলাফল তাত্ত্বিক মানের (naive ~৭৫%, কনসিস্টেন্ট হ্যাশিং ~২৫%) কাছাকাছি আসবে — naive mod hashing-এর ফ্র্যাকশন hash_val(k) % 3 != hash_val(k) % 4-এর জন্য প্রায় ঠিক $3/4$-এ কনভার্জ করে (যেহেতু $\text{lcm}(3,4)=12$ পিরিয়ডে ৩টি মান একই থাকে, ৯টি বদলায়), আর কনসিস্টেন্ট হ্যাশিং-এর ফ্র্যাকশন ভার্চুয়াল নোড সংখ্যা যথেষ্ট হলে $1/4$-এর কাছাকাছি স্থিতিশীল হয়।

৪ · কোথায় ব্যবহৃত হয়

কনসিস্টেন্ট হ্যাশিং ডিস্ট্রিবিউটেড ক্যাশ (Memcached ক্লায়েন্ট লাইব্রেরি), ডিস্ট্রিবিউটেড ডেটাবেস (Cassandra, DynamoDB), এবং লোড ব্যালেন্সারে (কোন সার্ভার কোন ব্যাকএন্ড হ্যান্ডল করবে তা ঠিক করতে) ব্যাপকভাবে ব্যবহৃত হয়। L17-এ hash-based sharding-এ এই একই "রিব্যালেন্সিং" সমস্যা আবার ফিরে আসবে, যেখানে কনসিস্টেন্ট হ্যাশিং একটি প্রধান সমাধান।

মূল কথা · Key takeaway

কনসিস্টেন্ট হ্যাশিং কোনো ম্যাজিক নয় — এটি শুধু ম্যাপিং ফাংশনকে "স্থানীয়" করে তোলে, যাতে একটি সার্ভার যোগ/বাদ দেওয়ার প্রভাব রিং-এর একটি ছোট অংশে সীমাবদ্ধ থাকে, পুরো কী-স্পেসে ছড়িয়ে না পড়ে। ভার্চুয়াল নোড এই প্রভাবকে আরও সমানভাবে ভাগ করে দেয়, যাতে কোনো একটি সার্ভার অস্বাভাবিক বেশি লোড না পায়।

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

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

প্র ০১ ভার্চুয়াল নোড ছাড়া (প্রতি সার্ভার শুধু ১টি পজিশন) কনসিস্টেন্ট হ্যাশিং ব্যবহার করলে কী সমস্যা হতে পারে?

মাত্র কয়েকটি সার্ভার হ্যাশ রিং-এ এলোমেলোভাবে বসানো হলে তাদের মধ্যে দূরত্ব খুবই অসমান হতে পারে — একটি সার্ভার হয়তো রিং-এর ৫০% দখল করল, আরেকটি মাত্র ৫%। ভার্চুয়াল নোড (প্রতি সার্ভারের জন্য শত শত পজিশন) এই দূরত্বগুলোকে "গড় করে" ফেলে, যাতে প্রতিটি ফিজিক্যাল সার্ভার পরিসংখ্যানগতভাবে প্রায় সমান পরিমাণ কী পায়।

প্র ০২ কনসিস্টেন্ট হ্যাশিং কি রিম্যাপিং সম্পূর্ণ শূন্যে নামিয়ে আনে?

না। যখনই সার্ভার সংখ্যা বদলায়, কিছু কী অবশ্যই রিম্যাপ হবে — এটাই একমাত্র উপায় নতুন সার্ভারকে কাজ দেওয়ার (বা বাদ দেওয়া সার্ভারের কাজ অন্যদের মধ্যে বণ্টন করার)। কনসিস্টেন্ট হ্যাশিং-এর লক্ষ্য রিম্যাপিং শূন্য করা নয় — বরং এটিকে ন্যূনতম ও আনুপাতিক রাখা (তাত্ত্বিকভাবে প্রায় $1/(n+1)$), যাতে সিস্টেমের বাকি অংশ অক্ষত থাকে।

প্র ০৩ কনসিস্টেন্ট হ্যাশিং লোড ব্যালেন্সিং (L11) থেকে কীভাবে আলাদা?

L11-এর লোড ব্যালেন্সিং অ্যালগরিদম (round robin, least connections) ধরে নেয় যেকোনো সার্ভার যেকোনো রিকোয়েস্ট হ্যান্ডল করতে পারে (স্টেটলেস, L12) — কোন সার্ভার কোন রিকোয়েস্ট পেল তা গুরুত্বপূর্ণ নয়। কনসিস্টেন্ট হ্যাশিং তখন দরকার যখন একই কী সবসময় একই সার্ভারে যেতে হবে (যেমন একটি ক্যাশ কী বা শার্ড কী) — অর্থাৎ ডেটা নির্দিষ্ট সার্ভারের সাথে যুক্ত থাকে, তাই ম্যাপিং এলোমেলো নয়, নির্ধারক (deterministic) হতে হয়।

অনুশীলন

  1. হিসাব করুন: ৭টি সার্ভার থেকে ৮টি সার্ভারে যাওয়ার সময় naive mod hashing ও কনসিস্টেন্ট হ্যাশিং তত্ত্বমতে কত শতাংশ কী রিম্যাপ করবে ($n=7$ ব্যবহার করে)?

    naive mod hashing ≈ $7/8 = 87.5\%$ কী রিম্যাপ করবে, আর কনসিস্টেন্ট হ্যাশিং ≈ $1/8 = 12.5\%$ কী রিম্যাপ করবে। লক্ষ্য করুন সার্ভার সংখ্যা বাড়ার সাথে সাথে naive mod hashing-এর সমস্যা আরও খারাপ হয়, কিন্তু কনসিস্টেন্ট হ্যাশিং-এর রিম্যাপ ফ্র্যাকশন কমতে থাকে।

  2. কোড পরিবর্তন করুন: উপরের কোড সেলে servers_3-কে ৭টি সার্ভারের তালিকা বানান এবং servers_4-এ একটি অষ্টম সার্ভার যোগ করুন। ফলাফল কি উপরের অনুশীলনী ১-এর হিসাবের কাছাকাছি আসে?

    হ্যাঁ — naive_fraction প্রায় $0.875$ (৮৭.৫%)-এর কাছাকাছি এবং ch_fraction প্রায় $0.125$ (১২.৫%)-এর কাছাকাছি আসা উচিত, ভার্চুয়াল নোড সংখ্যা (vnodes) যথেষ্ট বড় থাকলে। কম vnodes দিলে (যেমন ১০) ফলাফলে বেশি এলোমেলো তারতম্য দেখা যেতে পারে।

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

আগের পাঠ
Stateless বনাম Stateful সার্ভিস ডিজাইন