برنتی / می‌نویسم که فکر کنم

طراحی سرویس کوتاه‌کننده‌ی لینک: از یک سرور ساده تا مقیاس میلیاردی

1405.07.03·سیستم دیزاین

اگر چند مصاحبه‌ی طراحی سیستم رفته باشید، احتمالاً دست‌کم یک‌بار این سؤال را شنیده‌اید: «یک سرویس مثل 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 و یک جدول.

ClientApp ServerPostgreSQLPOST /urls · GET /:codeINSERT · SELECT
ساده‌ترین نسخه: همه‌چیز روی یک سرور و یک دیتابیس

جدول ما چیزی شبیه این است:

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 هم این شکلی است:

BrowserShortenerDatabaseexample.com1. GET /aZ3kP9x2. SELECT long_url3. long_url4. 302 + Location5. GET /some/very/long/path
مرورگر لینک کوتاه را باز می‌کند، سرویس آدرس اصلی را پیدا می‌کند و با ۳۰۲ مرورگر را به مقصد می‌فرستد

مزایا

  • خیلی ساده است و در یک روز پیاده‌سازی می‌شود.
  • هیچ 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 یا حتی یک ردیف در دیتابیس با آپدیت اتمیک باشد.

Coordinatornext_range = 3001App Server A1 … 1000App Server B1001 … 2000App Server C2001 … 3000reserve 1000 IDs
هر سرور یک بازه رزرو می‌کند و تا تمام شدنش بدون هیچ ارتباط شبکه‌ای شناسه می‌سازد
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 شارد شده نگه می‌دارند.

قدم ششم: آنالیتیکس، انقضا و امنیت

وقتی هسته‌ی سیستم پایدار شد، بقیه‌ی نیازمندی‌ها را اضافه می‌کنیم. تصویر نهایی چیزی شبیه این است:

ClientLoad BalancerApp ServersID Rangesetcd / ZooKeeperCacheRedis clusterDB Shardskey: codeQueueKafka · clicksAnalyticsClickHouse1. cache2. on missasync
معماری نهایی: مسیر redirect فقط به کش و در صورت miss به شارد دیتابیس می‌رسد؛ ثبت کلیک‌ها ناهمگام است

آنالیتیکس بدون کند کردن 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 شارد‌شده
سرویس تجاری جهانی با آمار و داشبوردقدم ششم: آنالیتیکس ناهمگام، چند منطقه، لایه‌های ضدسوءاستفاده

در مصاحبه هم همین مسیر را بروید. با قدم صفر شروع کنید و نیازمندی‌ها و اعداد را روشن کنید. یک طراحی ساده و درست بکشید، بعد بپرسید «اگر ترافیک ۱۰۰ برابر شود، اول چه چیزی می‌شکند؟» و فقط همان را درست کنید. مصاحبه‌کننده بیشتر از هر دیاگرامی، دنبال همین استدلال است: اینکه بدانید پیچیدگی را کجا و به چه دلیلی اضافه می‌کنید.

منابع برای مطالعه‌ی بیشتر

یادداشت‌ها

  1. سیستمی که تعداد درخواست‌های خواندنش خیلی بیشتر از نوشتن است. در چنین سیستمی بهینه‌سازی مسیر خواندن، مثلاً با کش و replica، بیشترین اثر را دارد. ↑

  2. تخمین سرانگشتی و تقریبی ظرفیت با چند فرض ساده. هدفش پیدا کردن مرتبه‌ی بزرگی اعداد است، نه دقت. اصطلاح از این می‌آید که چنین حسابی را می‌شود پشت یک پاکت نامه انجام داد. ↑

  3. مخفف Single Point of Failure. قطعه‌ای از سیستم که اگر از کار بیفتد، کل سیستم از کار می‌افتد، چون نسخه‌ی جایگزینی برایش وجود ندارد. ↑

  4. وضعیتی که نتیجه‌ی کار به ترتیب زمانی اجرای دو عملیات هم‌زمان بستگی دارد. این‌جا دو درخواست هر دو می‌بینند کد آزاد است و هر دو آن را ثبت می‌کنند. ↑

  5. درخواست برای کلیدهایی که نه در کش هستند و نه در دیتابیس. چون چیزی برای کش کردن پیدا نمی‌شود، هر درخواست مستقیم به دیتابیس می‌رسد. ↑

  6. فاصله‌ی زمانی بین نوشته شدن داده روی سرور اصلی (primary) و رسیدن آن به نسخه‌های replica. در این فاصله، خواندن از replica داده‌ی قدیمی را برمی‌گرداند. ↑

  7. روشی برای تقسیم کلیدها بین سرورها که با اضافه یا کم شدن یک سرور فقط بخش کوچکی از کلیدها جابه‌جا می‌شوند. در هش معمولی با باقی‌مانده، تقریباً همه‌ی کلیدها جابه‌جا می‌شوند. ↑

