সেশন ২৪

Rate Limiter / Search Design - এক উৎসবে দুইটা design

এই সেশনের লক্ষ্য: একটা online shop এর দুইটা অংশ শুরু থেকে design করা: অনেক server জুড়ে একটা distributed rate limiter, আর Bangla ও English দুই ভাষায় কাজ করা search ও autocomplete। প্রতিটার requirement, হিসাব, design আর trade-off দেখবো, আর দেখবো Redis আর index কীভাবে নতুন রূপে ফিরে আসে।

০০ যে প্রশ্নগুলোর উত্তর খুঁজবো

আগের সেশনে URL shortener দিয়ে case study এর সাতটা ধাপ শিখেছি। আজ সেই ধাপ মেনেই একটা online shop এর দুইটা ছোট অংশ design করবো। প্রথমটা একটা rate limiter, যেটা bot এর হাত থেকে login বাঁচায়। দ্বিতীয়টা search, যেটা “পাঞ্জাবি” লিখলেও খুঁজে পায়, “panjabi” লিখলেও খুঁজে পায়। দুইটাতেই আগের সেশনের Redis, index আর queue নতুন রূপে ফিরে আসবে।

  • ১ Login API তে bot এর ভিড়: প্রতিটা server এ আলাদা limit রাখলে কেন কাজ হয় না? (distributed rate limiter)
  • ২ Redis এ counter রাখলে race condition কোথায়? (INCR + EXPIRE, Lua script)
  • ৩ Sliding window log নাকি sliding window counter?
  • ৪ Redis নিজেই down হলে: fail open নাকি fail closed?
  • ৫ ২০ লাখ product থেকে "পাঞ্জাবি" কীভাবে খুঁজবো? (inverted index)
  • ৬ Bangla আর English text কে token এ ভাঙবো কীভাবে, আর index কখন update হয়? (tokenization, async indexing)
  • ৭ মিল পাওয়া ৩,০০০ product এর কোনটা আগে দেখাবো? (search ranking)
  • ৮ "pan" লিখতেই suggestion আসে কীভাবে? (typeahead, trie)

০১ Login API তে bot এর ভিড়: প্রতিটা server এ আলাদা limit রাখলে কেন কাজ হয় না?

চৈত্রের শেষ সপ্তাহ। পহেলা বৈশাখের আর দশ দিন বাকি। রাত দুইটায় রিয়ার phone বেজে উঠলো। রিয়া “বাজারঘর” নামের একটা online shop এর backend engineer। Alert বলছে, login API তে মিনিটে ৪০,০০০ চেষ্টা আসছে, যেখানে স্বাভাবিক সময়ে আসে মিনিটে ৩,০০০। ঘুমঘুম চোখে log খুলে রিয়া বুঝলো, এগুলো মানুষ না। কেউ অন্য কোনো site থেকে ফাঁস হওয়া email আর password এর লম্বা list নিয়ে একটা একটা করে চেষ্টা করছে।

এই আক্রমণের নাম credential stuffing। অনেকেই একই password সব জায়গায় ব্যবহার করে। তাই অন্য site এর ফাঁস হওয়া list থেকে কিছু account এ ঢোকা যায়। Bot এর দরকার শুধু অনেক বেশি চেষ্টা করার সুযোগ। আমাদের কাজ সেই সুযোগটা কেড়ে নেওয়া।

সেশন ২১ এ আপনি rate limiting শিখেছেন: একজন client নির্দিষ্ট সময়ে কতগুলো request পাঠাতে পারবে, তার একটা সীমা। সীমা পার হলে server 429 Too Many Requests ফেরত দেয়। সেখানে প্রশ্ন ছিলো algorithm নিয়ে। আজকের প্রশ্ন অন্য: বাজারঘরের ২০টা API server আছে। এই ২০টা server মিলে একটাই limit কীভাবে মানবে?

Requirement

Functional requirement (system কী করবে):

Non-functional requirement (কতটা ভালোভাবে করবে):

প্রথম চেষ্টা: প্রতিটা server নিজের memory তে গুনবে

সবচেয়ে সহজ উপায় হলো প্রতিটা server নিজের memory তে একটা counter রাখবে। কিন্তু load balancer (সেশন ০২) request গুলো ২০টা server এ ভাগ করে দেয়। তাই প্রতিটা server bot এর মোট চেষ্টার মাত্র ২০ ভাগের এক ভাগ দেখে। প্রত্যেকে ভাবে “মাত্র ১০টা, ঠিক আছে”। অথচ bot আসলে মিনিটে ২০০টা চেষ্টা করে ফেলেছে।

আগে: প্রতিটা server নিজে গোনে পরে: সবাই একটা counter দেখে Bot · 200/min Load Balancer Server 1 count: 10 Server 2 count: 10 Server 20 count: 10 প্রত্যেকে মাত্র 10 টা দেখে কেউ কারো count জানে না 20 server × 10 = 200 চেষ্টা পার Bot ধরা পড়ে না Bot · 200/min Load Balancer Server 1 Redis কে জিজ্ঞেস Server 2 Redis কে জিজ্ঞেস Server 20 Redis কে জিজ্ঞেস Redis একটাই counter: 11 > 10 মোট 10 টা চেষ্টা, তারপর 429 কোন server পেলো, তাতে কিছু যায় আসে না
বাঁ দিকে প্রতিটা server নিজের memory তে গোনে। Load balancer request ভাগ করে দেয়, তাই কোনো server ই limit পার হতে দেখে না, অথচ bot মিনিটে ২০০ বার চেষ্টা করে ফেলে। ডান দিকে সব server একই Redis counter বাড়ায়, তাই ১১ তম চেষ্টাতেই 429 যায়।

