بنناجح
المرحلة 3: في الشغل · الدرس 3 من 3 · 35 دقيقة

Patterns وأسئلة إنترفيو

المسائل اللي بتتكرر في أي Backend: مخزون، وحجز كراسي، ومحفظة فلوس، وعدادات، وطلب اتبعت مرتين. لكل واحدة الحل المناسب ليه، وتمنه. وفي الآخر أسئلة إنترفيو من Mid لحد Staff، وملخص للموضوع كله.

في الدرس ده هتتعلم
  • تحل مسائل المخزون و الحجز و المحفظة بالأداة المناسبة
  • تتعامل مع Hot Rows والعدادات اللي عليها ضغط عالي
  • تستخدم Idempotency Keys و Advisory Locks
  • تجاوب أسئلة الإنترفيو في الموضوع كله

1. المخزون والحجز المؤقت

المسألة: Flash Sale، 100 قطعة، و 10,000 واحد بيدوسوا “اشتري” في نفس الثانية. وكمان المستخدم بيحط في السلة ويدفع بعد دقيقتين.

المرحلة 1: الخصم نفسه. UPDATE ذري بشرط. مفيش أسرع ولا أأمن منه:

reserve.sql
UPDATE products SET stock = stock - :qty
WHERE id = :id AND stock >= :qty;
-- 1 row = اتحجز · 0 rows = خلص

المرحلة 2: الحجز لحد الدفع. متخصمش من stock وتستنى الدفع جوه ترانزاكشن مفتوحة (Lock دقيقتين = كارثة). اعمل Reservation بعمر:

reservations.php
DB::transaction(function () use ($product, $qty, $user) {
    $ok = Product::whereKey($product->id)
        ->where('stock', '>=', $qty)
        ->decrement('stock', $qty);

    if (! $ok) throw new OutOfStockException();

    Reservation::create([
        'product_id' => $product->id,
        'user_id'    => $user->id,
        'qty'        => $qty,
        'expires_at' => now()->addMinutes(10),
    ]);
});

// Job كل دقيقة: رجّع المخزون للحجوزات اللي عمرها خلص ومادفعتش
Reservation::where('expires_at', '<', now())->whereNull('paid_at')
    ->lock('for update skip locked')->limit(500)->get()
    ->each(fn ($r) => DB::transaction(function () use ($r) {
        Product::whereKey($r->product_id)->increment('stock', $r->qty);
        $r->delete();
    }));

وفي الآخر: CHECK (stock >= 0) في الداتابيز كخط دفاع أخير. لو أي كود في أي مكان نسي الشرط، الداتابيز ترفض بدل ما المخزون يبقى سالب (MySQL بيطبّق CHECK من 8.0.16).

ولو الضغط ضخم جداً على صف واحد (آلاف في الثانية)، كل الطلبات بتصطف على Row Lock واحد، والـ Throughput بيبقى محدود بسرعة ترانزاكشن واحدة. الحلول: قسّم المخزون على كذا صف (مثلاً 10 صفوف كل واحد فيه 10 قطع، وكل طلب يختار صف عشوائي)، أو اعمل الحجز الأول في Redis (DECR ذري) والداتابيز بعدين، أو طابور (Queue) بيعالج الطلبات ورا بعض.

2. حجز الكراسي والمواعيد

المسألة: كرسي في سينما، أو ميعاد عند دكتور. اتنين ميحجزوش نفس الحاجة.

الحل إمتى الشكل
Unique Constraint الحجز = صف جديد، والمكان والوقت ثابتين UNIQUE (show_id, seat_id) و createOrFirst أو INSERT وتلقط الـ Duplicate
صف لكل مكان + FOR UPDATE NOWAIT الكراسي موجودة كصفوف وبتتعدّل UPDATE seats SET user_id = ? WHERE id = ? AND user_id IS NULL (ذري) أو FOR UPDATE NOWAIT عشان ترد فوراً “حد بيحجزه”
Exclusion Constraint (PostgreSQL) الأوقات متغيرة ومتداخلة (ميعاد من 10:15 لـ 10:45) EXCLUDE USING gist (doctor_id WITH =, during WITH &&)
SERIALIZABLE القاعدة معقدة ومش ممكن تتعمل Constraint مع Retry
exclusion.sql
-- PostgreSQL: مستحيل يتحجز ميعادين متداخلين لنفس الدكتور، مهما كان الـ Isolation Level
CREATE EXTENSION IF NOT EXISTS btree_gist;

