طراحی سرویس کوتاهکننده لینک شبیه Bitly
طراحی مرحلهبهمرحله سرویس کوتاهکننده لینک شامل تعیین الزامات، طراحی API، تولید کد یکتا با Base62، تغییر مسیر، کش، مقیاسپذیری و مدیریت شمارنده توزیعشده
درک مسئله
Bitly یک سرویس کوتاهکننده لینک است که آدرسهای طولانی را به لینکهای کوتاهتر و قابلاشتراکگذاری تبدیل میکند. قابلیتهایی مثل تحلیل کلیکها هم دارد، اما برای شروع لازم نیست همه امکانات محصول را طراحی کنیم.
طراحی کوتاهکننده لینک یکی از سوالهای رایج و نسبتاً ساده مصاحبه طراحی سیستم است. این مسئله نقطه شروع خوبی برای یادگیری است، چون میتوانیم از یک معماری کوچک شروع کنیم و بعد بر اساس نیاز، تولید شناسه، کش و مقیاسپذیری را به آن اضافه کنیم.
الزامات عملکردی
اول باید با مصاحبهگر درباره قابلیتهای اصلی سیستم به توافق برسیم. اگر محصول را نمیشناسیم، چند سوال روشنکننده میپرسیم. هدف، انتخاب چند قابلیت مهم است، نه اضافه کردن تمام امکانات جانبی.
قابلیتهای اصلی این طراحی:
- کاربر بتواند یک آدرس طولانی ارسال کند و لینک کوتاه دریافت کند.
- کاربر در صورت تمایل، نام مستعار سفارشی برای لینک انتخاب کند.
- کاربر در صورت تمایل، تاریخ انقضای لینک را تعیین کند.
- باز کردن لینک کوتاه، کاربر را به آدرس اصلی هدایت کند.
احراز هویت، مدیریت حساب و تحلیل کلیکها را فعلاً خارج از محدوده میگذاریم. این قابلیتها ارزشمندند، اما برای توضیح هسته سیستم ضروری نیستند.
الزامات غیرعملکردی
الزامات غیرعملکردی مشخص میکنند سیستم با چه کیفیتی باید کار کند. برای این مسئله فرض میکنیم:
- هر کد کوتاه فقط به یک مقصد مشخص اشاره کند؛ دو مقصد متفاوت نباید همزمان یک کد یکسان داشته باشند.
- پاسخ تغییر مسیر، با تأخیر کمتر از ۱۰۰ میلیثانیه ارائه شود.
- هدف دسترسپذیری ۹۹٫۹۹٪ باشد.
- سیستم از یک میلیارد نگاشت و ۱۰۰ میلیون کاربر فعال روزانه پشتیبانی کند.
- در مسیر خواندن، دسترسپذیری را بر تازگی فوری داده ترجیح دهیم؛ اما یکتایی کدها هنگام ایجاد لینک حفظ شود.
تحلیل بلادرنگ کلیکها، تشخیص هرزنامه و شناسایی پیشرفته لینکهای مخرب فعلاً خارج از محدوده هستند.
این سیستم خواندنسنگین است. یک لینک یک بار ساخته میشود، اما ممکن است هزاران بار باز شود. برای مثال، میتوان نسبت خواندن به نوشتن را ۱۰۰۰ به ۱ فرض کرد. این نسبت روی انتخاب کش، پایگاه داده و نحوه مقیاسدهی سرویسها اثر میگذارد.
اعداد ظرفیت در ادامه فرضهای طراحی هستند. باید آنها را با مصاحبهگر هماهنگ کنیم، نه اینکه همه را الزام قطعی محصول بدانیم.
موجودیتها و API
موجودیتهای اصلی
در شروع کافی است موجودیتها را در سطح کلی مشخص کنیم:
- آدرس اصلی که کاربر میخواهد کوتاه کند.
- لینک کوتاه که شامل دامنه سرویس و یک کد است.
- کاربر سازنده لینک، اگر بعداً مالکیت و مدیریت لینکها را اضافه کنیم.
آدرس اصلی و لینک کوتاه الزاماً دو جدول جدا نیستند. در طراحی ساده، هر رکورد یک نگاشت بین کد و مقصد را نگه میدارد.
فیلدهای اصلی این نگاشت عبارتاند از:
| فیلد | کاربرد |
|---|---|
short_code | کد یکتا و کلید اصلی رکورد |
long_url | آدرس مقصد |
created_at | زمان ایجاد |
expires_at | زمان انقضا، در صورت تعیین شدن |
creator_id | شناسه سازنده، در صورت نیاز |
اگر نام مستعار سفارشی همان بخش انتهایی لینک است، میتوان آن را در short_code ذخیره کرد و لزوماً به ستون جداگانه نیاز نداریم.
ساخت لینک کوتاه
برای ساخت منبع جدید از POST استفاده میکنیم:
1
2
POST /urls
Content-Type: application/json
1
2
3
4
5
{
"long_url": "<original_url>",
"custom_alias": "optional_custom_alias",
"expiration_date": "2026-12-01T00:00:00Z"
}
فیلدهای نام مستعار و تاریخ انقضا اختیاریاند. پاسخ شامل لینک کوتاه ساختهشده است:
1
2
3
{
"short_url": "<short_domain>/abc123"
}
مقدارهای داخل علامتهای < > در مثالها جاینگهدار هستند.
تغییر مسیر
برای دسترسی به مقصد، کد کوتاه را در مسیر میگیریم:
1
GET /{short_code}
اگر کد معتبر و منقضینشده باشد، پاسخ تغییر مسیر برمیگردانیم:
1
2
HTTP/1.1 302 Found
Location: <original_url>
در این مرحله لازم نیست روی جزئیات همه کدهای وضعیت مکث کنیم. مهم این است که قرارداد ایجاد لینک و بازیابی مقصد روشن باشد.
طراحی سطح بالا
معماری اولیه سه جزء دارد:
- کلاینت وب یا موبایل.
- سرویس اپلیکیشن برای اعتبارسنجی، تولید کد و تغییر مسیر.
- پایگاه داده برای نگهداری نگاشتها و تاریخ انقضا.
مسیر ایجاد لینک
- کلاینت آدرس اصلی و گزینههای اختیاری را ارسال میکند.
- سرویس، قالب آدرس و تاریخ انقضا را اعتبارسنجی میکند.
- اگر نام مستعار مشخص شده باشد، امکان استفاده از آن بررسی میشود.
- در غیر این صورت، سرویس یک کد کوتاه تولید میکند.
- نگاشت کد، مقصد و تاریخ انقضا در پایگاه داده ذخیره میشود.
- لینک کوتاه به کلاینت برمیگردد.
تکراری بودن آدرس اصلی با برخورد کد کوتاه یکی نیست. ممکن است محصول اجازه دهد برای یک مقصد چند لینک با تاریخ انقضا یا مالک متفاوت ساخته شود. بنابراین بازگرداندن لینک موجود برای یک آدرس تکراری، تصمیم محصول است؛ نه شرط لازم برای یکتایی کدها.
در مقابل، یکتایی خود کد باید در پایگاه داده اعمال شود. صرفاً بررسی وجود کد قبل از درج کافی نیست، چون دو درخواست همزمان ممکن است هر دو نتیجه «وجود ندارد» بگیرند.
مسیر تغییر مسیر
لینک کوتاه روی دامنهای قرار دارد که کنترل آن در اختیار ماست. بنابراین درخواست باز کردن آن ابتدا به زیرساخت سرویس ما میرسد.
- مرورگر درخواست GET حاوی کد کوتاه را میفرستد.
- سرویس نگاشت کد را پیدا میکند.
- زمان انقضا بررسی میشود.
- اگر لینک معتبر باشد، پاسخ تغییر مسیر همراه با هدر
Locationبرمیگردد. - مرورگر درخواست مقصد را ارسال میکند.
برای کد ناموجود یا لینک منقضیشده، بهجای تغییر مسیر، پاسخ متناسب برمیگردانیم.
انتخاب بین 301 و 302
| ویژگی | 301 | 302 |
|---|---|---|
| معنا | تغییر مسیر دائمی | تغییر مسیر موقت |
| کش | ممکن است برای مدت طولانی ذخیره شود | رفتار کش به هدرها و سیاست کلاینت وابسته است |
| تغییر یا غیرفعال کردن لینک | با وجود کش قدیمی دشوارتر میشود | کنترل بیشتری روی مراجعههای بعدی داریم |
| ثبت کلیک در سرویس | ممکن است درخواستهای بعدی سرویس را دور بزنند | با سیاست کش مناسب، درخواستها به سرویس میرسند |
برای این طراحی، 302 انتخاب مناسبتری است، چون تاریخ انقضا داریم و میخواهیم کنترل لینک را حفظ کنیم. با این حال، 302 بهتنهایی تضمین نمیکند پاسخ هرگز کش نشود. سیاست کش را باید صریحاً با نیاز محصول هماهنگ کنیم.
بررسیهای عمیق
تولید کد کوتاه یکتا
سه هدف داریم: کد یکتا باشد، کوتاه بماند و تولید آن کمهزینه باشد.
استفاده از ابتدای آدرس
برداشتن چند نویسه ابتدایی آدرس راهحل مناسبی نیست. آدرسهای زیادی پیشوند یکسان دارند و به یک کد میرسند. در این حالت نمیدانیم کدام مقصد باید انتخاب شود.
این روش شرط اصلی یکتایی را برآورده نمیکند.
عدد تصادفی یا هش
میتوان عدد تصادفی تولید کرد و آن را به کد کوتاه تبدیل کرد. گزینه دیگر، هش کردن آدرس اصلی و استفاده از بخشی از خروجی است.
هش قطعی است؛ ورودی یکسان خروجی یکسان تولید میکند. اما خروجی کوتاهشده آن الزاماً یکتا نیست. دو آدرس متفاوت ممکن است به یک کد برسند. تولید تصادفی هم همین مسئله را دارد.
برای نمایش فشرده میتوان از Base62 استفاده کرد که شامل حروف کوچک، حروف بزرگ و رقمهاست. برخلاف Base64 معمولی، نویسههای + و / را ندارد و برای بخش مسیر لینک مناسبتر است.
شبهکد تولید تصادفی:
1
2
3
4
5
6
random_integer = generate_random_integer()
short_code = base62_encode(random_integer)
attempt_unique_insert(short_code, original_url)
if collision:
retry_with_a_new_code()
احتمال برخورد با افزایش تعداد کدها بیشتر میشود و شهود معمول ممکن است آن را دستکم بگیرد؛ همان مسئلهای که با نام Birthday Problem میشناسیم.
کد طولانیتر فضای بیشتری فراهم میکند، اما کوتاهی لینک را کاهش میدهد. در هر حال باید برخورد را با قید یکتایی و تلاش مجدد مدیریت کنیم. هش یا تصادفی بودن بهتنهایی جای این تضمین را نمیگیرد.
شمارنده یکتا و Base62
گزینه دیگر، اختصاص یک عدد افزایشی به هر لینک و تبدیل آن به Base62 است:
1
2
id = next_unique_id()
short_code = base62_encode(id)
اگر تخصیص عدد درست انجام شود، تبدیل یکبهیک عدد به Base62 برای کدهای تولیدشده برخورد ایجاد نمیکند. شکاف داشتن بین شناسهها هم مشکلی نیست؛ به یکتایی نیاز داریم، نه پیوستگی کامل اعداد.
برای یک میلیارد شناسه، شش نویسه کافی است، چون:
1
2
62^5 = 916,132,832
62^6 = 56,800,235,584
بنابراین تا زمانی که از ظرفیت ششنویسهای عبور نکردهایم، به هفت نویسه نیاز نداریم. فضای هفتنویسهای حدود ۳٫۵ تریلیون مقدار دارد.
دو نکته مهم باقی میماند:
- کدهای متوالی قابل حدساند. اگر غیرقابلپیشبینی بودن الزام باشد، باید جداگانه برای آن تصمیم بگیریم.
- نامهای مستعار سفارشی ممکن است با کدهای تولیدشده تداخل داشته باشند. برای همه کدها باید یک فضای نام یکتا یا سیاست جداسازی مشخص داشته باشیم.
سریع کردن تغییر مسیر
اولین قدم، انتخاب short_code بهعنوان کلید اصلی است. با یک پایگاه داده رابطهای مثل PostgreSQL، ایندکس مربوط به کلید اصلی جستجوی نگاشت را کارآمد میکند و یکتایی را هم اعمال میکند.
B-Tree انتخاب پیشفرض مناسبی است. لازم نیست فقط به دلیل جستجوی برابری، ایندکس هش جداگانه اضافه کنیم. انتخاب ایندکس تخصصی باید با اندازهگیری و نیاز واقعی توجیه شود.
برای برآورد بار، فرض کنیم هر یک از ۱۰۰ میلیون کاربر فعال روزانه، پنج لینک باز کند:
1
2
100,000,000 × 5 = 500,000,000 redirects/day
500,000,000 / 86,400 ≈ 5,787 requests/second
این میانگین است. اگر برای نمونه ضریب اوج ۱۰۰ در نظر بگیریم، به حدود ۵۸۰ هزار درخواست در ثانیه میرسیم که میتوان آن را تقریباً ۶۰۰ هزار فرض کرد. ضریب اوج یک فرض مستقل است و باید توجیه شود؛ از تعداد کاربران بهتنهایی نتیجه نمیشود.
کش درونحافظهای
برای کاهش بار پایگاه داده، Redis یا Memcached را بین اپلیکیشن و پایگاه داده قرار میدهیم:
- سرویس ابتدا نگاشت را در کش بررسی میکند.
- اگر موجود باشد، مقصد و وضعیت انقضا را از همانجا میگیرد.
- اگر موجود نباشد، پایگاه داده را میخواند.
- نگاشت معتبر را برای مراجعههای بعدی کش میکند.
این الگوی Cache-Aside است. چون بعضی لینکها بسیار محبوباند و نگاشتها معمولاً کم تغییر میکنند، کش میتواند بخش بزرگی از خواندنها را جذب کند.
البته زمان دسترسی خام RAM با زمان پاسخ Redis روی شبکه یکسان نیست. پاسخ واقعی شامل رفتوبرگشت شبکه، پردازش فرمان و انتقال داده هم میشود. همچنین پایگاه داده ممکن است داده و ایندکس را در حافظه داشته باشد و هر درخواست الزاماً خواندن دیسک ایجاد نکند.
چالشهای اصلی کش:
- گرم شدن کش و فشار اولیه روی پایگاه داده.
- انتخاب اندازه، TTL و سیاست حذف، مثلاً LRU.
- مدیریت لینکهای بسیار محبوب یا Hot Key.
- حذف یا بهروزرسانی نگاشت کششده.
- جلوگیری از هدایت کاربر با لینک منقضیشده.
TTL نگاشت نباید باعث شود لینک بعد از تاریخ انقضایش معتبر تلقی شود. نگه داشتن expires_at کنار مقصد و بررسی آن هنگام پاسخ، کنترل روشنتری فراهم میکند.
CDN و اجرای لبه
برای کاربران جهانی میتوان منطق تغییر مسیر یا نگاشتهای محبوب را به لبه نزدیک کرد. ابزارهایی مثل Cloudflare Workers و AWS Lambda@Edge امکان اجرای منطق نزدیک کاربر را فراهم میکنند.
در این حالت، بعضی درخواستها بدون رسیدن به سرور مبدا پاسخ میگیرند. سود اصلی، کاهش فاصله شبکه و بار مبدا است.
اما باید سیاست کش لبه را با انقضا و غیرفعال کردن لینک هماهنگ کنیم. اگر از یک طرف میخواهیم همه کلیکها به مبدا برسند و از طرف دیگر تغییر مسیر را در CDN کش کنیم، دو تصمیم ناسازگار گرفتهایم. برای تحلیل کلیک در چنین معماریای، ثبت رویداد در لبه یا مسیر دیگری لازم میشود.
این لایه هزینه، پیچیدگی ابطال کش و دشواری مانیتورینگ را اضافه میکند. اگر هدف تأخیر با معماری سادهتر برآورده شود، لازم نیست از ابتدا آن را وارد کنیم.
ظرفیت پایگاه داده
برای هر نگاشت، کد، مقصد، زمان ایجاد، زمان انقضا و در صورت نیاز شناسه سازنده را ذخیره میکنیم. اگر برای برآورد اولیه، اندازه هر رکورد را با مقداری حاشیه ۵۰۰ بایت فرض کنیم:
1
1,000,000,000 × 500 bytes = 500 GB
این فقط تخمین داده منطقی است. ایندکسها، سربار ذخیرهسازی، replicaها و نسخههای پشتیبان فضای بیشتری میخواهند. طول واقعی آدرسها هم بر تخمین اثر میگذارد.
با این حال، چنین حجمی بهتنهایی دلیل کافی برای Sharding نیست. ابتدا بررسی میکنیم یک نمونه مناسب PostgreSQL یا MySQL با کش و replication چه ظرفیتی دارد.
اگر برای مثال روزانه ۱۰۰ هزار لینک جدید ساخته شود، میانگین نوشتن حدود ۱٫۲ درخواست در ثانیه است. این بار بسیار کوچکتر از مسیر خواندن است. البته این فرض با نسبت اولیه ۱۰۰۰ به ۱ دقیقاً یکسان نیست؛ در مصاحبه باید فرضهای نهایی را یکدست کنیم.
PostgreSQL، MySQL یا DynamoDB همگی میتوانند گزینه باشند. برای این مدل ساده، انتخاب ابزاری که با آن آشنایی بیشتری داریم معمولاً بهتر از مقایسه طولانی فناوریهاست.
خرابی پایگاه داده
برای دسترسپذیری بالا، یک replica و فرایند failover در نظر میگیریم. در صورت خرابی primary، replica مناسب ارتقا پیدا میکند و اتصال سرویسها به مقصد جدید هدایت میشود.
پشتیبانگیری هم لازم است، اما جای replica را نمیگیرد. Backup برای بازیابی پس از حذف اشتباه، خرابی داده یا از دست رفتن گسترده زیرساخت است؛ نه مسیری که اپلیکیشن عادی درخواستها را مستقیم به آن بفرستد.
نوع replication، زمان failover و میزان دادهای که ممکن است از دست برود باید با هدف دسترسپذیری و بازیابی هماهنگ باشد.
جداسازی مسیر خواندن و نوشتن
با رشد ترافیک میتوانیم سرویس تغییر مسیر را از سرویس ایجاد لینک جدا کنیم:
- سرویس خواندن برای حجم زیاد درخواستهای تغییر مسیر مقیاس میگیرد.
- سرویس نوشتن اعتبارسنجی، تولید کد و ثبت نگاشت را انجام میدهد.
این جداسازی اجازه مقیاسدهی مستقل میدهد، اما از ابتدا اجباری نیست. میتوان ابتدا هر دو مسیر را در یک اپلیکیشن نگه داشت و فقط وقتی نیاز عملی داشتیم جدا کرد.
در هر دو حالت، نمونههای اپلیکیشن پشت Load Balancer قرار میگیرند و state اصلی در پایگاه داده و کش نگه داشته میشود.
شمارنده مشترک و تخصیص بازه
اگر چند نمونه سرویس نوشتن داشته باشیم، نباید هرکدام شمارنده مستقل از صفر داشته باشند. یک منبع مشترک برای تخصیص شناسه لازم است.
Redis با فرمان اتمیک INCR میتواند این نقش را داشته باشد. هر سرویس یک مقدار میگیرد، آن را Base62 میکند و نگاشت را ذخیره میکند. با بار نوشتن فرضشده، این تماس اضافی احتمالاً گلوگاه اصلی نیست.
اگر حجم تخصیص بالا رفت، میتوان بازه گرفت. برای مثال، با INCRBY شمارنده را اتمیک ۱۰۰۰ واحد افزایش میدهیم و سرویس از بازه اختصاصیافته بهصورت محلی استفاده میکند.
1
2
3
end = INCRBY counter 1000
start = end - 999
allocated_range = [start, end]
وقتی بازه تمام شد، سرویس بازه بعدی را میگیرد. اگر نمونهای از کار بیفتد، اعداد استفادهنشده آن بازه را کنار میگذاریم؛ ایجاد شکاف بهتر از استفاده دوباره و خطر برخورد است.
دقت مهم این است که INCRBY انتهای بازه، یعنی مقدار جدید شمارنده، را برمیگرداند، نه ابتدای بازه.
دوام شمارنده
اتمیک بودن افزایش شمارنده فقط مسئله همزمانی را حل میکند. اگر بعد از خرابی و failover مقدار شمارنده به عقب برگردد، ممکن است شناسههای قبلی دوباره تخصیص داده شوند.
بنابراین باید برای ماندگاری تخصیصها، رفتار failover و جلوگیری از استفاده دوباره از بازهها برنامه داشته باشیم. ذخیره دورهای شمارنده بهتنهایی تضمین نمیکند شناسه تکراری تولید نشود. برای طراحی ساده میتوان تخصیص شناسه را در همان ذخیرهگاه بادوام اصلی انجام داد و از اضافه کردن Redis صرفاً برای شمارنده خودداری کرد.
در هر انتخابی، قید یکتایی پایگاه داده آخرین خط دفاعی است؛ اما طراحی مولد شناسه هم باید از بازگشت شمارنده و استفاده مجدد جلوگیری کند.
معماری نهایی
طرح نهایی را میتوان با چند جزء روشن توضیح داد:
- Load Balancer برای توزیع درخواستها بین نمونههای سرویس.
- سرویس ایجاد لینک برای اعتبارسنجی و تخصیص کد.
- سرویس تغییر مسیر برای بازیابی سریع مقصد و بررسی انقضا.
- پایگاه داده بادوام با کلید اصلی یکتا روی
short_code. - کش برای نگاشتهای پرتکرار.
- replication و backup برای اهداف متفاوت دسترسپذیری و بازیابی.
- لایه لبه بهعنوان بهینهسازی اختیاری، اگر نیاز تأخیر و الگوی ترافیک آن را توجیه کنند.
در مصاحبه، ابتدا همین مسیر اصلی را بسازید و بعد بر اساس الزامها عمیق شوید. مهمترین بحثها، یکتایی کد، رفتار تغییر مسیر، انقضای لینک، بار خواندن و دوام تخصیص شناسهاند. یک پایگاه داده مناسب و کش میتوانند بخش بزرگی از مسئله را حل کنند؛ پیچیدگی بیشتر باید پاسخ به یک نیاز مشخص باشد.