সমাধান হলো counter টা সব server এর বাইরে, একটা shared জায়গায় রাখা। সেই জায়গাটা খুব দ্রুত হতে হবে, কারণ প্রতিটা request এর আগে তাকে জিজ্ঞেস করতে হবে। এখানে Redis (সেশন ০৪) একদম মানানসই: সব data memory তে থাকে, আর একটা call এ সাধারণত এক millisecond এর কম লাগে। সব server যখন একই shared counter দেখে limit মানে, তাকে বলে distributed rate limiter।

Back-of-envelope হিসাব

সেশন ২৩ এ দেখেছেন, design এর আগে মোটা দাগে একটা হিসাব করে নিতে হয়। বাজারঘরের বৈশাখী সপ্তাহের হিসাবটা এরকম:

বাজারঘর login API, বৈশাখী সপ্তাহ

স্বাভাবিক login: 3,000 / min ≈ 50 req/s
Bot attack এর সময়: 40,000 / min ≈ 670 req/s
সব API মিলে, sale এর peak এ: ~20,000 req/s

প্রতিটা request এ check: 1 টা Redis call, ~0.5 ms (একই data center)
এক Redis node এর ক্ষমতা: সহজ command এ ~100,000 ops/s
তাই 20,000 check/s এক node এই চলে, হাতে ~5 গুণ জায়গা থাকে

Memory:
  active key (user + IP): ~500,000
  প্রতিটা key: ~100 byte
  মোট: 500,000 × 100 byte ≈ 50 MB

হিসাব থেকে দুটো কথা পরিষ্কার। এক, একটা Redis node এই কাজের জন্য যথেষ্ট, আর হাতে অনেক জায়গা থাকে। দুই, memory প্রায় লাগেই না। তাই আসল চ্যালেঞ্জ speed বা size না। আসল চ্যালেঞ্জ হলো হিসাবটা সঠিক রাখা, যখন ২০টা server একই মুহূর্তে একই counter ছুঁতে চায়।

০২ Redis এ counter রাখলে race condition কোথায়?

রিয়ার team এর একজন junior প্রথম version টা লিখলো। প্রতিটা request এ server Redis থেকে counter পড়ে (GET)। ১০ এর কম হলে এক বাড়িয়ে লিখে দেয় (SET)। দেখতে একদম ঠিক। কিন্তু দুইটা server একদম একই সময়ে request পেলে কী হয়, দেখুন:

সময় Server A Redis Server B ১. GET count ১. GET count 9 ফেরত 9 ফেরত 9 < 10, তাই OK 9 < 10, তাই OK ২. SET count 10 ২. SET count 10 count = 10 হওয়ার কথা ছিলো 11 দুটো request ই পার হলো অথচ counter বাড়লো মাত্র 1 বার
দুই server একই মুহূর্তে counter পড়লো, দুজনেই পেলো ৯। দুজনেই ভাবলো জায়গা আছে, আর দুজনেই লিখলো ১০। মাঝখানের এই ফাঁকটাই race condition। Bot একসাথে শত শত request পাঠালে এই ফাঁক দিয়ে limit এর অনেক বেশি চেষ্টা পার হয়ে যায়।

দুইটা server ই পড়লো ৯। দুজনেই ভাবলো “এখনো জায়গা আছে”। দুজনেই লিখলো ১০। ফলে দুটো request ই পার হলো, কিন্তু counter বাড়লো মাত্র একবার। দুই বা তার বেশি কাজ একই data একসাথে ছুঁলে, কে আগে আর কে পরে তার উপর result নির্ভর করলে, তাকে বলে race condition। Bot একসাথে শত শত request পাঠায়, তাই এই ফাঁকটা সে ঠিকই কাজে লাগাবে।

সমাধান ১: INCR, যেটা ভাগ হয় না

Redis এর INCR command পড়া আর বাড়ানো, দুটো কাজ একটা ধাপে করে। Redis একটা command শেষ না করে আরেকটা ধরে না। তাই মাঝখানে অন্য কোনো server ঢুকতে পারে না। যে কাজ পুরোটা একসাথে হয়, মাঝপথে ভাগ হয় না, তাকে বলে atomic operation। দুই server একসাথে INCR করলে একজন পায় ১০, আরেকজন পায় ১১। যে ১১ পেলো, সে request টা আটকে দেয়।

সমাধান ২: INCR আর EXPIRE কে এক সাথে বাঁধা

Counter টা চিরকাল থাকলে চলবে না। এক মিনিট পরে সেটা মুছে যেতে হবে। তাই প্রথম INCR এর পর EXPIRE দিয়ে ৬০ সেকেন্ডের TTL (সেশন ০৪) বসাতে হয়। কিন্তু এখানে দুইটা আলাদা command। প্রথমটার পর server crash করলে দ্বিতীয়টা আর চলে না। তখন counter টা কখনো মোছে না, আর সেই user চিরদিনের জন্য আটকে যায়।

এর উত্তর হলো Lua script। Redis এর ভেতরে ছোট একটা script পাঠানো যায়। Redis পুরো script টা একটানা চালায়, মাঝখানে অন্য কোনো command ঢুকতে দেয় না। তাই script এর ভেতরের সব কাজ মিলে একটা atomic কাজ হয়ে যায়:

-- KEYS[1] = "rl:login:ip:203.0.113.7:29012345"   (IP আর মিনিটের নম্বর)
-- ARGV[1] = 10 (limit)    ARGV[2] = 60 (window, সেকেন্ডে)

local count = redis.call('INCR', KEYS[1])

if count == 1 then
    -- এই মিনিটের প্রথম request: এখনই TTL বসানো হয়
    redis.call('EXPIRE', KEYS[1], ARGV[2])
end

if count > tonumber(ARGV[1]) then
    return 0    -- limit পার, server 429 পাঠাবে
end

return 1        -- request এগিয়ে যাবে

প্রতিটা API server request পেলে এই script টা একবার চালায়। উত্তর ১ হলে request এগিয়ে যায়। উত্তর ০ হলে server 429 ফেরত দেয়। একটাই network round trip, আর কোনো race condition নেই।

