مقالات › EF Core و دیتابیس

تراکنش و هم‌زمانی در EF Core؛ قفل خوش‌بینانه، سطح ایزولاسیون و Race Condition

داده را زیر بار سازگار نگه دارید: تراکنش در SaveChanges، تراکنش صریح، Execution Strategy، هم‌زمانی خوش‌بینانه با RowVersion، سطوح ایزولاسیون و شمارنده‌های امن.

دو کاربر هم‌زمان آخرین کالای موجود در انبار را می‌خرند. دو مدیر یک مقاله را ویرایش می‌کنند و یکی بی‌صدا تغییرات دیگری را بازنویسی می‌کند. یک انتقال وجه از یک حساب کم می‌کند اما قبل از واریز به حساب دیگر از کار می‌افتد. این‌ها مشکلات سازگاری داده هستند و فقط در هم‌زمانی واقعی ظاهر می‌شوند؛ به همین دلیل از تست‌ها جان سالم به در می‌برند. این راهنما نشان می‌دهد EF Core و SQL Server چطور کمک می‌کنند جلوی آن‌ها را بگیرید.

تراکنش: همه یا هیچ

تراکنش عملیات را گروه‌بندی می‌کند تا یا همه موفق شوند یا همه شکست بخورند (اتمی بودن) و کاربران دیگر کار نیمه‌تمام را نبینند (ایزولاسیون).

SaveChanges خودش تراکنشی است

var order = new Order { CustomerId = 7 };
order.Items.Add(new OrderItem { ProductId = 3, Quantity = 2 });
db.Orders.Add(order);

product.Stock -= 2;

await db.SaveChangesAsync();   // INSERT سفارش، INSERT آیتم‌ها، UPDATE محصول — یک تراکنش

اگر هر دستوری شکست بخورد، همه‌چیز Rollback می‌شود. برای بیشتر Use Caseها یک SaveChanges برای هر عملیات بیزینسی همه‌ی چیزی است که لازم دارید.

تراکنش صریح

وقتی یک عملیات چند فراخوانی SaveChanges، SQL خام یا عملیات گروهی را در بر می‌گیرد، از تراکنش صریح استفاده کنید:

await using var tx = await db.Database.BeginTransactionAsync(ct);

var from = await db.Accounts.SingleAsync(a => a.Id == fromId, ct);
var to   = await db.Accounts.SingleAsync(a => a.Id == toId, ct);

from.Balance -= amount;
to.Balance   += amount;
await db.SaveChangesAsync(ct);

await db.Transfers.Where(t => t.Status == TransferStatus.Pending && t.AccountId == fromId)
    .ExecuteUpdateAsync(s => s.SetProperty(t => t.Status, TransferStatus.Done), ct);

await tx.CommitAsync(ct);   // بدون Commit، با Dispose شدن تراکنش Rollback انجام می‌شود

تراکنش‌ها را کوتاه نگه دارید: هرگز داخل تراکنش API خارجی صدا نزنید، ایمیل نفرستید یا منتظر ورودی کاربر نمانید. تراکنش‌های طولانی قفل نگه می‌دارند و درخواست‌های دیگر را مسدود می‌کنند.

تراکنش همراه با استراتژی تلاش مجدد

با EnableRetryOnFailure (رایج برای دیتابیس‌های ابری)، کل واحد کار را در یک Execution Strategy بپیچید تا یک خطای گذرا کل تراکنش را دوباره اجرا کند:

var strategy = db.Database.CreateExecutionStrategy();

await strategy.ExecuteAsync(async () =>
{
    await using var tx = await db.Database.BeginTransactionAsync(ct);
    // ... کار
    await db.SaveChangesAsync(ct);
    await tx.CommitAsync(ct);
});

تعارض هم‌زمانی: مشکل گم شدن تغییرات

10:00:00  مدیر A مقاله‌ی ۵ را بارگذاری می‌کند (Title = "Old")
10:00:05  مدیر B مقاله‌ی ۵ را بارگذاری می‌کند (Title = "Old")
10:00:30  A ذخیره می‌کند: Title = "Version A"
10:00:40  B ذخیره می‌کند: Excerpt = "New excerpt"  ← و Title = "Old" را هم دوباره می‌نویسد!

