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

Cache — Redis, cache-aside আর invalidation

Caching with Redis — Cache-Aside, Invalidation and Failure Modes

Taught in Bangla, this lesson explains cache layers, measures first to see when a cache helps, Redis keys, strings, hashes, sorted sets and TTL, cache-aside for product pages, key naming, versions, jitter and what not to cache, invalidation and a measured stale-data race fixed with a version guard, cache stampedes with locks, single-flight and stale-while-revalidate, failing open when Redis is down, memory, eviction and in-process LRU caches.

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

  • কোন স্তরে cache বসবে, আর cache আদৌ লাগবে কি না, তা মেপে ও হিসাব করে ঠিক করতে
  • Redis-এর string, hash, sorted set আর TTL আসল client দিয়ে ব্যবহার করতে
  • Key-এর নাম, version আর jitter-সহ TTL দিয়ে cache-aside বানাতে
  • লেখার পরে invalidation, read-write race আর stampede সামলাতে
  • Redis বন্ধ হলেও সাইট চালু রাখতে, আর eviction ও মেমরির খরচ বিচার করতে

১ · Cache কেন, আর কোন স্তরে

বাজারঘরের একটি পণ্যের পাতা খুলতে ডেটাবেসকে কয়েকটি কাজ করতে হয়: পণ্যের তথ্য, রেটিংয়ের হিসাব, শেষ পাঁচটি রিভিউ, আর ওই category-র সেরা চারটি পণ্য। একই পাতা দিনে হাজারবার খোলে, অথচ ডেটা বদলায় দিনে দুবার। প্রতিবার সব নতুন করে হিসাব করা অপচয়।

মুদি দোকানের কথা ভাবুন। চাল, ডাল আর লবণ দোকানি হাতের কাছে রাখেন, আর বাকি মাল থাকে পেছনের গুদামে। ক্রেতা চাইলে হাতের কাছে পেলে সঙ্গে সঙ্গে দেন, না পেলে গুদামে যান। ফেরার সময় কিছু মাল সামনে এনে রাখেন। Cache হলো ওই হাতের কাছের তাক। তাকে যা আছে তা গুদামের কপি, আসল হিসাব নয়। তাই তাকের দাম লেখা কার্ড পুরোনো হতে পারে, আর এই পর্বের বেশিরভাগ সমস্যা সেখান থেকেই আসে।

তাক একটি নয়, কয়েকটি। একটি অনুরোধ ক্রেতার ফোন থেকে ডেটাবেস পর্যন্ত যাওয়ার পথে অন্তত পাঁচ জায়গায় থামতে পারে।

Cache-এর পাঁচটি স্তর

Each layer answers if it can, and passes the request down if it cannot

Browser থেকে ডেটাবেস পর্যন্ত cache-এর পাঁচটি স্তর Browser cache CDN App মেমরি Redis PostgreSQL একজন ক্রেতার · Cache-Control সবার জন্য একই public ডেটা একটি process · সবচেয়ে দ্রুত সব server-এর · এই পর্বের বিষয় সত্যের উৎস · সবচেয়ে ধীর
ছবি ৪৪.১ — যে স্তরে উত্তর মেলে, নিচের স্তরগুলোর খরচ বেঁচে যায়। ওপরের স্তরের ডেটা বদলানো সবচেয়ে কঠিন।

ওপরের দুটি স্তর চলে HTTP দিয়ে। পর্ব ৩৩-এ (Status code আর error format) আমরা ETag আর 304 দেখেছি, আর Cache-Control header বলে দেয় কে কতক্ষণ জমাতে পারবে। Browser-এর ভেতরের server state cache পর্ব ১৯-এ (TanStack Query) আছে। এই পর্ব server-এর ভেতরের দুই স্তর নিয়ে। পুরো সিস্টেমের হিসাবে caching-এর তত্ত্ব আছে Caching কৌশল পর্বে। এখানে আমরা সেটি বাজারঘরে বসাব, চালাব আর ভাঙব।

মূল ধারণা

Cache একটি বিনিময়: আপনি তরতাজা ডেটার নিশ্চয়তা দিয়ে গতি আর ডেটাবেসের স্বস্তি কেনেন। তাই প্রতিটি cache-এর সাথে একটি প্রশ্ন আসে: এই ডেটা কত সেকেন্ড পুরোনো হলে ব্যবসার ক্ষতি নেই?

২ · আগে মাপুন, তারপর cache

Cache যোগ করলে নতুন একটি server, নতুন ব্যর্থতা আর পুরোনো ডেটার ঝুঁকি আসে। তাই প্রথম কাজ মাপা। এই পর্বের সব পরীক্ষা চলেছে একটি আসল ল্যাবে: Express 5.3.0, PostgreSQL 18.6 (npm-এর বহনযোগ্য embedded-postgres), Redis 8.10.3 আর Node.js 24.15. ডেটায় আছে ৫০,০০০ পণ্য আর ১০ লাখ রিভিউ।

প্রথমে একটি সস্তা পাতা: সব query index-এ চলে। একই ক্রমে ২০,০০০ অনুরোধ, একটির পর একটি, প্রথমে cache ছাড়া, পরে cache-সহ। জনপ্রিয় পণ্য বেশি আসে, বাস্তবের মতো (Zipf বণ্টন)।

সত্যিকারের run · একটি local কম্পিউটারে একবার · সব একই যন্ত্রে, network নেই · সস্তা পাতা · ~/bazarghar · উত্তর ছাঁটা

NO CACHE   db calls 20000 avg 0.244 ms  p50 0.215 ms  p95 0.371 ms
CACHE      db calls 6670  hits 13330  misses 6670  rate 66.7%
  hit  avg 0.100 ms  p50 0.097 ms
  miss avg 0.508 ms  p50 0.487 ms
  all  avg 0.236 ms  p50 0.109 ms  p95 0.571 ms
keyspace_hits:13330  keyspace_misses:6670

ফলটি অনেককে অবাক করে। গড় সময় প্রায় বদলায়নি, আর p95 উল্টো খারাপ হয়েছে। কেন? Cache miss-এ তিনটি কাজ হয়: Redis দেখা, ডেটাবেসে যাওয়া, Redis-এ লেখা। তাই miss (০.৫০৮ ms) cache ছাড়া পড়ার (০.২৪৪ ms) দ্বিগুণের বেশি। Cache hit-এর লাভ এই ক্ষতি কোনোমতে পুষিয়েছে।

সমতার বিন্দু — একটি পূর্ণ numerical

ধরুন h হলো hit rate, G মানে Redis থেকে পড়া, L মানে ডেটাবেস থেকে আনা, S মানে Redis-এ লেখা। Hit-এ শুধু G লাগে, miss-এ G + L + S.

গড় সময় = G + (1 − h)(L + S)
Cache লাভজনক যখন h > (G + S) / (L + S)

মাপা মান বসাই: G = ০.১০০, L = ০.২৪৪, আর S = ০.৫০৮ − ০.১০০ − ০.২৪৪ = ০.১৬৪ ms. সমতার বিন্দু h = (০.১০০ + ০.১৬৪) / (০.২৪৪ + ০.১৬৪) = ০.২৬৪ / ০.৪০৮ ≈ ৬৪.৭%. আমাদের hit rate ছিল ৬৬.৭%, মানে সীমার ঠিক ওপরে। সূত্রে গড় = ০.১০০ + ০.৩৩৩ × ০.৪০৮ ≈ ০.২৩৬ ms, যা মাপা ফলের সাথে মিলে গেল।

এবার ভারী পাতা। “এই category-র সেরা ৪টি পণ্য” বের করতে প্রায় ১.২৫ লাখ রিভিউ join করে গড় নিতে হয়। এবার আসল Express সার্ভারে, ১৬টি সংযোগ একসাথে, ৬,০০০ অনুরোধ।

পাতাReq/sডেটাবেসে পাতা আনাp50p95
সস্তা, cache ছাড়া566760002.558 ms4.544 ms
সস্তা, cache-সহ689526181.767 ms4.794 ms
ভারী, cache ছাড়া916000175.453 ms219.526 ms
ভারী, cache-সহ21026231.000 ms205.287 ms

সত্যিকারের run · একটি local কম্পিউটারে একবার · cache খালি থেকে শুরু, তাই hit rate ৫৬% · এটি সাধারণ ফল নয়

ভারী পাতায় throughput দ্বিগুণের বেশি, আর মধ্যম ক্রেতার সময় ১৭৫ গুণ কম। p95 প্রায় একই থাকল, কারণ এখনো অনেক miss আছে। সময় যত গেল, cache ভরল। একই বণ্টনে পরপর চারটি দফায় ডেটাবেসের কাজ কমেছে:

সত্যিকারের run · ভারী পাতা · প্রতি দফায় ৬,০০০টি নতুন অনুরোধ · TTL ৬০০ সেকেন্ড · উত্তর ছাঁটা

pass 1: 195 req/s hit 3379 miss 2621 rate 56.3% avg 81.851 ms p50 1.407 ms
pass 2: 273 req/s hit 4096 miss 1904 rate 68.3% avg 58.375 ms p50 0.930 ms
pass 3: 325 req/s hit 4343 miss 1657 rate 72.4% avg 48.996 ms p50 0.738 ms
pass 4: 369 req/s hit 4494 miss 1506 rate 74.9% avg 43.192 ms p50 0.696 ms

দুটি সতর্কতা। এখানে ডেটাবেস আর app একই যন্ত্রে, তাই network-এর খরচ শূন্যের কাছে। আলাদা server-এ ডেটাবেসের প্রতিটি যাওয়া-আসায় খরচ বাড়ে, আর Redis-এরও বাড়ে। আর ধীর query পেলে cache-এর আগে সেটি ঠিক করার চেষ্টা করুন। পর্ব ৪২-এ (N+1 সমস্যা) query মাপা দেখেছেন, আর Performance tuning পর্বে আছে index-এর পথ। Cache ধীর query লুকিয়ে রাখে, সারায় না।

৩ · Redis: key, string, hash, sorted set আর TTL

Redis হলো একটি সার্ভার, যা ডেটা মেমরিতে রাখে আর network-এ command শোনে। Salvatore Sanfilippo (antirez) এটি ২০০৯ সালে প্রকাশ করেন। প্রতিটি মানের একটি নাম থাকে, আর সেই নাম দিয়েই মান পাওয়া যায়। হোটেলের রিসেপশনের চাবির বোর্ডের মতো: ঘরের নম্বর জানলে এক হাতেই চাবি মেলে, পুরো হোটেল খুঁজতে হয় না।

এই ল্যাবে Docker ছাড়াই চলেছে Redis 8.10.3-এর একটি Windows build (redis-windows নামের community প্রকল্পের, প্রকাশিত SHA-256 মিলিয়ে নেওয়া)। এটি Redis-এর সরকারি build নয়। Linux-এ আপনার distribution-এর package বা Docker Compose বেশি প্রচলিত পথ, যদিও এই ল্যাবে সেগুলো চালানো হয়নি।