মূল কথা

“আগে পড়া, তারপর ভেবে লেখা” ধরনের কাজ দুই ধাপে করলে মাঝখানে অন্য কেউ ঢুকে পড়তে পারে। Shared counter এ পড়া আর লেখা সবসময় একটা atomic ধাপে করুন: ছোট কাজে INCR, একাধিক ধাপে Lua script।

০৩ Sliding window log নাকি sliding window counter?

সেশন ২১ এ চারটা algorithm এর ছবি দেখেছেন। এক লাইনে মনে করিয়ে দিই:

উপরের Lua script টা আসলে fixed window। এর একটা দুর্বলতা আছে। Bot ১০:০০:৫৯ এ ১০টা চেষ্টা করলো, আবার ১০:০১:০০ এ ১০টা। দুই সেকেন্ডে ২০টা চেষ্টা পার হয়ে গেলো, কারণ দুটো আলাদা মিনিটে পড়েছে। Login এর মতো জায়গায় এই ফাঁক বন্ধ করতে চাই। Sliding window এর দুটো রূপ আছে, আর এদের cost একেবারে আলাদা।

Sliding window log: প্রতিটা request এর সময় (timestamp) একটা তালিকায় রাখা হয়। Redis এ এর জন্য sorted set ব্যবহার হয়, যেটা প্রতিটা entry কে একটা সংখ্যা (এখানে সময়) ধরে সাজিয়ে রাখে। নতুন request এলে ৬০ সেকেন্ডের পুরোনো timestamp গুলো মুছে ফেলা হয়, তারপর বাকিগুলো গোনা হয়। হিসাব একদম নিখুঁত। কিন্তু limit যত, প্রতি user এর জন্য তত entry রাখতে হয়।

Sliding window counter: শুধু দুইটা সংখ্যা রাখা হয়: আগের মিনিটের count আর এই মিনিটের count। তারপর আগের মিনিটের যতটুকু অংশ এখনো “গত ৬০ সেকেন্ড” এর ভেতরে পড়ে, ততটুকু ধরে একটা আনুমানিক হিসাব করা হয়:

10:00 10:01 10:02 আগের মিনিট count = 80 এই মিনিট count = 30 (এখন পর্যন্ত) গত 60 সেকেন্ড এখন 10:01:15 আগের মিনিটের শেষ 45s মানে 75% এখনো window এর ভেতরে আনুমানিক = 30 + 80 × 0.75 = 90 90 < 100 (limit), তাই request যাবে
এখন ১০:০১:১৫। "গত ৬০ সেকেন্ড" এর মধ্যে এই মিনিটের পুরোটা আর আগের মিনিটের শেষ ৪৫ সেকেন্ড পড়ে। আগের মিনিটের ৮০টা request সমানভাবে ছড়ানো ছিলো ধরে নিয়ে তার ৭৫% নেওয়া হয়। মাত্র দুইটা সংখ্যা রেখেই প্রায় নিখুঁত হিসাব পাওয়া যায়।

এটা ধরে নেয় যে আগের মিনিটের request গুলো পুরো মিনিট জুড়ে সমানভাবে ছড়ানো ছিলো। সব সময় তা হয় না, তাই হিসাবটা আনুমানিক। তবে Cloudflare নিজেদের traffic এ মেপে দেখেছিলো, মাত্র ০.০০৩% request এ এই আনুমানিক হিসাব ভুল সিদ্ধান্ত দিয়েছিলো।

Sliding window logSliding window counter
প্রতি user এ কী রাখেপ্রতিটা request এর timestampদুইটা সংখ্যা
১০ লাখ user, limit মিনিটে ১০০১০ লাখ × ১০০ entry × ~৬৪ byte ≈ ৬.৪ GB১০ লাখ × ২ × ~৫০ byte ≈ ১০০ MB
নির্ভুলতানিখুঁতআনুমানিক, কিন্তু খুব কাছাকাছি
কোথায় মানায়কম limit, যেমন login এ মিনিটে ১০বড় limit, যেমন search API তে মিনিটে ১০০

রিয়া দুটোই ব্যবহার করলো। Login এ limit মাত্র ১০, তাই log রাখলেও memory খুব কম লাগে, আর হিসাব নিখুঁত হয়। Search আর product API তে limit বড়, সেখানে counter।

০৪ Redis নিজেই down হলে: fail open নাকি fail closed?

এখন প্রতিটা request এর পথে Redis বসে আছে। তাহলে Redis নিজেই একটা নতুন SPOF (সেশন ০১)। ধরুন বৈশাখের আগের সন্ধ্যায় Redis এর primary down হলো। Replica কে primary বানাতে (failover, সেশন ০৩) ২০ সেকেন্ড লাগবে। এই ২০ সেকেন্ড API server গুলো কী করবে? দুটো রাস্তা আছে।

কোনোটাই সব জায়গায় ঠিক না। উত্তরটা নির্ভর করে endpoint টার উপর: এখানে ভুল হলে কোনটা বেশি ক্ষতি করে?

Redis এর জবাব 5 ms এ এলো না failover চলছে, ~20 সেকেন্ড কোন endpoint? ভুল হলে ক্ষতি কম ভুল হলে ক্ষতি বড় Product দেখা, search কয়েক সেকেন্ড বাড়তি request FAIL OPEN সব request যায়, shop চালু Login, OTP SMS প্রতি SMS এ টাকা, account এর ঝুঁকি FAIL CLOSED (local limit) প্রতি server: মোট limit ÷ 20
Redis জবাব না দিলে প্রতিটা server নিজে সিদ্ধান্ত নেয়। Browse আর search এ দরজা খোলা থাকে, কারণ সেখানে কয়েক সেকেন্ডের বাড়তি request এ তেমন ক্ষতি নেই। Login আর OTP এ প্রতিটা server নিজের memory তে মোট limit এর ২০ ভাগের এক ভাগ মেনে চলে, যতক্ষণ না Redis ফিরে আসে।

