Systems Hub ৫ লাখ ইউজারের গল্প
উদাহরণশুরু · ১/২৯

তোমার অ্যাপে ৫,০০,০০০ ইউজার

প্রত্যেকের FCM টোকেন ডাটাবেজে সেভ করা আছে। অ্যাডমিন এখন একটা নোটিফিকেশন সবাইকে পাঠাতে চায়। এই একটামাত্র কাজ থেকেই পুরো গল্পটা শুরু।

ADMINপাঠাও ▸
API/broadcast
POSTGRES৫,০০,০০০ টোকেন
৫ লাখ ডিভাইস
সমস্যাধাপ ০১ · ২/২৯

সোজা API-তেই লুপ চালালে কী হয়

সবচেয়ে সহজ কোডটা এটাই — টোকেনগুলো নিয়ে এসো, একটা for লুপে একটার পর একটা পাঠাও। রিকোয়েস্টটা ততক্ষণ খোলা থাকে।

APIfor (const t of tokens)
৫ লাখ ফোন
পাঠানো হয়েছে: ৩,২১৪ / ৫,০০,০০০
সময় গেছে: ১০ মিনিট ৪২ সেকেন্ড
সমস্যাধাপ ০২ · ৩/২৯

৩০ সেকেন্ডেই ফেল — কে পেয়েছে জানার উপায় নেই

Nginx কানেকশন কেটে দিল, প্রসেসটা মরে গেল। এখন সবচেয়ে খারাপ প্রশ্নটা — কার কার কাছে গিয়েছিল?

APIকানেকশন কাটা
TIMEOUT
✓ পেয়েছে — ৩,২১৪
? জানা নেই — ৪,৯৬,৭৮৬
সমাধানধাপ ০৩ · ৪/২৯

কাজটা Redis-এ লিখে রাখি, তারপর ছেড়ে দিই

API আর নিজে পাঠাবে না। সে একটা জব Redis-এর কিউতে ঢুকিয়ে দেবে, ডাটাবেজে একটা লগ রো বানাবে, আর সাথে সাথে উত্তর দিয়ে দেবে।

APIশুধু ঢোকায়
QUEUEBullMQ · Redis
WORKERআলাদা প্রসেস
৫ লাখ ফোন
200 OK~৪০ms
নতুন সমস্যাধাপ ০৪ · ৫/২৯

ওয়ার্কার ৫ লাখ টোকেন কীভাবে বের করবে

জব তো পেল। কিন্তু ডাটাবেজ থেকে ৫ লাখ রো আনবে কীভাবে? দুইটা সহজ উত্তরই ভুল।

skip: 0 → 5000 → 10000 → …
প্রতি পেজ আগেরটার চেয়ে ধীর
findMany()৫ লাখ রো একসাথে
RAM ভরে যাচ্ছে
OUT OF MEMORY
সমাধানধাপ ০৫ · ৬/২৯

Cursor — শেষ আইডি মনে রেখে সরাসরি লাফ

পুরো লিস্ট না চেয়ে বলি — "গতবার এই আইডিতে ছিলাম, তার পরেরগুলো দাও"। Postgres তখন ইনডেক্সে সরাসরি ওই জায়গায় বসে পড়ে।

cursor: { courseId_studentId } · take: 5000
প্রতিটা পেজ সমান দ্রুত — শেষটাও প্রথমটার মতো
নতুন সমস্যাধাপ ০৬ · ৭/২৯

FCM এক কলে ৫০০-র বেশি টোকেন নেয় না

হাতে ৫০০০ টোকেন। একসাথে পাঠাতে গেলাম — Firebase সরাসরি রিকোয়েস্টটাই ফিরিয়ে দিল। এটা তাদের হার্ড লিমিট

WORKER৫০০০ টোকেন হাতে
FIREBASE FCMmax 500 / call
REJECTED
সমাধানধাপ ০৭ · ৮/২৯

৫০০ করে ভাগ করে পাঠাই

chunk(tokens, 500) — ৫০০০ টোকেন মানে ১০টা কল। আর পুরো ৫ লাখের জন্য ঠিক ১,০০০ কল

