Race Condition چیست؟ چگونه قبل از آنکه دیر شود، آن را در لاراول تشخیص دهیم؟

What is Race Condition? How to Detect It in Laravel Before It is Too Late?

28 اردیبهشت 1405
race-condition

فرض کنید بلیط‌های یک کنسرت از خواننده‌ای بسیار محبوب در حال فروش روی یک وبسایت است و ده‌ها نفر می‌خواهند همزمان بلیطِ آخرین صندلی خالی این کنسرت را خریداری کنند. نفر اول وارد مرحله انتخاب صندلی می‌شود و می‌بیند ۱ صندلی خالی است، نفرات دیگر هم دقیقا در همان لحظه وارد همان مرحله می‌شود و می‌بینند یک صندلی خالی است. حالا همه آن افراد با خیال راحت ثبت‌نام می‌کنند و همگی بلیط آخر را خریداری می‌کنند. چند دقیقه بعد صندوق پشتیبانی شما پر از این پیام می‌شود: «پرداخت کردم اما سایت می‌گوید بلیط تمام شده»! نتیجه اینکه شما ده‌ها صندلی بیشتر از ظرفیت فروخته‌اید! اول فکر می‌کنید باگی در فرآیند پرداخت ساییتان وجود دارد، اما کدها همگی درست هستند و تمام تست‌های واحد (unit tests) معمولی هم مشکلی را گزارش نمی‌کنند. پس مشکل از کجاست؟ Race Condition!

در این مقاله قرار است مفصل به شما بگوییم Race Condition چیست و چگونه آن را به صورت زودهنگام در پروژه خود شناسایی و برطرف کنیم؟ چگونه هشدارهای بلادرنگ به Discord یا Slack تیمتان بفرستید تا همگی فورا از مشکل مطلع شوند، و چطور تست‌هایی بنویسید که این باگ را قبل از اینکه کاربرانتان با آن مواجه شوند، شناسایی کنید.

Race Condition چیست؟

Race Condition در MySQL یعنی دوفرآیند با هم مسابقه می‌دهند که کدام‌یک زودتر به دیتابیس برسد. نتیجه این مسابقه به ترتیبِ رسیدن این دو فرآیند بستگی دارد، نه به منطقی که ما در برنامه می‌نویسیم. به بیانی دیگر Race Condition زمانی اتفاق می‌افتد که دو یا چند پردازش همزمان یک تکه داده را می‌خوانند و تغییر می‌دهند و نتیجه نهایی بستگی به این دارد که کدام پردازش زودتر به مقصد برسد.

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

یک مثال دیگر اینکه فرض کنید شما و همکارتان در یک فایل Google doc یک‌سری تغییرات انجام می‌دهید. اگر هردوی شما به صورت کاملا همزمان نتیجه را Save کنید، یکی از شما دو نفر کار دیگری را خراب می‌کند یا حتی ممکن است هردو تغییرات شما به شکل خراب‌شده‌ای با هم برخورد کنند. در زبان دیتابیس، به این الگو خواندن-تغییر-نوشتن (read-modify-write) می‌گویند و علت اصلی تمام مشکلات مربوط به Race Condition در برنامه‌های تحت وب تقریبا همین الگو است.

Race Condition یکی از موذی‌ترین باگ‌ها در backend است، مخصوصاً در پروژه‌هایی که تحت فشار ترافیکی بالا هستند و کوئری‌های غیربهینه اجرا می‌کنند. این باگ در محیط لوکال ظاهر نمی‌شود و هرچقدر شما کدهای خود را زیرورو می‌کنید، متوجه مشکل خاصی نمی‌شوید. این باگ فقط در بدترین زمان خود را نشان می‌دهد: روی محیط پروداکشن (production) و اغلب زیر ترافیک سنگین. چرا؟ چون در محیط لوکال، درخواست‌ها یکی یکی ارسال می‌شوند اما در محیط پروداکشن تعدادی درخواست به‌صورت همزمان ارسال می‌شوند و اینجاست که دیتابیس فرصت نمی‌کند در فاصله دو درخواست خودش را به‌روز نماید.

الگوی خطرناک کلاسیک

کدی را که در زیر آمده است، مشاهده کنید. این کد ظاهرا صحیح به‌نظر می‌رسد:

// ❌ Dangerous
public function book(Request $request)
{
    $slot = Schedule::find($request->slot_id);

    if ($slot->status === 'available') {
        $slot->status = 'booked';
        $slot->user_id = auth()->id();
        $slot->save();
    }
}

اما مشکلش کجاست؟ بین مرحله ۱ (خواندن) و مرحله ۴ (نوشتن) یک فاصله زمانی موجود است یعنی بین find() و save(). هر چقدر هم این فاصله کوتاه (حتی چند میلی‌ثانیه) باشد، این امکان وجود دارد که در این فاصله درخواست دیگری دریافت شود، همان مقدار قبلی را بخواند و همان کار را تکرار کند.

یعنی در یک تایم‌لاین، وضعیت به این شکل است:

Time →

Request A: [READ stock=1] ........... [stock-=1, WRITE stock=0] ✅
Request B: .......[READ stock=1] ........... [stock-=1, WRITE stock=0] ✅ ← problem

هر دو درخواست مقدار stock = 1 را می‌خوانند و هر دو موفق به خرید بلیط می‌شوند، در حالی که فقط یک بلیط باقی مانده بود!

چند مثال واقعی

۱. سیستم کد تخفیف

// ❌ Dangerous
public function redeemVoucher(Request $request)
{
    $voucher = Voucher::where('code', $request->code)->first();

    if ($voucher->used_count < $voucher->max_usage) {
        $voucher->used_count += 1;
        $voucher->save();
        return response()->json(['success' => true, 'discount' => $voucher->amount]);
    }

    return response()->json(['error' => 'Voucher has been fully redeemed'], 400);
}

کد فوق بررسی می‌کند که آیا تعداد دفعات استفاده از این کد تخفیف از سقف تعیین‌شده کمتر است یا خیر؟ اگر همزمان ۳۰ نفر بخواهند از این کد تخفیف استفاده کنند، هر ۳۰ نفر می‌بینند که  used_count = 0. پس همگی کد تخفیف را وارد می‌کنند. نتیجه اینکه کدتخفیفی که قرار بود مثلا ۱۰۰ بار استفاده شود، ۱۳۰ بار مورد استفاده قرار می‌گیرد.

۲. کیف پول دیجیتال

// ❌ Dangerous
public function withdraw(Request $request)
{
    $user = User::find(auth()->id());

    if ($user->balance >= $request->amount) {
        $user->balance -= $request->amount;
        $user->save();
        // process the withdrawal...
    }
}

کد فوق بررسی می‌کند که چنانچه موجودی کاربر از مبلغ درخواستی بیشتر یا مساوی با آن است، امکان برداشت وجود داشته باشد. حالا فرض کنید مقدار موجودی کیف پول ۱۰۰ میلیون تومان است و همزمان دو درخواست ۴۰ میلیون تومانی ارسال می‌شود. موجودی نهایی ۲۰ میلیون تومان خواهد شد!

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

۲. سامانه رزرو نوبت

// ❌ Dangerous
public function book(Request $request)
{
    $slot = Schedule::find($request->slot_id);

    if ($slot->status === 'available') {
        $slot->status = 'booked';
        $slot->user_id = auth()->id();
        $slot->save();
    }
}

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

روش‌های تشخیص زودهنگام Race Condition

حالا که دانستید Race Condition چیست و چه دردسرهایی ایجاد می‌کند، باید بتوانید به‌سرعت آن را تشخیص دهید، پیش از آنکه کاربرانتان با مشکلات ناشی از آن روبرو شوند. چهار روش عملی برای تشخیص Race Condition وجود دارد که در ادامه به آن‌ها می‌پردازیم:

یک) الگوهای کدی که باید مراقبشان باشید

هرموقع درحال کدنویسی هستید، مراقب الگوی read, compute, write باشید که بدون هیچ محافظتی استفاده شود. این الگو تقریبا همیشه خطرناک هست. نشانه‌های این الگو عبارتند از:

  • ->find() یا ->where()->first() که بلافاصله بعد از آن ->save() وجود داشته باشد.
  • شرایط if که یک مقدار دیتابیسی را چک می‌کند و سپس آن مقدار را تغییر می‌دهد
  • افزایش/کاهش دستی: $model->counter += 1
  • هر ویژگی که شامل موجودی محدود (limited stock)، سهمیه‌ها (quotas) یا منابع تک‌نوبتی (single-slot) باشد.