Product দেখা বা search এ কয়েক সেকেন্ড বেশি request গেলে তেমন ক্ষতি হয় না। তাই সেখানে fail open। কিন্তু login আর OTP (SMS এ পাঠানো এক বারের code) এ ক্ষতিটা বড়। OTP এর প্রতিটা SMS এ টাকা লাগে, আর bot মিনিটে হাজারটা SMS পাঠাতে পারলে এক রাতেই বড় একটা bill হয়ে যায়। সেখানে fail closed এর কাছাকাছি কিছু চাই।

রিয়ার team মাঝামাঝি একটা উপায় নিলো। Redis এর জবাব ৫ ms এর মধ্যে না এলে server টা timeout (সেশন ০১) ধরে নেয়। তারপর নিজের memory তে একটা ছোট local limit চালায়। ২০টা server, তাই প্রতিটা server মোট limit এর ২০ ভাগের এক ভাগ নিজে মানে। হিসাবটা নিখুঁত না, তবে পুরো খোলা দরজার চেয়ে অনেক ভালো।

মনে রাখার উপায়

নামটা দরজার মতো ভাবুন। Fail open মানে পাহারাদার না থাকলে দরজা খোলা থাকে। Fail closed মানে পাহারাদার না থাকলে দরজায় তালা পড়ে। কোন দরজায় কোন নিয়ম, সেটা ঠিক করে ঐ দরজার পেছনে কী আছে।

০৫ ২০ লাখ product থেকে “পাঞ্জাবি” কীভাবে খুঁজবো?

এবার রিয়ার সামনে সবচেয়ে বড় কাজ। গত বছর বৈশাখে customer রা অভিযোগ করেছিলো: “panjabi” লিখলে কিছুই আসে না, “পাঞ্জাবী” লিখলে অর্ধেক আসে। এবার search ঠিক করতে হবে।

Requirement আর হিসাব

বাজারঘর search, বৈশাখী সপ্তাহ

Product: 2,000,000 (20 লাখ)
Search: 1,500,000 / দিন ≈ 17 / s গড়ে
Sale এর peak (গড়ের 10 গুণ): ~170 search / s
Typeahead (debounce সহ): প্রতি search এ ~3 request, peak এ ~510 / s

Index এর size:
  প্রতি product এ token: ~50 (title + description)
  মোট posting: 2,000,000 × 50 = 100,000,000
  প্রতি posting: ~8 byte (compress করার পর)
  মোট index: ≈ 800 MB, এক machine এর memory তেই আঁটে

SQL এর LIKE কেন চলবে না

প্রথমে মনে হতে পারে, WHERE title LIKE '%পাঞ্জাবি%' লিখলেই তো হয়। কিন্তু সেশন ০৭ এর B-tree index সাজানো থাকে title এর শুরু থেকে। শব্দটা title এর মাঝখানে থাকলে index কোনো কাজে আসে না। তখন database কে ২০ লাখ row একটা একটা করে পড়তে হয়। তাছাড়া LIKE জানে না যে “panjabi” আর “পাঞ্জাবি” একই জিনিস। কোন result আগে দেখাতে হবে, সেটাও জানে না।

Inverted index: বইয়ের শেষের সূচির মতো

একটা মোটা বইয়ে “photosynthesis” কোথায় আছে খুঁজতে আপনি প্রথম পাতা থেকে পড়া শুরু করেন না। বইয়ের শেষে যান। সেখানে প্রতিটা শব্দের পাশে লেখা থাকে কোন কোন পাতায় সেটা আছে। Search engine ঠিক এই কাজটাই করে।

সাধারণ table এ প্রতিটা product এর পাশে তার শব্দগুলো থাকে। উল্টো দিক থেকে সাজালে প্রতিটা শব্দের পাশে থাকে সেই শব্দ যে যে product এ আছে তাদের তালিকা। এই উল্টো করে সাজানো কাঠামোর নাম inverted index। প্রতিটা শব্দের পাশের product তালিকাটাকে বলে posting list।

PRODUCT INVERTED INDEX P1 লাল সুতি পাঞ্জাবি P2 সাদা সুতি শাড়ি P3 লাল সিল্ক শাড়ি P4 লাল-সাদা পাঞ্জাবি সেট উল্টো করে সাজানো লাল 1 3 4 পাঞ্জাবি 1 4 সুতি 1 2 শাড়ি 2 3 সাদা 2 4 সিল্ক 3 সেট 4 খোঁজ: লাল পাঞ্জাবি দুইটা posting list বের হয় দুই তালিকার মিল [1, 3, 4] আর [1, 4] P1, P4 শুধু 2 তালিকা পড়া
বাঁ দিকে প্রতিটা product এর পাশে তার শব্দ। ডান দিকে উল্টোটা: প্রতিটা শব্দের পাশে যে যে product এ সেটা আছে (posting list)। "লাল পাঞ্জাবি" খুঁজতে শুধু দুইটা সাজানো তালিকা মেলাতে হয়, ২০ লাখ product পড়তে হয় না।

“লাল পাঞ্জাবি” খুঁজলে engine দুইটা শব্দের posting list বের করে। তারপর দুই তালিকার মিল খোঁজে। এই তালিকাগুলো product id ধরে সাজানো থাকে, তাই দুইটা সাজানো তালিকার মিল বের করা খুব দ্রুত হয়। ২০ লাখ product পড়তে হয় না, শুধু দুইটা ছোট তালিকা পড়লেই চলে।

