Levelwise
فارسی
داده و ذخیره‌سازی

تراکنش و سطح جداسازی (Isolation Level)

تراکنش چند تغییر را با هم ثبت می‌کند یا هیچ‌کدام را. ولی تراکنش به تنهایی جلوی همه مشکل‌های همزمانی را نمی‌گیرد. باید بدانی Lost Update و Deadlock از کجا می‌آیند و با آپدیت اتمی، بررسی نسخه و ترتیب ثابت قفل‌ها حلشان کنی.

بازبینی نشدهبا کمک AI نوشته شدهزمان خواندن: ۱۶ دقیقهمثال فروشگاه اینترنتیکد C# و EF Core 10

نویسنده: bezzad

مشکل: دو درخواست، یک ردیف

در فروشگاه ما فقط یک گوشی در انبار مانده است. دو مشتری در یک لحظه روی «خرید» می‌زنند. کد این است:

var product = await db.Products.FirstAsync(p => p.Id == productId, ct);
if (product.Stock > 0)
{
    product.Stock -= 1;
    await db.SaveChangesAsync(ct);
}

قدم به قدم ببین چه می‌شود:

  1. درخواست اول موجودی را می‌خواند: ۱.
  2. درخواست دوم هم موجودی را می‌خواند: ۱.
  3. هر دو شرط را درست می‌بینند.
  4. هر دو موجودی را صفر می‌کنند و ذخیره می‌کنند.
  5. یک گوشی را دو بار فروختیم.

جالب اینکه SaveChangesAsync خودش در یک تراکنش اجرا می‌شود. پس مشکل «نبودن تراکنش» نیست. مشکل فاصله بین خواندن و نوشتن است. به این Race Condition می‌گویند.

تراکنش چه تضمینی می‌دهد؟

تراکنش چهار تضمین دارد که به آن‌ها ACID می‌گویند:

  1. اتمی بودن (Atomicity). همه تغییرها با هم ثبت می‌شوند یا هیچ‌کدام. اگر کم کردن موجودی موفق شد ولی ثبت سفارش نه، هر دو برمی‌گردند.
  2. سازگاری (Consistency). قانون‌های دیتابیس (کلید خارجی، یکتایی، شرط‌ها) بعد از تراکنش هم درست هستند.
  3. جداسازی (Isolation). تراکنش‌های همزمان تا حدی از هم جدا هستند. «تا چه حد» را سطح جداسازی تعیین می‌کند.
  4. ماندگاری (Durability). وقتی ثبت شد، با قطع برق هم از بین نمی‌رود.

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

سطح‌های جداسازی

هر سطح جلوی بعضی از این مشکل‌ها را می‌گیرد:

  1. خواندن داده ثبت‌نشده (Dirty Read). تراکنش من تغییری را می‌بیند که تراکنش دیگر هنوز ثبت نکرده و شاید برگرداند.
  2. خواندن دوباره، جواب دیگر (Non-Repeatable Read). یک ردیف را دو بار می‌خوانم و دو مقدار مختلف می‌بینم.
  3. ردیف تازه (Phantom). یک کوئری را دو بار اجرا می‌کنم و بار دوم ردیف‌های تازه‌ای می‌بینم.
خواندن داده ثبت‌نشدهخواندن دوباره، جواب دیگرردیف تازه پیدا شدDirty ReadNon-Repeatable ReadPhantomRead UncommittedRead CommittedRepeatable ReadSerializableپیش‌فرضممکنممکنممکنجلوگیریممکنممکنجلوگیریجلوگیریممکنجلوگیریجلوگیریجلوگیریامن‌تر، ولی قفل و انتظار بیشترجدول طبق استاندارد SQL است. هر دیتابیس جزئیات خودش را دارد.
سطح پیش‌فرض در SQL Server و PostgreSQL، همان Read Committed است.

چرا همیشه بالاترین سطح را انتخاب نکنیم؟ چون سطح بالاتر یعنی قفل بیشتر یا رد شدن بیشتر تراکنش‌ها. پس انتظار، Deadlock و تلاش دوباره بیشتر می‌شود.

سطح بالاتر همه چیز را حل نمی‌کند. مثال خرید گوشی بالا، در سطح پیش‌فرض Read Committed هم خطا دارد. چون خواندن تمام شده و قفلی نمانده است. راه حل درست، تغییر شکل کد است، نه فقط بالا بردن سطح.

قفل یا نسخه‌بندی؟

دیتابیس‌ها برای جداسازی دو ابزار دارند:

  1. قفل. نویسنده ردیف را قفل می‌کند. خواننده باید صبر کند.
  2. نسخه‌بندی ردیف (MVCC). نویسنده یک نسخه جدید می‌سازد. خواننده نسخه قبلیِ ثبت‌شده را می‌خواند و صبر نمی‌کند.
