সব problem (16)
System Design courseSystem Design Problems

0%

0/16 done

Practice · High Level Design · Read-Heavy: Serve It Fast

Design a URL Shortener

BeginnerID generationCachingRedirectsAnalytics⏱ 2-3 ঘণ্টা

Classic warm-up: ছোট আর unique ID বানানো, শত কোটি link রাখা, caching দিয়ে millisecond এ redirect, আর গতি না কমিয়ে click গোনা।

Learn mode: পুরো solution নিচে দেখানো আছে।

Problem এর পরিচয়

DifficultyBeginner
সময়design আর core বানাতে 2–3 ঘণ্টা; interview তে মোটামুটি 45 মিনিট
আগে যা জানা দরকারBack-of-the-envelope estimation, caching, সাধারণ SQL
ConceptRead-heavy system এর আন্দাজ, unique ID বানানো, base62, cache-aside, redirect (301 বনাম 302), async click গোনা, key ধরে sharding
বাস্তবেbit.ly আর TinyURL, bank আর courier company র SMS link, poster এর QR code, marketing email এর campaign link

URL shortener হলো HLD এর চিরচেনা প্রথম problem, কারণ product টা এক বাক্যে বলা যায়, অথচ high-level design এর প্রায় সব মূল idea এখানে আসে: আগে আন্দাজ, তারপর খেয়াল করা যে read write এর একশো গুণ, কোনো bottleneck ছাড়া unique ID বানানো, যেখানে traffic সেখানে cache বসানো, আর ধীর কাজ (analytics) গরম পথ থেকে সরিয়ে রাখা। এটা ঠিকঠাক বুঝলে বেশিরভাগ read-heavy system চেনা চেনা লাগবে।

Problem statement

ঢাকার একটা courier company প্রতিটা parcel এর সাথে একটা SMS পাঠায়: "Track your parcel: https://track.example.com/orders/2026/11/08/BD-7781-55210-DHK?lang=bn&utm_source=sms"। এত long link SMS এর অক্ষর খেয়ে ফেলে (প্রতিটা বাড়তি SMS এ টাকা লাগে), আর সস্তা phone এ প্রায়ই দুই লাইনে ভেঙে গিয়ে কাজই করে না। Shortener থাকলে SMS এ লেখা থাকে "sho.rt/15FTGg", আর link টা তবুও পুরো page খোলে।

কী বানাচ্ছি: এমন একটা service যেটা একটা long URL কে একটা ছোট code বানায়, আর ছোট link খুললে যে কাউকে long URL এ পাঠিয়ে দেয়, দ্রুত, বছরের পর বছর।

কে ব্যবহার করে: business আর marketer রা link বানায় (অল্প write), আর সাধারণ মানুষ সেগুলো click করে (প্রচুর read, সারা দুনিয়া থেকে, প্রায়ই slow mobile network এ)।

কী সমস্যা মেটায়: এমন link যেটা একটা SMS, tweet বা QR code এ ধরে; একটা campaign link কোথায় যাবে সেটা বদলানোর একটা জায়গা; আর link বানানো মানুষদের জন্য click এর হিসাব।

Technically কোথায় মজা: একটা code কখনো দুইটা URL দেখাতে পারবে না, তাই অনেক server জুড়ে code unique হতে হবে; redirect হলো user এর অপেক্ষার পথে, তাই শত কোটি link থাকলেও millisecond এ হতে হবে; click গুনতে হবে redirect কে slow না করে; আর খোলা একটা "link বানাও" box spammer আর phisher দের টানে।

Requirement

Functional requirement

  1. Create: একটা long URL (http বা https) দিলে একটা ছোট code ফেরত দাও। চাইলে একটা custom alias ("eid26") নাও, যদি সেটা ফাঁকা থাকে।
  2. Redirect: sho.rt/<code> খুললে long URL এ redirect। অচেনা code এ 404।
  3. Click গোনা: প্রতিটা redirect এ একটা click রেকর্ড করো; link এর মালিক মোট সংখ্যা দেখতে পারবে (কয়েক মিনিট দেরি চলবে)।
  4. Default এ link expire হয় না (optional expiry তারিখ থাকলে ভালো)।
  5. এই design এর scope এর বাইরে: user account আর dashboard, link এর গন্তব্য edit করা, link preview।

Non-functional requirement

Requirementলক্ষ্যএখানে কেন গুরুত্বপূর্ণ
Read latencyserver এ redirect এর p99 50 ms এর নিচেUser একটা ফাঁকা page এর দিকে তাকিয়ে আছে; প্রতিটা redirect একটা বাড়তি hop
Availabilityredirect এর জন্য 99.99%ছাপানো QR code এর ভাঙা short link ঠিক করা যায় না
Durabilityবানানো link কখনো হারাবে নাLink ছাপা হয়, SMS এ যায়, বছরের পর বছর থাকে
Scaleমাসে 10 কোটি নতুন link, প্রতিটা link 100 বার পড়ানিচের আন্দাজে হিসাব
Consistencyনতুন link এক সেকেন্ডের মধ্যে কাজ করবে; click এর হিসাব পিছিয়ে থাকতে পারেcode → URL এর জন্য strong, analytics এর জন্য eventual
Uniquenessএকটা code কখনো দুইটা URL দেখাবে নাযে একটা সঠিকতার rule বাঁকানো যায় না
Securityopen redirect এর অপব্যবহার নেই, ক্ষতিকর গন্তব্য বন্ধ, create এ rate limitShortener phishing এর প্রিয় হাতিয়ার

Redirect এর latency, availability আর uniqueness, এই তিনটাই design এর আকৃতি ঠিক করে। Write এর throughput সামান্য; read এর throughput সামান্য না।

Hint 1: কোন দিকটা ভারী?

কিছু আঁকার আগে সংখ্যা বের করো। সেকেন্ডে কয়টা link বানানো হয়, আর কয়টা খোলা হয়? Design কোন দিকের জন্য optimise করবে?

Hint 2: code unique রাখা

Random code এ প্রতিটা create এ "এটা কি আগে আছে?" check লাগে। আগে থেকেই unique এমন কিছুকে কি ছোট একটা string বানানো যায়?

Hint 3: গরম পথ

একটা redirect সবচেয়ে কম কতটুকু কাজ করতে পারে? কোন কাজ redirect থেকে পুরোপুরি সরিয়ে ফেলা যায়?

Hint 4: 301 নাকি 302?

301 browser কে বলে redirect টা চিরকাল মনে রাখতে। এতে তোমার load এর কী হয়, আর click এর হিসাবের কী হয়?

Capacity আন্দাজ

ধরে নেওয়া (মুখে বলা, গোল সংখ্যা):

  • মাসে 10 কোটি নতুন link।
  • প্রতিটা link জীবনে মোটামুটি 100 বার খোলা হয়, তাই read write এর 100 গুণ।
  • একটা রাখা link (code, long URL, owner, তৈরির সময়) মোটামুটি 500 byte।
  • Link 5 বছর রাখা হয়; storage 3 বার replicate হয়।
  • সবচেয়ে ব্যস্ত ঘণ্টা গড়ের মোটামুটি 3 গুণ।

Traffic

Formulaফল
সেকেন্ডে write (গড়)10 কোটি ÷ (30 × 86,400)≈ 40 / s
সেকেন্ডে write (peak)40 × 3≈ 120 / s
সেকেন্ডে read (গড়)40 × 100≈ 4,000 / s
সেকেন্ডে read (peak)4,000 × 3≈ 12,000 / s

Storage

Formulaফল
5 বছরে link10 কোটি × 12 × 5600 কোটি
কাঁচা data6 × 10⁹ × 500 B3 TB
3 কপি সহ3 TB × 39 TB
Database এর বৃদ্ধি10 কোটি × 500 B প্রতি মাসেমাসে 50 GB