Catalog অনেক বড় হলে index টাকে কয়েকটা shard এ ভাগ করা হয় (সেশন ০৫)। Query সব shard এ একসাথে যায়, প্রতিটা shard নিজের সেরা result ফেরত দেয়, তারপর সেগুলো এক করে চূড়ান্ত তালিকা বানানো হয়। বাস্তবে এই কাজের জন্য সাধারণত Elasticsearch বা OpenSearch ব্যবহার হয়। দুটোই ভেতরে Lucene নামের একটা inverted index library দিয়ে বানানো।

০৬ Bangla আর English text কে token এ ভাঙবো কীভাবে, আর index কখন update হয়?

Inverted index বানানোর আগে একটা প্রশ্নের উত্তর দিতে হয়: “শব্দ” বলতে আসলে কী বোঝাবো? একজন seller title লিখলো “লাল সুতি পাঞ্জাবীগুলো”। আরেকজন লিখলো “Red Cotton PANJABI”। Customer খুঁজছে “পাঞ্জাবি”। তিনটা আলাদা বানান, অথচ জিনিস একই।

Text কে ছোট ছোট খোঁজার যোগ্য টুকরায় ভাঙা আর প্রতিটা টুকরাকে একটা নিয়মিত রূপে আনার কাজকে বলে tokenization। প্রতিটা টুকরাকে বলে token। Index এ শব্দ নয়, এই token গুলোই জমা হয়।

SELLER A SELLER B লাল সুতি পাঞ্জাবীগুলো Red Cotton PANJABI ১. normalize, ভাঙা, ছোট হাত লাল · সুতি · পাঞ্জাবীগুলো red · cotton · panjabi ২. বানান মেলানো (ী থেকে ি) লাল · সুতি · পাঞ্জাবিগুলো red · cotton · panjabi ৩. stemming ("গুলো" বাদ) লাল · সুতি · পাঞ্জাবি red · cotton · panjabi ৪. synonym (red থেকে লাল) লাল · সুতি · পাঞ্জাবি দুই product এর token এখন একই
দুইজন seller একই জিনিস দুই ভাষায়, দুই বানানে লিখেছে। চারটা ধাপ পার হয়ে দুটোই একই token এ পৌঁছায়। ড্যাশ দেওয়া বাক্সগুলোতে কিছু বদলায়নি: English এ এই দুই ধাপে কাজ কম, Bangla তে বেশি। Customer এর query ও ঠিক এই চার ধাপ পার হয়।

ধাপগুলো এক এক করে দেখি:

  1. Unicode normalization: Bangla তে একই দেখতে অক্ষর computer এ দুইভাবে লেখা যায়। যেমন “য়” একটা অক্ষর হিসেবেও লেখা যায়, আবার “য” এর সাথে নিচের ফোঁটা জুড়েও লেখা যায়। চোখে একই, কিন্তু computer এর কাছে আলাদা। তাই প্রথমেই সবকিছু একটা নিয়মিত রূপে আনা হয়।
  2. ভাঙা: space, কমা আর দাঁড়ি (।) দেখে text কে শব্দে ভাগ করা হয়। English এ বড় হাতের অক্ষর ছোট হাতের করা হয়, তাই “PANJABI” হয়ে যায় “panjabi”।
  3. বানান মেলানো: “পাঞ্জাবী” আর “পাঞ্জাবি”, “শাড়ী” আর “শাড়ি”, দুই বানানই খুব চলে। ঈ-কার কে ই-কার এ আনার মতো কয়েকটা নিয়ম দিয়ে এগুলোকে এক করা হয়।
  4. শব্দের মূল বের করা: “পাঞ্জাবীগুলো”, “পাঞ্জাবির”, “পাঞ্জাবিটা” থেকে “গুলো”, “র”, “টা” এর মতো শেষাংশ কেটে মূল শব্দ “পাঞ্জাবি” বের করা হয়। English এ “shirts” থেকে “shirt”। এই কাজের নাম stemming।
  5. প্রতিশব্দ মেলানো: “panjabi”, “punjabi” আর “পাঞ্জাবি” কে একই token এ আনা হয়, একটা ছোট synonym তালিকা দিয়ে। “red” আর “লাল” ও এভাবে মেলে।
সবচেয়ে জরুরি নিয়ম

যে tokenizer দিয়ে product index করেছেন, ঠিক সেই tokenizer দিয়েই customer এর query ভাঙতে হবে। Index এ “পাঞ্জাবি” জমা আছে, অথচ query থেকে বের হলো “পাঞ্জাবী”, তাহলে কোনো মিল পাওয়া যাবে না। দুই দিকে একই নিয়ম না চললে সবচেয়ে ভালো index ও খালি হাতে ফেরে।

নতুন product index এ কখন ঢোকে?

বৈশাখের আগে seller রা দিনে প্রায় ৩০,০০০ নতুন product যোগ করে, আর লাখো product এর দাম ও stock বদলায়। প্রতিটা save এর সাথে সাথে index update করতে গেলে seller এর “Save” button ধীর হয়ে যাবে। Search cluster একটু ধীর হলে product save ও আটকে যাবে।

তাই কাজটা আলাদা করা হয়। Product আগে main database এ save হয়, seller সাথে সাথে “সফল” দেখে। তারপর একটা product.updated event (সেশন ১০) queue তে যায়। একটা indexer worker সেটা তুলে tokenize করে index update করে। একে বলে async indexing।

Seller নতুন পাঞ্জাবি যোগ সাথে সাথে "সফল" দেখে ১. save Product API write path Main DB আসল data ২. event Queue product.updated Indexer worker ৩. tokenize করে ৪. update Customer "পাঞ্জাবি" খোঁজে Search API read path Search index Elasticsearch নতুন product search এ আসে ~1-2 সেকেন্ড পরে (near real-time)
Seller এর save শেষ হয় database এ লেখার সাথে সাথেই। Index update হয় আলাদা পথে: event যায় queue তে, worker সেটা tokenize করে index এ লেখে। Search cluster ধীর হলেও seller এর save আটকায় না। শুধু নতুন product search এ আসতে কয়েক সেকেন্ড দেরি হয়।

