تراکنش و سطح جداسازی (Isolation Level)
تراکنش چند تغییر را با هم ثبت میکند یا هیچکدام را. ولی تراکنش به تنهایی جلوی همه مشکلهای همزمانی را نمیگیرد. باید بدانی Lost Update و Deadlock از کجا میآیند و با آپدیت اتمی، بررسی نسخه و ترتیب ثابت قفلها حلشان کنی.
نویسنده: bezzad
مشکل: دو درخواست، یک ردیف
در فروشگاه ما فقط یک گوشی در انبار مانده است. دو مشتری در یک لحظه روی «خرید» میزنند. کد این است:
var product = await db.Products.FirstAsync(p => p.Id == productId, ct);
if (product.Stock > 0)
{
product.Stock -= 1;
await db.SaveChangesAsync(ct);
}
قدم به قدم ببین چه میشود:
- درخواست اول موجودی را میخواند: ۱.
- درخواست دوم هم موجودی را میخواند: ۱.
- هر دو شرط را درست میبینند.
- هر دو موجودی را صفر میکنند و ذخیره میکنند.
- یک گوشی را دو بار فروختیم.
جالب اینکه SaveChangesAsync خودش در یک تراکنش اجرا میشود. پس مشکل «نبودن تراکنش» نیست. مشکل فاصله بین خواندن و نوشتن است. به این Race Condition میگویند.
تراکنش چه تضمینی میدهد؟
تراکنش چهار تضمین دارد که به آنها ACID میگویند:
- اتمی بودن (Atomicity). همه تغییرها با هم ثبت میشوند یا هیچکدام. اگر کم کردن موجودی موفق شد ولی ثبت سفارش نه، هر دو برمیگردند.
- سازگاری (Consistency). قانونهای دیتابیس (کلید خارجی، یکتایی، شرطها) بعد از تراکنش هم درست هستند.
- جداسازی (Isolation). تراکنشهای همزمان تا حدی از هم جدا هستند. «تا چه حد» را سطح جداسازی تعیین میکند.
- ماندگاری (Durability). وقتی ثبت شد، با قطع برق هم از بین نمیرود.
مشکلهای همزمانی همه از حرف سوم میآیند. جداسازی کامل گران است، پس دیتابیسها به صورت پیشفرض جداسازی کامل ندارند.
سطحهای جداسازی
هر سطح جلوی بعضی از این مشکلها را میگیرد:
- خواندن داده ثبتنشده (Dirty Read). تراکنش من تغییری را میبیند که تراکنش دیگر هنوز ثبت نکرده و شاید برگرداند.
- خواندن دوباره، جواب دیگر (Non-Repeatable Read). یک ردیف را دو بار میخوانم و دو مقدار مختلف میبینم.
- ردیف تازه (Phantom). یک کوئری را دو بار اجرا میکنم و بار دوم ردیفهای تازهای میبینم.
چرا همیشه بالاترین سطح را انتخاب نکنیم؟ چون سطح بالاتر یعنی قفل بیشتر یا رد شدن بیشتر تراکنشها. پس انتظار، Deadlock و تلاش دوباره بیشتر میشود.
قفل یا نسخهبندی؟
دیتابیسها برای جداسازی دو ابزار دارند:
- قفل. نویسنده ردیف را قفل میکند. خواننده باید صبر کند.
- نسخهبندی ردیف (MVCC). نویسنده یک نسخه جدید میسازد. خواننده نسخه قبلیِ ثبتشده را میخواند و صبر نمیکند.
دیتابیس PostgreSQL همیشه نسخهبندی دارد. در SQL Server، سطح Read Committed به صورت پیشفرض با قفل کار میکند. با گزینه Read Committed Snapshot (RCSI) نسخهبندی روشن میشود و خواندن و نوشتن دیگر هم را بلاک نمیکنند. ولی دو نوشتن روی یک ردیف هنوز منتظر هم میمانند.
راه حل اول: آپدیت اتمی
برگردیم به خرید گوشی. اگر چک کردن و کم کردن در یک دستور باشد، فاصلهای نمیماند. دیتابیس یک دستور UPDATE را اتمی اجرا میکند:
var updated = await db.Products
.Where(p => p.Id == productId && p.Stock > 0)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.Stock, p => p.Stock - 1), ct);
if (updated == 0)
return Results.Conflict("Out of stock");
چرا درست است؟
- دستور UPDATE ردیف را قفل میکند، شرط را چک میکند و کم میکند. همه با هم.
- درخواست دوم منتظر قفل میماند. بعد شرط را با موجودی جدید (صفر) چک میکند.
- شرط درست نیست، پس هیچ ردیفی تغییر نمیکند و عدد صفر برمیگردد.
این سادهترین و سریعترین راه برای شمارندهها و موجودی است.
راه حل دوم: بررسی نسخه (Optimistic Concurrency)
حالا یک مشکل دیگر. در پنل پشتیبانی، دو کارمند صفحه یک سفارش را باز میکنند. کارمند الف آدرس را عوض میکند و ذخیره میزند. چند ثانیه بعد، کارمند ب وضعیت را عوض میکند و ذخیره میزند. فرم ب کل سفارش را میفرستد، با آدرس قدیمی.
دکمهها را بزن و ببین چه میشود:
فرم کارمند الف
دیتابیس
فرم کارمند ب
به این Lost Update میگویند. آخرین نوشتن برنده شد و تغییر الف بیصدا گم شد. اینجا تراکنش هیچ کمکی نمیکند:
- اینها دو درخواست جدا هستند، با چند دقیقه فاصله.
- هر ذخیره به تنهایی درست و موفق است.
- مشکل این است که ب با داده کهنه ذخیره میکند.
راه حل، یک ستون نسخه است. در SQL Server، ستون rowversion با هر تغییر ردیف خودکار عوض میشود:
public class Order
{
public int Id { get; set; }
public string ShippingAddress { get; set; } = "";
public OrderStatus Status { get; set; }
[Timestamp] public byte[] RowVersion { get; set; } = [];
}
فرم هنگام باز شدن نسخه را میگیرد و هنگام ذخیره پس میفرستد:
app.MapPut("/orders/{id:int}", async (int id, OrderForm form, ShopDb db, CancellationToken ct) =>
{
var order = await db.Orders.FindAsync([id], ct);
if (order is null) return Results.NotFound();
// Compare with the version the clerk saw, not the one we just read
db.Entry(order).Property(o => o.RowVersion).OriginalValue = form.RowVersion;
order.ShippingAddress = form.ShippingAddress;
order.Status = form.Status;
try
{
await db.SaveChangesAsync(ct);
return Results.NoContent();
}
catch (DbUpdateConcurrencyException)
{
return Results.Conflict("Someone else changed this order. Reload it.");
}
});
چطور کار میکند؟
- کتابخانه EF Core به دستور UPDATE یک شرط اضافه میکند: «فقط اگر نسخه هنوز همان است».
- ذخیره الف نسخه را عوض کرده است. پس شرط برای ب درست نیست و هیچ ردیفی تغییر نمیکند.
- کتابخانه EF Core خطای DbUpdateConcurrencyException میدهد و ما کد ۴۰۹ برمیگردانیم.
- فرم به ب میگوید سفارش را دوباره باز کند. اینکه بعد چه کنیم، تصمیم کسبوکار است.
قفل خوشبینانه (Optimistic)
- قفلی نگه نمیدارد. موقع ذخیره نسخه را چک میکند.
- برای فرمهایی که کاربر چند دقیقه باز نگه میدارد مناسب است.
- وقتی برخورد کم است، بهترین انتخاب است.
قفل بدبینانه (Pessimistic)
- ردیف را موقع خواندن قفل میکند (در SQL Server با UPDLOCK و در PostgreSQL با FOR UPDATE).
- فقط داخل یک تراکنش کوتاه معنی دارد.
- برای فرم باز در مرورگر مناسب نیست. اگر کاربر مرورگر را ببندد، قفل چه میشود؟
بنبست (Deadlock)
حالا دو سفارش همزمان میرسد. هر سفارش در یک تراکنش، موجودی کالاهایش را یکییکی کم میکند:
- سفارش اول: اول گوشی، بعد قاب.
- سفارش دوم: اول قاب، بعد گوشی.
دیتابیس این دور را تشخیص میدهد و یکی از تراکنشها را قربانی میکند. راهها:
- ترتیب ثابت قفلها. کالاها را همیشه به ترتیب شماره مرتب کن. حالا هر دو تراکنش اول سراغ گوشی میروند. دومی فقط صبر میکند و بنبستی نیست.
- تراکنش کوتاه. فراخوانی HTTP، ارسال ایمیل و محاسبه سنگین نباید داخل تراکنش باشد. قفل کوتاهتر یعنی برخورد کمتر.
- ایندکس درست. بدون ایندکس، دیتابیس برای پیدا کردن ردیف، ردیفهای بیشتری را میخواند و قفل میکند.
- تلاش دوباره. بنبست هیچ وقت کاملاً صفر نمیشود. کل تراکنش را دوباره اجرا کن.
- پیدا کردن علت. در SQL Server گزارش Deadlock Graph و در PostgreSQL لاگ دیتابیس نشان میدهد کدام کوئریها درگیر بودند.
در EF Core، گزینه EnableRetryOnFailure تلاش دوباره را برای خطاهای گذرا (از جمله بنبست) انجام میدهد. ولی وقتی خودت تراکنش باز میکنی، باید کل کار را داخل Execution Strategy بگذاری تا کل تراکنش دوباره اجرا شود:
var strategy = db.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
await using var tx = await db.Database.BeginTransactionAsync(ct);
// Same order for every transaction: sort by product id
foreach (var line in order.Lines.OrderBy(l => l.ProductId))
{
var updated = await db.Products
.Where(p => p.Id == line.ProductId && p.Stock >= line.Quantity)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.Stock, p => p.Stock - line.Quantity), ct);
if (updated == 0)
throw new OutOfStockException(line.ProductId);
}
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
});
قانونهای مهم
- خواندن، بعد تصمیم، بعد نوشتن یعنی خطر. اگر میشود، چک و تغییر را در یک دستور اتمی بنویس.
- برای فرمهای طولانی، بررسی نسخه. هیچ قفلی را بین دو درخواست HTTP نگه ندار.
- تراکنش را کوتاه نگه دار. هیچ کار شبکهای داخل تراکنش نباشد.
- قفلها را با ترتیب ثابت بگیر. مثلاً همیشه بر اساس شماره ردیف.
- برای بنبست، تلاش دوباره داشته باش. کل تراکنش، نه فقط آخرین دستور.
- سطح جداسازی را آگاهانه انتخاب کن. پیشفرض را بشناس و فقط جایی که لازم است بالاتر ببر.
اشتباههای رایج
| اشتباه | نتیجه | راه درست |
|---|---|---|
| خواندن موجودی، چک در کد، بعد کم کردن | فروش بیشتر از موجودی | آپدیت شرطی و اتمی |
| «تراکنش میگذاریم» برای دو درخواست جدا | تغییر کاربر بیصدا گم میشود | ستون نسخه و کد ۴۰۹ |
| استفاده از NOLOCK برای رفع کندی یا بنبست | خواندن داده ثبتنشده، ردیف تکراری یا گمشده | نسخهبندی (RCSI) یا رفع علت قفل |
| فراخوانی HTTP داخل تراکنش | قفل طولانی، بنبست و کندی | کار شبکهای بیرون از تراکنش |
| ترتیب متفاوت قفل در کدهای مختلف | بنبست در ساعتهای شلوغ | ترتیب ثابت، مثلاً بر اساس شماره |
| تلاش دوباره فقط برای آخرین دستور | نیمی از تراکنش برگشته و نیمی نه | تلاش دوباره برای کل تراکنش |
خلاصه در شش خط
- تراکنش همه تغییرها را با هم ثبت میکند یا هیچکدام، ولی جلوی همه مشکلهای همزمانی را نمیگیرد.
- سطح جداسازی تعیین میکند تراکنشها چقدر تغییر هم را میبینند. پیشفرض معمولاً Read Committed است.
- فاصله بین خواندن و نوشتن باعث فروش اضافه میشود. چک و تغییر را در یک دستور اتمی بنویس.
- برای دو درخواست جدا، تراکنش کمکی نمیکند. از ستون نسخه و خطای ۴۰۹ استفاده کن.
- بنبست از ترتیب متفاوت قفلها میآید. ترتیب ثابت، تراکنش کوتاه و تلاش دوباره.
- هیچ وقت NOLOCK را راه حل کندی نکن.