সত্যিকারের run · Redis 8.10.3 · redis-cli · > দিয়ে শুরু লাইন command, বাকিগুলো Redis-এর উত্তর · কিছু লাইন বাদ

$ redis-server --port 38441 --bind 127.0.0.1 --save "" --appendonly no

> SET shop:v1:product:7 "{\"id\":7,\"price\":320}" EX 300
OK
> GET shop:v1:product:7
"{\"id\":7,\"price\":320}"
> TTL shop:v1:product:7
(integer) 300
> HSET cart:u42 sku-7 2 sku-9 1
(integer) 2
> HINCRBY cart:u42 sku-7 1
(integer) 3
> HGETALL cart:u42
1) "sku-7"
2) "3"
3) "sku-9"
4) "1"
> ZADD top:sold 150 sku-7 90 sku-9 210 sku-3
(integer) 3
> ZINCRBY top:sold 5 sku-9
"95"
> ZREVRANGE top:sold 0 1 WITHSCORES
1) "sku-3"
2) "210"
3) "sku-7"
4) "150"
> TTL no:such:key
(integer) -2

String

একটি নামের নিচে একটি মান। JSON রাখার সবচেয়ে সহজ পথ, আর পাতা cache-এর জন্য এটিই সাধারণ পছন্দ।

SET, GET, INCR

Hash

একটি key-এর ভেতরে field-এর জোড়া। পুরোটা না পড়ে একটি field বদলানো যায়, যেমন cart-এর এক পণ্যের সংখ্যা।

HSET, HGETALL, HINCRBY

Sorted set

প্রতি সদস্যের একটি score, আর সব সময় score অনুযায়ী সাজানো। বেশি বিক্রির তালিকার জন্য একদম উপযুক্ত।

ZADD, ZINCRBY, ZREVRANGE

TTL (Time To Live) মানে কত সেকেন্ড পরে key নিজে মুছে যাবে। TTL command-এর উত্তরে দুটি বিশেষ মান আছে: -1 মানে key আছে কিন্তু মেয়াদ নেই, আর -2 মানে key-ই নেই। Cache-এ TTL ছাড়া key রাখা ভুল, কারণ invalidation কখনো ফসকালে সেই ভুল মান চিরকাল থাকে। TTL হলো শেষ রক্ষাকবচ।

আসল client: redis

Node.js-এ দুটি জনপ্রিয় package আছে: redis (node-redis, লেখার সময়ে 6.3.1) আর ioredis (6.0.0). Redis-এর docs node-redis-কে সুপারিশ করে, আর ioredis-কে পুরোনো কিন্তু সমর্থিত client বলে। এই পর্বে node-redis চলেছে।

import { createClient } from 'redis';

export const redis = createClient({ url: process.env.REDIS_URL });

// এই listener না থাকলে সংযোগের error-এ process পড়ে যায়
redis.on('error', (err) => log.warn({ err }, 'redis error'));

await redis.connect();

Redis-এর আরও দুটি কাজ: session আর rate limit

একই সার্ভার শুধু cache নয়। পর্ব ৪৫-এ session-ভিত্তিক login-এর store হিসেবে Redis আসবে, আর পর্ব ৪৯-এ rate limit. Counter-এর ধরনটি দেখে নিন, কারণ এর একটি ফাঁদ আছে।

সত্যিকারের run · Redis 8.10.3 · প্রতি IP-তে ৬০ সেকেন্ডের counter · 1.2.3.4 একটি উদাহরণ ঠিকানা

> SET ratelimit:1.2.3.4 0 EX 60 NX
OK
> INCR ratelimit:1.2.3.4
(integer) 1
> INCR ratelimit:1.2.3.4
(integer) 2
> TTL ratelimit:1.2.3.4
(integer) 60
> SET sess:abc123 "{\"userId\":42}" EX 1800
OK
> EXPIRE sess:abc123 1800
(integer) 1

INCR একা ব্যবহার করে পরে আলাদা EXPIRE দিলে মাঝখানে process পড়ে গেলে counter চিরকাল থেকে যায়। SET … EX 60 NX আগে ডাকলে সেই ফাঁক থাকে না, কারণ মান আর মেয়াদ একই command-এ বসে। Session-এ প্রতি অনুরোধে EXPIRE দিলে মেয়াদ এগিয়ে যায়। সতর্ক থাকুন: session আর rate limit-এ ডেটা হারালে ফল আলাদা। Cache হারালে শুধু ধীর হয়, session হারালে ক্রেতা logout হন। তাই এই দুই কাজে eviction আর persistence নিয়ে আলাদা সিদ্ধান্ত লাগে।

লাইসেন্সের ইতিহাস (হালনাগাদ যাচাই করে নিন): Redis 7.2 পর্যন্ত BSD লাইসেন্সে ছিল। ২০২৪ সালের মার্চে Redis Ltd. পরের সংস্করণ RSALv2 আর SSPLv1-এ নিয়ে যায়, যা OSI-স্বীকৃত open source নয়। কয়েক দিনের মধ্যে Linux Foundation Redis 7.2.4 থেকে Valkey fork ঘোষণা করে, BSD লাইসেন্সে। পরে Redis 8 (২০২৫) AGPLv3-কে তৃতীয় বিকল্প হিসেবে যোগ করে। লেখার সময়ে Redis 8.10.3 আর Valkey 9.1.3 চলছে। Valkey শুরু থেকে Redis-এর command-এর সাথে মিল রেখে চলছে, তবে দুটি প্রকল্প আলাদা পথে এগোচ্ছে, আর এই পর্বের run Valkey-তে করা হয়নি। নিজের ব্যবহারে কোন লাইসেন্স প্রযোজ্য, তা সরকারি পাতা পড়ে, প্রয়োজনে আইনজীবীর সাথে কথা বলে ঠিক করুন।

৪ · Cache-aside: বাজারঘরের পণ্যের পাতা

সবচেয়ে প্রচলিত কৌশলটির নাম cache-aside. Cache পাশে বসে থাকে, আর সব সিদ্ধান্ত নেয় আপনার কোড। Redis ডেটাবেসকে চেনেই না। নিচে একই পণ্য দুবার চাওয়া হলো। ব্যবহৃত সময় আসল run থেকে।

  1. ExpressGET /api/products/7অনুরোধ এলো
  2. RedisGETআগে এখানে
  3. PostgreSQLSELECTনা পেলে এখানে
  4. RedisSET … EXপরের জনের জন্য
Expressঅনুরোধ
GET /api/products/7
key বানানো হলো: shop:v1:product:7
Redismiss
> GET shop:v1:product:7
(nil)
PostgreSQLপাতার সব query
পণ্য + রেটিং + শেষ ৫ রিভিউ + সেরা ৪ পণ্য
পুরো উত্তর JSON-এ: 1155 byte
Redisজমা
> SET shop:v1:product:7 '{…}' EX 300
OK
উত্তর: X-Cache: MISS  173.0 ms
দ্বিতীয় অনুরোধhit
> GET shop:v1:product:7
"{\"id\":7, …}"
উত্তর: X-Cache: HIT  3.0 ms
  1. অনুরোধ। ক্রেতা পণ্য ৭ খুললেন। Express প্রথমে id থেকে shop:v1:product:7 নামটি বানাল।
  2. Redis দেখা। GET-এর উত্তর (nil), মানে key নেই। এটি একটি miss, আর এটি ভুল নয়, স্বাভাবিক ঘটনা।
  3. ডেটাবেস। এখন আসল কাজ: পাতার সব query চলল, আর ১,১৫৫ byte-এর উত্তর তৈরি হলো। সময়ের বড় অংশ এখানেই।
  4. জমা রাখা। ক্রেতাকে দেওয়ার আগে উত্তরটি EX 300 দিয়ে Redis-এ গেল। মোট সময় ১৭৩.০ ms, আর header-এ X-Cache: MISS.
  5. পরের অনুরোধ। এবার GET নিজেই JSON ফেরাল, ডেটাবেসে যেতে হলো না। মোট ৩.০ ms, প্রায় ৫৮ গুণ কম। পরের ৫ মিনিট এই পণ্যের সবাই এই পথেই যাবেন।

আসলে চলা কোডের মূল অংশ এটুকুই। id আগেই সংখ্যা হিসেবে যাচাই করা, পর্ব ২৭-এর (Request যাচাই) মতো।

const keyOf = (id) => `shop:v1:product:${id}`;
const TTL = 300;
const ttl = () => Math.round(TTL * (0.9 + Math.random() * 0.2)); // ±10% jitter

async function getProduct(id) {
  const k = keyOf(id);
  const c = await cacheGet(k);                // অংশ ৮-এ এর ভেতরটা দেখব
  if (c !== null) return { src: 'HIT', body: JSON.parse(c) };

  const v = await loadProductPage(id);        // ডেটাবেস
  if (v) await cacheSet(k, JSON.stringify(v), ttl());
  return { src: 'MISS', body: v };
}

app.get('/api/products/:id', async (req, res) => {
  const id = Number(req.params.id);
  if (!Number.isInteger(id) || id < 1) return res.status(400).json({ error: 'bad id' });
  const { src, body } = await getProduct(id);
  res.set('X-Cache', src);
  if (!body) return res.status(404).json({ error: 'not found' });
  res.json(body);
});

সত্যিকারের run · Express 5.3.0, Redis 8.10.3, PostgreSQL 18.6 · একটি local কম্পিউটারে একবার

GET /api/products/7  200  X-Cache: MISS  173.0 ms  1155 byte
GET /api/products/7  200  X-Cache: HIT   4.3 ms  1155 byte
GET /api/products/7  200  X-Cache: HIT   3.0 ms  1155 byte
GET /api/products/8  200  X-Cache: MISS  72.9 ms  1151 byte

X-Cache একটি নিজের বানানো header. এটি ক্রেতার জন্য নয়, আপনার জন্য: log আর metric-এ hit rate দেখা যায়। Redis নিজেও গোনে: INFO stats-এর keyspace_hits আর keyspace_misses. আগের run-এ এই দুটি ছিল 13330 আর 6670, যা আমাদের নিজের গণনার সাথে হুবহু মিলেছে।

নিচের ছোট মডেলে একটি Map হলো নকল Redis, আর ঘড়িটিও নকল, তাই ৩০০ সেকেন্ড অপেক্ষা করতে হয় না। এটি শেখানোর মডেল, আসল Redis নয়। চালিয়ে দেখুন t=121-এর লাইনটি।