এর মানে, নতুন product search এ আসতে কয়েক সেকেন্ড দেরি হয়। Elasticsearch নিজেও default ভাবে প্রতি ১ সেকেন্ডে নতুন লেখাগুলো search এর জন্য খুলে দেয়। এভাবে কয়েক সেকেন্ডের মধ্যে খুঁজে পাওয়াকে বলে near real-time search। এটা eventual consistency (সেশন ০৩) এর আরেকটা চেহারা। মনে রাখবেন, সত্যিকারের data থাকে main database এ। Index হারিয়ে গেলেও database থেকে আবার পুরোটা বানানো যায়।

০৭ মিল পাওয়া ৩,০০০ product এর কোনটা আগে দেখাবো?

“লাল পাঞ্জাবি” লিখলে মিল পাওয়া যায় ৩,০০০ product। Customer প্রথম পাতার ২০টার বেশি সাধারণত দেখে না। তাই কোন ২০টা আগে আসবে, সেটাই search এর আসল মান ঠিক করে। দুই ধরনের হিসাব মিলিয়ে এটা ঠিক হয়।

লেখার সাথে মিল (relevance):

ব্যবসার signal: কতজন কিনেছে, rating কত, stock আছে কি না, কতজন result এ click করে কিনেছে। Relevance দিয়ে প্রথম ১,০০০টা বাছাই হয়। তারপর এই signal গুলো মিলিয়ে চূড়ান্ত ক্রম হয়। Feed এর two-stage ranking এর সাথে মিলটা খেয়াল করুন।

ProductTitle এ মিলবিক্রি (৭ দিন)RatingStockক্রম
লাল সুতি পাঞ্জাবিদুই শব্দই১,২০০৪.৬আছে১
Red Cotton Panjabiদুই শব্দই (synonym দিয়ে)৩০০৪.৪আছে২
লাল-সাদা পাঞ্জাবি সেটদুই শব্দই৯০০৪.৭শেষনিচে

তৃতীয়টার বিক্রি আর rating ভালো, কিন্তু stock শেষ। Customer click করে “stock নেই” দেখলে হতাশ হয়। তাই সেটা নিচে নামে।

০৮ “pan” লিখতেই suggestion আসে কীভাবে?

Customer “p”, “a”, “n” লিখতেই নিচে ভেসে ওঠে “panjabi” আর “pant”। টাইপ করার সাথে সাথে সম্ভাব্য পুরো query দেখানোকে বলে typeahead (বা autocomplete)। মানুষ সাধারণত প্রতি ১৫০-২০০ ms এ একটা অক্ষর টাইপ করে। তাই suggestion কে এর চেয়েও দ্রুত আসতে হয়, নইলে পরের অক্ষর টাইপ হয়ে যায় আর পুরোনো suggestion অর্থহীন হয়ে পড়ে।

Trie: অক্ষর ধরে সাজানো গাছ

Trie একটা গাছের মতো data structure। এর প্রতিটা ধাপে একটা করে অক্ষর থাকে। Root থেকে নিচে নামতে নামতে অক্ষরগুলো জুড়লে একটা prefix (শব্দের শুরুর অংশ) তৈরি হয়। “pan” খুঁজতে root থেকে p, তারপর a, তারপর n, মাত্র তিন ধাপ। Dictionary তে লাখটা query থাকলেও ধাপ তিনটাই।

p a n y j t pant · 4,100 a b i panjabi · 9,200 j a m a payjama · 2,600 root "pa" node এ রাখা top-3 panjabi · 9,200 pant · 4,100 payjama · 2,600 "pan" এর top-2 panjabi · 9,200 pant · 4,100 "pan" লিখলে: p, a, n এই তিন ধাপ নামা, তারপর node এ রাখা তালিকাটা ফেরত
গাঢ় পথটা "pan" prefix। Root থেকে তিন ধাপে n node এ পৌঁছানো যায়, dictionary তে যত query ই থাকুক। প্রতিটা node এ তার নিচের সবচেয়ে জনপ্রিয় query গুলো আগে থেকেই রাখা থাকে (top-k), তাই পুরো উপগাছ ঘুরতে হয় না। সংখ্যাগুলো গত সপ্তাহে কতবার খোঁজা হয়েছে তার হিসাব।

শুধু prefix এর node এ পৌঁছানো যথেষ্ট না। এর নিচে হাজারটা query থাকতে পারে। প্রতিবার পুরো উপগাছ ঘুরে সবচেয়ে জনপ্রিয় ৫টা বের করতে গেলে সময় লাগে। তাই প্রতিটা node এ আগে থেকেই তার নিচের সেরা ৫টা query হিসাব করে রেখে দেওয়া হয়। Request এলে শুধু prefix ধরে node এ যাওয়া আর সেখানে রাখা তালিকাটা ফেরত দেওয়া। একে বলে top-k রাখা।

Bangla তেও একই trie কাজ করে, শুধু অক্ষরগুলো Bangla। “পাঞ্” লেখা মানে computer এর কাছে চারটা অক্ষর: প, া, ঞ, ্। Tokenization এর synonym তালিকা ব্যবহার করে “panj” লিখলেও “পাঞ্জাবি” suggestion দেখানো যায়।

Trie প্রতিটা search এ বদলায় না। Search এর log থেকে প্রতি ঘণ্টায় একটা job নতুন trie বানায়, তারপর পুরোনোটার জায়গায় বসিয়ে দেয়। এটা আরেকটা async কাজ। এক ঘণ্টা পুরোনো জনপ্রিয়তার হিসাব suggestion এর জন্য যথেষ্ট ভালো।

জনপ্রিয় prefix cache করা, আর অপ্রয়োজনীয় request কমানো