Bandwidth: একটা redirect এর response খুব ছোট (কয়েকশো byte এর header)। 12,000 / s × 500 B ≈ peak এ সেকেন্ডে 6 MB বাইরে যায়। Network চিন্তার বিষয় না।

Cache এর size: traffic খুব অসমান: কয়েকটা campaign link বেশিরভাগ click পায়। গত মাসে বানানো link এর উপরের 20% (2 কোটি) যদি 80% read পায়, ওগুলো cache করতে লাগে 2 কোটি × 500 B ≈ 10 GB, যেটা একটা Redis node এর memory তে আরামে ধরে।

Code এর দৈর্ঘ্য: 6টা base62 অক্ষরে হয় 62⁶ ≈ 5,680 কোটি code; 7টায় 3.5 লাখ কোটি। 600 কোটি link ছয় অক্ষরে অনেক জায়গা রেখেই ধরে; ID শুরু করব 1,000,000,000 থেকে, যাতে প্রথম দিন থেকেই code একই দৈর্ঘ্যের দেখায় (প্রায় 5,600 কোটি পর্যন্ত 6 অক্ষর)।

সংখ্যা কী বলছে: read write কে 100 এ 1 এ হারায় আর data ছোট। কঠিন অংশ storage না, সেকেন্ডে 12,000 lookup millisecond এ উত্তর দেওয়া। তাই: database এর সামনে একটা cache, read replica, আর redirect এর পথে ধীর কিছু না।

API design

Method আর pathRequestসফল হলেError
POST /v1/links{"url": "https://...", "alias": "eid26"?, "expires_at"?: "..."}, header Idempotency-Key201 {"code": "15FTGg", "short_url": "https://sho.rt/15FTGg"}400 খারাপ URL বা alias, 409 alias নেওয়া, 422 বন্ধ করা গন্তব্য, 429 বেশি create
GET /{code}কিছু না302 Location: <long URL>404 অচেনা code, 410 মেয়াদ শেষ
GET /v1/links/{code}/statsকিছু না (owner এর API key)200 {"clicks": 1234, "by_day": [...]}403 owner না, 404
  • Authentication: link বানাতে আর stats পড়তে API key (বা login করা session) লাগে, যাতে প্রতি client rate limit দেওয়া যায় আর অপব্যবহার কার সেটা বোঝা যায়। Redirect public আর নামহীন।
  • Idempotency: POST /v1/links একটা Idempotency-Key নেয়; timeout এর পরে retry করা request দ্বিতীয় link না পেয়ে একই code ফেরত পায়। (একই URL এর জন্য দুইটা code ক্ষতিকর না, কিন্তু ID নষ্ট করে আর stats গুলিয়ে দেয়।)
  • GET /{code} root এ কেন: short link যত ছোট হয় তত ভালো; sho.rt/15FTGg, sho.rt/r/15FTGg এর চেয়ে ভালো।
  • Default এ 302, 301 না: 301 browser চিরকাল cache করে, তাই বারবার visit আমাদের কাছে আসেই না, এতে load বাঁচে কিন্তু click এর হিসাব হারায়, আর ভুল গন্তব্য ঠিক করা অসম্ভব হয়ে যায়। 301 শুধু সেই link এ যেগুলো কখনো বদলাবে না আর যাদের click গুরুত্বপূর্ণ না।

Database design

Access pattern: একটা table, সবসময় তার key (code) দিয়ে খোঁজা, একবার লেখা আর বহুবার পড়া, গরম পথে কোনো join নেই। এটা key-value ধরনের pattern।

বাছাই: শুরু করো PostgreSQL (বা MySQL) দিয়ে। সেকেন্ডে 120 write আর পাঁচ বছরে 9 TB তে read replica সহ একটা primary অনেকদিন সামলায়, আর SQL দেয় constraint (unique code), ID allocator এর জন্য transaction, আর সহজ report। Key-value store (DynamoDB, Cassandra) আকর্ষণীয় হয় যখন region জুড়ে হাত না লাগিয়ে horizontal scaling চাও; এটা ভালো বিকল্প, required না।

CREATE TABLE links (
  code        VARCHAR(30)  PRIMARY KEY,            -- base62 ID or custom alias
  long_url    VARCHAR(2048) NOT NULL,
  owner_id    BIGINT        NOT NULL,             -- API key / account that created it
  created_at  TIMESTAMPTZ   NOT NULL DEFAULT now(),
  expires_at  TIMESTAMPTZ
);
CREATE INDEX links_owner ON links (owner_id, created_at DESC);  -- "my links" in the dashboard

-- The ID allocator: one row, bumped once per block of IDs.
CREATE TABLE id_blocks (name TEXT PRIMARY KEY, next_id BIGINT NOT NULL);
-- UPDATE id_blocks SET next_id = next_id + 1000 WHERE name = 'links' RETURNING next_id - 1000;

-- Clicks are not stored row-by-row in this table: they go to a queue and an analytics store.
  • Primary key = code: redirect এর lookup একটা primary-key read, আর database নিজেই "একটা code কখনো দুইটা URL দেখাবে না" নিশ্চিত করে (আগে থেকে থাকা alias insert করতে গেলে fail করে)।
  • গরম পথে কোনো foreign key নেই: owner_id accounts table কে দেখায়, কিন্তু redirect এ আমরা join করি না।
  • Replication: write এর জন্য একটা primary, read এর জন্য দুই বা বেশি async replica। তৈরির ঠিক পরের read-after-write সামলানো হয় primary থেকে নতুন code ফেরত দিয়ে আর cache এ সেটা রেখে দিয়ে।
  • পরে partitioning: link একটা primary ছাড়িয়ে গেলে code ধরে shard করো (code এর hash, consistent hashing বা fixed কয়টা logical partition)। তখন প্রতিটা redirect ঠিক একটা shard ছোঁয়।
  • Click: links database এ সেকেন্ডে 12,000টা insert দিলে write ই bottleneck হয়ে যেত। তার বদলে click যায় একটা queue দিয়ে গোনার জন্য বানানো একটা store এ (time-series বা columnar store, বা জমানো counter)।

Architecture এর ছবি

BrowserGET /15FTGgLBhealth checksApp serversstatelessRedishot codesDB primarywritesReplicasreadsClick queueasyncCounterworkerGEThit302missSELECTSETclickmost redirects: app -> Redis -> 302, about 1 ms of worka miss reads a replica, then fills Redis for next timethe click is queued; the redirect never waits for countingwrites go to the primary; reads come from Redis or replicas
Play চাপো। বেশিরভাগ redirect যায় Browser → LB → app → Redis, আর মোটামুটি এক millisecond এ একটা 302 দিয়ে উত্তর দেয়। Miss হলে একটা replica পড়ে, পরের request এর জন্য Redis ভরে রাখে। প্রতিটা click একটা queue তে যায়, আর একটা worker পরে গোনে, তাই redirect কখনো গোনার জন্য অপেক্ষা করে না।
Componentকেন আছেConnection
DNS (আঁকা নেই)sho.rt load balancer এ resolve হয়; ছোট TTL যাতে সরানো যায়Browser → DNS → LB
Load balancerApp server গুলোয় request ভাগ করে, অসুস্থদের বাদ দেয়, TLS শেষ করে→ app server
App serverStateless: check, code বানানো, redirect খোঁজা; যেকোনো server যেকোনো request সামলায়→ Redis, DB, queue
Redisগরম code → URL জোড়া রাখে; বেশিরভাগ redirect এখানেই থামেপ্রতিটা redirect এ পড়া, miss এ লেখা
DB primaryLink এর সত্যের উৎস; সব write নেয়→ replica (async)
Read replicaCache miss সামলায় যাতে primary read এর চাপে না পড়েmiss এ পড়া
Click queueClick এর event redirect এর পথ থেকে সরায়; spike সামলায়app → queue → worker
Counter workerপ্রতি code এ প্রতি মিনিটের click জমিয়ে analytics store এ রাখেqueue পড়ে