ذخیره‌ی B بی‌صدا تغییر A را بازنویسی کرد. این همان مشکل Lost Update است.

هم‌زمانی خوش‌بینانه با RowVersion

هم‌زمانی خوش‌بینانه فرض می‌کند تعارض‌ها نادرند، چیزی را قفل نمی‌کند اما تعارض را هنگام ذخیره تشخیص می‌دهد. در SQL Server، ستون rowversion با هر به‌روزرسانی خودکار تغییر می‌کند:

public class Article
{
    public int Id { get; set; }
    public string Title { get; set; } = "";

    [Timestamp]                          // نگاشت به rowversion در SQL Server
    public byte[] RowVersion { get; set; } = [];
}

حالا EF Core نسخه را به بخش WHERE اضافه می‌کند:

UPDATE Articles SET Title = @p0
WHERE Id = @p1 AND RowVersion = @p2;

اگر کس دیگری ردیف را تغییر داده باشد، هیچ ردیفی منطبق نمی‌شود و EF Core خطای DbUpdateConcurrencyException می‌دهد:

try
{
    await db.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException ex)
{
    var entry = ex.Entries.Single();
    var databaseValues = await entry.GetDatabaseValuesAsync(ct);

    if (databaseValues is null)
        return NotFound("The article was deleted by another user.");

    // گزینه‌ی ۱: به کلاینت بگویید دوباره بارگذاری و تلاش کند (رایج‌ترین برای UI)
    return Conflict("This article was modified by someone else. Please reload.");

    // گزینه‌ی ۲: «کلاینت برنده است» — مقادیر اصلی را تازه و دوباره ذخیره کنید
    // entry.OriginalValues.SetValues(databaseValues);
    // await db.SaveChangesAsync(ct);
}

رفت‌وبرگشت نسخه از طریق API

در Web APIها نسخه را برای کلاینت بفرستید و آن را دوباره بخواهید:

public record ArticleDto(int Id, string Title, string Version);        // rowversion به Base64
public record UpdateArticle(string Title, string Version);

var article = await db.Articles.FindAsync([id], ct);
db.Entry(article!).Property(a => a.RowVersion).OriginalValue = Convert.FromBase64String(cmd.Version);
article!.Title = cmd.Title;
await db.SaveChangesAsync(ct);   // اگر نسخه کهنه باشد خطا می‌دهد

این به‌طور طبیعی با HTTP جور است: نسخه می‌تواند یک ETag باشد که در If-Match برگردانده می‌شود و تعارض به 412 Precondition Failed یا 409 Conflict تبدیل می‌شود.

Race Condition روی شمارنده‌ها و موجودی

خواندن-تغییر-نوشتن در کد برنامه در شرایط هم‌زمانی ناامن است:

// ❌ دو درخواست Stock = 1 را می‌خوانند، هر دو یکی کم می‌کنند و ذخیره می‌کنند ← موجودی صفر، اما دو کالا فروخته شد
var product = await db.Products.FindAsync(id);
if (product.Stock >= qty) { product.Stock -= qty; await db.SaveChangesAsync(); }

بگذارید دیتابیس آن را اتمی انجام دهد:

// ✅ یک دستور اتمی همراه با شرط محافظ
var updated = await db.Products
    .Where(p => p.Id == id && p.Stock >= qty)
    .ExecuteUpdateAsync(s => s.SetProperty(p => p.Stock, p => p.Stock - qty), ct);

if (updated == 0)
    return Conflict("Not enough stock.");

همین الگو برای شمارنده‌ی بازدید، لایک و سهمیه‌ها کار می‌کند. راه دیگر: Stock را به‌عنوان Concurrency Token علامت بزنید (.IsConcurrencyToken()) و در صورت تعارض دوباره تلاش کنید.

محدودیت یکتا بهتر از «بررسی و بعد درج»

// ❌ Race: دو درخواست هر دو «وجود ندارد» را می‌بینند و هر دو درج می‌کنند
if (!await db.Users.AnyAsync(u => u.Email == email)) db.Users.Add(newUser);