বৈশাখের সপ্তাহে হাজার হাজার মানুষ ঠিক একই জিনিস লেখে: “p”, “pa”, “pan”, “পা”, “পাঞ”। এই ছোট prefix গুলোর উত্তর সবার জন্য একই। তাই এগুলোর উত্তর Redis (সেশন ০৪) বা CDN edge এ (সেশন ০৬) ১০ মিনিটের TTL দিয়ে cache করা হয়। বেশিরভাগ request trie service পর্যন্ত পৌঁছায়ই না।

App এর দিক থেকেও request কমানো যায়। প্রতিটা অক্ষরে request না পাঠিয়ে, শেষ অক্ষর টাইপ করার পর ১০০ ms অপেক্ষা করা হয়। এর মধ্যে আরেকটা অক্ষর এলে আগের request বাতিল। এই কৌশলের নাম debounce। দ্রুত টাইপ করা মানুষের জন্য এতে request প্রায় অর্ধেক হয়ে যায়।

"pan" App: debounce 100 ms টাইপ থামলে তবেই request যায় Prefix cache (Redis / CDN) জনপ্রিয় prefix, TTL 10 min HIT MISS সাথে সাথে ফেরত ~5 ms প্রতি ঘণ্টায় নতুন trie বসে Trie service memory তে trie, top-k তৈরি, ~10 ms Build job (offline) search log থেকে জনপ্রিয়তা গোনে
App টাইপ থামার জন্য ১০০ ms অপেক্ষা করে, তারপর request পাঠায়। জনপ্রিয় prefix এর উত্তর cache থেকেই ফেরত যায়। শুধু বিরল prefix trie service পর্যন্ত পৌঁছায়। Trie নিজে প্রতিটা search এ বদলায় না; প্রতি ঘণ্টায় একটা offline job search log থেকে নতুন trie বানিয়ে পুরোনোটার জায়গায় বসায়।
তিন স্তরে বাঁচানো

Typeahead এর load তিন স্তরে কমে। App এ debounce প্রথমেই অর্ধেক request বাদ দেয়। Cache জনপ্রিয় prefix গুলো সামলায়। Trie service এর কাছে পৌঁছায় শুধু বিরল prefix গুলো, আর সেখানেও top-k আগে থেকে হিসাব করা থাকে।

বৈশাখের আগের রাতে রিয়া নিজের phone এ “panj” লিখলো। চোখের পলকে নিচে এলো “পাঞ্জাবি”। সে হাসলো, laptop বন্ধ করলো, আর এবার শান্তিতে ঘুমাতে গেলো।

০৯ মগজে প্রেশার দিন

আগে নিজে ভাবুন। তারপর দেখুন: সবাই যেভাবে ভাবে, কেন সেটা অসম্পূর্ণ, আর আসল কথাটা কোথায়।

প্রশ্ন ১: Rate limiter এর Redis down হলো। Limiter কী করবে?
উত্তর দেখুন আগে নিজে ভাবুন

সবাই যেভাবে ভাবে

“নিরাপত্তা সবার আগে। Limiter কাজ না করলে সব request আটকে দেবো (fail closed)। Bot তো ঢুকতে পারবে না।”

Bot আটকানোর চিন্তাটা ঠিক, কিন্তু এর দাম কে দিচ্ছে সেটা হিসাবে আসেনি।

যে জায়গাটা যোগ করা দরকার

সব endpoint এ fail closed করলে Redis এর একটা সমস্যায় পুরো shop বন্ধ হয়ে যায়। Bot যা চাইছিলো, আপনি নিজেই সেটা করে দিলেন। সঠিক উত্তর হলো endpoint ধরে ঠিক করা। Product দেখা আর search এ fail open, কারণ কয়েক সেকেন্ড বাড়তি request এ ক্ষতি সামান্য। Login আর OTP এ local fallback limit বা fail closed, কারণ সেখানে প্রতিটা বাড়তি চেষ্টায় টাকা বা account এর ঝুঁকি। সাথে Redis call এ ছোট একটা timeout রাখুন, যাতে ধীর Redis পুরো API কে ধীর করে না দেয়।

সঠিক ভাষায়

Fail open আর fail closed একটা ব্যবসার সিদ্ধান্ত, শুধু technical না। প্রতিটা endpoint এর জন্য জিজ্ঞেস করুন: limiter ছাড়া কয়েক মিনিট চললে বেশি ক্ষতি কোনটায়, bot ঢোকায় নাকি customer আটকানোয়?

প্রশ্ন ২: Product table এ title এর উপর index আছে। তাহলে search এর জন্য আলাদা Elasticsearch কেন লাগবে?
উত্তর দেখুন আগে নিজে ভাবুন

সবাই যেভাবে ভাবে

“Index থাকলে query fast হয়। LIKE '%পাঞ্জাবি%' চালালেই database index ব্যবহার করে দ্রুত খুঁজে দেবে।”

Index সম্পর্কে ধারণাটা ঠিক, কিন্তু কোন index কোন প্রশ্নের উত্তর দিতে পারে, সেই জায়গায় গোলমাল।

যে জায়গাটা যোগ করা দরকার

B-tree index (সেশন ০৭) পুরো title এর শুরু ধরে সাজানো। “পাঞ্জাবি” title এর মাঝখানে থাকলে সেটা কাজে আসে না, তাই full scan হয়। শব্দ ধরে খুঁজতে লাগে inverted index। তার উপর লাগে tokenization (“পাঞ্জাবী” আর “panjabi” কে এক করা) আর ranking (৩,০০০ মিলের মধ্যে কোনটা আগে)। তবে catalog ছোট হলে আলাদা system লাগে না। PostgreSQL এর full-text search আর GIN index আসলে একটা inverted index। কয়েক লাখ product পর্যন্ত সেটাই যথেষ্ট।

সঠিক ভাষায়