§ فهرست « سیستم دیزاین »
  1. 1405.07.03دیتابیس ستونی و OLAP: چرا گزارش سالانه را نباید از دیتابیس اپلیکیشن گرفتاز یک گزارش سالانه که دیتابیس فروشگاه را از کار می‌اندازد شروع می‌کنم و به تفاوت بار کاری OLTP و OLAP می‌رسم: دیتابیس ستونی مثل ClickHouse داده را چطور روی دیسک می‌چیند، چرا کوئری‌های تحلیلی روی آن چند ده برابر سریع‌تر است و چه چیزهایی را در ازایش از دست می‌دهید.
  2. 1405.07.03View در دیتابیس: وقتی سه تیم یک عدد را سه‌جور حساب می‌کننداز یک مسئله‌ی واقعی در طراحی سیستم شروع می‌کنم که در آن چند سرویس و تیم یک کوئری مشترک را هر کدام به شکل خودشان می‌نویسند و به view می‌رسم: view چیست، چه چیزی را حل می‌کند، materialized view کجا به کار می‌آید و در پروژه‌ی واقعی چطور باید ساخت و نگه‌داری‌اش کرد.
  3. 1405.07.03Bloom Filter: بررسی عضویت در مجموعه‌های بزرگ با حافظه‌ی کمBloom filter با چند بیت برای هر عضو جواب می‌دهد که یک مقدار «قطعاً در مجموعه نیست» یا «شاید هست». در این نوشته سازوکار، ریاضی انتخاب اندازه، یک پیاده‌سازی تست‌شده، محدودیت‌ها و جاهایی که در دیتابیس‌ها و CDNها به کار رفته را مرور می‌کنم و می‌گویم کجا در طراحی یک سیستم واقعی دردسر درست می‌کند.
  4. 1405.07.03ZooKeeper را هنوز باید بشناسیم؟ هماهنگی در سیستم‌های توزیع‌شدهکافکا دیگر به ZooKeeper نیاز ندارد، ولی مسئله‌ای که ZooKeeper حل می‌کرد سر جایش است. در این نوشته می‌گویم ZooKeeper چطور کار می‌کند، کجا هنوز به کار می‌آید و مهندس ارشد دقیقاً چه چیزی از آن باید بداند.
  5. 1405.07.03کافکا، RabbitMQ یا صف Redis؟ مقایسه با یک مثال سادههر سه ابزار پیام را از یک سرویس به سرویس دیگر می‌رسانند، ولی برای کارهای متفاوتی ساخته شده‌اند. در این نوشته با یک فروشگاه اینترنتی ساده نشان می‌دهم هر کدام کجا به کار می‌آید و کجا نه.
  6. 1405.07.03طراحی سرویس کوتاه‌کننده‌ی لینک: از یک سرور ساده تا مقیاس میلیاردیطراحی URL Shortener پرتکرارترین سؤال مصاحبه‌های طراحی سیستم است. در این نوشته قدم‌به‌قدم از ساده‌ترین نسخه با یک سرور و یک دیتابیس شروع می‌کنیم و تا معماری توزیع‌شده با کش، تولید شناسه‌ی توزیع‌شده، شاردینگ و آنالیتیکس جلو می‌رویم و مزایا و معایب هر مرحله را می‌سنجیم.