// শেখানোর মডেল: Map হলো নকল Redis, আর now একটি নকল ঘড়ি (সেকেন্ড)
let now = 0;
const store = new Map();
const redis = {
  get(k) {
    const e = store.get(k);
    if (!e) return null;
    if (e.exp <= now) { store.delete(k); return null; }   // TTL শেষ
    return e.v;
  },
  set(k, v, ttl) { store.set(k, { v, exp: now + ttl }); },
  del(k) { store.delete(k); },
};
const db = { 7: { price: 320 }, 9: { price: 150 } };
let hits = 0, misses = 0;
function getProduct(id) {
  const k = `shop:v1:product:${id}`;
  const cached = redis.get(k);
  if (cached !== null) { hits++; return { src: 'HIT ', ...JSON.parse(cached) }; }
  misses++;
  const row = db[id];                    // ডেটাবেস থেকে পড়া
  redis.set(k, JSON.stringify(row), 300);
  return { src: 'MISS', ...row };
}
function show(t, id) {
  now = t;
  const p = getProduct(id);
  console.log(`t=${String(t).padStart(3)}  id=${id}  ${p.src}  price=${p.price}`);
}
show(0, 7); show(10, 7); show(11, 9); show(120, 7);
db[7].price = 350;                       // দাম বদলাল, cache জানে না
show(121, 7);
redis.del('shop:v1:product:7');          // invalidation
show(122, 7); show(423, 7);              // ৩০১ সেকেন্ড পরে TTL শেষ
console.log(`hits=${hits} misses=${misses} rate=${(100 * hits / (hits + misses)).toFixed(1)}%`);

t=121 id=7 HIT price=320 লাইনটি লক্ষ করুন। ডেটাবেসে দাম ৩৫০ হয়ে গেছে, কিন্তু cache এখনো ৩২০ বলছে। এটিই stale data. del-এর পর t=122-এ MISS হয়ে ৩৫০ এলো। আর t=423-এ TTL শেষ, তাই আবার MISS. শেষ লাইন hits=3 misses=4 rate=42.9%: ছোট নমুনায় hit rate কম, কারণ প্রতিটি নতুন key-এর প্রথম পড়া অবশ্যই miss.

৫ · Key-এর নাম, version, jitter আর যা cache করা যাবে না

Key-এর নাম ঠিক করে দুটি জিনিস: কে কার ডেটা পাবে, আর পরে সব মোছা যাবে কীভাবে। আমাদের cache key-এর গঠন:

shop : v1 : product : 7
অ্যাপ : ডেটার আকারের version : ধরন : id

এখানে v1 মানে JSON-এর আকার। ধরুন নতুন release-এ price সংখ্যা থেকে { amount, currency } হলো। পুরোনো key-গুলো নতুন কোড পড়লে ভাঙবে। Rolling deploy-এর সময় পুরোনো আর নতুন server একসাথে চলে, তাই FLUSHDB করলেও সমস্যা থাকে, কারণ পুরোনো server আবার পুরোনো আকার লিখবে। সমাধান সহজ: নতুন কোড v2 ব্যবহার করবে। দুটি আকার আলাদা নামে থাকে, আর v1-এর key TTL শেষে নিজেই মরে। এ ছাড়া নামে উত্তর যা যা-র ওপর নির্ভর করে তার সবই থাকতে হবে: ভাষা, মুদ্রা, sort, page.

যা একই key-তে রাখা যাবে না

এই নিয়ম ভাঙলে ফল শুধু ধীর নয়, ভয়ংকর। নিচে ইচ্ছা করে লেখা একটি ভুল: /api/bad/me সবার জন্য একটিই key ব্যবহার করে।

সত্যিকারের run · ইচ্ছাকৃত ভুল · ফোন নম্বর কল্পিত

X-User: rahim -> MISS {"user":"rahim","phone":"017xx-xxx-111"}
X-User: karim -> HIT {"user":"rahim","phone":"017xx-xxx-111"}

করিম রহিমের ফোন নম্বর পেলেন। কোনো error নেই, কোনো log নেই। Authentication ঠিক ছিল, আর authorization-ও, কিন্তু cache দুটোর পরে বসে আরেকজনের উত্তর ফিরিয়েছে। ব্যক্তিগত ডেটায় key-তে user id লাগবেই, যেমন shop:v1:cart:u42, আর ওপরের স্তরে Cache-Control: private বা no-store. একই ভুল CDN-এ হলে ক্ষতি আরও বড় (পর্ব ৮৪-এ আসবে)।

ডেটাসিদ্ধান্তকারণ
পণ্যের পাতা, category, সেরা তালিকাহ্যাঁ, TTL-সহঅনেকে পড়েন, কম বদলায়, কয়েক সেকেন্ড পুরোনো চলে
ক্রেতার cart, প্রোফাইলসাবধানে, user id key-তেভুল key মানে অন্যের ডেটা ফাঁস
দেখানোর জন্য স্টকের আনুমানিক সংখ্যাখুব ছোট TTL“প্রায় ১০টি আছে” দেখাতে পুরোনো চলে
Order-এর সময় স্টক কমানোনাসিদ্ধান্ত নেবে ডেটাবেসের transaction (পর্ব ৪৩)
Wallet balance, পেমেন্টের অবস্থানাপুরোনো মানে আসল টাকার ক্ষতি
Permission, roleখুব সাবধানেপুরোনো মান মানে সরানো অধিকার এখনো চলছে

TTL-এ jitter

রাতে একটি job ১,০০০টি পণ্য cache-এ ভরল, সবার TTL ৩০০ সেকেন্ড। ঠিক ৩০০ সেকেন্ড পরে সবকটি একসাথে মরবে, আর সব অনুরোধ একই মুহূর্তে ডেটাবেসে ছুটবে। অফিস ছুটির সময় একসাথে সব লিফটে ভিড়ের মতো। Jitter মানে TTL-এ একটু এলোমেলো অংশ যোগ করা, যাতে মেয়াদ ছড়িয়ে পড়ে। আমাদের কোডের ttl() ঠিক এই কাজই করে।

// শেখানোর মডেল: ১০০০টি key একই মুহূর্তে লেখা, ঘড়ি নকল, PRNG বীজ-নির্ভর
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 KEYS = 1000, TTL = 300;
function peak(jitterPct) {
  const rnd = mulberry32(44);
  const perWindow = new Map();                  // ১০ সেকেন্ডের ঘর
  for (let i = 0; i < KEYS; i++) {
    const spread = TTL * jitterPct / 100;
    const ttl = TTL - spread + rnd() * 2 * spread;
    const slot = Math.floor(ttl / 10) * 10;
    perWindow.set(slot, (perWindow.get(slot) ?? 0) + 1);
  }
  const slots = [...perWindow.entries()].sort((a, b) => a[0] - b[0]);
  const top = Math.max(...slots.map(([, n]) => n));
  return { windows: slots.length, top, first: slots[0][0], last: slots.at(-1)[0] };
}
for (const j of [0, 5, 10, 20]) {
  const r = peak(j);
  console.log(`jitter +-${String(j).padStart(2)}%  ` +
    `expiry from ${r.first}s to ${r.last + 9}s  ` +
    `peak ${String(r.top).padStart(4)} keys in 10 s  ` +
    `about ${(r.top / 10).toFixed(0)} reloads/s`);
}

Jitter ছাড়া ১,০০০টি key-ই একটি ১০ সেকেন্ডের ঘরে: peak 1000 keys in 10 s, মানে প্রায় ১০০ reload/s. ±১০%-এ চূড়া নেমে এলো 180-এ, ±২০%-এ 103-এ। হিসাবটি সহজ: ±২০% মানে ১২০ সেকেন্ডে ছড়ানো, মানে ১২টি ঘর, ঘরপ্রতি গড়ে প্রায় ৮৩টি। এলোমেলোর জন্য চূড়া গড়ের একটু ওপরে থাকে। মূল্য হলো মেয়াদ এখন একটু অনিশ্চিত, যা cache-এ সাধারণত সমস্যা নয়।

৬ · Invalidation: delete, update আর race

বিক্রেতা দাম বদলালেন। ডেটাবেস ঠিক হলো, কিন্তু Redis-এ পুরোনো কপি রয়ে গেল। তিনটি পথ আছে: TTL শেষ হওয়া পর্যন্ত অপেক্ষা, cache-এ নতুন মান লেখা, অথবা key মুছে দেওয়া। সিদ্ধান্তের ভিত্তি পরিষ্কার করতে আসল পরীক্ষা চালানো হলো। Invalidation-এর সাধারণ পছন্দ হলো ডেটাবেসে লেখার পরে DEL.

app.patch('/api/products/:id/price', async (req, res) => {
  const id = Number(req.params.id), price = Number(req.body.price);
  await pool.query(
    'UPDATE products SET price=$1, version=version+1 WHERE id=$2', [price, id]);
  await redis.del(keyOf(id));   // commit-এর পরে, আগে নয়
  res.status(204).end();
});

এখানে যাচাই আর authorization সংক্ষেপের জন্য বাদ, বাস্তবে পর্ব ২৭ আর পর্ব ৪৮-এর মতো লাগবে। কেন SET নয়? দুইজন বিক্রেতা প্রায় একসাথে দাম বদলালে ডেটাবেসে এক ক্রমে আর cache-এ উল্টো ক্রমে লেখা পৌঁছাতে পারে। আর DEL-এ ক্রম গুরুত্বপূর্ণ নয়: যতবারই মুছুন, ফল একই। এ ছাড়া পাতার মান বানাতে পুরো পাতা আবার হিসাব করতে হতো, যা হয়তো কেউ পড়বেনই না।

তবুও একটি race বাকি থাকে

একটি reader cache miss পেয়ে ডেটাবেস থেকে পুরোনো দাম পড়ল। সে Redis-এ লেখার আগেই একজন writer দাম বদলে key মুছল। এবার reader পুরোনো দাম লিখে দিল। এটি একটি race condition. নিচের সময় আসল run থেকে, যেখানে reader-এর পথে ১০০ ms-এর একটি বিরতি ইচ্ছা করে বসানো (যেমন garbage collection-এর থামা)।

1reader: GET → nil2reader: SELECT → 5003reader: (থেমে আছে)4writer: UPDATE → 5205writer: DEL6reader: SET 500 ← পুরোনো7ক্রেতা: GET → 500
এই মুহূর্তের মান
সময়
ডেটাবেসের দাম
Cache-এর দাম
Reader-এর হাতে
  1. Reader miss পেল। Key নেই, তাই সে ডেটাবেসে যাবে। এখন পর্যন্ত সব স্বাভাবিক।
  2. Reader পুরোনো দাম পড়ল। ডেটাবেসে এখন ৫০০, তাই এটি সঠিক। কিন্তু মানটি এখন শুধু reader-এর মেমরিতে আছে, আর সে থেমে গেল।
  3. Writer লিখল। দাম ৫২০, commit হয়ে গেছে। ডেটাবেস এখন সত্যি ৫২০ বলে।
  4. Writer key মুছল। নিয়ম মেনেই। কিন্তু মোছার মতো কিছু ছিল না, তাই এই DEL-এর কোনো প্রভাব পড়ল না।
  5. Reader পুরোনো দাম লিখল। সে জানে না কিছু বদলেছে। Cache-এ বসল ৫০০, আর পূর্ণ ৩০০ সেকেন্ডের TTL.
  6. ক্ষতি। কেউ আর কিছু করবেন না, তবু ৩০০ সেকেন্ড ধরে সবাই ৫০০ দেখবেন। TTL না থাকলে এটি চিরকাল থাকত। তাই TTL শেষ রক্ষাকবচ।