যা জেনেবুঝে রাখিনি: microservices (একটা service ই create আর redirect দুইটা করে), প্রথম দিন থেকে Kafka (এই সংখ্যায় একটা managed queue বা Redis Streams যথেষ্ট), বা একাধিক region (যতক্ষণ না availability বা latency র লক্ষ্য সেটা চায়)।

একটা request এর জীবন

একটা link বানানো

BrowserAppID blockDB primaryPOST /links {url}validate url, rate limitnext()1,000,000,000 (local)base62 -> 15FTGgINSERT (code PK)ok201 sho.rt/15FTGg
Play চাপো। Browser একটা long URL পাঠায়। App সেটা check করে আর rate limit দেখে, নিজের local block থেকে পরের ID নেয় (কোনো network call নেই), base 62 এ encode করে, code কে primary key রেখে insert করে, আর short URL সহ 201 ফেরত দেয়।
  1. Client → LB → app: URL আর একটা API key সহ POST /v1/links।
  2. App API key, rate limit, URL এর format (http/https, সর্বোচ্চ 2,048 অক্ষর), আর জানা ক্ষতিকর domain এর blocklist check করে।
  3. App নিজের local block থেকে পরের ID নেয়। প্রতি 1,000টা link এ মাত্র একবার নতুন block নিতে database ছোঁয়।
  4. App ID টা base 62 এ encode করে (code, url, owner) primary তে insert করে। Primary key uniqueness নিশ্চিত করে।
  5. App নতুন জোড়াটা Redis এ লেখে (যাতে প্রথম click গুলো miss না হয়) আর short URL সহ 201 ফেরত দেয়।

একটা short link খোলা (cache miss, তারপর hit)

BrowserAppRedisReplicaQueueGET /15FTGgGET code:15FTGgnil (miss)SELECT url WHERE code=?https://shop.../eid-saleSET code:15FTGg EX 86400click 15FTGg302 to the long URL
Play চাপো। Browser /15FTGg চায়। Redis এ কিছু নেই, তাই app একটা replica পড়ে, উত্তরটা একদিনের জন্য Redis এ রাখে, click টা queue তে দেয় আর 302 দিয়ে উত্তর দেয়। একই code এর পরের request Redis এই থামে।
  1. Browser → DNS → LB → যেকোনো app server: GET /15FTGg।
  2. App Redis এর কাছে code:15FTGg চায়। Hit হলে: ধাপ 5 এ যাও।
  3. Miss: app primary key দিয়ে replica পড়ে (কয়েক millisecond)।
  4. App জোড়াটা একটা TTL দিয়ে Redis এ লেখে (একদিন, ব্যবহারে নতুন হয়), তাই পরের request hit।
  5. App অপেক্ষা না করে click 15FTGg queue তে দেয়; queue ভরা থাকলে click টা বাদ পড়ে, redirect না।
  6. App উত্তর দেয় 302 Location: <long URL>। Browser আসল page এ চলে যায়।

Component গুলো গভীরে

Unique code বানানো

তিনটা চেনা উপায়:

উপায়কীভাবেভালোখারাপ
URL hash করা (MD5/SHA, 7 অক্ষর নেওয়া)base62(hash(url))[:7]একই URL → একই code, কোনো counter নেইCollision ধরে retry করতে হয়; শত কোটি link এ hash এর prefix প্রায়ই মিলে যায়
Random code7টা random base62 অক্ষরCode আন্দাজ করা যায় নাপ্রতিটা create এ "আগে আছে কি?"; table বাড়লে collision বাড়ে
Counter + base62 (বেছে নেওয়া)একটা unique সংখ্যা, encode করাগঠনের কারণেই unique, কোনো check নেই, ছোট codeCode পরপর (আন্দাজ করা যায়) যদি না এলোমেলো করা হয়; counter টা যেন bottleneck না হয়

Counter bottleneck হয় শুধু যদি প্রতিটা link database এর কাছে পরের সংখ্যা চায়। তাই প্রতিটা app server একটা UPDATE ... RETURNING দিয়ে ID এর একটা block (এখানে 1,000) নিজের করে নেয়, তারপর memory থেকে দেয়। কোনো server crash করলে তার block এর বাকিটা নষ্ট হয়, তাতে ক্ষতি নেই: আমাদের 5,600 কোটি ছয় অক্ষরের code আছে।

ID allocatorone row: next unused IDServer A1,000,000,000 - 999Server B1,000,001,000 - 999block 1block 215FTGg15FTWo15FTGhone database write per 1,000 linkseach server counts inside its own block: no collisions, no locks per link
Play চাপো। একটা shared allocator Server A কে 1,000,000,000 থেকে শুরু হওয়া block দেয়, আর Server B কে পরেরটা। তারপর প্রতিটা server নিজের block এর ভেতরে গোনে, তাই 15FTGg আর 15FTGh আসে A থেকে আর 15FTWo B থেকে, কোনো collision ছাড়া, প্রতি link এ কোনো lock ছাড়া।

Code আন্দাজ করা যাওয়া যদি সমস্যা হয় (কেউ সব link এক এক করে ঘুরে দেখতে পারে), encode এর আগে ID টাকে একটা উল্টানো যায় এমন এলোমেলো করার ধাপের মধ্য দিয়ে পাঠাও (ছোট একটা Feistel network এর মতো key দেওয়া permutation)। Unique থাকে, আর তবুও কোনো lookup লাগে না।

Redirect cache করা

TTL সহ cache-aside: Redis পড়ো, miss হলে একটা replica পড়ে Redis ভরো। আমাদের requirement এ link এর গন্তব্য কখনো বদলায় না, তাই edit এ cache invalidate করার দরকার নেই; TTL শুধু সেই link গুলোর জন্য memory রাখে যেগুলো জনপ্রিয় থাকে। Expiry বা edit এর feature থাকলে link বদলানোর সময় key মুছে দিতাম। গরম link (TV বিজ্ঞাপনের একটা campaign) ছোট cache time দিয়ে CDN edge থেকে serve করা যায়, তাতে সেই traffic এর বেশিরভাগ আমাদের load balancer ও দেখে না।

Redirect slow না করে click গোনা

Redirect একটা ছোট event (code, সময়, হয়তো দেশ আর device) একটা queue তে দিয়ে ফেরত আসে। একটা worker queue পড়ে একটা analytics store এ প্রতি code, প্রতি মিনিটের counter এ যোগ করে। Queue ভরা থাকলে user কে দেরি না করিয়ে click টাই বাদ দিই: আন্দাজের হিসাব মেনে নেওয়া যায়, slow redirect না। টেকসই queue হলে worker restart এও click টিকে থাকে; worker কে idempotent হতে হবে (প্রতিটা event ID একবারই গোনো), কারণ queue at least once দেয়।

চালিয়ে দেখার মতো core

এই program টাই design এর হৃদয়, এক বসায় পড়ে ফেলার মতো ছোট: base62 encoding, একটা allocator যেটা ID এর block দেয়, প্রতি server এ ID এর উৎস, Redis এর জায়গায় একটা LRU cache, PostgreSQL এর জায়গায় একটা database map, আর একটা service যেটা link বানায় আর cache-aside আর asynchronous click queue দিয়ে redirect খোঁজে। দুইটা "app server" একটা database, একটা cache আর একটা allocator share করে।

urlshortener/
├── go.mod
├── base62.go          Encode / Decode
├── ids.go             Allocator (shared) and IDs (one per server)
├── cache.go           LRU cache, hits and misses
├── shortener.go       DB, Service: Create, Resolve, click worker
├── shortener_test.go  round trip, no duplicates under concurrency, cache, validation, full queue
└── cmd/demo/main.go   two servers, a few links, ten redirects
// base62.go
package shortener

// The 62 characters a short code may use: digits, then lowercase, then uppercase.
const alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"

