Laravel المتقدم

قاعدة البيانات المتقدمة: المعاملات والقفل

18 دقيقة الدرس 12 من 40

قاعدة البيانات المتقدمة: المعاملات والقفل

تعد معاملات قاعدة البيانات وآليات القفل ضرورية للحفاظ على سلامة البيانات في التطبيقات متعددة المستخدمين. يوفر Laravel واجهات برمجة تطبيقات أنيقة لإدارة المعاملات، وتنفيذ القفل المتشائم والمتفائل، ومنع حالات الجمود. دعنا نستكشف هذه المفاهيم المتقدمة لقاعدة البيانات.

أساسيات معاملات قاعدة البيانات

تضمن المعاملات أن سلسلة من عمليات قاعدة البيانات إما أن تنجح جميعها أو تفشل جميعها، مع الحفاظ على اتساق قاعدة البيانات.

use Illuminate\Support\Facades\DB; // معاملة أساسية DB::transaction(function () { $user = User::create(['name' => 'John Doe', 'email' => 'john@example.com']); Profile::create([ 'user_id' => $user->id, 'bio' => 'Developer', ]); Subscription::create([ 'user_id' => $user->id, 'plan' => 'premium', ]); }); // معاملة مع قيمة إرجاع $order = DB::transaction(function () use ($cartItems) { $order = Order::create([ 'user_id' => auth()->id(), 'total' => $this->calculateTotal($cartItems), ]); foreach ($cartItems as $item) { OrderItem::create([ 'order_id' => $order->id, 'product_id' => $item['product_id'], 'quantity' => $item['quantity'], 'price' => $item['price'], ]); // تقليل المخزون Product::find($item['product_id'])->decrement('stock', $item['quantity']); } return $order; }); // التحكم اليدوي في المعاملة DB::beginTransaction(); try { $account = Account::find($accountId); $account->balance -= $amount; $account->save(); Transaction::create([ 'account_id' => $accountId, 'amount' => -$amount, 'type' => 'withdrawal', ]); DB::commit(); } catch (\Exception $e) { DB::rollBack(); throw $e; }
ملاحظة: يقوم Laravel تلقائياً بالتراجع عن المعاملات إذا تم طرح استثناء داخل إغلاق المعاملة. يمنحك التحكم اليدوي في المعاملة مزيداً من المرونة للسيناريوهات المعقدة.

المعاملات المتداخلة ونقاط الحفظ

يدعم Laravel المعاملات المتداخلة باستخدام نقاط الحفظ، مما يسمح بالتراجع الجزئي داخل معاملة أكبر.

