Levelwise
فارسی
Actor Model

Microsoft Orleans

در Orleans هر موجودیت یک Grain است، یعنی یک Actor مجازی که همیشه وجود دارد. Orleans خودش آن را روی یکی از سرورها فعال می‌کند، پیام‌هایش را یکی‌یکی اجرا می‌کند و وقتی بیکار شد از حافظه خارج می‌کند. بزرگ‌ترین تله آن، بن‌بست در فراخوانی‌های چرخه‌ای است.

بازبینی نشدهبا کمک AI نوشته شدهزمان خواندن: ۱۵ دقیقهمثال سبد خرید فروشگاه اینترنتیکد C# و .NET 10

نویسنده: bezzad

مشکل: میلیون‌ها سبد خرید

فروشگاه اینترنتی ما در روز حراج یک میلیون کاربر فعال دارد. هر کاربر یک سبد خرید دارد. سبد خرید هر چند ثانیه تغییر می‌کند: اضافه کردن کالا، تغییر تعداد، اعمال کد تخفیف.

راه معمول این است که هر تغییر را مستقیم در دیتابیس بنویسیم. ولی:

  1. دیتابیس زیر بار می‌رود. هر کلیک یعنی یک خواندن و یک نوشتن.
  2. همزمانی دردسر دارد. کاربر در دو تب همزمان کالا اضافه می‌کند و یکی از تغییرها گم می‌شود.
  3. کش کردن سخت است. اگر سبد را در حافظه یک سرور نگه داریم، درخواست بعدی شاید به سرور دیگری برود.

ایده Actor اینجا کمک می‌کند: یک Actor برای هر سبد. ولی چه کسی میلیون‌ها Actor را بسازد، روی سرورها پخش کند و بعد از restart دوباره بسازد؟ Orleans همین کار را می‌کند.

ایده اصلی: Actor مجازی

در Orleans به هر Actor یک Grain می‌گوییم. به هر سرور هم یک Silo. چند Silo با هم یک کلاستر می‌سازند.

سرویس وبGetGrain("ali")کلاستر سرورهاSilo 1CartGrain "ali"CartGrain "sara"StockGrain "p-7"Silo 2CartGrain "reza"StockGrain "p-2"ذخیره دائمی وضعیتکلاینت نمی‌دانداین شیء روی کدام سرور است
سرویس وب فقط می‌گوید «سبد علی». Orleans می‌داند این Grain الان روی Silo 1 است.

فرق بزرگ Orleans با Actor های کلاسیک این است: Grain همیشه وجود دارد. نه ساختن لازم است و نه از بین بردن.

  1. هر Grain یک شناسه دارد. مثلاً سبد خرید علی.
  2. کد فقط شناسه را می‌خواهد. اگر Grain در حافظه نباشد، Orleans همان لحظه آن را روی یکی از Silo ها فعال می‌کند.
  3. هر Grain در هر لحظه معمولاً فقط یک نسخه فعال در کل کلاستر دارد. پس همه درخواست‌های سبد علی به یک جا می‌روند.
  4. اگر 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);
در Production: به جای کلاستر محلی و حافظه، یک سرویس برای عضویت در کلاستر و یک دیتابیس برای وضعیت لازم است. Orleans برای چند گزینه مثل Azure Storage، Redis و ADO.NET بسته آماده دارد.

تک‌نخی بودن: هم مزیت، هم تله

هر Grain به طور پیش‌فرض Non-Reentrant است. یعنی:

  1. یک درخواست شروع می‌شود.
  2. اگر وسط کار await کند، Grain درخواست دیگری را شروع نمی‌کند. صبر می‌کند تا همین درخواست کامل تمام شود.
  3. پس بین دو await، هیچ درخواست دیگری وضعیت را عوض نمی‌کند.

این عالی است: داخل Grain هیچ قفلی لازم نیست. ولی یک تله بزرگ هم دارد.

بن‌بست: وقتی دو Grain منتظر هم هستند