// Encode writes a number in base 62. Different numbers always give different codes, so a
// unique ID becomes a unique code with no collision check.
func Encode(n uint64) string {
	if n == 0 {
		return "0"
	}
	var buf [11]byte // 62^11 > 2^64, so 11 characters always fit
	i := len(buf)
	for n > 0 {
		i--
		buf[i] = alphabet[n%62]
		n /= 62
	}
	return string(buf[i:])
}

// Decode turns a code back into its number; ok is false for a character outside the alphabet.
func Decode(code string) (n uint64, ok bool) {
	for i := 0; i < len(code); i++ {
		c := code[i]
		var d byte
		switch {
		case c >= '0' && c <= '9':
			d = c - '0'
		case c >= 'a' && c <= 'z':
			d = c - 'a' + 10
		case c >= 'A' && c <= 'Z':
			d = c - 'A' + 36
		default:
			return 0, false
		}
		n = n*62 + uint64(d)
	}
	return n, true
}
// ids.go
package shortener

import "sync"

// Allocator stands in for one row in the database: "the next unused ID". It hands out whole
// blocks, so an app server talks to it once per block, not once per link.
type Allocator struct {
	mu    sync.Mutex
	next  uint64
	block uint64
}

// NewAllocator starts handing out IDs at start, block IDs at a time.
func NewAllocator(start, block uint64) *Allocator {
	return &Allocator{next: start, block: block}
}

// Take reserves the next block and returns its first ID and the ID just after it.
func (a *Allocator) Take() (from, to uint64) {
	a.mu.Lock()
	defer a.mu.Unlock()
	from = a.next
	a.next += a.block
	return from, a.next
}

// IDs is one app server's private block of IDs. Inside the block it needs no coordination.
type IDs struct {
	mu        sync.Mutex
	alloc     *Allocator
	next, end uint64
	Blocks    int // how many blocks this server has taken
}

// NewIDs gives an app server its own ID source backed by the shared allocator.
func NewIDs(a *Allocator) *IDs { return &IDs{alloc: a} }

// Next returns a fresh ID, taking a new block when the current one runs out.
func (s *IDs) Next() uint64 {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.next == s.end {
		s.next, s.end = s.alloc.Take()
		s.Blocks++
	}
	id := s.next
	s.next++
	return id
}
// cache.go
package shortener

import (
	"container/list"
	"sync"
)

// Cache is a small LRU cache of code -> long URL, like one Redis in front of the database.
type Cache struct {
	mu           sync.Mutex
	cap          int
	order        *list.List // front = most recently used
	items        map[string]*list.Element
	Hits, Misses int
}

type entry struct{ code, url string }

// NewCache holds at most capacity links.
func NewCache(capacity int) *Cache {
	return &Cache{cap: capacity, order: list.New(), items: map[string]*list.Element{}}
}

// Get returns the cached URL for code and counts a hit or a miss.
func (c *Cache) Get(code string) (string, bool) {
	c.mu.Lock()
	defer c.mu.Unlock()
	e, ok := c.items[code]
	if !ok {
		c.Misses++
		return "", false
	}
	c.Hits++
	c.order.MoveToFront(e)
	return e.Value.(entry).url, true
}

// Put stores a link, evicting the least recently used one when full.
func (c *Cache) Put(code, url string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	if e, ok := c.items[code]; ok {
		c.order.MoveToFront(e)
		return
	}
	if c.order.Len() == c.cap {
		old := c.order.Back()
		c.order.Remove(old)
		delete(c.items, old.Value.(entry).code)
	}
	c.items[code] = c.order.PushFront(entry{code, url})
}
// shortener.go
// Package shortener is the core of a URL shortener: unique codes from ID blocks, a database,
// a cache in front of it for redirects, and click counting that never slows a redirect down.
package shortener

import (
	"errors"
	"strings"
	"sync"
	"sync/atomic"
)

var (
	ErrBadURL   = errors.New("bad url (http or https only, at most 2048 characters)")
	ErrBadAlias = errors.New("bad alias: 4 to 30 letters, digits or dashes")
	ErrTaken    = errors.New("alias already taken")
	ErrNotFound = errors.New("no such code")
)

// DB is the source of truth: code -> long URL. Reads counts lookups that reached it.
type DB struct {
	mu    sync.Mutex
	links map[string]string
	Reads int
}

func NewDB() *DB { return &DB{links: map[string]string{}} }

// insert adds a link only if the code is free (a primary-key insert).
func (d *DB) insert(code, url string) bool {
	d.mu.Lock()
	defer d.mu.Unlock()
	if _, ok := d.links[code]; ok {
		return false
	}
	d.links[code] = url
	return true
}

func (d *DB) get(code string) (string, bool) {
	d.mu.Lock()
	defer d.mu.Unlock()
	d.Reads++
	url, ok := d.links[code]
	return url, ok
}

// Service is one app server. Several can share the same DB, cache and allocator.
type Service struct {
	ids     *IDs
	db      *DB
	cache   *Cache
	clicks  chan string
	mu      sync.Mutex
	counts  map[string]int
	dropped atomic.Int64 // clicks lost because the queue was full
	done    sync.WaitGroup
}

// NewService starts the background worker that counts clicks from a queue of queueSize.
func NewService(ids *IDs, db *DB, cache *Cache, queueSize int) *Service {
	s := &Service{ids: ids, db: db, cache: cache, clicks: make(chan string, queueSize), counts: map[string]int{}}
	s.done.Add(1)
	go func() {
		defer s.done.Done()
		for code := range s.clicks {
			s.mu.Lock()
			s.counts[code]++
			s.mu.Unlock()
		}
	}()
	return s
}

func validURL(u string) bool {
	return (strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://")) &&
		len(u) <= 2048 && !strings.ContainsAny(u, " \t\n")
}

func validAlias(a string) bool {
	if len(a) < 4 || len(a) > 30 {
		return false
	}
	for _, c := range a {
		if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-') {
			return false
		}
	}
	return true
}

// Create stores a long URL and returns its short code: the alias if one is given, otherwise
// the next ID in base 62.
func (s *Service) Create(longURL, alias string) (string, error) {
	if !validURL(longURL) {
		return "", ErrBadURL
	}
	code := alias
	if alias == "" {
		code = Encode(s.ids.Next())
	} else if !validAlias(alias) {
		return "", ErrBadAlias
	}
	if !s.db.insert(code, longURL) {
		return "", ErrTaken
	}
	return code, nil
}

// Resolve finds the long URL for a redirect: cache first, then the database (cache-aside).
// The click is put on a queue; if the queue is full the click is dropped, the redirect isn't.
func (s *Service) Resolve(code string) (string, error) {
	url, ok := s.cache.Get(code)
	if !ok {
		if url, ok = s.db.get(code); !ok {
			return "", ErrNotFound
		}
		s.cache.Put(code, url)
	}
	select {
	case s.clicks <- code:
	default:
		s.dropped.Add(1)
	}
	return url, nil
}

// Close stops taking clicks and waits until every queued click is counted.
func (s *Service) Close() { close(s.clicks); s.done.Wait() }

// Dropped is how many clicks were lost because the queue was full.
func (s *Service) Dropped() int64 { return s.dropped.Load() }

// Clicks returns the counted clicks for a code (after Close, the final number).
func (s *Service) Clicks(code string) int {
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.counts[code]
}
// cmd/demo/main.go
// Command demo runs two app servers that share one database, one cache and one ID allocator.
package main

import (
	"fmt"

	shortener "urlshortener"
)