دو) لاگ گرفتن از تمام کوئری‌ها برای نظارت از الگوهای مشکوک

لاراول یک قابلیت داخلی دارد که قادر است تمام کوئری‌هایی که به دیتابیس ارسال می‌شوند، ثبت کند. فقط لازم است یک listener به AppServiceProvider اضافه کنید. این کار کمک می‌کند تمام کوئری‌ها در یک فابل لاگ جداگانه ذخیره شوند و بعدها بتوانید بررسی کنید که آیا چندین درخواست همزمان، دقیقا یک کوئری مشابه را اجرا کرده‌اند یا خیر؟

// app/Providers/AppServiceProvider.php

use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

public function boot(): void
{
    if (config('app.debug')) {
        DB::listen(function ($query) {
            Log::channel('race_condition')->debug('Query Executed', [
                'sql'      => $query->sql,
                'bindings' => $query->bindings,
                'time_ms'  => $query->time,
            ]);
        });
    }

یک کانال لاگ اختصاصی در config/logging.php اضافه کنید:

'race_condition' => [
    'driver' => 'daily',
    'path'   => storage_path('logs/race-condition.log'),
    'level'  => 'debug',
    'days'   => 14,
],

سه) استفاده از Observerها برای شناسایی داده‌های عجیب

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

اما چرا موجودی منفی مهم است؟ در دنیای واقعی چیزی تحت عنوان «موجودی منفی» نداریم و اگر در پایگاه داده موجودی منفی مشاهده کنیم یعنی حتما Race Condition اتفاق افتاده است و دو درخواست به‌صورت همزمان موجودی را خوانده و از آن کسر کرده‌اند.

// app/Observers/ProductObserver.php

namespace App\Observers;

use App\Models\Product;
use Illuminate\Support\Facades\Log;

class ProductObserver
{
    public function updating(Product $product): void
    {
        if ($product->isDirty('stock') && $product->stock < 0) {
            Log::channel('race_condition')->warning('⚠️ Potential Race Condition Detected', [
                'model'      => 'Product',
                'id'         => $product->id,
                'old_stock'  => $product->getOriginal('stock'),
                'new_stock'  => $product->stock,
                'user_id'    => auth()->id(),
                'url'        => request()->fullUrl(),
                'timestamp'  => now()->toDateTimeString(),
            ]);
        }
    }
}

آن را در AppServiceProvider ثبت نمایید:

public function boot(): void
{
    Product::observe(ProductObserver::class);
}

Observerها اطلاعاتی مانند این اطلاعات را ذخیره می‌کنند: کدام محصول، موجودی قدیم چقدر بوده، موجودی جدید چقدر شده، کدام کاربر باعث شده، چه آدرسی درخواست داده، دقیقاً چه زمانی. این اطلاعات کمکتان می‌کنند سناریوی رخ داده را عینا بازسازی کنید.

چهار) Middleware برای رصد درخواست‌های همزمان

میدلور می‌تواند تعداد درخواست‌هایی را که به‌صورت همزمان به یک آدرس مشخص ارسال شده است، می‌شمارد. اما چطور این کار را انجام می‌دهد؟ قبل از اجرای درخواست، یک شمارنده در کش به اندازه یک واحد زیاد می‌شود، اگر این شمارنده بزرگتر از ۱ شد، یعنی حداقل دو درخواست همزمان داریم و یک هشدار در لاگ ثبت می‌شود که محتوی این اطلاعات است: چه آدرسی، کدام کاربر، چند درخواست همزمان. پس از اجرای درخواست، از شمارنده یک واحد کم می‌شود.

با توصیحات فوق حتما متوجه شده‌اید که این میدلور خودش Race Condition را حل نمی‌کند، فقط در این مورد به شما هشدار می‌دهد.

// app/Http/Middleware/MonitorConcurrentRequests.php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;

class MonitorConcurrentRequests
{
    public function handle($request, Closure $next)
    {
        $key = 'concurrent:' . $request->path() . ':' . ($request->user()?->id ?? 'guest');
        $concurrent = Cache::increment($key);

        Cache::expire($key, 10);

        if ($concurrent > 1) {
            Log::channel('race_condition')->warning('🔴 Concurrent Request Detected', [
                'path'       => $request->path(),
                'user_id'    => $request->user()?->id,
                'concurrent' => $concurrent,
                'ip'         => $request->ip(),
                'timestamp'  => now()->toDateTimeString(),
            ]);
        }

        $response = $next($request);

        Cache::decrement($key);
        return $response;
    }
}