WORKERchunk(tokens, 500)
৫০০-র ব্যাচ× ১,০০০
FCMপ্রতিটার আলাদা রেজাল্ট
৫,০০,০০০ ÷ ৫০০ = ১,০০০ কল
নতুন সমস্যাধাপ ০৮ · ৯/২৯

১,০০০ কল একসাথে ছেড়ে দিলে

ভাগ তো হলো। এবার সবগুলো একসাথে ছেড়ে দিলাম — দশ সেকেন্ডেই শেষ হওয়ার কথা। কিন্তু দুই দিক থেকে ধাক্কা এলো।

WORKER১,০০০ কল একসাথে
FCMকোটা ছাড়িয়ে গেছে
POSTGRESপ্রতি ব্যাচের আগে টোকেন কোয়েরি
429
নিজের ডাটাবেজও একসাথে ১,০০০ কোয়েরি খাচ্ছে
সমাধানধাপ ০৯ · ১০/২৯

থ্রটল — সেকেন্ডে সর্বোচ্চ ১০ ব্যাচ

নিজেকেই গতি বেঁধে দিই। একটা ছোট টোকেন-বাকেট: প্রতি সেকেন্ডে ১০টার বেশি ব্যাচ যাবে না। বাকিরা লাইনে অপেক্ষা করে।

WORKER
throttle.wait()১০ ব্যাচ / সেকেন্ড
FCM৫,০০০ notification/sec
১০ × ৫০০ = ৫,০০০ / সেকেন্ড → ৫ লাখ = ~১০০ সেকেন্ড
নতুন সমস্যাধাপ ১০ · ১১/২৯

অ্যাডমিন দুইবার ক্লিক করে ফেলল

সবই ঠিক চলছিল। তারপর অ্যাডমিন বাটনটা দুইবার চাপল — বা BullMQ টাইমআউট ভেবে জবটা আবার দিল। ফল একই: সবাই একই মেসেজ দুইবার পেল

ক্লিক ১
ক্লিক ২ভুলে
QUEUE২টা আলাদা জব
WORKERদুইবার চালালো
ফোন২টা নোটিফিকেশন
×২
সমাধানধাপ ১১ · ১২/২৯

SET NX — একজনই ঢুকতে পারে

Redis-এর একটামাত্র কমান্ড। NX মানে — key না থাকলেই কেবল লিখবে। Redis একটাই থ্রেডে চলে, তাই এটা পুরোপুরি atomic

ক্লিক ১
ক্লিক ২
রিট্রাই
SET key 1 NX EX 900একজনই OK পায়
QUEUE১টাই জব
OK
null
null
নতুন সমস্যাধাপ ১২ · ১৩/২৯

লক ছাড়তে গিয়ে অন্যের লক মুছে ফেলা

A লক নিল, কিন্তু কাজটা লকের মেয়াদের চেয়ে বেশি সময় নিল। লক এক্সপায়ার করল, B নতুন লক নিল। তারপর A কাজ শেষ করে DEL করল — কিন্তু সেটা এখন B-র লক

WORKER Aকাজ ধীর হলো
WORKER Bনতুন লক নিল
locktok = c3d4
POSTGRESদুইজন একসাথে
DEL
সমাধানধাপ ১৩ · ১৪/২৯

টোকেন মিলিয়ে তবেই DEL — Lua দিয়ে

প্রতিটা লকের সাথে একটা র‍্যান্ডম টোকেন রাখি। ছাড়ার সময় Lua স্ক্রিপ্টে GET আর DEL একসাথে চালাই — মাঝখানে রেস ঢোকার ফাঁকই থাকে না।

WORKER Atok = a1b2
WORKER Btok = c3d4
if GET == ARGV[1] then DELLua · atomic
lockc3d4 · অক্ষত
✗ মেলেনি
নতুন সমস্যাধাপ ১৪ · ১৫/২৯

FCM সাময়িক ডাউন — ওই ব্যাচ হারিয়ে গেল

নেটওয়ার্ক বা FCM মাঝে মাঝে সাড়া দেবে না — এটা স্বাভাবিক। কিন্তু তখন যদি জবটা মরে যায়, ওই ৫০০ জন কখনোই নোটিফিকেশন পাবে না