“এটি তো কৃত্রিম বিরতি দিয়ে ঘটানো” ভাবছেন? তাই এবার কোনো বিরতি ছাড়াই পরীক্ষা হলো। প্রতি দফায় key মোছা হলো, তারপর ৩টি reader আর ১টি writer একসাথে ছাড়া হলো, আর শেষে cache আর ডেটাবেস মেলানো হলো।

সত্যিকারের run · আসল Redis আর PostgreSQL · একটি local কম্পিউটারে একবার · ইচ্ছা করে race-প্রবণ পরীক্ষা, সাধারণ ট্রাফিকের হার নয়

UPDATE then DEL                      rounds 4000  stale left in cache 139  (3.48%)
DEL then UPDATE (wrong order)        rounds 4000  stale left in cache 2101  (52.52%)
UPDATE then DEL + version guard      rounds 4000  stale left in cache 0  (0.00%)

আগের একটি run-এ একই পরীক্ষায় ১৬১ আর ২১১০ এসেছিল, তাই সংখ্যাগুলো নয়, মাত্রাটি মনে রাখুন। ক্রম উল্টালে প্রায় অর্ধেক দফায় পুরোনো মান থেকে যায়, কারণ মোছা আর commit-এর মাঝে যেকোনো reader পুরোনো দাম এনে বসাতে পারে। দুই writer-এর তুলনাও হলো, যেখানে লেখা আর cache-এর মাঝে ১ থেকে ৫ ms-এর এলোমেলো বিরতি আছে:

সত্যিকারের run · একটি local কম্পিউটারে একবার · ১ থেকে ৫ ms-এর বিরতি ইচ্ছা করে বসানো

two writers, UPDATE then SET   rounds 1000  cache differs from database 378  (37.8%)
two writers, UPDATE then DEL   rounds 1000  cache differs from database 0    (0.0%)

Version guard

পণ্যের সারিতে version কলাম আছে, যা প্রতিটি UPDATE-এ বাড়ে। Writer আলাদা একটি key-তে সবচেয়ে নতুন version লিখে রাখে, আর reader cache-এ লেখার আগে মিলিয়ে দেখে। মিলিয়ে দেখা আর লেখা একসাথে হতে হবে, নইলে আবার race. তাই Lua script, যা Redis-এ অবিচ্ছিন্নভাবে চলে।

// reader: নিজের version পুরোনো হলে cache-এ লিখবে না
const GUARD_SET = `local v = redis.call('GET', KEYS[2])
  if v and tonumber(v) > tonumber(ARGV[2]) then return 0 end
  redis.call('SET', KEYS[1], ARGV[1], 'EX', 300)
  return 1`;

// writer: commit-এর পরে নতুন version লিখে key মোছে
const GUARD_BUMP = `local v = redis.call('GET', KEYS[2])
  if (not v) or tonumber(ARGV[1]) > tonumber(v) then
    redis.call('SET', KEYS[2], ARGV[1], 'EX', 3600)
  end
  redis.call('DEL', KEYS[1])
  return 1`;

await redis.eval(GUARD_SET, { keys: [k, vk], arguments: [json, String(v.version)] });

৪,০০০ দফায় ০ পাওয়া প্রমাণ নয়, কিন্তু যুক্তিটি আলাদা করে খাটে। Reader পুরোনো version নিয়ে দুই অবস্থায় আসতে পারে। Writer version লেখার আগে এলে তার মান writer-এর DEL-এ মুছে যাবে। পরে এলে guard তাকে আটকাবে। তবে এটি একটি বাড়তি জটিলতা। পণ্যের পাতায় ছোট TTL আর UPDATE-এর পরে DEL প্রায়ই যথেষ্ট। যেখানে পুরোনো মানের দাম বড়, সেখানে এই guard ভাবুন, আর সবচেয়ে বড় ক্ষেত্রে cache বাদ দিন। Version key-এর TTL যেন পাতার TTL-এর চেয়ে লম্বা থাকে।

লেখার অন্য দুই কৌশল। Write-through-এ প্রতিটি লেখা একই ধাপে cache আর ডেটাবেসে যায়। পড়া সব সময় তরতাজা থাকার কাছাকাছি, কিন্তু লেখা ধীর হয়, আর কখনো পড়া হবে না এমন ডেটাও cache-এ ঢোকে। Write-behind-এ লেখা আগে cache-এ যায়, পরে পেছনে ডেটাবেসে। খুব দ্রুত, কিন্তু Redis পড়ে গেলে লেখা হারাতে পারে, তাই order বা টাকায় নয়। বিস্তারিত Caching কৌশল পর্বে।

৭ · Cache stampede: lock, single-flight আর stale-while-revalidate

ঈদের অফারের একটি পণ্য। সেকেন্ডে শত শত অনুরোধ, সব hit, ডেটাবেস আরামে। হঠাৎ key-এর TTL শেষ। সেই মুহূর্তে যত অনুরোধ এলো, সবাই miss পেল, আর সবাই একই ভারী query চালাল। একে বলে cache stampede. দোকানের সামনের তাক খালি দেখে সব ক্রেতা একসাথে গুদামে ঢুকে পড়ার মতো।

এবার সত্যিই ঘটানো যাক। দুটি Express instance, প্রতিটিতে ৩০টি একসাথে চলা client, একটি hot key, TTL ২ সেকেন্ড, মোট ৮ সেকেন্ড।

কৌশলমোট অনুরোধডেটাবেসে পাতা আনাp99সবচেয়ে ধীর
শুধু cache-aside10275524011.4 ms692.0 ms
Single-flight, প্রতি process-এ12954789.2 ms263.9 ms
Redis lock, সব instance-এ13117949.0 ms249.2 ms
Stale-while-revalidate119683639.6 ms664.8 ms

সত্যিকারের run · একটি local কম্পিউটারে একবার · ৮ সেকেন্ডে key ৪ বার ভরা হয়েছে · এটি সাধারণ ফল নয়

৮ সেকেন্ডে ২ সেকেন্ডের TTL মানে ৪ বার ভরা। শুধু cache-aside-এ প্রতিবার ৬০টি client-ই ডেটাবেসে গেছে: ৪ × ৬০ = ২৪০. দরকার ছিল ৪টি। ডেটা ছোট হওয়ায় এই ল্যাব টিকে গেছে, তবু সবচেয়ে ধীর অনুরোধ ৬৯২ ms. সত্যিকারের ভারী query আর ব্যস্ত ডেটাবেসে এই ঢেউ cascading failure শুরু করতে পারে।

Single-flight একটি process-এর ভেতরের সমাধান: একই key-এর জন্য চলতে থাকা কাজ থাকলে নতুন কাজ শুরু না করে সেই Promise-এ অপেক্ষা। Map দিয়ে নকল Redis বানিয়ে ১০০টি অনুরোধে দেখা যাক।

// শেখানোর মডেল: Map-এ নকল Redis, setTimeout-এ ১০ ms-এর নকল ডেটাবেস
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function makeApp(useSingleFlight) {
  const cache = new Map(), inflight = new Map();
  const count = { dbReads: 0, maxAtOnce: 0, running: 0 };
  async function loadFromDb(id) {
    count.dbReads++; count.running++;
    count.maxAtOnce = Math.max(count.maxAtOnce, count.running);
    await sleep(10);
    count.running--;
    return { id, price: 320 };
  }
  async function get(id) {
    if (cache.has(id)) return cache.get(id);
    if (!useSingleFlight) {
      const v = await loadFromDb(id); cache.set(id, v); return v;
    }
    let p = inflight.get(id);
    if (!p) {                         // প্রথমজনই শুধু ডেটাবেসে যাবে
      p = loadFromDb(id).then((v) => { cache.set(id, v); return v; })
        .finally(() => inflight.delete(id));
      inflight.set(id, p);
    }
    return p;                         // বাকিরা একই Promise-এ অপেক্ষা করে
  }
  return { get, count };
}
(async () => {
  for (const sf of [false, true]) {
    const app = makeApp(sf);
    const out = await Promise.all(Array.from({ length: 100 }, () => app.get(7)));
    const c = app.count;
    console.log(`single-flight=${String(sf).padEnd(5)} answers=${out.length} dbReads=${c.dbReads} maxAtOnce=${c.maxAtOnce}`);
  }
})();

dbReads=100 থেকে dbReads=1. উত্তর দুই ক্ষেত্রেই ১০০টি, আর maxAtOnce বলছে একসাথে ডেটাবেসে কতজন ছিল। .finally অংশটি জরুরি: এটি না থাকলে ব্যর্থ বা পুরোনো Promise চিরকাল Map-এ থেকে যেত। কিন্তু মাপা টেবিলে single-flight-এ এসেছিল ৮, ৪ নয়। কারণ এই Map একটি process-এর, তাই ২টি instance × ৪ বার = ৮. Instance যত, ডেটাবেসে ঢেউ তত।

সব instance-এ একজনকে বাছতে Redis-এই একটি distributed lock রাখা যায়। SET … NX PX একটি আণবিক command: key না থাকলেই বসে, মেয়াদসহ।

1TTL শেষ: key মুছে গেল2A, B, C: GET → nil3A, B, C: SET lock NX4A: ডেটাবেস থেকে আনছে5B, C: 50 ms পরে GET6A: SET key, lock খোলা7B, C: GET → মান
এই মুহূর্তের মান
Cache
Lock
ডেটাবেসে পাতা আনা
অপেক্ষায়
  1. মেয়াদ শেষ। জনপ্রিয় key-টি মুছে গেল। তিনটি অনুরোধ একই মুহূর্তে এলো, ধরুন দুটি আলাদা server-এ।
  2. সবাই miss পেল। এখান পর্যন্ত আগের মতোই। সবাই জানে ডেটা নেই।
  3. Lock-এর দৌড়। তিনজনই SET … NX পাঠাল। Redis command একটির পর একটি চালায়, তাই শুধু A পেল OK, বাকিরা nil. মান হিসেবে A নিজের uuid রাখে।
  4. A কাজ করছে। ডেটাবেসে যাচ্ছে শুধু A. B আর C ডেটাবেসকে না ছুঁয়ে ৫০ ms পরপর cache দেখছে। ডেটাবেসে পাতা আনা হলো ১ বার।
  5. A শেষ করল। মান cache-এ বসল, আর A lock খুলল। খোলার সময় Lua দিয়ে মিলিয়ে দেখে lock-টি এখনো তার নিজের কি না, যাতে মেয়াদ শেষে অন্যের lock মুছে না যায়।
  6. বাকিরা পেল। B আর C পরের চেষ্টাতেই মান পেল, X-Cache: WAIT. তিনজনের জন্য ডেটাবেস ১ বার। মাপা run-এ ঠিক এটিই হয়েছে: ২৪০ থেকে ৪।
const UNLOCK = `if redis.call('GET',KEYS[1])==ARGV[1]
  then return redis.call('DEL',KEYS[1]) end return 0`;