func main() {
	alloc := shortener.NewAllocator(1_000_000_000, 1000)
	db, cache := shortener.NewDB(), shortener.NewCache(2)
	idsA, idsB := shortener.NewIDs(alloc), shortener.NewIDs(alloc)
	a := shortener.NewService(idsA, db, cache, 100)
	b := shortener.NewService(idsB, db, cache, 100)
	fmt.Println("IDs start at 1,000,000,000 and are handed out in blocks of 1,000")

	create := func(name string, s *shortener.Service, url, alias string) string {
		code, err := s.Create(url, alias)
		if err != nil {
			fmt.Printf("  %s create %-36s -> error: %v\n", name, url, err)
			return ""
		}
		fmt.Printf("  %s create %-36s -> %s\n", name, url, code)
		return code
	}
	sale := create("A", a, "https://shop.example.com/eid-sale", "")
	news := create("B", b, "https://news.example.com/budget-2026", "")
	jobs := create("A", a, "https://jobs.example.com/backend", "")
	create("A", a, "https://shop.example.com/eid-sale", "eid26")
	create("B", b, "https://other.example.com/", "eid26")
	create("B", b, "ftp://files.example.com/x", "")
	fmt.Printf("  blocks taken: A %d, B %d\n", idsA.Blocks, idsB.Blocks)

	n, _ := shortener.Decode(sale)
	fmt.Printf("code %s is ID %d\n", sale, n)

	// Ten redirects: the sale link is popular. The cache holds 2 links.
	for _, code := range []string{sale, sale, news, sale, jobs, sale, sale, news, "zzzzzz", sale} {
		url, err := a.Resolve(code)
		if err != nil {
			fmt.Printf("  GET /%s -> 404 (%v)\n", code, err)
			continue
		}
		fmt.Printf("  GET /%s -> 302 %s\n", code, url)
	}
	a.Close()
	b.Close()
	fmt.Printf("cache: %d hits, %d misses; database reads: %d\n", cache.Hits, cache.Misses, db.Reads)
	fmt.Printf("clicks: %s=%d %s=%d %s=%d, dropped %d\n", sale, a.Clicks(sale), news, a.Clicks(news), jobs, a.Clicks(jobs), a.Dropped())
}

Demo চালালে:

$ go run ./cmd/demo
IDs start at 1,000,000,000 and are handed out in blocks of 1,000
  A create https://shop.example.com/eid-sale    -> 15FTGg
  B create https://news.example.com/budget-2026 -> 15FTWo
  A create https://jobs.example.com/backend     -> 15FTGh
  A create https://shop.example.com/eid-sale    -> eid26
  B create https://other.example.com/           -> error: alias already taken
  B create ftp://files.example.com/x            -> error: bad url (http or https only, at most 2048 characters)
  blocks taken: A 1, B 1
code 15FTGg is ID 1000000000
  GET /15FTGg -> 302 https://shop.example.com/eid-sale
  GET /15FTGg -> 302 https://shop.example.com/eid-sale
  GET /15FTWo -> 302 https://news.example.com/budget-2026
  GET /15FTGg -> 302 https://shop.example.com/eid-sale
  GET /15FTGh -> 302 https://jobs.example.com/backend
  GET /15FTGg -> 302 https://shop.example.com/eid-sale
  GET /15FTGg -> 302 https://shop.example.com/eid-sale
  GET /15FTWo -> 302 https://news.example.com/budget-2026
  GET /zzzzzz -> 404 (no such code)
  GET /15FTGg -> 302 https://shop.example.com/eid-sale
cache: 5 hits, 5 misses; database reads: 5
clicks: 15FTGg=6 15FTWo=2 15FTGh=1, dropped 0

Output টা পড়ো:

  • Code: A পেয়েছে 1,000,000,000 থেকে শুরু হওয়া block আর B পরেরটা, তাই A এর link 15FTGg, 15FTGh আর B এর 15FTWo। প্রতিটা server তার link গুলোর জন্য ঠিক একটা block নিয়েছে: প্রতি 1,000 link এ allocator এ একবার যাওয়া।
  • Alias: প্রথমবার eid26 ফাঁকা ছিল; দ্বিতীয়বার চাইলে "alias already taken" দিয়ে fail করে, primary-key insert এটা নিশ্চিত করে। ftp:// link টা কোনো ID খরচ করার আগেই বাতিল।
  • Decode: 15FTGg আবার ID 1,000,000,000 এ ফিরে যায়, তাই কোনো lookup ছাড়াই একটা code কোন shard এর সেটা বের করা যায়।
  • Redirect: cache এ মাত্র 2টা link ধরে, তবু জনপ্রিয় sale link টা ভেতরে থেকে যায়। 10টা lookup এর 5টা cache hit; 5টা miss এর মধ্যে আছে প্রতিটা link এর প্রথম দেখা, jobs link news কে বের করে দেওয়ার পর news আবার পড়া, আর অচেনা code টা। শুধু miss গুলোই database এ পৌঁছেছে। (Demo create এর সময় cache গরম করে না, যাতে প্রথম miss টা দেখা যায়; production করে, উপরের lifecycle এর মতো।)
  • Click: 6, 2 আর 1, background worker গুনেছে redirect গুলোর উত্তর দেওয়ার পরে; queue কখনো ভরেনি বলে কিছুই বাদ পড়েনি।

Test গুলো সবচেয়ে জরুরি জিনিসগুলো দেখে: base62 এর round trip, 4টা server একসাথে 2,400 link বানালেও কোনো duplicate code নেই (-race দিয়ে চালানো), cache করা link database একবারই পড়ে, check আর alias এর error, আর ভরা click queue redirect আটকে না দিয়ে click বাদ দেয়।

// shortener_test.go
package shortener

import (
	"errors"
	"sync"
	"testing"
)

func TestEncodeDecodeRoundTrip(t *testing.T) {
	for _, n := range []uint64{0, 1, 61, 62, 3843, 1_000_000_000, 1<<64 - 1} {
		got, ok := Decode(Encode(n))
		if !ok || got != n {
			t.Fatalf("Decode(Encode(%d)) = %d, %v", n, got, ok)
		}
	}
	if _, ok := Decode("ab-c"); ok {
		t.Fatal("'-' is not in the alphabet")
	}
}

func TestBlocksDontOverlapAcrossServers(t *testing.T) {
	alloc := NewAllocator(1, 50)
	db := NewDB()
	var servers []*Service
	for i := 0; i < 4; i++ {
		servers = append(servers, NewService(NewIDs(alloc), db, NewCache(8), 10))
	}
	var wg sync.WaitGroup
	var mu sync.Mutex
	seen := map[string]bool{}
	for _, s := range servers {
		for g := 0; g < 2; g++ {
			wg.Add(1)
			go func(s *Service) {
				defer wg.Done()
				for i := 0; i < 300; i++ {
					code, err := s.Create("https://example.com/x", "")
					if err != nil {
						t.Error(err)
						return
					}
					mu.Lock()
					if seen[code] {
						t.Errorf("duplicate code %s", code)
					}
					seen[code] = true
					mu.Unlock()
				}
			}(s)
		}
	}
	wg.Wait()
	if len(seen) != 4*2*300 {
		t.Fatalf("got %d codes, want %d", len(seen), 4*2*300)
	}
}

func TestResolveUsesTheCacheAfterTheFirstRead(t *testing.T) {
	db := NewDB()
	s := NewService(NewIDs(NewAllocator(100, 10)), db, NewCache(4), 10)
	code, _ := s.Create("https://example.com/a", "")
	for i := 0; i < 3; i++ {
		if url, err := s.Resolve(code); err != nil || url != "https://example.com/a" {
			t.Fatalf("Resolve = %q, %v", url, err)
		}
	}
	s.Close()
	if db.Reads != 1 {
		t.Fatalf("database reads = %d, want 1", db.Reads)
	}
	if s.Clicks(code) != 3 {
		t.Fatalf("clicks = %d, want 3", s.Clicks(code))
	}
}

