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

Eviction আর invalidation — LRU, LFU, TTL আর cache stampede

Cache Eviction and Invalidation — LRU, LFU, TTL and Cache Stampede

Taught in Bangla, this lesson separates eviction, expiration and invalidation, builds an O(1) LRU from a hash map and doubly linked list, compares LFU, FIFO, random, sampled LRU, SLRU, 2Q, ARC and W-TinyLFU against Belady's OPT in a simulator, designs TTLs with jitter and soft TTL, five invalidation strategies, and handles cache stampede with locks, XFetch and stale serving, plus penetration with Bloom filters and avalanche.

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

  • Eviction, expiration আর invalidation — কোনটি গতির প্রশ্ন, কোনটি সঠিকতার
  • O(1) LRU-র গঠন নিজের হাতে, আর LFU, FIFO, random, নমুনা-LRU, SLRU, 2Q, ARC ও W-TinyLFU কখন কোনটি
  • Scan আর জনপ্রিয়তার বদলে কোন policy ভাঙে — simulator-এ, Belady-র OPT-এর পাশে রেখে
  • TTL-এর নকশা আর jitter-এর হিসাব; invalidation-এর পাঁচ কৌশলের ক্রম ও হারানো বার্তার ঝুঁকি
  • Stampede, penetration আর avalanche — lock, XFetch-এর সূত্র, stale পরিবেশন, Bloom filter, আর AI-এর cache-পরামর্শ যাচাই

১ · তিনটি আলাদা সমস্যা — eviction, expiration আর invalidation

Cache মানে ধীর উৎসের দরকারি অংশের একটি দ্রুত কপি। কিন্তু কপি রাখলেই তিনটি প্রশ্ন আসে, আর তিনটির উত্তর আলাদা জায়গায় লুকিয়ে থাকে। Cache কোথায় বসবে আর কে কখন তা ভরবে — cache-aside, read-through, write-through, write-back — সেই কৌশলগুলো পর্ব ২৮-এ। এই পর্বের প্রশ্ন তার পরের: কপি কখন আর কীভাবে সরবে।

পাড়ার মুদি দোকানের তাক ভাবুন। তাক ভরা, নতুন বিস্কুটের কার্টন এসেছে — কোনটি নামিয়ে গুদামে পাঠাবেন, যেটি অনেক দিন কেউ চায়নি, না যেটি কম বিক্রি হয়? এটি eviction. দুধের প্যাকেটে মেয়াদ — তারিখ পেরোলে বিক্রি নয়, যত জায়গাই থাকুক: expiration, অর্থাৎ TTL. আর কোম্পানি দাম বদলাল — পুরোনো দামের স্টিকার এখনই বদলাতে হবে, মেয়াদ বাকি থাকলেও: invalidation. তিন রকম খবর — জায়গার, সময়ের, বদলের।

EvictionExpiration (TTL)Invalidation
প্রশ্নজায়গা ফুরিয়েছে — কাকে সরাব?এই কপি কতক্ষণ বিশ্বাসযোগ্য?উৎস বদলেছে — পুরোনো কপি সরাব কীভাবে?
খবর আসেcache-এর memory থেকেঘড়ি থেকেলেখার পথ থেকে
সিদ্ধান্ত নেয়cache নিজে, eviction policy দিয়েলেখক মেয়াদ দেয়, cache গোনেঅ্যাপ বা invalidation-ব্যবস্থা
ভুল হলেবেশি miss — ধীর, কিন্তু উত্তর ঠিকখুব বড়: পুরোনো তথ্য; খুব ছোট: বেশি missপুরোনো তথ্য — উত্তরই ভুল
মাপকাঠিhit ratiohit ratio আর staleness দুটোইstaleness-এর সময় আর ঘটনার সংখ্যা

নিচের ছবিতে তিনটিই: বাঁয়ে পড়ার পথ, ডানে লেখার পথ, মাঝে cache-এর ভেতর।

পড়ার পথ, cache-এর ভেতরে hash map আর LRU list, আর লেখার পথ থেকে invalidation

Reads look up the cache; misses go to the database; writes reach the cache only through invalidation

পড়ার পথ, cache-এর hash map আর LRU list, database আর লেখার পথ থেকে invalidation অ্যাপ — পড়ার পথ অ্যাপ — লেখার পথ Cache · RAM · জায়গা ৪টি key Database — সত্যের উৎস (source of truth) hash map doubly linked list event GET user:42 UPDATE user:42 user:9 user:42 post:7 cfg user:42 · 290s post:7 · 45s cfg · 3500s user:9 · 12s ⇅ ⇅ ⇅ CDC বা pub/sub DEL O(1) খোঁজ ওপরে MRU · নিচে LRU ভরা হলে নিচ থেকে eviction প্রতিটি লেখা আগে এখানে · user:42 এখন v8 খোঁজা লেখার পরে DEL miss হলে মান + TTL বসানো commit log লেখা
ছবি ২৯.১ — সবুজ: পড়া, hit হলে এখানেই শেষ। ভাঙা ধূসর: miss-এ ডেটাবেস; নীল: মান আর TTL নিয়ে cache ভরা। কমলা: লেখা আগে ডেটাবেসে, তারপর অ্যাপ নিজেই key মোছে; বেগুনি: ডেটাবেসের commit log থেকে event এসে মোছে (পর্ব ৩২ আর ৩৩)। Hash map-এর ক্রম আর list-এর ক্রম আলাদা — map বলে “কোথায়”, list বলে “কে কত পুরোনো”। ভরা হলে লাল node (tail) সরে; আর প্রতিটি entry-র পাশের TTL শূন্যে নামলে সে মেয়াদোত্তীর্ণ।
মূল ধারণা

Eviction গতির প্রশ্ন, invalidation সঠিকতার, আর TTL দুটোর মাঝে সেতু। সবচেয়ে খারাপ eviction policy-ও কখনো ভুল উত্তর দেয় না — শুধু বেশি বার ডেটাবেসে যায়। কিন্তু একটি হারানো invalidation টাকার অঙ্ক, স্টকের সংখ্যা বা কারও অনুমতি ভুল দেখাতে পারে। তাই নকশায় প্রথম প্রশ্ন: এই ডেটা কতক্ষণ পুরোনো থাকলে ক্ষতি নেই? উত্তর “এক মুহূর্তও নয়” হলে, যেমন mobile banking-এর ব্যালান্স, সেই পড়া cache থেকে নয়।

২ · LRU-র ভেতরে — hash map আর doubly linked list, প্রতিটি কাজ O(1)

সবচেয়ে পরিচিত eviction policy LRU (least recently used): জায়গা লাগলে সেই entry সরবে, যাকে সবচেয়ে বেশি সময় কেউ ছোঁয়নি — যা এইমাত্র লাগল, তা শিগগির আবার লাগার সম্ভাবনা বেশি। Hardware আর OS-এ একই নিয়ম: CA-র replacement policy আর OS-এর LRU. সেখানে কয়েকটি way বা frame; এখানে লাখ লাখ key, আর প্রতিটি সিদ্ধান্ত মাইক্রোসেকেন্ডে।

দুটি কাজ দ্রুত লাগবে: key দিয়ে মান খোঁজা, আর “কে সবচেয়ে পুরোনো” জানা ও ক্রম বদলানো। Hash table খোঁজে O(1)-এ, কিন্তু ক্রম জানে না; সময়-চিহ্নের heap ক্রম জানে, কিন্তু প্রতিটি hit-এ O(log n). উত্তর হলো জোড়া: hash map বলে node কোথায়, আর doubly linked list রাখে ক্রম — মাথায় সবচেয়ে সাম্প্রতিক বা MRU, লেজে সবচেয়ে পুরোনো বা LRU. Node-এর ঠিকানা জানা থাকলে তাকে খুলে নেওয়া মাত্র দুটি pointer বদল; আগের node-এর ঠিকানা লাগে বলেই list দ্বিমুখী। মৌলিক গঠন DSA-র hash table আর linked list পর্বে।

head-এর পরে · MRU
মাঝে
tail-এর আগে · LRU
সরানো হলো (evicted)
প্রতিটি ধাপে
অনুরোধ
ফল
hash map-এ key
pointer-এর কাজ
  1. put A. Cache ফাঁকা। নতুন node, hash map-এ A → node, আর list-এর মাথায় বসানো: চারটি pointer বদল। দশটি key থাকুক বা দশ লাখ, কাজ একই — এটাই O(1).
  2. put B. B এল মাথায়, A এক ঘর নিচে। মাথা মানে সবচেয়ে সাম্প্রতিক, অর্থাৎ MRU.
  3. put C. Cache ভরা। লেজের পাশে A — সবচেয়ে পুরোনো, পরের eviction-এর প্রার্থী।
  4. get A — hit. Hash map এক ধাপে A-এর node দিল, list হাঁটতে হয়নি। Node খুলে (২টি pointer) মাথায় বসানো (৪টি); এখন লেজে B. LRU-তে প্রতিটি পড়াও একটি লেখা।
  5. put D — eviction. ভরা cache-এ নতুন key: লেজের B-কে খুলে map থেকে মোছা, তারপর D মাথায়। B সরল সবচেয়ে বেশি সময় অছোঁয়া বলে — কতবার ছোঁয়া হয়েছিল, LRU দেখে না।
  6. get B — miss. Map-এ B নেই; DB থেকে এনে put — আবার ভরা, এবার লেজের C সরল। আগের ধাপে যাকে সরানো হলো, তাকেই এখন লাগল: এটাই policy-র ভুল অনুমানের দাম।
  7. get A — hit. A মাথায়, লেজে D. তিনটি get-এ দুটি hit; নিচের কোডের আউটপুট হুবহু এই সাত সারি।

এবার একই গঠন কোডে। Hash map হিসেবে JavaScript-এর Map, আর list নিজে হাতে, দুই প্রান্তে দুটি পাহারাদার node (sentinel) দিয়ে — যাতে প্রথম বা শেষ node-এর জন্য আলাদা শর্ত না লাগে:

// O(1) LRU cache: a hash map (key -> node) plus a doubly linked list (head = MRU, tail = LRU).
class LRUCache {
  constructor(cap) {
    this.cap = cap; this.map = new Map();
    this.head = { key: "HEAD" }; this.tail = { key: "TAIL" };     // sentinels: no null checks
    this.head.next = this.tail; this.tail.prev = this.head;
  }
  unlink(n) { n.prev.next = n.next; n.next.prev = n.prev; }        // O(1): two pointer changes
  pushFront(n) { n.next = this.head.next; n.prev = this.head; this.head.next.prev = n; this.head.next = n; }
  get(key) {
    const n = this.map.get(key); if (!n) return undefined;          // O(1) lookup
    this.unlink(n); this.pushFront(n);                               // a hit makes it most recent
    return n.value;
  }
  put(key, value) {
    let n = this.map.get(key), evicted = "";
    if (n) { n.value = value; this.unlink(n); }
    else {
      if (this.map.size === this.cap) {                              // full: drop the tail (LRU)
        const lru = this.tail.prev; this.unlink(lru); this.map.delete(lru.key); evicted = lru.key;
      }
      n = { key, value }; this.map.set(key, n);
    }
    this.pushFront(n);
    return evicted;
  }
  order() { const k = []; for (let n = this.head.next; n !== this.tail; n = n.next) k.push(n.key); return k.join(" <-> "); }
}
const db = { A: "a1", B: "b1", C: "c1", D: "d1" }, cache = new LRUCache(3);
console.log("op     result               list (MRU ... LRU)");
for (const [op, k] of [["put", "A"], ["put", "B"], ["put", "C"], ["get", "A"], ["put", "D"], ["get", "B"], ["get", "A"]]) {
  let note = op === "get" && cache.get(k) !== undefined ? "hit" : op === "get" ? "miss, load" : "stored";
  if (note !== "hit") { const e = cache.put(k, db[k]); if (e) note += ", evict " + e; }
  console.log(`${op} ${k}`.padEnd(7) + note.padEnd(21) + cache.order());
}

আউটপুট:

op     result               list (MRU ... LRU)
put A  stored               A
put B  stored               B <-> A
put C  stored               C <-> B <-> A
get A  hit                  A <-> C <-> B
put D  stored, evict B      D <-> A <-> C
get B  miss, load, evict C  B <-> D <-> A
get A  hit                  A <-> B <-> D

প্রতিটি সারি stepper-এর একটি ধাপ: put D-তে লেজের B সরল, get B-তে miss আর C সরল, শেষে A <-> B <-> D. get-এ ছয়টি pointer বদল, eviction-সহ put-এও ছয়টি (লেজ খোলা ২ + মাথায় বসানো ৪) — key-সংখ্যার ওপর নির্ভর করে না। বাস্তবে নিজে লিখতে হয় কমই: JavaScript-এর Map ঢোকানোর ক্রম মনে রাখে, তাই hit-এ delete করে আবার set করলেই সেটি সবচেয়ে নতুন; Java-র LinkedHashMap (access-order আর removeEldestEntry) আর Python-এর functools.lru_cache-ও একই কাজ করে। ইন্টারভিউর “LRU cache লিখুন” প্রশ্নে অবশ্য এই হাতে-লেখা রূপটিই চাওয়া হয়।

বহু thread-এ LRU-র লুকানো দাম। প্রতিটি hit list বদলায়, তাই বহু thread-এর server-এ প্রতিটি পড়া একই lock-এর লাইনে দাঁড়ায়। এজন্য বড় cache-গুলো নিখুঁত LRU এড়ায়: Redis নমুনা নিয়ে আন্দাজ করে (অংশ ৩), Java-র Caffeine পড়ার ঘটনা buffer-এ জমিয়ে পরে একসাথে ক্রম বদলায় (লাইব্রেরির বর্ণনা অনুযায়ী), আর OS ও ডেটাবেস চালায় clock — একটি reference বিটে LRU-র কাছাকাছি (OS-এর clock অংশ)।

৩ · LRU-র বাইরে — LFU, FIFO, random আর নমুনা-ভিত্তিক LRU

LRU দেখে একটিই সংকেত — সাম্প্রতিকতা। অন্যরা অন্য সংকেত দেখে, বা সস্তা হতে কম দেখে।

  • FIFO: যে আগে ঢুকেছে সে আগে যায়; hit-এ কিছুই বদলায় না, তাই পড়া lock ছাড়াই চলে। দুর্বলতা: সবচেয়ে জনপ্রিয় key-ও পালা এলে বেরিয়ে যায়, তারপর miss করে আবার ঢোকে। OS-এ FIFO-র Belady anomaly এখানে।
  • Random: এলোমেলো একটি entry সরানো, বাড়তি তথ্য শূন্য। বড় cache-এ ফল FIFO-র কাছাকাছি, আর চক্রাকার ব্যবহারে LRU-র চেয়েও ভালো — যেখানে LRU ঠিক পরের দরকারিটিকেই সরায় (CA-র LRU-র ফাঁদ)।
  • LFU: প্রতিটি entry-র ব্যবহারের counter; সবচেয়ে কম ব্যবহৃত সরে। স্থির জনপ্রিয়তায় — পণ্যের ক্যাটালগ, সাইটের config — LRU-র চেয়ে ভালো। দুটি ফাঁদ: গতকালের জনপ্রিয়তা — গতকাল ভাইরাল ভিডিওর বড় counter তাকে আজও আটকে রাখে; আর নতুনের দুর্ভোগ — নতুন key counter ১ নিয়ে ঢুকে প্রথম সুযোগেই সরে। প্রতিকার aging: নিয়মিত সব counter অর্ধেক করা।
  • নমুনা-ভিত্তিক LRU: list নয়, প্রতিটি key-এ শুধু শেষ ব্যবহারের সময়; জায়গা লাগলে কয়েকটি key এলোমেলোভাবে তুলে তাদের সবচেয়ে পুরোনোটিকে সরানো। Redis এভাবেই কাজ করে — allkeys-lru নীতিতে ডিফল্টে প্রতিবার ৫টি নমুনা (maxmemory-samples), আর সংস্করণ ৩.০ থেকে প্রার্থীদের একটি ছোট pool রেখে আন্দাজ আরও ভালো (documentation-এর বর্ণনা অনুযায়ী)।

নমুনা কতটা ভালো? k-টি key সমান সম্ভাবনায় তোলা হলে তাদের সবচেয়ে পুরোনোটির গড় শতাংশ-অবস্থান k/(k + ১) — k-টি সমবণ্টিত সংখ্যার সর্বোচ্চটির গড়। তাই k = ৫-এ victim গড়ে বাকি প্রায় ৮৩% key-এর চেয়ে পুরোনো, k = ১০-এ প্রায় ৯১%; নিখুঁত LRU-তে ১০০%. দশটি নমুনায় বেশিরভাগ লাভ আসে, আর key-প্রতি দুটি pointer-এর memory বাঁচে — কোটি key-তে বড় অঙ্ক।

# memory-র সীমা; এর ওপরে গেলে eviction শুরু
maxmemory 4gb
# লেখার সময়ে প্রচলিত নাম: noeviction (অনেক সংস্করণে ডিফল্ট), allkeys-lru, allkeys-lfu,
# allkeys-random, volatile-lru, volatile-lfu, volatile-random, volatile-ttl
# volatile-* শুধু TTL-ওয়ালা key থেকে বাছে; allkeys-* সব key থেকে
maxmemory-policy allkeys-lfu
# নমুনার আকার: বড় হলে LRU/LFU-র আন্দাজ ভালো, CPU বেশি
maxmemory-samples 10
# LFU counter-এর ক্ষয় (মিনিটে) আর বৃদ্ধির হার
lfu-decay-time 1
lfu-log-factor 10

খেয়াল করুন: ডিফল্ট প্রায়ই noeviction — memory ভরলে নতুন লেখা error দেয়, তাই cache হিসেবে চালালে policy নিজে বেছে নিতে হয়; আর volatile-* নীতিতে TTL-বিহীন key কখনো সরে না। Redis-এর LFU (সংস্করণ ৪.০ থেকে) key-প্রতি একটি ছোট logarithmic counter রাখে, যা সময়ের সাথে ক্ষয় হয় — aging-এর বাস্তব রূপ।

Policyentry-প্রতি বাড়তি তথ্যhit-এ কাজscan সহ্য করে?কোথায় মানায়
FIFOঢোকার ক্রমকিছু নানাখুব সরল, পড়া-ভারী, lock এড়াতে
Randomকিছু নাকিছু নাআংশিকmetadata-র জায়গা নেই; চক্রাকার ব্যবহার
LRUদুটি pointernode মাথায় সরানোনাসাম্প্রতিকতা-নির্ভর চাহিদা
নমুনা-LRUশেষ ব্যবহারের সময়সময় লেখানাকোটি key, memory বাঁচানো
LFU (aging-সহ)countercounter বাড়ানোহ্যাঁস্থির জনপ্রিয়তা
SLRU / 2Qকোন অংশে আছেঅংশ বদলহ্যাঁbuffer pool, সাধারণ cache
ARCদুই list + দুই ghost listlist বদলহ্যাঁমিশ্র চাহিদা, নিজে মানিয়ে নেয়
W-TinyLFUমোট একটি ছোট sketch (key-প্রতি কয়েক বিট)sketch-এ গোনাহ্যাঁমিশ্র চাহিদা; প্রকাশিত benchmark-এ উঁচু hit

৪ · Scan, বদলাতে থাকা জনপ্রিয়তা আর আধুনিক policy — SLRU, 2Q, ARC, W-TinyLFU আর Belady-র সীমা

রাত ২টায় একটি report job সব পণ্য একবার করে পড়ল, বা একটি crawler পুরোনো সব পাতা ঘুরল। প্রতিটি key একবারই লাগবে, কিন্তু LRU-র চোখে প্রত্যেকে “এইমাত্র ব্যবহৃত” — তারা মাথায় বসে, আর সারাদিনের জনপ্রিয় key-গুলো লেজ দিয়ে বেরিয়ে যায়। এর নাম cache pollution; যে policy এটি সহ্য করে, তার গুণ scan resistance.

চারটি কৌশল, একটিই চিন্তা: একবার দেখাকে বিশ্বাস নয়

  • Segmented LRU (SLRU): নতুনরা ঢোকে পরীক্ষাধীন (probation) অংশে, আবার ব্যবহৃত হলে ওঠে সুরক্ষিত (protected) অংশে; eviction শুধু probation-এর লেজ থেকে। Scan-এর key দ্বিতীয়বার আসে না, তাই সুরক্ষিত অংশ ছুঁতেই পারে না। Memcached-এর নতুন সংস্করণের hot-warm-cold LRU এই পরিবারের (খুঁটিনাটি সংস্করণভেদে আলাদা)।
  • 2Q (Johnson ও Shasha, VLDB ১৯৯৪): প্রথমবার-আসারা একটি ছোট FIFO-তে; সেখান থেকে সরানোদের শুধু key মনে রাখে একটি ghost list; সেই key আবার এলে তবেই মূল LRU-তে।
  • ARC (IBM-এর Megiddo ও Modha, USENIX FAST ২০০৩): T1-এ একবার দেখা, T2-এ অন্তত দুবার দেখা, আর দুটির ghost list B1, B2. B1-এ থাকা key-তে miss মানে “সাম্প্রতিকদের তাড়াতাড়ি সরিয়েছি”, তাই T1-এর লক্ষ্য-আকার বাড়ে; B2-তে উল্টো। হাতে-বসানো parameter ছাড়াই সে workload-এর সাথে মানিয়ে নেয়। ZFS-এর ARC এর পরিবর্তিত রূপ।
  • W-TinyLFU (Einziger, Friedman ও Manes, ACM Transactions on Storage ২০১৭): প্রশ্ন বদলায় — “কাকে বের করব” নয়, “নতুনকে ঢুকতে দেব কি না”; এটি একটি admission policy. নতুন key আর eviction-এর প্রার্থীর আনুমানিক চাহিদা তুলনা হয়, নতুনটি বেশি জনপ্রিয় হলে তবেই ঢোকে। গোনে মোট একটি ছোট count-min sketch (গঠন পর্ব ৪৫-এ), যার counter নির্দিষ্ট সংখ্যক ঘটনার পরে সব অর্ধেক হয় — aging. সামনে একটি ছোট LRU জানালা (গবেষণাপত্রের ডিফল্টে মোটামুটি ১%), যাতে হঠাৎ জনপ্রিয় নতুনরা সুযোগ পায়। Java-র Caffeine লাইব্রেরি এটি ব্যবহার করে বলে তার documentation জানায়।

কোন চাহিদায় কোনটি জেতে

সাম্প্রতিকতা যেখানে সংকেত — সংবাদ-সাইটের “এইমাত্র” খবর, চ্যাটের শেষ বার্তা, চালকের সর্বশেষ অবস্থান — সেখানে LRU, কারণ জনপ্রিয়তা দ্রুত বদলায়। স্থির জনপ্রিয়তা — পণ্যের ক্যাটালগ, শীর্ষ বিক্রির তালিকা — সেখানে frequency. মিশ্র, তার ওপর scan — বেশিরভাগ বাস্তব সিস্টেম — সেখানে SLRU, ARC বা W-TinyLFU. ঈদের আগে অনলাইন বাজারের flash sale-এ দুটোই ঘটে: পুরোনো জনপ্রিয় পণ্য থাকে, আর হঠাৎ নতুন অফারে ভিড়।

সীমা কোথায় — Belady-র OPT

১৯৬৬ সালে IBM-এর László Bélády যে নীতি বর্ণনা করেন: যে entry সবচেয়ে দেরিতে আবার লাগবে, তাকে সরানো — এর চেয়ে কম miss অসম্ভব (প্রমাণের রূপরেখা OS-এর OPT অংশে)। ভবিষ্যৎ জানতে হয় বলে চালানো যায় না, কিন্তু রেকর্ড করা trace-এ জানা যায় আর কতটা উন্নতি সম্ভব; cache-এ সে আর-কখনো-না-লাগা key ঢুকতেই দেয় না। এই Belady-র সীমা-সহ নিচে চারটি policy, তিনটি seeded trace-এ: ১০,০০০টি key, Zipf-ধাঁচের জনপ্রিয়তা (α = ০.৯), cache ৩০০টি key-এর। (খ)-তে অর্ধেক সময় একটি job প্রতিটি ব্যবহারকারী-অনুরোধের পাশে দুটি নতুন, একবার-পড়া key ছোঁয়; (গ)-তে প্রতি ২০,০০০ অনুরোধে জনপ্রিয়তা ৩,০০০ key সরে যায়। Hit গোনা শুধু ব্যবহারকারীর অনুরোধে। আকার বনাম hit-এর বক্ররেখা পর্ব ১৭-এর অংশ ৬-এ; এখানে আকার স্থির, policy বদলায়:

// Hit ratio of FIFO, random, LRU, LFU and Belady's OPT (offline bound) on three seeded traces.
const N = 10000, ALPHA = 0.9, CAP = 300, REQS = 60000, WARM = 10000;
function mulberry32(a) { return () => { a = (a + 0x6D2B79F5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a);
  t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; }
const rnd = mulberry32(7), cdf = new Float64Array(N); let sum = 0;
for (let k = 1; k <= N; k++) { sum += Math.pow(k, -ALPHA); cdf[k - 1] = sum; }
function zipf() { let lo = 0, hi = N - 1; const x = rnd() * sum;
  while (lo < hi) { const m = (lo + hi) >> 1; if (cdf[m] < x) lo = m + 1; else hi = m; } return lo; }
const A = [], B = [], C = []; let once = N;      // keys >= N belong to a scan: read once, never again
for (let i = 0; i < REQS; i++) {
  const k = zipf(); A.push(k); B.push(k); C.push((k + 3000 * Math.floor(i / 20000)) % N);
  if (Math.floor(i / 5000) % 2) B.push(once++, once++);   // half the time a job scans 2 keys/request
}
function run(pol, tr) {
  const c = new Map(), next = new Int32Array(tr.length), seen = new Map(), rr = mulberry32(99);
  for (let i = tr.length - 1; i >= 0; i--) { next[i] = seen.get(tr[i]) ?? 1e9; seen.set(tr[i], i); }
  let z = 0, hits = 0;
  tr.forEach((k, i) => {
    const hit = c.has(k); if (k < N && ++z > WARM && hit) hits++;   // score user requests only
    if (hit) { if (pol === "LRU") { c.delete(k); c.set(k, 0); } else if (pol === "LFU") c.set(k, c.get(k) + 1);
      else if (pol === "OPT") c.set(k, next[i]); return; }
    if (pol !== "OPT" && c.size >= CAP) {                  // evict one key, then admit the new one
      let v = c.keys().next().value, m = Infinity, j = Math.floor(rr() * c.size);  // FIFO/LRU: oldest
      if (pol === "RANDOM") for (const x of c.keys()) if (j-- === 0) { v = x; break; }
      if (pol === "LFU") for (const [x, f] of c) if (f < m) { m = f; v = x; }      // ties: oldest
      c.delete(v);
    }
    c.set(k, pol === "OPT" ? next[i] : pol === "LFU" ? 1 : 0);
    if (pol === "OPT" && c.size > CAP) { let m = -1, v; for (const [x, n] of c) if (n > m) { m = n; v = x; } c.delete(v); }
  });
  return ((hits / (REQS - WARM) * 100).toFixed(1) + "%").padStart(10);
}
console.log(`keys ${N}, Zipf alpha ${ALPHA}, cache ${CAP} keys, ${REQS} user requests (first ${WARM} = warm-up)`);
console.log("policy     (a) Zipf  (b) + scan  (c) shift");
for (const p of ["FIFO", "RANDOM", "LRU", "LFU", "OPT"]) console.log(p.padEnd(8) + run(p, A) + run(p, B) + run(p, C));

আউটপুট:

keys 10000, Zipf alpha 0.9, cache 300 keys, 60000 user requests (first 10000 = warm-up)
policy     (a) Zipf  (b) + scan  (c) shift
FIFO         34.7%     27.7%     34.6%
RANDOM       34.7%     27.7%     34.5%
LRU          39.1%     31.9%     39.0%
LFU          48.7%     48.2%     22.2%
OPT          59.9%     59.9%     59.2%

(ক) স্থির Zipf: সীমা OPT-এর ৫৯.৯%; LFU ৪৮.৭%, LRU ৩৯.১%, FIFO আর random ৩৪.৭% — স্থির জনপ্রিয়তায় frequency-ই ভালো সংকেত। (খ) scan: LRU নামল ৩৯.১% থেকে ৩১.৯%-এ, FIFO আর random ২৭.৭%-এ — miss ৬০.৯% থেকে ৬৮.১%, ডেটাবেসে প্রায় ১২% বেশি চাপ। LFU-র ৪৮.২% প্রায় অক্ষত, কারণ counter ১-এর নতুনরা একে অপরকেই সরায়; OPT অপরিবর্তিত, কারণ সে একবার-পড়া key ঢুকতেই দেয় না। (গ) জনপ্রিয়তার বদল: ছবি উল্টো — LFU ধসে পড়ল ২২.২%-এ, কারণ পুরোনো জনপ্রিয়দের বড় counter তাদের আটকে রাখে আর নতুনরা ঢুকেই বেরিয়ে যায়; LRU-র ৩৯.০% প্রায় অপরিবর্তিত। তাই একটি trace দেখে policy বাছাই বিপজ্জনক — নিজের log থেকে মাপুন। LFU-তে aging যোগ করে (গ) আবার চালিয়ে দেখুন — W-TinyLFU ঠিক এই কাজটিই করে।

আরও গভীরে: LIRS (Jiang ও Zhang, SIGMETRICS ২০০২) শেষ দুই ব্যবহারের মাঝের দূরত্ব দেখে। সাম্প্রতিক দুটি কাজ — S3-FIFO (SOSP ২০২৩) আর SIEVE (NSDI ২০২৪) — দেখিয়েছে যে কয়েকটি FIFO queue আর একটি “দেখা হয়েছে” বিটের সরল নকশা প্রকাশিত মূল্যায়নের বহু web trace-এ LRU-র চেয়ে বেশি hit দেয়, আর hit-এ ক্রম বদলাতে হয় না বলে বহু thread-এ সহজে চলে। সবচেয়ে খারাপ ক্ষেত্রের তত্ত্ব ভাবনার প্রশ্ন ১-এ।

৫ · TTL-এর নকশা — fixed বনাম sliding, jitter আর soft TTL

Expiration-এর দুই রূপ। Fixed (absolute) TTL: লেখার মুহূর্ত থেকে T সেকেন্ড, যতবারই পড়া হোক — invalidation না থাকলেও কোনো কপি T-এর বেশি পুরোনো নয়, তবে replica থেকে ভরা হলে সাথে যোগ হয় replication lag. Sliding TTL: প্রতিটি পড়ায় মেয়াদ নতুন করে শুরু, শুধু অলস key মরে। Session-এ এটি স্বাভাবিক, কিন্তু উৎস-বদলানো ডেটায় বিপজ্জনক: প্রতি সেকেন্ডে পড়া জনপ্রিয় key কখনোই মরে না, তার staleness-এর সীমা নেই — তাই সাথে একটি সর্বোচ্চ বয়স। Redis-ধাঁচের store-এ সাধারণ GET মেয়াদ বাড়ায় না; তার জন্য আলাদা কমান্ড (যেমন GETEX, Memcached-এ touch — সংস্করণভেদে আলাদা)।

খুঁটিনাটি: মেয়াদোত্তীর্ণ key সাধারণত সাথে সাথে মোছে না — lazy expiration-এ পড়ার সময় মেয়াদ দেখে মোছা হয়, আর পেছনের একটি কাজ নমুনা নিয়ে মরা key খোঁজে। তাই expiration থাকলেও eviction policy লাগে।

TTL কত? আগে ব্যবসার প্রশ্ন — কতক্ষণ পুরোনো চলে; তারপর অঙ্ক — পুরোনো উত্তরের ভাগ মোটামুটি T ÷ ২P (P = উৎস কত ঘন ঘন বদলায়), পুরো হিসাব পর্ব ১৭-এর অংশ ৭-এ। Invalidation থাকলেও TTL রাখুন — হারানো invalidation-এর ক্ষতির সীমা সেটিই।

একসাথে লেখা, একসাথে মরা — TTL jitter

Deploy-এর পরে একটি warm-up script ১ লাখ key একসাথে লিখল, সবার TTL ৩৬০০ s. এক ঘণ্টা পরে একই সেকেন্ডে ১ লাখ key মরবে, পরের অনুরোধগুলো একসাথে ডেটাবেসে ছুটবে, আর আবার লেখা হলে ঢেউ প্রতি ঘণ্টায় ফিরবে। প্রতিকার: মেয়াদে এলোমেলো অংশ — TTL × (১ + J·u), u −১ থেকে ১-এর মধ্যে সমবণ্টিত। N-টি key তখন ২·J·T সেকেন্ডের জানালায় ছড়ায়:

গড় মেয়াদ-শেষ প্রতি সেকেন্ডে ≈ N ÷ (২ · J · T)  ·  সর্বোচ্চ সেকেন্ড গড়ের চেয়ে কিছুটা বেশি

N = ১,০০,০০০, T = ৩৬০০, J = ০.১০ হলে জানালা ৭২০ s, গড় প্রায় ১৩৯; কিন্তু কোনো কোনো সেকেন্ডে বেশি পড়ে, আর ডেটাবেসের সাথে মেলাতে হয় সর্বোচ্চটি। Seeded simulator-এ, সাথে আবার লেখার পরের ঢেউ:

// 100,000 keys are written at the same moment (say, a warm-up script after a deploy), TTL 3600 s.
// Each key's TTL gets a random +-J share: TTL * (1 + J * u), u uniform in [-1, 1).
// Every key is written again right after it expires. Count expirations per one-second bucket.
const KEYS = 100000, TTL = 3600;
function mulberry32(a) { return () => { a = (a + 0x6D2B79F5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a);
  t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; }
function wave(J, round) {                       // round = which expiry of each key (1st, 2nd, 3rd ...)
  const rnd = mulberry32(5), perSec = new Map();
  for (let k = 0; k < KEYS; k++) {
    let t = 0;
    for (let r = 0; r < round; r++) t += TTL * (1 + J * (2 * rnd() - 1));
    const s = Math.floor(t); perSec.set(s, (perSec.get(s) || 0) + 1);
  }
  const secs = [...perSec.keys()], max = Math.max(...perSec.values());
  const span = Math.max(...secs) - Math.min(...secs) + 1;
  console.log(`+-${(J * 100).toFixed(0)}%`.padStart(6) + String(round).padStart(7) +
    String(span).padStart(10) + (KEYS / span).toFixed(1).padStart(12) + String(max).padStart(10));
}
console.log("jitter  round  span (s)  mean per s  max per s");
for (const J of [0, 0.01, 0.05, 0.1, 0.2]) wave(J, 1);
wave(0, 3);
for (const r of [2, 3]) wave(0.1, r);

আউটপুট:

jitter  round  span (s)  mean per s  max per s
  +-0%      1         1    100000.0    100000
  +-1%      1        72      1388.9      1479
  +-5%      1       360       277.8       325
 +-10%      1       720       138.9       180
 +-20%      1      1440        69.4        99
  +-0%      3         1    100000.0    100000
 +-10%      2      1436        69.6       169
 +-10%      3      2129        47.0       130

±০%: ১ লাখ মেয়াদ এক সেকেন্ডে, তৃতীয় দফাতেও — ঢেউ নিজে থেকে ছড়ায় না। ±১% শুনতে সামান্য, তবু সর্বোচ্চ ১,৪৭৯ — প্রায় ৬৮ গুণ কম। ±১০%: জানালা ৭২০ s, গড় ১৩৮.৯, সর্বোচ্চ ১৮০ — গড়ের প্রায় ৩০% বেশি; এলোমেলো গণনায় সর্বোচ্চটি মোটামুটি গড় + ৩√গড়-এর ঘরে। ±২০%: ৯৯. শেষ দুই সারি: আবার লেখার পরে দ্বিতীয় ঢেউয়ের জানালা দ্বিগুণ, কিন্তু সর্বোচ্চ মাত্র ১৬৯ — দুটি সমবণ্টিত সংখ্যার যোগফল ত্রিভুজ-আকৃতির, মাঝখানটা আগের মতোই ঘন; তৃতীয় দফায় ঘণ্টা-আকৃতি, ১৩০. তাই দ্বিতীয়-তৃতীয় দফায় “গড়” কলাম বিভ্রান্তিকর — সর্বোচ্চটিই দেখুন।

Soft TTL — মেয়াদ পেরোলেও কাউকে অপেক্ষা করতে হয় না

Soft TTL-এ দুটি সময়সীমা: soft মেয়াদের পরে মানটি “নবায়ন দরকার”, তবু দেওয়া চলে আর একজন পেছনে নবায়ন করে; hard মেয়াদের পরে আর নয়। Store-এর TTL hard মেয়াদে, soft মেয়াদ লেখা থাকে মানের ভেতরে। HTTP-তে একই ধারণা stale-while-revalidate (পর্ব ১৭); stampede ঠেকাতে এর ভূমিকা অংশ ৭-এ।

৬ · Invalidation-এর কৌশল — শুধু TTL থেকে event, versioned key আর tag

প্রকৌশলীদের প্রিয় উক্তি: “Computer science-এ কঠিন কাজ দুটি — cache invalidation আর নাম দেওয়া।” এটি প্রচলিতভাবে Netscape-এর প্রকৌশলী Phil Karlton-এর বলে ধরা হয়, যদিও লিখিত উৎস নিশ্চিত করা কঠিন। কঠিন কেন? Invalidation আসলে বার্তা পৌঁছানোর সমস্যা: লেখা এক জায়গায়, কপি অন্য জায়গায়, আর মাঝের বার্তা দেরিতে আসে, হারায় বা ক্রম উল্টে আসে। CDN-এর purge-এর দিক থেকে একই প্রশ্ন পর্ব ১৭-এর ভাবনার প্রশ্নে; এখানে application cache-এর পাঁচ কৌশল:

কৌশলকীভাবেসর্বোচ্চ stalenessপ্রধান ঝুঁকিকখন
শুধু TTLকেউ কিছু বলে না; মেয়াদে মরেTTLবদল দেরিতে দেখা যায়অল্প পুরোনো চলে: গণনা, “জনপ্রিয়” তালিকা
লেখার পরে মোছা (delete on write)অ্যাপ DB commit-এর পরে key মোছেপ্রায় শূন্য; race হলে TTL পর্যন্তcommit আর মোছার মাঝে crash; অন্য পথের লেখা অজানা; পড়া-লেখার race (পর্ব ২৮)এক সার্ভিসই ডেটার মালিক
Event-চালিত (pub/sub বা CDC)লেখার event বা DB-র log থেকে key মোছাevent-এর দেরি (ms থেকে s)বার্তা হারানো, দেরি, ক্রম উল্টানোবহু লেখক, বহু cache, বহু সার্ভিস
Versioned keykey-এর নামেই সংস্করণ: user:42:v7 → v8প্রায় শূন্যসংস্করণ জানতে একটি বাড়তি পড়া; পুরোনো key memory খায়বড় object, snapshot, config
Tag বা দল ধরেএকটি tag বা দলের সংস্করণ বাড়িয়ে সবাইকে একসাথে অচলপ্রায় শূন্যপুরো দল একসাথে miss — নিজের হাতে stampedeতালিকা, search-এর ফল, বিভাগের পাতা

লেখার পরে মোছা — সবচেয়ে প্রচলিত, আর তার ফাঁক

Cache-aside নকশায় অ্যাপ লেখে ডেটাবেসে, তারপর cache থেকে key মোছে; পরের পড়া miss করে নতুন মান আনে। নতুন মান বসানোর বদলে মোছা কেন, আর পড়া-লেখার race কীভাবে পুরোনো মান ফেরায় — পর্ব ২৮-এ। এখানে দুটি কাঠামোগত ফাঁক: এক, commit সফল, তারপর মোছার আগেই সার্ভার বন্ধ — পুরোনো মান TTL পর্যন্ত; এটি dual write-এর সমস্যা, যার একটি উত্তর পর্ব ২৫-এর outbox pattern. দুই, ডেটা বদলায় অনেক পথে — অন্য সার্ভিস, রাতের batch job, হাতে চালানো SQL — আর তারা কেউ cache-এর কথা জানে না।

Event-চালিত invalidation — log শোনে, key মোছে

দ্বিতীয় ফাঁকের উত্তর: প্রতিটি commit-ই invalidation-এর উৎস। Change data capture (CDC) ডেটাবেসের replication log পড়ে প্রতিটি পরিবর্তনকে event বানায়, আর একটি ছোট কর্মী সংশ্লিষ্ট key মোছে — লেখা যে পথেই আসুক। বিকল্পে লেখক নিজে একটি pub/sub channel-এ “user:42 বদলেছে” প্রকাশ করে, আর প্রতিটি সার্ভার শুনে নিজের local cache-এর কপি মোছে। Pub/sub পর্ব ৩২-এ, log-ভিত্তিক stream পর্ব ৩৩-এ। চারটি ঝুঁকি:

  • হারানো বার্তা: কিছু pub/sub বার্তা জমিয়ে রাখে না — Redis-এর Pub/Sub documentation অনুযায়ী at-most-once; সংযোগ-কাটা গ্রাহক বার্তা পায় না। উত্তর: টেকসই log (offset ধরে আবার পড়া), আর TTL নিরাপত্তা-জাল।
  • দুবার আসা: at-least-once-এ একই বার্তা দুবার আসে; মোছা idempotent, তাই ক্ষতি নেই।
  • ক্রম উল্টানো: মোছার বার্তায় ক্রম অপ্রাসঙ্গিক — দুটোই key সরায়। কিন্তু বার্তায় নতুন মান পাঠালে v7 শেষে বসে v8-কে মুছে দিতে পারে; তখন শুধু বড় version হলে লেখা, অর্থাৎ compare-and-set. তাই নিয়ম: event-এ মান নয়, invalidation পাঠানো।
  • আগে এসে পড়া: event key মুছল, কিন্তু পরের miss পড়ল read replica থেকে, যেখানে বদল তখনো আসেনি — পুরোনো মান আবার পুরো TTL-এর জন্য বসল (পর্ব ২২)। উত্তর: miss-এ primary থেকে পড়া, lag পেরোনোর পরে দ্বিতীয়বার মোছা (“delayed double delete”), বা versioned key.

Versioned key আর দল ধরে অচল করা

সবচেয়ে শক্তিশালী কৌশল: ভুলে যাওয়ার দরকারই না রাখা — ডেটা বদলালে নামই বদলাবে। Versioned key-এ সারিতে একটি version column, আর cache key-এ সেই সংখ্যা। পুরোনো key ভুল হয় না, শুধু অব্যবহৃত — TTL বা eviction-এ সরে। নামে hash-ওয়ালা static ফাইল (cache busting) একই চিন্তা।

on update(user 42):
    DB:    UPDATE users SET name = ..., version = version + 1 WHERE id = 42   -- now version 8
    cache: SET user:42:ver 8              -- tiny pointer key (or keep the version in a parent object)

on read(user 42):
    v    = cache.GET user:42:ver          -- on a miss, read the version from the DB
    data = cache.GET "user:42:v" + v      -- user:42:v8
    if data is missing:
        data = DB.read(42); cache.SET "user:42:v" + v, data, EX 3600
    -- nobody asks for user:42:v7 again; TTL or eviction removes it

-- group (namespace) invalidation: every page of a product list carries the group version
key = "plist:ns" + cache.GET(plist:ns) + ":page:" + n     -- plist:ns5:page:3
INCR plist:ns                                            -- ns5 -> ns6: every page is now a miss

সমস্যা পুরো যায় না, ছোট হয়: এখন শুধু একটি ছোট version key ঠিক রাখতে হয়। দলের সংস্করণ বাড়ালে পুরো দল একসাথে miss — বড় দলে সেটি নিজেই ঢল, তাই soft TTL বা ধাপে ধাপে। CDN-এর tag-purge (surrogate key) এই দল-চিন্তারই রূপ, পর্ব ১৭-এ।

৭ · Cache stampede গভীরে — lock, XFetch, stale পরিবেশন আর pre-warming

জনপ্রিয় একটি key cache-এ না থাকলে বহু অনুরোধ একসাথে miss করে ডেটাবেসে ছোটে — cache stampede, বা dogpile; ভিড়ের সাধারণ রূপ thundering herd-এর প্রথম পরিচয় পর্ব ৬-এর deep dive-এ। কারণ চারটি: hot key-এর মেয়াদ শেষ; ঠান্ডা শুরু (cold start) — নতুন node বা restart; deploy-এ flush বা গণ-invalidation; আর memory-র চাপে hot key-এরই eviction. আকার: সেকেন্ডে λ অনুরোধ আর recompute-এ Δ সেকেন্ড হলে মেয়াদের দেয়ালে আটকায় প্রায় λ × Δ অনুরোধ — λ = ২,০০০, Δ = ০.০৫ s-এ ১০০টি একই query. চাপে ডেটাবেস ধীর হলে Δ বাড়ে, আর সংখ্যাটিও।

Cache · hot key
Database
অনুরোধ #১
অনুরোধ #২ থেকে #১০০
ঘড়ি আর হিসাব (সেকেন্ডে ২,০০০ অনুরোধ, recompute ৫০ ms)
সময়
DB-তে মোট query
অপেক্ষায় থাকা অনুরোধ
  1. মেয়াদের ঠিক আগে। ঘড়িতে ৯.৯৯০ s; hot key-এ সেকেন্ডে ২,০০০ অনুরোধ, সবাই hit, মেয়াদ ১০ ms পরে। এই key-এর জন্য ডেটাবেসে কেউ যায় না।
  2. ১০.০০০ s — key নেই। TTL শেষ; প্রথম অনুরোধ miss করে ডেটাবেসে গেল। Recompute-এ ৫০ ms, এই সময় key ফাঁকা।
  3. সুরক্ষা ছাড়া — stampede. ৫০ ms-এ আসে ২,০০০ × ০.০৫ = ১০০টি অনুরোধ; প্রত্যেকে নিজেই একই query চালায়। ডেটাবেস ধীর হলে জানালা বড় হয়, আরও miss — চাপ নিজেই নিজেকে বাড়ায়।
  4. Single-flight — একই মুহূর্ত, অন্য নকশা। প্রথম অনুরোধ মেয়াদসহ একটি lock নিল (যেমন SET lock:hot <token> NX PX 2000) আর ডেটাবেসে গেল। বাকি ৯৯ জন lock ধরা দেখে কয়েক ms পরপর আবার দেখেন — ডেটাবেসে যান না। এটিই request coalescing.
  5. ১০.০৫০ s — একটি query, সবার উত্তর। Lock-ধারী নতুন মান TTL ১০ s-সহ লিখল, token মিলিয়ে lock ছাড়ল; অপেক্ষার ৯৯ জন নতুন মান পেলেন। ডেটাবেসে মোট ১টি query; দাম, ১০০ জনের গড়ে প্রায় ২৫ ms অপেক্ষা — p99-এ চোখে পড়ে।
  6. আরও ভালো — soft TTL আর পুরোনো মান। Store-এ আসল মেয়াদ বেশি, ১০ s-এ শুধু “নবায়নের সময়”। প্রথম জন lock নিয়ে পেছনে নবায়ন চালায়, আর সবাই সাথে সাথে পুরোনো মান পান — অপেক্ষা শূন্য, দাম ৫০ ms পর্যন্ত পুরোনো তথ্য। Lock-ধারী বিকল হলে ২ s পরে lock নিজেই ছাড়ে — lock আসলে একটি lease.
# 1) try to take the lock: only one caller gets OK; it expires by itself after 2000 ms
SET lock:product:42 9f3c1a NX PX 2000
# 2) OK -> read the database, then write the value with its TTL
SET product:42 "{...}" EX 600
# 3) release only if the lock is still ours (check and delete together, e.g. in a Lua script)
#    if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) end
# nil in step 1 -> someone else is computing: wait a few ms and GET again, or serve the stale copy

তিনটি খুঁটিনাটি: lock-এর মেয়াদ recompute-এর চেয়ে বড়, নইলে কাজ শেষের আগেই আরেকজন lock পায়; ছাড়ার সময় token মেলানো, যাতে অন্যের lock ভুলে মুছে না যায়; আর lock-সেবা বিকল হলে নিয়ম কী, তা আগেই ঠিক করা। Distributed lock, lease আর fencing token পর্ব ৪৩-এ।

XFetch — মেয়াদের আগেই, সম্ভাবনার নিয়মে নবায়ন

Lock-এ সমন্বয় লাগে, আর দেয়ালে কেউ না কেউ অপেক্ষা করেই। বিকল্প: দেয়ালে পৌঁছানোর আগেই কাউকে দিয়ে নবায়ন — probabilistic early expiration. Vattani, Chierichetti ও Lowenstein-এর “Optimal Probabilistic Cache Stampede Prevention” (PVLDB, ২০১৫) গবেষণাপত্রের রূপটির নাম XFetch. প্রতিটি entry-র সাথে রাখা হয় Δ — শেষ recompute-এর সময়। প্রতিটি অনুরোধ কারও সাথে কথা না বলে সিদ্ধান্ত নেয়:

t − Δ · β · ln(rand()) ≥ expiry হলে এখনই recompute  ·  সম্ভাবনা = e−(expiry − t) ÷ (Δ·β)

rand() ০ থেকে ১-এর মধ্যে, তাই −Δ·β·ln(rand()) একটি সূচকীয় (exponential) এলোমেলো দূরত্ব, গড় Δ·β — অনুরোধটি যেন নিজের ঘড়ি সেই দূরত্বে এগিয়ে দেখে। মেয়াদ যত কাছে, সম্ভাবনা তত বেশি। β = ১ ডিফল্ট; বাড়ালে আরও আগে নবায়ন, ডেটাবেসে কিছু বাড়তি কাজ। আর ধীর recompute-এ (বড় Δ) নবায়ন নিজে থেকেই আগে শুরু হয়।

function fetch(key, ttl, beta = 1):
    value, delta, expiry = cache.read(key)
    if value is missing or now() - delta * beta * ln(rand()) >= expiry:
        start = now()
        value = recompute_value()          -- the database or the slow service
        delta = now() - start              -- measured, stored with the value
        cache.write(key, (value, delta), ttl)
    return value
একটি entry, β = 1
এখন (t)
মেয়াদ (expiry)
Δ
rand()
হিসাব
সিদ্ধান্ত
  1. মান আছে, মেয়াদ বাকি ১৫০ ms. Entry-র সাথে Δ = ৫০ ms, β = ১. সাধারণ TTL হলে এখন কোনো প্রশ্নই উঠত না।
  2. এলোমেলো সংখ্যা। অনুরোধটি টানল ০.০২; তার ln = −৩.৯১২ — ছোট সংখ্যার ln বড় ঋণাত্মক।
  3. এগোনো ঘড়ির দূরত্ব। −Δ·β·ln(rand) = ০.০৫ × ৩.৯১২ = ০.১৯৬ s. গড় দূরত্ব ৫০ ms, কিন্তু কখনো কখনো অনেক বড় — এবার তেমনই।
  4. সিদ্ধান্ত: এখনই নবায়ন। ৯.৮৫০ + ০.১৯৬ = ১০.০৪৬, মেয়াদ ১০.০০০-এর পরে — তাই এই অনুরোধ এখনই recompute করবে, বাকিরা এখনো বৈধ পুরোনো মান পাবে। ১৫০ ms বাকিতে এর সম্ভাবনা e−১৫০/৫০ = e−৩ ≈ ৫%.
  5. একই মুহূর্তে আরেকজন। সে টানল ০.৫: দূরত্ব ০.০৫ × ০.৬৯৩ = ০.০৩৫ s, এগোনো ঘড়ি ৯.৮৮৫ — মেয়াদের আগে, তাই cache থেকেই মান। ১০০ ms বাকিতে সম্ভাবনা ১৩.৫%, ৫০ ms-এ ৩৬.৮%, শূন্যে ১০০%.
  6. ৯.৯০০ s — কেউ অপেক্ষা করেনি। নতুন মান, নতুন মেয়াদ ১৯.৯০০ s, Δ আবার মাপা — মেয়াদের ৫০ ms আগেই। সেকেন্ডে ২,০০০ অনুরোধে প্রথম নবায়ন সাধারণত মেয়াদের প্রায় Δβ·ln(λΔβ) = ০.০৫ × ln ১০০ ≈ ২৩০ ms আগে শুরু হয়।

এবার চারটি নকশা একই seeded Poisson-ধাঁচের traffic-এ — সুরক্ষা নেই, single-flight lock, lock আর পুরোনো মান (soft TTL), আর XFetch; গোনা হচ্ছে recompute, দেয়ালে অপেক্ষা, পুরোনো মান, আর একসাথে সর্বোচ্চ query:

// One hot key, 2,000 req/s (seeded Poisson arrivals), TTL 10 s, 60 s of traffic.
// Recomputing the value from the database takes DELTA seconds. Four ways to handle expiry.
const RATE = 2000, DELTA = 0.05, TTL = 10, T = 60, BETA = 1;
function mulberry32(a) { return () => { a = (a + 0x6D2B79F5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a);
  t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; }
function sim(mode) {
  const rnd = mulberry32(2026);
  let t = 0, expiry = TTL, inflight = [], db = 0, waited = 0, stale = 0, peak = 0;
  const recompute = () => { inflight.push(t + DELTA); db++; peak = Math.max(peak, inflight.length); };
  for (;;) {
    t += -Math.log(1 - rnd()) / RATE; if (t >= T) break;
    inflight = inflight.filter((done) => (done <= t ? ((expiry = Math.max(expiry, done + TTL)), false) : true));
    if (t < expiry) {                                    // the cached value is still valid
      // XFetch: refresh early with probability exp(-(expiry - t) / (DELTA * BETA))
      if (mode === "xfetch" && t - DELTA * BETA * Math.log(1 - rnd()) >= expiry) recompute();
      continue;
    }
    if (mode === "none" || mode === "xfetch") { recompute(); waited++; }   // everyone goes to the DB
    else if (mode === "lock") { if (!inflight.length) recompute(); waited++; } // one goes, others wait
    else { if (!inflight.length) recompute(); stale++; } // "stale": one refreshes, others get old value
  }
  console.log(mode.padEnd(8) + String(db).padStart(13) + String(waited).padStart(9) +
    String(stale).padStart(8) + String(peak).padStart(10));
}
console.log(`recompute ${DELTA * 1000} ms, TTL ${TTL} s, ${RATE} req/s for ${T} s`);
console.log("mode     DB recomputes   waited  served  peak DB");
console.log("                      at expiry   stale  parallel");
["none", "lock", "stale", "xfetch"].forEach(sim);

আউটপুট:

recompute 50 ms, TTL 10 s, 2000 req/s for 60 s
mode     DB recomputes   waited  served  peak DB
                      at expiry   stale  parallel
none              503      503       0       117
lock                5      475       0         1
stale               5        0     475         1
xfetch             12        0       0         3

৬০ সেকেন্ডে মেয়াদ শেষ পাঁচবার। none: ৫০৩টি recompute — দফাপ্রতি প্রায় ১০০, ঠিক λ × Δ; এক মুহূর্তে ১১৭টি একই query, এলোমেলো আগমনে গড়ের বেশি। মডেলে ডেটাবেস ধীর হয় না; বাস্তবে হয়, আর সংখ্যা আরও বাড়ে। lock: ৫টি recompute, কিন্তু ৪৭৫ জন দেয়ালে দাঁড়ান। stale: একই ৫টি, অপেক্ষা শূন্য, ৪৭৫ জন পান ৫০ ms পর্যন্ত পুরোনো মান। xfetch: ১২টি — দফাপ্রতি প্রায় ২.৪, কারণ lock নেই বলে প্রথম নবায়ন চলার সময়ে আরও এক-দুজন একই সিদ্ধান্ত নেন; তবু কেউ দেয়ালে আটকান না, একসাথে সর্বোচ্চ ৩টি query. সাথে একটি “চেষ্টা-করা” lock দিলে duplicate-ও যায়। DELTA ০.২ করলে কী হবে — অনুশীলন ৪-এ।

শেষ অস্ত্রটি সবচেয়ে পুরোনো: pre-warming — জানা ভিড়ের আগে (ফল প্রকাশ, flash sale, নতুন node) cache ভরে রাখা, আর নতুন node-এ traffic ধাপে ধাপে বাড়ানো। অজানা মুহূর্তে lock, soft TTL আর XFetch; জানা মুহূর্তে প্রস্তুতি।

৮ · Penetration আর avalanche — অস্তিত্বহীন key আর গণ-miss

Stampede একটি key-এর গল্প। আরও দুটি পরিচিত বিপদ আছে; অনেক ইন্টারভিউ-সংকলনে তিনটিকে একসাথে penetration, breakdown আর avalanche নামে ডাকা হয় — এখানে breakdown মানে একটি hot key-এর মেয়াদ শেষ, অর্থাৎ stampede. নাম উৎসভেদে আলাদা, ধারণা এক।

Cache penetration — যা নেই, তার জন্য বারবার

কেউ এমন id চাইছে যা ডেটাবেসেও নেই — একটি bug-এর ভুল id, পুরোনো লিংক, বা এলোমেলো id দিয়ে খোঁজা কোনো স্বয়ংক্রিয় client. উৎসেই নেই বলে cache-এ কখনো বসবে না, আর প্রতিটি অনুরোধ ডেটাবেস পর্যন্ত যায়। প্রতিরক্ষা তিন স্তরে:

  • Negative caching: “নেই” উত্তরও ছোট TTL-এ (ধরা যাক ৩০–৬০ s) cache করা — একই id বারবার এলে কাজ করে। শর্ত: id-টি পরে তৈরি হলে সেই “নেই” মুছতে হবে।
  • Bloom filter: প্রতিবার নতুন এলোমেলো id এলে negative cache কাজে আসে না। তখন সামনে সব বৈধ id-এর Bloom filter: “অবশ্যই নেই” বললে ডেটাবেসে না গিয়েই ফেরত। সেকেন্ডে ৫,০০০টি অস্তিত্বহীন id আর ১% ভুল-ইতিবাচক হারে ডেটাবেসে পৌঁছায় প্রায় ৫০টি; এক কোটি id-এর জন্য লাগে প্রায় ১২ MB আর ৭টি hash — সূত্র পর্ব ৪৫-এ।
  • ইনপুট যাচাই আর সীমা: ভুল ধরনের id প্রথমেই প্রত্যাখ্যান, আর client-প্রতি অনুরোধের হারে সীমা — পর্ব ৪৯-এর rate limiter দিয়ে।

Cache avalanche — একসাথে বিপুল miss

দুই উৎস: সমলয়ে মেয়াদ (অংশ ৫-এর ঢেউ, প্রতিকার jitter) আর একটি cache node বিকল — তার ভাগের সব key একসাথে miss. হিসাব: সেকেন্ডে ১,০০,০০০ পড়া, ১০টি node-এ সমান ভাগ, hit ratio ৯৫%. স্বাভাবিকে ডেটাবেস পায় ১,০০,০০০ × ০.০৫ = ৫,০০০. একটি node পড়লে তার ১০,০০০-এর পুরোটাই ডেটাবেসে, বাকি ৯টি থেকে ৯ × ৫০০ = ৪,৫০০ — মোট ১৪,৫০০, প্রায় ২.৯ গুণ। ডেটাবেসের নিরাপদ সীমা ধরা যাক ৮,০০০ হলে এক node-এর বিভ্রাটই সেবা ফেলে দিতে পারে; সব key একসাথে মরলে চাপ ২০ গুণ।

  • Cache-এর redundancy: node-এর replica বা দুটি cluster; consistent hashing (পর্ব ১৬), যাতে node বদলে শুধু তার ভাগটুকু সরে; replication আর বহু-স্তরের cache পর্ব ৩০-এ।
  • বিকল্প পথ: অ্যাপ সার্ভারের ছোট local cache, ডেটাবেসমুখী query-র সীমা, circuit breaker (পর্ব ৪৮), আর বাড়তি অনুরোধ নম্রভাবে ফেরানো — load shedding (পর্ব ১৮)।
  • পুরোনো দেখানো, বন্ধ নয়: soft TTL-এর পুরোনো মান বা সরল ডিফল্ট — graceful degradation.
  • ফিরে আসার নিয়ম: ঠান্ডা node-এ traffic ধাপে ধাপে, আগে ভরে নিয়ে; নইলে ভাবনার প্রশ্ন ৫-এর ফাঁদ।

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

  • LRU cache-এ get আর put O(1)-এ কীভাবে? hash map node খোঁজে, doubly linked list ক্রম রাখে।
  • LRU না LFU? বদলাতে থাকা সাম্প্রতিক চাহিদায় LRU; স্থির জনপ্রিয়তায় aging-সহ LFU; scan থাকলে SLRU, ARC বা W-TinyLFU.
  • Cache stampede কীভাবে ঠেকাবেন? single-flight lock, soft TTL-এ পুরোনো মান, XFetch-এ আগেভাগে নবায়ন, pre-warming আর TTL jitter.
  • Penetration আর avalanche-এর পার্থক্য? অস্তিত্বহীন key-এর ঢল বনাম একসাথে বহু miss — প্রতিকার Bloom filter বনাম jitter ও redundancy.
  • Event-এ নতুন মান না পাঠিয়ে “মুছে ফেলার” বার্তা কেন? মোছা idempotent আর ক্রম-নিরপেক্ষ; উল্টো ক্রমে আসা পুরোনো মান নতুনটিকে মুছে দিতে পারে।

৯ · AI-এর যুগে — eviction আর invalidation-এর পরামর্শ যাচাই

“অ্যাপ ধীর” বললে AI প্রায়ই লেখে: “Redis বসান, LRU দিন, TTL এক ঘণ্টা, invalidation-এর জন্য Kafka.” কিছুটা হয়তো ঠিক, কিন্তু cache-এর ভুল নিঃশব্দ — পুরোনো দাম, ভুল স্টক, মেয়াদের মুহূর্তে ডেটাবেসের ধস — আর demo-তে ধরা পড়ে না। চেনা ভুলগুলো:

AI-এর ভুলকেন ভুলকীভাবে ধরবেন
“LRU সবসময় সেরা” বা “LFU সবসময় সেরা”scan-এ LRU, জনপ্রিয়তার বদলে LFU ভাঙেনিজের trace-এ simulator চালানো
সব key-এ একই TTL, jitter নেইএকসাথে লেখা key একসাথে মরেN ÷ ২JT আর সর্বোচ্চ সেকেন্ড নিজে হিসাব
লেখার পরে cache-এ নতুন মান SETউল্টো ক্রমে পুরোনো মান জেতেদুটি সমান্তরাল লেখার ক্রম আঁকা (পর্ব ২৮)
Stampede-এর হিসাব নেই, বা ভুল অঙ্কλ × Δ query একসাথে, চাপে আরওλ আর Δ বসিয়ে গুণ, তারপর load test
“DB-তে ACID, তাই consistency ঠিক আছে”ACID-এর C আর cache-এর staleness আলাদা (পর্ব ৪০)প্রতিটি ডেটার সর্বোচ্চ staleness সংখ্যায় লেখা
Redis-এর setting, ডিফল্ট বা সীমা আত্মবিশ্বাসে বলাপ্রায়ই পুরোনো বা বানানোনিজের সংস্করণের documentation, CONFIG GET
ছোট অ্যাপে CDC + Kafka + বহু-স্তরের cacheঅতিরিক্ত নকশা, নতুন বিভ্রাটের উৎসসংখ্যা দিয়ে দরকার দেখাতে বলা

প্রশ্নে সংখ্যা, ডেটার ধরন আর staleness-এর সীমা দিন; কারণ আর হিসাব চান:

তুমি একজন অভিজ্ঞ backend ইঞ্জিনিয়ার। আমাদের একটি কাল্পনিক অনলাইন বাজার: Redis-ধাঁচের cache, ৮ GB memory, প্রায় ২ কোটি পণ্য-key; peak-এ সেকেন্ডে ৫০,০০০ পড়া, ডেটাবেস নিরাপদে সামলায় সেকেন্ডে ৩,০০০ query. হোমপেজের "আজকের অফার" key-তে সেকেন্ডে ৫,০০০ অনুরোধ, recompute-এ ১৫০ ms. প্রতি রাতে একটি export job সব পণ্য একবার করে পড়ে। ১. কোন eviction policy বেছে নেবে আর কেন — রাতের scan আর ঈদের অফারে জনপ্রিয়তার বদল, দুটো মাথায় রেখে LRU আর LFU-র ঝুঁকি বলো। ২. পণ্যের দাম, স্টকের সংখ্যা আর পণ্যের বিবরণ — প্রতিটির TTL, jitter আর invalidation-এর পথ (লেখার পরে মোছা, CDC, না versioned key) বলো, আর প্রতিটির সর্বোচ্চ staleness হিসাব করে দেখাও। ৩. "আজকের অফার" key-এর মেয়াদ শেষে কত query একসাথে যাবে, λ × Δ দিয়ে হিসাব করো; তারপর lock, soft TTL আর XFetch-এর মধ্যে বেছে নাও। ৪. ১০টি cache node-এর একটি বিকল হলে ডেটাবেসে কত query যাবে, ধাপে ধাপে হিসাব করো, আর কী দিয়ে সামলাব বলো। ৫. Redis-এর কোনো setting-এর নাম বা ডিফল্ট বললে কোন সংস্করণের documentation থেকে তা বলো; নিশ্চিত না হলে "জানি না" বলো। নতুন component শুধু সংখ্যা দিয়ে দরকার দেখাতে পারলে।

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

AI দেয় দ্রুত খসড়া, যাচাইয়ের দায় আপনার।
Miss গুনুন, মেয়াদ ছড়ান, আর প্রতিটি invalidation-এর হারিয়ে যাওয়ার পথ খুঁজুন।

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

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

প্র ০১Belady-র OPT সবচেয়ে কম miss দেয় — তাহলে কেউ সেটি চালায় না কেন? আর ভবিষ্যৎ না জেনে LRU সবচেয়ে খারাপ ক্ষেত্রে কতটা পিছিয়ে থাকতে পারে?

১৯৬৬ সালে László Bélády IBM Systems Journal-এ যে MIN নীতি বর্ণনা করেন — সবচেয়ে দেরিতে আবার লাগবে এমন entry সরানো — তা যেকোনো ক্রমে সবচেয়ে কম miss দেয়। কিন্তু ভবিষ্যৎ জানতে হয়, আর কোন key কখন চাওয়া হবে তা নির্ভর করে মানুষের আচরণের ওপর।

তাই OPT-এর কাজ মাপকাঠি: একটি দিনের log-এ OPT চালালে জানা যায় এই আকারের cache-এ সর্বোচ্চ কত hit সম্ভব। অংশ ৪-এ সীমা ৫৯.৯%, LRU ৩৯.১% — ভালো policy খোঁজার জায়গা আছে। সীমা ৪১% হলে উত্তর হতো cache বড় করা।

১৯৮৫ সালে Daniel Sleator আর Robert Tarjan Communications of the ACM-এ competitive analysis নিয়ে আসেন: সবচেয়ে খারাপ ক্রমে একটি online algorithm OPT-এর কত গুণ miss করে। ফল: k আকারের cache-এ LRU আর FIFO-র miss কখনো OPT-এর k গুণের বেশি নয় (ছোট একটি ধ্রুবক বাদে), আর ভবিষ্যৎ-না-জানা কোনো deterministic algorithm এর চেয়ে ভালো নিশ্চয়তা দিতে পারে না — প্রতিপক্ষ সবসময় এইমাত্র সরানো key-টিই চাইতে পারে; k + ১টি key-এর চক্রে LRU প্রতিবার miss করে, OPT প্রতি k অনুরোধে একবার।

১০ লাখ key-এর cache-এ “k গুণ” মানে “১০ লাখ গুণ” — শুনতে ভয়ানক, কিন্তু একই গবেষণার আরেক ফল: LRU k আকার পেলে আর OPT মাত্র h ≤ k, অনুপাত বড়জোর k ÷ (k − h + ১); k = ২h হলে ২-এর কম। অর্থাৎ দ্বিগুণ memory পাওয়া LRU ভবিষ্যৎ-জানা OPT-এর দুই গুণের বেশি miss করে না। এলোমেলো সিদ্ধান্তের algorithm-এ গুণক log k-এর ঘরে নামে বলে পরে দেখানো হয় (১৯৯১ সালের একটি পরিচিত গবেষণায়)। বাস্তবের traffic প্রতিপক্ষ নয় — locality থাকে — তাই LRU সাধারণত এই সীমার চেয়ে অনেক ভালো।

মূল উপলব্ধি: OPT হলো রুলার, যন্ত্র নয় — নিজের trace-এ দেখায় কতটা উন্নতি সম্ভব। আর competitive analysis বলে, ভবিষ্যৎ না জানার ক্ষতি অনেকটাই বাড়তি জায়গা দিয়ে পূরণ করা যায়।

প্র ০২LRU-র দুর্বলতা এত দিন ধরে জানা — তবু বহু cache এখনো LRU বা তার কাছাকাছি কিছু চালায় কেন? ARC-এর মতো “উন্নত” নিয়ম বাস্তবে কী বাধায় পড়েছে?

কারণ তিনটি: সরলতা, অনুমানযোগ্যতা আর “যথেষ্ট ভালো”। LRU কয়েক লাইনে লেখা যায়, তার আচরণ আগে থেকে বলা যায়, আর অনেক web চাহিদায় সাম্প্রতিকতা সত্যিই ভালো সংকেত। কোনো ঘটনার পরে “cache কেন এই key সরাল” — LRU-তে উত্তর এক বাক্যে, adaptive নিয়মে সবসময় নয়; দলের নতুন প্রকৌশলীও প্রথম দিনেই নিয়মটি বুঝে ফেলেন।

উন্নত নিয়মের ইতিহাসেও শিক্ষা আছে। IBM-এর Megiddo ও Modha ২০০৩ সালে ARC প্রকাশ করেন, আর IBM-এর এর ওপর একটি patent ছিল। PostgreSQL ৮.০ (২০০৫) buffer cache-এ ARC চালু করেছিল; প্রকাশিত আলোচনা অনুযায়ী patent-এর আশঙ্কায় পরের ছোট সংস্করণেই 2Q-ধাঁচে ফেরে, আর ৮.১-এ আসে clock-sweep — একটি usage counter আর ঘুরন্ত হাতল, যা আজও চলছে। আরেক কারণ ছিল বহু CPU-তে কেন্দ্রীয় list-এর lock-এর দাম; clock-sweep-এ hit মানে শুধু একটি counter বাড়ানো।

অর্থাৎ মাপকাঠি শুধু hit ratio নয়: প্রতিটি hit-এ কত কাজ, কত lock, আর entry-প্রতি কত বাড়তি জায়গা। তাই পরিচিত সিস্টেমগুলো আপস করে: Linux-এর page cache দুই list-এ scan ঠেকায়, Memcached-এ hot-warm-cold ভাগ, Redis নমুনা দিয়ে LRU আর ক্ষয়-যুক্ত LFU আন্দাজ করে — source code-এর বর্ণনা অনুযায়ী প্রতিটি object-এর একটিই ২৪-বিটের ঘরে, LRU-তে শেষ ব্যবহারের ঘড়ি আর LFU-তে ছোট একটি counter ও শেষ ক্ষয়ের সময় — আর Caffeine W-TinyLFU চালায় পড়ার ঘটনা buffer-এ জমিয়ে (documentation অনুযায়ী)। ZFS বহু বছর ধরে ARC-এর পরিবর্তিত রূপ চালাচ্ছে — ভালোভাবে বানালে adaptive নিয়মও টেকে; আর S3-FIFO, SIEVE দেখাচ্ছে খুব সরল নকশাও LRU-কে হারাতে পারে।

মূল উপলব্ধি: eviction policy বাছাই একটি প্রকৌশল-সিদ্ধান্ত: hit ratio, প্রতিটি পড়ার খরচ, concurrency, memory, ব্যাখ্যাযোগ্যতা — এমনকি আইনি ঝুঁকিও হিসাবে আসে। আগে মাপুন, তারপর যেটুকু জটিলতা দাম পোষায়, ততটুকুই নিন।

প্র ০৩লেখার পরে cache থেকে key মুছে দিলেই কি ঝামেলা শেষ? বড় সিস্টেম কেন ডেটাবেসের log পড়ে invalidation পাঠায়, আর তাতে নতুন কোন ঝামেলা আসে?

একটি বিখ্যাত প্রকাশিত উদাহরণ Nishtala ও সহকর্মীদের “Scaling Memcache at Facebook” (NSDI ২০১৩)। সেখানে memcache একটি look-aside cache, আর লেখার পরে key মোছা হয়, নতুন মান বসানো নয় — লেখকদের যুক্তি, মোছা idempotent, তাই retry নিরাপদ।

কিন্তু শুধু অ্যাপের মোছায় ভরসা নয়। গবেষণাপত্রের বর্ণনা অনুযায়ী প্রতিটি ডেটাবেস সার্ভারে mcsqueal নামের একটি কর্মী commit log পড়ে, সম্পন্ন লেনদেন থেকে মোছার key বের করে, অনেকগুলো জুড়ে cache সার্ভারে পাঠায়। লাভ তিনটি: লেখা যে পথেই আসুক, log-এ থাকবেই; অ্যাপ commit-এর পরে মোছার আগে বন্ধ হলেও invalidation হারায় না; আর জুড়ে পাঠালে network-এর packet কম।

নতুন ঝামেলাও আসে। Invalidation এখন asynchronous — commit থেকে মোছা পর্যন্ত ছোট একটি জানালায় পুরোনো মান। আর একটি সূক্ষ্ম race: পাঠক A miss করে পুরোনো মান পড়ল, তখনই লেখা হলো আর মোছা পৌঁছাল, তারপর A তার পুরোনো মান cache-এ বসাল — এখন পুরো মেয়াদের জন্য। গবেষণাপত্রে এর নাম stale set, প্রতিকার lease: miss-এ cache একটি টোকেন দেয়, আর মাঝে key মোছা হয়ে থাকলে সেই টোকেনের লেখা প্রত্যাখ্যাত। একই lease অল্প সময়ে নতুন টোকেন না দিয়ে thundering herd-ও ঠেকায়। আর বহু region-এ কপি থাকলে প্রশ্ন আরও জটিল — কোন region-এ মোছা কখন পৌঁছাল, তার হিসাব রাখতে হয়; গবেষণাপত্রে এর জন্য আলাদা ব্যবস্থার বর্ণনা আছে।

সাধারণ শিক্ষা: invalidation একটি বার্তা-ব্যবস্থার সমস্যা। বার্তা হোক idempotent, পৌঁছানো at-least-once (টেকসই log, retry), ক্রমের বদলে version বা lease, আর শেষে TTL — সব ব্যর্থ হলেও ক্ষতির সীমা। একই চিন্তা পর্ব ২৫-এর outbox-এ আর পর্ব ৩৩-এর stream-এ।

মূল উপলব্ধি: “লেখার পরে মোছা” ভালো শুরু, কিন্তু নির্ভরযোগ্য invalidation মানে লেখার প্রতিটি পথ ধরা, বার্তা idempotent রাখা, race-এর জন্য আলাদা পাহারা — আর TTL কখনো বাদ না দেওয়া।

প্র ০৪XFetch-এ নবায়নের সম্ভাবনা মেয়াদের দিকে সূচকীয় হারে বাড়ে কেন? Lock থাকতে আবার সম্ভাবনার খেলা কেন দরকার?

Lock মানে সমন্বয়: একটি বাড়তি round trip, নিজেও বিকল হতে পারে এমন একটি lock-সেবা, lock-ধারী মারা গেলে lease ফুরানো পর্যন্ত অপেক্ষা — আর মেয়াদের দেয়ালে অপেক্ষারত সবাই latency-র লেজে দাগ ফেলে। Simulator-এ lock প্রতি দফায় প্রায় ৯৫ জনকে দাঁড় করিয়েছিল।

XFetch (Vattani, Chierichetti ও Lowenstein, PVLDB ২০১৫) সমন্বয় ছাড়াই চলে: প্রতিটি অনুরোধ entry-র Δ আর মেয়াদ দেখে নিজে সিদ্ধান্ত নেয়। −Δβ·ln(rand) সূচকীয় বণ্টনের এলোমেলো সংখ্যা, গড় Δβ; তাই মেয়াদের s সেকেন্ড আগে নবায়নের সম্ভাবনা e−s/(Δβ). দুটি গুণ। এক, মাপকাঠি Δ: ৫০ ms-এর recompute-এ সম্ভাবনা কয়েকশো ms আগে থেকে বাড়ে, ২০০ ms-এ এক সেকেন্ডের বেশি আগে থেকে। দুই, traffic যত ঘন, নবায়ন তত আগে: সেকেন্ডে λ অনুরোধে মেয়াদের s আগে পর্যন্ত গড়ে λΔβ·e−s/(Δβ)টি নবায়ন-সিদ্ধান্ত, যা ১ হয় s ≈ Δβ·ln(λΔβ)-এ — λ = ২,০০০, Δ = ৫০ ms-এ প্রায় ২৩০ ms আগে।

Duplicate কত? প্রথম নবায়ন চলার Δ সময়ে আরও কেউ একই সিদ্ধান্ত নিতে পারে; একটি সরল হিসাবে তার গড় e১/β − ১, β = ১-এ প্রায় ১.৭, λ-এর ওপর প্রায় নির্ভর করে না। Simulator-এ দফাপ্রতি প্রায় ২.৪টি recompute — কাছাকাছি। গবেষণাপত্রে দেখানো হয়েছে, এ ধরনের নিয়মের একটি পরিবারে সূচকীয় রূপটি তাঁদের সংজ্ঞায়িত মাপকাঠিতে সর্বোত্তম। বাস্তবে XFetch নবায়নের সময় বাছে, একটি “চেষ্টা-করা” lock duplicate আটকায়, আর soft TTL নিশ্চিত করে কেউ খালি হাতে না ফেরে। β বাড়ালে নিরাপত্তা আর ডেটাবেসের কাজ দুটোই বাড়ে।

মূল উপলব্ধি: সবাই একই নিয়ম মানলে সবাই একই মুহূর্তে একই কাজ করে — সেটিই stampede. সঠিক মাপের সামান্য এলোমেলোতা সমলয়ের খাড়া দেয়ালকে মসৃণ ঢালে বদলায়, কোনো সমন্বয় ছাড়াই; TTL আর retry-র jitter-ও একই শিক্ষা।

প্র ০৫একটি cache cluster কিছুক্ষণের জন্য বসে গেল, তারপর ফিরে এল — তবু ডেটাবেস ঘণ্টার পর ঘণ্টা বসে থাকতে পারে কেন? এমন অবস্থা থেকে বেরোনোর উপায় কী?

এর নাম metastable failure. Bronson, Aghayev, Charapko ও Zhu-র “Metastable Failures in Distributed Systems” (HotOS ২০২১) অনুযায়ী একটি সাময়িক ধাক্কা সিস্টেমকে খারাপ অবস্থায় ফেলে, আর তারপর সিস্টেম নিজেই সেই অবস্থা টিকিয়ে রাখে — ধাক্কা চলে যাওয়ার পরেও। Cache হারানো তাদের পরিচিত উদাহরণগুলোর একটি।

শিকলটি এরকম। ডেটাবেস বানানো hit ratio ৯৫% ধরে, সেকেন্ডে ৫,০০০ query-র জন্য। Cache বসে গেল — ১,০০,০০০ query; ডেটাবেস ধীর, অ্যাপের timeout, retry, চাপ আরও বেশি। Cache ফিরল, কিন্তু খালি; ভরার একমাত্র পথ ডেটাবেস থেকে পড়া, আর query-গুলো timeout-এ মরছে বলে কোনো মান cache-এ বসছেই না। Cache খালি বলে ডেটাবেস ধীর, ডেটাবেস ধীর বলে cache খালি — একটি স্থিতিশীল খারাপ অবস্থা। অংশ ৮-এর অঙ্কে এক node-এর বিভ্রাটেই চাপ ২.৯ গুণ, পুরো cluster গেলে ২০ গুণ — এত বাড়তি ক্ষমতা সাধারণত কেউ রাখে না।

বেরোনোর উপায় চক্রটি ভাঙা: (১) চাপ কমানো — load shedding আর admission control দিয়ে ডেটাবেসমুখী query সীমিত, যাতে যেগুলো যায় সেগুলো সফল হয় আর cache-এ বসে; (২) retry-র লাগাম — backoff আর jitter, retry-র বাজেট, circuit breaker (পর্ব ৪৮); (৩) ধাপে ধাপে traffic — ১০%, ২৫%, ৫০% করে, বা snapshot বা replica থেকে আগে ভরে; (৪) পুরোনো দেখানো — soft TTL-এর মান বা সরল ডিফল্ট পাতা, যাতে অধৈর্য refresh না বাড়ে। নকশাতেই রাখা যায় বিকল node-এর ভাগ সামলানোর ব্যবস্থা — “Scaling Memcache at Facebook” গবেষণাপত্রের Gutter: বিকল সার্ভারের key সাময়িকভাবে ধরতে অল্প কয়েকটি আলাদা সার্ভার, ছোট মেয়াদে।

মূল উপলব্ধি: ডেটাবেসের আকার hit ratio ধরে ঠিক করা হলে cache আর শুধু গতির জিনিস নয় — সে availability-র অংশ। তাই “cache নেই” অবস্থাও একটি failure mode, যার চাপ হিসাব করে, মহড়া দিয়ে আর বেরোনোর পথ লিখে রাখতে হয়।

অনুশীলন

খাতা-কলমে করুন, তারপর উত্তর মেলান। দুটি হিসাব, একটি নকশার সিদ্ধান্ত, আর একটি simulator-এর পূর্বাভাস।

  1. LRU বনাম LFU হাতে trace: cache-এ জায়গা ৩টি key-এর। LFU-তে counter গোনা হয় শুধু cache-এ থাকার সময় (সরে গেলে counter হারায়), আর সমান counter হলে যেটি সবচেয়ে বেশি সময় অব্যবহৃত, সেটি সরে। দুটি ক্রমে hit গুনুন — (ক) A B A B C D E A B (খ) A A A B B C D E C D C D. কোন ক্রমে কে জিতল, আর কেন?

    (ক) LRU-তে ২টি hit, LFU-তে ৪টি। LRU: ৩ আর ৪-এ hit; ৫-এ C; ৬-এ D এসে লেজের A সরায়, ৭-এ E এসে B; ৮-এ A miss (C সরে), ৯-এ B miss (D সরে)। LFU: ৩, ৪-এ hit, counter A২, B২; ৫-এ C১; ৬-এ D এসে C১-কে সরায়, ৭-এ E এসে D১-কে; ৮ আর ৯-এ A, B hit. C, D, E একবারের অতিথি — একটি ছোট scan; LFU জনপ্রিয়দের সরাতে দেয়নি।

    (খ) LRU-তে ৭টি hit, LFU-তে ৩টি। LRU: ২, ৩, ৫-এ hit; ৭-এ D এসে A সরায়, ৮-এ E এসে B (cache C, D, E); তারপর C D C D — চারটিই hit. LFU: প্রথম পাঁচটিতে A৩, B২; ৬-এ C১; তারপর D, E, C, D… প্রত্যেকে counter ১-এর আগেরজনকে সরায়, আর পুরোনো A৩, B২ জায়গা আটকে রাখে — ৯ থেকে ১২-এর চারটিই miss.

    শিক্ষা: একবারের অতিথিদের ভিড়ে frequency জেতে, জনপ্রিয়তার বদলে recency — অংশ ৪-এর (খ) আর (গ) কলামের ছোট রূপ।

  2. নকশার সিদ্ধান্ত — তিন ধরনের ডেটা: একটি কাল্পনিক অনলাইন বাস-টিকিটের সেবা, ঈদের আগে ভিড়। তিনটি ডেটার জন্য TTL, jitter আর invalidation-এর পথ বেছে নিন, আর সর্বোচ্চ staleness লিখুন — (ক) রুট আর বাস-কোম্পানির বিবরণ (মাসে কয়েকবার বদলায়), (খ) একটি যাত্রার খালি আসনের সংখ্যা (প্রতি মিনিটে বহুবার বদলায়), (গ) টিকিটের দাম (দিনে কয়েকবার, অ্যাডমিন প্যানেল থেকে)।

    (ক) রুটের বিবরণ: বড় TTL, ধরা যাক ২৪ ঘণ্টা ± ১০%; বদল কম আর পরিকল্পিত, তাই versioned key (route:17:v4) বা লেখার পরে মোছা। Staleness প্রায় শূন্য; invalidation হারালেও ≤ ২৪ ঘণ্টা + jitter. স্থির জনপ্রিয় — LFU-ধাঁচ বা W-TinyLFU মানায়।

    (খ) খালি আসন: দেখানোর জন্য, বিক্রির জন্য নয়। তালিকার পাতায় ছোট TTL (২–৫ s, jitter-সহ) আর soft TTL, যাতে ভিড়ে stampede না হয়; লেখা থাকে “প্রায় ১২টি আসন”। কিন্তু কেনার মুহূর্তে আসন সংরক্ষণ সবসময় ডেটাবেসের লেনদেনে (পর্ব ২৪) — সেখানে staleness-এর সীমা শূন্য। পাতায় সর্বোচ্চ staleness ≈ TTL + নবায়নের সময়, ৫ s-এর কম।

    (গ) দাম: ভুল দাম ব্যবসায়িক আর আইনি ঝুঁকি। মাঝারি TTL (৫–১০ মিনিট ± ১০%) নিরাপত্তা-জাল, আসল invalidation event-চালিত — অন্য পথেও দাম বদলাতে পারে বলে CDC থেকে মোছা, বা versioned key; চেকআউটে দাম আবার ডেটাবেস থেকে। স্বাভাবিকে staleness event-এর দেরি (সেকেন্ডের ঘরে), event-ব্যবস্থা বিকল হলে ≤ TTL.

    তিনটির উত্তর তিন রকম — “সব key-এ TTL এক ঘণ্টা” তিনটিতেই ভুল।

  3. Jitter-এর হিসাব: একটি warm-up script ৫০,০০০টি key একসাথে লিখল, TTL ৬০০ s. (ক) Jitter ছাড়া মেয়াদ শেষের মুহূর্তে কী হবে? (খ) ±৫% সমবণ্টিত jitter-এ গড়ে প্রতি সেকেন্ডে কতটি মেয়াদ শেষ? (গ) ডেটাবেস প্রতি সেকেন্ডে বাড়তি ৫০০টির বেশি recompute নিতে না পারলে ন্যূনতম কত jitter লাগবে, আর আপনি কত বেছে নেবেন?

    (ক) ৬০০তম সেকেন্ডে ৫০,০০০টি key একসাথে মরে, পরের অনুরোধগুলো এক সেকেন্ডে ডেটাবেসে; আবার একসাথে লেখা হলে প্রতি ১০ মিনিটে একই ঢেউ।

    (খ) জানালা = ২ × ০.০৫ × ৬০০ = ৬০ s; গড় = ৫০,০০০ ÷ ৬০ ≈ ৮৩৩ প্রতি সেকেন্ডে। অংশ ৫-এর কোডে KEYS, TTL আর J-এর তালিকা বদলে চালালে সর্বোচ্চ সেকেন্ডে ৮৯১.

    (গ) গড় ≤ ৫০০ চাইলে জানালা ≥ ৫০,০০০ ÷ ৫০০ = ১০০ s, অর্থাৎ ২ × J × ৬০০ ≥ ১০০, J ≥ ৮.৩৩%. কিন্তু ঠিক ৮.৩৩%-এ সর্বোচ্চ সেকেন্ড ৫৪৭ — সীমা পার। তাই ±১০% বা বেশি: জানালা ১২০ s, গড় প্রায় ৪১৭, সর্বোচ্চ ৪৫৮ — সীমার নিচে; ±১৫%-এ সর্বোচ্চ ৩৩৭. নিয়ম: গড় নয়, সর্বোচ্চটি ক্ষমতার নিচে রাখুন।

  4. পূর্বাভাস — stampede simulator, ২০০ ms recompute: অংশ ৭-এর simulator-এ শুধু DELTA = 0.2 করলে চারটি সারিতে (none, lock, stale, xfetch) recompute, অপেক্ষা, পুরোনো মান আর সর্বোচ্চ একসাথে query মোটামুটি কত হবে? চালানোর আগে হিসাব লিখুন, তারপর চালিয়ে মেলান।

    পূর্বাভাস: পাঁচ দফা মেয়াদ, প্রতি দফায় দেয়ালে λ × Δ = ২,০০০ × ০.২ = ৪০০টি অনুরোধ। none: প্রায় ২,০০০ recompute আর ২,০০০ অপেক্ষা, একসাথে চারশোর ঘরে query. lock: ৫টি recompute, প্রায় ২,০০০ অপেক্ষা, প্রত্যেকে গড়ে প্রায় ১০০ ms করে। stale: ৫টি, অপেক্ষা ০, প্রায় ২,০০০ পুরোনো মান। xfetch: অপেক্ষা ০; নবায়ন শুরু মেয়াদের প্রায় ০.২ × ln ৪০০ ≈ ১.২ s আগে; দফাপ্রতি গড়ে ১ + (e − ১) ≈ ২.৭টি, মোট ১৪-এর আশপাশে।

    আসল আউটপুট:

    recompute 200 ms, TTL 10 s, 2000 req/s for 60 s
    mode     DB recomputes   waited  served  peak DB
                          at expiry   stale  parallel
    none             1972     1972       0       416
    lock                5     1995       0         1
    stale               5        0    1995         1
    xfetch             17        0       0         9

    none-এ ১,৯৭২ আর একসাথে ৪১৬ — ৫০ ms-এর প্রায় চার গুণ। lock আর stale-এ ৫টি recompute, ১,৯৯৫ জন যথাক্রমে অপেক্ষায় আর পুরোনো মানে। xfetch-এ ১৭টি, অপেক্ষা ০ — পূর্বাভাসের কাছে, এলোমেলোতার ভেতরে; কিন্তু একসাথে সর্বোচ্চ ৯টি, অর্থাৎ এক দফায় অনেকে প্রায় একসাথে সিদ্ধান্ত নিয়েছেন। শিক্ষা: Δ বাড়লে সুরক্ষাহীন নকশার ক্ষতি আর lock-এর অপেক্ষা দুটোই বাড়ে; XFetch নিজে থেকে আগে শুরু করে।

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

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

ক্রমএকটি hot key-এর মেয়াদ শেষে single-flight lock-এর ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. Hot key-এর মেয়াদ শেষ; প্রথম অনুরোধ miss করে
  2. সে মেয়াদসহ একটি lock নেয় (যেমন SET … NX PX)
  3. বাকিরা lock ধরা দেখে অপেক্ষা করেন বা পুরোনো মান পান
  4. Lock-ধারী ডেটাবেস থেকে মান এনে TTL-সহ cache-এ লেখে
  5. নিজের token মিলিয়ে lock ছাড়ে; অপেক্ষারত সবাই নতুন মান পান

একটি অনলাইন দোকানে পণ্যের দাম ডেটাবেসে বদলানো হলো, কিন্তু cache-এ পুরোনো দাম রয়ে গেছে আর ক্রেতারা সেটিই দেখছেন। কোন সমস্যার সমাধান দরকার?

  • Eviction — cache-এর জায়গা বাড়ানো
  • Invalidation — বদলের খবর cache পর্যন্ত পৌঁছানো
  • Compression — মান ছোট করে রাখা
  • ডেটাবেসের আরও replica যোগ করা

LRU cache-এ get আর put দুটোই O(1) রাখতে সাধারণত কোন জোড়া লাগে?

  • Sorted array আর binary search
  • শেষ ব্যবহারের সময় দিয়ে সাজানো min-heap
  • Hash map আর doubly linked list
  • শুধু একটি singly linked list

প্রতি রাতে একটি report job লক্ষাধিক key একবার করে পড়ে, আর পরদিন সকালে cache-এর hit ratio অনেক নেমে যায়। Cache-এর আকার না বাড়িয়ে কোন বদল এই ক্ষতি সবচেয়ে ভালো ঠেকায়?

  • সাধারণ LRU রেখে দেওয়া
  • LRU বদলে FIFO চালু করা
  • SLRU বা W-TinyLFU-এর মতো scan-সহনশীল policy
  • সব key-এর TTL ছোট করা

XFetch-এ একটি entry-র Δ = ৫০ ms, β = ১, আর মেয়াদ শেষ হতে বাকি ১০০ ms. এই মুহূর্তের একটি অনুরোধ আগেভাগে recompute করার সম্ভাবনা প্রায় কত?

  • প্রায় ১.৮%
  • প্রায় ১৩.৫%
  • প্রায় ৫০%
  • ১০০% — মেয়াদ কাছে, তাই নিশ্চিত

১০,০০০টি key একই মুহূর্তে লেখা হলো, TTL ৬০০ s ± ১০% (সমবণ্টিত)। গড়ে প্রতি সেকেন্ডে প্রায় কতটি key-এর মেয়াদ শেষ হবে?

  • প্রায় ১০,০০০
  • প্রায় ১,৬৬৭
  • প্রায় ৮৩
  • প্রায় ১৭

একটি স্বয়ংক্রিয় client প্রতিবার নতুন, এলোমেলো, অস্তিত্বহীন পণ্য-id দিয়ে খুঁজছে; প্রতিটি অনুরোধ ডেটাবেস পর্যন্ত যাচ্ছে। কোনটি সবচেয়ে কার্যকর প্রতিরক্ষা?

  • সব key-এর TTL বাড়ানো
  • শুধু negative caching — “নেই” উত্তর ৬০ s cache করা
  • LRU বদলে LFU চালু করা
  • বৈধ id-এর Bloom filter, সাথে ইনপুট যাচাই আর rate limit

শব্দকোষ

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

মনে রাখুন

  1. Eviction জায়গার প্রশ্ন (ভুলে শুধু ধীর), TTL সময়ের, invalidation বদলের (ভুলে উত্তরই ভুল)।
  2. LRU = hash map + doubly linked list, প্রতিটি কাজ O(1); scan-এ LRU, জনপ্রিয়তার বদলে LFU ভাঙে; SLRU, ARC, W-TinyLFU দুটো সংকেত মেলায়, আর OPT নিজের trace-এ সীমা দেখায়।
  3. TTL-এ jitter: গড় N ÷ ২JT, কিন্তু সর্বোচ্চ সেকেন্ডটি ক্ষমতার নিচে রাখুন; sliding TTL-এর সাথে সর্বোচ্চ বয়স, আর soft TTL-এ মেয়াদ পেরোলেও কেউ অপেক্ষা করেন না।
  4. Invalidation: শুধু TTL, লেখার পরে মোছা, event (pub/sub, CDC), versioned key, tag — বার্তা হারায়, দুবার আসে, উল্টো ক্রমে আসে; তাই মোছার বার্তা, version আর TTL-এর জাল।
  5. Stampede-এ λ × Δ query; প্রতিকার single-flight lock, soft TTL, XFetch (t − Δβ·ln rand ≥ expiry) আর pre-warming; penetration-এ negative cache ও Bloom filter, avalanche-এ jitter, redundancy আর বিকল্প পথ।

পরের পর্বে: এতক্ষণ একটি cache-এর ভেতরে ছিলাম। এবার cache নিজেই বহু সার্ভারে ছড়ানো — key কীভাবে node-এ ভাগ হয়, node বিকল হলে কী হয়, একটি hot key একটি node-কে কীভাবে ডোবায়, আর local থেকে distributed পর্যন্ত বহু-স্তরের cache.

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

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