همزمانی خوش‌بینانه و بدبینانه در EF Core با مثال موجودی کیف‌پول

پاسخ کوتاه: وقتی دو درخواست یک موجودی را می‌خوانند و هر دو نتیجه می‌گیرند «پول کافی هست»، race condition دارید و علامت کلاسیکش پولی است که ظاهر یا ناپدید می‌شود. EF Core سه ابزار به شما می‌دهد: همزمانی خوش‌بینانه (توکن rowversion باعث می‌شود ذخیره دوم شکست بخورد تا دوباره تلاش کنید)، به‌روزرسانی شرطی اتمیک (بررسی و نوشتن در یک دستور SQL) و قفل بدبینانه (ردیف قفل می‌شود و درخواست دوم منتظر می‌ماند). انتخاب به این بستگی دارد که بین خواندن و نوشتن چقدر منطق هست.

یک مثال واقعی: برداشت دوباره

یک کیف‌پول ۱۰۰ موجودی دارد. دو درخواست برداشت ۸۰ همزمان می‌رسند؛ شاید دوبار کلیک، شاید تلاش مجدد یک اپ موبایل روی اینترنت ضعیف. هر درخواست کار بدیهی را انجام می‌دهد:

  1. موجودی را می‌خواند (۱۰۰)،
  2. بررسی می‌کند که ۸۰ را پوشش می‌دهد (می‌دهد)،
  3. می‌نویسد ۱۰۰ − ۸۰ = ۲۰.

هر دو درخواست قبل از این‌که یکی به گام ۳ برسد، از گام ۲ رد می‌شوند. هر دو موفق‌اند. کاربر از کیف‌پولی که ۱۰۰ داشت ۱۶۰ برداشته و موجودی ذخیره‌شده ۲۰ را نشان می‌دهد. نه استثنایی، نه لاگی: کد دقیقاً همان کاری را کرد که به آن گفته شده بود.

تراکنش به‌تنهایی کمکی نمی‌کند. در سطح ایزولیشن پیش‌فرض READ COMMITTED در SQL Server، هر دو تراکنش می‌توانند همان موجودی commit‌شده را بخوانند. محافظت باید از جای دیگری بیاید.

کد قابل اجرا

یک Wallet با rowversion در SQL Server. EF Core آن را به شرط WHERE هر UPDATE اضافه می‌کند:

using System.ComponentModel.DataAnnotations;
using System.Data;
using Microsoft.EntityFrameworkCore;

public sealed class Wallet
{
    public Guid Id { get; set; }

    public long BalanceRials { get; set; }

    [Timestamp]
    public byte[] Version { get; set; } = [];
}

public sealed class WalletDb(DbContextOptions<WalletDb> options) : DbContext(options)
{
    public DbSet<Wallet> Wallets => Set<Wallet>();
}

public sealed class InsufficientFundsException(Guid walletId) : Exception($"Wallet {walletId} has insufficient funds.");

۱. خوش‌بینانه: تشخیص تعارض و تلاش مجدد

public async Task WithdrawOptimisticAsync(Guid walletId, long amount, CancellationToken ct)
{
    for (var attempt = 1; ; attempt++)
    {
        var wallet = await db.Wallets.SingleAsync(w => w.Id == walletId, ct);
        if (wallet.BalanceRials < amount)
        {
            throw new InsufficientFundsException(walletId);
        }

        wallet.BalanceRials -= amount;
        try
        {
            await db.SaveChangesAsync(ct);
            return;
        }
        catch (DbUpdateConcurrencyException) when (attempt < 3)
        {
            // Someone changed the row since we read it: forget our copy and re-read.
            db.ChangeTracker.Clear();
        }
    }
}

UPDATE فقط وقتی ردیف را پیدا می‌کند که Version هنوز همانی باشد که خواندیم. اگر درخواست دیگری زودتر رسیده باشد، هیچ ردیفی تطبیق نمی‌خورد، EF Core استثنای DbUpdateConcurrencyException می‌دهد و ما موجودی جدید را دوباره می‌خوانیم و بررسی می‌کنیم.

۲. اتمیک: بررسی را بخشی از نوشتن کنید

public async Task<bool> TryWithdrawAtomicAsync(Guid walletId, long amount, CancellationToken ct)
{
    var updated = await db.Wallets
        .Where(w => w.Id == walletId && w.BalanceRials >= amount)
        .ExecuteUpdateAsync(s => s.SetProperty(w => w.BalanceRials, w => w.BalanceRials - amount), ct);

    return updated == 1;
}

این به یک دستور UPDATE … SET BalanceRials = BalanceRials - @amount WHERE Id = @id AND BalanceRials >= @amount تبدیل می‌شود. دیتابیس شرط و نوشتن را زیر یک قفل ردیف انجام می‌دهد، پس فاصله‌ای بین «بررسی» و «نوشتن» برای race وجود ندارد. اگر موجودی کم باشد، هیچ ردیفی تغییر نمی‌کند و false برمی‌گردد.

۳. بدبینانه: اول ردیف را قفل کنید