WORKER
FCMসাময়িক আউটেজ
✗ হারিয়ে গেল
৫০০ জন বাদ · কোনো রেকর্ড নেই
সমাধানধাপ ১৫ · ১৬/২৯

৩ বার চেষ্টা, প্রতিবার দ্বিগুণ অপেক্ষা

ফেল করলে জবটা কিউতে ফেরত যায়। তারপর ১০ সেকেন্ড, ২০ সেকেন্ড, ৪০ সেকেন্ড — দ্বিগুণ করে অপেক্ষা, যাতে FCM শ্বাস নেওয়ার সময় পায়।

WORKER
delayed set১০s → ২০s → ৪০s
FCMফিরে এসেছে
failed set৩ বারের পর
অপেক্ষা…
নতুন সমস্যাধাপ ১৬ · ১৭/২৯

টোকেনগুলোর একটা অংশ মরা

ছয় মাস চলার পর দেখা গেল — অনেক টোকেনে পাঠানোই যাচ্ছে না। অ্যাপ আনইনস্টল হয়েছে, ডেটা ক্লিয়ার হয়েছে। কিন্তু সেগুলো এখনো প্রতিটা ক্যাম্পেইনে যাচ্ছে

WORKER
FCMtoken-not-registered
অযথা API কল · অযথা সময়
সমাধানধাপ ১৭ · ১৮/২৯

মরা টোকেন নিভিয়ে দিই — ডিলিট করি না

FCM নিজেই বলে দেয় কোন টোকেনটা আর নেই। সেগুলো জমিয়ে isValid = false করে দিই। পরের ক্যাম্পেইনে ওগুলো কোয়েরিতেই আসবে না।

WORKER
FCMপ্রতিটার আলাদা রেজাল্ট
isValid = falseডিলিট না, নিভিয়ে দেওয়া
প্রতিটা ক্যাম্পেইন টোকেন টেবিলকে একটু একটু করে সুস্থ করে
নতুন সমস্যাধাপ ১৮ · ১৯/২৯

পুশ তো গেল — এখন ৫ লাখ ইউজার অ্যাপ খুলল

নোটিফিকেশন পেয়ে সবাই প্রায় একসাথে অ্যাপ খুলবে, নোটিফিকেশন লিস্ট দেখবে, বারবার রিফ্রেশ করবে। ব্রডকাস্টের ঠিক পরের এক মিনিটই সবচেয়ে বড় ঢেউ।

৫ লাখ ইউজার
APIঅথ + পারমিশন + লিস্ট
POSTGRESপ্রতিটায় ৩–৪টা কোয়েরি
OVERLOAD
সমাধানধাপ ১৯ · ২০/২৯

উত্তরটা Redis-এ রেখে দিই

একবার ডাটাবেজ থেকে পড়ে উত্তরটা Redis-এ রাখি, একটা মেয়াদ সহ। পরের হাজারটা রিকোয়েস্ট ডাটাবেজের ধারেকাছেও যায় না

REDISfreshUntil + TTL
POSTGRESপ্রায় বসে থাকে
অথ চেক এখন ০টা DB কোয়েরিতে
নতুন সমস্যাধাপ ২০ · ২১/২৯

ক্যাশ এক্সপায়ার হওয়ার মুহূর্তে

TTL শেষ হলো। ঠিক সেই সেকেন্ডে হাজার হাজার রিকোয়েস্ট এলো — সবাই মিস পেল, আর সবাই একসাথে ডাটাবেজে গেল। একেই বলে cache stampede।

CACHEএইমাত্র এক্সপায়ার
POSTGRES১০,০০০ একই কোয়েরি
STAMPEDE
সমাধানধাপ ২১ · ২২/২৯

একজন ডাটাবেজে যায়, বাকিরা অপেক্ষা করে

তিন স্তর একসাথে: এক প্রসেসে singleFlight, প্রসেসের বাইরে Redis লক, আর বাকিরা ৪০–১০০ms এলোমেলো গ্যাপে ক্যাশ চেক করতে থাকে।

CACHEমিস
SET NXএকজনই OK
POSTGRES১টা কোয়েরি
বাকিরা ৪০–১০০ms পরপর চেক করছে
নতুন সমস্যাধাপ ২২ · ২৩/২৯