سبد علی موقع تسویه حساب، از Grain تخفیف می‌پرسد «تخفیف این سبد چقدر است؟». Grain تخفیف برای محاسبه، اقلام سبد را لازم دارد. پس دوباره سبد علی را صدا می‌زند.

CartGrain "ali"در حال اجرا: تسویه حسابدر صف: خواندن اقلامDiscountGrainدر حال اجرا: محاسبه تخفیف۱. منتظر جواب تخفیف۲. اقلام سبد را می‌خواهد۳. درخواست دوم پشت درخواست اول منتظر استبعد از زمان انتظار پیش‌فرض، هر دو با خطا تمام می‌شوند
درخواست دوم سبد پشت درخواست اول منتظر است. درخواست اول هم منتظر جواب تخفیف است. چرخه کامل شد.
// 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;
}

قدم به قدم:

  1. سبد درخواست Checkout را شروع می‌کند و منتظر جواب تخفیف می‌ماند.
  2. از نظر Orleans، Checkout هنوز تمام نشده است. پس درخواست دیگری به سبد داده نمی‌شود.
  3. تخفیف درخواست GetItems را به سبد می‌فرستد. این درخواست در صف، پشت Checkout می‌ماند.
  4. حالا یک چرخه داریم. Checkout منتظر تخفیف است، تخفیف منتظر GetItems، و GetItems منتظر تمام شدن Checkout.
  5. هیچ کدام جلو نمی‌روند. بعد از زمان انتظار پیش‌فرض (سی ثانیه)، فراخوانی با خطای Timeout شکست می‌خورد.

راه‌ها، از بهتر به بدتر

  1. چرخه را در طراحی حذف کن. سبد، اقلامش را همراه درخواست بفرستد. دیگر تماس برگشتی لازم نیست.
var discount = await discounts.Calculate(cart.State.Items.AsReadOnly());
  1. اجازه ورود دوباره فقط برای همین زنجیره. متد AllowCallChainReentrancy از کلاس RequestContext فقط به درخواست‌هایی که از همین زنجیره برمی‌گردند اجازه ورود می‌دهد.
using var scope = RequestContext.AllowCallChainReentrancy();
var discount = await discounts.Calculate(this.GetPrimaryKeyString());
  1. متدهای فقط‌خواندنی را علامت بزن. با attribute های ReadOnly یا AlwaysInterleave روی متد GetItems در رابط، این متد می‌تواند وسط کارهای دیگر اجرا شود.
  2. کل Grain را Reentrant کن. ساده‌ترین راه است، ولی خطرناک‌ترین هم هست. بین دو await، درخواست دیگری می‌تواند وضعیت را عوض کند. همان Race Condition که از آن فرار کرده بودیم برمی‌گردد.
نشانه خطر: اگر اولین راه‌حل تو برای هر بن‌بست، Reentrant کردن کل Grain است، مزیت اصلی Orleans را دور ریخته‌ای.

Grain داغ

فرض کن یک Grain داریم که تعداد کل سفارش‌های امروز را می‌شمارد. همه سفارش‌ها آن را صدا می‌زنند. با اضافه کردن Silo هم سریع‌تر نمی‌شود. چرا؟

  1. این Grain فقط یک نسخه فعال دارد، روی یک Silo.
  2. درخواست‌ها را یکی‌یکی اجرا می‌کند.
  3. پس 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 معمولی با بار کم.
  • منطق اصلی کوئری و گزارش روی کل داده است.
  • همه کارها به یک وضعیت سراسری مشترک نیاز دارند.

خلاصه در شش خط

  1. هر Grain یک Actor مجازی با شناسه است. همیشه وجود دارد و Orleans خودش آن را فعال می‌کند.
  2. هر Grain در کلاستر معمولاً یک نسخه فعال دارد و درخواست‌ها را یکی‌یکی اجرا می‌کند.
  3. داخل Grain قفل لازم نیست.
  4. اگر دو Grain با await منتظر هم باشند، بن‌بست می‌سازند.
  5. اول چرخه را در طراحی حذف کن. Reentrant کردن کل Grain آخرین راه است.
  6. یک Grain سراسری گلوگاه است. وضعیت را تقسیم کن.