روش‌های جلوگیری از Race Condition

تا به اینجای مطلب آموختید Race Condition چیست و با چه روش‌هایی می‌توان آن را شناسایی کرد. در ادامه می‌خواهیم توضیح دهیم که چطور می‌توانید جلوی Race Condition را بگیرید. لاراول چهار راه برای جلوگیری از به‌وقوع پیوستن Race Condition دارد که عبارتند از:

یک) قفل‌گذاری بدبینانه

این راه، مستقیم‌ترین رویکرد است. کافیست ردیف دیتابیس را برای تمام مدت تراکنش قفل کنید (یعنی کل عملیات را در یک تراکنش بگذارید و دیتابیس، آن ردیف را قفل کند). در اینصورت سایر درخواست‌هایی که می‌خواهند همان ردیف را بخوانند، مجبورند که منتظر بمانند. پس از اینکه تراکنش تکمیل شد، قفل هم آزاد می‌شود.

با قفل‌گذاری بدبینانه در واقع دارید می‌گویید این ردیف دیتابیس متعلق به فلان کاربر است و تا زمانی که کار او تمام نشده، کاربر دیگری حق ندارد به آن دسترسی داشته باشد. در مثال بلیط کنسرت که اول مقاله مطرح کردیم، درخواست A ردیف را قفل می‌کند. درخواست B که همان ردیف را می‌خواهد دریافت می‌شود ولی باید در انتظار بماند تا کار A تمام شود.

// ✅ Safe from race condition
public function purchaseTicket($eventId)
{
    DB::transaction(function () use ($eventId) {
        // lockForUpdate() prevents other processes from reading this row
        // until the transaction commits or rolls back
        $event = Event::lockForUpdate()->find($eventId);

        if ($event->available_stock > 0) {
            $event->available_stock -= 1;
            $event->save();
            Order::create([
                'event_id' => $event->id,
                'user_id'  => auth()->id(),
                'status'   => 'pending',
            ]);
        } else {
            throw new \Exception('Tickets are sold out');
        }
    });
}

نکته خیلی مهم: متد lockForUpdate()باید حتما درون یک DB::transaction() استفاده شود تا تاثیر داشته باشد. بدون تراکنش، قفل نگه داشته نمی‌شود.

دقت کنید که این روش باعث به صف ایستادن درخواست‌ها می‌شود. (شناخت دقیق نحوه مدیریت lockها و transactionها نیازمند درک درست معماری داخلی MySQL است.)

دو) عملیات اتمیک (Atomic)

بجای اینکه دیتابیس هر سه مرحله (بخوان، کم کن، بنویس - read, decrease, write) را انجام دهد، می‌تواند همه کار را در یک مرحله انجام دهد. decrement در دیتابیس یک عملیات اتمیک هست.

اگر با مفاهیم بهینه‌سازی کوئری و عملیات سطح پایین SQL آشنا نیستید، مطالعه مقاله خیلی به شما کمک می‌کند زیادی می‌کند:
در بهینه‌سازی پرفورمنس SQL حرفه‌ای شوید!

یعنی دیتابیس بدون آنکه به کسی اجازه دخالت بدهد، موجودی را کم می‌کند:

// ✅ Atomic — no gap between read and write
$updated = DB::table('products')
    ->where('id', $productId)
    ->where('stock', '>', 0)
    ->decrement('stock');

if ($updated === 0) {
    throw new \Exception('Out of stock or already updated by another process');
}

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

سه) قفل‌گذاری خوشبینانه

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

یک ستون version اضافه کنید و بررسی کنید که آیا داده قبل از ذخیره‌سازی تغییر کرده است یا خیر:

// Migration
Schema::table('products', function (Blueprint $table) {
    $table->integer('version')->default(0);
});

// Usage
public function updateStock(int $amount, int $version): void
{
    $updated = DB::table('products')
        ->where('id', $this->id)
        ->where('version', $version)   // confirm data hasn't changed
        ->update([
            'stock'   => DB::raw("stock - {$amount}"),
            'version' => DB::raw('version + 1'),
        ]);

    if ($updated === 0) {
        throw new \Exception('Data was modified by another process - please retry');
    }
}

