এই পর্বে যা শিখবেন
- Cache কেন — AMAT-ধাঁচের সূত্রে গড় latency আর ডেটাবেসের ভারের হিসাব, আর cache খালি হলে কী হয়
- কী cache করবেন আর কী নয় — বহু পঠিত ডেটা, ভারী হিসাব, রেন্ডার করা অংশ, session আর balance-এর মতো কড়া ডেটা
- Cache-aside, read-through, write-through, write-behind আর write-around — প্রতিটির পথ, লাভ আর দাম
- Cache-aside-এর race — কেন update নয় delete, কেন TTL, আর lease কীভাবে stale set বন্ধ করে
- Refresh-ahead, coalescing, negative caching, কৌশল বাছাইয়ের টেবিল, cache মাপার metric — আর AI-এর caching-পরামর্শ যাচাই
১ · Cache কেন — latency, ডেটাবেসের ভার আর খরচ
একটি কাল্পনিক অনলাইন বাজারের কথা ভাবুন। প্রতিটি পণ্যের পাতা খুলতে ডেটাবেসে কয়েকটি query: নাম, দাম, স্টক আর রিভিউর গড়। সাধারণ দিনে সব ঠিক। ঈদের সেলের সন্ধ্যায় সেকেন্ডে দশ হাজার পাতা — ডেটাবেসের CPU পুরো ব্যস্ত, প্রতিটি query ধীর, আর ধীর query মানে আরও বেশি অপেক্ষমাণ connection. অথচ খেয়াল করুন: বেশিরভাগ মানুষ দেখছেন সবচেয়ে জনপ্রিয় কয়েকশো পণ্য, আর প্রতিবার উত্তর হুবহু এক। একই উত্তর বারবার ডেটাবেস থেকে বানানোর দরকার কী?
সমাধান cache: বারবার লাগা ডেটার একটি কপি দ্রুত memory-তে, সাধারণত Redis বা Memcached-এর মতো একটি key-value store-এ। চাওয়া জিনিস cache-এ পেলে hit, না পেলে miss — তখন মূল উৎস, অর্থাৎ ডেটাবেস। CDN-এর cache দেখেছেন পর্ব ১৭-এ; এই মডিউলের বিষয় application-এর নিজের cache, সার্ভিস আর ডেটাবেসের মাঝখানে।
ব্যস্ত রেস্তোরাঁয় ওয়েটার প্রতিটি চায়ের অর্ডারে রান্নাঘরে যান না — কাউন্টারে ফ্লাস্কে চা তৈরি থাকে। ফ্লাস্ক থেকে ঢাললেই হলো (hit). ফ্লাস্ক খালি হলে রান্নাঘরে নতুন করে বানাতে হয় (miss), খদ্দেরকে একটু বেশি অপেক্ষা করতে হয়। কতক্ষণের পুরোনো চা চলবে, সেটি একটি নিয়ম (TTL); আর রাঁধুনি রেসিপি বদলালে ফ্লাস্কের পুরোনো চা না ফেললে খদ্দের পুরোনো স্বাদই পাবেন — এই পর্বের অর্ধেকটা ঠিক এই সমস্যা নিয়ে।
তিনটি লাভ — নকশার ভাষায়
Latency
Memory থেকে একটি key আনা network-সহ মোটামুটি এক মিলিসেকেন্ডের ঘরে বা কম; join বা হিসাবওয়ালা ডেটাবেস query দশ মিলিসেকেন্ড থেকে অনেক বেশি। সংখ্যাগুলো আন্দাজ — নিজের সিস্টেমে মাপুন।
দ্রুত উত্তরডেটাবেসের ভার
Hit ratio h হলে ডেটাবেস পায় মোট পড়ার শুধু (১ − h) অংশ। ৯০% hit মানে ডেটাবেসে দশ ভাগের এক ভাগ, আর ডেটাবেসের নিজের latency-ও তখন স্থির থাকে।
ভার কমানোখরচ
ডেটাবেস বড় করা বা replica বাড়ানো সাধারণত দামি আর ধীর কাজ; কয়েক GB memory-র cache অনেক ক্ষেত্রে সস্তা। তবে RAM-ও বিনা পয়সায় নয় — সব ডেটা নয়, শুধু বারবার লাগা অংশ, অর্থাৎ working set.
কম সার্ভারগড় latency-র সূত্র — CPU cache-এর AMAT-এর মতোই
প্রতিটি অনুরোধে আগে cache দেখা হয় (খরচ tc); miss হলে তার ওপর ডেটাবেসের খরচ tdb যোগ হয়। Hit ratio h আর সেকেন্ডে মোট পড়া R হলে:
ডেটাবেসের ভার = R × (১ − h)
এটি Computer Architecture-এর AMAT — hit time + miss rate × miss penalty — হুবহু একই সূত্র, শুধু nanosecond-এর জায়গায় millisecond; CPU cache-এর হিসাব Cache performance পর্বের অংশ ৩-এ। প্রচলিত ভুল: miss-এর খরচ শুধু tdb ধরা — miss-এও আগে cache দেখতে হয়েছে। ধরা যাক tc = ১ ms, tdb = ২০ ms, সেকেন্ডে ১০,০০০ পড়া, আর ডেটাবেস স্বস্তিতে সামলায় সেকেন্ডে ২,৫০০ query:
// Read path with a cache in front of a database. ASSUMED inputs: change them and run again.
const T_CACHE = 1; // ms for one cache lookup (paid on every request, hit or miss)
const T_DB = 20; // ms for one database query (paid only on a miss)
const RPS = 10000; // read requests per second
const DB_CAP = 2500; // queries per second the database serves comfortably
const col = (v, w) => String(v).padStart(w);
console.log(`no cache: every read costs ${T_DB} ms and the DB gets ${RPS} q/s`);
console.log(col("hit", 7) + col("avg ms", 8) + col("vs no cache", 13) + col("DB q/s", 8) + col("DB busy", 9));
for (const h of [0, 0.5, 0.75, 0.9, 0.95, 0.99, 0.999]) {
const avg = h * T_CACHE + (1 - h) * (T_CACHE + T_DB); // = T_CACHE + (1 - h) * T_DB
const dbq = RPS * (1 - h); // only misses reach the DB
console.log(col((h * 100).toFixed(1) + "%", 7) + col(avg.toFixed(2), 8) +
col((T_DB / avg).toFixed(2) + "x", 13) + col(dbq.toFixed(0), 8) +
col((100 * dbq / DB_CAP).toFixed(1) + "%", 9) + (dbq > DB_CAP ? " OVERLOADED" : ""));
}
const hMin = 1 - DB_CAP / RPS;
console.log(`\nDB keeps up only if hit ratio >= 1 - ${DB_CAP}/${RPS} = ${hMin.toFixed(2)}`);
console.log(`a miss still costs ${T_CACHE + T_DB} ms: with >1% misses, p99 is a miss`);আউটপুট:
no cache: every read costs 20 ms and the DB gets 10000 q/s
hit avg ms vs no cache DB q/s DB busy
0.0% 21.00 0.95x 10000 400.0% OVERLOADED
50.0% 11.00 1.82x 5000 200.0% OVERLOADED
75.0% 6.00 3.33x 2500 100.0%
90.0% 3.00 6.67x 1000 40.0%
95.0% 2.00 10.00x 500 20.0%
99.0% 1.20 16.67x 100 4.0%
99.9% 1.02 19.61x 10 0.4%
DB keeps up only if hit ratio >= 1 - 2500/10000 = 0.75
a miss still costs 21 ms: with >1% misses, p99 is a miss
প্রথম সারি: hit শূন্য হলে cache উল্টো ক্ষতি — প্রতিটি পড়া ২১ ms, cache ছাড়ার চেয়েও ধীর। ৫০%-এ গড় ১১ ms, কিন্তু ডেটাবেস তখনো দ্বিগুণ ভারে — এখানে আসল প্রশ্ন ডেটাবেস বাঁচানো। ৭৫%-এ ডেটাবেস ঠিক সীমায়, তাই ন্যূনতম hit ratio ০.৭৫. ৯০% থেকে ৯৯% — hit মাত্র ৯ শতাংশ-বিন্দু বাড়ল, অথচ ডেটাবেসের query ১,০০০ থেকে ১০০, দশ গুণ কম। আর miss-এর অনুরোধ তবু ২১ ms নেয়: ৯৫% hit-এ প্রতি ১০০টির ৫টি miss, তাই p99 (পর্ব ৪) miss-এর সমানই — গড় ২ ms দেখে tail ভুলবেন না।
Cache খালি হলে? টেবিলের প্রথম সারিটিই আসল ঝুঁকি। Deploy, restart বা cache node বিকল হলে কিছুক্ষণ h প্রায় শূন্য — ডেটাবেসে হঠাৎ ৪০০% ভার। তাই নকশার প্রশ্ন: cache ছাড়া ডেটাবেস কতক্ষণ টিকবে? আগে থেকে ভরে রাখা (cache warming), ধীরে traffic ফেরানো আর request coalescing — পর্ব ৬-এর ফল প্রকাশের নকশায় দেখেছেন; cache stampede-এর পুরো আলোচনা পর্ব ২৯-এ।
২ · কী cache করবেন, আর কী করবেন না
তিনটি প্রশ্ন দিয়ে শুরু করুন। বারবার পড়া হয় তো? — read:write অনুপাত যত বেশি, লাভ তত; একবার লিখে একবার পড়া জিনিস cache-এ শুধু memory খায়। আনতে বা বানাতে খরচ বেশি? — join, aggregation, অন্য সার্ভিসের ধীর API, রেন্ডার করা HTML. একটু পুরোনো হলে ক্ষতি কতটা? — cache-এর কপি সবসময় একটু পিছিয়ে থাকতে পারে; সেই staleness ব্যবসা সহ্য করে কি না, সেটিই শেষ কথা।
| ডেটার ধরন | উদাহরণ | সিদ্ধান্ত | খেয়াল রাখুন |
|---|---|---|---|
| বহু পঠিত (hot read) | পণ্যের বিবরণ, প্রোফাইল, config | হ্যাঁ — cache-aside, TTL কয়েক মিনিট | লেখার পরে key মোছা |
| ভারী হিসাবের ফল | রিভিউর গড়, trending তালিকা | হ্যাঁ — আগে হিসাব করে রাখা | কত পুরোনো চলবে |
| রেন্ডার করা অংশ | হোমপেজের “আজকের অফার” ব্লক | হ্যাঁ | key-তে ভাষা আর সংস্করণ |
| Session-এর তথ্য | লগইন, cart | store হিসেবে, cache হিসেবে নয় | eviction মানে হঠাৎ লগআউট |
| খুব দ্রুত বদলায় | live স্কোর, স্টকের সংখ্যা | খুব ছোট TTL, নয়তো নয় | hit ratio কম, লাভ সামান্য |
| কড়া consistency | wallet balance, সিটের শেষ হিসাব | সিদ্ধান্ত cache থেকে নয় | দেখানোর জন্য সময়-সহ কপি চলে |
Session Redis-এ রাখা আসলে cache নয় — সেখানে সেটিই একমাত্র কপি। পর্ব ১৫-এ দেখেছেন, সেই store-এ eviction মানে হঠাৎ লগআউট। আর কতটুকু রাখবেন — চাহিদা Zipf-ধাঁচের হলে অল্প জিনিসেই বড় hit ratio মেলে (পর্ব ১৭-এর অংশ ৬)।
Wallet balance cache-এ — কেন বিপজ্জনক। একটি কাল্পনিক mobile banking সেবায় balance ১,০০০ টাকা। ব্যবহারকারী ৮০০ টাকা পাঠালেন; cache-এ পুরোনো ১,০০০ রয়ে গেল আরও ৩০ সেকেন্ড। এই সময়ে আরেকটি ৮০০ টাকার অনুরোধ cache দেখে অনুমোদন পেলে balance ঋণাত্মক। নিয়ম: টাকা কাটার সিদ্ধান্ত সবসময় ডেটাবেসের transaction-এর ভেতরে, শর্তসহ — যেমন UPDATE … WHERE balance >= 800 — পর্ব ২৪-এর atomic update দিয়ে। Cache চলতে পারে শুধু দেখানোর পর্দায়, “শেষ হালনাগাদ ১০:৩২” লিখে। Ledger-ভিত্তিক পুরো নকশা পর্ব ৬০-এ।
৩ · পড়ার কৌশল — cache-aside আর read-through
Cache-এ ডেটা কে আনবে আর কখন, আর লেখা হলে কপির কী হবে — এই দুই প্রশ্নের উত্তরই একেকটি caching কৌশল। ছবিতে চারটি প্রধান পথ একসাথে; নিচে একে একে।
App, cache আর database — চার কৌশলের চার পথ
Cache-aside, read-through, write-through and write-behind: who talks to the database, and when
Cache-aside (lazy loading)
Cache-aside-এ cache একটি নিষ্ক্রিয় তাক — সে ডেটাবেস চেনে না। সব কাজ app-এর: আগে cache দেখা, miss হলে নিজে ডেটাবেস থেকে পড়া, তারপর ফলটি একটি TTL দিয়ে cache-এ রেখে দেওয়া। “Lazy” কারণ কেউ না চাইলে কিছুই cache-এ ওঠে না। Redis বা Memcached-এর সাথে সবচেয়ে প্রচলিত কৌশল এটিই; Redis-এর কমান্ড-স্তরের উদাহরণ DBMS কোর্সের Redis পর্বের caching অংশে।
function getProduct(id):
key = "product:" + id
v = cache.get(key) # ১ আগে cache
if v != null:
return v # hit
v = db.query("SELECT ... FROM products WHERE id = ?", id) # ২-৩ miss: app নিজে DB-তে
cache.set(key, v, ttl = 300) # ৪ পরের জনের জন্য রেখে দেওয়া, ৩০০ সেকেন্ড
return v| সময় | |
| এই অনুরোধের খরচ | |
| DB-তে মোট read query |
- প্রথম অনুরোধ — miss. রুমা পণ্য ৪২-এর পাতা চাইলেন। App প্রথমে cache-এ key
product:42খোঁজে, খরচ প্রায় ১ ms. সদ্য deploy হয়েছে, cache খালি — miss. - App নিজেই ডেটাবেসে যায়। Cache-aside-এ cache ডেটাবেস চেনে না; app নিজে query পাঠায়, ধরা যাক ২০ ms. এই সময়টুকু রুমার অপেক্ষা।
- মান বসানো, তারপর উত্তর। App ফলটি ৩০০ সেকেন্ডের TTL দিয়ে cache-এ রাখে, তারপর রুমাকে উত্তর দেয় — মোট ১ + ২০ = ২১ ms. প্রথম অনুরোধ cache ছাড়ার চেয়েও সামান্য ধীর।
- পরের অনুরোধ — hit. ৫ সেকেন্ড পরে সজীব একই পাতা চাইলেন: কপি আছে, মেয়াদ বাকি ২৯৫ সেকেন্ড। উত্তর ১ ms-এ, ডেটাবেস জানলই না। জনপ্রিয় পাতায় এমন hit-ই বেশি — সেটাই লাভ।
- দাম বদলাল — ডেটাবেস আগে, delete পরে। ১২০ সেকেন্ডে দোকানি দাম ৫৫০ থেকে ৫২০ করলেন। লেখার পথ: ডেটাবেসে UPDATE আর COMMIT, তারপর cache থেকে
DEL product:42. না মুছলে বাকি প্রায় ১৮০ সেকেন্ড সবাই পুরোনো দাম দেখতেন। - Delete-এর পরের পড়া — আবার miss, এবার টাটকা। নাদিয়া পাতা চাইলেন: miss, ডেটাবেস থেকে ৫২০, নতুন TTL-সহ SET, উত্তর ২১ ms. তিনজন পাঠক, ডেটাবেসে মাত্র ২টি query — বাস্তবে হাজারজনে একটি। ধাপ ৫ আর ৬-এর মাঝেই লুকিয়ে আছে একটি race, অংশ ৫-এ দেখব।
Cache-aside-এর লাভ
শুধু চাওয়া ডেটাই memory-তে। Cache বিকল হলেও app সরাসরি ডেটাবেসে গিয়ে চলতে পারে — ধীরে, আর ডেটাবেস ভার সামলালে। Key-তে কী আকারে রাখবেন, app-ই ঠিক করে — কয়েকটি টেবিলের join করা ফলও একটি key-তে।
সরল, নমনীয়দাম
প্রতিটি key-এর প্রথম অনুরোধ miss — নতুন deploy-এ cold start. লেখার পরে delete না হলে TTL শেষ পর্যন্ত পুরোনো মান। প্রতিটি সার্ভিসে একই logic, ভুলের সুযোগও তত — আর অংশ ৫-এর race.
দায়িত্ব app-এরRead-through
Read-through-এ app শুধু cache-কে চেনে। Miss হলে cache বা cache-library নিজেই একটি loader ফাংশন চালিয়ে ডেটাবেস থেকে আনে, জমায়, তারপর উত্তর দেয়। Java-র JCache-এর CacheLoader বা Caffeine library-র LoadingCache এই ধাঁচের। সাধারণ Redis সার্ভার নিজে ডেটাবেস থেকে কিছু আনে না (কিছু module বা বাণিজ্যিক সংস্করণে এমন সুবিধা আছে), তাই Redis-এর সাথে read-through সাধারণত app-এর পাশের একটি library-স্তর। লাভ: logic এক জায়গায়, আর অনেক loader একই key-এর একসাথে আসা miss-কে একটিমাত্র load-এ মেলায় (documentation দেখে নিশ্চিত হোন)। দাম: cache-স্তরকে ডেটার উৎস আর গঠন জানতে হয়।
Cache-aside হলো নিজে বাজার করা: পাড়ার খুচরা দোকানের তাকে না পেলে আপনি নিজেই পাইকারি আড়তে গিয়ে এনে তাকে রেখে দেন, পরের জনের জন্য। Read-through হলো দোকানের কর্মচারী: আপনি শুধু তাঁকে চান, তাকে না থাকলে তিনি নিজেই আড়ত থেকে আনিয়ে দেন — আড়ত কোথায়, আপনার জানার দরকারই নেই।
নামের গোলমাল। Facebook-এর memcache গবেষণাপত্র একে বলে “demand-filled look-aside cache”, Microsoft-এর architecture নির্দেশিকায় Cache-Aside pattern, অনেক লেখায় lazy loading. Cache-aside আর read-through-এর পার্থক্য একটাই প্রশ্নে: miss হলে ডেটাবেসে কে যায় — app, না cache?
৪ · লেখার কৌশল — write-through, write-behind আর write-around
পড়ার কৌশল ঠিক করে cache কীভাবে ভরে; লেখার কৌশল ঠিক করে ডেটা বদলালে দুই কপি — cache আর ডেটাবেস — কীভাবে মেলে, আর কখন ব্যবহারকারীকে “OK” বলা হয়।
Write-through — দুই জায়গায় একসাথে
Write-through-এ প্রতিটি লেখা cache আর ডেটাবেস দুটোতেই যায়, আর দুটো শেষ হলে তবেই “OK”. লাভ: যে key লেখা হলো, তার কপি সাথে সাথে টাটকা — লেখার পরের প্রথম পড়াও hit. দাম: লেখার latency cache আর ডেটাবেস মিলিয়ে (একসাথে পাঠালেও ধীরটির সমান); আর এমন ডেটাও cache-এ ঢোকে যা হয়তো কেউ পড়বে না — লেখা-ভারী কাজে memory অপচয়, তাই এখানেও TTL লাগে। দুটি লেখা আসলে দুটি আলাদা কাজ: একটি সফল আরেকটি ব্যর্থ হলে কপি দুটি আলাদা। প্রচলিত ক্রম: আগে ডেটাবেস (সত্যের উৎস), তারপর cache, আর cache-এ ব্যর্থ হলে key মুছে দেওয়া। CPU-তেও একই নাম; সেখানে store-এর দেরি লুকাতে write buffer (Cache performance পর্বের অংশ ১)।
Write-behind (write-back) — আগে cache, পরে ডেটাবেস
Write-behind-এ লেখা যায় শুধু cache-এ, app সাথে সাথে “OK” পায়। Cache-এ বদলানো কিন্তু ডেটাবেসে না-লেখা entry — dirty entry — একটি পেছনের কাজ নির্দিষ্ট সময় পরপর batch-এ ডেটাবেসে লেখে, যাকে বলে flush. CPU আর OS-এর জগতে এর নাম write-back, caching library-তে প্রায়ই write-behind. লাভ তিনটি: লেখার latency প্রায় শুধু cache-এর; হঠাৎ লেখার ঢেউ cache শুষে নেয়; আর একই key-তে বারবার লেখা flush-এ এক হয়ে যায় — write coalescing. View বা like-এর মতো counter-এ এটি বিশাল সাশ্রয়।
দাম গুরুতর: flush-এর আগে cache node বিকল হলে যে লেখার জন্য ব্যবহারকারী “OK” পেয়েছেন, তা হারায় — durability-র প্রতিশ্রুতি ভাঙে। ধাপে ধাপে দেখুন:
| সময় | |
| OK পাওয়া লেখা | |
| DB-তে লেখা |
- শুরুতে দুই কপি মিলছে। একটি পোস্টের like-সংখ্যা
like:7= ১০, cache আর ডেটাবেসে একই। কোনো dirty entry নেই। - লেখা শুধু cache-এ। ০.২ সেকেন্ডে একজন like দিলেন: cache-এ ১১, app ১ ms-এ OK পেল। ডেটাবেসে এখনো ১০;
like:7এখন dirty. - আরও লেখা জমছে। ০.৬ সেকেন্ডে আরেকটি like (১২) আর নতুন পোস্ট
like:9= ৫. তিনটি লেখাই OK পেয়েছে, ডেটাবেস এখনো কিছু জানে না। - Flush — তিনটি লেখা, দুটি DB-লেখা। ১ সেকেন্ডে পেছনের কাজ dirty তালিকা এক batch-এ লিখল:
like:7 = 12,like:9 = 5. একই key-এর দুটি লেখা একটিতে মিলেছে — write coalescing. এখন আবার দুই কপি এক। - আবার একটি লেখা। ১.৩ সেকেন্ডে আরেকটি like: cache-এ ১৩, OK দেওয়া হলো। পরের flush ২ সেকেন্ডে।
- Crash. ১.৭ সেকেন্ডে cache node বিকল — মেশিন বন্ধ বা memory ফুরিয়ে process মারা গেল। Memory-র সব, dirty তালিকাসহ, হারাল; ২ সেকেন্ডের flush আর কখনো হলো না।
- পুনরুদ্ধারের পরে — একটি like নেই। নতুন cache খালি, পড়া যাচ্ছে ডেটাবেসে:
like:7= ১২. চারটি লেখা OK পেয়েছিল, পৌঁছেছে তিনটি। Like-এ এটি হয়তো সহ্য করা যায়; টাকার লেনদেনে কখনো নয়।
Flush-এর ব্যবধান কত রাখবেন? নিচের simulator ১০ সেকেন্ডে ৫০টি key-তে ২,০০০টি লেখা চালায় (seeded PRNG, তাই প্রতিবার একই ফল), আর cache node মারা যায় ৭.৩৫ সেকেন্ডে:
// Write-through vs write-behind: 2000 counter updates on 50 keys over 10 s (seeded).
// ASSUMED: cache write 1 ms, DB write 20 ms, flush = one DB write per DIRTY key, done instantly.
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(28), T_CACHE = 1, T_DB = 20, KEYS = 50, CRASH = 7350; // crash at 7.35 s
const writes = Array.from({ length: 2000 }, () => ({ t: rnd() * 10000, key: Math.floor(rnd() * KEYS) }))
.sort((a, b) => a.t - b.t);
const acked = writes.filter(w => w.t < CRASH); // the app already said "OK" for these
console.log(`${acked.length} writes acknowledged before the cache node crashed at ${CRASH} ms\n`);
console.log("strategy ms/write DB writes lost writes keys hurt");
console.log("write-through".padEnd(20) + String(T_CACHE + T_DB).padStart(8) +
String(acked.length).padStart(11) + "0".padStart(13) + "0".padStart(11));
for (const F of [100, 1000, 5000]) { // flush interval in ms
const last = Math.floor(CRASH / F) * F; // the last flush that finished
let dbw = 0;
for (let s = 0; s < last; s += F) // one DB write per dirty key per flush
dbw += new Set(acked.filter(w => w.t >= s && w.t < s + F).map(w => w.key)).size;
const lost = acked.filter(w => w.t >= last); // only in the dead cache's memory
console.log(`write-behind ${F} ms`.padEnd(20) + String(T_CACHE).padStart(8) + String(dbw).padStart(11) +
String(lost.length).padStart(13) + String(new Set(lost.map(w => w.key)).size).padStart(11));
}আউটপুট:
1479 writes acknowledged before the cache node crashed at 7350 ms
strategy ms/write DB writes lost writes keys hurt
write-through 21 1479 0 0
write-behind 100 ms 1 1213 7 7
write-behind 1000 ms 1 345 74 42
write-behind 5000 ms 1 50 472 50
Crash-এর আগে ১,৪৭৯টি লেখা “OK” পেয়েছে। Write-through-এ প্রতিটি ২১ ms, ডেটাবেসে ১,৪৭৯টি লেখা, কিছুই হারায় না — কারণ ডেটাবেসের OK-এর পরেই app-এর OK. Write-behind-এ প্রতিটি লেখা ১ ms. ১০০ ms পরপর flush-এ ডেটাবেসের লেখা ১,২১৩ — খুব বেশি কমেনি, কারণ ১০০ ms-এ একই key-তে দুবার লেখা কমই পড়ে — আর হারায় মাত্র ৭টি। ১ সেকেন্ডে flush: ডেটাবেসের লেখা ৩৪৫, চার গুণের বেশি সাশ্রয়, কিন্তু crash-এ হারায় ৭৪টি লেখা, ৪২টি key-তে। ৫ সেকেন্ডে: ডেটাবেসে মাত্র ৫০টি লেখা, কিন্তু হারায় ৪৭২টি, আর সব ৫০টি key-ই ক্ষতিগ্রস্ত। Flush-এর ব্যবধানই সর্বোচ্চ ক্ষতির জানালা: বাড়ালে ডেটাবেস বাঁচে, ঝুঁকি বাড়ে। সরলীকরণ: flush মুহূর্তেই শেষ হয় বলে ধরা; বাস্তবে flush-এর মাঝখানেও crash হতে পারে। CRASH বদলে দেখুন হারানোর সংখ্যা কীভাবে flush-এর ঠিক আগে-পরে লাফায়।
হারানো ছাড়াও তিনটি সূক্ষ্ম ঝুঁকি। ক্রম: অর্ডার আর তার payment আলাদা key-তে; flush উল্টো ক্রমে বা আংশিক হলে ডেটাবেসে payment আছে, অর্ডার নেই। দেরিতে ধরা পড়া ভুল: unique শর্ত ভাঙলে তা ধরা পড়ে flush-এ, অথচ ব্যবহারকারী আগেই OK পেয়েছেন। অন্য পাঠক: report বা search সরাসরি ডেটাবেস পড়লে পিছিয়ে থাকা ছবি দেখে। প্রতিকার: cache-কে নিজেই টেকসই করা — replica আর append-only log (Redis-এর AOF প্রতি সেকেন্ডে fsync-এর সেটিংয়ে বিকলে মোটামুটি এক সেকেন্ডের লেখা হারাতে পারে; খুঁটিনাটি সংস্করণভেদে আলাদা) — বা আগে একটি টেকসই log-এ লেখা, অর্থাৎ write-ahead log-এর ধারণা (পর্ব ১৯; log-ভিত্তিক streaming পর্ব ৩৩-এ)। নিয়ম: টাকা, অর্ডার আর বুকিংয়ে write-behind নয়।
Write-around — শুধু ডেটাবেসে, cache এড়িয়ে
Write-around-এ লেখা সরাসরি ডেটাবেসে; cache-এ নতুন মান বসে না, বড়জোর পুরোনো key মুছে দেওয়া হয়। পরের পড়া miss হয়ে cache-aside-এর পথে নতুন মান আনে। লাভ: একবার লেখা আর কদাচিৎ পড়া ডেটা — log, পুরোনো অর্ডার, upload-এর তথ্য — cache দূষিত করে না। দাম: লেখার ঠিক পরের পড়া miss. বাস্তবে সবচেয়ে প্রচলিত জোড়া: পড়ায় cache-aside, লেখায় write-around আর delete.
| কৌশল | লেখার latency | Cache টাটকা? | Crash-এ লেখা হারায়? | মানানসই |
|---|---|---|---|---|
| Write-through | cache + DB | হ্যাঁ, লেখা key-তে | না | লেখার পরপরই পড়া হয় এমন ডেটা |
| Write-behind | শুধু cache | হ্যাঁ | হ্যাঁ, flush-এর জানালা | counter, analytics — ক্ষতি সহনীয় |
| Write-around + delete | শুধু DB | পরের miss-এ | না | বেশিরভাগ cache-aside সিস্টেম |
৫ · Cache-aside-এর race — পুরোনো মান যখন চিরকাল থেকে যায়
Cache আর ডেটাবেস দুটি আলাদা সিস্টেম; দুটোকে একসাথে বদলানোর কোনো transaction নেই। তাই লেখার পথে দুটি আলাদা কাজ — ডেটাবেস বদলানো আর cache-এর কপি সামলানো — আর তাদের ক্রম, ব্যর্থতা আর অন্য অনুরোধের সাথে জড়িয়ে যাওয়াই সব সমস্যার উৎস। এ ধরনের ভুলের নাম race condition: একা চালালে কোড নির্ভুল, শুধু একটি দুর্ভাগা ক্রমে ভুল।
লেখার তিনটি ক্রম
- (ক) আগে cache delete, পরে ডেটাবেস — সবচেয়ে খারাপ। Delete আর COMMIT-এর মাঝখানে কোনো পাঠক এলে miss করবেন, ডেটাবেস থেকে পুরোনো মান পড়বেন (নতুনটি তখনো commit হয়নি) আর সেটিই cache-এ বসাবেন। জানালাটি পুরো লেখার সময়জুড়ে — ব্যস্ত key-তে প্রায় নিশ্চিত।
- (খ) আগে ডেটাবেস, পরে cache-এ নতুন মান — “update on write”. দুজন লেখক প্রায় একসাথে: B-র commit পরে, কিন্তু A-র set কোনো কারণে দেরিতে পৌঁছাল — cache-এ রইল A-র পুরোনো মান।
- (গ) আগে ডেটাবেসের COMMIT, পরে cache delete — প্রচলিত পথ। বাকি থাকে একটি race: পাঠক commit-এর আগে পুরোনো মান পড়েছেন, আর তাঁর set পৌঁছাল delete-এর পরে. একে বলে stale set.
| সময় | |
| অবস্থা |
- পাঠক A-র miss. A দাম চাইলেন; cache-এ
price:42নেই — হয়তো মেয়াদ ফুরিয়েছে, বা আগের কোনো লেখায় মুছেছে। - A পুরোনো মান পড়ে আটকে গেলেন। ডেটাবেস দিল ১০০. ঠিক তখনই A-র process থেমে গেল — garbage collection-এর বিরতি, CPU না পাওয়া বা network-এর দেরি। হাতে ১০০, cache-এ এখনো বসানো হয়নি।
- লেখক B-র COMMIT. ৩ ms-এ B দাম ১২০ করে commit করলেন। ডেটাবেস এখন ১২০.
- B নিয়মমতো delete করলেন। ৪ ms-এ
DEL price:42— cache তো খালিই, মোছার কিছু নেই। B-র দিক থেকে সব নিখুঁত: আগে DB, পরে delete. - A জেগে উঠে পুরোনো মান বসালেন। ৬০ ms-এ A
SET price:42 = 100পাঠালেন। Cache এখন ডেটাবেসের চেয়ে পুরোনো — এটিই stale set. - এরপর সবাই ভুল দেখবেন। আর কোনো লেখা বাকি নেই যে কপিটি মুছবে। TTL থাকলে মেয়াদ শেষে ঠিক হবে; না থাকলে পরের লেখা পর্যন্ত, হয়তো কয়েক দিন। কোনো error নেই, কোনো log-এ লাল দাগ নেই — নিঃশব্দ ভুল।
- Lease দিয়ে আবার একই ঘটনা। এবার miss-এর সময় cache A-কে একটি token দিল — ৭. B-র COMMIT আর DEL আগের মতোই; কিন্তু DEL সেই key-এর খোলা token বাতিল করে দেয়।
- পুরোনো token-এর set প্রত্যাখ্যাত। ৬০ ms-এ A token ৭ দেখিয়ে ১০০ বসাতে চাইলেন — cache জানে মাঝে delete হয়েছে, তাই না। পরের পাঠক C miss করে token ৮ পেলেন, ডেটাবেস থেকে ১২০ এনে বসালেন — গৃহীত। দাম শুধু একটি বাড়তি miss.
কেন “update on write”-এর চেয়ে “delete on write” নিরাপদ
- Delete-এর ক্রম লাগে না, set-এর লাগে। দুটি delete যেকোনো ক্রমে পৌঁছাক, ফল একই — খালি key. দুটি set উল্টো ক্রমে পৌঁছালে পুরোনোটি জেতে।
- Retry নিরাপদ। Delete-এর timeout হলে আবার পাঠানো যায় — delete idempotent. দেরিতে আসা পুরোনো set-এর retry নতুন মানকে ঢেকে দিতে পারে।
- পড়া-বদলানো-লেখার ফাঁদ নেই। Update-এ অনেক app cache থেকে পুরো object এনে একটি field বদলে আবার set করে; দুজন দুটি আলাদা field বদলালে একজনের বদল হারায় — ডেটাবেসে দুটোই আছে, cache-এ একটি।
- অপচয় আর জটিলতা কম। একটি সারির বদল অনেক কপিতে পড়ে — পণ্যের পাতা, category-র তালিকা, cart. সবগুলোর নতুন মান লেখার পথে হিসাব করা ভারী আর ভুলপ্রবণ; মুছে দেওয়া সহজ।
তবে delete-ও race-মুক্ত নয়। নিচের simulator একটি ব্যস্ত key-তে তিন কৌশল পাশাপাশি চালায়: ৭০ ms-এর মধ্যে তিনটি লেখা, এক সেকেন্ডে ২০০টি পড়া, আর ধরা যাক ২% ধাপ হঠাৎ ৫০ থেকে ২০০ ms আটকে যায়। প্রতিটি কৌশল একই ২,০০০টি seed-এ — তাই তুলনা ন্যায্য, আর প্রতিবার একই ফল:
// Cache-aside on ONE hot key, many independent trials. Seeded: every run prints the same numbers.
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 TRIALS = 2000, TTL = Infinity; // TTL in ms (Infinity = never expires); try 200
const PAUSE = 0.02; // ASSUMED: 2% of steps stall 50-200 ms (GC, slow network)
function trial(mode, seed) {
const rnd = mulberry32(seed), U = (lo, hi) => lo + rnd() * (hi - lo);
const lag = (lo, hi) => rnd() < PAUSE ? U(50, 200) : U(lo, hi);
const W = [0, 1, 2].map(() => U(0, 50) + U(5, 20)); // 3 writes commit by ~70 ms
const WD = W.map(() => lag(0.5, 2)); // commit -> cache step
const R = Array.from({ length: 200 }, () => [U(0, 1000), lag(1, 5)]); // read time, fill delay
let db = 0, cache = null, gen = 0, stale = 0, late = 0; const ev = [];
const at = (t, f) => { const i = ev.findIndex(e => e.t > t); ev.splice(i < 0 ? ev.length : i, 0, { t, f }); };
W.forEach((t, k) => at(t, () => { const v = ++db; // DB commit of version v
at(t + WD[k], (t2) => { if (mode === "update") cache = { v, exp: t2 + TTL }; // set own value
else { cache = null; gen++; } }); })); // delete, which also voids leases
for (const [t, d] of R) at(t, () => {
if (t > 300) late++; // all writes finished long before
if (cache && cache.exp > t) { if (t > 300 && cache.v !== db) stale++; return; } // hit
const v = db, lease = gen; // miss: read DB, take a lease
at(t + d, (t2) => { if (mode === "lease" && lease !== gen) return; // deleted meanwhile: refuse
cache = { v, exp: t2 + TTL }; });
});
while (ev.length) { const e = ev.shift(); e.f(e.t); }
return [stale, late, cache && cache.v !== db ? 1 : 0];
}
console.log(`${TRIALS} keys, TTL = ${TTL} ms, stale = cache older than the DB`);
console.log("mode stale keys at end stale reads after t = 300 ms");
for (const mode of ["update", "delete", "lease"]) {
let keys = 0, st = 0, all = 0;
for (let s = 1; s <= TRIALS; s++) { const [a, b, c] = trial(mode, s); st += a; all += b; keys += c; }
console.log(mode.padEnd(8) + String(keys).padStart(10) + String(st).padStart(17) +
` of ${all} (${(100 * st / all).toFixed(2)}%)`);
}আউটপুট:
2000 keys, TTL = Infinity ms, stale = cache older than the DB
mode stale keys at end stale reads after t = 300 ms
update 183 25566 of 279473 (9.15%)
delete 240 33532 of 279473 (12.00%)
lease 0 0 of 279473 (0.00%)
TTL নেই, তাই race-এ বসা পুরোনো মান চিরকাল থাকে। Update-এ ২,০০০ key-এর ১৮৩টি শেষে ভুল, আর ৩০০ ms-এর পরের — সব লেখা থেমে যাওয়ার অনেক পরের — পড়ার ৯.১৫% পুরোনো মান পেয়েছে। Delete-এ ২৪০টি key আর ১২.০০% — চমকে দেওয়ার মতো, বেশি! প্রতিটি delete cache খালি করে, পরের কয়েক ms-এর পাঠকেরা miss করে ডেটাবেসে যান, আর লেখাগুলো কাছাকাছি বলে সেই fill-গুলোর কোনোটি পরের লেখার delete পেরিয়ে পুরোনো মান বসায়। Update-এ cache ভরা থাকে, fill কম; কিন্তু আটকে যাওয়া লেখকের set নতুনটির পরে পৌঁছালে পুরোনো মান জেতে।
PAUSE ০ করে চালান: update ২৭, delete ১২৯. আবার ০.১ করুন — দেরি আর retry বেশি: update ৫৮৩, delete ৪৯৩, ক্রম উল্টে যায়। অর্থাৎ কে বেশি ভুল করবে, তা নির্ভর করে কোনটি বেশি ঘটে — লেখকের দেরি (update-এর শত্রু), না লেখার ঢেউয়ের মাঝে fill (delete-এর শত্রু)। স্থির শুধু তৃতীয় সারি: lease-এ শূন্য, যেকোনো অনুমানে — delete-এর পরে পুরোনো token-এর প্রতিটি set প্রত্যাখ্যাত। Delete-কে ডিফল্ট করার আসল কারণ এটিই: তার বাকি race-টি lease দিয়ে পুরো বন্ধ করা যায়, update-এর reorder বন্ধ করতে প্রতিটি মানে version লাগে।
প্রতিকার — ক্রম, TTL, lease, version আর log থেকে invalidation
- ক্রম: ডেটাবেসের COMMIT-এর পরে delete — UPDATE বাক্যের পরে নয়। Transaction খোলা থাকলে অন্য পাঠক MVCC-তে তখনো পুরোনো মান দেখেন, তাই commit-এর আগের delete আসলে ক্রম (ক)।
- TTL — নিরাপত্তা-জাল: race যত বিরলই হোক, প্রতিটি key-তে TTL থাকলে ভুল কপি বড়জোর TTL পর্যন্ত টেকে, চিরকাল নয়। TTL বাছুন ব্যবসা কতটা পুরোনো সহ্য করে তা দেখে; সব key-এর মেয়াদ একসাথে না ফুরাতে একটু এলোমেলো সময় যোগ করুন (পর্ব ২৯)।
- Lease: Facebook-এর প্রকৌশলীদের “Scaling Memcache at Facebook” (NSDI ২০১৩) গবেষণাপত্রের বর্ণনা অনুযায়ী miss-এ memcache একটি token দেয়, সেই key-এ delete এলে token বাতিল, আর set চলে শুধু বৈধ token-এ — উপরের ধাপ ৭–৮. একই key-এ token কিছুক্ষণে একবারই দেওয়া হয়, বাকিরা একটু পরে আবার দেখেন — তাই lease thundering herd-ও কমায়।
- Version-সহ মান: ডেটাবেসের সারিতে version কলাম (বা commit-এর ক্রমিক নম্বর), cache-এ মানের সাথে সেই version, আর set শুধু তখনই যখন নতুনটির version বড় — compare-and-set, Redis-এ সাধারণত ছোট একটি Lua script দিয়ে। ফাঁদ: key খালি থাকলে তুলনার কিছু নেই, পুরোনো set-ও সফল হবে। তাই delete-এর বদলে কিছুক্ষণ “version ৮ পর্যন্ত বাতিল” ধরনের একটি ছোট চিহ্ন রাখতে হয় — এক রকম tombstone.
- Delayed double delete: commit-এর পরে delete, কয়েকশো ms পরে আবার delete — মাঝে বসা পুরোনো মান দ্বিতীয়টিতে মোছে। শিল্পে প্রচলিত কৌশল, কোনো মানসম্মত নিয়ম নয়: জানালা ছোট করে, বন্ধ করে না, আর প্রতিটি লেখায় বাড়তি miss আনে।
- Log থেকে invalidation: app-এর বদলে একটি আলাদা প্রক্রিয়া ডেটাবেসের commit log পড়ে delete পাঠায় — change data capture. App commit-এর পরে crash করলেও delete হারায় না, আর delete আসে commit-এর ক্রমেই। পুরো ধারণা পর্ব ৩৩-এ।
# লেখা: আগে COMMIT, তারপর delete
function updatePrice(id, price):
db.transaction:
UPDATE products SET price = ?, version = version + 1 WHERE id = ?
# COMMIT হয়ে গেছে — এবার, এর আগে নয়
cache.delete("price:" + id) # timeout হলে আবার; delete বারবার করা নিরাপদ
# পড়া: lease-সহ cache-aside
function getPrice(id):
(v, token) = cache.get_or_lease("price:" + id)
if v != null: return v
if token == null: # অন্য কেউ এই key আনছেন
sleep(5 ms); return getPrice(id)
v = db.query("SELECT price FROM products WHERE id = ?", id)
cache.set_if_lease("price:" + id, v, token, ttl = 300) # মাঝে delete হলে প্রত্যাখ্যাত
return v৬ · নিজের লেখা নিজে দেখা — read-your-writes, replica আর cache
নাদিয়া প্রোফাইলে নিজের নাম বদলালেন, Save চাপলেন, পাতা reload — পুরোনো নাম! তিনি ভাববেন সেভ হয়নি, আবার চেষ্টা করবেন। এটি read-your-writes ভাঙা, যা পর্ব ২২-এর অংশ ৪-এ replica-র ক্ষেত্রে দেখেছেন। Cache এখানে সমস্যাটিকে বড় করে দুইভাবে।
- Replica থেকে fill: লেখা leader-এ commit হলো, delete-ও হলো; পরের miss-এ app পড়ল একটি read replica থেকে, যেখানে replication lag-এর কারণে বদল তখনো পৌঁছায়নি — পুরোনো নাম cache-এ বসল। Replica কয়েক ms-এ ঠিক হয়ে যায়, কিন্তু cache-এর ভুল কপি টেকে পুরো TTL.
- প্রতি সার্ভারের নিজস্ব cache: local cache-এ delete শুধু এক সার্ভারে হলে অন্যগুলোতে পুরোনো কপি; load balancer নাদিয়াকে কোন সার্ভারে পাঠায়, ফল তার ওপর নির্ভর করে।
প্রতিকার, সহজ থেকে কঠিন:
- লেখার পরে কিছুক্ষণ (replication lag-এর স্বাভাবিক সর্বোচ্চের চেয়ে বেশি) লেখকের পড়া leader থেকে, আর সেই ফল cache-এ না বসানো — session-এ “শেষ লেখার সময়” রেখে।
- লেখকের নিজের key-এ লেখার পথেই version-সহ নতুন মান বসানো — শুধু এই key-তে write-through.
- Invalidation আসুক replica-র নিজের log থেকে, অর্থাৎ বদল replica-তে পৌঁছানোর পরে delete (ভাবনার প্রশ্ন ২)।
- Local cache খুব ছোট TTL-এ, বা সব সার্ভারে ছড়ানো invalidation-সহ। বহু-স্তরের cache-এর নকশা পর্ব ৩০-এ।
Cache-aside সাধারণত দেয় eventual consistency: লেখা থেমে গেলে শেষ পর্যন্ত সবাই নতুন মান দেখবেন — TTL বা delete-এর পরে। Read-your-writes, monotonic read বা তার চেয়ে কড়া নিশ্চয়তা চাইলে আলাদা নকশা লাগে, আর প্রতিটির দাম আছে। Consistency model-এর পুরো সিঁড়ি পর্ব ৪০-এ।
৭ · আরও চারটি প্যাটার্ন — refresh-ahead, coalescing, negative caching আর আগে হিসাব করা ফল
Refresh-ahead — মেয়াদ ফুরানোর আগেই নবায়ন
শুধু TTL-এ চললে একটি জনপ্রিয় key-এর মেয়াদ ফুরানো মাত্র পরের অনুরোধগুলো miss — ব্যবহারকারী অপেক্ষা করেন, ডেটাবেস হঠাৎ ঝাঁক পায়। Refresh-ahead: মেয়াদের শেষ অংশে (ধরা যাক শেষ ২০%) কোনো অনুরোধ এলে তাঁকে বর্তমান কপিই দেওয়া হয়, আর পেছনে একটিমাত্র reload শুরু হয়; নতুন মান আসে মেয়াদ ফুরানোর আগেই। Caffeine library-র refreshAfterWrite এই ধাঁচের (খুঁটিনাটি documentation-এ), আর HTTP-র stale-while-revalidate একই চিন্তা (পর্ব ১৭)। একটি hot key-এ তিন পদ্ধতি:
// One hot key: 1 request per ms (1000 req/s) for 5 minutes. ASSUMED: TTL 60 s, DB reload 50 ms.
const DUR = 300000, TTL = 60000, LOAD = 50, AHEAD = 0.2; // refresh in the last 20% of the TTL
function run(mode) {
let exp = -1, done = -1, dbq = 0, waited = 0, worst = 0;
for (let t = 0; t < DUR; t++) {
if (done >= 0 && t >= done) { exp = done + TTL; done = -1; } // a reload has landed
if (t < exp) { // hit: answer from cache
if (mode === "refresh-ahead" && done < 0 && exp - t < AHEAD * TTL) { dbq++; done = t + LOAD; }
continue; // (reload runs in background)
}
waited++; // miss: this user waits
if (mode === "TTL only") { dbq++; if (done < 0) done = t + LOAD; } // every miss hits the DB
else if (done < 0) { dbq++; done = t + LOAD; } // one reload, others wait
worst = Math.max(worst, done - t);
}
return [dbq, waited, worst];
}
console.log("mode DB queries users who waited worst wait ms");
for (const mode of ["TTL only", "TTL + coalescing", "refresh-ahead"]) {
const [q, w, x] = run(mode);
console.log(mode.padEnd(20) + String(q).padStart(11) + String(w).padStart(18) + String(x).padStart(15));
}আউটপুট:
mode DB queries users who waited worst wait ms
TTL only 250 250 50
TTL + coalescing 5 250 50
refresh-ahead 7 50 50
পাঁচ মিনিটে ৩,০০,০০০ অনুরোধ। শুধু TTL: প্রতিবার মেয়াদ ফুরালে reload-এর ৫০ ms-এ আসা ৫০টি অনুরোধ সবাই miss আর সবাই ডেটাবেসে — শুরুর খালি cache-সহ ৫ বার, মোট ২৫০টি query আর ২৫০ জনের অপেক্ষা। Coalescing ডেটাবেসকে বাঁচায় — মাত্র ৫টি query — কিন্তু অপেক্ষা একই ২৫০ জন, কারণ মান তো তখনো আসেনি। Refresh-ahead-এ অপেক্ষা শুধু শুরুর ৫০ জন, তারপর আর কেউ নন। দাম: query ৫ থেকে ৭, কারণ নবায়ন মেয়াদের আগেই। নিয়মটি জাগে শুধু শেষ ২০%-এ কেউ key-টি চাইলে, তাই কদাচিৎ চাওয়া key-এ অকারণ reload হয় না।
Request coalescing — একই key-এর জন্য একজনই যান
একই key-এ একসাথে বহু miss এলে সবাইকে ডেটাবেসে না পাঠিয়ে একজনকে পাঠানো, বাকিরা তাঁর ফলের অপেক্ষায় — Go-র singleflight বা read-through library-র ভেতরের ব্যবস্থা। এক process-এর ভেতরে এটি সহজ; বহু সার্ভারের মধ্যে লাগে lease বা ছোট একটি lock. ফল প্রকাশের নকশায় এর হিসাব পর্ব ৬-এ, cache stampede-এর গভীর আলোচনা পর্ব ২৯-এ।
Negative caching — “নেই” উত্তরও মনে রাখা
কেউ বারবার এমন পণ্য চাইছেন যা নেই — পুরোনো লিংক, ভুল ID, বা স্বয়ংক্রিয় script. প্রতিবার miss, প্রতিবার ডেটাবেস, আর কিছু না পাওয়ায় cache-এ বসানোর কিছু থাকে না। Negative caching: “নেই” উত্তরটিই ছোট TTL-এ রাখা, যেমন product:999999 → NOT_FOUND ৩০ সেকেন্ড। বিপদ: পণ্যটি এইমাত্র তৈরি হলে ৩০ সেকেন্ড সবাই “নেই” দেখবেন। প্রতিকার: negative entry-র TTL খুব ছোট, আর তৈরির পথে সেই key delete. বিপুল সংখ্যক অস্তিত্বহীন key-এর জন্য memory বাঁচাতে Bloom filter — পর্ব ৪৫-এ।
আগে হিসাব করা ফল — aggregate আর read model
Trending তালিকা, leaderboard, “এই সপ্তাহের সেরা বিক্রি” — হাজার হাজার সারির হিসাব। প্রতিটি miss-এ এই হিসাব চালালে miss-ই ডেটাবেস বসিয়ে দেবে। তাই উল্টো পথ: একটি পেছনের কাজ প্রতি মিনিটে হিসাব করে ফলটি cache-এ লিখে দেয় — cache-এর দিক থেকে write-through-ধাঁচের ধাক্কা — আর পাঠক কখনো হিসাব চালান না, বড়জোর শেষ জানা ফল পান। এটি এক ধরনের materialized view. Event থেকে আলাদা read model বানানোর পুরো নকশা পর্ব ৩৫-এ, আর news feed-এর আগে-তৈরি feed পর্ব ৫৬-এ।
৮ · কৌশল বাছাই আর cache মাপা
কোনো কৌশল সব ডেটার জন্য সেরা নয়; একই সিস্টেমে আলাদা ডেটা আলাদা কৌশলে চলে। দুটি প্রশ্ন সিদ্ধান্তের বেশিরভাগ ঠিক করে দেয়: পুরোনো মান দেখালে ক্ষতি কত, আর একটি লেখা হারালে ক্ষতি কত।
| কাজের ধরন | কৌশল | মেয়াদ আর invalidation | কেন |
|---|---|---|---|
| বহু পঠিত, কদাচিৎ বদলায় (catalogue, config) | cache-aside বা read-through | দীর্ঘ TTL + লেখায় delete | hit বেশি, পুরোনো হলে ক্ষতি কম |
| বহু পঠিত, বদলায়, একটু পুরোনো চলে (profile) | cache-aside + write-around | TTL কয়েক মিনিট + lease বা version | লেখকের জন্য read-your-writes |
| লেখা-ভারী counter, সামান্য হারানো চলে (view, like) | write-behind | flush এক সেকেন্ডের ঘরে | ডেটাবেসে লেখা কয়েক গুণ কম |
| লেখা হারানো চলবে না (অর্ডার, payment) | সরাসরি ডেটাবেস | cache শুধু দেখানোর কপি | durability আগে |
| কড়া consistency (balance, সিটের শেষ হিসাব) | সিদ্ধান্ত ডেটাবেস থেকে | দেখানোয় ছোট TTL আর সময় | ভুলের দাম টাকা |
| ভারী হিসাবের ফল (trending) | আগে হিসাব + refresh-ahead | পেছনের কাজ প্রতি মিনিটে | miss-এ হিসাব নয় |
| একবার লেখা, কদাচিৎ পড়া (log) | cache নয়, বা write-around | — | memory বাঁচে |
Cache মাপা — চারটি সংখ্যা, আর দুটি বাড়তি
- Hit ratio: মোট, আর key-এর ধরন (prefix) অনুযায়ী আলাদা — গড়ের ৯৫% লুকিয়ে রাখতে পারে যে একটি ধরন ৪০%-এ।
- Latency: hit আর miss-এর আলাদা histogram, p50 আর p99. Miss-এর p99 আসলে ডেটাবেসের স্বাস্থ্যের খবর।
- Memory: ব্যবহার, key-এর সংখ্যা, মানের গড় আকার। কয়েক MB-এর একটি key network আর latency দুটোই নষ্ট করে।
- Eviction rate: জায়গার অভাবে প্রতি সেকেন্ডে কত key সরানো হচ্ছে। এই eviction বাড়তে থাকা মানে cache working set-এর চেয়ে ছোট, hit ratio পড়বে (পর্ব ২৯)।
সাথে দুটি: cache-এর পেছনে ডেটাবেসের QPS — আসল উদ্দেশ্যের মাপ — আর staleness, অর্থাৎ নমুনা key-এ cache আর ডেটাবেস মিলিয়ে দেখা। Dashboard, alert আর SLO পর্ব ৫০-এ। আর মাঝে মাঝে একটি পরীক্ষা: staging-এ cache বন্ধ করলে ডেটাবেস কতক্ষণ টেকে?
আরও গভীরে: Hardware-ও একই সমস্যায় পড়ে — বহু core-এর নিজস্ব cache-এ একই ঠিকানার কপি। সেখানে সমাধান coherence protocol, আর প্রায় সব আধুনিক নকশা write-update নয়, write-invalidate পথে চলে (MSI, MESI) — অংশ ৫-এর “delete, update নয়”-এর hardware-রূপ; দেখুন Multicore আর coherence পর্ব। আর Facebook-এর memcache গবেষণাপত্রে (NSDI ২০১৩) lease, commit log থেকে invalidation আর বহু region-এর যে নকশা প্রকাশিত হয়েছে, তা এই পর্বের কৌশলগুলোরই বিশাল মাপের রূপ — বিস্তারিত ভাবনার প্রশ্ন ২-এ।
পরীক্ষা ও ইন্টারভিউতে — বিশ্ববিদ্যালয়, চাকরি বা system design ইন্টারভিউর পরীক্ষায় এই ধরনের প্রশ্ন আসতে পারে:
- Cache-aside আর read-through-এর পার্থক্য? Miss-এ ডেটাবেসে কে যায় — cache-aside-এ app, read-through-এ cache-এর loader.
- tc = ১ ms, tdb = ২০ ms, hit ৯০% — গড়? ১ + ০.১ × ২০ = ৩ ms; miss-এও cache দেখার ১ ms যোগ হয়।
- Write-through বনাম write-behind — কোনটিতে লেখা হারায়? Write-behind-এ, flush-এর আগে cache বিকল হলে; ক্ষতির জানালা flush-এর ব্যবধান।
- Cache update না করে delete কেন? Delete-এর ক্রম লাগে না, retry নিরাপদ, অপচয় কম; বাকি stale set ঠেকায় lease বা version, সীমা টানে TTL.
- Cache পুরো খালি হলে কী হয়? ডেটাবেস পায় সব অনুরোধ — warming, coalescing আর ধীরে traffic ফেরানো লাগে।
৯ · AI-এর যুগে — caching-এর পরামর্শ যাচাই
“সাইট ধীর” বললে AI প্রায় সবসময় বলে “Redis যোগ করুন”। Cache প্রায়ই উত্তরের অংশ, কিন্তু caching-এর ভুল নিঃশব্দ: কোনো error নেই, শুধু পুরোনো দাম, হারানো like, বা cache restart-এ বসে যাওয়া ডেটাবেস। চেনা ভুলগুলো:
| AI-এর ভুল | কেন ভুল | কীভাবে ধরবেন |
|---|---|---|
| “Redis বসান, সমস্যা মিটে যাবে” — কৌশল, TTL বা invalidation-এর কথা নেই | পুরোনো ডেটা আর cache বিকলের পথ অজানা | প্রতিটি ডেটার জন্য পড়া-লেখার পথ আর TTL লিখিয়ে নেওয়া |
| “আগে cache delete, তারপর ডেটাবেস update” | সবচেয়ে বড় race-এর জানালা (ক্রম ক) | অংশ ৫-এর তিন ক্রম মিলিয়ে দেখা |
| Hit ratio-র হিসাবে miss-এ cache-এর সময় বাদ, বা “৯৫ থেকে ৯৯ মাত্র ৪% উন্নতি” | miss ৫% থেকে ১% — ডেটাবেসের ভার ৫ গুণ কম | tc + (১ − h) × tdb আর R × (১ − h) নিজে হিসাব |
| “Payment দ্রুত করতে write-behind”, বা cache-এর balance দেখে টাকা কাটা | crash-এ OK-পাওয়া লেনদেন হারায়; পুরোনো balance-এ দুবার খরচ | “লেখা হারালে বা পুরোনো দেখালে ক্ষতি কত?” প্রশ্ন |
| Redis বা Memcached-এর ডিফল্ট, সীমা বা দাম আত্মবিশ্বাসে বলা | প্রায়ই ভুল বা পুরোনো; যেমন maxmemory-policy-র ডিফল্ট | নিজের সংস্করণের documentation আর config |
| সেকেন্ডে একশো অনুরোধের অ্যাপে তিন স্তরের cache | over-engineering, invalidation তিন গুণ কঠিন | আগে মেপে দেখা ডেটাবেস আসলেই ধীর কি না |
প্রশ্নে সংখ্যা, ডেটার ধরন আর ঝুঁকি দিয়ে দিন, আর প্রতিটি সিদ্ধান্তের কারণ চান:
AI-এর caching-পরিকল্পনা হাতে পেলে — যাচাইয়ের চেকলিস্ট
Miss গুনুন, লেখার ক্রম পড়ুন, আর cache খালি হলে কী হবে — আগে ভাবুন।
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন। কমান্ডের খুঁটিনাটি নয়, নকশার সিদ্ধান্তের পেছনের কারণগুলোই আসল লক্ষ্য।
প্র ০১“Computer science-এ কঠিন কাজ মাত্র দুটি: cache invalidation আর নাম দেওয়া” — কথাটি কোথা থেকে, আর invalidation আসলে এত কঠিন কেন?
উক্তিটি প্রায়ই Netscape-এর প্রকৌশলী Phil Karlton-এর নামে উদ্ধৃত হয়; Martin Fowler-সহ অনেকে লেখায় তাঁকেই উৎস বলেছেন, যদিও মূল লিখিত উৎস খুঁজে পাওয়া কঠিন। ঠাট্টার আড়ালে কথাটি গুরুতর, আর এই পর্বের প্রায় সব সমস্যা তার কারণ দেখায়।
- দুই কপি, এক transaction নেই। ডেটাবেসের commit আর cache-এর delete দুটি আলাদা কাজ, আলাদা মেশিনে। মাঝখানে app crash করতে পারে, network ভাঙতে পারে, delete-এর timeout হতে পারে। প্রতিটি ক্ষেত্রে কপি দুটি আলাদা হয়ে যায়, আর কেউ টেরও পায় না।
- ক্রম আর সমকালীনতা। অংশ ৫-এর stale set: প্রতিটি অনুরোধ আলাদাভাবে নির্ভুল, শুধু একটি দুর্ভাগা ক্রমে ভুল। টেস্টে প্রায় কখনো ধরা পড়ে না, ধরা পড়ে ভিড়ের দিনে।
- নির্ভরতার হিসাব। একটি পণ্যের দাম বসে আছে পণ্যের পাতায়, category-র তালিকায়, সার্চের ফলে, কারও cart-এ। কোন কোন key মুছতে হবে, তা জানা নিজেই একটি নকশার সমস্যা। একটি সমাধান tag ধরে মোছা (CDN-এ surrogate key, পর্ব ১৭); আরেকটি key-এর নামে version —
v42:product:7— যাতে version বাড়ালেই পুরোনো সব key অপ্রাসঙ্গিক, পরে TTL-এ নিজে থেকেই সরে। - বিস্তার। বহু cache node, প্রতিটি সার্ভারের local cache, বহু region — delete-কে সবার কাছে পৌঁছাতে হবে, আর পথে হারালে কেউ জানবে না।
তাই বাস্তবের নকশা কয়েক স্তরের: delete (ক্রমহীন, idempotent), প্রতিটি key-তে TTL (ভুলের সর্বোচ্চ আয়ু), হট key-তে lease বা version (race বন্ধ), আর commit log থেকে invalidation (delete হারায় না)। কোনো একটি একা যথেষ্ট নয়।
মূল উপলব্ধি: Invalidation কঠিন, কারণ এটি আসলে একটি distributed consistency-র সমস্যা — শুধু ছোট মাপে, আর ছদ্মবেশে। “Cache যোগ করা” মানে একটি দ্বিতীয় কপি তৈরি করা; তার দাম শুরুতেই হিসাব করুন।
প্র ০২একটি বিশাল social network memcache দিয়ে ডেটাবেসকে কীভাবে রক্ষা করেছিল — NSDI ২০১৩-এর “Scaling Memcache at Facebook” থেকে কী শেখার আছে?
Nishtala ও সহকর্মীদের এই গবেষণাপত্র বিশ্বের সবচেয়ে বড় cache-ব্যবস্থাগুলোর একটির প্রকাশিত বর্ণনা — নিচের সব কথা সেই বর্ণনা অনুযায়ী, পরের পরিবর্তন জানা নেই। মূল কাঠামো ঠিক এই পর্বের cache-aside: web সার্ভার আগে memcache দেখে, miss হলে MySQL থেকে পড়ে বসায়; লেখায় আগে ডেটাবেস, তারপর memcache থেকে delete. গবেষণাপত্রের যুক্তি: delete idempotent, update নয়।
- Lease: miss-এ একটি ৬৪-bit token; মাঝে delete এলে token অচল, তাই stale set বসতে পারে না। একই key-এ token কিছুক্ষণে একবারই দেওয়া হয় (গবেষণাপত্রে ডিফল্ট হিসেবে প্রতি ১০ সেকেন্ডে একবারের কথা আছে), বাকিরা একটু অপেক্ষা করে আবার দেখেন — ফলে জনপ্রিয় key-এ হঠাৎ ভিড়ও কমে।
- Commit log থেকে invalidation: mcsqueal নামের প্রক্রিয়া ডেটাবেসের commit log পড়ে delete ছড়িয়ে দেয় — web সার্ভার commit-এর পরে মারা গেলেও delete হারায় না। এটি change data capture-এর একটি প্রাথমিক রূপ।
- বহু region: একটি region-এ মূল ডেটাবেস, বাকিগুলোতে replica. Replica-region-এর cache-এর invalidation আসে সেখানকার replica-র log থেকে — বদল replica-তে পৌঁছানোর পরে delete, ফলে replica থেকে পুরোনো fill-এর সমস্যা কমে। নিজের লেখা নিজে দেখার জন্য ছিল “remote marker”: লেখক কিছুক্ষণ মূল region থেকে পড়েন।
- Gutter: বিকল memcache সার্ভারের ভার নিতে কয়েকটি অতিরিক্ত সার্ভারের ছোট দল, যাতে একটি node মারা গেলেই তার সব miss ডেটাবেসে আছড়ে না পড়ে।
খেয়াল করার মতো বিষয়: তাঁরা strong consistency দাবি করেননি। পুরোনো মান কতটা দেখা যায়, তা মেপে দেখেছেন আর সহনীয় সীমায় রেখেছেন — নকশার লক্ষ্য ছিল “যথেষ্ট টাটকা, আর ডেটাবেস নিরাপদ”।
মূল উপলব্ধি: বিশাল মাপে নতুন কোনো জাদু নেই — সেই একই cache-aside, delete, lease আর log; পার্থক্য শুধু প্রতিটি ফাঁক মেপে বন্ধ করার শৃঙ্খলায়। আর consistency-র দাবি নয়, consistency-র মাপ।
প্র ০৩CPU-র cache আর OS-এর page cache তো write-back-ই চলে — তাহলে অ্যাপের write-behind cache নিয়ে এত সতর্কতা কেন?
প্রশ্নটি যুক্তিসংগত: আধুনিক CPU-র data cache সাধারণত write-back, আর Linux-এর page cache-ও ফাইলে লেখা RAM-এ জমিয়ে পরে ডিস্কে নামায় (dirty page কিছুক্ষণ পরে লেখা হয়; মেয়াদ আর সীমা kernel-এর সেটিং, সংস্করণভেদে আলাদা)। তবু সেখানে এটি নিরাপদ, কারণ তিনটি জিনিস মেলে, যা অ্যাপের write-behind-এ মেলে না।
- একই ব্যর্থতার ক্ষেত্র। CPU-র cache আর RAM একই মেশিনে, একই বিদ্যুতে; বিদ্যুৎ গেলে দুটোই যায়, আর প্রোগ্রামের অবস্থাও যায় — cache-এর কারণে আলাদা করে কিছু হারায় না। অ্যাপের cache node আলাদা মেশিন; সে একা মারা যেতে পারে, অথচ ব্যবহারকারী ইতিমধ্যে “OK” পেয়েছেন।
- স্পষ্ট টেকসই হওয়ার পথ। OS কাউকে বলে না যে লেখা ডিস্কে পৌঁছেছে; যার নিশ্চয়তা দরকার, সে fsync ডাকে। ডেটাবেস ঠিক এটাই করে: commit-এর আগে WAL-এ লিখে fsync (পর্ব ১৯)। ২০১৮ সালে PostgreSQL সম্প্রদায়ে আলোচিত “fsyncgate” দেখিয়েছিল এই পথ কতটা সূক্ষ্ম: Linux-এ fsync ব্যর্থ হলে dirty page বাদ পড়তে পারত, ফলে পরের fsync সফল দেখালেও ডেটা হারাত; PostgreSQL পরে fsync-এর ব্যর্থতাকে গুরুতর ত্রুটি ধরে log থেকে পুনরুদ্ধারের পথ নেয়।
- Hardware নিজে নিশ্চয়তা দেয়। বহু core-এর cache coherence protocol মেলায়; RAID controller-এর write-back cache নিরাপদ হয় battery বা flash-সহ সুরক্ষায়, যাতে বিদ্যুৎ গেলেও জমা লেখা পরে নামানো যায়।
অ্যাপের write-behind-এ এর কোনোটিই আপনাআপনি নেই। উপরন্তু ডেটাবেসের অন্য পাঠক — report, search, অন্য সার্ভিস — পিছিয়ে থাকা ছবি দেখে, আর flush-এ ধরা পড়া শর্তভঙ্গের কথা ব্যবহারকারীকে আর জানানো যায় না। তাই write-behind ঠিক আছে যেখানে সামান্য ক্ষতি সহনীয় (view, like, analytics), অথবা যেখানে cache-কেই টেকসই করা হয়েছে — replica আর নিজের append-only log-সহ, যা তখন কার্যত একটি ডেটাবেস।
মূল উপলব্ধি: Write-back নিরাপদ তখনই, যখন cache আর টেকসই হওয়ার প্রতিশ্রুতি একই ব্যর্থতার ক্ষেত্রে থাকে, বা cache নিজেই টেকসই। “OK” কে, কখন বলছে — নকশার আসল প্রশ্ন সেটিই।
প্র ০৪Cache তো শুধু গতি বাড়ায় — তাহলে cache খালি হলে বা ভুল মান রাখলে পুরো সাইট কেন বসে যেতে পারে?
২০১০ সালের ২৩ সেপ্টেম্বর Facebook প্রায় আড়াই ঘণ্টা অনেকের কাছে অচল ছিল। প্রতিষ্ঠানটির প্রকৌশলীদের প্রকাশিত ব্যাখ্যা অনুযায়ী ঘটনাটি ছিল cache আর ডেটাবেসের মাঝের একটি feedback loop. Cache-এ থাকা configuration-এর মান যাচাই করে ভুল পেলে ডেটাবেস থেকে ঠিক মান আনার একটি স্বয়ংক্রিয় ব্যবস্থা ছিল। ডেটাবেসের মূল কপিতে একটি মান বদলানো হলো, যাকে ব্যবস্থাটি “ভুল” বলে ধরল। ফলে প্রতিটি client একসাথে ঠিক করতে ডেটাবেসে গেল, ডেটাবেস-গুচ্ছ ভারে বসে গেল, আর ডেটাবেসের error-কেও ভুল মান ধরে client cache-এর key মুছে আবার ডেটাবেসে গেল — চাপ বাড়তেই থাকল। শেষে ডেটাবেসের দিকে traffic পুরো থামিয়ে ব্যবস্থাটি বন্ধ করে তবে ফেরানো গিয়েছিল।
শিক্ষাগুলো সব ধরনের cache-এ খাটে।
- Cache ভার বহন করে। অংশ ১-এর টেবিলে h = ০-এ ডেটাবেসে ৪০০% ভার। Cache-এর ওপর ভর করে ডেটাবেস ছোট রাখলে cache খালি হওয়া মানে বিভ্রাট — তাই warming, ধীরে ধীরে traffic ফেরানো আর coalescing.
- Error আর miss এক নয়। ডেটাবেস error দিলে key মুছে আবার চেষ্টা করা আগুনে ঘি। Error-এ পুরোনো মান দেখানো (stale-if-error-এর মতো), retry-র সীমা আর circuit breaker (পর্ব ৪৮)।
- নিজেকে টিকিয়ে রাখা বিভ্রাট। মূল কারণ সরালেও চাপ থামে না, কারণ চাপই নতুন চাপ তৈরি করছে — এটি metastable failure (পর্ব ১৮)। বের হতে প্রায়ই ভার কমাতে হয় বাইরে থেকে।
- Cache-এর ভুল মান নিঃশব্দে ছড়ায়। একটি ভুল মান লাখো অনুরোধে পৌঁছায়, আর “cache-এ আছে” বলে কেউ সন্দেহ করে না — তাই staleness মাপা আর নমুনা যাচাই।
মূল উপলব্ধি: যে মুহূর্তে ডেটাবেসের আকার cache-এর hit ratio ধরে ঠিক করা হয়, cache আর “ঐচ্ছিক গতি” নয়, availability-র অংশ। তাকে availability-র মতোই নকশা আর পরীক্ষা করুন — খালি cache-সহ।
প্র ০৫Hit ratio ৯৯% — তবু অনেক পাতা ধীর কেন? একটি পাতা বানাতে ১০০টি cache lookup লাগলে কী হয়?
গড় ভালো, কিন্তু পাতা বানায় একসাথে অনেক lookup — এটি fan-out. প্রতিটি lookup স্বাধীনভাবে ৯৯% hit হলে ১০০টির সবগুলো hit হওয়ার সম্ভাবনা ০.৯৯১০০ ≈ ০.৩৬৬. অর্থাৎ প্রায় ৬৩.৪% পাতায় অন্তত একটি miss. Lookup-গুলো সমান্তরালে চললে পাতা অপেক্ষা করে সবচেয়ে ধীরটির জন্য — তাই ৬৩% পাতা ১ ms-এর বদলে ২১ ms নেয়। ১০টি lookup হলে এই ভাগ প্রায় ৯.৬%; hit ৯৯.৯% হলে ১০০টিতেও প্রায় ৯.৫%. ব্যবহারকারীর চোখে এটিই tail latency: অল্প ধীর অংশ, বহুগুণে বড় হয়ে। অথচ প্রতিটি lookup-এর গড় তখনো মাত্র ১.২ ms — dashboard-এ সব সবুজ, আর ব্যবহারকারী বিরক্ত।
Google-এর Jeffrey Dean আর Luiz André Barroso তাঁদের “The Tail at Scale” (Communications of the ACM, ২০১৩) প্রবন্ধে এই অঙ্কটিই সামনে আনেন: বহু অংশে ভাগ করা অনুরোধে প্রতিটি অংশের বিরল দেরিও প্রায় প্রতিটি অনুরোধকে ছুঁয়ে যায়। তাঁদের প্রস্তাবিত কৌশলের মধ্যে আছে hedged request, অর্থাৎ দেরি হলে দ্বিতীয় কপিতে একই অনুরোধ — যা ডেটাবেসের দিকে সাবধানে, সীমা রেখে ব্যবহার করতে হয়।
Cache-এর দিক থেকে প্রতিকার:
- Lookup কমানো: পাতার একসাথে লাগা অংশগুলো একটি key-তে রাখা (denormalized cache object), বা multi-get দিয়ে এক round trip-এ অনেক key একসাথে আনা।
- হট key-এর miss মুছে ফেলা: refresh-ahead, যাতে জনপ্রিয় key-এর মেয়াদ ফুরানোর মুহূর্তটাই না আসে।
- কাছে আরেক স্তর: খুব জনপ্রিয় key সার্ভারের local cache-এ — পর্ব ৩০-এ।
- ঠিক জিনিস মাপা: শুধু key-এর hit ratio নয়, পুরো পাতা বানানোর p99 (পর্ব ৪, পর্ব ৫০)।
মূল উপলব্ধি: Hit ratio একটি key-এর মাপ, ব্যবহারকারী দেখেন পুরো পাতা। Fan-out থাকলে “৯৯% hit” মানে “প্রায় দুই-তৃতীয়াংশ পাতায় অন্তত একটি miss” — সংখ্যাটি সবসময় পাতার স্তরে অনুবাদ করে দেখুন।
অনুশীলন
প্রথমে নিজে লিখুন, তারপর উত্তর খুলুন। সংখ্যার প্রশ্নে প্রতিটি ধাপ লিখে হিসাব করুন।
-
কৌশল বাছাই: একটি কাল্পনিক অনলাইন বাজার আর তার wallet. চার ধরনের ডেটার প্রতিটির জন্য পড়ার কৌশল, লেখার কৌশল, TTL আর সবচেয়ে বড় ঝুঁকি লিখুন — (ক) পণ্যের catalogue, (খ) wallet balance, (গ) ব্যবহারকারীর প্রোফাইল, (ঘ) trending তালিকা।
(ক) Catalogue: cache-aside বা read-through; লেখায় write-around — ডেটাবেসে COMMIT, তারপর key delete; TTL দীর্ঘ, ধরা যাক ১০ মিনিট থেকে এক ঘণ্টা। ঝুঁকি: একটি পণ্যের বদল অনেক key-তে পড়ে (পাতা, category, সার্চ) — কোনটি মুছবেন তার তালিকা বা tag লাগে। দাম আর স্টক আলাদা key-তে ছোট TTL-এ, কারণ এগুলো বেশি বদলায়।
(খ) Wallet balance: টাকা কাটার সিদ্ধান্ত কখনো cache থেকে নয় — ডেটাবেসের শর্তযুক্ত transaction-এ (পর্ব ২৪)। দেখানোর পর্দায় চাইলে ছোট TTL-এর কপি, সাথে “শেষ হালনাগাদ”-এর সময়; লেনদেনের পরে সেই key delete. Write-behind কোনোভাবেই নয়। ঝুঁকি: পুরোনো balance দেখে দ্বিতীয় খরচ অনুমোদন।
(গ) প্রোফাইল: cache-aside, লেখায় COMMIT-এর পরে delete, TTL কয়েক মিনিট, জনপ্রিয় প্রোফাইলে lease বা version. ঝুঁকি: read-your-writes — replica থেকে fill হলে লেখক নিজের পুরোনো নাম দেখবেন; প্রতিকার লেখার পরে কিছুক্ষণ লেখকের পড়া leader থেকে (অংশ ৬)।
(ঘ) Trending: আগে হিসাব — পেছনের কাজ প্রতি মিনিটে হিসাব করে cache-এ লেখে; পাঠক কখনো হিসাব চালান না; refresh-ahead বা miss-এ শেষ জানা ফল। এক-দুই মিনিট পুরোনো চলে। ঝুঁকি: পেছনের কাজ থেমে গেলে তালিকা নিঃশব্দে পুরোনো — তাই “শেষ হিসাবের সময়” মেপে alert.
-
হিসাব — ডেটাবেসের ভার: সেকেন্ডে ৫০,০০০ পড়া আর ৫০০ লেখা; লেখাগুলো write-around কৌশলে সরাসরি ডেটাবেসে যায়। ডেটাবেস স্বস্তিতে সামলায় সেকেন্ডে ৩,০০০ query. tc = ১ ms, tdb = ২০ ms. (ক) এখন hit ratio ৯২% — ডেটাবেসের ভার কত, সামলাতে পারবে? (খ) ন্যূনতম hit ratio কত? সেখানে গড় পড়ার latency কত? (গ) Cache restart হয়ে পুরো খালি হলে ডেটাবেসে কত গুণ ভার?
(ক) পড়ার miss = ৫০,০০০ × (১ − ০.৯২) = ৪,০০০; সাথে লেখা ৫০০ — মোট ৪,৫০০ q/s, সীমার ১৫০%. পারবে না; latency বাড়বে, queue জমবে।
(খ) ৫০,০০০ × (১ − h) + ৫০০ ≤ ৩,০০০ → (১ − h) ≤ ২,৫০০ ÷ ৫০,০০০ = ০.০৫ → h ≥ ৯৫%. সেখানে গড় = ১ + ০.০৫ × ২০ = ২ ms. তবে ৯৫%-এ ডেটাবেস ঠিক ১০০%-এ — কোনো ফাঁকা জায়গা নেই; বাস্তবে লক্ষ্য রাখুন ৯৭%-এর মতো, যেখানে ভার ১,৫০০ + ৫০০ = ২,০০০, অর্থাৎ প্রায় ৬৭%, আর গড় ১.৬ ms.
(গ) h = ০: ৫০,০০০ + ৫০০ = ৫০,৫০০ q/s — সীমার প্রায় ১৬.৮ গুণ। তাই restart-এর আগে warming, বা নতুন cache-এ ধীরে ধীরে traffic ফেরানো, আর coalescing; নইলে ডেটাবেস বসে যাবে আর cache কখনো ভরবেই না।
-
Race খুঁজুন: নিচের কোডে দাম বদলানো আর পড়ার পথ। কোন race-এ পুরোনো দাম cache-এ থেকে যেতে পারে, কতক্ষণ, আর কীভাবে ঠিক করবেন?
function updatePrice(id, price): cache.delete("price:" + id) # ১ db.execute("UPDATE products SET price = ? WHERE id = ?", price, id) # ২ function getPrice(id): p = cache.get("price:" + id) if p == null: p = db.query("SELECT price FROM products WHERE id = ?", id) cache.set("price:" + id, p) # TTL নেই return pমূল race — ক্রম (ক): লাইন ১-এর delete আর লাইন ২-এর COMMIT-এর মাঝখানে কোনো পাঠক
getPriceডাকলে miss করবেন, ডেটাবেস থেকে পুরোনো দাম পড়বেন আর বসাবেন।cache.set-এ TTL নেই, তাই পুরোনো দাম থাকবে পরের দাম-বদল পর্যন্ত — হয়তো কয়েক সপ্তাহ। ব্যস্ত পণ্যে এটি প্রায় নিশ্চিত।সংশোধন: (১) ক্রম উল্টান — আগে ডেটাবেসে UPDATE আর COMMIT, তারপর delete;
db.executeকোনো খোলা transaction-এর ভেতরে থাকলে delete যাবে commit-এর পরে। (২)cache.set-এ TTL দিন, যাতে যেকোনো ভুলের আয়ু সীমিত। (৩) ক্রম ঠিক করার পরেও stale set বাকি থাকে — পাঠক commit-এর আগে পড়ে delete-এর পরে বসালে; জনপ্রিয় key-এ lease বা version-সহ set. (৪) Delete ব্যর্থ হলে retry, বা commit log থেকে invalidation. -
আউটপুট অনুমান: অংশ ৫-এর race simulator-এ
TTL = Infinity-এর জায়গায়TTL = 200করলে তিন সারির “stale keys at end” আর “stale reads after t = 300 ms” কেমন বদলাবে? আগে অনুমান লিখুন, তারপর চালিয়ে মেলান।অনুমানের যুক্তি: লেখা শেষ প্রায় ৭০ ms-এ, আর আটকে থাকা fill বা set পৌঁছায় বড়জোর কয়েকশো ms-এর মধ্যে। প্রতিটি পুরোনো কপি বসার ২০০ ms পরে মেয়াদ ফুরাবে, পরের পাঠক miss করে টাটকা মান আনবেন। তাই এক সেকেন্ড শেষে কোনো key-ই ভুল থাকবে না — প্রথম কলাম তিন সারিতেই ০. ৩০০ ms-এর পরের stale পড়া শূন্য হবে না, কারণ কিছু পুরোনো কপি ৩০০ ms পেরিয়েও কিছুক্ষণ বাঁচে — কিন্তু অনেক কমবে। Lease-এ আগেও শূন্য, এখনো শূন্য।
আসল আউটপুট:
2000 keys, TTL = 200 ms, stale = cache older than the DB mode stale keys at end stale reads after t = 300 ms update 0 1467 of 279473 (0.52%) delete 0 1089 of 279473 (0.39%) lease 0 0 of 279473 (0.00%)Update-এ stale পড়া ৯.১৫% থেকে ০.৫২% (প্রায় ১৮ গুণ কম), delete-এ ১২.০০% থেকে ০.৩৯% (প্রায় ৩১ গুণ কম), আর শেষে ভুল key তিন সারিতেই ০. TTL race বন্ধ করে না, ভুলের আয়ু বেঁধে দেয়; ছোট TTL মানে কম staleness কিন্তু বেশি miss আর ডেটাবেসে বেশি ভার — সেই দর-কষাকষি পর্ব ২৯-এ।
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমLease-সহ cache-aside-এ একটি পড়ার ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)
- App cache-এ key খোঁজে
- Miss — cache একটি lease token দেয়
- App ডেটাবেস থেকে মান পড়ে
- App token দেখিয়ে TTL-সহ মানটি cache-এ বসায়
- পরের অনুরোধ cache থেকেই hit পায়
একটি সার্ভিসে cache lookup ১ ms, ডেটাবেস query ২০ ms; miss হলে আগে cache দেখে তারপর ডেটাবেসে যায়। Hit ratio ৯০% হলে গড় পড়ার latency কত?
- ২.৯ ms
- ৩ ms
- ২১ ms
- ১০.৫ ms
Cache-aside সিস্টেমে লেখার কোন ক্রমে পুরোনো মান cache-এ বসে যাওয়ার জানালা সবচেয়ে বড়?
- আগে cache delete, তারপর ডেটাবেস update
- আগে ডেটাবেস COMMIT, তারপর cache delete
- COMMIT-এর পরে delete, আর lease-সহ set
- COMMIT, delete, কিছুক্ষণ পরে আবার delete
একটি write-behind cache প্রতি ৫ সেকেন্ডে ডেটাবেসে flush করে। এর সবচেয়ে বড় ঝুঁকি কোনটি?
- প্রতিটি লেখা ধীর হয়ে যায়
- লেখার পরের পড়া সবসময় miss
- Flush-এর আগে cache node বিকল হলে OK-পাওয়া লেখা হারানো
- ডেটাবেসে লেখার সংখ্যা বেড়ে যাওয়া
Memcache-ধাঁচের lease কীভাবে “stale set” ঠেকায়?
- প্রতিটি লেখার সময় পুরো cache lock করে
- সব key-এর TTL ছোট করে দেয়
- সব লেখা cache-এর মধ্য দিয়ে ডেটাবেসে পাঠায়
- Miss-এ একটি token দেয়, মাঝের delete সেটি বাতিল করে, পুরোনো token-এর set প্রত্যাখ্যাত হয়
একটি সার্ভিসে সেকেন্ডে ২০,০০০ পড়া। Cache-এর hit ratio ৯৫% থেকে নেমে ৯০% হলে ডেটাবেসে পড়ার query কী হবে?
- প্রায় ৫% বাড়বে
- সেকেন্ডে ১,০০০ থেকে ২,০০০ — দ্বিগুণ
- সেকেন্ডে ১৮,০০০
- বদলাবে না, cache তো এখনো চলছে
একটি কাল্পনিক mobile wallet-এ টাকা পাঠানোর আগে balance যাচাই করতে হবে। কোন নকশা সঠিক?
- ডেটাবেসের শর্তযুক্ত transaction-এ যাচাই ও কাটা; cache শুধু দেখানোর জন্য
- Cache-এর balance দেখে অনুমোদন, পরে ডেটাবেস
- Write-behind cache-এ balance কমানো, পরে flush
- TTL এক সেকেন্ড রাখলেই cache থেকে যাচাই নিরাপদ
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- গড় latency = tc + (১ − h) × tdb, ডেটাবেসের ভার = R × (১ − h); ডেটাবেস দেখে miss ratio, আর cache খালি হলে h = ০.
- পড়ায় cache-aside (app নিজে আনে) বা read-through (cache-এর loader আনে); দুটিতেই শুধু চাওয়া ডেটা cache-এ।
- লেখায় write-through টাটকা কিন্তু ধীর, write-behind দ্রুত কিন্তু flush-এর জানালায় লেখা হারায়, write-around শুধু ডেটাবেসে আর cache-এ delete.
- COMMIT-এর পরে delete, প্রতিটি key-তে TTL, হট key-তে lease বা version — update-এর চেয়ে delete নিরাপদ, কিন্তু একা যথেষ্ট নয়।
- কৌশল বাছুন “পুরোনো দেখালে ক্ষতি” আর “লেখা হারালে ক্ষতি” দিয়ে; balance-এর সিদ্ধান্ত কখনো cache থেকে নয়; মাপুন hit ratio, latency, memory আর eviction rate.
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ২৯ · Eviction আর invalidation — LRU, LFU, TTL আর cache stampedeপরবর্তী পর্বকোন কপি সরবে, কতক্ষণ বাঁচবে, আর মেয়াদ ফুরানোর ভিড় সামলানো।
- পর্ব ৩০ · Distributed cache — partitioning, replication, hot key আর বহু-স্তরের cacheএই মডিউলেএকটি cache সার্ভার যথেষ্ট না হলে — বহু node, hot key আর local cache-এর স্তর।
- পর্ব ২২ · Replication — leader-follower, multi-leader, leaderless আর replication lagআগের মডিউলRead-your-writes আর replica-র দেরি — cache-এর সাথে যার সংঘাত অংশ ৬-এ।
- সিস্টেম ডিজাইন · কোর্স হোম৬৬টি পর্বপুরো কোর্সের রোডম্যাপ — কোন পর্ব কখন।
- Computer Architecture · Cache performance — write-through, write-back আর AMATগভীরে যানCPU cache-এ একই নাম, একই সূত্র — nanosecond-এর মাপে।
- DBMS · Redis — মেমরিতে রাখা data structure serverপাশের ট্র্যাকTTL, caching pattern, persistence আর cluster — কমান্ড ধরে।