فقط قفلنویسندهموجودی: ۵ به ۴قفل شدهخوانندهمنتظر می‌ماند تا نویسنده تمام کندنسخه‌بندی ردیفنویسندهنسخه جدید: ۴هنوز ثبت نشدهنسخه قبلی: ۵خوانندهفوراً آخرین نسخه ثبت‌شده را می‌خواند

دیتابیس PostgreSQL همیشه نسخه‌بندی دارد. در SQL Server، سطح Read Committed به صورت پیش‌فرض با قفل کار می‌کند. با گزینه Read Committed Snapshot (RCSI) نسخه‌بندی روشن می‌شود و خواندن و نوشتن دیگر هم را بلاک نمی‌کنند. ولی دو نوشتن روی یک ردیف هنوز منتظر هم می‌مانند.

چیزی که نسخه‌بندی حل نمی‌کند: خواننده نسخه قبلی را می‌بیند. پس دو تراکنش ممکن است هر دو یک موجودی قدیمی را بخوانند و هر دو بر اساس آن تصمیم بگیرند. مثلاً کیف پول مشتری ۱۰۰ هزار تومان دارد و دو خرید ۸۰ هزار تومانی همزمان می‌رسد. هر دو عدد ۱۰۰ را می‌بینند و قبول می‌کنند. یک شکل معروف این مشکل Write Skew نام دارد. راه حل همان آپدیت اتمی پایین، یا قفل صریح هنگام خواندن است.

راه حل اول: آپدیت اتمی

برگردیم به خرید گوشی. اگر چک کردن و کم کردن در یک دستور باشد، فاصله‌ای نمی‌ماند. دیتابیس یک دستور 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");

چرا درست است؟

  1. دستور UPDATE ردیف را قفل می‌کند، شرط را چک می‌کند و کم می‌کند. همه با هم.
  2. درخواست دوم منتظر قفل می‌ماند. بعد شرط را با موجودی جدید (صفر) چک می‌کند.
  3. شرط درست نیست، پس هیچ ردیفی تغییر نمی‌کند و عدد صفر برمی‌گردد.

این ساده‌ترین و سریع‌ترین راه برای شمارنده‌ها و موجودی است.

راه حل دوم: بررسی نسخه (Optimistic Concurrency)

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

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

مثال زنده: دو کارمند، یک سفارش

فرم کارمند الف

هنوز باز نشده

دیتابیس

فرم کارمند ب