سه) قفل‌گذاری کش با Redis

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

مثل اینست که به هر سفارش، یک کلید مجازی اختصاص دهید و هرکسی آن کلید را داشت بتواند سفارش را پردازش کند.

قفل‌های کش مبتنی بر Redis انعطاف‌پذیری بسیار بالایی دارند. (در حاشیه: برخی توسعه‌دهندگان برای workloadهای سنگین و concurrency بالا، MariaDB را به MySQL ترجیح می‌دهند.)

use Illuminate\Support\Facades\Cache;
use Illuminate\Contracts\Cache\LockTimeoutException;

public function processOrder(int $orderId): void
{
    $lock = Cache::lock("order:{$orderId}", 10);

    try {
        $lock->block(5); // wait up to 5 seconds to acquire the lock
        $order = Order::find($orderId);
        // process order safely...
    } catch (LockTimeoutException) {
        throw new \Exception('System is busy, please try again');
    } finally {
        $lock->release();
    }
}

ارسال هشدار به هم‌تیمی‌ها از طریق دیسکورد و اسلک

تشخیص Race Condition در لاگ‌ها خوب است اما لازم است همان لحظه تیم شما در جریان قرار گیرد تا سریعا به آن رسیدگی کند. تصور کنید ساعت ۳ صبح است و فروش فوق‌العاده شما دچار Race Condition شده و کاربران دارند بیش از دفعات مجاز از کد تخفیف استفاده می‌کنند. طبیعتا اگر هشدار فوری به اعضای تیم نرسد، تا صبح خیلی ضرر می‌کنید.

فعال کردن نوتیفیکیشن دیسکورد

دیسکورد از همان فرمتی که اسلک استفاده می‌کند، پشتیبانی می‌کند. یعنی شما می‌توانید از درایور Slack در لاراول استفاده کنید و مستقیما به دیسکورد پیام بفرستید و دیگر نیازی به نصب پکیج جداگانه نیست.

قدم اول: به کانال مورد نظر در دیسکورد بروید و مسیر زیر را دنبال کنید:
Edit Channel → Integrations → Webhooks → New Webhook
و آدرس URL را کپی کنید.

قدم دوم: در فایل .env این خط را اضافه کنید:

LOG_DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/XXXX/XXXX/slack

به /slack آخر دقت کنید. این دارد به دیسکورد می‌گوید: «من با فرمت Slack پیام می‌فرستم.»

قدم سوم: پیکربندی لاراول را به اینصورت انجام دهید که در در config/logging.php:

'discord' => [
    'driver'   => 'slack',
    'url'      => env('LOG_DISCORD_WEBHOOK_URL'),
    'username' => 'Laravel Error Bot',
    'emoji'    => ':rotating_light:',
    'level'    => 'critical',
],

فعال کردن نوتیفیکیشن اسلک

قدم اول: یک اپلیکیشن Slack در آدرس api.slack.com/apps ایجاد کنید، قابلیت Incoming Webhooks را فعال نموده و یک آدرس webhook برای کانال موردنظر خود درنظر بگیرید.

قدم دوم: در فایل .env این مورد را اضافه کنید:

LOG_SLACK_WEBHOOK_URL=https://hooks.slack.com/services/XXXXXX/XXXXXX/xxxxxxxx

قدم سوم: در config/logging.php:

'slack' => [
    'driver'   => 'slack',
    'url'      => env('LOG_SLACK_WEBHOOK_URL'),
    'username' => 'Laravel Alert',
    'emoji'    => ':warning:',
    'level'    => 'critical',
],

برای کدنویسی تمیز از پکیج‌های Spatie استفاده کنید

تا به اینجا در مورد ارسال هشدار از طریق دیسکورد و اسلک صحبت کردیم. استفاده از درایور Slack در config/logging.php کار می‌کند، ولی برای ارسال یک پیام ساده می‌بایست تنظیمات زیادی انجام بدید. همچنین برای دیسکورد و اسلک باید دو کانال جداگانه تعریف کنید.

اگر کدنویسی تمیز را ترجیح می‌دهید، استفاده از پکیج‌های Spatie روش بهتری برای ارسال هشدار است. اسپیتی (Spatie) یک شرکت معروف است که پکیج‌های خیلی تمیز و ساده‌ای برای لاراول دارد. همچنین دو پکیج زیر را ارائه کرده است:

composer require spatie/laravel-slack-alerts
composer require spatie/laravel-discord-alert

روش استفاده:

use Spatie\SlackAlerts\Facades\SlackAlert;
use Spatie\DiscordAlerts\Facades\DiscordAlert;

// Send to Slack
SlackAlert::message("🚨 Race condition detected on /api/tickets/purchase - stock went negative!");

// Send to Discord
DiscordAlert::message("🚨 Race condition detected on /api/tickets/purchase - stock went negative!");

ساخت یک کلاس Exception سفارشی برای Race Condition

این روش، حرفه‌ای‌ترین روش ارسال هشدار است. همه‌چیز را با یک کلاس exception اختصاصی به هم متصل کنید. این کلاس پیام خطا را دریافت می‌کند، اطلاعات بیشتری شامل آدرس URL، متد درخواست، IP، user_id و timestamp را جمع کرده و هم در لاگ محلی ذخیره می‌کند و هم به اسلک هشدار ارسال می‌نماید:

// app/Exceptions/RaceConditionException.php

namespace App\Exceptions;

use Exception;
use Illuminate\Support\Facades\Log;

class RaceConditionException extends Exception
{
    public function __construct(
        string $message = '',
        protected array $context = []
    ) {
        parent::__construct($message);
    }

    public function report(): void
    {
        $payload = array_merge([
            'message'   => $this->getMessage(),
            'file'      => $this->getFile(),
            'line'      => $this->getLine(),
            'url'       => request()->fullUrl(),
            'method'    => request()->method(),
            'user_id'   => auth()->id(),
            'ip'        => request()->ip(),
            'timestamp' => now()->toDateTimeString(),
        ], $this->context);

        // Log locally
        Log::channel('race_condition')->critical('🚨 Race Condition Detected', $payload);
        
        // Alert the team
        Log::channel('slack')->critical('🚨 Race Condition Alert', $payload);
    }
}

در فایل bootstrap/app.php باید به لاراول بگویید که هنگام برخورد با این exception خاص، متد report آن را اجرا کند. این کار فقط یک بار در ابتدای پروژه انجام می‌شود:

->withExceptions(function (Exceptions $exceptions) {
    $exceptions->report(function (RaceConditionException $e) {
        // The report() method on the exception handles everything
    });
})

آن را در هر جایی throw کنید. با این کار تمام کارهای مربوط به لاگ کردن و هشدار فرستادن اتوماتیک انجام می‌شود:

throw new RaceConditionException(
    'Stock went negative during concurrent purchase',
    ['product_id' => $productId, 'user_id' => auth()->id()]
);

تست Race Condition

اکثر دولوپرها این بخش را نادیده می‌گیرند، در صورتیکه مهم‌‌ترین و حیاتی‌ترین بخش کار همین بخش است. می‌توان گفت که تقریبا غیرممکن است که بتوان Race Condition را با unit testهای استاندارد شناسایی کرد. چرا؟ چون unit testها یکی‌یکی درخواست‌ها را اجرا می‌کنند اما همانطور که پیش‌تر اشاره کردیم Race Condition فقط زمانی بروز می‌کند که چندین درخواست همزمان به سرور برسند. پس برای شناسایی آن‌ها نیاز به شبیه‌سازی درخواست‌های همزمان داریم.

سه روش برای تست Race Condition وجود دارد که به‌ترتیب از ساده تا حرفه‌ای آن‌ها را شرح می‌دهیم:

روش اول: Apache Benchmark (ab)

Apache Benchmark یک ابزار خط فرمان خفن (!) است که با آپاچی نصب می‌شود و روی بیشتر سیستم‌های لینوکس و مک در درسترس است. کار آن شلیک تعداد زیادی درخواست همزمان به سرور شماست.

# Fire 50 requests with concurrency level 50 (all at once)
ab -n 50 -c 50 \
   -H "Authorization: Bearer YOUR_TOKEN" \
   -H "Content-Type: application/x-www-form-urlencoded" \
   -p /tmp/post_data.txt \
   http://localhost:8000/api/tickets/purchase

محتوای فایل /tmp/post_data.txt:

event_id=1&quantity=1