সব key একই সেকেন্ডে এক্সপায়ার করে

ব্রডকাস্টের পর সব ক্যাশ প্রায় একই মুহূর্তে ভরেছিল। TTL একই, তাই সেগুলো একই মুহূর্তে মরেও যায় — আর প্রতি ৩০ সেকেন্ডে একটা করে ঢেউ আসে।

সব key একসাথে ভরেছে → একসাথেই এক্সপায়ার
প্রতি TTL-এ একটা করে স্পাইক
সমাধানধাপ ২৩ · ২৪/২৯

TTL-এ ±১০% এলোমেলো মিশিয়ে দিই

৩০ সেকেন্ড না দিয়ে ২৭ থেকে ৩৩ সেকেন্ডের মধ্যে যেকোনো একটা। ছোট পরিবর্তন — কিন্তু এতে এক্সপায়ার ছড়িয়ে যায়, আর ঢেউটা সমান স্রোত হয়ে যায়।

applyJitter(ttl, 0.1) → ২৭s … ৩৩s
লোড সমান — আর কোনো স্পাইক নেই
নতুন সমস্যাধাপ ২৪ · ২৫/২৯

কোর্স এডিট হলে ক্যাশ কীভাবে মুছব

অ্যাডমিন একটা কোর্সের নাম বদলাল। কিন্তু ওই কোর্স কোন কোন ক্যাশ key-তে আছে — সেটা তো জানা নেই। কোয়েরি, হোস্ট, পেজ মিলিয়ে হাজার হাজার key।

REDISSCAN চলছে — সব কমান্ড লাইনে
SCAN — Redis একটাই থ্রেডে চলে, তাই বাকি সব থেমে থাকে
সমাধানধাপ ২৫ · ২৬/২৯

মুছি না — ভার্সন বাড়াই

ভার্সন নম্বরটা key-এর নামের ভেতরেই বসিয়ে রেখেছি। কোর্স এডিট হলে শুধু একটা INCR — পরের রিকোয়েস্ট আর পুরনো নামটা চাইবেই না।

INCR …:version41 → 42
…:list:…:v41পুরনো
…:list:…:v42নতুন
পুরনো key অনাথ — TTL শেষে Redis নিজেই মুছে দেবে
শেষ সমস্যাধাপ ২৬ · ২৭/২৯

Redis নিজেই ধীর হয়ে গেলে

পুরো সিস্টেমটা এখন Redis-এর ওপর দাঁড়িয়ে। কিন্তু Redis যদি ধীর হয়ে যায়? ডিফল্ট ক্লায়েন্ট অসীমকাল চেষ্টা করতে থাকে — আর প্রতিটা রিকোয়েস্ট ঝুলে যায়।

API
REDISসাড়া দিচ্ছে না
সব ঝুলে আছে
খালি কানেকশনই আর থাকে না
সমাধানধাপ ২৭ · ২৮/২৯

১৫০০ms এ হাল ছেড়ে সোজা ডাটাবেজে

ক্যাশের জন্য আলাদা একটা কানেকশন রাখি, যার টাইমআউট দেড় সেকেন্ড আর রিট্রাই মাত্র একবার। ফেল করলে catch ধরে সরাসরি ডাটাবেজ থেকে পড়ে।

APItry / catch
REDIScommandTimeout 1500ms
POSTGRESloader()
উত্তর
সাইট ধীর হয় — বন্ধ হয় না
পুরো পথশেষ · ২৯/২৯

শুরুর সেই এক ক্লিক — শেষ পর্যন্ত

একটা সহজ কাজ ছিল: ৫ লাখ ইউজারকে একটা নোটিফিকেশন পাঠানো। প্রতিটা সমাধান একটা নতুন সমস্যা এনেছে, আর সেই চেইনটাই পুরো আর্কিটেকচারটা বানিয়ে দিয়েছে।

ADMIN
API৪০ms
QUEUE
WORKERলক · পেজ · ব্যাচ · থ্রটল
FCM
৫ লাখ~১০০s
আর ফিরতি পথে — ক্যাশ, লক, jitter, ভার্সন, fallback