DB::transaction(function () { // معاملة خارجية $order = Order::create(['user_id' => 1, 'total' => 0]); $total = 0; foreach ($items as $item) { DB::transaction(function () use ($order, $item, &$total) { // معاملة داخلية (نقطة حفظ) try { $orderItem = OrderItem::create([ 'order_id' => $order->id, 'product_id' => $item['id'], 'quantity' => $item['qty'], ]); $product = Product::lockForUpdate()->find($item['id']); if ($product->stock < $item['qty']) { throw new \Exception('مخزون غير كافٍ'); } $product->decrement('stock', $item['qty']); $total += $product->price * $item['qty']; } catch (\Exception $e) { // هذه المعاملة الداخلية تتراجع، ولكن الخارجية تستمر Log::warning("فشل إضافة العنصر {$item['id']}: " . $e->getMessage()); } }); } $order->update(['total' => $total]); }); // معاملة مع محاولات مخصصة DB::transaction(function () { // عمليات حرجة }, 5); // أعد المحاولة حتى 5 مرات عند حدوث جمود

القفل المتشائم

يقفل القفل المتشائم صفوف قاعدة البيانات طوال مدة المعاملة، مما يمنع التعديلات المتزامنة.

// قفل مشترك (قفل قراءة) - يسمح بقراءات أخرى لكن يمنع الكتابة $user = User::where('id', 1)->sharedLock()->first(); // قفل حصري (قفل كتابة) - يمنع جميع الوصول الآخر $product = Product::where('id', 5)->lockForUpdate()->first(); // تحويل الأموال بين الحسابات مع القفل DB::transaction(function () use ($fromAccountId, $toAccountId, $amount) { // قفل كلا الحسابين بترتيب متسق لمنع الجمود $accounts = Account::whereIn('id', [$fromAccountId, $toAccountId]) ->orderBy('id') ->lockForUpdate() ->get() ->keyBy('id'); $fromAccount = $accounts[$fromAccountId]; $toAccount = $accounts[$toAccountId]; if ($fromAccount->balance < $amount) { throw new \Exception('أموال غير كافية'); } $fromAccount->decrement('balance', $amount); $toAccount->increment('balance', $amount); TransactionLog::create([ 'from_account_id' => $fromAccountId, 'to_account_id' => $toAccountId, 'amount' => $amount, ]); }); // إدارة المخزون مع القفل المتشائم public function decreaseStock($productId, $quantity) { return DB::transaction(function () use ($productId, $quantity) { $product = Product::lockForUpdate()->findOrFail($productId); if ($product->stock < $quantity) { throw new InsufficientStockException(); } $product->decrement('stock', $quantity); StockLog::create([ 'product_id' => $productId, 'quantity' => -$quantity, 'type' => 'sale', ]); return $product; }); }
تحذير: اقفل السجلات دائماً بترتيب متسق (مثل حسب ID) لمنع حالات الجمود. إذا حاولت معاملتان قفل نفس السجلات بترتيبات مختلفة، يمكن أن يحدث جمود.

القفل المتفائل

يستخدم القفل المتفائل عمود الإصدار لاكتشاف التعارضات، مما يسمح بتزامن أفضل من القفل المتشائم.

// إضافة عمود الإصدار إلى الترحيل Schema::table('products', function (Blueprint $table) { $table->integer('version')->default(0); }); // نموذج مع القفل المتفائل namespace App\Models; use Illuminate\Database\Eloquent\Model; class Product extends Model { protected $fillable = ['name', 'price', 'stock', 'version']; public function decreaseStock($quantity) { $currentVersion = $this->version; $affected = static::where('id', $this->id) ->where('version', $currentVersion) ->where('stock', '>=', $quantity) ->update([ 'stock' => DB::raw("stock - {$quantity}"), 'version' => DB::raw('version + 1'), ]); if ($affected === 0) { throw new OptimisticLockException('تم تعديل المنتج بواسطة معاملة أخرى'); } $this->refresh(); return $this; } public function updateWithVersion(array $attributes) { $currentVersion = $this->version; $affected = static::where('id', $this->id) ->where('version', $currentVersion) ->update(array_merge($attributes, [ 'version' => $currentVersion + 1, ])); if ($affected === 0) { throw new OptimisticLockException('تم تعديل السجل بواسطة مستخدم آخر'); } $this->refresh(); return $this; } } // الاستخدام في المتحكم public function purchase(Request $request, $productId) { $maxRetries = 3; $attempt = 0; while ($attempt < $maxRetries) { try { $product = Product::findOrFail($productId); $product->decreaseStock($request->quantity); return response()->json(['message' => 'عملية شراء ناجحة']); } catch (OptimisticLockException $e) { $attempt++; if ($attempt >= $maxRetries) { return response()->json([ 'error' => 'غير قادر على إكمال الشراء. يرجى المحاولة مرة أخرى.', ], 409); } // تأخير صغير قبل إعادة المحاولة usleep(100000); // 100ms } } }
نصيحة: استخدم القفل المتفائل للسيناريوهات ذات احتمالية التعارض المنخفضة (مثل تحديثات المنتج). استخدم القفل المتشائم للسيناريوهات ذات التعارض العالي (مثل خصم المخزون، المعاملات المالية).

الأقفال الاستشارية

الأقفال الاستشارية هي أقفال على مستوى التطبيق لا تقفل صفوف قاعدة البيانات ولكنها تمنع التنفيذ المتزامن لعمليات محددة.

// أقفال MySQL الاستشارية use Illuminate\Support\Facades\DB; class AdvisoryLock { public static function acquire($name, $timeout = 10) { $result = DB::select( "SELECT GET_LOCK(?, ?) as locked", [$name, $timeout] ); return $result[0]->locked === 1; } public static function release($name) { $result = DB::select( "SELECT RELEASE_LOCK(?) as released", [$name] ); return $result[0]->released === 1; } public static function execute($name, callable $callback, $timeout = 10) { if (!static::acquire($name, $timeout)) { throw new \Exception("لم يتمكن من الحصول على القفل: {$name}"); } try { return $callback(); } finally { static::release($name); } } } // مثال على الاستخدام - التأكد من إنشاء تقرير واحد فقط في كل مرة public function generateReport($userId) { return AdvisoryLock::execute( "generate_report_{$userId}", function () use ($userId) { // منطق إنشاء التقرير $data = $this->fetchReportData($userId); $pdf = $this->createPdf($data); return $pdf; }, 30 // مهلة 30 ثانية ); } // أقفال PostgreSQL الاستشارية class PostgresAdvisoryLock { public static function acquire($key) { // يعيد true إذا تم الحصول على القفل، false إذا كان مقفلاً بالفعل $result = DB::select('SELECT pg_try_advisory_lock(?) as locked', [$key]); return $result[0]->locked; } public static function release($key) { DB::select('SELECT pg_advisory_unlock(?)', [$key]); } }

استراتيجيات منع الجمود

نفذ استراتيجيات لمنع ومعالجة حالات الجمود في تطبيقك.

// الاستراتيجية 1: قفل الموارد بترتيب متسق public function transferFunds($fromId, $toId, $amount) { DB::transaction(function () use ($fromId, $toId, $amount) { // اقفل الحسابات دائماً بترتيب ID تصاعدي $ids = [$fromId, $toId]; sort($ids); $accounts = Account::whereIn('id', $ids) ->orderBy('id') ->lockForUpdate() ->get() ->keyBy('id'); $fromAccount = $accounts[$fromId]; $toAccount = $accounts[$toId]; // إجراء التحويل $fromAccount->decrement('balance', $amount); $toAccount->increment('balance', $amount); }); } // الاستراتيجية 2: إعادة المحاولة عند الجمود use Illuminate\Database\QueryException; public function processOrder($orderId) { $maxAttempts = 3; $attempt = 0; while ($attempt < $maxAttempts) { try { return DB::transaction(function () use ($orderId) { $order = Order::lockForUpdate()->findOrFail($orderId); foreach ($order->items as $item) { $product = Product::lockForUpdate()->find($item->product_id); $product->decrement('stock', $item->quantity); } $order->update(['status' => 'processed']); return $order; }); } catch (QueryException $e) { // تحقق مما إذا كان الخطأ جموداً (رمز خطأ MySQL 1213) if ($e->getCode() == '40001' || strpos($e->getMessage(), 'Deadlock') !== false) { $attempt++; if ($attempt >= $maxAttempts) { throw $e; } // تأخير أسي usleep(pow(2, $attempt) * 100000); // 200ms, 400ms, 800ms continue; } throw $e; } } } // الاستراتيجية 3: اجعل المعاملات قصيرة // سيء - معاملة طويلة DB::transaction(function () { $order = Order::lockForUpdate()->find(1); // استدعاء API خارجي في المعاملة! $payment = PaymentGateway::charge($order->total); $order->update(['payment_id' => $payment->id]); }); // جيد - معاملة قصيرة $order = Order::find(1); $payment = PaymentGateway::charge($order->total); DB::transaction(function () use ($order, $payment) { $order = Order::lockForUpdate()->find($order->id); $order->update(['payment_id' => $payment->id]); });
تمرين 1: أنشئ نظام حجز مقاعد لحدث ما. استخدم القفل المتشائم لضمان أن مستخدماً واحداً فقط يمكنه حجز مقعد معين في وقت واحد. نفذ منع الجمود المناسب عن طريق قفل المقاعد بترتيب متسق.
تمرين 2: ابنِ نظام مصرفي مع تحويلات الحسابات. نفذ كل من نهج القفل المتشائم والمتفائل. قارن أدائهما تحت حمل متزامن باستخدام بذارة قاعدة البيانات التي تحاكي تحويلات متزامنة متعددة.
تمرين 3: أنشئ ميزة إنشاء تقرير تستخدم الأقفال الاستشارية لضمان إنشاء تقرير واحد فقط لكل مستخدم في وقت واحد. إذا جاء طلب ثانٍ أثناء إنشاء تقرير، ضعه في قائمة الانتظار أو أرجع رسالة خطأ مناسبة.