B-tree উত্তর দেয় “এই value টা কোথায়?”। Inverted index উত্তর দেয় “এই শব্দটা কোন কোন document এ আছে?”। Search এর জন্য দ্বিতীয় প্রশ্নটাই আসল।

প্রশ্ন ৩: Tokenizer এ "ঈ-কার কে ই-কার করা" নিয়ম নতুন যোগ হলো। তবু "পাঞ্জাবী" লিখলে পুরোনো product আসছে না। কেন?
উত্তর দেখুন আগে নিজে ভাবুন

সবাই যেভাবে ভাবে

“নতুন নিয়ম deploy হয়েছে, তাই এখন থেকে সব search এ নিয়মটা চলবে। হয়তো cache এর কারণে একটু সময় লাগছে।”

Query এর দিকটা ঠিক ধরেছেন, কিন্তু index এর দিকটা বাদ পড়ে গেছে।

যে জায়গাটা যোগ করা দরকার

নতুন নিয়ম শুধু নতুন query আর নতুন product এ চলছে। পুরোনো product গুলো index এ ঢুকেছিলো পুরোনো নিয়মে, তাই সেখানে এখনো “পাঞ্জাবী” জমা আছে। Query থেকে আসছে “পাঞ্জাবি”, তাই মিল হচ্ছে না। Tokenizer বদলালে পুরো catalog আবার index করতে হয় (reindex)। বাস্তবে নতুন একটা index পাশে বানানো হয়, সেটা পুরো ভরে গেলে search কে একবারে নতুনটায় সরিয়ে নেওয়া হয়। এতে মাঝখানে কোনো সময় search ভাঙা থাকে না।

মাথায় রাখুন

Tokenizer হলো index আর query এর মধ্যে একটা চুক্তি। চুক্তি বদলালে দুই পক্ষকেই নতুন চুক্তিতে আনতে হয়, মানে পুরো index আবার বানাতে হয়।

১০ যে টার্মগুলো শিখলাম

টার্মমানে
Credential stuffingঅন্য site থেকে ফাঁস হওয়া email আর password এর list নিয়ে bot দিয়ে একের পর এক login চেষ্টা।
Distributed rate limiterএমন rate limiter, যেটা সব server এর জন্য একটা shared counter (যেমন Redis) দেখে একটাই limit মানে।
Race conditionএকাধিক কাজ একই data একসাথে ছুঁলে result নির্ভর করে কে আগে পৌঁছালো তার উপর।
Atomic operationযে কাজ পুরোটা একসাথে হয়, মাঝপথে অন্য কেউ ঢুকতে পারে না। যেমন Redis এর INCR।
Lua script (Redis)Redis এর ভেতরে চলা ছোট script; পুরোটা একটানা চলে, তাই কয়েকটা command মিলে একটা atomic কাজ হয়।
Sliding window logপ্রতিটা request এর timestamp রেখে গত N সেকেন্ডের request নিখুঁতভাবে গোনা। Memory বেশি লাগে।
Sliding window counterআগের আর এখনকার window এর দুইটা count থেকে আনুমানিক হিসাব। Memory খুব কম।
Fail openLimiter বা check কাজ না করলে request যেতে দেওয়া। Availability রক্ষা পায়, নিরাপত্তা কমে।
Fail closedLimiter বা check কাজ না করলে request আটকে দেওয়া। নিরাপত্তা রক্ষা পায়, availability কমে।
Inverted indexপ্রতিটা শব্দ থেকে সেই শব্দ যে যে document এ আছে তার তালিকা। বইয়ের শেষের সূচির মতো।
Posting listInverted index এ একটা শব্দের পাশের document id এর সাজানো তালিকা।
TokenizationText কে খোঁজার যোগ্য টুকরায় (token) ভাঙা আর নিয়মিত রূপে আনা।
Stemmingশব্দের শেষাংশ কেটে মূল রূপ বের করা: “পাঞ্জাবিগুলো” থেকে “পাঞ্জাবি”, “shirts” থেকে “shirt”।
Async indexingDatabase এ save এর পরে event আর worker দিয়ে আলাদাভাবে index update করা।
Near real-time searchনতুন data কয়েক সেকেন্ডের মধ্যে search এ পাওয়া যায়, সাথে সাথে না।
TF-IDF / BM25Relevance এর হিসাব: document এ শব্দটা কতবার আছে, আর পুরো collection এ শব্দটা কতটা বিরল।
Typeaheadটাইপ করার সাথে সাথে সম্ভাব্য পুরো query এর suggestion দেখানো (autocomplete)।
Trieঅক্ষর ধরে সাজানো গাছ; prefix এর দৈর্ঘ্য যত, খুঁজতে ঠিক তত ধাপ লাগে।
Top-kপ্রতিটা trie node এ আগে থেকে রাখা তার নিচের সবচেয়ে জনপ্রিয় k টা query।
Debounceশেষ keystroke এর পর অল্প অপেক্ষা করে request পাঠানো, যাতে প্রতিটা অক্ষরে request না যায়।

১১ শর্ট সামারি

  1. অনেক server জুড়ে একটাই limit মানতে counter রাখতে হয় একটা shared, দ্রুত জায়গায় (Redis), আর পড়া ও বাড়ানো করতে হয় একটা atomic ধাপে (INCR বা Lua script), নইলে race condition এ limit ফসকে যায়।
  2. ছোট limit এ নিখুঁত sliding window log আর বড় limit এ সস্তা sliding window counter মানায়; limiter নিজে down হলে fail open নাকি fail closed, সেটা endpoint ধরে ব্যবসার ক্ষতি দেখে ঠিক করতে হয়।
  3. Search চলে inverted index এর উপর, যেটা async ভাবে update হয়; একই tokenizer index আর query দুই দিকে Bangla ও English বানান এক করে, আর typeahead চলে top-k রাখা trie, prefix cache আর debounce দিয়ে।

১২ মনে রাখুন