هنوز باز نشده
یک حالت را انتخاب کن.

    به این Lost Update می‌گویند. آخرین نوشتن برنده شد و تغییر الف بی‌صدا گم شد. اینجا تراکنش هیچ کمکی نمی‌کند:

    1. این‌ها دو درخواست جدا هستند، با چند دقیقه فاصله.
    2. هر ذخیره به تنهایی درست و موفق است.
    3. مشکل این است که ب با داده کهنه ذخیره می‌کند.

    راه حل، یک ستون نسخه است. در 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.");
        }
    });

    چطور کار می‌کند؟

    1. کتابخانه EF Core به دستور UPDATE یک شرط اضافه می‌کند: «فقط اگر نسخه هنوز همان است».
    2. ذخیره الف نسخه را عوض کرده است. پس شرط برای ب درست نیست و هیچ ردیفی تغییر نمی‌کند.
    3. کتابخانه EF Core خطای DbUpdateConcurrencyException می‌دهد و ما کد ۴۰۹ برمی‌گردانیم.
    4. فرم به ب می‌گوید سفارش را دوباره باز کند. اینکه بعد چه کنیم، تصمیم کسب‌وکار است.
    در REST API: سرور نسخه را در هدر ETag می‌فرستد. کلاینت آن را در هدر If-Match پس می‌فرستد. اگر نسخه عوض شده باشد، سرور کد ۴۱۲ برمی‌گرداند.

    قفل خوش‌بینانه (Optimistic)

    • قفلی نگه نمی‌دارد. موقع ذخیره نسخه را چک می‌کند.
    • برای فرم‌هایی که کاربر چند دقیقه باز نگه می‌دارد مناسب است.
    • وقتی برخورد کم است، بهترین انتخاب است.

    قفل بدبینانه (Pessimistic)

    • ردیف را موقع خواندن قفل می‌کند (در SQL Server با UPDLOCK و در PostgreSQL با FOR UPDATE).
    • فقط داخل یک تراکنش کوتاه معنی دارد.
    • برای فرم باز در مرورگر مناسب نیست. اگر کاربر مرورگر را ببندد، قفل چه می‌شود؟

    بن‌بست (Deadlock)

    حالا دو سفارش همزمان می‌رسد. هر سفارش در یک تراکنش، موجودی کالاهایش را یکی‌یکی کم می‌کند:

    1. سفارش اول: اول گوشی، بعد قاب.
    2. سفارش دوم: اول قاب، بعد گوشی.
    بن‌بستتراکنش ۱سفارش: گوشی، بعد قابتراکنش ۲سفارش: قاب، بعد گوشیردیف گوشیProducts.Id = 1ردیف قابProducts.Id = 2قفل داردقفل داردمنتظردیتابیس دور را می‌بیندیکی را قربانی می‌کندو تراکنشش را برمی‌گرداندSQL Server: 1205PostgreSQL: 40P01کد باید دوباره تلاش کندراه حل: همه تراکنش‌ها کالاها را به ترتیب شماره قفل کنند. اول گوشی، بعد قاب.
    هر تراکنش قفل یک ردیف را دارد و منتظر ردیف دیگر است. هیچ‌کدام نمی‌تواند جلو برود.

    دیتابیس این دور را تشخیص می‌دهد و یکی از تراکنش‌ها را قربانی می‌کند. راه‌ها:

    1. ترتیب ثابت قفل‌ها. کالاها را همیشه به ترتیب شماره مرتب کن. حالا هر دو تراکنش اول سراغ گوشی می‌روند. دومی فقط صبر می‌کند و بن‌بستی نیست.
    2. تراکنش کوتاه. فراخوانی HTTP، ارسال ایمیل و محاسبه سنگین نباید داخل تراکنش باشد. قفل کوتاه‌تر یعنی برخورد کمتر.
    3. ایندکس درست. بدون ایندکس، دیتابیس برای پیدا کردن ردیف، ردیف‌های بیشتری را می‌خواند و قفل می‌کند.
    4. تلاش دوباره. بن‌بست هیچ وقت کاملاً صفر نمی‌شود. کل تراکنش را دوباره اجرا کن.
    5. پیدا کردن علت. در 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);
    });

    قانون‌های مهم

    1. خواندن، بعد تصمیم، بعد نوشتن یعنی خطر. اگر می‌شود، چک و تغییر را در یک دستور اتمی بنویس.
    2. برای فرم‌های طولانی، بررسی نسخه. هیچ قفلی را بین دو درخواست HTTP نگه ندار.
    3. تراکنش را کوتاه نگه دار. هیچ کار شبکه‌ای داخل تراکنش نباشد.
    4. قفل‌ها را با ترتیب ثابت بگیر. مثلاً همیشه بر اساس شماره ردیف.
    5. برای بن‌بست، تلاش دوباره داشته باش. کل تراکنش، نه فقط آخرین دستور.
    6. سطح جداسازی را آگاهانه انتخاب کن. پیش‌فرض را بشناس و فقط جایی که لازم است بالاتر ببر.

    اشتباه‌های رایج

    اشتباه نتیجه راه درست
    خواندن موجودی، چک در کد، بعد کم کردن فروش بیشتر از موجودی آپدیت شرطی و اتمی
    «تراکنش می‌گذاریم» برای دو درخواست جدا تغییر کاربر بی‌صدا گم می‌شود ستون نسخه و کد ۴۰۹
    استفاده از NOLOCK برای رفع کندی یا بن‌بست خواندن داده ثبت‌نشده، ردیف تکراری یا گم‌شده نسخه‌بندی (RCSI) یا رفع علت قفل
    فراخوانی HTTP داخل تراکنش قفل طولانی، بن‌بست و کندی کار شبکه‌ای بیرون از تراکنش
    ترتیب متفاوت قفل در کدهای مختلف بن‌بست در ساعت‌های شلوغ ترتیب ثابت، مثلاً بر اساس شماره
    تلاش دوباره فقط برای آخرین دستور نیمی از تراکنش برگشته و نیمی نه تلاش دوباره برای کل تراکنش

    خلاصه در شش خط

    1. تراکنش همه تغییرها را با هم ثبت می‌کند یا هیچ‌کدام، ولی جلوی همه مشکل‌های همزمانی را نمی‌گیرد.
    2. سطح جداسازی تعیین می‌کند تراکنش‌ها چقدر تغییر هم را می‌بینند. پیش‌فرض معمولاً Read Committed است.
    3. فاصله بین خواندن و نوشتن باعث فروش اضافه می‌شود. چک و تغییر را در یک دستور اتمی بنویس.
    4. برای دو درخواست جدا، تراکنش کمکی نمی‌کند. از ستون نسخه و خطای ۴۰۹ استفاده کن.
    5. بن‌بست از ترتیب متفاوت قفل‌ها می‌آید. ترتیب ثابت، تراکنش کوتاه و تلاش دوباره.
    6. هیچ وقت NOLOCK را راه حل کندی نکن.