طراحی سیستم برای بار زیاد
وقتی یک سرور کافی نیست، به جای سرور بزرگتر، سرورهای بیشتر میگذاریم و بار را بین آنها پخش میکنیم. دیتابیس سختترین بخش است. با Replica خواندن را پخش میکنیم و با Sharding خود داده را تقسیم میکنیم. هر کدام از اینها هزینهای دارد که باید بشناسیم.
نویسنده: bezzad
مشکل: فروش ویژه ساعت ۱۲
فروشگاه ما ساعت ۱۲ ظهر هزار گوشی با تخفیف میفروشد. در دقیقه اول حدود دویست هزار نفر وارد میشوند و بارها روی دکمه خرید میزنند. سه شرط داریم:
- بیشتر از هزار گوشی فروخته نشود.
- سیستم نخوابد.
- هر کاربر فقط یک گوشی بخرد.
امروز همه چیز روی یک سرور و یک دیتابیس است. این سرور در چند ثانیه اول از کار میافتد.
قدم اول: عدد واقعی را حساب کن
قبل از طراحی، یک حساب سرانگشتی انجام بده. این کار در مصاحبه هم امتیاز مثبت است.
- دویست هزار کاربر در شصت ثانیه یعنی حدود ۳۳۰۰ کاربر در ثانیه.
- اگر هر کاربر به طور میانگین پنج بار کلیک یا refresh کند، یعنی حدود ۱۶ هزار درخواست در ثانیه.
- ولی فقط هزار نفر واقعاً چیزی میخرند. یعنی بیشتر از ۹۹٪ درخواستها در آخر جواب «تمام شد» میگیرند.
نتیجه مهم: لازم نیست همه درخواستها به دیتابیس برسند. باید بیشتر آنها را ارزان و زود جواب بدهیم.
دو راه بزرگتر شدن
- راه عمودی (Scale Up). یک سرور قویتر بخر. کد عوض نمیشود. ولی سقف دارد، گران است و اگر همان یک سرور بیفتد، همه چیز میافتد.
- راه افقی (Scale Out). چند سرور معمولی کنار هم بگذار و بار را پخش کن. تقریباً بیسقف است و خرابی یک سرور مهم نیست. ولی یک شرط دارد: برنامه باید بیحالت (stateless) باشد.
بیحالت یعنی چه؟ یعنی هیچ دادهای از کاربر در حافظه یک سرور خاص نمیماند:
- درخواست اول کاربر به سرور ۱ میرود و سبد خرید در حافظه سرور ۱ ذخیره میشود.
- درخواست دوم همان کاربر به سرور ۲ میرود.
- سرور ۲ سبد را نمیبیند. کاربر فکر میکند سبدش خالی شده.
- پس داده کاربر را در جای مشترک، مثل Redis یا دیتابیس، نگه دار.
پخش بار (Load Balancing)
یک Load Balancer جلوی سرورها مینشیند و هر درخواست را به یکی از آنها میدهد. چند روش رایج:
- روش نوبتی (Round Robin). به ترتیب: اول، دوم، سوم، دوباره اول. ساده است و برای درخواستهای هماندازه خوب کار میکند.
- روش کمترین اتصال (Least Connections). به سروری بده که الان کمترین کار را دارد. برای درخواستهایی که زمانشان خیلی فرق دارد بهتر است.
- روش بر اساس کلید (Hash). درخواستهای یک کاربر همیشه به یک سرور میرود. فقط وقتی لازم است که سرور حالت دارد. معمولاً بهتر است همین حالت را حذف کنی.
کار مهم دیگر Load Balancer چک سلامت است. هر چند ثانیه از هر سرور میپرسد «سالمی؟». سرور ناسالم را از لیست بیرون میگذارد. ولی بین خراب شدن سرور و چک بعدی، چند درخواست به سرور خراب میرسند.
مثال زنده
چند درخواست بفرست. بعد سرور ۲ را خراب کن و دوباره بفرست:
طراحی فروش ویژه
حالا همه چیز را کنار هم بگذاریم:
- بار را قبل از سرور کم کن. صفحه محصول و عکسها روی CDN باشند. برای هر کاربر و هر IP محدودیت درخواست بگذار. کلیکهای تکراری به سرورها نمیرسند.
- سرورهای API بیحالت و چندتایی. پشت Load Balancer، با Scale افقی.
- موجودی را اتمی کم کن. این مهمترین بخش است (کد پایین). خواندن موجودی و کم کردن آن باید یک عمل باشد، نه دو عمل جدا.
- کار سنگین را به صف بده. بعد از رزرو موفق، فقط یک پیام به صف بفرست و به کاربر بگو «رزرو شد». ساختن سفارش، فاکتور و ایمیل را یک Worker با سرعت خودش انجام میدهد. صف اوج بار را صاف میکند.
- دیتابیس را محافظت کن. خواندنها را به Replica ها بفرست. نوشتن فقط به Primary.
دیتابیس: Replica
بیشتر سیستمها خیلی بیشتر از نوشتن، میخوانند. مشتری صد بار محصول را میبیند و یک بار میخرد. پس:
- یک Primary همه نوشتنها را میگیرد.
- چند Replica کپی داده را دارند و خواندنها بین آنها پخش میشود.
- کپی معمولاً با تأخیر است. چند میلیثانیه تا چند ثانیه. به این Replication Lag میگویند.
مشکل تأخیر را با یک مثال ببینیم: مشتری سفارش ثبت میکند و فوراً به صفحه «سفارشهای من» میرود. این صفحه از Replica میخواند. سفارش هنوز به Replica نرسیده. مشتری فکر میکند سفارشش گم شده.
راه حل: خواندنهایی که بلافاصله بعد از نوشتن همان کاربر هستند، از Primary بخوان. به این خواندن نوشته خود (Read Your Writes) میگویند.
دیتابیس: Sharding
وقتی حجم داده یا تعداد نوشتنها برای یک سرور زیاد است، خود داده را تقسیم میکنیم. هر تکه را یک Shard میگوییم و روی یک دیتابیس جدا است.
مهمترین تصمیم انتخاب کلید Shard است:
- کلید باید داده را مساوی پخش کند. اگر کلید «شهر» باشد، Shard تهران خیلی بزرگتر از بقیه میشود. به این Hot Shard میگویند.
- کوئریهای رایج باید فقط به یک Shard بروند. «سفارشهای این مشتری» با کلید شناسه مشتری فقط یک Shard را میخواند. ولی «همه سفارشهای امروز» باید از همه Shard ها بخواند و کند است.
- تراکنش بین Shard ها نداریم. چیزهایی که باید با هم تغییر کنند، باید در یک Shard باشند.
- اضافه کردن Shard سخت است. با تقسیم ساده باقیمانده، وقتی تعداد Shard از سه به چهار برسد، بیشتر کلیدها جابجا میشوند. روش Consistent Hashing این جابجایی را خیلی کمتر میکند.
کد
کم کردن اتمی موجودی در دیتابیس
شرط موجودی داخل همان دستور UPDATE است. دیتابیس این یک دستور را اتمی اجرا میکند.
app.MapPost("/sale/{productId:guid}/buy", async (
Guid productId, ShopDbContext db, CancellationToken ct) =>
{
// One atomic statement: check and decrement together.
int updated = await db.Products
.Where(p => p.Id == productId && p.Stock > 0)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.Stock, p => p.Stock - 1), ct);
return updated == 1
? Results.Accepted()
: Results.Conflict("Sold out");
});
همان کار با Redis
برای بار خیلی زیاد، موجودی را در Redis نگه میداریم. Redis دستورها را یکییکی اجرا میکند، پس دو دستور DECR هرگز وسط هم اجرا نمیشوند.
long left = await redis.StringDecrementAsync($"sale:{productId}:stock");
if (left < 0)
{
return Results.Conflict("Sold out");
}
await queue.PublishAsync(new PhoneReserved(productId, userId), ct);
return Results.Accepted();
برای شرط «یک گوشی برای هر کاربر»، چک کاربر و کم کردن موجودی باید با هم انجام شوند. مثلاً با یک اسکریپت Lua در Redis یا یک تراکنش دیتابیس. وگرنه ممکن است موجودی کم شود، ولی خرید به خاطر تکراری بودن کاربر رد شود.
انتخاب Shard
using System.Buffers.Binary;
public static int ShardFor(Guid customerId, int shardCount)
{
// Do NOT use GetHashCode(): it is not guaranteed to be stable,
// and string hash codes change in every process. The shard must never change.
Span<byte> bytes = stackalloc byte[16];
customerId.TryWriteBytes(bytes);
uint hash = BinaryPrimitives.ReadUInt32LittleEndian(bytes);
return (int)(hash % (uint)shardCount);
}
در .NET، مقدار هش رشتهها در هر اجرای برنامه فرق میکند. اگر Shard را با آن انتخاب کنی، بعد از restart داده مشتری را در Shard اشتباه میگردی.
قانونهای مهم
- اول بپرس، بعد طراحی کن. چند کاربر؟ چند درخواست در ثانیه؟ پرداخت هم در همین جریان است؟ اعداد طراحی را عوض میکنند.
- برنامه بیحالت. حالت در Redis یا دیتابیس، نه در حافظه سرور.
- بار را زود کم کن. CDN، cache و محدودیت درخواست قبل از سرور و دیتابیس.
- کارهای حساس اتمی. چک و تغییر در یک عمل.
- کار سنگین با صف. کاربر منتظر ساختن فاکتور و ایمیل نماند.
- هر جزء را چندتایی بگذار. یک Load Balancer، یک Redis یا یک دیتابیس تنها، یک نقطه خرابی است.
- دیتابیس را به ترتیب scale کن. اول ایندکس و cache، بعد Replica، در آخر Sharding.
اشتباههای رایج
| اشتباه | نتیجه | راه درست |
|---|---|---|
| خواندن موجودی و کم کردن در دو دستور جدا | دو نفر آخرین گوشی را میخرند. | یک دستور اتمی، در دیتابیس یا Redis. |
| نگه داشتن session در حافظه سرور | با Scale افقی، کاربر دادهاش را گم میکند. | حالت در Redis یا دیتابیس. |
| تنها جواب: «سرور بیشتر» | دیتابیس گلوگاه است و سرور بیشتر بار آن را بیشتر میکند. | کم کردن بار قبل از دیتابیس و صف. |
| خواندن بلافاصله بعد از نوشتن از Replica | کاربر تغییر خودش را نمیبیند. | خواندن نوشته خود از Primary. |
| کلید Shard نامساوی، مثل شهر | یک Shard زیر بار میماند و بقیه بیکارند. | کلید با پخش مساوی، مثل شناسه مشتری. |
| انتخاب Shard با متد GetHashCode | بعد از restart داده در Shard اشتباه گشته میشود. | هش ثابت و مستقل از اجرا. |
کدام ابزار برای کدام مشکل؟
اول اینها
- خواندن زیاد: cache و Replica.
- بار ناگهانی: CDN، محدودیت درخواست و صف.
- سرورهای API زیر بار: Scale افقی با برنامه بیحالت.
فقط با دلیل
- تقسیم داده با Sharding: فقط وقتی حجم داده یا نوشتن واقعاً از یک سرور بیشتر است.
- روش Hash در Load Balancer: فقط وقتی حذف حالت از سرور ممکن نیست.
خلاصه در هفت خط
- قبل از طراحی، اعداد را حساب کن: کاربر، درخواست در ثانیه، و چه بخشی واقعاً کار میکند.
- سرور بزرگتر سقف دارد. سرورهای بیشتر بیسقفتر است، ولی برنامه باید بیحالت باشد.
- یک Load Balancer بار را پخش میکند و سرور ناسالم را با چک سلامت بیرون میگذارد.
- بار را زود کم کن و کار سنگین را به صف بده.
- کارهای حساس مثل کم کردن موجودی باید اتمی باشند.
- با Replica خواندن پخش میشود. حواست به تأخیر کپی باشد.
- با Sharding داده تقسیم میشود. کلید درست مهمترین تصمیم است و این آخرین راه است.