پس از اجرای این تست باید دیتابیس را چک کنید. چنانچه موجودی منفی شد یا تعداد کل سفارشات، از موجودی اولیه بیشتر شد، یعنی که race condition اتفاق افتاده است. این روش خیلی سریع و ساده است و نیازی به نوشتن کد تست نیست. ولی مشکلی که دارد این است که در این روش کنترل دقیقی روی فرآیند تست ندارید و سرور باید در حال اجرا باشد.

روش دوم: درخواست‌های همزمان با curl_multi در PHP

در این روش به جای استفاده از ابزاری خاص، در تست‌های لاراول از قابلیت curl_multi استفاده می‌کنیم. این قابلیت باعث می‌شود بتوانیم چندین درخواست HTTP را همزمان با هم اجرا کنیم.

// tests/Feature/RaceConditionTest.php

namespace Tests\Feature;

use Tests\TestCase;
use App\Models\Product;
use App\Models\User;
use App\Models\Order;

class RaceConditionTest extends TestCase
{
    public function test_stock_does_not_go_negative_under_concurrent_requests(): void
    {
        $initialStock = 10;
        $product      = Product::factory()->create(['stock' => $initialStock]);
        $token        = User::factory()->create()->createToken('test')->plainTextToken;
        $concurrency = 20;
        $handles     = [];
        $multiHandle = curl_multi_init();

        for ($i = 0; $i < $concurrency; $i++) {
            $ch = curl_init();
            curl_setopt_array($ch, [
                CURLOPT_URL            => "http://localhost:8000/api/products/{$product->id}/purchase",
                CURLOPT_POST           => true,
                CURLOPT_POSTFIELDS     => http_build_query(['quantity' => 1]),
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER     => [
                    "Authorization: Bearer {$token}",
                    'Content-Type: application/x-www-form-urlencoded',
                ],
            ]);
            curl_multi_add_handle($multiHandle, $ch);
            $handles[] = $ch;
        }

        // Execute all requests concurrently
        do {
            $status = curl_multi_exec($multiHandle, $active);
            curl_multi_select($multiHandle);
        } while ($active > 0);
        foreach ($handles as $ch) {
            curl_multi_remove_handle($multiHandle, $ch);
        }

        curl_multi_close($multiHandle);

        // Assert stock never went negative
        $product->refresh();
        $this->assertGreaterThanOrEqual(
            0,
            $product->stock,
            "Stock went negative - race condition detected!"
        );

        // Assert total orders never exceeded initial stock
        $totalOrders = Order::where('product_id', $product->id)->count();

        $this->assertLessThanOrEqual(
            $initialStock,
            $totalOrders,
            "Orders ({$totalOrders}) exceeded initial stock ({$initialStock}) - race condition confirmed!"
        );
    }
}

آن را با دستور زیر اجرا کنید:

php artisan test --filter=RaceConditionTest

روش سوم: هلپر همزمانی در لاراول (بهترین روش)

لاراول از نسخه ۱۱ به بعد تابع جدیدی با نام Concurrency::run() اضافه کرده که کار را خیلی ساده‌تر کرده است.

use Illuminate\Support\Facades\Concurrency;

$results = Concurrency::run(
    array_fill(0, 10, function () use ($productId) {
        return $this->postJson("/api/products/{$productId}/purchase", ['quantity' => 1]);
    })
);

$successCount = collect($results)->filter(fn($r) => $r->status() === 200)->count();

// Should never exceed initial stock
$this->assertLessThanOrEqual($initialStock, $successCount);

کد فوق دارد می‌گوید که ۱۰ تا تابع به صورت همزمان اجرا شود. هر تابع یک درخواست POST می‌فرستد.

بعد از اجرا، نتیجه کلیه درخواست‌ها در متغیر $results جمع‌ می‌شود. باید می‌شماریم چند تا از آن‌ها موفق (status 200) بوده‌اند. این تعداد نباید از موجودی اولیه بیشتر باشد.

چک‌لیست برای دیپلویمنت فیچرهای حساس

هر فیچری که با منابع محدود سروکار داشته باشد، فیچر حساس محسوب می‌شود. فیچرهای مرتبط با موجودی، کیف پول، کوپن، رزرو نوبت و ... از این دسته‌اند. پیش از آنکه چنین فیچرهایی را روی پروداکشن منتشر کنید باید این فهرست حرفه‌ای را مرحله به مرحله چک کنید:

بررسی کد (Code review)

  • تراکنش + قفل بدبینانه: هرجا که الگوی «خواندن-تغییر-نوشتن» (read-modify-write) دارید باید حتما داخل DB::transaction و همراه با lockForUpdate() باشد.
  • عملیات اتمیک: برای افزایش و کاهش ساده موجودی‌ها، به جای اینکه خودتان دستی کم و زیاد کنید، باید از توابع اتمیک دیتابیس مثل decrement استفاده کنید. این روش سریع‌تر و ایمن‌تر است و احتمال خطای پائینی دارد.
  • خودداری از افزایش/کاهش دستی: هیچ $model->counter += 1 یا $model->balance - نباید بدون محافظ وجود داشته باشد.

مانیتورینگ

  • Observerها فعال باشند: Observerها روی مدل‌های بحرانی (Product، Wallet، Voucher، Schedule) باید فعال باشند.
  • کانال لاگ اختصاصی داشته باشید: کانال لاگ اختصاصی برای ردیابی ناهنجاری‌ها تنظیم شده باشد. یعنی یک فایل جداگانه به‌نام race-condition.log داشته باشید تا لاگ‌های مربوط به این باگ از سایر باگ‌ها جدا شود.
  • middleware درخواست‌های همزمان داشته باشید: باید روی اندپوینت‌های حساس (مثل خرید، رزرو و غیره) میدلوری قرار داده باشید که شناسایی کند که چند درخواست همزمان از یک کاربر دریافت شده است.

هشداردهی

  • وب‌هوک اسلک یا دیسکورد تنظیم و تست شده باشد.
  • Exception به کانال هشدار متصل باشد: exception باید مسیریابی RaceConditionException را به کانال هشدار هدایت کند، یعنی هنگامی کهRaceConditionException پرتاب می‌شود به‌صورت خودکار به دیسکورد یا اسلک برود.
  • سطح لاگ روی critical باشد:  از سطح debug اجتناب کنید. اگر سطح لاگ پائین باشد هر پیام کوچکی به‌عنوان هشدار ارسال می‌شود. شما باید فقط خطاهای بحرانی را بفرستید.

تست

  • تست درخواست همزمان برای هر فیچر دارای تراکنش وجود داشته باشد.
  • تست‌ها را در ابتدا بدون فیکس اجرا کنید => باید شکست بخورد. سپس فیکس را اعمال کنید => باید قبول شود. بدین ترتیب اطمینان حاصل می‌کنید که فیکس شما واقعا باگ را برطرف می‌کند.

اگر می‌خواهید MySQL را حرفه‌ای یاد بگیرید

اگر مفاهیمی مثل transaction، locking، indexing، query optimization و معماری MySQL برایتان پیچیده است، پیشنهاد ما اینست آموزش‌های تخصصی ما را ببینید:

نتیجه

Race condition باگی موذی است که باعث می‌شود همه زحماتتان به باد برود.این باگ در محیط لوکال خودش را نشان نمی‌دهد و از تمام تست‌های شما بی‌سروصدا عبور می‌کند اما زیر ترافیک سنگین در پروداکشن خود را تمام و کمال به نمایش می‌گذارد!

اما خوشبختانه لاراول هرآنچه را که برای مقابله با این باگ نیاز دارید، برایتان فراهم می‌کند؛ lockForUpdate()، عملیات اتمیک، قفل‌های Redis و Observerها و نیز یک سیستم لاگ‌گیری درجه یک که می‌تواند تیم شما را بلافاصله ار وقوع این باگ مطلع کند.

شما نه تنها باید به روش‌های رفع سریع این مشکل مسلط باشید بلکه باید به‌صورت زودهنگام آن را در کد خود تشخیص دهید و از آن پیشگیری کنید. پیش از دیپلیومنت فیچرهای حساس و در مرحله code review باید الگوهای خطرناک را پیدا کنید، داده‌ها را مدام با Observerها رصد کنید و تست‌های درخواست‌های همزمان بنویسید.

بدترین زمان برای شناسایی race condition زمانی است که کاربران معترضتان دارند تیکت پشتیبانی ثبت می‌کنند: «پول از حسابم کسر شده اما بلیطی دریافت نکرده‌ام»!


منبع مورد استفاده در این مقاله: Medium

نویسنده شوید
دیدگاه‌های شما

در این قسمت، به پرسش‌های تخصصی شما درباره‌ی محتوای مقاله پاسخ داده نمی‌شود. سوالات خود را اینجا بپرسید.