প্রত্যেকের FCM টোকেন ডাটাবেজে সেভ করা আছে। অ্যাডমিন এখন একটা নোটিফিকেশন সবাইকে পাঠাতে
চায়। এই একটামাত্র কাজ থেকেই পুরো গল্পটা শুরু।
ADMINপাঠাও ▸
API/broadcast
POSTGRES৫,০০,০০০ টোকেন
৫ লাখ ডিভাইস
প্রতিটা ডিভাইসের একটা করে FCM টোকেন — ফোন, ট্যাব, ব্রাউজার আলাদা
অ্যাডমিন এক ক্লিক করবে, বাকিটা ব্যাকএন্ডের দায়িত্ব
প্রশ্ন একটাই — এই ৫ লাখে পৌঁছাব কীভাবে?
সমস্যাধাপ ০১ · ২/২৯
সোজা API-তেই লুপ চালালে কী হয়
সবচেয়ে সহজ কোডটা এটাই — টোকেনগুলো নিয়ে এসো, একটা for লুপে একটার
পর একটা পাঠাও। রিকোয়েস্টটা ততক্ষণ খোলা থাকে।
APIfor (const t of tokens)
৫ লাখ ফোন
পাঠানো হয়েছে: ৩,২১৪ / ৫,০০,০০০
সময় গেছে: ১০ মিনিট ৪২ সেকেন্ড
একটা FCM কল ≈ ২০০ মিলিসেকেন্ড
৫,০০,০০০ × ২০০ms = প্রায় ২৭ ঘণ্টা
আর এই পুরো সময় HTTP রিকোয়েস্টটা খোলা থাকতে হবে
সমস্যাধাপ ০২ · ৩/২৯
৩০ সেকেন্ডেই ফেল — কে পেয়েছে জানার উপায় নেই
Nginx কানেকশন কেটে দিল, প্রসেসটা মরে গেল। এখন সবচেয়ে খারাপ প্রশ্নটা — কার কার কাছে
গিয়েছিল?
APIকানেকশন কাটা
TIMEOUT
✓ পেয়েছে — ৩,২১৪
? জানা নেই — ৪,৯৬,৭৮৬
কোথায় থেমেছে তার কোনো রেকর্ড নেই — সবটা মেমোরিতে ছিল
আবার চালালে প্রথম ৩,২১৪ জন দুইবার পাবে
না চালালে বাকি ৪.৯৬ লাখ কখনোই পাবে না
সমাধানধাপ ০৩ · ৪/২৯
কাজটা Redis-এ লিখে রাখি, তারপর ছেড়ে দিই
API আর নিজে পাঠাবে না। সে একটা জব Redis-এর কিউতে ঢুকিয়ে দেবে, ডাটাবেজে একটা লগ রো
বানাবে, আর সাথে সাথে উত্তর দিয়ে দেবে।
APIশুধু ঢোকায়
QUEUEBullMQ · Redis
WORKERআলাদা প্রসেস
৫ লাখ ফোন
200 OK~৪০ms
একজনকে পাঠাও বা ৫ লাখকে — রেসপন্স একই ~৪০ms
জব Redis-এ টিকে থাকে — সার্ভার ক্র্যাশ করলেও হারায় না
ওয়ার্কার আলাদা প্রসেস, তাই সে ব্যস্ত থাকলেও সাইট স্লো হয় না
নতুন সমস্যাধাপ ০৪ · ৫/২৯
ওয়ার্কার ৫ লাখ টোকেন কীভাবে বের করবে
জব তো পেল। কিন্তু ডাটাবেজ থেকে ৫ লাখ রো আনবে কীভাবে? দুইটা সহজ উত্তরই ভুল।
skip: 0 → 5000 → 10000 → …
প্রতি পেজ আগেরটার চেয়ে ধীর
findMany()৫ লাখ রো একসাথে
RAM ভরে যাচ্ছে
OUT OF MEMORY
findMany() — ৫ লাখ রো একসাথে RAM-এ, প্রসেস মরে যায়
skip: 495000 — Postgres ৪,৯৫,০০০ রো পড়ে, তারপর ফেলে দেয়
শেষ পেজে পৌঁছাতে পৌঁছাতে কোয়েরি অসহ্য ধীর
সমাধানধাপ ০৫ · ৬/২৯
Cursor — শেষ আইডি মনে রেখে সরাসরি লাফ
পুরো লিস্ট না চেয়ে বলি — "গতবার এই আইডিতে ছিলাম, তার পরেরগুলো দাও"। Postgres তখন ইনডেক্সে
সরাসরি ওই জায়গায় বসে পড়ে।
cursor: { courseId_studentId } · take: 5000
প্রতিটা পেজ সমান দ্রুত — শেষটাও প্রথমটার মতো
শেষ পেজও প্রথম পেজের মতোই দ্রুত
async function* — মেমোরিতে একসাথে ৫০০০ আইডি, ৫ লাখ নয়
প্রতিবার yield করে দিয়ে পরেরটায় যায়
নতুন সমস্যাধাপ ০৬ · ৭/২৯
FCM এক কলে ৫০০-র বেশি টোকেন নেয় না
হাতে ৫০০০ টোকেন। একসাথে পাঠাতে গেলাম — Firebase সরাসরি রিকোয়েস্টটাই ফিরিয়ে দিল। এটা তাদের
হার্ড লিমিট।
WORKER৫০০০ টোকেন হাতে
FIREBASE FCMmax 500 / call
REJECTED
sendEachForMulticast — সর্বোচ্চ ৫০০ টোকেন
একটাও বেশি দিলে পুরো কলটাই বাতিল
মানে ৫০০০ টোকেন ভাগ করতেই হবে
সমাধানধাপ ০৭ · ৮/২৯
৫০০ করে ভাগ করে পাঠাই
chunk(tokens, 500) — ৫০০০ টোকেন মানে ১০টা কল। আর পুরো ৫ লাখের জন্য
ঠিক ১,০০০ কল।
WORKERchunk(tokens, 500)
৫০০-র ব্যাচ× ১,০০০
FCMপ্রতিটার আলাদা রেজাল্ট
৫,০০,০০০ ÷ ৫০০ = ১,০০০ কল
chunk(tokens, 500) — সহজ, কিন্তু বাধ্যতামূলক
sendEachForMulticastপ্রতিটা টোকেনের আলাদা রেজাল্ট ফেরত দেয়
তাই কোনটা গেল, কোনটা ফেল করল — সব জানা যায়
নতুন সমস্যাধাপ ০৮ · ৯/২৯
১,০০০ কল একসাথে ছেড়ে দিলে
ভাগ তো হলো। এবার সবগুলো একসাথে ছেড়ে দিলাম — দশ সেকেন্ডেই শেষ হওয়ার কথা। কিন্তু দুই দিক
থেকে ধাক্কা এলো।
WORKER১,০০০ কল একসাথে
FCMকোটা ছাড়িয়ে গেছে
POSTGRESপ্রতি ব্যাচের আগে টোকেন কোয়েরি
429
নিজের ডাটাবেজও একসাথে ১,০০০ কোয়েরি খাচ্ছে
FCM-এর কোটা আছে — ছাড়ালে গুগল রিকোয়েস্ট ফিরিয়ে দেয়
প্রতিটা ব্যাচের আগে টোকেন কোয়েরি হয় — নিজের Postgres-ই বসে যায়
দ্রুত পাঠাতে গিয়ে কিছুই পাঠানো হয় না
সমাধানধাপ ০৯ · ১০/২৯
থ্রটল — সেকেন্ডে সর্বোচ্চ ১০ ব্যাচ
নিজেকেই গতি বেঁধে দিই। একটা ছোট টোকেন-বাকেট: প্রতি সেকেন্ডে ১০টার বেশি ব্যাচ যাবে না।
বাকিরা লাইনে অপেক্ষা করে।
WORKER
throttle.wait()১০ ব্যাচ / সেকেন্ড
FCM৫,০০০ notification/sec
১০ × ৫০০ = ৫,০০০ / সেকেন্ড → ৫ লাখ = ~১০০ সেকেন্ড
maxBatchesPerSec: 10 — ১০ × ৫০০ = ৫,০০০ / সেকেন্ড
৫,০০,০০০ ÷ ৫,০০০ = প্রায় ১০০ সেকেন্ডে পুরো ব্রডকাস্ট
সার্ভার বড় হলে সংখ্যাটা বাড়িয়ে দিলেই সময় কমে
নতুন সমস্যাধাপ ১০ · ১১/২৯
অ্যাডমিন দুইবার ক্লিক করে ফেলল
সবই ঠিক চলছিল। তারপর অ্যাডমিন বাটনটা দুইবার চাপল — বা BullMQ টাইমআউট ভেবে জবটা আবার দিল। ফল
একই: সবাই একই মেসেজ দুইবার পেল।
ক্লিক ১
ক্লিক ২ভুলে
QUEUE২টা আলাদা জব
WORKERদুইবার চালালো
ফোন২টা নোটিফিকেশন
×২
ভুলে দুইবার ক্লিক — সবচেয়ে সাধারণ কারণ
BullMQ টাইমআউট ভেবে নিজেই রিট্রাই করতে পারে
দুইটা ওয়ার্কার একই জব তুলে নিতে পারে
সমাধানধাপ ১১ · ১২/২৯
SET NX — একজনই ঢুকতে পারে
Redis-এর একটামাত্র কমান্ড। NX মানে — key না থাকলেই কেবল লিখবে।
Redis একটাই থ্রেডে চলে, তাই এটা পুরোপুরি atomic।
ক্লিক ১
ক্লিক ২
রিট্রাই
SET key 1 NX EX 900একজনই OK পায়
QUEUE১টাই জব
OK
null
null
NX — key না থাকলেই লিখবে, তাই প্রথমজনই কেবল OK পায়
EX — নিজে থেকেই মুছে যায়, ক্র্যাশ করলেও চিরকাল আটকে থাকে না
দুই স্তরে — কিউতে ঢোকার আগে dedup, প্রসেসের আগে lock
নতুন সমস্যাধাপ ১২ · ১৩/২৯
লক ছাড়তে গিয়ে অন্যের লক মুছে ফেলা
A লক নিল, কিন্তু কাজটা লকের মেয়াদের চেয়ে বেশি সময় নিল। লক এক্সপায়ার করল, B নতুন লক নিল।
তারপর A কাজ শেষ করে DEL করল — কিন্তু সেটা এখন B-র লক।
WORKER Aকাজ ধীর হলো
WORKER Bনতুন লক নিল
locktok = c3d4
POSTGRESদুইজন একসাথে
DEL
A-র লক এক্সপায়ার করেছিল, সে জানেই না
তার DEL মুছল B-র লক
এখন দুইজন একসাথে চলছে — লক থাকা আর না থাকা সমান
সমাধানধাপ ১৩ · ১৪/২৯
টোকেন মিলিয়ে তবেই DEL — Lua দিয়ে
প্রতিটা লকের সাথে একটা র্যান্ডম টোকেন রাখি। ছাড়ার সময় Lua স্ক্রিপ্টে GET আর DEL
একসাথে চালাই — মাঝখানে রেস ঢোকার ফাঁকই থাকে না।
WORKER Atok = a1b2
WORKER Btok = c3d4
if GET == ARGV[1] then DELLua · atomic
lockc3d4 · অক্ষত
✗ মেলেনি
টোকেন না মিললে কিছুই হয় না — শুধু 0 ফেরত
Lua-তে পুরোটা একটাই অবিভাজ্য অপারেশন
একই প্যাটার্ন তিন জায়গায় — ক্যাশ ফিল, ক্যাম্পেইন, ডেস্কটপ লগইন
নতুন সমস্যাধাপ ১৪ · ১৫/২৯
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ডিলিট না, নিভিয়ে দেওয়া
প্রতিটা ক্যাম্পেইন টোকেন টেবিলকে একটু একটু করে সুস্থ করে
দুইটা এরর কোড খুঁজি — token-not-registered আর invalid-token
ডিলিট করি না, কারণ তাহলে কতজন অ্যাপ ছেড়ে গেছে সেই হিসাবটা হারাত
এক updateMany-তে সবগুলো একসাথে
নতুন সমস্যাধাপ ১৮ · ১৯/২৯
পুশ তো গেল — এখন ৫ লাখ ইউজার অ্যাপ খুলল
নোটিফিকেশন পেয়ে সবাই প্রায় একসাথে অ্যাপ খুলবে, নোটিফিকেশন লিস্ট দেখবে, বারবার রিফ্রেশ করবে।
ব্রডকাস্টের ঠিক পরের এক মিনিটই সবচেয়ে বড় ঢেউ।
৫ লাখ ইউজার
APIঅথ + পারমিশন + লিস্ট
POSTGRESপ্রতিটায় ৩–৪টা কোয়েরি
OVERLOAD
প্রতিটা প্রোটেক্টেড রিকোয়েস্টে ৩–৪টা কোয়েরি — শুধু "তুমি কে, তোমার অ্যাক্সেস আছে কি" জানতেই
নোটিফিকেশন পাঠানোর ঠিক পরেই সবচেয়ে বড় ট্রাফিক
পাঠানোর সমস্যা মিটল, এবার পড়ার সমস্যা
সমাধানধাপ ১৯ · ২০/২৯
উত্তরটা Redis-এ রেখে দিই
একবার ডাটাবেজ থেকে পড়ে উত্তরটা Redis-এ রাখি, একটা মেয়াদ সহ। পরের হাজারটা রিকোয়েস্ট
ডাটাবেজের ধারেকাছেও যায় না।
REDISfreshUntil + TTL
POSTGRESপ্রায় বসে থাকে
অথ চেক এখন ০টা DB কোয়েরিতে
TTL ঠিক করি গুরুত্ব দিয়ে — নোটিফিকেশন ফিড ২০s, অথ ৬০s, পারমিশন ২ মিনিট
অথ চেক এখন শূন্য ডাটাবেজ কোয়েরিতে শেষ
এটা এক এন্ডপয়েন্টের লাভ না — সব এন্ডপয়েন্টের
নতুন সমস্যাধাপ ২০ · ২১/২৯
ক্যাশ এক্সপায়ার হওয়ার মুহূর্তে
TTL শেষ হলো। ঠিক সেই সেকেন্ডে হাজার হাজার রিকোয়েস্ট এলো — সবাই মিস পেল, আর সবাই একসাথে
ডাটাবেজে গেল। একেই বলে cache stampede।
CACHEএইমাত্র এক্সপায়ার
POSTGRES১০,০০০ একই কোয়েরি
STAMPEDE
১০,০০০ রিকোয়েস্ট = ১০,০০০ হুবহু একই কোয়েরি, এক সেকেন্ডে
ক্যাশ যত ভালো কাজ করে, ধাক্কাটা তত বড় হয়
ক্যাশ থাকা সত্ত্বেও ডাটাবেজ পড়ে যেতে পারে
সমাধানধাপ ২১ · ২২/২৯
একজন ডাটাবেজে যায়, বাকিরা অপেক্ষা করে
তিন স্তর একসাথে: এক প্রসেসে singleFlight, প্রসেসের বাইরে Redis
লক, আর বাকিরা ৪০–১০০ms এলোমেলো গ্যাপে ক্যাশ চেক করতে থাকে।
CACHEমিস
SET NXএকজনই OK
POSTGRES১টা কোয়েরি
বাকিরা ৪০–১০০ms পরপর চেক করছে
singleFlight — এক প্রসেসের ভেতর সবাই একটাই Promise ভাগ করে
Redis লক — ছয় ইনস্ট্যান্সের মধ্যে একজনই DB-তে যায়
লোডার ধীর হলে heartbeat লকের মেয়াদ বাড়ায়, যাতে লক ছুটে না যায়
নতুন সমস্যাধাপ ২২ · ২৩/২৯
সব key একই সেকেন্ডে এক্সপায়ার করে
ব্রডকাস্টের পর সব ক্যাশ প্রায় একই মুহূর্তে ভরেছিল। TTL একই, তাই সেগুলো একই মুহূর্তে মরেও
যায় — আর প্রতি ৩০ সেকেন্ডে একটা করে ঢেউ আসে।
সব key একসাথে ভরেছে → একসাথেই এক্সপায়ার
প্রতি TTL-এ একটা করে স্পাইক
একসাথে লেখা হাজারটা key-এর TTL-ও একই
তাই ডাটাবেজের লোড সমান না, ঢেউয়ের মতো
প্রতিটা ঢেউ আবার stampede-এর ঝুঁকি নিয়ে আসে
সমাধানধাপ ২৩ · ২৪/২৯
TTL-এ ±১০% এলোমেলো মিশিয়ে দিই
৩০ সেকেন্ড না দিয়ে ২৭ থেকে ৩৩ সেকেন্ডের মধ্যে যেকোনো একটা। ছোট পরিবর্তন — কিন্তু এতে
এক্সপায়ার ছড়িয়ে যায়, আর ঢেউটা সমান স্রোত হয়ে যায়।
applyJitter(ttl, 0.1) → ২৭s … ৩৩s
লোড সমান — আর কোনো স্পাইক নেই
freshTtl ± ১০% — প্রতিটা key আলাদা সময়ে মরে
ডাটাবেজের লোড ঢেউ থেকে সমান স্রোতে বদলে যায়
তিন লাইনের কোড, কিন্তু প্রোডাকশনে পার্থক্যটা বিশাল
নতুন সমস্যাধাপ ২৪ · ২৫/২৯
কোর্স এডিট হলে ক্যাশ কীভাবে মুছব
অ্যাডমিন একটা কোর্সের নাম বদলাল। কিন্তু ওই কোর্স কোন কোন ক্যাশ key-তে আছে — সেটা তো
জানা নেই। কোয়েরি, হোস্ট, পেজ মিলিয়ে হাজার হাজার key।
REDISSCAN চলছে — সব কমান্ড লাইনে
SCAN — Redis একটাই থ্রেডে চলে, তাই বাকি সব থেমে থাকে
key-এর নাম আগে থেকে জানা নেই
SCAN চলার সময় বাকি সব কমান্ড অপেক্ষা করে
ইনভ্যালিডেট করতে গিয়ে পুরো সাইট ধীর হয়ে যেত
সমাধানধাপ ২৫ · ২৬/২৯
মুছি না — ভার্সন বাড়াই
ভার্সন নম্বরটা key-এর নামের ভেতরেই বসিয়ে রেখেছি। কোর্স এডিট হলে শুধু একটা INCR — পরের রিকোয়েস্ট আর পুরনো নামটা চাইবেই না।
INCR …:version41 → 42
…:list:…:v41পুরনো
…:list:…:v42নতুন
পুরনো key অনাথ — TTL শেষে Redis নিজেই মুছে দেবে
একটা কমান্ড, শূন্যটা SCAN
পুরনো key কেউ আর চায় না — তাই অনাথ হয়ে যায়
TTL শেষে Redis নিজেই পরিষ্কার করে — আমাদের কিছু করতে হয় না
শেষ সমস্যাধাপ ২৬ · ২৭/২৯
Redis নিজেই ধীর হয়ে গেলে
পুরো সিস্টেমটা এখন Redis-এর ওপর দাঁড়িয়ে। কিন্তু Redis যদি ধীর হয়ে যায়? ডিফল্ট ক্লায়েন্ট
অসীমকাল চেষ্টা করতে থাকে — আর প্রতিটা রিকোয়েস্ট ঝুলে যায়।
API
REDISসাড়া দিচ্ছে না
সব ঝুলে আছে
খালি কানেকশনই আর থাকে না
ডিফল্ট ioredisঅসীমকাল রিট্রাই করে
প্রতিটা রিকোয়েস্ট আটকে থাকে — Node-এর হাতে খালি কানেকশন থাকে না
ক্যাশ ধীর হওয়ার কারণে পুরো সাইট কার্যত ডাউন
সমাধানধাপ ২৭ · ২৮/২৯
১৫০০ms এ হাল ছেড়ে সোজা ডাটাবেজে
ক্যাশের জন্য আলাদা একটা কানেকশন রাখি, যার টাইমআউট দেড় সেকেন্ড আর রিট্রাই মাত্র একবার।
ফেল করলে catch ধরে সরাসরি ডাটাবেজ থেকে পড়ে।