CREATE TABLE appointments (
    id        bigserial PRIMARY KEY,
    doctor_id bigint    NOT NULL,
    during    tstzrange NOT NULL,
    EXCLUDE USING gist (doctor_id WITH =, during WITH &&)
);
-- INSERT متداخل ← ERROR: conflicting key value violates exclusion constraint

لو القاعدة ممكن تتكتب Constraint، اكتبها Constraint. الداتابيز بتضمنها تحت أي ضغط وأي Isolation Level وأي كود بيكتب في الجدول.

3. المحفظة والفلوس: Ledger

متخزنش الرصيد بس كرقم بيتعدّل. اعمل Ledger: كل حركة صف جديد مش بيتعدّل (Append-only)، والرصيد = مجموع الحركات. وغالباً بتحتفظ برصيد محسوب كمان للسرعة، وبتحدّثه ذرياً في نفس الترانزاكشن:

ledger.php
DB::transaction(function () use ($wallet, $amount, $reference) {
    // 1) الخصم الذري بشرط: مفيش سحب أكتر من الرصيد
    $ok = Wallet::whereKey($wallet->id)
        ->where('balance', '>=', $amount)
        ->decrement('balance', $amount);

    if (! $ok) throw new InsufficientFundsException();

    // 2) الحركة نفسها، و reference عليه Unique (نفس العملية متتسجلش مرتين)
    LedgerEntry::create([
        'wallet_id' => $wallet->id,
        'amount'    => -$amount,
        'reference' => $reference,
    ]);
}, attempts: 3);

// Migration: خطوط دفاع في الداتابيز نفسها
$table->bigInteger('balance');                       // بالقروش، مش float
DB::statement('ALTER TABLE wallets ADD CONSTRAINT balance_non_negative CHECK (balance >= 0)');
$table->unique('reference');                         // في ledger_entries

فوايد الـ Ledger: تقدر تراجع أي رصيد (مجموع الحركات = الرصيد المحفوظ؟)، وعندك تاريخ كامل، ومفيش UPDATE على الماضي، والتصحيح بيبقى حركة عكسية جديدة.

4. العدادات (Hot Counters)

عداد مشاهدات منتج مشهور: 5,000 UPDATE ... SET views = views + 1 في الثانية على صف واحد. ذري وصح، بس كل واحد مستني اللي قبله على نفس الـ Row Lock، وفي PostgreSQL كل UPDATE نسخة جديدة (Dead Tuples).

الحل الفكرة التمن
Sharded Counter 16 صف للعداد، كل زيارة تزود صف عشوائي، والإجمالي = SUM قراية أغلى شوية
تجميع في Redis INCR في Redis، و Job كل دقيقة ينقل الإجمالي للداتابيز ممكن تخسر آخر دقيقة لو Redis وقع
جدول أحداث INSERT لكل زيارة (مفيش تعارض بين INSERTs)، والعد بعدين مساحة أكبر

5. الطلب اللي اتبعت مرتين: Idempotency Keys

المستخدم دوس “ادفع”، والنت قطع، والتطبيق عمل Retry. أو دوس مرتين. الـ Concurrency Control جوه الداتابيز مش هيلاحظ: دول طلبين منفصلين وسليمين.

الحل: الـ Client يبعت Idempotency Key (UUID بيتعمل مرة لكل عملية) في Header. والسيرفر يسجّله بـ Unique، ولو اتكرر يرجّع نفس الرد الأول.

idempotency.php
// Header: Idempotency-Key: 7f9c...-...
$key = $request->header('Idempotency-Key');

$record = IdempotencyKey::createOrFirst(
    ['key' => $key, 'user_id' => $user->id],
    ['status' => 'processing'],
);

if (! $record->wasRecentlyCreated) {
    // طلب مكرر: رجّع النتيجة اللي اتحفظت، أو 409 لو لسه بيتنفذ
    return $record->status === 'done'
        ? response()->json($record->response, $record->http_status)
        : response()->json(['message' => 'Request in progress'], 409);
}

$response = $this->charge($request);   // العملية الحقيقية مرة واحدة بس

$record->update(['status' => 'done', 'response' => $response->getData(true), 'http_status' => $response->status()]);

return $response;

ده بالظبط اللي Stripe بيعمله في الـ API بتاعه. والفكرة الأساسية: الـ Unique Constraint على الـ Key هو اللي بيحل السباق بين الطلبين.

6. Advisory Locks

لما عايز Lock على فكرة مش على صف: “مفيش اتنين يعملوا Checkout لنفس السلة”، أو “Job الحسابات الشهرية يشتغل مرة واحدة”. ومن غير ما تعمل جدول مخصوص، ومن غير Redis:

advisory.sql
-- PostgreSQL: Lock على رقم بتختاره، بيتفك لوحده مع آخر الترانزاكشن
BEGIN;
SELECT pg_advisory_xact_lock(hashtext('checkout:cart:42'));
-- ... الشغل
COMMIT;

-- من غير استنا: true لو خدته، false لو مع حد
SELECT pg_try_advisory_xact_lock(hashtext('monthly-billing'));

-- MySQL: Lock بالاسم، على مستوى الـ Session (لازم تفكه بنفسك)
SELECT GET_LOCK('checkout:cart:42', 5);    -- استنى 5 ثواني · 1 = خدته
SELECT RELEASE_LOCK('checkout:cart:42');

خلي بالك: الـ Advisory Locks في MySQL مربوطة بالـ Connection مش بالترانزاكشن، فلو الـ Connection رجعت للـ Pool من غير RELEASE_LOCK، الـ Lock بيفضل. والـ pg_advisory_lock (من غير xact) في PostgreSQL نفس الحكاية. فضّل نسخة الترانزاكشن لما تقدر.

Constraint لو القاعدة على الداتا، UPDATE ذري لو الحسبة بسيطة، Row Lock لو المنطق معقد، Version لو المستخدم بياخد وقت، Idempotency Key لو الطلب ممكن يتكرر، و Advisory Lock لو بتحمي عملية مش صف.

7. أسئلة إنترفيو

Mid

عندك endpoint بيخصم من المخزون، وفي الـ Flash Sale اتباع أكتر من المتاح. إيه السبب وإيه الحل؟

  • السبب الأرجح Lost Update: الكود بيقرا الـ stock في PHP، ويتأكد إنه أكبر من صفر، ويكتب القيمة المحسوبة. طلبين يقروا نفس القيمة والاتنين يكتبوا.
  • الحل الأفضل: UPDATE ذري بشرط SET stock = stock - :qty WHERE id = :id AND stock >= :qty وتشوف عدد الصفوف.
  • لو المنطق أعقد: SELECT ... FOR UPDATE جوه ترانزاكشن.
  • خط دفاع أخير: CHECK (stock >= 0).
  • وتتأكد بـ Test تحت ضغط متوازي، مش Test عادي.
Mid

الفرق بين Pessimistic و Optimistic Locking؟ وإمتى تستخدم كل واحد؟

  • Pessimistic: تقفل قبل ما تعدّل (FOR UPDATE)، واللي بعدك يستنى. أحسن لما التعارض كتير (Hot Rows) والترانزاكشن قصيرة.
  • Optimistic: متقفلش، وفي الكتابة اشترط إن محدش غيّر (WHERE version = ?)، ولو 0 rows أعد. أحسن لما التعارض نادر، أو لما الوقت بين القراية والكتابة طويل (فورم بيتعدّل في دقايق) لأنك مينفعش تمسك Lock والمستخدم بيفكر.
  • تحت تعارض عالي، الـ Optimistic بيضيّع شغل كتير في Retries.
Senior

اشرح Write Skew، وإزاي تحميه في MySQL وفي PostgreSQL.

  • ترانزاكشنين يقروا نفس الداتا، كل واحد يتأكد من قاعدة، وكل واحد يعدّل صف مختلف. كل واحد لوحده سليم، والاتنين مع بعض بيكسروا القاعدة. مثال: آخر دكتورين نوبتجية يخرجوا مع بعض.
  • Snapshot Isolation (يعني REPEATABLE READ في الاتنين) مش بيشوفها، لأن مفيش تعارض كتابة على نفس الصف.
  • PostgreSQL: SERIALIZABLE (SSI) بيكشفها ويرمي 40001، ومعاه Retry.
  • MySQL: FOR UPDATE على كل الصفوف اللي القرار اتبنى عليها، أو SERIALIZABLE (اللي بيحوّل القراية لـ FOR SHARE، فواحد هيستنى أو يحصل Deadlock ويتعاد).
  • في الاتنين: لو ممكن، حوّل القاعدة لـ Constraint (UNIQUE أو EXCLUDE أو CHECK)، أو اعمل صف تقفل عليه (Materialize the Conflict).
Senior