یک ایندکس یکتا دیتابیس را مجبور به اعمال قانون می‌کند؛ خطای نقض را بگیرید و پیام دوستانه برگردانید. اعتبارسنجی در حالت عادی پیام خوبی می‌دهد و محدودیت در Race نادر، درستی را تضمین می‌کند.

سطوح ایزولاسیون

ایزولاسیون کنترل می‌کند تراکنش‌های هم‌زمان چه چیزی را می‌بینند:

سطح جلوگیری از توضیح
Read Uncommitted هیچ خواندن کثیف — پرهیز کنید
Read Committed خواندن کثیف پیش‌فرض SQL Server
Repeatable Read خواندن غیرتکرارپذیر قفل‌های اشتراکی را بیشتر نگه می‌دارد
Serializable Phantom قوی‌ترین و پرانسدادترین
Snapshot / RCSI خواننده نویسنده را مسدود نمی‌کند نسخه‌بندی ردیف در tempdb

بسیاری از تیم‌ها Read Committed Snapshot Isolation (RCSI) را در SQL Server فعال می‌کنند تا خواندن هرگز نوشتن را مسدود نکند:

ALTER DATABASE Portfolio SET READ_COMMITTED_SNAPSHOT ON;

سطوح قوی‌تر را فقط برای عملیات مشخصی که لازمشان دارند انتخاب کنید:

await using var tx = await db.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct);

قفل بدبینانه (به‌ندرت لازم است)

وقتی تعارض زیاد است و تلاش مجدد گران، می‌توانید داخل یک تراکنش با Hintهای SQL خام (WITH (UPDLOCK, ROWLOCK)) ردیف‌ها را صریح قفل کنید. کم از آن استفاده کنید؛ توان پردازشی را کم می‌کند و می‌تواند Deadlock ایجاد کند.

چک‌لیست

  • در صورت امکان یک SaveChanges برای هر عملیات بیزینسی
  • تراکنش‌های صریح و کوتاه برای عملیات چندمرحله‌ای
  • پیچیدن تراکنش در Execution Strategy وقتی تلاش مجدد فعال است
  • RowVersion برای موجودیت‌هایی که چند کاربر ویرایششان می‌کنند
  • ExecuteUpdate اتمی برای شمارنده‌ها و موجودی
  • محدودیت یکتا برای قوانین یکتایی
  • در نظر گرفتن RCSI برای کاهش انسداد

جمع‌بندی

SaveChanges خودش اتمی است؛ فقط وقتی یک عملیات بیزینسی چند مرحله دارد تراکنش صریح اضافه کنید و آن را کوتاه نگه دارید. با هم‌زمانی خوش‌بینانه (RowVersion + DbUpdateConcurrencyException) جلوی گم شدن تغییرات را بگیرید، تغییر شمارنده‌ها و موجودی را در SQL اتمی کنید، به‌جای «بررسی و بعد درج» به محدودیت یکتا تکیه کنید و سطوح ایزولاسیون را آگاهانه انتخاب کنید. این الگوها داده‌ی شما را وقتی ترافیک واقعی می‌رسد درست نگه می‌دارند.

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

هم‌زمانی خوش‌بینانه یا بدبینانه؟

خوش‌بینانه برای بیشتر وب‌اپلیکیشن‌ها؛ تعارض‌ها نادرند و چیزی قفل نمی‌شود. بدبینانه فقط برای ردیف‌های داغ با تعارض زیاد.

آیا EF Core برای خواندن هم از تراکنش استفاده می‌کند؟

برای کوئری‌ها تراکنش صریح لازم نیست؛ هر کوئری با ایزولاسیون پیش‌فرض دیتابیس اجرا می‌شود.

آیا می‌توانم RowVersion را با SQLite یا PostgreSQL استفاده کنم؟

آن‌ها rowversion مخصوص SQL Server را ندارند، اما می‌توانید یک Property به‌عنوان Concurrency Token تعریف و خودتان به‌روز کنید (یا در PostgreSQL از ستون سیستمی xmin از طریق Provider مربوط به Npgsql استفاده کنید).