async function withLock(id) {
  const k = keyOf(id), lk = `lock:${k}`, me = randomUUID();
  const c = await cacheGet(k);
  if (c !== null) return { src: 'HIT', body: JSON.parse(c) };

  let got = null;
  try { got = await redis.set(lk, me, { NX: true, PX: 2000 }); }
  catch { return { src: 'MISS', body: await loadProductPage(id) }; } // Redis নেই

  if (got === 'OK') {
    try {
      const v = await loadProductPage(id);
      if (v) await cacheSet(k, JSON.stringify(v), ttl());
      return { src: 'MISS', body: v };
    } finally {
      await redis.eval(UNLOCK, { keys: [lk], arguments: [me] }).catch(() => {});
    }
  }
  for (let i = 0; i < 40; i++) {            // প্রায় ২ সেকেন্ড, তারপর হাল ছাড়া
    await sleep(50);
    const again = await cacheGet(k);
    if (again !== null) return { src: 'WAIT', body: JSON.parse(again) };
  }
  return { src: 'MISS', body: await loadProductPage(id) };
}

এখানে lock শুধু একটি অপ্টিমাইজেশন। মেয়াদের চেয়ে কাজ বেশি সময় নিলে দুজন একসাথে ডেটাবেসে যেতে পারে, কিন্তু তাতে ক্ষতি শুধু বাড়তি একটি query. তাই এটি ঠিক আছে। টাকা বা স্টকের মতো নিরাপত্তার কাজে এই ধরনের lock-এর ওপর ভরসা করা যায় না (ভাবনার প্রশ্ন ০৪ দেখুন)।

Stale-while-revalidate

অপেক্ষা না করিয়ে পুরোনো মান দিয়ে দিলে কেমন হয়? Stale-while-revalidate-এ মানের ভেতরে একটি নরম মেয়াদ থাকে, আর Redis-এর আসল TTL তার চেয়ে অনেক লম্বা। নরম মেয়াদ পার হলে অনুরোধ সঙ্গে সঙ্গে পুরোনো মান পায়, আর একজনই পেছনে নতুন করে আনে। আমাদের run-এ "STALE":2622 অনুরোধ অপেক্ষা ছাড়াই উত্তর পেয়েছে, ডেটাবেসে refresh হয়েছে মাত্র ৩ বার।

তাহলে ৬৩ কেন? কারণ শুরুতে cache খালি ছিল, কিছুই পুরোনো নেই, তাই ৬০টি client-ই miss করেছে। SWR cold cache-এর stampede ঠেকায় না, শুধু মেয়াদ ফুরানোরটি ঠেকায়। এই পথে দাম কিছুক্ষণ পুরোনো দেখাবে। এটি মেনে নেওয়া যায় কি না, ব্যবসার সিদ্ধান্ত। শুরুর ফাঁক ঢাকতে SWR-এর সাথে lock মেলানো যায়।

Negative caching

কেউ এমন পণ্য চাইলেন, যা নেই। Cache-এ থাকে না, তাই প্রতিবার ডেটাবেস। “নেই” উত্তরটিকেও অল্প সময় জমা রাখলে এই খরচ কমে। আমরা 'null' লিখেছি ৩০ সেকেন্ডের TTL-এ। TTL ছোট রাখুন, কারণ পণ্যটি একটু পরেই তৈরি হতে পারে।

সত্যিকারের run · ৮টি সংযোগ একসাথে · একটি local কম্পিউটারে একবার

negative caching without: same missing id 1000 req -> 1000 page loads, p50 1.15 ms | 1000 different missing ids -> 1000 page loads
negative caching with   : same missing id 1000 req -> 8 page loads, p50 0.72 ms | 1000 different missing ids -> 1000 page loads

একই id-তে ১,০০০ থেকে ৮, আর ৮ হলো সেই ছোট stampede: ৮টি সংযোগই প্রথমে একসাথে miss করেছে। কিন্তু ডানের সংখ্যাটি দেখুন। প্রতিবার আলাদা নেই-এমন id পাঠালে negative caching কোনো কাজেই আসে না, উল্টো Redis-এ আবর্জনা জমে। ইচ্ছাকৃত এমন আক্রমণ ঠেকায় rate limit (API নিরাপত্তা), cache নয়।

৮ · Redis বন্ধ হলে: fail open, আসল run

Cache হলো গতি বাড়ানোর সহায়ক, দোকানের দরজা নয়। হাতের কাছের তাকটি ভেঙে পড়লে দোকান বন্ধ হয় না, দোকানি একটু বেশি হেঁটে গুদাম থেকে আনেন। সফটওয়্যারে এই আচরণের নাম fail open: cache-এর সমস্যা মানে miss, 500 নয়। এই ল্যাবে Redis process সত্যিই বন্ধ করে দেখা হলো। দুটি instance পাশাপাশি চলেছে: একটিতে client-এর ডিফল্ট সেটিং, অন্যটিতে নিচের সেটিং।

const redis = createClient({
  url: process.env.REDIS_URL,
  disableOfflineQueue: true,          // সংযোগ নেই? সঙ্গে সঙ্গে error
  commandOptions: { timeout: 50 },    // শুধু socket-এ না লেখা command-এ খাটে
  socket: {
    connectTimeout: 500,
    reconnectStrategy: (n) => Math.min(n * 100, 2000),
  },
});

function deadline(p, ms = 50) {       // নিজের সময়সীমা
  let t;
  const timer = new Promise((_, rej) => {
    t = setTimeout(() => rej(new Error('cache deadline')), ms);
  });
  return Promise.race([p, timer]).finally(() => clearTimeout(t));
}

async function cacheGet(k) {
  try { return await deadline(redis.get(k)); }
  catch { stat.cacheErr++; return null; }           // error মানে miss
}
async function cacheSet(k, v, ex) {
  try { await deadline(redis.set(k, v, { EX: ex })); }
  catch { stat.cacheErr++; }
}
অবস্থাডিফল্ট client, p50Fail open, p50Fail open-এর উত্তর
সব ঠিক, cache-এ আছে0.9 ms1.3 ms200, HIT
Redis ১.৫ সেকেন্ড থমকে (CLIENT PAUSE)1540.4 ms229.9 ms200, MISS
Redis process বন্ধ10118.7 ms90.4 ms৩০০-এ ৩০০টি 200

সত্যিকারের run · node-redis 6.3.1 · ৮টি সংযোগ একসাথে · ভারী পাতা · একটি local কম্পিউটারে একবার

ডিফল্ট client-এ Redis বন্ধ থাকলে প্রতিটি অনুরোধে প্রায় ১০ সেকেন্ড লেগেছে। কারণ দুটি জিনিস মিলে। সংযোগ না থাকলে command offline queue-তে জমে। আর node-redis-এর docs অনুযায়ী commandOptions.timeout-এর ডিফল্ট ৫,০০০ ms. GET-এ ৫ সেকেন্ড আর SET-এ ৫ সেকেন্ড, মোট ১০. উত্তর শেষে 200 ঠিকই এসেছে, কিন্তু ক্রেতা ততক্ষণে চলে গেছেন।

একটি ফাঁদ, যেটিতে এই ল্যাবও পড়েছিল। প্রথমে আমরা commandOptions: { timeout: 50 } দিয়ে ভেবেছিলাম কাজ শেষ। কিন্তু CLIENT PAUSE-এ সময় ১৫৪০ ms-ই রইল। node-redis-এর docs বলে, এই timeout শুধু সেই command-এ খাটে, যা এখনো socket-এ লেখা হয়নি। সংযোগ খোলা আর Redis চুপ থাকলে command আগেই লেখা হয়ে গেছে, তাই timeout কাজ করে না। তাই উপরে নিজের deadline() লেগেছে। এটি থমকে থাকা অনুরোধকে কেবল ছেড়ে দেয়, পাঠানো command বাতিল করে না, আর Redis ফিরলে তার উত্তর এসে ফেলে দেওয়া হয়। শিক্ষাটি হলো: option-এর নাম দেখে নয়, ব্যর্থতা ঘটিয়ে আচরণ দেখে ভরসা করুন।

Redis বন্ধ থাকার সময় ৩০০ অনুরোধে cacheErr এসেছে ৬০০ (GET আর SET দুটোই), আর ডেটাবেসে গেছে ৩০০টিই। সব উত্তর 200. এবার দেখা যাক Redis ফিরলে কী হয়।

সত্যিকারের run · Redis আবার চালু, মেমরি খালি · app restart ছাড়াই আবার সংযোগ

Redis back, cache empty
safe first  300 {"200/MISS":132,"200/HIT":168} p50 1.2 ms max 141.2 ms
safe second 300 {"200/HIT":300} p50 1.0 ms max 4.6 ms
safe stats {"db":132,"hit":468,"miss":132,"cacheErr":0,"wait":0,"stale":0}

দুটি জিনিস লক্ষ করুন। এক, app নিজেই আবার সংযোগ করেছে, কেউ restart করেননি। দুই, Redis-এর মেমরি খালি হয়ে গেছে, কারণ আমরা persistence বন্ধ রেখেছিলাম, তাই আবার cold cache. এই ল্যাবে ৫০০টি পণ্যের মধ্যেই অনুরোধ ঘুরেছে, তাই দ্রুত সেরে উঠেছে। বড় সাইটে এই সময় ডেটাবেস পুরো ভার পায়। ধরুন ডেটাবেস সেকেন্ডে ৫০০টি query নিতে পারে, আর ট্রাফিক ২,০০০। Hit rate ৯০% হলে ডেটাবেসে যায় ২০০, স্বস্তি। Redis বন্ধ হলে যায় ২,০০০, মানে চারগুণ ভার। তখন fail open মানে ডেটাবেসও পড়ে যাওয়া। তাই বাস্তবে এর সাথে ডেটাবেসের সংযোগে সীমা, লোড কমানোর পথ বা circuit breaker লাগে। Fail open-এর কাজ দরজা খোলা রাখা, ভিড় সামলানো নয়।

Lock আর rate limit-এর ক্ষেত্রে: উপরের withLock-এ Redis না থাকলে সরাসরি ডেটাবেসে যায়, এটিও fail open. Rate limit-এ সিদ্ধান্ত আলাদা: Redis পড়লে সবাইকে ছেড়ে দেবেন, না সবাইকে আটকাবেন? Login-এর মতো সংবেদনশীল জায়গায় অনেকে আটকানোর দিকে যান, সাধারণ পড়ার API-তে ছেড়ে দেওয়ার দিকে। পর্ব ৪৯-এ এটি আসবে।

৯ · Memory, eviction আর in-process LRU

Redis মেমরিতে থাকে, আর মেমরি ডিস্কের চেয়ে দামি। প্রতি key-এর সাথে নিজের কিছু খরচ যোগ হয়। এখানে মেপে দেখা হলো:

সত্যিকারের run · Redis 8.10.3 · ১,০০০ byte মানের ১ লাখ key · used_memory-এর পার্থক্য

100000 keys, 1000 byte value, no TTL: 103.5 MiB, 1086 byte/key, MEMORY USAGE 1042
100000 keys, same, with TTL:          106.0 MiB, 1111 byte/key

