اگر چند مصاحبهی طراحی سیستم رفته باشید، احتمالاً دستکم یکبار این سؤال را شنیدهاید: «یک سرویس مثل bit.ly یا TinyURL طراحی کن». سؤال ساده به نظر میرسد؛ یک لینک بلند میگیریم، یک کد کوتاه برمیگردانیم و هر کس آن کد را باز کرد، به لینک اصلی میفرستیمش. ولی همین مسئلهی ساده تقریباً همهی مفاهیم پایهی طراحی سیستم را لمس میکند: تخمین ظرفیت، طراحی API، انتخاب دیتابیس، تولید شناسهی یکتا، کش، شاردینگ و پردازش ناهمگام.
برای همین هم مصاحبهکنندهها دوستش دارند. جواب درست یکی نیست و چیزی که سنجیده میشود، این است که از کجا شروع میکنید و هر تصمیم را با چه دلیلی عوض میکنید. من هم در این نوشته همین مسیر را میروم: از سادهترین طراحی که واقعاً کار میکند شروع میکنم و هر بار که یک محدودیت واقعی جلوی راهمان سبز شد، یک قدم پیچیدهترش میکنم.
قدم صفر: اول بپرسید، بعد طراحی کنید
بزرگترین اشتباه در این سؤال این است که مستقیم بروید سراغ کشیدن دیاگرام. اول باید روشن شود دقیقاً چه چیزی قرار است ساخته شود.
نیازمندیهای کارکردی
- گرفتن یک URL بلند و برگرداندن یک لینک کوتاه و یکتا.
- باز کردن لینک کوتاه، کاربر را به URL اصلی هدایت (redirect) کند.
- اختیاری: کاربر بتواند نام دلخواه (custom alias) انتخاب کند.
- اختیاری: لینک تاریخ انقضا داشته باشد.
- اختیاری: آمار کلیکها (تعداد، کشور، مرورگر) ثبت شود.
نیازمندیهای غیرکارکردی
- دسترسپذیری بالا. اگر سرویس از کار بیفتد، همهی لینکهایی که در شبکههای اجتماعی و ایمیلها پخش شدهاند یکجا خراب میشوند.
- تأخیر کم در redirect. کاربر نباید حس کند یک مرحلهی اضافه در کار است.
- کدها نباید قابل حدس باشند. اگر کسی بتواند کدها را پشتسرهم امتحان کند، به لینکهای خصوصی دیگران میرسد.
یک نکتهی کلیدی هم همین اول کار معلوم میشود: این سیستم بهشدت read-heavy1 است. هر لینک یکبار ساخته میشود و ممکن است هزاران بار باز شود. تقریباً همهی تصمیمهای بعدی از همین واقعیت میآیند.
تخمین ظرفیت (back-of-the-envelope)2
فرض کنیم ماهی ۱۰۰ میلیون لینک جدید ساخته میشود و نسبت خواندن به نوشتن ۱۰۰ به ۱ است. اعداد دقیق مهم نیستند؛ مهم این است که بفهمیم با چه مرتبهای از بزرگی سروکار داریم:
| مورد | محاسبه | نتیجه |
|---|---|---|
| نوشتن در ثانیه | ۱۰۰ میلیون ÷ (۳۰ روز × ۸۶٬۴۰۰ ثانیه) | حدود ۴۰ درخواست |
| خواندن در ثانیه | ۴۰ × ۱۰۰ | حدود ۴٬۰۰۰ درخواست (در اوج شاید ۳ برابر) |
| تعداد رکورد در ۵ سال | ۱۰۰ میلیون × ۱۲ × ۵ | ۶ میلیارد رکورد |
| حجم ذخیرهسازی | ۶ میلیارد × حدود ۵۰۰ بایت | حدود ۳ ترابایت |
| حافظهی کش (۲۰٪ لینکهای پرتکرار روزانه) | ۴٬۰۰۰ × ۸۶٬۴۰۰ × ۰٫۲ × ۵۰۰ بایت | حدود ۳۵ گیگابایت |
از این جدول چند نتیجه میگیریم. ۴۰ نوشتن در ثانیه برای یک دیتابیس معمولی هیچ است. ۴٬۰۰۰ خواندن در ثانیه هم با یک کش کوچک بهراحتی جواب داده میشود. ۳ ترابایت داده در طول چند سال هم روی یک ماشین جا میشود، ولی دیگر راحت نیست. یعنی در مقیاس «متوسط» حتی به توزیعشدگی سنگین نیاز نداریم؛ این را بعداً یادمان میماند.
طول کد کوتاه
اگر از حروف کوچک و بزرگ انگلیسی و ارقام استفاده کنیم (Base62)، با هر کاراکتر ۶۲ حالت داریم:
| طول کد | تعداد حالتها |
|---|---|
| ۶ کاراکتر | حدود ۵۷ میلیارد |
| ۷ کاراکتر | حدود ۳٬۵ تریلیون |
| ۸ کاراکتر | حدود ۲۱۸ تریلیون |
برای ۶ میلیارد رکورد، ۶ کاراکتر هم کافی است، ولی ۷ کاراکتر دو مزیت دارد: جا برای رشد میگذارد و فضای کدها را آنقدر خلوت نگه میدارد که حدس زدن یک کد معتبر بهصورت تصادفی تقریباً ناممکن شود (حدود یک در ۶۰۰).
API
دو endpoint اصلی کافی است:
POST /api/v1/urls
Content-Type: application/json
{ "long_url": "https://example.com/some/very/long/path?x=1", "custom_alias": null, "expires_at": null }
→ 201 Created
{ "short_url": "https://sho.rt/aZ3kP9x" }
GET /aZ3kP9x
→ 302 Found
Location: https://example.com/some/very/long/path?x=1
۳۰۱ یا ۳۰۲؟
این سؤال تقریباً همیشه پرسیده میشود و جوابش یک trade-off واقعی است:
| کد وضعیت | معنی | مزیت | عیب |
|---|---|---|---|
| 301 Moved Permanently | انتقال دائمی؛ مرورگر نتیجه را کش میکند | بار سرور کمتر، redirect سریعتر در دفعات بعد | کلیکهای بعدی دیگر به سرور شما نمیرسند؛ آمار ناقص میشود و لینک را نمیشود عوض یا غیرفعال کرد |
| 302 Found | انتقال موقت؛ هر بار از سرور پرسیده میشود | همهی کلیکها ثبت میشوند و مقصد قابلتغییر است | بار بیشتر روی سرور |
اگر آنالیتیکس یا امکان غیرفعالکردن لینک (مثلاً برای لینکهای فیشینگ) مهم است، که تقریباً همیشه هست، 302 انتخاب منطقیتری است. بیشتر سرویسهای تجاری هم همین کار را میکنند.
قدم اول: یک سرور، یک دیتابیس و شناسهی افزایشی
سادهترین طراحی که واقعاً کار میکند این است: یک اپلیکیشن سرور، یک دیتابیس رابطهای مثل PostgreSQL و یک جدول.
جدول ما چیزی شبیه این است:
CREATE TABLE urls (
id BIGSERIAL PRIMARY KEY,
code VARCHAR(16) UNIQUE NOT NULL,
long_url TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ
);
ایده این است: هر ردیف جدید یک id افزایشی از دیتابیس میگیرد (۱، ۲، ۳ و...) و ما همین عدد را به Base62 تبدیل میکنیم تا کد کوتاه ساخته شود. چون id یکتاست، کد هم یکتاست و هیچ برخوردی (collision) رخ نمیدهد.
const ALPHABET =
"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
export function toBase62(n: bigint): string {
if (n === 0n) return ALPHABET[0];
let out = "";
while (n > 0n) {
out = ALPHABET[Number(n % 62n)] + out;
n /= 62n;
}
return out;
}
// toBase62(125n) === "21"
// toBase62(3_521_614_606_207n) === "ZZZZZZZ" (بزرگترین کد ۷ کاراکتری)
جریان redirect هم این شکلی است:
مزایا
- خیلی ساده است و در یک روز پیادهسازی میشود.
- هیچ collisionای وجود ندارد، چون یکتایی را خود دیتابیس تضمین میکند.
- کدها کوتاهترین حالت ممکن را دارند؛ اولین میلیون لینک با ۴ کاراکتر یا کمتر ساخته میشود.
- برای یک سرویس داخلی شرکت یا یک پروژهی شخصی کاملاً کافی است.
معایب
- کدها قابل حدساند. اگر لینک من
aZ3kP9xباشد،aZ3kP9yاحتمالاً لینک نفر بعدی است. هر کسی میتواند با یک اسکریپت ساده همهی لینکها را پیمایش کند و حتی حجم کسبوکار شما را از روی سرعت رشد کدها تخمین بزند. - نقطهی شکست واحد (SPOF)3. اگر سرور یا دیتابیس از دسترس خارج شود، کل سرویس خوابیده است.
- شمارنده به یک دیتابیس گره خورده. وقتی بخواهید چند دیتابیس داشته باشید، دیگر یک
idافزایشی مشترک ندارید.
یک وصلهی ارزان برای حدسپذیری
قبل از اینکه کل طراحی را عوض کنیم، مشکل حدسپذیری را میشود با یک جایگشت برگشتپذیر کمرنگ کرد. اگر id را در یک عدد بزرگ که نسبت به ۶۲ اول است ضرب کنیم و باقیماندهاش را بر ۶۲ به توان ۷ بگیریم، هر id به یک عدد یکتای دیگر در همان بازه نگاشت میشود، ولی ترتیب از بین میرود:
const SPACE = 62n ** 7n; // تعداد کل کدهای ۷ کاراکتری
const MULTIPLIER = 1_580_030_173n; // فرد و بخشناپذیر بر ۳۱، پس نسبت به SPACE اول است
const scramble = (id: bigint) => (id * MULTIPLIER) % SPACE;
toBase62(scramble(1n)); // "1IVE1D"
toBase62(scramble(2n)); // "3rRi3g" - دیگر شبیه کد قبلی نیست
کتابخانههایی مثل Sqids (نام قبلیاش Hashids) همین کار را آماده انجام میدهند. فقط یادتان باشد این مبهمسازی (obfuscation) است، نه امنیت. کسی که چند نمونه کد داشته باشد، میتواند الگو را بازسازی کند. اگر لینکها واقعاً خصوصیاند، کد باید تصادفی باشد و فضای کد بهاندازهی کافی بزرگ.
قدم دوم: کد را از خود URL بسازیم (هش)
رویکرد دومی که در مصاحبهها زیاد مطرح میشود این است که بهجای شمارنده، از خود URL هش بگیریم: مثلاً SHA-256 یا MD5 را حساب کنیم، خروجی را Base62 کنیم و ۷ کاراکتر اولش را برداریم.
مشکل از همین «۷ کاراکتر اول» شروع میشود. با بریدن هش، احتمال برخورد دو URL متفاوت روی یک کد دیگر صفر نیست. طبق همان حساب قبلی، وقتی جدول به ۶ میلیارد رکورد برسد، از هر حدود ۶۰۰ درج جدید یکی به کدی میخورد که قبلاً گرفته شده. پس باید برخورد را مدیریت کنیم:
async function shorten(longUrl: string): Promise<string> {
for (let attempt = 0; attempt < 5; attempt++) {
// در تلاشهای بعدی یک salt اضافه میکنیم تا هش عوض شود
const input = attempt === 0 ? longUrl : `${longUrl}#${attempt}`;
const code = toBase62(sha256AsBigInt(input)).slice(0, 7);
// INSERT ... ON CONFLICT (code) DO NOTHING RETURNING code
const inserted = await db.insertIfAbsent(code, longUrl);
if (inserted) return code;
// این کد قبلاً برای همین URL ساخته شده؟ پس همان را برگردان
const existing = await db.findLongUrl(code);
if (existing === longUrl) return code;
}
throw new Error("too many collisions");
}
یک جزئیات مهم در این کد هست: یکتایی را با ON CONFLICT به خود دیتابیس سپردهام، نه با الگوی «اول SELECT کن، اگر نبود INSERT کن». آن الگو race condition4 دارد؛ دو درخواست همزمان هر دو میبینند کد آزاد است و هر دو درج میکنند.
مزایا
- حذف تکرار (deduplication) رایگان. یک URL همیشه به یک کد میرسد، پس اگر هزار نفر یک لینک را کوتاه کنند، فقط یک ردیف ذخیره میشود.
- به شمارندهی مرکزی نیاز ندارد؛ هر سروری بدون هماهنگی با بقیه میتواند کد را حساب کند.
- کدها ترتیبی نیستند و حدس زدنشان از روی هم ممکن نیست.
معایب
- هر نوشتن دستکم یک رفتوبرگشت به دیتابیس برای بررسی برخورد دارد و با پر شدن فضا، تعداد تلاشها بیشتر میشود.
- dedup همیشه مطلوب نیست. اگر دو کاربر یک URL را کوتاه کنند و هرکدام آمار کلیک خودش را بخواهد، باید کد جداگانه داشته باشند؛ آن وقت باید شناسهی کاربر را هم وارد هش کنید و مزیت dedup از بین میرود.
- یک نکتهی ظریف: کدی که از هش ساخته میشود، قابل پیشبینی است. هر کسی میتواند هش یک URL حدسی را حساب کند و ببیند کسی آن را کوتاه کرده یا نه.
برای سبک کردن بررسی برخورد، میشود جلوی دیتابیس یک Bloom filter گذاشت. Bloom filter با حافظهی خیلی کم میگوید «این کد قطعاً وجود ندارد» یا «شاید وجود دارد». در حالت اول، که اکثر مواقع است، اصلاً سراغ دیتابیس نمیرویم.
قدم سوم: مقیاسدادن مسیر خواندن
تا اینجا روی نحوهی ساختن کد تمرکز کردیم، ولی یادتان هست که ۹۹ درصد ترافیک خواندن است. پس اولین جایی که فشار حس میشود، مسیر redirect است. سه تغییر اینجا بیشترین اثر را دارند:
۱. اپلیکیشن سرور بدون state و پشت Load Balancer. سرور ما هیچ دادهای در حافظهی خودش نگه نمیدارد، پس میشود هر تعداد نسخه که لازم است پشت یک load balancer بالا آورد. این هم SPOF لایهی اپلیکیشن را از بین میبرد و هم مقیاس افقی میدهد.
۲. کش جلوی دیتابیس. توزیع کلیکها خیلی نامتوازن است؛ لینکی که امروز در یک توییت پربازدید پخش شده، میلیونها بار باز میشود و بیشتر لینکها تقریباً هیچوقت. طبق تخمین قدم صفر، حدود ۳۵ گیگابایت کش بخش بزرگی از ترافیک را جذب میکند. الگوی رایج cache-aside است:
app.get("/:code", async (req, res) => {
const { code } = req.params;
let longUrl = await redis.get(`u:${code}`);
if (longUrl === NOT_FOUND) return res.status(404).end();
if (!longUrl) {
longUrl = await db.findLongUrl(code);
// کدهای ناموجود را هم کوتاهمدت کش میکنیم (negative caching)
await redis.set(`u:${code}`, longUrl ?? NOT_FOUND, {
EX: longUrl ? 86_400 : 60,
});
if (!longUrl) return res.status(404).end();
}
res.redirect(302, longUrl);
});
خط negative caching را جدی بگیرید. بدون آن، کسی که با اسکریپت کدهای تصادفی و ناموجود را امتحان میکند، همهی درخواستها را مستقیم به دیتابیس میفرستد؛ به این حمله cache penetration5 میگویند. Bloom filter قدم قبل هم اینجا به کار میآید؛ در نوشتهی جداگانهای دربارهی Bloom filter توضیح دادهام چرا این کاربرد از کاربرد قبلی حساستر است.
خوشبختانه دادهی ما تقریباً تغییرناپذیر است. نگاشت کد به URL بعد از ساخته شدن عوض نمیشود، پس دردسر همیشگی کش، یعنی ناسازگاری (invalidation)، اینجا تقریباً وجود ندارد. تنها استثنا وقتی است که لینکی حذف یا غیرفعال میشود؛ آن موقع کلیدش را از کش پاک میکنیم.
۳. Replica برای خواندن. اگر کش miss کند، درخواست به دیتابیس میرسد. با چند read replica، بار خواندن بین چند نسخه پخش میشود و نوشتنها فقط روی primary انجام میشوند. تأخیر replication (replication lag)6 هم اینجا مشکل جدی ندارد؛ فقط ممکن است لینکی که همین الان ساخته شده، برای چند میلیثانیه روی replica پیدا نشود. اگر این هم مهم است، سرور میتواند بلافاصله بعد از ساختن، نگاشت را در کش بنویسد (write-through).
مزایا: تأخیر redirect به چند میلیثانیه میرسد، لایهی اپلیکیشن SPOF ندارد و دیتابیس از زیر بار خواندن بیرون میآید.
معایب: دو قطعهی جدید (کش و load balancer) باید مانیتور و نگهداری شوند. نکتهی مهمتر اینکه هنوز یک primary داریم که همهی نوشتنها و شمارندهی id به آن وابستهاند.
قدم چهارم: تولید شناسهی یکتا بهصورت توزیعشده
حالا فرض کنید سرویس بزرگ شده و میخواهیم دیتابیس را بین چند ماشین تقسیم کنیم یا در چند دیتاسنتر فعال باشیم. شمارندهی افزایشی یک دیتابیس دیگر جواب نمیدهد. این هستهی اصلی سؤال است و چند راهحل شناختهشده دارد.
راه اول: Ticket Server
فلیکر سالها از همین روش استفاده میکرد: یک یا دو سرور MySQL که تنها کارشان تحویل دادن عدد یکتاست. برای جلوگیری از SPOF، دو سرور دارند که یکی اعداد فرد و دیگری اعداد زوج میدهد (مقالهی فلیکر).
- مزیت: ساده است و اعداد کوتاه و تقریباً مرتب میسازد.
- عیب: هر نوشتن یک رفتوبرگشت شبکهای به ticket server دارد و این سرور هنوز گلوگاه و نقطهی حساس سیستم است.
راه دوم: رزرو بازه (Range Allocation)
به نظر من برای این مسئله تمیزترین راهحل همین است. بهجای اینکه هر بار یک عدد بگیریم، هر سرور اپلیکیشن یک بازه مثلاً ۱٬۰۰۰ تایی رزرو میکند و تا تمام شدنش، عددها را از حافظهی خودش میدهد. هماهنگکننده میتواند ZooKeeper، etcd یا حتی یک ردیف در دیتابیس با آپدیت اتمیک باشد.
class RangeAllocator {
private next = 0n;
private end = 0n;
async nextId(): Promise<bigint> {
if (this.next >= this.end) {
// اتمیک: UPDATE counters SET value = value + 1000 RETURNING value
this.end = await coordinator.incrementBy(1000n);
this.next = this.end - 1000n;
}
return this.next++;
}
}
(در محیط واقعی باید جلوی این را هم بگیرید که دو درخواست همزمان هر دو برای گرفتن بازهی جدید اقدام کنند؛ مثلاً با نگهداشتن یک Promise مشترک برای درخواست در جریان.)
- مزیت: از هر ۱٬۰۰۰ نوشتن فقط یکی با هماهنگکننده حرف میزند؛ اگر هماهنگکننده چند ثانیه از دسترس خارج شود، سرورها با باقیماندهی بازهشان کار را ادامه میدهند. اعداد هم کوتاه میمانند.
- عیب: اگر سروری ریاستارت شود، باقیماندهی بازهاش هدر میرود. با فضای ۳٬۵ تریلیونی این اصلاً مهم نیست. اعداد هم دیگر دقیقاً به ترتیب زمان ساخته نمیشوند، که برای ما اهمیتی ندارد.
ترکیب این روش با جایگشت قدم اول، کدهای کوتاه، یکتا و غیرترتیبی میدهد، بدون هیچ بررسی برخوردی.
راه سوم: Snowflake
توییتر برای تولید شناسهی توزیعشده Snowflake را ساخت: یک عدد ۶۴ بیتی که از زمان (بر حسب میلیثانیه)، شناسهی ماشین و یک شمارندهی محلی تشکیل میشود. هر ماشین کاملاً مستقل شناسه میسازد. اینستاگرام هم نسخهی مشابهی را داخل خود PostgreSQL پیاده کرده است.
- مزیت: هیچ هماهنگکنندهای در مسیر نوشتن نیست و شناسهها بر اساس زمان مرتباند.
- عیب: یک عدد ۶۴ بیتی در Base62 تا ۱۱ کاراکتر میشود؛ برای سرویسی که کل هدفش «کوتاه» بودن است، این هزینهی کمی نیست. به ساعت سیستم هم وابسته است و اگر ساعت ماشینی عقب برود (clock skew)، باید با دقت مدیریتش کنید.
راه چهارم: سرویس تولید کلید از پیش (Key Generation Service)
در این روش یک سرویس جداگانه از قبل میلیونها کد تصادفی ۷ کاراکتری میسازد و در یک جدول «کدهای آزاد» ذخیره میکند. سرورهای اپلیکیشن دستهای از کدها را برمیدارند و هنگام ساختن لینک، یکی را مصرف میکنند. این همان راهحلی است که در منابع آموزشی معروف مثل Grokking the System Design Interview زیاد دیده میشود.
- مزیت: کدها کاملاً تصادفی و کوتاهاند و در لحظهی نوشتن هیچ بررسی برخوردی لازم نیست.
- عیب: یک سرویس و یک جدول دیگر برای نگهداری دارید، باید مطمئن شوید یک کد دوبار به دو سرور داده نشود و خود KGS هم باید replica داشته باشد تا SPOF نشود. در عمل، رزرو بازه همان مزایا را با قطعات کمتر میدهد.
جمعبندی روشهای تولید کد
| روش | طول کد | بررسی برخورد | هماهنگی مرکزی | حدسپذیری |
|---|---|---|---|---|
| شمارندهی دیتابیس | کوتاهترین | ندارد | کامل، در هر نوشتن | بالا (مگر با جایگشت) |
| هش بریدهشده | ثابت، ۷ | دارد | ندارد | از روی URL قابل محاسبه |
| Ticket Server | کوتاه | ندارد | در هر نوشتن | بالا (مگر با جایگشت) |
| رزرو بازه | کوتاه | ندارد | هر هزار نوشتن یکبار | بالا (مگر با جایگشت) |
| Snowflake | تا ۱۱ | ندارد | ندارد | متوسط |
| KGS | ثابت، ۷ | ندارد (از قبل انجام شده) | یک سرویس جدا | پایین |
قدم پنجم: مقیاسدادن لایهی داده
الگوی دسترسی ما خیلی ساده است: تقریباً همهی درخواستها «با این کلید، این مقدار را بده» هستند. نه join داریم، نه تراکنش پیچیده. به همین دلیل خیلی از طراحیها در این مرحله سراغ یک key-value store توزیعشده مثل DynamoDB یا Cassandra میروند. این نوع دیتابیسها از ابتدا برای پخش شدن روی چند ماشین طراحی شدهاند؛ ایدهی اصلیشان را مقالهی Dynamo آمازون توضیح میدهد.
اگر بخواهید با PostgreSQL بمانید، که با ۳ ترابایت داده کاملاً شدنی است، باید خودتان شارد کنید. کلید شاردینگ طبیعی همان code است: با هش کردنش و استفاده از consistent hashing7 تعیین میکنیم هر کد روی کدام شارد باشد. consistent hashing باعث میشود با اضافه شدن یک شارد جدید فقط بخش کوچکی از داده جابهجا شود، نه همهاش.
یک دام رایج اینجا شاردینگ بر اساس بازهی id است. در این حالت همهی لینکهای جدید، که پرترافیکترینها هم هستند، روی آخرین شارد مینشینند و آن شارد داغ (hot shard) میشود.
مزایا: ظرفیت ذخیرهسازی و نوشتن تقریباً بهصورت خطی با تعداد ماشینها بالا میرود و از دست رفتن یک ماشین با replication جبران میشود.
معایب: پیچیدگی عملیاتی بالا میرود. پشتیبانگیری، مهاجرت داده و کوئریهایی که روی همهی شاردها اجرا میشوند (مثلاً «همهی لینکهای این کاربر») سختتر میشوند. برای کوئری اخیر معمولاً یک جدول یا ایندکس جداگانه که بر اساس user_id شارد شده نگه میدارند.
قدم ششم: آنالیتیکس، انقضا و امنیت
وقتی هستهی سیستم پایدار شد، بقیهی نیازمندیها را اضافه میکنیم. تصویر نهایی چیزی شبیه این است:
آنالیتیکس بدون کند کردن redirect
بدترین کار این است که در مسیر redirect یک UPDATE urls SET clicks = clicks + 1 بزنید. این کار هر خواندن را به یک نوشتن تبدیل میکند و ردیف لینکهای پربازدید را به نقطهی رقابت قفل (lock contention) بدل میکند. راه درست این است که سرور فقط یک رویداد (کد، زمان، IP، User-Agent و Referer) را بدون انتظار در یک صف مثل Kafka بیندازد و redirect را بلافاصله برگرداند. یک سرویس مصرفکننده رویدادها را دستهای میخواند و در یک دیتابیس تحلیلی ستونی مثل ClickHouse مینویسد.
- مزیت: تأخیر redirect به سیستم آنالیتیکس وابسته نیست. اگر ClickHouse ساعتی از کار بیفتد، رویدادها در صف میمانند و بعداً پردازش میشوند.
- عیب: آمار چند ثانیه تا چند دقیقه عقب است و ممکن است در شرایط خاص تعدادی رویداد گم یا تکراری شود. برای آمار کلیک، این دقت معمولاً کاملاً قابلقبول است.
انقضای لینک
دو کار با هم لازم است. هنگام خواندن، اگر expires_at گذشته بود، 404 یا 410 برمیگردانیم (lazy expiration). یک job پسزمینه هم ردیفهای منقضی را پاک میکند تا حجم دیتابیس بیدلیل بالا نرود. اگر از DynamoDB یا Cassandra استفاده میکنید، TTL داخلی خودشان همین کار را انجام میدهد. TTL کلید در کش هم نباید از زمان انقضای لینک بیشتر باشد.
یک توصیهی شخصی: کد لینکهای منقضی را دوباره استفاده نکنید، حداقل نه بهزودی. آن لینک قدیمی ممکن است هنوز در ایمیل یا پستی قدیمی باشد و کسی که بعداً بازش میکند، سر از مقصد کاملاً بیربطی درمیآورد.
نام دلخواه (Custom Alias)
نام دلخواه با کدهای تولیدی در یک فضای مشترک زندگی میکند، پس باید مطمئن شوید با هم برخورد نمیکنند. دو راه ساده وجود دارد: یا نامهای دلخواه طول یا الگوی متفاوتی داشته باشند، یا هنگام تولید کد، کدهای گرفتهشده توسط aliasها رد شوند. چون درج با ON CONFLICT انجام میشود، در بدترین حالت فقط یک تلاش مجدد لازم میشود. یک فهرست کلمات رزروشده (مثل api، admin یا login) هم لازم است.
امنیت و سوءاستفاده
کوتاهکنندههای لینک ابزار محبوب فیشینگاند، چون مقصد واقعی را پنهان میکنند. چند لایهی دفاعی تقریباً ضروری است:
- بررسی URL هنگام ساخت با سرویسهایی مثل Google Safe Browsing و مسدود کردن دامنههای شناختهشدهی مخرب.
- rate limiting بر اساس IP یا کلید API، تا کسی نتواند در چند دقیقه میلیونها لینک بسازد.
- امکان غیرفعالکردن لینک. یکی دیگر از دلایل انتخاب 302 همین است؛ با 301، مرورگرهایی که redirect را کش کردهاند، دیگر از شما نمیپرسند.
چند منطقهی جغرافیایی
اگر کاربران در قارههای مختلفاند، مسیر خواندن را با GeoDNS به نزدیکترین منطقه میفرستیم و داده را بین منطقهها replicate میکنیم. روش رزرو بازه اینجا یک مزیت دیگر هم نشان میدهد: کافی است هر منطقه بازههای مخصوص خودش را داشته باشد (مثلاً با یک پیشوند چند بیتی)، تا نوشتنها بدون هماهنگی بین منطقهها انجام شوند.
کدام طراحی را انتخاب کنیم؟
جواب صادقانه این است که به مقیاس بستگی دارد و بیشتر سیستمها هیچوقت به قدم ششم نمیرسند:
| شرایط | طراحی پیشنهادی |
|---|---|
| سرویس داخلی شرکت یا پروژهی شخصی | قدم اول: یک سرور، PostgreSQL، شمارنده + جایگشت |
| محصول عمومی با ترافیک متوسط | قدم سوم: چند سرور بدون state، کش Redis، read replica |
| میلیاردها لینک و ترافیک سنگین | قدم چهارم و پنجم: رزرو بازه، key-value store شاردشده |
| سرویس تجاری جهانی با آمار و داشبورد | قدم ششم: آنالیتیکس ناهمگام، چند منطقه، لایههای ضدسوءاستفاده |
در مصاحبه هم همین مسیر را بروید. با قدم صفر شروع کنید و نیازمندیها و اعداد را روشن کنید. یک طراحی ساده و درست بکشید، بعد بپرسید «اگر ترافیک ۱۰۰ برابر شود، اول چه چیزی میشکند؟» و فقط همان را درست کنید. مصاحبهکننده بیشتر از هر دیاگرامی، دنبال همین استدلال است: اینکه بدانید پیچیدگی را کجا و به چه دلیلی اضافه میکنید.
منابع برای مطالعهی بیشتر
- Alex Xu، کتاب System Design Interview: An Insider's Guide، جلد اول، فصل ۸: Design a URL Shortener.
- System Design Primer: طراحی Pastebin/Bit.ly
- Flickr Engineering: Ticket Servers
- Twitter Engineering: Announcing Snowflake
- Instagram Engineering: Sharding & IDs at Instagram
- Dynamo: Amazon's Highly Available Key-value Store
- RFC 9110، بخش کدهای وضعیت Redirect
- Sqids: کتابخانهی تولید شناسههای کوتاه و مبهم
یادداشتها
-
سیستمی که تعداد درخواستهای خواندنش خیلی بیشتر از نوشتن است. در چنین سیستمی بهینهسازی مسیر خواندن، مثلاً با کش و replica، بیشترین اثر را دارد. ↑
-
تخمین سرانگشتی و تقریبی ظرفیت با چند فرض ساده. هدفش پیدا کردن مرتبهی بزرگی اعداد است، نه دقت. اصطلاح از این میآید که چنین حسابی را میشود پشت یک پاکت نامه انجام داد. ↑
-
مخفف Single Point of Failure. قطعهای از سیستم که اگر از کار بیفتد، کل سیستم از کار میافتد، چون نسخهی جایگزینی برایش وجود ندارد. ↑
-
وضعیتی که نتیجهی کار به ترتیب زمانی اجرای دو عملیات همزمان بستگی دارد. اینجا دو درخواست هر دو میبینند کد آزاد است و هر دو آن را ثبت میکنند. ↑
-
درخواست برای کلیدهایی که نه در کش هستند و نه در دیتابیس. چون چیزی برای کش کردن پیدا نمیشود، هر درخواست مستقیم به دیتابیس میرسد. ↑
-
فاصلهی زمانی بین نوشته شدن داده روی سرور اصلی (primary) و رسیدن آن به نسخههای replica. در این فاصله، خواندن از replica دادهی قدیمی را برمیگرداند. ↑
-
روشی برای تقسیم کلیدها بین سرورها که با اضافه یا کم شدن یک سرور فقط بخش کوچکی از کلیدها جابهجا میشوند. در هش معمولی با باقیمانده، تقریباً همهی کلیدها جابهجا میشوند. ↑