ক্যাশিংকে ফ্রি পারফরম্যান্স হিসেবে বিক্রি করা হয়। Redis যোগ করো, ধীর read-গুলো এর পেছনে রাখো, response time নামতে দেখো। এবং এটা কাজ করে — ঠিক প্রথম সেই বাগ রিপোর্ট পর্যন্ত যেখানে লেখা "সংখ্যাটা ভুল," আর আপনি বোঝেন সংখ্যাটা ঠিকই ছিল, ক্যাশ কেবল তার একটা পুরোনো কপি দেখাচ্ছিল।
ওই রিপোর্ট ক্যাশিং নিয়ে আমার ভাবনা বদলে দিয়েছে। ক্যাশ কোনো পারফরম্যান্স ফিচার নয় যা আপনি জুড়ে দেন; এটা আপনার ডেটার দ্বিতীয় একটা কপি যা প্রথমটার সঙ্গে অমিল হতে পারে। প্রতিটি ক্যাশ যোগ করা মানে একটা প্রতিশ্রুতি যে কপিটা যা পড়ছে তার জন্য সত্যের যথেষ্ট কাছাকাছি। কখনো এই প্রতিশ্রুতি রাখা সহজ। কখনো এটা রাখা সেই ধীর query-র চেয়েও কঠিন যেটা এড়াতে চাইছিলেন।
ক্যাশ কোনো পারফরম্যান্স ফিচার নয় যা আপনি জুড়ে দেন। এটা আপনার ডেটার দ্বিতীয় একটা কপি যা প্রথমটার সঙ্গে অমিল হতে পারে।
কিছু ক্যাশ করার আগে যে প্রশ্নটি করি
"এটা কি ধীর?" — প্রথম প্রশ্ন হিসেবে এটা জিজ্ঞেস করা বন্ধ করেছি, কারণ যেকোনো গুরুত্বপূর্ণ জিনিসের জন্য ধীর-কিন্তু-সঠিক, দ্রুত-কিন্তু-ভুলকে হারিয়ে দেয়। বরং তিনটি জিনিস ক্রমানুসারে জিজ্ঞেস করি:
- এটা আসলে কতবার পড়া হয়? ঘণ্টায় একবার পড়া কিছু ক্যাশ করে জটিলতার সমান দামের কিছুই বাঁচে না।
- পাঠক আসলে কতটা staleness সহ্য করতে পারে? সেকেন্ড? মিনিট? শূন্য?
- ক্যাশ না থাকলে আবার হিসাব করতে কত খরচ?
ভালো ক্যাশিং প্রার্থী অনেকবার পড়া হয়, একটু পিছিয়ে থাকা সহ্য করে ও তৈরি করা ব্যয়বহুল। তিনটিই মিললে ক্যাশ প্রায় একটা ফ্রি জয়। একটাও না মিললে — বিশেষত staleness-এরটা — আমি দ্রুত সতর্ক হয়ে যাই।
কোথায় এটা স্পষ্টভাবে লাভজনক
সবচেয়ে ভালো জায়গা হলো এমন ডেটা যা প্রতিনিয়ত পড়া হয়, খুব কম বদলায় ও কেউ মিলিসেকেন্ড-পর্যন্ত হালনাগাদ আশা করে না। রেফারেন্স ও ক্যাটালগ-ধরনের ডেটা এর ক্লাসিক উদাহরণ: category tree, configuration, lookup, এমন জিনিস যা হাজার হাজার request-এর জন্য একই আর কেউ সম্পাদনা করলেই শুধু বদলায়।
async function getCategoryTree() {
const cached = await redis.get("category:tree");
if (cached) return JSON.parse(cached);
const tree = await db.buildCategoryTree(); // expensive, rarely changes
await redis.set("category:tree", JSON.stringify(tree), "EX", 3600);
return tree;
}ঘণ্টায় হাজারবার পড়া হয়, হয়তো দিনে একবার বদলায় ও কোনো ব্যবহারকারী কয়েক মিনিট পুরোনো category tree দেখলে খারাপ কিছু হয় না। পুরো চেকলিস্ট সন্তুষ্ট। Redis এই কাজের জন্যই।
কোথায় আমি ইচ্ছে করে ক্যাশ করি না
এবার বেশি কাজের অর্ধেক, কারণ কোথায় ক্যাশ না করতে হয় তা জানাই সেই ক্যাশকে আলাদা করে যা সাহায্য করে আর যা ইনসিডেন্ট তৈরি করে।
আমি টাকা ক্যাশ করি না ও ইনভেন্টরি ক্যাশ করি না। যা কিছু ব্যবহারকারী ঠিক এই মুহূর্তের আশা করে — অ্যাকাউন্ট ব্যালেন্স, wallet, স্টকে বাকি থাকা আইটেমের সংখ্যা — সেখানে stale read কোনো ছোটখাটো UX বিরক্তি নয়, এটা পারফরম্যান্সের পোশাক পরা একটা correctness বাগ। কাউকে ত্রিশ সেকেন্ড পুরোনো ব্যালেন্স দেখিয়ে তার ভিত্তিতে সিদ্ধান্ত নিতে দিলে আপনি পেজটা দ্রুত করেননি, মিথ্যা বলতে বাধ্য করেছেন। স্টকের শেষ ইউনিট দুবার বিক্রি করুন কারণ দুটি request-ই ক্যাশ করা "1 available" পড়েছিল — ক্যাশ তখন আপনার সত্যিকারের টাকা আর সত্যিকারের ক্ষমাপ্রার্থনার দাম নিল।
লক্ষণটা সহজ: কয়েক সেকেন্ডের জন্যও ভুল হওয়া যদি একটা খারাপ সিদ্ধান্ত বা ভাঙা invariant ঘটায়, ক্যাশ করবেন না। টাটকা পড়ুন। যে latency বাঁচাচ্ছেন তা যে শ্রেণির বাগ কিনছেন তার সমান দামি নয়।
Invalidation, সৎভাবে
"কম্পিউটার সায়েন্সে কঠিন জিনিস মাত্র দুটি" একটা পুরোনো রসিকতা, কিন্তু cache invalidation সেখানে তার জায়গা অর্জন করে। দুটি পদ্ধতি ও তারা ভিন্নভাবে ব্যর্থ হয়।
TTL — কেবল কয়েক সেকেন্ড পর entry-গুলো মেয়াদোত্তীর্ণ হতে দিন — সহজ আর আমি এটাই ডিফল্ট রাখি। এর দুর্বলতা হলো আপনি অনুমোদিত ভুলের একটা নির্দিষ্ট জানালা বেছে নিচ্ছেন ও সুবিধা না হারিয়ে সেই জানালা ছোট করতে পারবেন না। Event-চালিত invalidation — অন্তর্নিহিত ডেটা বদলানোর মুহূর্তেই entry-টা সরিয়ে দিন — বেশি সঠিক কিন্তু বেশি কাজ ও একটা eviction পথ মিস করে চিরকাল stale ডেটা দিতে থাকা সহজ, বাঁচানোর জন্য কোনো TTL নেই।
লোডের নিচে শুধু দেখা দেয় এমন একটা ফাঁদও আছে। হাজারটা entry একই expiry সময় ভাগ করলে তারা সবাই একই মুহূর্তে মেয়াদোত্তীর্ণ হয় ও হাজারটা request একসঙ্গে ডেটাবেসে হুমড়ি খেয়ে পড়ে সেগুলো আবার তৈরি করতে — ঠিক যখন ট্রাফিক সর্বোচ্চ তখন নিজের ঘাড়ে ডেকে আনা একটা spike। একটু randomness দিয়ে expiry ছড়িয়ে দিলে সেই খাদটা একটা মৃদু ঢালে পরিণত হয়।
// Don't let a whole class of keys expire on the same tick.
const ttl = 3600 + Math.floor(Math.random() * 300); // 60–65 min, spread out
await redis.set(key, value, "EX", ttl);যে নিয়মে আমি আসলে চলি
উপরের সবকিছু একটা নীতিতে গিয়ে মিলে যায়: ক্যাশ একটা optimization, কখনো source of truth নয়। ক্যাশ সম্পূর্ণ খালি থাকলেও সিস্টেমকে সঠিক হতে হবে। Redis flush করলে যদি কেবল ধীর নয়, ভুল উত্তর তৈরি হয়, তাহলে ক্যাশ চুপচাপ ভার-বহনকারী হয়ে গেছে ও সেটা একটা ডিজাইন বাগ, যত দ্রুতই এটা জিনিস করে থাকুক।
তাই আমি আগে সঠিক, ক্যাশ-বিহীন পথ তৈরি করি আর নিশ্চিত করি এটা ঠিক। তারপর উপরে ক্যাশিং যোগ করি, কেবল যেখানে তিনটি প্রশ্ন হ্যাঁ বলে, এটাকে এমন একটা স্তর হিসেবে দেখে যা যেকোনো মুহূর্তে মুছে দিতে পারি এবং গতি হারাব কিন্তু correctness কখনো নয়। ওই সীমারেখায় ধরে রাখলে Redis একটা দারুণ টুল। সেই সীমা পেরিয়ে যেতে দিলে এটা সবচেয়ে বিভ্রান্তিকর শ্রেণির বাগে পরিণত হয় — যেখানে প্রতিটি কোড সঠিক আর উত্তর তবুও ভুল।