মানে প্রতি key-এ মানের বাইরে প্রায় ৮৬ byte, আর TTL-এ আরও প্রায় ২৫ byte. হিসাব করা সহজ: আমাদের ১,১৫৫ byte-এর ৫০,০০০টি পাতা × (১,১৫৫ + ১১১) byte ≈ ৬ কোটি ৩৩ লাখ byte, প্রায় ৬০.৪ MiB. আপনার ডেটার আকারে নিজে মেপে নিন, কারণ এই সংখ্যা key-এর নাম আর মানের আকারে বদলায়।

মেমরি শেষ হলে কী হবে, তা ঠিক করে eviction policy. maxmemory 0 আর noeviction Redis-এর ডিফল্ট, মানে কিছু না বদলালে সীমাই নেই। সীমা বসালে আর policy না বদলালে লেখা ব্যর্থ হয়, আসল run-এ এই error:

OOM command not allowed when used memory > 'maxmemory'.

Cache-এর জন্য তাই maxmemory আর allkeys-lru ধরনের policy বসান। একই ৮ MB সীমায় ১ লাখ key লিখলে ৬,৭০৩টি থাকল, ৯৩,২৯৭টি সরল, আর কোনো error নেই। নিচে ১,২০,০০০ অনুরোধের Zipf-ধরনের পরীক্ষা, মূল্যায়ন প্রথম ৪০,০০০ অনুরোধের পরে:

PolicyMemoryHit rateKey আছেEvicted
allkeys-lru4 MB65.0%311939335
allkeys-lru8 MB75.2%688524588
allkeys-lru16 MB83.9%144589488
allkeys-lru32 MB86.2%221090
allkeys-lfu8 MB77.5%691022594
allkeys-random8 MB71.7%686227905
volatile-ttl8 MB71.9%687927997

সত্যিকারের run · একটি local কম্পিউটারে একবার · ১,০০০ byte মান · একটি বিশেষ ট্রাফিকের ফল, সব সাইটের নয়

৪ থেকে ৮ MB-তে hit rate ১০ পয়েন্ট বাড়ল, ১৬ থেকে ৩২ MB-তে মাত্র ২.৩. এটিই ট্রাফিকের দীর্ঘ লেজ: শুরুর মেমরি সবচেয়ে কাজে লাগে। ৩২ MB-তে কিছুই সরেনি, তবু ১০০% হয়নি, কারণ প্রথমবার দেখা key অবশ্যই miss. Policy-র তফাত এখানে মেমরির তফাতের চেয়ে কম। Redis-এর LRU নিখুঁত নয়, এটি কয়েকটি key নমুনা নিয়ে আনুমানিক কাজ করে। LFU গণনা রাখে, আর Redis 8.6 থেকে allkeys-lrm-ও আছে, যা শুধু লেখার সময় হিসাব করে। ভেতরের গঠন আছে Eviction আর invalidation পর্বে। JavaScript-এর Map-এ ছোট একটি LRU কিন্তু খুব সহজ:

// শেখানোর মডেল: Map ঢোকানোর ক্রম মনে রাখে, তাই প্রথম key-ই সবচেয়ে পুরোনো
class LRU {
  constructor(max) { this.max = max; this.map = new Map(); }
  get(k) {
    if (!this.map.has(k)) return undefined;
    const v = this.map.get(k);
    this.map.delete(k);          // মুছে আবার ঢোকানো: এখন সে সবচেয়ে নতুন
    this.map.set(k, v);
    return v;
  }
  set(k, v) {
    this.map.delete(k);
    this.map.set(k, v);
    if (this.map.size > this.max) {
      const oldest = this.map.keys().next().value;
      this.map.delete(oldest);
      console.log(`  evict ${oldest}`);
    }
  }
  keys() { return [...this.map.keys()].join(' '); }
}
const c = new LRU(3);
for (const [op, k] of [['set', 'A'], ['set', 'B'], ['set', 'C'], ['get', 'A'],
                       ['set', 'D'], ['get', 'B'], ['set', 'E']]) {
  const r = op === 'get' ? c.get(k) : c.set(k, k.toLowerCase());
  const note = op === 'get' ? (r === undefined ? ' -> miss' : ' -> hit') : '';
  console.log(`${op} ${k}${note}   [পুরোনো → নতুন] ${c.keys()}`);
}

get A -> hit-এর পরে ক্রম হলো B C A: A এখন সবচেয়ে নতুন। তাই set D-তে সবচেয়ে পুরোনো B সরল, A নয়। পরে get B -> miss. শেষে set E-তে C সরল। Map যেহেতু ঢোকানোর ক্রম মনে রাখে, মুছে আবার ঢোকানোই “ব্যবহার করা হলো” বোঝায়, আর প্রতিটি কাজ গড়ে O(1).

In-process LRU বনাম Redis, কয়েকটি instance-এ

Process-এর ভেতরের LRU দ্রুত, Redis লাগে না। তাহলে Redis কেন? দুটি instance চালিয়ে A-তে দাম ৭০০ থেকে ৭৫০ করা হলো।

সত্যিকারের run · দুটি Express instance · TTL ৬০ সেকেন্ড · lru মানে process-এর ভেতরের cache, aside মানে Redis · একটি local কম্পিউটারে একবার · উত্তর ছাঁটা

--- lru
PATCH price 750 through A
A 200 MISS price 750
B 200 HIT price 700
A {"HIT":4000}: p50 0.141 ms p99 0.538 ms

--- aside
PATCH price 750 through A
A 200 MISS price 750
B 200 HIT price 750
A {"HIT":4000}: p50 0.346 ms p99 0.802 ms

In-process cache-এ B পুরোনো দাম দেখাল, আর TTL শেষ না হওয়া পর্যন্ত দেখাবে, কারণ A-র মোছা B জানে না। Redis-এ সবাই একই কপি দেখে। আর দাম হলো প্রতি hit-এ network-এর একটি যাওয়া-আসা, এখানে HTTP-সহ মোট ০.১৪১ বনাম ০.৩৪৬ ms. In-process cache-এর প্রতিটি instance-এ নিজের কপি থাকে, restart-এ হারায়, আর Node.js-এর heap-এ চাপ দেয়।

প্রশ্নIn-process LRURedis
গতিদ্রুততম, network নেইএকটি network যাওয়া-আসা
Instance-এর মধ্যে মিলনেইএক কপি
Restart-এর পরেখালিথাকে, যদি Redis চালু থাকে
নতুন server লাগে?নাহ্যাঁ, আর তার পরিচালনা
মানানসই ডেটাপ্রায় অপরিবর্তিত: config, category তালিকা, খুব ছোট TTLযা বদলায়, আর সবার কাছে একই থাকা দরকার

একটি server হলে in-process LRU প্রায়ই যথেষ্ট। বড় সিস্টেমে দুটি মিলিয়ে দুই স্তরের cache হয়, যা Distributed cache পর্বে আছে।

আরও গভীরে: Facebook-এর ২০১৩ সালের NSDI প্রবন্ধ “Scaling Memcache at Facebook” (Nishtala ও সহলেখক) cache-aside-কে বিশাল মাপে বর্ণনা করে। সেখানে “lease” নামের একটি কৌশল miss-এর সময় একটি ছোট টোকেন দিয়ে পুরোনো লেখা আর stampede দুটিই কমায়, যা আমাদের version guard আর lock-এর কাছাকাছি চিন্তা। HTTP caching-এর মান RFC 9111-এ, আর stale-while-revalidate RFC 5861-এ। Vattani ও সহলেখকদের ২০১৫ সালের প্রবন্ধে lock ছাড়াই সম্ভাবনা দিয়ে আগেভাগে refresh করার একটি পদ্ধতি আছে।

পরীক্ষা ও ইন্টারভিউতে:

  • Cache-aside-এ লেখার পরে কী করবেন? ডেটাবেসে commit-এর পরে key DEL, আর TTL রাখা।
  • Hit rate ৯০% থেকে ৯৯% হলে ডেটাবেসের ভার কত কমে? Miss ১০% থেকে ১%, মানে ১০ ভাগের ১ ভাগ।
  • Stampede ঠেকাবেন কীভাবে? Single-flight, lock, stale-while-revalidate, আর TTL-এ jitter.
  • Redis বন্ধ হলে? Fail open: cache-কে miss ধরে ডেটাবেসে যাওয়া, ছোট timeout-সহ, আর ডেটাবেসের ভার সীমিত রাখা।
  • Eviction আর expiration-এর পার্থক্য? Expiration হলো TTL শেষ। Eviction হলো মেমরি ভরে যাওয়ায় সরানো।

১০ · AI-এর যুগে: cache-এর কোড যাচাই

AI সহজেই একটি “কাজ করা” cache লিখে দেয়। Demo-তে সব ঠিক থাকে, কারণ এই পর্বের ভুলগুলো ধরা পড়ে শুধু একসাথে অনেক অনুরোধে, লেখার সময়ে, বা Redis বন্ধ হলে। সাধারণ ভুলগুলো:

AI-এর ভুলযা ঘটবেযাচাই
পুরোনো client API, যেমন callback-এর ধরনVersion-এ না মেলা বা deprecated কোডনিজের version-এর docs আর npm view redis version
TTL ছাড়া SETভুল মান চিরকাল থাকেTTL key চালিয়ে -1 আসে কি না
req.user ছাড়াই keyএকজনের ডেটা আরেকজন পানদুই user দিয়ে একই URL চালানো
লেখার পরে invalidation নেইপুরোনো দামUpdate-এর পরপরই পড়ে দেখা
try/catch ছাড়া cache callRedis পড়লে পুরো সাইটে 500Redis বন্ধ করে চালানো
Timeout আছে বলে দাবিথমকে থাকা Redis-এ অনুরোধ আটকেCLIENT PAUSE দিয়ে মাপা
Production-এ KEYS shop:*পুরো keyspace হেঁটে Redis আটকে যেতে পারেএর বদলে SCAN বা versioned key
Redis-এর আসল URL বা password কোডেSecret ফাঁসEnvironment variable, আর কখনো AI-তে পেস্ট নয়

AI-কে কাজ দেওয়ার সময় ব্যর্থতার শর্তগুলোও বলে দিন। ফলে সে শুধু সুখের পথ লিখবে না, আর আপনি মেলানোর মতো পরীক্ষা পাবেন।

আমি Express 5 আর node-redis ব্যবহার করছি (version: [এখানে npm ls redis-এর ফল]). নিচের route-এ cache-aside যোগ করো। শর্ত: ১. Key-তে version থাকবে, আর উত্তর যা যা-র ওপর নির্ভর করে সব থাকবে। ২. প্রতিটি SET-এ jitter-সহ TTL. ৩. Redis সমস্যায় route 500 দেবে না, আর সর্বোচ্চ 50 ms অপেক্ষা করবে। ৪. Update route-এ commit-এর পরে DEL. ৫. নতুন package যোগ করবে না, যা নেই এমন option বানাবে না। লেখার পরে বলো: কোন কোন অবস্থায় পুরোনো ডেটা দেখা যেতে পারে, আর সেটি আমি কোন কমান্ডে পরীক্ষা করব। [এখানে route-এর কোড, কোনো secret ছাড়া]