func TestAliasAndValidation(t *testing.T) {
	s := NewService(NewIDs(NewAllocator(1, 10)), NewDB(), NewCache(2), 10)
	defer s.Close()
	if _, err := s.Create("https://a.example", "promo"); err != nil {
		t.Fatal(err)
	}
	if _, err := s.Create("https://b.example", "promo"); !errors.Is(err, ErrTaken) {
		t.Fatalf("err = %v, want ErrTaken", err)
	}
	if _, err := s.Create("javascript:alert(1)", ""); !errors.Is(err, ErrBadURL) {
		t.Fatalf("err = %v, want ErrBadURL", err)
	}
	if _, err := s.Create("https://a.example", "no"); !errors.Is(err, ErrBadAlias) {
		t.Fatalf("err = %v, want ErrBadAlias", err)
	}
	if _, err := s.Resolve("nothing"); !errors.Is(err, ErrNotFound) {
		t.Fatalf("err = %v, want ErrNotFound", err)
	}
}

func TestFullClickQueueDropsClicksNotRedirects(t *testing.T) {
	s := NewService(NewIDs(NewAllocator(1, 10)), NewDB(), NewCache(2), 0)
	code, _ := s.Create("https://example.com/", "")
	s.mu.Lock() // hold the counter so the worker can't drain the queue
	for i := 0; i < 5; i++ {
		if _, err := s.Resolve(code); err != nil {
			t.Fatal(err)
		}
	}
	dropped := s.Dropped()
	s.mu.Unlock()
	s.Close()
	if dropped == 0 {
		t.Fatal("expected some clicks to be dropped with a zero-size queue")
	}
}
$ go test -race -v ./...
=== RUN   TestEncodeDecodeRoundTrip
--- PASS: TestEncodeDecodeRoundTrip (0.00s)
=== RUN   TestBlocksDontOverlapAcrossServers
--- PASS: TestBlocksDontOverlapAcrossServers (0.01s)
=== RUN   TestResolveUsesTheCacheAfterTheFirstRead
--- PASS: TestResolveUsesTheCacheAfterTheFirstRead (0.00s)
=== RUN   TestAliasAndValidation
--- PASS: TestAliasAndValidation (0.00s)
=== RUN   TestFullClickQueueDropsClicksNotRedirects
--- PASS: TestFullClickQueueDropsClicksNotRedirects (0.00s)
PASS
ok  	urlshortener	1.020s
?   	urlshortener/cmd/demo	[no test files]

একই core আরো পাঁচটা language এ

একই class আর একই output সহ single-threaded version। close() call করলে click এর "worker" চলে, design দেখাতে এটুকুই যথেষ্ট; Go version এটা একসাথে (concurrently) চালায়।

"""A URL shortener's core: unique codes from ID blocks, a database, a cache in front of it
for redirects, and click counting that never slows a redirect down. Two app servers share
one database, one cache and one ID allocator."""
from collections import OrderedDict

ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"


def encode(n: int) -> str:
    """A number in base 62. Different numbers always give different codes."""
    if n == 0:
        return "0"
    out = []
    while n > 0:
        out.append(ALPHABET[n % 62])
        n //= 62
    return "".join(reversed(out))


def decode(code: str) -> int | None:
    n = 0
    for c in code:
        d = ALPHABET.find(c)
        if d < 0:
            return None
        n = n * 62 + d
    return n


class Allocator:
    """Stands in for one database row, "the next unused ID". Hands out whole blocks."""

    def __init__(self, start: int, block: int):
        self.next, self.block = start, block

    def take(self) -> tuple[int, int]:
        start = self.next
        self.next += self.block
        return start, self.next


class IDs:
    """One app server's private block of IDs."""

    def __init__(self, alloc: Allocator):
        self.alloc = alloc
        self.next = self.end = 0
        self.blocks = 0

    def take_next(self) -> int:
        if self.next == self.end:
            self.next, self.end = self.alloc.take()
            self.blocks += 1
        n = self.next
        self.next += 1
        return n


class Cache:
    """A small LRU cache of code -> long URL, like one Redis in front of the database."""

    def __init__(self, capacity: int):
        self.cap = capacity
        self.items: OrderedDict[str, str] = OrderedDict()  # last = most recently used
        self.hits = self.misses = 0

    def get(self, code: str) -> str | None:
        if code not in self.items:
            self.misses += 1
            return None
        self.hits += 1
        self.items.move_to_end(code)
        return self.items[code]

    def put(self, code: str, url: str) -> None:
        if code in self.items:
            self.items.move_to_end(code)
            return
        if len(self.items) == self.cap:
            self.items.popitem(last=False)
        self.items[code] = url


class DB:
    """The source of truth: code -> long URL. reads counts lookups that reached it."""

    def __init__(self):
        self.links: dict[str, str] = {}
        self.reads = 0

    def insert(self, code: str, url: str) -> bool:
        if code in self.links:
            return False
        self.links[code] = url
        return True

    def get(self, code: str) -> str | None:
        self.reads += 1
        return self.links.get(code)


class ShortenerError(Exception):
    pass


BAD_URL = "bad url (http or https only, at most 2048 characters)"
BAD_ALIAS = "bad alias: 4 to 30 letters, digits or dashes"
TAKEN = "alias already taken"
NOT_FOUND = "no such code"


def valid_url(u: str) -> bool:
    return u.startswith(("http://", "https://")) and len(u) <= 2048 and not any(c in u for c in " \t\n")


def valid_alias(a: str) -> bool:
    return 4 <= len(a) <= 30 and all(c.isascii() and (c.isalnum() or c == "-") for c in a)


class Service:
    """One app server. Clicks go on a bounded queue; a worker counts them later."""

    def __init__(self, ids: IDs, db: DB, cache: Cache, queue_size: int):
        self.ids, self.db, self.cache = ids, db, cache
        self.queue: list[str] = []
        self.queue_size = queue_size
        self.counts: dict[str, int] = {}
        self.dropped = 0

    def create(self, long_url: str, alias: str = "") -> str:
        if not valid_url(long_url):
            raise ShortenerError(BAD_URL)
        code = alias
        if alias == "":
            code = encode(self.ids.take_next())
        elif not valid_alias(alias):
            raise ShortenerError(BAD_ALIAS)
        if not self.db.insert(code, long_url):
            raise ShortenerError(TAKEN)
        return code

    def resolve(self, code: str) -> str:
        url = self.cache.get(code)
        if url is None:
            url = self.db.get(code)
            if url is None:
                raise ShortenerError(NOT_FOUND)
            self.cache.put(code, url)
        if len(self.queue) < self.queue_size:
            self.queue.append(code)
        else:
            self.dropped += 1
        return url

    def close(self) -> None:
        """The worker: count every queued click."""
        for code in self.queue:
            self.counts[code] = self.counts.get(code, 0) + 1
        self.queue.clear()

    def clicks(self, code: str) -> int:
        return self.counts.get(code, 0)


alloc = Allocator(1_000_000_000, 1000)
db, cache = DB(), Cache(2)
ids_a, ids_b = IDs(alloc), IDs(alloc)
a, b = Service(ids_a, db, cache, 100), Service(ids_b, db, cache, 100)
print("IDs start at 1,000,000,000 and are handed out in blocks of 1,000")


def create(name: str, s: Service, url: str, alias: str = "") -> str:
    try:
        code = s.create(url, alias)
    except ShortenerError as e:
        print(f"  {name} create {url:<36} -> error: {e}")
        return ""
    print(f"  {name} create {url:<36} -> {code}")
    return code


sale = create("A", a, "https://shop.example.com/eid-sale")
news = create("B", b, "https://news.example.com/budget-2026")
jobs = create("A", a, "https://jobs.example.com/backend")
create("A", a, "https://shop.example.com/eid-sale", "eid26")
create("B", b, "https://other.example.com/", "eid26")
create("B", b, "ftp://files.example.com/x")
print(f"  blocks taken: A {ids_a.blocks}, B {ids_b.blocks}")
print(f"code {sale} is ID {decode(sale)}")

# Ten redirects: the sale link is popular. The cache holds 2 links.
for code in [sale, sale, news, sale, jobs, sale, sale, news, "zzzzzz", sale]:
    try:
        print(f"  GET /{code} -> 302 {a.resolve(code)}")
    except ShortenerError as e:
        print(f"  GET /{code} -> 404 ({e})")