PostgreSQL REPEATABLE READ و MySQL REPEATABLE READ: نفس الاسم، إيه الفرق؟

  • PostgreSQL: Snapshot Isolation حقيقي. لو عدّلت صف اتعدّل واتعمله COMMIT بعد الـ Snapshot بتاعتك: Error 40001 (First-Updater-Wins). فالـ Lost Update ممنوعة، والـ Write Skew لأ.
  • MySQL InnoDB: القراية العادية من Read View ثابتة، بس الكتابة (UPDATE، DELETE، FOR UPDATE) بتعمل Current Read على آخر نسخة من غير Error. فالـ Read-Modify-Write بـ SELECT عادي بيعمل Lost Update بصمت.
  • كمان InnoDB بياخد Next-Key/Gap Locks على الكتابات بشروط Range، و PostgreSQL مش بيعمل Gap Locks خالص.
  • النتيجة: نفس الكود ممكن يبقى آمن (بـ Retry) على PostgreSQL ومش آمن على MySQL.
Senior

اشرح MVCC، وإيه علاقته بالـ VACUUM والترانزاكشنز الطويلة.

  • كل كتابة بتعمل نسخة جديدة من الصف، والقراية بتختار النسخة المناسبة للـ Snapshot بتاعتها. فالقراية مش بتستنى الكتابة ولا العكس.
  • PostgreSQL: النسخ في الجدول نفسه بـ xmin/xmax، والقديمة بيمسحها VACUUM. InnoDB: النسخة الحالية في الجدول والقديمة في الـ Undo Log، والـ Purge بينضّف.
  • التنضيف مش يقدر يمسح نسخة ممكن أقدم Snapshot مفتوحة لسه محتاجاها. فترانزاكشن واحدة مفتوحة ساعات (idle in transaction) بتوقف التنضيف في كل الداتابيز: Bloat، و Indexes أكبر، وكويريز أبطأ، و History list length كبير في InnoDB.
  • الحل: ترانزاكشنز قصيرة، ومراقبة pg_stat_activity، و idle_in_transaction_session_timeout.
Senior

حصل Deadlock بين تحويلين. اشرح ليه حصل، وإزاي تمنعه، والكود يتعامل إزاي.

  • T1 (علي → منى) قفلت علي واستنت منى. T2 (منى → علي) قفلت منى واستنت علي. دايرة في الـ Wait-for Graph، والداتابيز لغت واحدة.
  • المنع: اقفل الحسابين بترتيب ثابت (بالـ id) قبل أي تعديل، فمفيش دايرة ممكنة.
  • كمان: ترانزاكشنز قصيرة، و Indexes على أعمدة الشروط، و FOR UPDATE من الأول بدل FOR SHARE وترقية.
  • الكود: Retry للترانزاكشن كلها على 1213/40001 (MySQL) أو 40P01 (PostgreSQL)، بعدد محاولات محدود. في Laravel: DB::transaction($fn, attempts: 3)، ومن غير side effects جواها.
Senior

Job بيبعت إيميل تأكيد الأوردر أحياناً بيفشل بـ ModelNotFoundException، وأحياناً بيتبعت لأوردر اتلغى. ليه؟

  • الـ Job اتبعت للـ Queue جوه الترانزاكشن، قبل الـ COMMIT.
  • الـ Worker شغّله بسرعة قبل الـ COMMIT، فالأوردر لسه مش ظاهر لأي Connection تانية (الـ Isolation شغالة صح): ModelNotFound.
  • ولو الترانزاكشن عملت ROLLBACK بعد ما الـ Job اتبعت، الإيميل بيتبعت لأوردر مش موجود.
  • الحل: dispatch(...)->afterCommit() أو 'after_commit' => true في الـ Queue، أو ShouldDispatchAfterCommit على الـ Event. ونفس الفكرة لأي side effect: Webhooks، وCache invalidation، وأي API.
  • وفيه حل أقوى لو لازم الضمان يبقى 100%: Transactional Outbox، يعني تكتب الرسالة في جدول جوه نفس الترانزاكشن، و Process تانية تبعتها.
Staff

صمّم نظام حجز تذاكر لحفلة: 50,000 تذكرة، و 500,000 مستخدم أول دقيقة.

  • متخليش الداتابيز هي البوابة: Virtual Queue (طابور انتظار) قدام الحجز، يدخّل الناس بمعدل الداتابيز تقدر عليه.
  • الحجز المؤقت: تذكرة بتتحجز 10 دقايق لحد الدفع (Reservation بـ expires_at)، والحجز نفسه UPDATE ذري أو FOR UPDATE SKIP LOCKED على صفوف التذاكر المتاحة: كل طلب ياخد تذكرة مختلفة من غير ما يستنى.
  • تقسيم الضغط: بدل عداد واحد للتذاكر المتاحة (Hot Row)، صفوف لكل قسم أو لكل مجموعة، أو عداد ذري في Redis كفلتر أولي والداتابيز مصدر الحقيقة.
  • Constraints: UNIQUE (event_id, seat_id) على الحجوزات، و CHECK على العدد.
  • الدفع: Idempotency Keys مع الـ Payment Provider، ومنطق Outbox للإيميلات والتذاكر.
  • التنضيف: Job بـ SKIP LOCKED يرجّع الحجوزات اللي عمرها خلص.
  • المراقبة: Lock waits، و Deadlocks، و 40001، و p99 latency على endpoint الحجز.