AI-এর লেখা cache হাতে পেলে

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

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

প্র ০১Cache invalidation-কে কেন কম্পিউটার বিজ্ঞানের কঠিনতম কাজগুলোর একটি বলা হয়? এটি কি সত্যিই এত কঠিন?

একটি বহুল প্রচলিত রসিকতা, যা সাধারণত Phil Karlton-এর নামে বলা হয়: কম্পিউটার বিজ্ঞানে কঠিন কাজ দুটি, cache invalidation আর নাম দেওয়া। রসিকতার পেছনে সত্যটি হলো, একই ডেটার দুটি কপি আছে, আর দুটি জায়গায় একসাথে কিছু লেখার কোনো সাধারণ উপায় নেই।

ডেটাবেসে commit আর Redis-এ DEL দুটি আলাদা কাজ। এদের মাঝে process পড়ে যেতে পারে, network ভাঙতে পারে, বা অন্য একটি reader ঢুকে পড়তে পারে। এই পর্বের পরীক্ষায় সব নিয়ম মেনেও ৪,০০০ দফায় ১৩৯ বার পুরোনো মান থেকে গেছে। অর্থাৎ কোড ভুল ছিল না, নকশার মধ্যেই একটি ফাঁক আছে। Transaction দিয়ে এটি ঢাকা যায় না, কারণ Redis ডেটাবেসের transaction-এর অংশ নয়।

আরও কঠিন হলো নির্ভরতা চেনা। একটি পণ্যের দাম বদলালে শুধু সেই পণ্যের পাতা নয়, category পাতা, সেরা তালিকা, খোঁজের ফল আর হোমপেজও পুরোনো হয়। কোন ডেটা কোন cache-এ ঢুকেছে, তা কেউ একসাথে দেখতে পান না।

বড় সিস্টেম তাই নিখুঁত হওয়ার চেষ্টা ছেড়ে সীমা বাঁধে। Facebook তাদের ২০১৩ সালের প্রবন্ধে এই race-এর জন্য lease বর্ণনা করেছে। অনেক দল ডেটাবেসের change log পড়ে invalidation পাঠায়, যাতে app-এর ক্র্যাশে বার্তা না হারায়। আর সব জায়গায় TTL থাকে, শেষ রক্ষাকবচ হিসেবে।

মূল উপলব্ধি: লক্ষ্য “কখনো পুরোনো নয়” হতে পারে না। লক্ষ্য হলো “সবচেয়ে খারাপ অবস্থায় কতক্ষণ পুরোনো, আর তার ক্ষতি কত”, এই সংখ্যাটি জেনে মেনে নেওয়া।

প্র ০২২০১০ সালে Facebook-এর একটি বড় outage কীভাবে cache আর ডেটাবেসের মধ্যে আটকে যায়? Fail open-এর জন্য এর শিক্ষা কী?