public async Task WithdrawPessimisticAsync(Guid walletId, long amount, CancellationToken ct)
{
    await using var transaction = await db.Database.BeginTransactionAsync(IsolationLevel.ReadCommitted, ct);

    var wallet = await db.Wallets
        .FromSql($"SELECT * FROM Wallets WITH (UPDLOCK, ROWLOCK) WHERE Id = {walletId}")
        .SingleAsync(ct);

    if (wallet.BalanceRials < amount)
    {
        throw new InsufficientFundsException(walletId);
    }

    wallet.BalanceRials -= amount;
    await db.SaveChangesAsync(ct);
    await transaction.CommitAsync(ct);
}

UPDLOCK تا پایان تراکنش روی ردیف قفل به‌روزرسانی می‌گیرد؛ درخواست دومی که همین SELECT را اجرا کند، تا commit اولی منتظر می‌ماند و بعد موجودی جدید را می‌خواند. FromSql با رشته interpolated همچنان walletId را به‌صورت پارامتر می‌فرستد؛ پس این چسباندن رشته نیست.

زیر بار ثابتش کنید

به هیچ‌کدام از این‌ها کورکورانه اعتماد نکنید. یک کیف‌پول با موجودی ۱۰۰ بسازید، ۲۰ برداشت ۱۰تایی را موازی روی یک SQL Server واقعی بفرستید و دو چیز را بررسی کنید: دقیقاً ۱۰ تا موفق شوند و موجودی نهایی ۰ باشد، هرگز منفی. همین کار را با نسخه ساده «بخوان، بررسی کن، بنویس» تکرار کنید و ببینید وضعیت نهایی دیگر جور درنمی‌آید. providerهای درون‌حافظه‌ای قفل نمی‌گیرند و rowversion را بررسی نمی‌کنند؛ پس روی SQL Server تست کنید (یک کانتینر Docker کافی است).

کدام، کی؟

وضعیت انتخاب
یک برداشت یا واریز ساده که یک UPDATE بیانش می‌کند به‌روزرسانی شرطی اتمیک
چند فیلد طبق قواعدی که در C# حساب می‌شوند تغییر می‌کنند خوش‌بینانه (rowversion) + تلاش مجدد
تلاش مجدد گران است یا اثر جانبی دارد (صدا زدن درگاه، ارسال پیام) قفل بدبینانه، کوتاه
رقابت زیاد روی یک ردیف داغ به‌روزرسانی اتمیک؛ از قفل طولانی پرهیز کنید

مزایا و معایب

خوش‌بینانه: در زمان فکر کردن قفلی نگه داشته نمی‌شود؛ وقتی تعارض کم است خوب مقیاس می‌پذیرد. ولی باید استثنا را مدیریت و دوباره تلاش کنید، و زیر رقابت شدید تلاش‌ها روی هم انباشته می‌شوند.

به‌روزرسانی اتمیک: ساده‌ترین گزینه درست؛ یک رفت‌وبرگشت و چیزی برای تکرار نیست. ولی فقط برای قواعدی مناسب است که SQL بیانشان می‌کند، و change tracking را دور می‌زند؛ پس entityهای درون حافظه ممکن است بعدش کهنه باشند.

بدبینانه: فهمیدنش آسان است چون درخواست دوم فقط منتظر می‌ماند. ولی قفل طولانی throughput را پایین می‌آورد و اگر ردیف‌ها با ترتیب متفاوت قفل شوند deadlock ممکن است. تراکنش را کوچک نگه دارید.

یک نکته درباره موجودی

هر سه روش از یک ستون موجودی محافظت می‌کنند. در طراحی مبتنی بر لجر، موجودی از تراکنش‌های ثبت‌شده به دست می‌آید و کنترل همزمانی به این تبدیل می‌شود که «دو برداشت نتوانند هر دو از بررسی موجودی رد شوند». همین سه ابزار آنجا هم کار می‌کنند.


کیف‌پول یا جریان پرداختی می‌سازید و می‌خواهید بخش همزمانی‌اش بررسی شود؟ ببینید چطور می‌توانم کمک کنم.

پرسش‌های متداول

آیا تراکنش دیتابیس به‌تنهایی جلوی race را می‌گیرد؟

نه. در سطح پیش‌فرض READ COMMITTED در SQL Server، دو تراکنش می‌توانند قبل از این‌که یکی بنویسد، هر دو یک موجودی را بخوانند. یا concurrency token لازم است، یا به‌روزرسانی شرطی اتمیک، یا قفلی که خواننده دوم را منتظر نگه دارد.

برای کیف‌پول کدام روش را انتخاب کنم؟

برای برداشت‌های ساده با به‌روزرسانی شرطی اتمیک شروع کنید؛ چون بررسی و نوشتن یک دستورند، race ممکن نیست. اگر تغییر منطقی بیش از یک UPDATE دارد rowversion، و اگر تکرار تلاش گران یا دارای اثر جانبی است قفل بدبینانه.

چطور مطمئن شوم کدم واقعاً امن است؟

تعداد زیادی برداشت را به‌صورت موازی روی یک دیتابیس واقعی (نه provider درون‌حافظه‌ای) اجرا کنید و بررسی کنید موجودی نهایی هیچ‌وقت منفی نشود و تعداد موفق‌ها دقیقاً همانی باشد که موجودی اجازه می‌دهد.