Staff

امتى ترفع الـ Isolation Level لـ SERIALIZABLE، وامتى لأ؟

  • PostgreSQL: SERIALIZABLE (SSI) أداؤه قريب من REPEATABLE READ، وبيحمي من كل الـ Anomalies بما فيها Write Skew. ينفع لترانزاكشنز فيها قواعد معقدة على كذا صف، بشرط كل الترانزاكشنز اللي بتلمس الداتا دي تبقى SERIALIZABLE، والكود يعمل Retry على 40001، والـ Indexes كويسة (وإلا الـ SIREAD بيبقى على الجدول كله و False Positives كتير).
  • MySQL: SERIALIZABLE = Locks على كل قراية، فالتوازي بيقل والـ Deadlocks بتزيد. غالباً الأحسن تفضل على READ COMMITTED أو REPEATABLE READ وتحمي الأماكن الحساسة بـ FOR UPDATE.
  • الأفضل في الحالتين: Constraints لو القاعدة ممكن تتكتب، وUPDATEs ذرية للحسابات البسيطة، و SERIALIZABLE للحالات اللي فاضلة.

8. ملخص سريع

المشكلة

  • Isolation = كل ترانزاكشن كأنها لوحدها.
  • الهدف: Serializable = النتيجة زي تشغيلهم ورا بعض.
  • تعارض = ترانزاكشنين مختلفين + نفس العنصر + كتابة واحدة على الأقل.
  • مفيش Cycle في الـ Precedence Graph = Conflict Serializable.

الـ Anomalies

  • Dirty Read: قريت حاجة مش متثبتة.
  • Non-repeatable: نفس الصف قيمتين.
  • Phantom: نفس الشرط صفوف مختلفة.
  • Lost Update: Read-Modify-Write مسح تحديث.
  • Read Skew: x قديم و y جديد.
  • Write Skew: قرار على قراية، وكتابة في صف تاني.

الآليات

  • 2PL: خد كل الـ Locks قبل ما تفك أي واحد. Strict = الـ X لحد الـ COMMIT.
  • Gap / Next-Key Locks: InnoDB بيقفل المسافات في الـ Index ضد الـ Phantoms.
  • MVCC: نسخ كتير. القراية مش بتستنى. التنضيف VACUUM/Purge.
  • OCC / version: اشتغل واتأكد في الآخر.
  • SSI: Snapshot + مراقبة rw → 40001.

Deadlocks

  • دايرة في Wait-for Graph → الداتابيز تلغي واحدة.
  • InnoDB فوري (1213) · PostgreSQL بعد ثانية (40P01).
  • رتّب الـ Locks، قصّر الترانزاكشن، FOR UPDATE من الأول.
  • Retry للترانزاكشن كلها.

MySQL و PostgreSQL

  • الافتراضي: MySQL RR · PostgreSQL RC.
  • PG RR = Snapshot Isolation، Lost Update ← 40001.
  • MySQL RR: الكتابة Current Read، Lost Update بتعدّي.
  • PG SERIALIZABLE = SSI · MySQL SERIALIZABLE = SELECT ← FOR SHARE.

Laravel

  • DB::transaction($fn, attempts: 3) · الـ Closure كلها بتتعاد.
  • where('stock','>=',$q)->decrement('stock',$q) · شوف الـ affected.
  • lockForUpdate() جوه ترانزاكشن بس.
  • createOrFirst / upsert + Unique Index.
  • ->afterCommit() للـ Jobs والإيميلات.
  • Cache::lock · WithoutOverlapping · ->lock('for update skip locked').

أي أداة لأي مسألة

  • قاعدة على الداتا ← Constraint.
  • حسبة بسيطة ← UPDATE ذري بشرط.
  • منطق معقد قصير ← FOR UPDATE.
  • المستخدم بياخد وقت ← version.
  • طلب ممكن يتكرر ← Idempotency Key.
  • عملية مش صف ← Advisory / Cache Lock.
  • Write Skew في PG ← SERIALIZABLE + Retry.
علّم عليه لما تقدر تجاوب أسئلة الإنترفيو من غير ما تبص.