২০১০ সালের ২৩ সেপ্টেম্বর Facebook দুই ঘণ্টার বেশি সময় অনেকের কাছে বন্ধ ছিল বলে খবরে আসে, আর প্রকৌশল দলের নিজের লেখায় (“More Details on Today's Outage”) কারণ জানানো হয়। সংক্ষেপে: একটি স্বয়ংক্রিয় ব্যবস্থা cache-এ থাকা configuration-এর মান যাচাই করত। মান ভুল মনে হলে ডেটাবেস থেকে ঠিক মান এনে বসাত। Cache-এর সাময়িক গোলমালে এটি চমৎকার কাজ করত।

সেদিন ডেটাবেসের কপিতেই এমন একটি মান গেল, যাকে এই ব্যবস্থা ভুল ভাবল। ফলে প্রতিটি client ভুল দেখে ডেটাবেসে গেল ঠিক করতে, আর ডেটাবেসেও ভুলই পেল। প্রকৌশলীদের বর্ণনা অনুযায়ী সেকেন্ডে কয়েক লাখ query-তে ডেটাবেস দ্রুত ভেঙে পড়ে। আরও খারাপ, ডেটাবেসের error-কেও client ভুল মান ধরে cache key মুছে দিত, ফলে ব্যর্থতা নিজেই আরও ভার তৈরি করল। শেষে সব ট্রাফিক থামিয়ে সাইট বন্ধ করে ধীরে ধীরে ফেরাতে হয়।

আমাদের cacheGet-এর সাথে মিলটি দেখুন: “error মানে miss, তাই ডেটাবেস থেকে আনা।” এই নিয়মটি একই ধরনের। পরিমাণ ঠিক থাকলে এটি সাইট বাঁচায়, আর পরিমাণ বেশি হলে ডেটাবেসকে মেরে ফেলে। আমাদের ল্যাবে Redis বন্ধ হলে ডেটাবেসে ৩০০-এ ৩০০টি অনুরোধই গেছে।

তাই fail open-এর সাথে ব্রেক লাগে: ডেটাবেসে একসাথে কত যাবে তার সীমা, সীমা পার হলে দ্রুত 503, circuit breaker, আর পুনরুদ্ধারের সময় ধীরে ট্রাফিক বাড়ানো। স্বয়ংক্রিয় মেরামত ব্যবস্থার নিজের ওপরও সীমা দরকার।

মূল উপলব্ধি: মেরামতের কোড সবচেয়ে খারাপ সময়ে সবচেয়ে বেশি চলে। তাই “error হলে আবার চেষ্টা” বা “ডেটাবেস থেকে আনা” ধরনের কোড লেখার সময় জিজ্ঞেস করুন, এক হাজার client একসাথে এটি করলে কী হবে।

প্র ০৩২০২৩ সালে ChatGPT-তে কেউ কেউ অন্যের চ্যাটের শিরোনাম দেখেছিলেন। Redis client-এর একটি বাগ এতে কী ভূমিকা রাখে, আর আমরা কী শিখব?

২০২৩ সালের ২০ মার্চের ঘটনা নিয়ে OpenAI একটি postmortem প্রকাশ করে। তাদের বর্ণনা অনুযায়ী, সমস্যাটি ছিল asyncio ভিত্তিক redis-py client-এ। একটি অনুরোধ queue-তে ঢোকার পরে কিন্তু উত্তর পড়ার আগে বাতিল হলে সংযোগটি নষ্ট অবস্থায় থেকে যেত। পরের, সম্পর্কহীন অনুরোধটি তখন আগের জনের জন্য আসা উত্তরটি পেতে পারত। ওই দিন তাদের একটি server পরিবর্তনে Redis অনুরোধ বাতিল হওয়া হঠাৎ বেড়ে যায়, তাই বাগটি সামনে আসে। OpenAI জানায়, কিছু ব্যবহারকারী অন্যের শিরোনাম দেখেন, আর ওই সময়ে সক্রিয় Plus গ্রাহকদের ১.২%-এর পেমেন্ট-সংক্রান্ত তথ্য প্রকাশ হয়ে থাকতে পারে।

মূল কারণটি বোঝা জরুরি। একটি Redis সংযোগে command আর উত্তর ক্রম মেনে চলে: প্রথম প্রশ্নের উত্তর প্রথমে আসে। উত্তরের গায়ে “এটি কার” লেখা থাকে না। তাই মাঝখান থেকে একটি অপেক্ষা ফেলে দিলে ক্রমের হিসাব গুলিয়ে যেতে পারে, যদি client সেটি ঠিকমতো না সামলায়।

আমাদের deadline()-এর দিকে তাকান। এটি অপেক্ষা ফেলে দেয়, কিন্তু client-এর ভেতরের command বাতিল করে না। command চলতে থাকে, উত্তর এলে client তাকে ঠিক জায়গায় মেলায়, আর আমরা ফলটি উপেক্ষা করি। ক্রম নষ্ট হয় না। এই পার্থক্যটি সূক্ষ্ম, তাই আপনার নিজের client-এর আচরণ যাচাই করুন, আর client সব সময় হালনাগাদ রাখুন।

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

মূল উপলব্ধি: Cache-এর ব্যর্থতা সব সময় ধীরগতি নয়। কখনো তা একজনের ডেটা আরেকজনের হাতে তুলে দেয়, কোনো error ছাড়াই। ব্যক্তিগত ডেটায় “cache বিশ্বাসযোগ্য” ধরে নেবেন না।

প্র ০৪Redis lock কি টাকা বা স্টকের মতো কাজে ভরসা করার মতো? Redlock বিতর্ক থেকে কী শেখার আছে?

Lock দুই কারণে নেওয়া হয়। দক্ষতার জন্য: একই কাজ দুবার না করা, যেমন stampede. Lock ফসকালে ক্ষতি একটি বাড়তি query. সঠিকতার জন্য: দুজন একসাথে করলে ডেটা নষ্ট হবে, যেমন শেষ পণ্যটি দুজনের কাছে বিক্রি। Lock ফসকালে ক্ষতি আসল টাকা।

২০১৬ সালে Martin Kleppmann তাঁর লেখা “How to do distributed locking”-এ Redis-এর একাধিক node-ভিত্তিক Redlock অ্যালগরিদমের সমালোচনা করেন। তাঁর উদাহরণটি সহজ: client ১ lock নিল, তারপর garbage collection-এ দীর্ঘক্ষণ থেমে গেল। এর মধ্যে lock-এর মেয়াদ শেষ, client ২ lock নিয়ে লিখে ফেলল। client ১ জেগে ভাবল lock তারই, আর পুরোনো মান লিখে দিল। লক্ষ করুন, এটি আমাদের read-write race-এরই আত্মীয়।

তাঁর প্রস্তাব fencing token: প্রতিবার lock দেওয়ার সময় একটি বাড়তে থাকা সংখ্যা, আর storage পুরোনো সংখ্যার লেখা ফিরিয়ে দেবে। Redis-এর স্রষ্টা antirez পাল্টা লেখায় Redlock-এর পক্ষে যুক্তি দেন, আর সময়ের অনুমান নিয়ে দুই পক্ষের মতভেদ আজও পুরোপুরি মেটেনি। তবে প্রায় সবাই একটি বিষয়ে একমত: দক্ষতার lock আর সঠিকতার lock আলাদা জিনিস।

সঠিকতার জন্য যেখানে ডেটা থাকে, সেখানেই রক্ষা করা ভালো। স্টক কমানো হোক শর্তসহ একটি UPDATE … WHERE stock >= 1 বা transaction দিয়ে, যা পর্ব ৪৩-এ (Application-এ transaction) দেখেছেন। আমাদের version guard-ও fencing token-এর মতোই একটি বাড়তে থাকা সংখ্যা। ডেটাবেস নিজেই শেষ সিদ্ধান্ত নিলে lock ব্যর্থ হলেও ডেটা নষ্ট হয় না।

মূল উপলব্ধি: নিজেকে জিজ্ঞেস করুন, lock দুজনকে একসাথে ঢুকতে দিলে কী হবে? উত্তর “একটু বাড়তি কাজ” হলে Redis lock ঠিক আছে। উত্তর “টাকার ক্ষতি” হলে শেষ পাহারাদার হবে ডেটাবেস।

প্র ০৫Cache কখন সহায়ক থেকে অপরিহার্য হয়ে যায়? এই বদল বিপজ্জনক কেন?

শুরুতে cache একটি বিলাস: না থাকলে সাইট একটু ধীর। তারপর ট্রাফিক বাড়ে। ডেটাবেসের ক্ষমতা আগের মতোই থাকে, কারণ cache তো ভারটি নিচ্ছে, আর কেউ টের পান না। একদিন দেখা যায়, ৯৫% hit rate-এ ডেটাবেস ৭০% ব্যস্ত। হিসাব করুন: miss ৫%-এ এই ভার, তাহলে cache ছাড়া ভার ২০ গুণ, মানে ক্ষমতার ১৪ গুণ। এই মুহূর্তে cache আর সহায়ক নয়, সে আপনার সিস্টেমের ভারবাহী দেয়াল। খাতায় কিন্তু এখনো “ঐচ্ছিক” লেখা।

এই পর্বের মাপা ফলেই ছোট করে এর ছবি আছে। Redis বন্ধ হলে ভারী পাতায় p50 ১ ms থেকে ৯০ ms-এ গেছে। তাও মাত্র ৮টি সংযোগে। আর ফিরে আসার পরে cache খালি থাকে, তাই পুনরুদ্ধারের সময়টিও বিপজ্জনক: Redis ফিরল, অথচ hit rate ০।

তিনটি প্রশ্ন নিয়মিত করুন। এক, cache পুরো খালি হলে ডেটাবেস কি টিকবে? এই পরীক্ষা production-এর আগে staging-এ চালান। দুই, cache ছাড়া কোন ধীর query লুকিয়ে আছে? Hit rate ভালো থাকলে ধীর query কারও চোখে পড়ে না, তাই ডেটাবেসের নিজের slow log দেখুন। তিন, deploy-এ v1 থেকে v2 হলে সব key একসাথে নতুন হয়, তাই সেটিও একটি ছোট cold cache. জনপ্রিয় key আগে ভরে নেওয়া (warming) বা ধীরে ট্রাফিক ছাড়া এখানে সাহায্য করে।

এটি বলে না যে cache খারাপ। বড় সাইট cache ছাড়া অর্থনৈতিকভাবে চলে না, আর সেটি সচেতন সিদ্ধান্ত হতে পারে। তখন Redis-কে replica, পর্যবেক্ষণ আর পরিকল্পনাসহ মূল উপাদান হিসেবে চালাতে হয়, যা পর্ব ৮৭-তে আবার আসবে।

মূল উপলব্ধি: সমস্যা cache-এর ওপর নির্ভর করা নয়। সমস্যা হলো না জেনে নির্ভর করা। Cache খালি করে আগেই দেখে নিন, আপনার সিস্টেম কতটা ভার বহন করে।

অনুশীলন

হিসাব, নকশা বিচার আর ফল আগে থেকে বলা, তিন রকম কাজ আছে।

  1. Numerical: একটি পাতায় Redis থেকে পড়তে ১ ms, ডেটাবেস থেকে আনতে ৪৯ ms, আর Redis-এ লিখতে ১ ms লাগে। ট্রাফিক সেকেন্ডে ১,০০০ অনুরোধ, hit rate ৯০%. (ক) গড় সময় কত? (খ) সমতার বিন্দু কত? (গ) ডেটাবেসে সেকেন্ডে কতটি অনুরোধ যায়? (ঘ) Hit rate ৯৯% হলে সেটি কত?

    (ক) গড় = G + (1 − h)(L + S) = ১ + ০.১০ × (৪৯ + ১) = ১ + ৫ = ৬ ms. Cache ছাড়া ৪৯ ms.

    (খ) h = (G + S) / (L + S) = (১ + ১) / (৪৯ + ১) = ২ / ৫০ = ৪%. ডেটাবেস ধীর হলে সামান্য hit rate-এই লাভ।

    (গ) ১,০০০ × ০.১০ = ১০০টি।

    (ঘ) ১,০০০ × ০.০১ = ১০টি। Hit rate ৯ পয়েন্ট বাড়ল, ডেটাবেসের ভার ১০ ভাগের ১ ভাগ হলো। তাই ৯০% থেকে ৯৯% যাওয়া অনেক দামি।

  2. Command: redis-cli-তে (যেকোনো Redis বা Valkey-তে) নিচের কাজটি করুন: views:p7 counter বানান, যা প্রথম INCR-এর পরে ৬০ সেকেন্ডে মুছে যাবে, আর পরের INCR-এ মেয়াদ বাড়বে না। কোন দুটি command, আর TTL দিয়ে কী দেখবেন?

    প্রতিবার আগে SET views:p7 0 EX 60 NX, তারপর INCR views:p7. NX থাকায় key থাকলে প্রথমটি কিছুই করে না, মেয়াদও বদলায় না। INCR মেয়াদ মোছে না। TTL views:p7 দিলে ৬০-এর নিচে নামতে থাকা একটি সংখ্যা পাবেন, সব INCR-এর পরেও। ৬০ সেকেন্ড পরে -2, মানে key নেই।

  3. নকশা বিচার: AI নিচের কোড দিল। অন্তত চারটি সমস্যা ধরুন।
    app.get('/api/cart', auth, async (req, res) => {
      const c = await redis.get('cart');
      if (c) return res.json(JSON.parse(c));
      const cart = await db.cart.find(req.user.id);
      await redis.set('cart', JSON.stringify(cart));
      res.json(cart);
    });
    • Key-তে user নেই: সবচেয়ে গুরুতর। একজনের cart আরেকজন পাবেন। হওয়া উচিত shop:v1:cart:u${id}.
    • TTL নেই: ভুল মান চিরকাল থাকবে।
    • Invalidation নেই: cart বদলালে DEL লাগবে।
    • Error সামলানো নেই: Redis পড়লে route 500 দেবে, বা ডিফল্ট timeout পর্যন্ত আটকে থাকবে।
    • নকশার প্রশ্ন: cart একজনেরই ডেটা, পড়া অল্প, লেখা ঘন ঘন। হয়তো এখানে cache দরকারই নেই। আগে মাপুন।
  4. ফল আগে বলুন: একটি LRU cache, সর্বোচ্চ ২টি key. ক্রম: set A, set B, get A, set C, get B, get A, set D. প্রতিটি get-এর ফল কী, আর শেষে cache-এ কী কী আছে?

    set A, set B: ক্রম A B. get A: hit, ক্রম B A. set C: সবচেয়ে পুরোনো B সরল, ক্রম A C. get B: miss. get A: hit, ক্রম C A. set D: C সরল, ক্রম A D. শেষে আছে A আর D.

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

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

ক্রমCache-aside-এ একটি miss-এর ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. অনুরোধ থেকে cache key বানানো
  2. GET দিয়ে Redis দেখা, উত্তর nil
  3. ডেটাবেস থেকে ডেটা আনা
  4. SET দিয়ে TTL-সহ Redis-এ রাখা
  5. ক্লায়েন্টকে উত্তর পাঠানো

Redis থেকে পড়তে ১ ms, ডেটাবেস থেকে আনতে ৯ ms আর Redis-এ লিখতে ১ ms লাগে। Hit rate ৮০% হলে cache-aside-এ গড় সময় কত?

  • ১ ms
  • ৩ ms
  • ২.২ ms
  • ৯ ms

Cache-aside-এ পণ্যের দাম বদলানোর পরে সাধারণত কোন ক্রমটি বেছে নেওয়া হয়?

  • আগে cache মুছে তারপর ডেটাবেসে লেখা
  • ডেটাবেসে লিখে তারপর cache-এ নতুন মান লেখা
  • ডেটাবেসে commit করে তারপর cache-এর key মুছে দেওয়া
  • কিছু না করে TTL-এর জন্য অপেক্ষা করা

দুটি Node.js instance-এ একটি জনপ্রিয় key-এর জন্য process-এর ভেতরে Map দিয়ে single-flight আছে। Key মুছে যাওয়ার পরে প্রতি instance-এ ৫০টি অনুরোধ একসাথে এলে ডেটাবেসে কতবার যাবে?

  • ১ বার
  • ৫০ বার
  • ১০০ বার
  • ২ বার

Redis-এ maxmemory সীমা বসানো হলো, কিন্তু maxmemory-policy রইল noeviction. মেমরি ভরে গেলে নতুন SET-এর কী হবে?

  • সবচেয়ে পুরোনো key সরে গিয়ে নতুনটি বসবে
  • OOM error আসবে, আর key লেখা হবে না
  • Redis নিজে restart হবে
  • মান ডিস্কে চলে যাবে

node-redis-এ commandOptions.timeout ৫০ ms বসানো আছে। Redis-এর সংযোগ খোলা, কিন্তু সার্ভারটি থমকে আছে, আর command socket-এ লেখা হয়ে গেছে। কী ঘটবে?

  • Timeout কাজ করবে না, Redis উত্তর দেওয়া পর্যন্ত অপেক্ষা চলতে পারে
  • ৫০ ms পরে command বাতিল হবে
  • Client নিজে থেকে ডেটাবেস থেকে পড়বে
  • Node.js process পড়ে যাবে

একটি API /api/me-র উত্তর cache:me নামের একটিই key-তে Redis-এ রাখে। সবচেয়ে বড় ঝুঁকি কোনটি?

  • Redis-এর মেমরি দ্রুত শেষ হবে
  • Hit rate খুব কম হবে
  • একজনের ব্যক্তিগত ডেটা অন্য ব্যবহারকারীরা পাবেন
  • ডেটাবেস ধীর হয়ে যাবে

শব্দকোষ

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

মনে রাখুন

  1. আগে মাপুন: সমতার বিন্দু h = (G + S) / (L + S). সস্তা query-তে cache লাভ নাও দিতে পারে।
  2. Cache-aside: GET, না পেলে ডেটাবেস, তারপর jitter-সহ TTL দিয়ে SET. Key-তে version আর উত্তর যা-র ওপর নির্ভর করে তার সব।
  3. লেখার পরে: commit, তারপর DEL. Race তবুও থাকে, TTL তাকে সীমিত করে, আর দরকারে version guard.
  4. Stampede: single-flight একটি process-এ, Redis lock সব instance-এ, stale-while-revalidate অপেক্ষা ছাড়া। Lock দক্ষতার জন্য, সঠিকতার জন্য নয়।
  5. Redis ঐচ্ছিক: error মানে miss, নিজের deadline-সহ। maxmemory আর eviction policy বসান। টাকার সিদ্ধান্ত cache-এ নয়।

পরের পর্বে: মডিউল ৬ শেষ। এবার নিরাপত্তা। পর্ব ৪৫-এ Session-ভিত্তিক login: cookie, store আর logout, যেখানে আজকের Redis আবার ফিরবে session store হিসেবে।

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

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