a.close()
b.close()
print(f"cache: {cache.hits} hits, {cache.misses} misses; database reads: {db.reads}")
print(f"clicks: {sale}={a.clicks(sale)} {news}={a.clicks(news)} {jobs}={a.clicks(jobs)}, dropped {a.dropped}")

Scaling এর ধাপ

এই ধাপগুলো উদাহরণ মাত্র; আসল সীমা hardware আর traffic এর ধরনের উপর নির্ভর করে।

ধাপTrafficআগে কী ভাঙেকী বদলায়
1. 100 userমিনিটে কয়েকটা redirectকিছুই নাApp আর PostgreSQL চালানো একটা ছোট server। প্রতিদিন backup।
2. 10K userসেকেন্ডে কয়েক ডজন redirectএকটা box মানেই single point of failurePostgreSQL কে standby সহ managed instance এ নাও; load balancer এর পেছনে দুইটা app server।
3. 100K userসেকেন্ডে কয়েকশোগরম link এর database readRedis (cache-aside) আর একটা read replica যোগ করো। Click গোনা queue আর worker এ সরাও।
4. 1M userসেকেন্ডে কয়েক হাজার (আমাদের আন্দাজের সীমা)Click এর write পথ; হইচই করা creator; spammerপ্রতি server এ ID block, প্রতি API key rate limit, ক্ষতিকর URL check, আলাদা store এ analytics, viral link এর জন্য CDN।
5. 10M+ userসেকেন্ডে দশ হাজারের বেশি, সারা দুনিয়াএকটা primary র storage আর write এর হার; দূরের user এর latencyCode ধরে link shard; multi-region read পথ (প্রতি region এ cache আর replica, write একটা home region এ); একটা Redis cluster।

প্রতিটা ধাপে নতুন অংশটা যোগ করো শুধু যখন মাপা bottleneck সেটা চায়। Sharding, multi-region আর Kafka ধাপ 5 এর হাতিয়ার; ধাপ 2 এ ব্যবহার করলে কোনো লাভ ছাড়াই খরচ আর চালানোর ঝুঁকি বাড়ে।

Failure এর পরিস্থিতি

Failureকী হয়কীভাবে ধরা পড়েকীভাবে সেরে ওঠেData নিরাপদ?User এর উপর প্রভাব
Database primary failCreate fail; redirect বেশিরভাগ Redis আর replica থেকে চলতে থাকেHealth check, replication monitorAutomatic failover standby কে primary বানায় (~1 মিনিট)Synchronous standby: কোনো committed link হারায় নাপ্রায় এক মিনিট নতুন link fail
Redis failপ্রতিটা redirect miss করে replica পড়েCache call এর error rate আর latencyRedis restart বা তার replica তে failover; নিজে থেকেই আবার ভরেRedis এ কোনো একমাত্র data নেইRedirect slow; replica কে পুরো read এর চাপ নিতে হয় (সেই মাপে রাখো)
App server crashচলমান request fail; তার না-ব্যবহার করা ID block নষ্টLB health checkLB traffic পাঠানো বন্ধ করে; autoscaling নতুন একটা আনেকোনো data হারায় না; নষ্ট ID ক্ষতিকর নাকয়েকটা error, client retry করে
Zone এর মধ্যে network partitionএকটা zone primary তে পৌঁছাতে পারে নাHealth check, error rateWrite যায় primary থাকা zone এ; read চলে local cache/replica থেকেPrimary-key insert code unique রাখেএকটা zone এ কিছুক্ষণ create fail করতে পারে
Click queue বন্ধ বা ভরাClick বাদ পড়ে (জেনেবুঝে)Queue এর গভীরতা, বাদ পড়া click এর metricQueue ফেরাও; worker আবার চালুLink এর কিছু হয় না; হিসাব একটু কমRedirect এ কিছু না; stats কম গোনে
Click event দুইবারWorker retry করে একটা click দুইবার গোনেAnalytics store এ duplicate IDEvent ID দিয়ে idempotent গণনাহিসাব ঠিক থাকেকিছু না
Traffic spike (viral link)একটা code সেকেন্ডে 1 লাখ hit পায়প্রতি key request এর metricCDN edge caching আর ওই key এর জন্য process এর ভেতরের cacheশুধু read, কোনো ঝুঁকি নেইEdge সামলালে কিছু না
Data হারানো / খারাপ deploy row মুছলLink আর resolve হয় না404 এর হার লাফায়Backup আর WAL archive থেকে point-in-time restoreBackup আরেক account আর region এRestore না হওয়া পর্যন্ত ভাঙা link

Reliability আর observability

  • SLI: redirect এর সাফল্যের হার (চেনা code এ non-5xx), server এ redirect latency র p99, create এর সাফল্যের হার।
  • SLO: 30 দিনে 99.99% redirect সফল আর 99% 50 ms এর মধ্যে; 99.9% create সফল।
  • SLA (business দের কাছে বিক্রি করলে): SLO এর চেয়ে ঢিলা, যেমন মাসে 99.9%, credit সহ।
  • Metric: endpoint আর status ধরে request, cache hit rate, replica lag, ঘণ্টায় কয়টা ID block নেওয়া হলো, queue এর গভীরতা আর বাদ পড়া click, 404 এর হার, প্রতি API key create।
  • Alert: redirect এর error-budget burn এ page; cache hit rate 80% এর নিচে, replica lag 5 s এর বেশি, queue এর বয়স 5 মিনিটের বেশি হলে ticket।
  • Log: trace ID, code আর status সহ structured; token থাকা পুরো long URL redact না করে কখনো log কোরো না।
  • Tracing: redirect sample করো আর সব slow বা fail গুলো রাখো; trace দেখায় cache বনাম replica তে কত সময় গেল।
  • Health check: readiness ছোট timeout দিয়ে Redis আর DB এর connection দেখে; liveness শুধু process দেখে।
  • Disaster recovery: প্রতিদিন snapshot আর একটানা WAL archiving (RPO এক মিনিটের নিচে), প্রতি মাসে restore test, আরেক zone এ একটা standby (RTO মোটামুটি এক মিনিট)।

Security

  • অপব্যবহারই মূল হুমকি। Shortener phishing আর malware link লুকিয়ে রাখে। নতুন URL blocklist আর safe-browsing service এর সাথে মিলিয়ে দেখো, প্রতি API key আর IP তে create এ rate limit দাও, আর user দের link report করতে দাও; একটা code কে blocked চিহ্ন দিয়ে বন্ধ করো (একটা সতর্কতার page এ redirect)।
  • বিপজ্জনক scheme এ open redirect না: শুধু http আর https নাও; javascript:, data: আর shortener নিজের দিকেই ফেরত আসা URL (loop) বাতিল করো।
  • Authentication আর authorization: link বানাতে আর stats পড়তে API key লাগে; একটা code এর stats শুধু তার owner দেখতে পারে (প্রতিটা request এ মালিকানা check করো)।
  • একের পর এক খুঁজে দেখা: পরপর code থাকলে যে কেউ সব link ঘুরে দেখতে পারে। Link private হতে পারলে encode এর আগে ID এলোমেলো করো, বা "unlisted" link এর জন্য long random code ব্যবহার করো।
  • Encryption: সব জায়গায় TLS (sho.rt এ HSTS), encrypted storage আর backup।
  • Privacy: click event এ IP থেকে পাওয়া data থাকে; analytics এর যা লাগে শুধু সেটাই রাখো (দেশ, পুরো IP না), আর retention ঠিক করো।
  • Secret: database আর Redis এর credential secret manager এ, code এ না।

Trade-off analysis

