Microsoft Orleans
در Orleans هر موجودیت یک Grain است، یعنی یک Actor مجازی که همیشه وجود دارد. Orleans خودش آن را روی یکی از سرورها فعال میکند، پیامهایش را یکییکی اجرا میکند و وقتی بیکار شد از حافظه خارج میکند. بزرگترین تله آن، بنبست در فراخوانیهای چرخهای است.
نویسنده: bezzad
مشکل: میلیونها سبد خرید
فروشگاه اینترنتی ما در روز حراج یک میلیون کاربر فعال دارد. هر کاربر یک سبد خرید دارد. سبد خرید هر چند ثانیه تغییر میکند: اضافه کردن کالا، تغییر تعداد، اعمال کد تخفیف.
راه معمول این است که هر تغییر را مستقیم در دیتابیس بنویسیم. ولی:
- دیتابیس زیر بار میرود. هر کلیک یعنی یک خواندن و یک نوشتن.
- همزمانی دردسر دارد. کاربر در دو تب همزمان کالا اضافه میکند و یکی از تغییرها گم میشود.
- کش کردن سخت است. اگر سبد را در حافظه یک سرور نگه داریم، درخواست بعدی شاید به سرور دیگری برود.
ایده Actor اینجا کمک میکند: یک Actor برای هر سبد. ولی چه کسی میلیونها Actor را بسازد، روی سرورها پخش کند و بعد از restart دوباره بسازد؟ Orleans همین کار را میکند.
ایده اصلی: Actor مجازی
در Orleans به هر Actor یک Grain میگوییم. به هر سرور هم یک Silo. چند Silo با هم یک کلاستر میسازند.
فرق بزرگ Orleans با Actor های کلاسیک این است: Grain همیشه وجود دارد. نه ساختن لازم است و نه از بین بردن.
- هر Grain یک شناسه دارد. مثلاً سبد خرید علی.
- کد فقط شناسه را میخواهد. اگر Grain در حافظه نباشد، Orleans همان لحظه آن را روی یکی از Silo ها فعال میکند.
- هر Grain در هر لحظه معمولاً فقط یک نسخه فعال در کل کلاستر دارد. پس همه درخواستهای سبد علی به یک جا میروند.
- اگر Silo بمیرد، پیام بعدی Grain را روی Silo دیگری فعال میکند. کد ما هیچ تغییری نمیبیند.
به همین دلیل به آن Virtual Actor میگوییم. مثل حافظه مجازی: تو فقط آدرس را داری و سیستم میداند داده واقعاً کجاست.
کد یک Grain
اول رابط Grain. همه متدها async هستند، چون ممکن است Grain روی سرور دیگری باشد:
public interface ICartGrain : IGrainWithStringKey
{
Task AddItem(string productId, int quantity);
Task<IReadOnlyDictionary<string, int>> GetItems();
}
بعد خود Grain. وضعیت آن به صورت خودکار از دیتابیس خوانده میشود:
[GenerateSerializer]
public sealed class CartState
{
[Id(0)] public Dictionary<string, int> Items { get; set; } = [];
}
public sealed class CartGrain(
[PersistentState("cart", "carts")] IPersistentState<CartState> cart) : Grain, ICartGrain
{
public async Task AddItem(string productId, int quantity)
{
// No lock: Orleans runs one request at a time for this grain.
cart.State.Items[productId] = cart.State.Items.GetValueOrDefault(productId) + quantity;
await cart.WriteStateAsync();
}
public Task<IReadOnlyDictionary<string, int>> GetItems()
=> Task.FromResult<IReadOnlyDictionary<string, int>>(cart.State.Items.AsReadOnly());
}
راهاندازی Silo و استفاده از Grain در یک API:
var builder = WebApplication.CreateBuilder(args);
builder.UseOrleans(silo =>
{
silo.UseLocalhostClustering(); // for local development only
silo.AddMemoryGrainStorage("carts"); // use a real database in production
});
var app = builder.Build();
app.MapPost("/carts/{customerId}/items", async (
string customerId, AddItemRequest request, IGrainFactory grains) =>
{
var cart = grains.GetGrain<ICartGrain>(customerId); // no "new", no lookup
await cart.AddItem(request.ProductId, request.Quantity);
return Results.NoContent();
});
app.Run();
public sealed record AddItemRequest(string ProductId, int Quantity);
تکنخی بودن: هم مزیت، هم تله
هر Grain به طور پیشفرض Non-Reentrant است. یعنی:
- یک درخواست شروع میشود.
- اگر وسط کار await کند، Grain درخواست دیگری را شروع نمیکند. صبر میکند تا همین درخواست کامل تمام شود.
- پس بین دو await، هیچ درخواست دیگری وضعیت را عوض نمیکند.
این عالی است: داخل Grain هیچ قفلی لازم نیست. ولی یک تله بزرگ هم دارد.
بنبست: وقتی دو Grain منتظر هم هستند
سبد علی موقع تسویه حساب، از Grain تخفیف میپرسد «تخفیف این سبد چقدر است؟». Grain تخفیف برای محاسبه، اقلام سبد را لازم دارد. پس دوباره سبد علی را صدا میزند.
// CartGrain
public async Task<decimal> Checkout()
{
var discounts = GrainFactory.GetGrain<IDiscountGrain>(0);
var discount = await discounts.Calculate(this.GetPrimaryKeyString()); // waits...
return discount;
}
// DiscountGrain
public async Task<decimal> Calculate(string cartId)
{
var cart = GrainFactory.GetGrain<ICartGrain>(cartId);
var items = await cart.GetItems(); // queued behind Checkout: deadlock
return items.Count >= 3 ? 0.1m : 0m;
}
قدم به قدم:
- سبد درخواست Checkout را شروع میکند و منتظر جواب تخفیف میماند.
- از نظر Orleans، Checkout هنوز تمام نشده است. پس درخواست دیگری به سبد داده نمیشود.
- تخفیف درخواست GetItems را به سبد میفرستد. این درخواست در صف، پشت Checkout میماند.
- حالا یک چرخه داریم. Checkout منتظر تخفیف است، تخفیف منتظر GetItems، و GetItems منتظر تمام شدن Checkout.
- هیچ کدام جلو نمیروند. بعد از زمان انتظار پیشفرض (سی ثانیه)، فراخوانی با خطای Timeout شکست میخورد.
راهها، از بهتر به بدتر
- چرخه را در طراحی حذف کن. سبد، اقلامش را همراه درخواست بفرستد. دیگر تماس برگشتی لازم نیست.
var discount = await discounts.Calculate(cart.State.Items.AsReadOnly());
- اجازه ورود دوباره فقط برای همین زنجیره. متد AllowCallChainReentrancy از کلاس RequestContext فقط به درخواستهایی که از همین زنجیره برمیگردند اجازه ورود میدهد.
using var scope = RequestContext.AllowCallChainReentrancy();
var discount = await discounts.Calculate(this.GetPrimaryKeyString());
- متدهای فقطخواندنی را علامت بزن. با attribute های ReadOnly یا AlwaysInterleave روی متد GetItems در رابط، این متد میتواند وسط کارهای دیگر اجرا شود.
- کل Grain را Reentrant کن. سادهترین راه است، ولی خطرناکترین هم هست. بین دو await، درخواست دیگری میتواند وضعیت را عوض کند. همان Race Condition که از آن فرار کرده بودیم برمیگردد.
Grain داغ
فرض کن یک Grain داریم که تعداد کل سفارشهای امروز را میشمارد. همه سفارشها آن را صدا میزنند. با اضافه کردن Silo هم سریعتر نمیشود. چرا؟
- این Grain فقط یک نسخه فعال دارد، روی یک Silo.
- درخواستها را یکییکی اجرا میکند.
- پس Silo بیشتر به یک Grain تنها کمکی نمیکند.
راهها:
- شمارنده را تقسیم کن. مثلاً شانزده شمارنده جزئی بر اساس شناسه سفارش، و یک Grain که هر چند ثانیه آنها را جمع میکند.
- تغییرها را دستهای بفرست، به جای یک تماس برای هر سفارش.
- برای کار بدون وضعیت، StatelessWorker. این نوع Grain میتواند چند نسخه همزمان داشته باشد.
چند ابزار دیگر Orleans
- تایمر و یادآور (Timer و Reminder). تایمر فقط تا وقتی Grain فعال است کار میکند. یادآور ذخیره میشود و حتی Grain غیرفعال را بیدار میکند.
- جریان (Stream). برای فرستادن رویداد از یک Grain به چند Grain دیگر، بدون منتظر ماندن.
- متدهای یکطرفه (OneWay). فرستنده منتظر جواب نمیماند. برای کارهایی که جواب لازم ندارند.
اشتباههای رایج
| اشتباه | نتیجه | راه درست |
|---|---|---|
| دو Grain که با await همدیگر را صدا میزنند | بنبست و خطای Timeout. | داده لازم را همراه درخواست بفرست. |
| Reentrant کردن کل Grain برای رفع بنبست | Race Condition داخل Grain برمیگردد. | فقط زنجیره یا متد فقطخواندنی. |
| یک Grain سراسری برای همه | گلوگاه، و Silo بیشتر کمکی نمیکند. | وضعیت را ریز کن یا تقسیم کن. |
| کار مسدودکننده داخل Grain | همه درخواستهای آن Grain کند میشوند. | فقط کد async و کوتاه. |
| ذخیره نکردن وضعیت بعد از تغییر | بعد از غیرفعال شدن یا crash، تغییر گم میشود. | ذخیره وضعیت بعد از هر تغییر مهم. |
| کوئری روی همه Grain ها، مثل «همه سبدهای بالای یک میلیون» | Orleans برای این ساخته نشده است. | داده را برای گزارش در دیتابیس جدا هم بنویس. |
چه وقت Orleans؟
مناسب
- تعداد زیادی موجودیت با وضعیت خودشان: سبد خرید، بازیکن، دستگاه.
- خواندن و نوشتن زیاد روی هر موجودیت.
- تیم .NET است و میخواهد کلاستر بدون کد پیچیده داشته باشد.
نامناسب
- برنامه CRUD معمولی با بار کم.
- منطق اصلی کوئری و گزارش روی کل داده است.
- همه کارها به یک وضعیت سراسری مشترک نیاز دارند.
خلاصه در شش خط
- هر Grain یک Actor مجازی با شناسه است. همیشه وجود دارد و Orleans خودش آن را فعال میکند.
- هر Grain در کلاستر معمولاً یک نسخه فعال دارد و درخواستها را یکییکی اجرا میکند.
- داخل Grain قفل لازم نیست.
- اگر دو Grain با await منتظر هم باشند، بنبست میسازند.
- اول چرخه را در طراحی حذف کن. Reentrant کردن کل Grain آخرین راه است.
- یک Grain سراسری گلوگاه است. وضعیت را تقسیم کن.