Transactions بين أكتر من نظام
الترانزاكشن بتحمي اللي جوه داتابيز واحدة بس. بس الأوردر الحقيقي بيلمس الداتابيز، و Queue، وبوابة دفع، و Service الشحن. الدرس ده بيشرح مشكلة الـ Dual Write، والـ Two-Phase Commit وليه قليل اللي بيستخدمه، والـ Saga، والـ Outbox Pattern اللي بيحل أغلب المشاكل دي ببساطة.
- تفهم مشكلة الـ Dual Write وليه مفيش ترتيب صح ليها
- تشرح Two-Phase Commit وليه بيقفل لو الـ Coordinator وقع
- تصمم Saga بخطوات تعويض (Compensations)
- تطبّق Transactional Outbox في Laravel
- تعمل Consumers Idempotent بالـ Inbox
1. مشكلة الـ Dual Write
الأوردر اتعمل، ولازم نبعت Event لـ Kafka أو RabbitMQ أو Redis Queue عشان الإيميل والشحن والـ Analytics. المشكلة إن الداتابيز والـ Queue نظامين منفصلين، ومفيش ترانزاكشن واحدة بتلمّهم:
BEGIN; INSERT INTO orders ...; COMMIT;الأوردر اتثبت1⚡ السيرفر وقع (أو الـ Queue واقعة)- 3
publish(OrderPlaced)ماتبعتش
- 1
publish(OrderPlaced)اتبعت BEGIN; INSERT INTO orders ...; COMMIT;ERROR: deadlock / constraint2- 3
consumer: SendConfirmationEmailOrder not found
دوس ▶ شغّل، أو امشي خطوة خطوة بالأسهم.
مفيش ترتيب صح بين كتابتين في نظامين، لأن أي واحد فيهم ممكن يفشل بعد ما التاني نجح. ونفس المشكلة مع أي نظامين: الداتابيز و Elasticsearch، أو الداتابيز و Redis Cache، أو داتابيزين لـ Services مختلفة.
كتابتين في نظامين منفصلين عمرهم ما هيبقوا Atomic مع بعض لوحدهم. محتاج بروتوكول أو Pattern.
2. Two-Phase Commit (2PC)
- Prepare: الـ Coordinator بيسأل كل Participant: “تقدر تعمل COMMIT؟”. كل واحد بيعمل كل الشغل، ويكتب في الـ Log على الديسك إنه جاهز، ويحتفظ بالـ Locks، ويرد “آه” أو “لأ”. وبعد ما يرد “آه” مايقدرش يغيّر رأيه.
- Commit: لو الكل قال “آه”، الـ Coordinator بيكتب قراره في الـ Log وبيقول للكل “COMMIT”. لو حد قال “لأ” أو ماردش، بيقول للكل “ROLLBACK”.
-- PostgreSQL (محتاج max_prepared_transactions > 0، والافتراضي 0)
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
PREPARE TRANSACTION 'transfer-778'; -- المرحلة الأولى: الترانزاكشن بقت "In-doubt" على الديسك
-- ... الـ Coordinator بيستنى باقي المشاركين
COMMIT PREPARED 'transfer-778'; -- أو ROLLBACK PREPARED 'transfer-778'
-- MySQL: XA
XA START 'transfer-778';
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
XA END 'transfer-778';
XA PREPARE 'transfer-778';
XA COMMIT 'transfer-778'; -- أو XA ROLLBACKالمشكلة الكبيرة: الـ Blocking. لو الـ Coordinator وقع بعد ما الكل عمل Prepare وقبل ما يبعت القرار، كل Participant بقى ماسك ترانزاكشن “In-doubt”: مايقدرش يعمل COMMIT (ممكن الباقي يكون اتلغى) ولا ROLLBACK (ممكن الباقي يكون اتثبت). فبيفضل ماسك الـ Locks لحد ما الـ Coordinator يرجع، حتى لو ساعات. وكمان كل الأطراف لازم يدعموا البروتوكول، والـ Latency بتزيد (رحلتين على الشبكة + fsync في كل مرحلة).
علشان كده الـ 2PC بين داتابيزات بيستخدم جوه أنظمة متخصصة (زي MySQL نفسه بين الـ Redo والـ Binlog، أو داتابيزات موزعة زي Spanner بيعملوا Consensus تحت الـ 2PC عشان الـ Coordinator مايقعش)، لكن نادراً في Microservices عادية. وأغلب الـ Queues والـ APIs أصلاً مابتدعموش.
2PC بيدّي Atomicity حقيقية بين كذا نظام، بس الكل لازم يدعمه، وأي وقوع للـ Coordinator في اللحظة الغلط بيقفل الـ Locks لحد ما يرجع.
3. Saga
مثال الأوردر:
| الخطوة | الـ Service | الترانزاكشن | التعويض لو حاجة بعدها فشلت |
|---|---|---|---|
| 1 | Orders | اعمل أوردر pending |
خلّي الأوردر cancelled |
| 2 | Inventory | احجز المخزون | رجّع المخزون |
| 3 | Payments | اخصم الفلوس | رجّع الفلوس (Refund) |
| 4 | Shipping | اعمل شحنة | — (آخر خطوة) |
لو الدفع (3) فشل: نرجّع المخزون (تعويض 2)، ونلغي الأوردر (تعويض 1).
حاجات لازم تعرفها عن الـ Saga:
- مفيش Isolation: بين الخطوات، الأنظمة التانية بتشوف الحالات الوسطانية (أوردر
pending، ومخزون محجوز لسه مادفعش). ولازم تصمم الحالات دي صراحة. - التعويض مش ROLLBACK: الإيميل اللي اتبعت مش بيرجع. التعويض بيبقى “ابعت إيميل اعتذار”. والفلوس اللي اتخصمت بترجع بـ Refund، وده ممكن يفشل هو كمان ويتعاد.
- كل خطوة وتعويضها لازم يبقوا Idempotent، لأن الرسائل ممكن توصل مرتين.
- شكلين: Orchestration (Service مركزية بتقول لكل واحد يعمل إيه، وبتتابع الحالة، وأسهل في الفهم والـ Debugging)، و Choreography (كل Service بتسمع Events وترد بـ Events من غير مركز، ومناسبة للعمليات البسيطة).
4. Transactional Outbox
ده الحل اللي بيحل مشكلة الـ Dual Write في 90% من الحالات، من غير 2PC ومن غير أي حاجة غير الداتابيز:
Schema::create('outbox', function (Blueprint $table) {
$table->id();
$table->uuid('event_id')->unique(); // الـ Consumers بيستخدموه عشان يتجاهلوا التكرار
$table->string('type'); // OrderPlaced
$table->json('payload');
$table->timestamp('published_at')->nullable()->index();
$table->timestamps();
});DB::transaction(function () use ($cart, $user) {
$order = Order::create([...]);
// نفس الترانزاكشن: لو الأوردر اتلغى، الـ Event يتلغى معاه
Outbox::create([
'event_id' => (string) Str::uuid(),
'type' => 'OrderPlaced',
'payload' => ['order_id' => $order->id, 'total' => $order->total],
]);
});// بيشتغل كل ثانية أو كـ Worker دايم. كذا نسخة منه شغالين مع بعض من غير ما ياخدوا نفس الصفوف
public function handle(): void
{
DB::transaction(function () {
$events = Outbox::whereNull('published_at')
->orderBy('id')
->limit(100)
->lock('for update skip locked')
->get();
foreach ($events as $event) {
Queue::pushRaw(json_encode([
'event_id' => $event->event_id,
'type' => $event->type,
'payload' => $event->payload,
]), 'events');
}
Outbox::whereIn('id', $events->pluck('id'))->update(['published_at' => now()]);
});
}لاحظ إن الـ Relay برضه بيعمل Dual Write (يبعت للـ Queue، وبعدين يعلّم في الداتابيز). لو وقع بين الاتنين، الـ Event هيتبعت تاني في المرة الجاية. يعني الـ Outbox بيدّيك At-least-once delivery: كل Event هيوصل مرة على الأقل، وممكن أكتر. ودي مقبولة بشرط إن اللي بيستقبل يبقى Idempotent.
وبدل الـ Relay اللي بيعمل Polling، ممكن CDC: أداة زي Debezium بتقرا الـ WAL أو الـ Binlog وتبعت كل صف جديد في outbox لـ Kafka مباشرة، من غير Polling ومن غير حمل على الجدول.
5. الطرف التاني: Idempotent Consumers و Inbox
لأن الرسائل ممكن توصل مرتين، الـ Consumer لازم يتأكد إنه ماعالجش الـ Event ده قبل كده، في نفس الترانزاكشن اللي بيعمل فيها الشغل:
public function handle(): void
{
DB::transaction(function () {
// جدول processed_events فيه event_id عليه Unique
$first = ProcessedEvent::createOrFirst(['event_id' => $this->eventId])->wasRecentlyCreated;
if (! $first) {
return; // اتعالج قبل كده: تجاهل
}
Shipment::create(['order_id' => $this->payload['order_id']]);
});
}ده اسمه Inbox أو Idempotent Consumer. ولو العملية Idempotent بطبيعتها (زي “خلّي الحالة = shipped”)، مش محتاج جدول.
الـ Outbox: الداتا والـ Event في نفس الترانزاكشن، والإرسال بعدين. والـ Inbox: سجل اللي اتعالج في نفس ترانزاكشن الشغل. ومع بعض: At-least-once delivery + Idempotency = Exactly-once processing من وجهة نظر البيزنس.
6. تختار إيه؟
| الموقف | الحل |
|---|---|
| تعديل الداتابيز + إرسال Event أو Job | Outbox (أو afterCommit لو خسارة رسالة نادرة مقبولة) |
| عملية بين كذا Service، كل واحد ليه داتابيز | Saga + Outbox في كل Service |
| كذا داتابيز لازم يتثبتوا مع بعض حرفياً، وكلهم بيدعموا XA | 2PC، وبحذر |
| تحديث Cache أو Search Index بعد التعديل | Outbox أو CDC، أو afterCommit مع TTL كأمان |
7. أسئلة الإنترفيو
فكّر في إجابتك الأول، وبعدين افتح السؤال.
01يعني إيه Dual Write problem؟
لما عملية لازم تكتب في نظامين منفصلين زي الداتابيز والـ Queue. مفيش ترتيب آمن: لو الداتابيز الأول والإرسال فشل يبقى داتا من غير Event، ولو الـ Queue الأول والترانزاكشن فشلت يبقى Event لداتا مش موجودة.
02اشرح Two-Phase Commit وعيبه الأساسي.
Coordinator بيسأل كل Participant يعمل Prepare: يجهز ويكتب في الـ Log إنه جاهز ويمسك الـ Locks ويرد. لو الكل وافق، يقول للكل COMMIT، وإلا ROLLBACK. العيب إنه Blocking: لو الـ Coordinator وقع بعد الـ Prepare، الـ Participants يفضلوا ماسكين الـ Locks مش عارفين يعملوا COMMIT ولا ROLLBACK. وكمان كل الأطراف لازم يدعموه.
03الفرق بين Saga و 2PC؟
2PC بيدّي Atomicity حقيقية وبيمسك Locks لحد ما الكل يتفق. Saga بيقسم العملية لترانزاكشنز محلية كل واحدة بتعمل COMMIT لوحدها، ولو حاجة فشلت بيشغّل Compensations بالعكس. Saga مفيهوش Isolation (الحالات الوسطانية ظاهرة)، بس مش بيقفل حاجة ومناسب للـ Microservices.
04اشرح الـ Transactional Outbox.
بدل ما تبعت الـ Event للـ Queue مباشرة، تكتبه في جدول outbox في نفس ترانزاكشن الداتا، فيا الاتنين يتثبتوا يا الاتنين يتلغوا. Process منفصلة (Relay أو CDC زي Debezium) بتقرا الجدول وتبعت للـ Queue. بيدّي At-least-once، فالـ Consumers لازم يبقوا Idempotent.
05إزاي تعمل Consumer Idempotent؟
كل Event ليه event_id ثابت، والـ Consumer بيسجله في جدول عليه Unique في نفس الترانزاكشن اللي بيعمل فيها الشغل. لو التسجيل فشل لأنه موجود، يبقى اتعالج قبل كده فيتجاهله. أو تخلي العملية نفسها Idempotent بطبيعتها، زي set status بدل increment.