সিদ্ধান্তবেছে নেওয়াবিকল্পকেন বেছে নেওয়াকখন বদলাবে
Code বানানোCounter block + base62Random code, বা URL hashগঠনের কারণেই unique, কোনো check নেই, সবচেয়ে ছোট codePrivate link এর জন্য আন্দাজ করা যায় না এমন code লাগলে → random বা এলোমেলো ID
Redirect status302301Click এর হিসাব আর গন্তব্য বদলানোর সুযোগ থাকেAnalytics লাগে না এমন স্থায়ী link → 301 load বাঁচায়
DatabasePostgreSQL + replicaDynamoDB / Cassandraসহজ, constraint আছে, এই scale এ কয়েক বছর যথেষ্টMulti-region write বা একটা primary ছাড়িয়ে হাত না লাগিয়ে scaling
CacheRedis, cache-aside, TTLCache নেই, বা write-throughRead write এর 100 গুণ আর অসমান; miss সস্তাHit rate কম হলে cache শুধু একটা hop যোগ করে
Click গোনাAsync queue, ভরা থাকলে বাদপ্রতি click এ synchronous insertRedirect এর latency আর availability আগেপ্রতি click এ ঠিকঠাক billing লাগলে টেকসই queue নাও আর কখনো বাদ দিও না
Service এর পরিধিএকটা serviceCreate আর redirect আলাদা serviceছোট একটা team, shared data, সহজ deployখুব আলাদা scaling বা team হলে → redirect এর পথ আলাদা করো

ডান পাশের বিকল্পগুলোয় চালানোর খরচ নিচের দিকে বাড়ে: প্রতিটা বিকল্প একটা নড়াচড়া করা অংশ বা dependency যোগ করে। প্রত্যেকটাই ভিন্ন একটা requirement এ সঠিক সিদ্ধান্ত, আর interviewer ঠিক এটাই শুনতে চান।

শেষ architecture

UsersCDN edgehot 302sLB2 zonesAppzone A + BRedis clustercode -> urlShard 1+ replicasShard 2+ replicasID allocatorblocks of IDsClick stream-> analytics storetop links are answered at the edge (short TTL)the rest: Redis first, then the shard that owns the codenew links take IDs from a block; clicks flow to analyticsevery box is here because a number or a requirement asked for it
Play চাপো। বড় scale এ সবচেয়ে গরম link গুলোর উত্তর দেয় CDN edge। বাকি সব load balancer হয়ে দুইটা zone এর app server এ যায়, যেটা আগে Redis cluster কে জিজ্ঞেস করে, তারপর যে shard এর মালিক সেই shard কে। নতুন link একটা block থেকে ID নেয়, আর click যায় একটা analytics store এ।

Requirement অনুযায়ী এটা যুক্তিসঙ্গত, কারণ প্রতিটা অংশ একটা সংখ্যা বা একটা rule এর উত্তর দেয়: সেকেন্ডে 12,000 read আর অসমান বিন্যাস Redis আর edge cache এর পক্ষে যুক্তি দেয়; সমন্বয় ছাড়া uniqueness আসে ID block আর একটা primary key থেকে; redirect এর 99.99% availability আসে দুইটা zone এর stateless app server, replica আর এমন একটা cache থেকে যেটা primary failover এর সময় read বয়ে নিতে পারে; আর analytics কখনো redirect slow করতে পারে না কারণ সেটা asynchronous। Sharding আঁকা আছে কারণ ধাপ 5 এ design সেদিকেই যায়, launch এ লাগে বলে না।

Interview এর আলোচনা

1. দুইটা server কখনো একই code দেবে না, এটা কীভাবে নিশ্চিত করো?

উত্তর

Code আসে unique সংখ্যা থেকে। Database এর একটা allocator row একটা atomic UPDATE ... RETURNING দিয়ে ID এর block দেয়, তাই দুইটা server কখনো একে অপরের উপর পড়া block পায় না। নিজের block এর ভেতরে server নিজে গোনে। code এর primary key শেষ সুরক্ষা। Custom alias নির্ভর করে alias আগে থাকলে primary-key insert fail করার উপর।

2. long URL টা hash করলেই তো হয়, করছ না কেন?

উত্তর

Table বাড়লে hash এর ছোট একটা prefix মিলে যায়, তাই যাই হোক existence check আর retry এর কৌশল লাগে, আর একই URL ছোট করা দুইজন আলাদা user একই code share করবে (আলাদা stats চাইলে সমস্যা)। "একই URL → একই code" চাইলে hash ঠিক আছে, কিন্তু uniqueness এর জন্য counter সহজ।

3. একটা link viral হলে কী হয়, ধরো একটা code এ সেকেন্ডে 1 লাখ click?

উত্তর

একটা Redis key গরম হয়ে যায়। ছোট cache time দিয়ে CDN edge থেকে serve করো (302 নিজেও cache করা যায়), প্রতিটা app server এ সবচেয়ে গরম key গুলোর জন্য ছোট একটা process এর ভেতরের cache যোগ করো, আর click event গুলো batch করো। Database এই traffic কখনো দেখে না।

4. 301 নাকি 302, আর কেন?

উত্তর

Default এ 302: browser এটা মনে রাখে না, তাই প্রতিটা click আমাদের কাছে আসে (গুনতে পারি) আর পরে গন্তব্য বদলাতে বা বন্ধ করতে পারি। 301 load বাঁচায় কারণ browser cache করে, কিন্তু analytics আর নিয়ন্ত্রণ হারাও। কিছু service method ঠিক রাখতে 307/308 ব্যবহার করে; GET link এ পার্থক্য নেই।

5. Link এর মেয়াদ কীভাবে যোগ করবে?

উত্তর

expires_at রাখো; redirect এ সময় পার হলে 410 ফেরত দাও। Redis এর TTL মেয়াদের চেয়ে পরে রেখো না। পরে একটা background job মেয়াদ শেষ row মুছতে পারে; তাদের ID কখনো আবার ব্যবহার হয় না, তাই ছাপা পুরনো একটা code হঠাৎ অন্য কোথাও নিয়ে যেতে পারে না।

6. Analytics team real time এ দেশ আর device ধরে click চায়। কী বদলাবে?

উত্তর

Redirect এর সময় click event এ দেশ (IP থেকে) আর device (user agent থেকে) যোগ করো, তারপর একটা টেকসই log এ যাও (Kafka, Kinesis), যেটা একটা stream processor কে খাওয়ায়, আর সে প্রতি মিনিটে জমিয়ে columnar বা time-series store এ রাখে। তবুও asynchronous: redirect এর পথ বদলায় না।

7. Multi-region এ কীভাবে যাবে?

উত্তর

প্রতিটা region এ read এর পথ বসাও: app server, একটা Redis cache আর read replica (বা সারা দুনিয়ায় replicate হওয়া একটা key-value store)। Write রাখো একটা home region এ (বা প্রতিটা region কে নিজের ID range দাও, যাতে collision ছাড়া যেকোনো জায়গায় create হতে পারে)। নতুন link দূরের region এ দেখাতে এক সেকেন্ড লাগতে পারে; create এর সময় local cache গরম করে সেটা ঢেকে দাও।

সাধারণ ভুল: আন্দাজের আগেই Kafka আর microservices আঁকা; প্রতিটা click links table এ একটা row হিসেবে রাখা; analytics না ভেবে 301 ব্যবহার; ভুলে যাওয়া যে random code এ collision check লাগে; অপব্যবহার আর open-redirect এর ঝুঁকি উপেক্ষা করা; redirect কে analytics এর জন্য অপেক্ষা করানো।

Interviewer যা খোঁজেন: সংখ্যা সহ পরিষ্কার requirement, read-heavy হওয়ার বোধ, যুক্তি সহ collision-free একটা ID কৌশল, redirect এর পথে cache-aside, async analytics, access pattern থেকে বুঝেশুনে database বাছা, আর 301/302, আন্দাজ করা যায় এমন code আর কখন shard করবে সেটা নিয়ে সৎ trade-off।

নিজে design করে solution টা বুঝেছো? Mark করো, তাহলে progress ট্র্যাক হবে।