ক্যাশ স্ট্র্যাটেজি
Cache-aside, read-through, write-through, write-behind — কোনটা কখন ব্যবহার করবেন আর ভুল করলে কী ভাঙে।
বাস্তব জীবনের উপমা
একজন বাবুর্চি যিনি রান্নার আগেই সব উপকরণ প্রস্তুত করে রাখেন (write-through) বনাম আরেকজন যিনি প্রয়োজন হলেই কেবল প্যান্ট্রি থেকে জিনিস নেন (cache-aside) — একই রান্নাঘর, ভিন্ন ছন্দ।
গল্পে বুঝি
আল-খোয়ারিজমির একটা মুদি-হার্ডওয়্যারের দোকান, পেছনে একটা ছোট গুদাম যেখানে বস্তা-বাক্সে আসল স্টক থাকে আর একটা মোটা খাতায় সব হিসাব লেখা। সামনের কাউন্টারের তাকে সে ঘনঘন লাগা জিনিস সাজিয়ে রাখে। কেউ পেরেক চাইলে কাউন্টারে থাকলে সাথে সাথে দেয়; না থাকলে আল-খোয়ারিজমি নিজেই উঠে গুদামে হেঁটে যায়, এক প্যাকেট এনে দেয়, আর কয়েক প্যাকেট কাউন্টারের তাকে রেখে দেয় — পরেরবার যেন আর হাঁটতে না হয়। এই যে সে নিজেই খুঁজে না পেলে গুদামে যায়, এটাই cache-aside। পাশের দোকানে ইবনে সিনার আবার একটা কাজের ছেলে ফাতিমা আল-ফিহরির ভাই আছে — ইবনে সিনা কাউন্টারে জিনিস না পেলে নিজে যায় না, ছেলেটাকে বলে দেয়, ছেলেটাই গুদাম থেকে এনে তাকে সাজিয়ে দেয়। ইবনে সিনা শুধু তাকের দিকেই তাকায়, গুদামের ব্যাপারটা ছেলেটা সামলায় — এটাই read-through, যেখানে ক্যাশ লেয়ার নিজেই DB থেকে এনে দেয়।
এবার হিসাব লেখার পালা। বিক্রি হলে আল-খোয়ারিজমি প্রতিবার একসাথে দুটো কাজ করে — কাউন্টারের স্টক কমায় আর সাথে সাথেই গুদামের মোটা খাতায় এন্ট্রি তোলে। ফলে তার কাউন্টার আর খাতা সবসময় মিলে থাকে, কিন্তু প্রতিটা বিক্রিতে দুই জায়গায় লেখায় একটু সময় বেশি লাগে — এটাই write-through। ভিড়ের সময় ইবনে সিনার আবার এত ধৈর্য নেই; সে ঝটপট শুধু কাউন্টারে হিসাব রাখে, খদ্দের বিদায় করে দেয়, আর ভাবে রাতে দোকান বন্ধ করার আগে একবারে সব খাতায় তুলে দেবে। এতে বিক্রি দ্রুত হয়, কিন্তু যদি সে ভুলে যায় বা রাতে দোকানে আগুন লাগে, তাহলে দিনের যেসব বিক্রি খাতায় ওঠেনি সেগুলো চিরতরে হারিয়ে যায় — এটাই write-behind, দ্রুত কিন্তু ঝুঁকিপূর্ণ।
মিলিয়ে নিই: কাউন্টারে না পেলে আল-খোয়ারিজমির নিজে গুদামে যাওয়া হলো cache-aside, কাজের ছেলের হয়ে এনে দেওয়া হলো read-through, বিক্রির সাথে সাথেই কাউন্টার আর খাতা দুটোই আপডেট করা হলো write-through, আর রাতে একবারে খাতায় তোলা হলো write-behind। বাস্তবে cache-aside আর read-through দিয়ে পড়ার চাপ কমানো হয়, write-through দিয়ে যেখানে ডেটা সবসময় মিলতে হবে (যেমন প্রোফাইল আপডেট), আর write-behind দিয়ে কাউন্টার, ভিউ কাউন্ট বা অ্যানালিটিক্সের মতো জায়গায় গতি বাড়ানো হয় — যেখানে কয়েক সেকেন্ডের হিসাব হারালেও বিশাল ক্ষতি নেই।
চারটি স্ট্র্যাটেজি
প্রতিটি ক্যাশিং সিস্টেম এই চারটি প্যাটার্নের কোনো একটির উপর — বা এগুলোর সংমিশ্রণে — তৈরি। এই পছন্দটাই ঠিক করে দেয় ক্যাশ পপুলেট করার দায়িত্ব কার, স্টেল ডেটা কীভাবে দেখা দেয়, এবং write-এর সময় কী ঘটে।
| স্ট্র্যাটেজি | DB থেকে কে পড়ে | DB-তে কে লেখে | কনসিস্টেন্সি |
|---|---|---|---|
| Cache-aside | অ্যাপ্লিকেশন | অ্যাপ্লিকেশন | Eventual |
| Read-through | ক্যাশ লেয়ার | অ্যাপ্লিকেশন | Eventual |
| Write-through | অ্যাপ্লিকেশন | ক্যাশ লেয়ার → DB | Strong |
| Write-behind | অ্যাপ্লিকেশন | ক্যাশ লেয়ার (async) | Eventual |
Cache-Aside (Lazy Loading)
সবচেয়ে প্রচলিত প্যাটার্ন। সব লজিকের মালিক অ্যাপ্লিকেশন নিজেই: ক্যাশ চেক করো, miss হলে → DB থেকে ফেচ করো, ক্যাশ পপুলেট করো।
class UserService {
constructor(
private cache: RedisClient,
private db: Database
) {}
async getUser(id: string): Promise<User> {
const cacheKey = `user:${id}`;
// 1. Try cache
const cached = await this.cache.get(cacheKey);
if (cached) return JSON.parse(cached);
// 2. Cache miss — fetch from DB
const user = await this.db.users.findById(id);
if (!user) throw new NotFoundError(`User ${id} not found`);
// 3. Populate cache (TTL: 5 minutes)
await this.cache.setex(cacheKey, 300, JSON.stringify(user));
return user;
}
async updateUser(id: string, data: Partial<User>): Promise<User> {
const user = await this.db.users.update(id, data);
// Invalidate — don't update cache, let next read repopulate
await this.cache.del(`user:${id}`);
return user;
}
} সুবিধা: সরল। শুধু যা আসলেই চাওয়া হয় তাই ক্যাশ করে। Redis রিস্টার্ট হলেও ক্যাশ টিকে থাকে (পরের রিকোয়েস্টে DB থেকে আবার পপুলেট হয়ে যায়)।
অসুবিধা: miss-এর পরের প্রথম রিকোয়েস্ট ধীর। write-এর সময় invalidate করতে ভুলে গেলে স্টেল ডেটা সার্ভ হতে পারে।
Cache-aside হলো সঠিক ডিফল্ট। যতক্ষণ না নির্দিষ্ট কোনো কারণ থাকে অন্য কিছু ব্যবহার করার, ততক্ষণ এটাই ব্যবহার করুন।
Read-Through
miss হলে ক্যাশ লেয়ার নিজেই ডেটাবেস থেকে ফেচ করে, অ্যাপ্লিকেশনের কাছে ব্যাপারটা অদৃশ্য থাকে। অ্যাপ্লিকেশন শুধু ক্যাশের সাথে কথা বলে।
// The cache wraps the data source
class ReadThroughCache<T> {
private store = new Map<string, { value: T; expiresAt: number }>();
constructor(private loader: (key: string) => Promise<T>) {}
async get(key: string): Promise<T> {
const entry = this.store.get(key);
if (entry && Date.now() < entry.expiresAt) {
return entry.value; // hit
}
// miss — load through
const value = await this.loader(key);
this.store.set(key, { value, expiresAt: Date.now() + 300_000 });
return value;
}
}
// Application just calls get — doesn't know about DB
const userCache = new ReadThroughCache<User>((id) => db.users.findById(id));
const user = await userCache.get('user:123'); সুবিধা: অ্যাপ্লিকেশন কোড আরও সরল — সব ক্যাশ পপুলেশন লজিক এক জায়গায় হ্যান্ডল হয়।
অসুবিধা: cold start-এ এখনও latency থাকে। cache miss ভিন্নভাবে হ্যান্ডল করা কঠিন (যেমন, null রিটার্ন করা বনাম throw করা)। node-cache-manager-এর মতো লাইব্রেরিগুলো এই প্যাটার্ন বাস্তবায়ন করে।
Write-Through
প্রতিটি write ক্যাশের মধ্য দিয়ে ডেটাবেসে যায়। ক্যাশ ও DB সবসময় সিঙ্কে থাকে।
class WriteThroughUserCache {
async saveUser(user: User): Promise<void> {
// Write to DB and cache atomically
await Promise.all([
this.db.users.upsert(user),
this.cache.setex(`user:${user.id}`, 3600, JSON.stringify(user))
]);
}
async getUser(id: string): Promise<User | null> {
const cached = await this.cache.get(`user:${id}`);
if (cached) return JSON.parse(cached);
// Only miss on first access or after eviction
const user = await this.db.users.findById(id);
if (user) {
await this.cache.setex(`user:${id}`, 3600, JSON.stringify(user));
}
return user;
}
} সুবিধা: ক্যাশ সবসময় ফ্রেশ — কোনো স্টেল read নেই। কোনো invalidation লজিকের দরকার নেই।
অসুবিধা: write latency বাড়ে (দুটো write)। ক্যাশ এমন ডেটায় ভরে যায় যা হয়তো কখনো পড়াই হবে না। প্রাথমিক লোডগুলো হ্যান্ডল করতে read-through-এর সাথে মিলিয়ে সবচেয়ে ভালো কাজ করে।
Write-through প্রায়ই read-through-এর সাথে মিলিয়ে ব্যবহার হয়। একসাথে এরা নিশ্চিত করে ক্যাশ সবসময় কনসিস্টেন্ট থাকে — তবে তার মূল্য হিসেবে write পারফরম্যান্স কমে এবং সম্ভবত খুব কম-পড়া ডেটাও ক্যাশ হয়।
Write-Behind (Write-Back)
write সাথে সাথে ক্যাশে চলে যায়, তারপর ক্যাশ অ্যাসিনক্রোনাসভাবে ডেটাবেসে ফ্লাশ করে। অ্যাপ্লিকেশন দ্রুত write অ্যাকনলেজমেন্ট পায়।
class WriteBehindCache {
private dirtyKeys = new Set<string>();
private flushInterval: NodeJS.Timeout;
constructor(
private cache: Map<string, unknown>,
private db: Database,
flushEveryMs = 1000
) {
// Flush dirty keys to DB every second
this.flushInterval = setInterval(() => this.flush(), flushEveryMs);
}
async write(key: string, value: unknown): Promise<void> {
this.cache.set(key, value); // immediate, synchronous
this.dirtyKeys.add(key); // mark for async flush
// Returns immediately — DB write happens later
}
private async flush(): Promise<void> {
const keys = [...this.dirtyKeys];
this.dirtyKeys.clear();
await Promise.all(
keys.map(async (key) => {
const value = this.cache.get(key);
if (value !== undefined) {
await this.db.set(key, value);
}
})
);
}
destroy(): void {
clearInterval(this.flushInterval);
}
} সুবিধা: সবচেয়ে কম write latency। একাধিক write-কে একটা DB অপারেশনে ব্যাচ করা যায়। কাউন্টার, ভিউ কাউন্ট, অ্যানালিটিক্সের জন্য চমৎকার।
অসুবিধা: ফ্লাশ হওয়ার আগেই ক্যাশ ক্র্যাশ করলে ডেটা হারানোর ঝুঁকি। ফেইলিওর হ্যান্ডলিং জটিল। আর্থিক বা ক্রিটিক্যাল ডেটার জন্য উপযুক্ত নয়।
যেখানে কয়েক সেকেন্ডের write হারানো মেনে নেওয়া যায় না, সেখানে কখনোই write-behind ব্যবহার করবেন না — পেমেন্ট, ইনভেন্টরি পরিবর্তন, ইউজার-জেনারেটেড কন্টেন্ট। পারফরম্যান্সের লাভটা ডেটা হারানোর ঝুঁকির তুলনায় মূল্যহীন।
Refresh-Ahead
অ্যাক্সেস প্যাটার্নের ভিত্তিতে, এন্ট্রি এক্সপায়ার হওয়ার আগেই আগেভাগে ক্যাশ রিফ্রেশ করা।
class RefreshAheadCache<T> {
private store = new Map<
string,
{
value: T;
expiresAt: number;
refreshAt: number; // refresh when this passes, before expiry
}
>();
constructor(
private loader: (key: string) => Promise<T>,
private ttlMs: number,
private refreshThreshold = 0.8 // refresh when 80% of TTL has passed
) {}
async get(key: string): Promise<T | null> {
const entry = this.store.get(key);
if (!entry) return null; // cold miss
const now = Date.now();
// Trigger background refresh if nearing expiry
if (now > entry.refreshAt && now < entry.expiresAt) {
this.refreshInBackground(key); // don't await
}
if (now > entry.expiresAt) return null; // expired
return entry.value;
}
private async refreshInBackground(key: string): Promise<void> {
const value = await this.loader(key);
const now = Date.now();
this.store.set(key, {
value,
expiresAt: now + this.ttlMs,
refreshAt: now + this.ttlMs * this.refreshThreshold
});
}
} সুবিধা: বেশিরভাগ cache miss দূর করে। জনপ্রিয় key কখনো cold থাকে না।
অসুবিধা: যেসব key আর পড়া হবে না সেগুলো রিফ্রেশ করে কাজ নষ্ট করে। সঠিকভাবে বাস্তবায়ন করা জটিল। বেশিরভাগ অ্যাপ্লিকেশনের জন্য অতিরিক্ত।
একটি স্ট্র্যাটেজি বেছে নেওয়া
Start here:
Is your read:write ratio > 10:1?
Yes → Cache-aside. Simple, effective.
No → Is write latency critical?
Yes → Write-behind (accept data loss risk)
No → Write-through (if consistency matters)
Do you need zero stale reads?
Yes → Write-through + short TTL
No → Cache-aside with reasonable TTL
Multiple services sharing the same data?
Yes → Distributed cache (Redis) + cache-aside
No → In-process cache, don't bother with Redis মূল ডিজাইন নিয়ম
সবসময় এন্ট্রি এক্সপায়ার করান। TTL ছাড়া ক্যাশ হলো বাড়তি ধাপসহ একটা মেমরি লিক। এমনকি ২৪-ঘণ্টার TTL-ও কখনো এক্সপায়ার না হওয়ার চেয়ে ভালো।
ক্যাশ key ডিটারমিনিস্টিক রাখুন। user:${id}, user_${Date.now()} নয়। একই ইনপুট যদি একই key না বানায়, তাহলে আপনি কখনো hit পাবেন না।
আপনার key namespace করুন। সার্ভিস বা এন্টিটি টাইপ দিয়ে prefix দিন: auth:session:abc123, catalog:product:456। একাধিক সার্ভিসে Redis শেয়ার করার সময় collision ঠেকায়।
একইভাবে সিরিয়ালাইজ করুন। কিছু পরিবেশে JSON.stringify key-এর ক্রম অনুযায়ী ভিন্ন আউটপুট দেয়। একটা ক্যানোনিকাল সিরিয়ালাইজার ব্যবহার করুন বা অন্তত এ ব্যাপারে সচেতন থাকুন।
// Good: deterministic key
const key = `product:${productId}:v2`;
// Bad: non-deterministic
const key = `product:${productId}:${Date.now()}`;
// Good: namespaced
const key = `catalog:product:${productId}`;
// Canonical JSON for complex keys
import { stringify } from 'fast-json-stable-stringify';
const key = `search:${stringify({ query, page, filters })}`;