عندما تشحن محفظتك في تطبيق توصيل بمئة ريال ثم تدفع منها ثمن طلبين، فأنت تتوقع شيئاً واحداً بسيطاً: أن يكون الرصيد صحيحاً بالهللة. ولكن خلف هذا التوقع أسئلة لا تظهر في البداية: ماذا لو ضغط العميل «ادفع» مرتين؟ ماذا لو وصل طلبان للخصم في اللحظة نفسها؟ ماذا لو قال مزود الدفع إن الشحن فشل بعد أن أضفنا الرصيد؟ وأغلب الأنظمة تبدأ بعمود balance في جدول المستخدمين، ثم تكتشف هذه الأسئلة واحداً بعد الآخر في بيئة الإنتاج، ومع أموال حقيقية. وفي مقال محرك التوزيع ذكرنا أن كل طلب يغير شيئاً يحتاج مفتاح منع التكرار، وفي هذا المقال سوف نبني ذلك مع بقية ما تحتاجه المحفظة في قاعدة بيانات حقيقية، وسوف نناقش ما يلي:
- لماذا لا يكفي عمود الرصيد، وما هو دفتر القيد المزدوج Double-Entry Ledger بلغة المهندس.
- المعمارية الكاملة للمحفظة: المكونات ونموذج البيانات والواجهات وتقدير السعة ومراحل النمو والأعطال.
- الدفتر الذي لا يقبل التعديل Immutable Ledger، والرصيد المحسوب مقابل الرصيد المحفوظ.
- المبالغ بأعداد صحيحة بالهللة بدل الكسور.
- مفتاح منع التكرار Idempotency Key داخل المعاملة نفسها.
- السباق بين طلبين على الرصيد نفسه، ومستويات العزل Isolation Levels، و SELECT ... FOR UPDATE.
- الحجز Hold والتحصيل Capture والاسترداد Refund.
- المطابقة Reconciliation مع مزود الدفع PSP والبنك.
- ما يقوله نظام المدفوعات في المملكة ومعيار PCI DSS باختصار.
- مختبر كامل على PostgreSQL 18.6 بمخرجات حقيقية، فيه سباق يضيع فيه 95% من الدفعات.
لماذا لا يكفي عمود الرصيد؟
الحل البسيط أن تضيف عمود balance إلى جدول المستخدمين، فإذا دفع العميل خمسة وعشرين ريالاً نقصتها من العمود. وهذا الحل يعمل في العرض التجريبي، ولكنه يفشل في ثلاثة أشياء: الأول أنك لا تعرف لماذا الرصيد 75 وليس 80، لأن العمود يحفظ النتيجة ويرمي السبب، والثاني أن أي خطأ في الكود أو تعديل يدوي يغير المال دون أثر، والثالث أن المال في النظام كله لم يعد يخضع لأي معادلة تستطيع أن تتحقق منها، فإذا زاد رصيد أحد فلا تعرف من أين جاءت الزيادة. والمحاسبون حلوا هذه المشكلة قبل الحاسب بقرون بفكرة بسيطة: لا يتحرك المال إلا من حساب إلى حساب، ويسجل كل تحرك مرتين.
المعمارية الكاملة: كيف تتصل الأجزاء ببعضها؟
قبل أن ندخل في القيد المزدوج والأقفال والمطابقة، سوف نرى المحفظة كاملة كنظام، والسبب أن الدفتر نفسه جدولان ودالة، ولكن حوله خدمات تطلب منه أن يكتب، ومزود دفع وبنك يرسلان ما يجب أن نطابقه، وفريق مالي ينتظر قائمة الحالات المفتوحة كل صباح. والصورة التالية تبين ذلك في طبقات من اليسار إلى اليمين: الكلاينت، ثم الحافة Edge، ثم الخدمات، ثم البيانات، ثم ما يحدث بعد القيد خارج الطلب، ثم الجهات الخارجية:

في الصورة أعلاه لاحظ التالي:
- الخدمات الثلاث التي تحرك المال، المحفظة والطلبات والشحن، لا تكتب في جداول الدفتر مباشرة، وإنما تستدعي دالة واحدة هي
post()التي سوف تراها في التجربة العملية، فتبقى القواعد كلها في مكان واحد. - السهم الأخضر الطويل من الهاتف إلى مزود الدفع هو ما يصغر نطاق PCI DSS، فرقم البطاقة لا يمر ببوابتك ولا بخدماتك، والذي يصلك هو إشعار Webhook بمرجع العملية.
- المطابقة والفحص الليلي مهمتان مجدولتان خارج طريق العميل، ولكنهما جزء أساسي من التصميم وليستا إضافة، لأنهما الطريقة الوحيدة لاكتشاف ما فات الكود.
المكونات ومسؤولية كل منها
| المكون | مسؤوليته | أداة مفتوحة المصدر مقترحة | لماذا هي |
|---|---|---|---|
| بوابة ال API | TLS والتحقق من الهوية وحد معدل الطلبات، ورفض أي طلب يحرك المال دون مفتاح Idempotency-Key | NGINX (رخصة BSD-2-Clause) أو HAProxy (رخصة GPLv2) | مجربان، ويكفيان لحمل المحافظ الذي يقاس بعشرات العمليات في الثانية وليس بالآلاف |
| خدمة المحفظة Wallet API | الرصيد وكشف الحركات والتحويل بين المحافظ | كودك | القراءة من جدول الأرصدة المحفوظة أو من نسخة القراءة، والكتابة عبر الدفتر فقط |
| الدفتر Ledger | كتابة القيود والسطور وتحديث الأرصدة في معاملة واحدة، ومنع التكرار والسحب بأكثر من الرصيد | PostgreSQL (رخصة PostgreSQL License) مع الدوال والقيود التي في التجربة العملية | القيود والـ Triggers والأقفال داخل قاعدة البيانات نفسها، فلا يستطيع أي كود أن يتجاوزها |
| تجميع الاتصالات | حماية قاعدة البيانات من كثرة الاتصالات | PgBouncer (رخصة ISC) | كل استدعاء للدالة معاملة قصيرة، فيناسبه التجميع على مستوى المعاملة |
| التوفر العالي | نسخة متماثلة متزامنة تحل مكان الرئيسية عند العطل | Patroni (رخصة MIT) مع النسخ المتزامن Synchronous Replication في PostgreSQL | القيد الذي أكدته للعميل يجب ألا يضيع عند التحويل إلى النسخة الأخرى، وشرحنا الفكرة في مقال التوفر العالي |
| النسخ الاحتياطي | استعادة لأي نقطة زمنية، والاحتفاظ بالسجلات عشر سنوات | pgBackRest (رخصة MIT) | نسخ كاملة وتزايدية مع أرشيف WAL، وشرحنا قاعدة 3-2-1 واختبار الاستعادة في مقال النسخ الاحتياطي |
| خدمة الشحن Top-up | إنشاء جلسة الدفع لدى المزود، واستقبال ال Webhook والتحقق من توقيعه، ثم قيد الشحن | كودك، والمزود خارجي | معرف الحدث من المزود فريد، ومرجع العملية يحفظ في العمود ref |
| الطابور Queue | نقل الحدث ledger.posted إلى الإشعارات والتقارير | RabbitMQ (رخصة MPL 2.0)، أو Debezium (رخصة Apache-2.0) ليقرأ جدول outbox من سجل التغييرات | الإشعار يتأخر أو يتكرر دون أن يمس المال، لأن الحدث يكتب في المعاملة نفسها مع القيد |
| المطابقة والفحص الليلي | مقارنة ملف التسوية وكشف البنك بالدفتر، والتحقق أن الدفتر يساوي صفراً | استعلامات SQL كالتي في التجربة، تجدولها بأداة pg_cron (رخصة PostgreSQL License) أو cron عادي | المطابقة على المرجع بربط FULL JOIN واحد، والنتيجة قائمة حالات مفتوحة للفريق المالي |
نموذج البيانات
الجداول الأربعة الأساسية هي نفسها جداول الملف ledger.sql في التجربة العملية، ولن نكررها هنا، وإنما نلخصها ونضيف إليها جدولين يحتاجهما النظام الحقيقي حول الدفتر:
| الجدول | الأعمدة الأساسية | دوره |
|---|---|---|
accounts | id, code, type, currency, allow_negative | شجرة الحسابات: wallet:1001 وholds:1001 وpsp:clearing وbank:main وfees:psp وmerchant:77 |
journal | id, idempotency_key UNIQUE, request_hash, kind, ref, created_at | سطر لكل حدث مالي، ومعه مفتاح منع التكرار ومرجع المزود أو الطلب |
postings | id, journal_id, account_id, amount, currency | سطور القيد، مجموعها صفر، ولا تعدل ولا تحذف |
balances | account_id, balance, allow_negative | الرصيد المحفوظ، يتحدث في نفس المعاملة، وعليه قيد CHECK يمنع السالب |
psp_report | psp_ref, amount, fee, settled_on | ملف التسوية بعد تحميله، للمطابقة |
-- Around the lab tables in a real system:
CREATE TABLE webhook_events ( -- the PSP may send the same event twice
event_id text PRIMARY KEY, -- the provider's event id
psp_ref text NOT NULL, -- becomes journal.ref of the top-up entry
type text NOT NULL, -- payment.captured, payment.refunded ...
payload jsonb NOT NULL,
received_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE outbox ( -- written in the same transaction as post()
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
journal_id bigint NOT NULL REFERENCES journal (id),
topic text NOT NULL, -- ledger.posted
created_at timestamptz NOT NULL DEFAULT now(),
published_at timestamptz -- NULL until the relay sends it to RabbitMQ
);الواجهات الرئيسية
أسماء المفاتيح في الجدول هي نفس الصيغ التي تستخدمها التجربة العملية، والقاعدة أن المفتاح يصف العملية التجارية نفسها، فالإعادة بعد انقطاع الشبكة تحمل المفتاح نفسه دائماً:
| الطلب | المسار | ما يفعله | المفتاح والملاحظات |
|---|---|---|---|
| GET | /wallets/me | الرصيد المتاح من جدول balances | قراءة بالمفتاح الأساسي، دون SUM على السطور |
| GET | /wallets/me/entries?cursor= | كشف الحركات صفحة بعد صفحة | من نسخة القراءة، بترقيم Cursor وليس OFFSET |
| POST | /topups | إنشاء جلسة دفع لدى المزود وإعادة رابط صفحته | Idempotency-Key، ورقم البطاقة لا يصل إلى هذا الطلب |
| POST | /webhooks/psp | تأكيد الشحن من المزود، فيضاف قيد الشحن | التحقق من التوقيع، وevent_id فريد، ومفتاح القيد topup-pay_A1 |
| POST | /transfers | تحويل من محفظة إلى أخرى | المفتاح من التطبيق مثل tr-8f3a، والمفتاح نفسه بمبلغ مختلف يرفض |
| POST | /orders/{id}/hold ثم /capture و/release | حجز مبلغ الطلب ثم تحصيل النهائي وإعادة الباقي | داخلي من خدمة الطلبات، والمفاتيح hold-5531 وcapture-5531 وrelease-5531 |
| POST | /orders/{id}/refunds | استرداد جزئي أو كامل بقيد جديد | المفتاح refund-5531-1 ثم -2 لكل استرداد مستقل، ومرجعه الطلب الأصلي |
| حدث | ledger.posted | من outbox إلى RabbitMQ، للإشعار وتحديث التقارير | قد يصل مرتين، فالمستهلك يتجاهل journal_id الذي عالجه |
تقدير السعة بالأرقام
المحفظة نظام صحة قبل أن يكون نظام حمل، ولكن الأرقام تبين لك أين يقع الضغط الحقيقي، والافتراضات التالية كلها افتراضات مكتوبة صراحة وليست أرقام شركة:
- افتراض: مليونا محفظة، و10% منها نشطة كل يوم، وكل محفظة نشطة تقوم بثلاث عمليات مالية في اليوم.
- افتراض: الذروة خمسة أضعاف متوسط اليوم، وثلث العمليات شحن يأتي معه Webhook.
- افتراض: كل قيد مع سطريه وفهارسه قرابة 500 بايت، وكل مستخدم نشط يفتح الرصيد خمس مرات في اليوم.
- من اللائحة التنفيذية: الاحتفاظ بالقيود عشر سنوات على الأقل.
| الكمية | الحساب | النتيجة |
|---|---|---|
| القيود في اليوم | 2,000,000 × 10% × 3 | 600,000 قيد |
| متوسط القيود | 600,000 ÷ 86,400 | قرابة 7 قيود في الثانية |
| القيود في الذروة | 7 × 5 | 35 قيداً في الثانية |
| الأسطر المكتوبة في الذروة | 35 × (سطر قيد + سطران) | 105 أسطر جديدة و70 تحديث رصيد في الثانية |
| قراءة الرصيد | 200,000 × 5 ÷ 86,400 × 5 | قرابة 58 قراءة في الثانية في الذروة |
| إشعارات الشحن Webhooks | 600,000 ÷ 3 ÷ 86,400 × 5 | قرابة 12 في الثانية في الذروة |
| التخزين في اليوم | 600,000 × 500 بايت | 300 ميغابايت |
| التخزين لعشر سنوات | 300 ميغابايت × 365 × 10 | قرابة 1.1 تيرابايت |
والخلاصة أن 35 قيداً في الثانية حمل صغير على قاعدة PostgreSQL واحدة، وفي التجربة العملية كتبت دالة الدفتر 507 قيود في الثانية على حساب واحد ساخن على جهاز تطوير، أما الضغط الحقيقي فهو في الحساب الذي يمر به كل شيء، مثل حساب الرسوم أو حساب تاجر كبير، لأن كل القيود تنتظر القفل على سطره نفسه. فإذا نما النظام عشر مرات صار هذا الحساب 350 قيداً في الثانية على سطر واحد، والحل المعروف أن تقسم حساب الشركة الساخن إلى عدة حسابات فرعية مثل fees:psp:1 إلى fees:psp:8، يختار كل قيد واحداً منها، وتجمعها في التقارير. وأما التخزين فيكبر ببطء، ولكن لعشر سنوات، لذلك خطط لتقسيم جدول السطور بالشهر من البداية.
مراحل النمو: ماذا تبني اليوم، وماذا تؤجل؟
| الجزء | البداية MVP: سيرفر أو اثنان | النمو: آلاف العمليات في الساعة | الحجم الكبير: ملايين المحافظ |
|---|---|---|---|
| الدفتر | PostgreSQL واحدة بالجداول والدوال التي في التجربة | نسخة متماثلة متزامنة مع Patroni، ونسخة للقراءة لكشوف الحركات | تقسيم postings بالشهر، وأرشفة السنوات المغلقة، ودفتر لكل عملة أو منطقة |
| الحسابات الساخنة | حساب واحد لكل شيء | تقسيم حسابات الشركة الساخنة إلى حسابات فرعية | قاعدة متخصصة في القيود المالية مثل TigerBeetle (رخصة Apache-2.0) إذا وصلت الكتابة إلى ما لا تتحمله قاعدة واحدة |
| ما بعد القيد | الإشعار داخل الطلب نفسه | جدول outbox وRabbitMQ | بث الأحداث للتقارير ومكافحة الاحتيال |
| المطابقة | استعلام يومي يراجع نتيجته إنسان | مطابقة آلية وقائمة حالات مفتوحة للفريق المالي | أكثر من مزود دفع وأكثر من بنك، ولكل منها مطابقة |
| النسخ الاحتياطي | نسخة يومية مع اختبار استعادة | pgBackRest مع أرشيف WAL واستعادة لأي لحظة | نسخ في منطقة أخرى، وتمرين استعادة دوري موثق |
والذي لا تبنيه في اليوم الأول: لا تفصل الدفتر إلى خدمات صغيرة لكل نوع حساب، ولا تبن معاملات موزعة بين قواعد بيانات مختلفة، ولا تستخدم قاعدة متخصصة أو Kafka قبل أن تحتاجها، والسبب أن القيد الواحد يجب أن يكون معاملة واحدة في قاعدة واحدة، وكل تقسيم يكسر هذه القاعدة يحتاج حلولاً أصعب بكثير. أما ما يجب أن يكون موجوداً من البداية فهو القيد المزدوج، والمبالغ بالهللة، ومفتاح منع التكرار داخل المعاملة، والدفتر الذي يقبل الإضافة فقط، والمطابقة اليومية، لأن تغيير أي منها بعد ملايين القيود مكلف جداً.
ماذا لو تعطل جزء منها؟
| ماذا لو تعطل | الأثر | كيف تخفف الأثر |
|---|---|---|
| PostgreSQL الرئيسية | لا دفع ولا تحويل حتى يكتمل التحويل إلى النسخة الأخرى | نسخة متزامنة فلا يضيع قيد مؤكد، والتطبيق يعيد الطلب بالمفتاح نفسه فيحصل على القيد نفسه دون تكرار |
| مزود الدفع | لا يمكن شحن المحفظة | الدفع من الرصيد والتحويل بين المحافظ يستمران لأنهما داخل الدفتر، ورسالة واضحة للعميل عن الشحن |
| Webhook لم يصل | المزود خصم من البطاقة ولم يضف الرصيد، كحالة pay_E4 في التجربة | مهمة تسأل المزود عن الجلسات المعلقة بعد دقائق، والمطابقة اليومية تكشف ما بقي |
| RabbitMQ أو الإشعارات | تتأخر الإشعارات | المال صحيح لأن الحدث محفوظ في outbox مع القيد، ويرسل عند العودة |
| خطأ في الكود | محاولة قيد غير متوازن أو سحب بأكثر من الرصيد أو تعديل قيد قديم | القيود في قاعدة البيانات ترفضها كما في قسم نكسر القواعد، والفحص الليلي يوقف العمل إذا اختلف شيء، والتصحيح بقيد عكسي |
| حساب ساخن | طوابير على القفل وبطء في كل الدفعات | معاملات قصيرة، وتقسيم الحساب الساخن إلى حسابات فرعية، ومراقبة زمن انتظار الأقفال |
القيد المزدوج Double-Entry للمهندسين
في القيد المزدوج كل عملية مالية تسمى قيداً Journal Entry، وكل قيد له سطران أو أكثر يسمى كل منهما Posting، وكل سطر إما مدين Debit أو دائن Credit، ومجموع المدين يساوي مجموع الدائن في كل قيد. وفي الكود نكتب المدين موجباً والدائن سالباً، فتصبح القاعدة كلها سطراً واحداً: مجموع سطور كل قيد يساوي صفراً. والصورة التالية تبين أول قيدين في المختبر:

وأكثر نقطة تربك المهندسين هنا هي نوع الحساب: محفظة العميل في دفاترك ليست مالك، وإنما مال تدين به للعميل، أي التزام Liability، فعندما يشحن العميل مئة ريال فإن حساب المحفظة يزيد في الجانب الدائن، وحساب مزود الدفع psp:clearing يزيد في الجانب المدين لأنه أصل Asset، فالمزود سوف يحول لك هذا المبلغ لاحقاً. ولهذا يحتاج كل حساب نوعاً، والرصيد الذي يراه العميل هو مجموع سطور محفظته بإشارة معكوسة. ولا تحتاج أن تصبح محاسباً لتبني هذا، ولكن تحتاج أن تتفق مع المحاسب على شجرة الحسابات من اليوم الأول، لأن تغييرها بعد ملايين القيود مكلف جداً.
ويعطيك هذا التصميم ثلاثة أشياء لا يعطيها عمود الرصيد: كل ريال له قيد يشرح من أين جاء وإلى أين ذهب، والدفتر كله يجب أن يساوي صفراً في كل لحظة فتستطيع أن تتحقق منه كل ليلة باستعلام واحد، والقيود القديمة لا تتغير، فتصحيح أي خطأ يكون بقيد جديد يعكسه وليس بتعديل القديم.
دفتر لا يقبل التعديل، ورصيد محسوب أم محفوظ؟
الدفتر الذي لا يقبل التعديل Immutable Ledger يعني أن جدولي القيود والسطور يقبلان الإضافة فقط، فلا UPDATE ولا DELETE حتى من مطور يظن أنه يصلح خطأ، وفي المختبر نفرض هذا بـ Trigger يرفض أي تعديل. وإذا أردت أن تكشف العبث حتى من مدير قاعدة البيانات نفسه فالفكرة نفسها التي شرحناها في مقال سجل التدقيق تنطبق هنا: سلسلة هاش تربط كل قيد بما قبله، ومرساة موقعة تحفظ خارج السيرفر.
وقد يتساءل البعض: إذا كان الرصيد هو مجموع السطور، فهل نحسب SUM على ملايين السطور في كل عملية دفع؟ والإجابة أن هناك طريقتين، ولكل منهما مكانها:
- الرصيد المحسوب Derived Balance: SUM على سطور الحساب عند الطلب، وهو الحقيقة دائماً، ولكنه يبطؤ مع نمو الحساب، وتحتاج معه طريقة لمنع السحب بأكثر من الرصيد بين طلبين متزامنين.
- الرصيد المحفوظ Cached Balance: جدول فيه سطر لكل حساب يتحدث في المعاملة نفسها التي تضيف السطور، ويقفل عند الخصم، وعليه قيد CHECK يمنع السالب. وهذا ما يفعله المختبر، بشرط مهم: الجدول نسخة من الحقيقة وليس الحقيقة، لذلك نتحقق كل ليلة أن كل رصيد محفوظ يساوي مجموع سطوره، وأي فرق يعني خطأ في الكود يجب أن يوقف العمل حتى يفهم.
المبالغ بالهللة وليس بالكسور
لا تحفظ المال في float أو double أبداً، والسبب أن 0.1 لا يمكن تمثيلها بدقة في النظام الثنائي، والفروق الصغيرة تتراكم مع ملايين العمليات، وقد شرحنا هذا بالتفصيل في مقال الأرقام العشرية وفي قسم المبالغ المالية في مقال الأنظمة العددية. والطريقة الأبسط في الدفتر أن تحفظ المبلغ عدداً صحيحاً بأصغر وحدة للعملة، أي بالهللة للريال، فمئة ريال تصبح 10000 في عمود bigint، ومعها رمز العملة في عمود مستقل، ولا تجمع أبداً سطرين بعملتين مختلفتين. والتحويل إلى ريالات يكون عند العرض فقط. ولاحظ أن العملات لا تتفق في عدد الخانات، فبعضها له ثلاث خانات بعد الفاصلة وبعضها ليس له خانات، لذلك اجعل عدد الخانات صفة للعملة وليس رقماً ثابتاً في الكود.
مفتاح منع التكرار داخل المعاملة
شرحنا فكرة مفتاح منع التكرار Idempotency Key في مقال تصميم ال API: التطبيق ينشئ معرفاً فريداً لكل عملية ويرسله مع الطلب، فإذا أعاد الطلب بعد انقطاع الشبكة عرف السيرفر أنه نفس الطلب. وما يخص المحفظة هنا نقطتان. الأولى أن المفتاح يحفظ في المعاملة نفسها التي تضيف القيد، كعمود فريد UNIQUE في جدول القيود، وليس في Cache منفصل، والسبب أنك إذا حفظت المفتاح ثم سقط السيرفر قبل القيد فقد رفضت الإعادة ولم يتحرك المال، وإذا حفظت القيد ثم سقط قبل المفتاح فقد خصمت مرتين. والثانية أن المفتاح المكرر بطلب مختلف خطأ في الكود يجب أن يرفض، لذلك نحفظ معه بصمة للطلب، وهذا ما تبينه الصورة التالية:

وفي المختبر نستخدم INSERT ... ON CONFLICT (idempotency_key) DO NOTHING، ويذكر توثيق INSERT في PostgreSQL أن هذه الصيغة تضمن نتيجة الإضافة أو عدمها حتى مع التزامن، فإذا وصل طلبان بالمفتاح نفسه في اللحظة نفسها انتظر الثاني حتى تنتهي معاملة الأول، ثم لا يضيف شيئاً ويعيد القيد نفسه. وإذا فشلت معاملة الأول بسبب نقص الرصيد مثلاً فلم يحفظ المفتاح أصلاً، فيعيد العميل المحاولة لاحقاً بالمفتاح نفسه بشكل طبيعي.
السباق على الرصيد ومستويات العزل
أخطر خطأ في المحافظ ليس الطلب المكرر، وإنما طلبان مختلفان على الرصيد نفسه في اللحظة نفسها. والطريقة البسيطة أن يقرأ الكود الرصيد، ثم يحسب الرصيد الجديد في التطبيق، ثم يكتبه، وبين القراءة والكتابة يقرأ طلب آخر الرصيد القديم نفسه، فيكتب كل منهما نتيجته فوق الآخر، وهذا يسمى التحديث الضائع Lost Update، كما في الجهة اليسرى من الصورة التالية:

وقد شرحنا مستويات العزل بتجارب على جلستين في الجزء الثالث من سلسلة تعلم SQL، والمستوى الافتراضي في PostgreSQL هو Read Committed، وفيه لا يمنع النظام هذا السباق لأن كل جملة ترى آخر ما التزم قبلها، كما يشرح توثيق مستويات العزل. وأمامك ثلاثة حلول صحيحة:
- التعديل الذري في جملة واحدة:
UPDATE wallets SET balance = balance - 100 WHERE id = 1 AND balance >= 100، فقاعدة البيانات تقرأ وتكتب في خطوة واحدة على السطر المقفل، وإذا لم يتغير أي سطر فالرصيد لا يكفي. وهذا يكفي لحساب واحد، ولكنه لا يكفي عندما يتحرك المال بين حسابين ومعه سطور الدفتر. - القفل الصريح SELECT ... FOR UPDATE: داخل معاملة، فيقفل السطر حتى نهاية المعاملة، وأي طلب آخر يريد قفله ينتظر، كما يشرح توثيق الأقفال الصريحة. وعندما تقفل أكثر من حساب فاقفلها دائماً بترتيب ثابت، مثل رقم الحساب، وإلا قفل طلب A ثم B، وقفل طلب آخر B ثم A، فانتظر كل منهما الآخر إلى أن يكتشف PostgreSQL الجمود Deadlock ويلغي أحدهما.
- المستوى التسلسلي Serializable: لا تقفل بنفسك، وإنما تكتشف قاعدة البيانات أن معاملتين لا يمكن أن تكونا حدثتا بالتتابع، فتلغي إحداهما بخطأ رمزه 40001، وعلى تطبيقك أن يعيد المعاملة كلها، كما يشرح توثيق التعامل مع فشل التسلسل. وهو آمن ومريح للمنطق المعقد، ولكن على حساب ساخن Hot Account يدفع منه الجميع ينتج إعادات كثيرة جداً، كما سوف ترى في المختبر.
الحجز والتحصيل والاسترداد
في تطبيق التوصيل لا يعرف أحد المبلغ النهائي لحظة الطلب، فقد ينقص صنف من المطعم، أو يتغير رسم التوصيل، لذلك لا تخصم المبلغ مباشرة، وإنما تحجزه Hold، وفي البطاقات يسمى هذا التفويض Authorization. والحجز في الدفتر ليس حالة خاصة، وإنما قيد عادي ينقل المال من محفظة العميل إلى حساب حجز للعميل نفسه، فيقل ما يستطيع أن يصرفه، ويبقى المال ماله. ثم عند التسليم تحصل Capture المبلغ النهائي إلى حساب المطعم، وتعيد Release الباقي إلى المحفظة. وإذا طلب العميل استرداداً بعد يوم فهذا قيد جديد يعكس جزءاً من التحصيل، ولا تعدل قيد التحصيل القديم أبداً. والصورة التالية تبين طلباً بأربعين ريالاً كما في المختبر:

ولاحظ ثلاث نقاط عملية: الحجز يحتاج مدة انتهاء، فإذا ألغي الطلب أو نسي النظام التحصيل يعيد مؤقت المال بعد مدة محددة، والتحصيل لا يجوز أن يتجاوز المحجوز، والاسترداد يربط بالعملية الأصلية، وهذا ما تطلبه أيضاً اللائحة التنفيذية لنظام المدفوعات كما سوف نرى.
المطابقة مع مزود الدفع والبنك
دفترك يقول ما تظن أنه حدث، وملف التسوية Settlement File من مزود الدفع يقول ما يعترف به هو، وكشف البنك يقول ما وصل فعلاً، والمطابقة Reconciliation هي المقارنة اليومية بين الثلاثة. ولن تتطابق كل يوم، والسبب ليس خطأ في كودك بالضرورة: إشعار Webhook من المزود لم يصل، أو شحن قبلته ثم رفضه بنك العميل، أو مبلغ اختلف بسبب رسوم أو عملة. لذلك فالقاعدة أن الحساب الوسيط psp:clearing يجب أن يقترب من الصفر بعد كل تسوية، وما يبقى فيه هو بالضبط قائمة الحالات المفتوحة التي يجب أن يعالجها إنسان. والصورة التالية تبين نتيجة المطابقة في المختبر:

والمطابقة تتم على مرجع المزود وليس على المبلغ، لأن مبلغين متساويين لا يعنيان العملية نفسها، ولذلك يجب أن تحفظ مرجع المزود مع كل قيد شحن، كما في العمود ref في المختبر، وهذه هي مهمة Reconciliation في المعمارية الكاملة أعلاه. والإشعارات التي يرسلها المزود إلى سيرفرك تعالج بالمنطق نفسه: لها معرف فريد يمنع تكرارها، وتكتب في المعاملة نفسها مع القيد، ثم ينشر الحدث لبقية الخدمات بنمط Outbox الذي شرحناه في مقال الأنظمة الموزعة.
التنظيم في المملكة وPCI DSS باختصار
هذا القسم للمهندس الذي يريد أن يعرف لماذا يسأله القسم القانوني أسئلة معينة، وليس بديلاً عن مستشار قانوني. ففي المملكة يحكم المحافظ الإلكترونية نظام المدفوعات وخدماتها الصادر بالمرسوم الملكي م/26 بتاريخ 1443/3/22هـ (28 أكتوبر 2021)، وهو يعد المحافظ في صورة حسابات أو سجلات إلكترونية من خدمات الدفع، وينص على أنه لا يجوز لأحد أن يشغل نظام مدفوعات أو يقدم خدمات دفع في المملكة إلا بترخيص من البنك المركزي السعودي SAMA. وفي صفحة أنواع التراخيص أربعة أنواع: مؤسسة مدفوعات صغرى، ومؤسسة مدفوعات كبرى، ومؤسسة نقود إلكترونية صغرى للمحافظ، ومؤسسة نقود إلكترونية كبرى للمحافظ. وفي اللائحة التنفيذية للنظام متطلبات تمس التصميم مباشرة، منها:
- حماية أموال العملاء Safeguarded Funds: تطلب المادة 93 حمايتها فور استلامها، وإذا بقيت لدى مقدم الخدمة إلى نهاية يوم العمل التالي فتوضع في حساب منفصل لدى بنك مرخص لهذا الغرض فقط، ولا تخلط بأموال الرسوم أو المصاريف، ويخضع الحساب لمطابقة يومية مع العمليات والنقود الإلكترونية القائمة. وهذا يعني أن حساباً مثل bank:safeguarding في دفترك ليس تفصيلاً محاسبياً، وإنما شيء يجب أن تستطيع مطابقته كل يوم.
- السجلات: تطلب اللائحة الاحتفاظ بسجلات العمليات والسجلات المحاسبية، ومنها دفاتر الأستاذ والقيود والمطابقات، عشر سنوات على الأقل، وحفظها بالصيغة التي أنشئت بها دون إضافة أو حذف أو تعديل، وتسجيل كل إجراء على السجل، وهذا وصف قريب جداً للدفتر الذي لا يقبل التعديل.
- الاسترداد: تطلب اللائحة تسجيل الاسترداد مقابل العملية الأصلية برقم تسلسلها الأصلي، والاحتفاظ بسجلات الاسترداد والتعويض عشر سنوات.
وأما معيار PCI DSS فهو من مجلس معايير أمن صناعة بطاقات الدفع، ويطبق على كل جهة تخزن أو تعالج أو تنقل بيانات حامل البطاقة Cardholder Data أو بيانات المصادقة الحساسة، أو تستطيع أن تؤثر في أمن البيئة التي تمر بها هذه البيانات. وهذا التعريف نفسه هو النصيحة الهندسية: نطاق التدقيق يتحدد بالأماكن التي يمر بها رقم البطاقة، فإذا استخدمت صفحة الدفع أو حزمة الهاتف التي يقدمها مزود الدفع، بحيث يذهب رقم البطاقة من جهاز العميل إلى المزود مباشرة ويعود إليك رمز Token فقط، فإن سيرفراتك لا تخزن الرقم ولا تعالجه، ويصغر ما يدخل في النطاق كثيراً. وحدد النطاق النهائي مع مزود الدفع أو المدقق المعتمد، ولا تفترضه.
التجربة العملية: دفتر قيود على PostgreSQL 18.6
في هذه التجربة سوف نبني الدفتر كاملاً في قاعدة بيانات واحدة: الجداول والقيود التي تحمي المعادلة، ودالة التحويل التي لا تتكرر، ثم نختبر السباق بأداة pgbench التي تأتي مع PostgreSQL، ونطابق الشحنات مع ملف تسوية من مزود الدفع. ونشغل PostgreSQL في Container بمنفذ مربوط على 127.0.0.1 فقط، ونربط مجلد التجربة داخله لتقرأ psql الملفات منه:
docker run -d --name lab-case-ledger -e POSTGRES_PASSWORD=lab -p 127.0.0.1:15440:5432 \
-v "$PWD:/lab" -w /lab postgres:18.6
docker exec lab-case-ledger psql -U postgres -tAc 'select version()'PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2) on x86_64-pc-linux-gnu, compiled by gcc (Debian 14.2.0-19) 14.2.0, 64-bitالجداول والقيود ودالة التحويل
الملف ledger.sql فيه كل الدفتر:
-- Wallet ledger: double entry, immutable postings, idempotent posting function.
-- Amounts are bigint in halalas (1 SAR = 100 halalas). A posting is a debit when positive and a credit when negative.
CREATE TABLE accounts (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
code text NOT NULL UNIQUE, -- wallet:1001, holds:1001, psp:clearing, bank:main, fees:psp, merchant:77
type text NOT NULL CHECK (type IN ('asset', 'liability', 'revenue', 'expense')),
currency char(3) NOT NULL DEFAULT 'SAR',
allow_negative boolean NOT NULL DEFAULT false
);
CREATE TABLE journal ( -- one row per business event
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
idempotency_key text NOT NULL UNIQUE,
request_hash text NOT NULL, -- the same key with a different request is an error
kind text NOT NULL, -- topup, transfer, hold, capture, release, refund, settlement
ref text, -- the PSP or the original journal reference
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE postings ( -- the lines of each event; never updated, never deleted
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
journal_id bigint NOT NULL REFERENCES journal (id),
account_id bigint NOT NULL REFERENCES accounts (id),
amount bigint NOT NULL CHECK (amount <> 0),
currency char(3) NOT NULL
);
CREATE INDEX ON postings (account_id);
CREATE INDEX ON postings (journal_id);
CREATE TABLE balances ( -- a cache of SUM(postings), kept in the same transaction
account_id bigint PRIMARY KEY REFERENCES accounts (id),
balance bigint NOT NULL DEFAULT 0, -- on the account's normal side (credit for a wallet)
allow_negative boolean NOT NULL,
CHECK (allow_negative OR balance >= 0)
);
-- 1) Debits = credits for every journal entry, checked at COMMIT (deferred), per currency.
CREATE FUNCTION check_balanced() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE bad record;
BEGIN
SELECT currency, SUM(amount) AS diff INTO bad
FROM postings WHERE journal_id = NEW.journal_id
GROUP BY currency HAVING SUM(amount) <> 0 LIMIT 1;
IF FOUND THEN
RAISE EXCEPTION 'journal % is not balanced: % % off', NEW.journal_id, bad.diff, bad.currency;
END IF;
RETURN NULL;
END $$;
CREATE CONSTRAINT TRIGGER postings_balanced AFTER INSERT ON postings
DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_balanced();
-- 2) The ledger is append-only: no UPDATE or DELETE, even by mistake.
CREATE FUNCTION forbid_change() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
RAISE EXCEPTION '% is append-only: write a reversing entry instead', TG_TABLE_NAME;
END $$;
CREATE TRIGGER postings_append_only BEFORE UPDATE OR DELETE ON postings FOR EACH ROW EXECUTE FUNCTION forbid_change();
CREATE TRIGGER journal_append_only BEFORE UPDATE OR DELETE ON journal FOR EACH ROW EXECUTE FUNCTION forbid_change();
-- 3) post(): one idempotent entry. lines = [{"account": "wallet:1001", "amount": -2500}, ...]
CREATE FUNCTION post(p_key text, p_kind text, p_ref text, p_lines jsonb) RETURNS bigint
LANGUAGE plpgsql AS $$
DECLARE
v_hash text := md5(p_kind || coalesce(p_ref, '') || p_lines::text);
v_id bigint;
v_old journal%ROWTYPE;
l record;
BEGIN
-- the unique key makes a second request with the same key wait for the first, then do nothing
INSERT INTO journal (idempotency_key, request_hash, kind, ref)
VALUES (p_key, v_hash, p_kind, p_ref)
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING id INTO v_id;
IF v_id IS NULL THEN
SELECT * INTO v_old FROM journal WHERE idempotency_key = p_key;
IF v_old.request_hash <> v_hash THEN
RAISE EXCEPTION 'idempotency key % was used for a different request', p_key;
END IF;
RETURN v_old.id; -- replay: same answer, no new money movement
END IF;
-- lock the cached balances in a fixed order (by id) so two transfers cannot deadlock
PERFORM 1 FROM balances b JOIN accounts a ON a.id = b.account_id
WHERE a.code IN (SELECT x->>'account' FROM jsonb_array_elements(p_lines) x)
ORDER BY b.account_id FOR UPDATE OF b;
FOR l IN SELECT a.id, a.type, a.currency, (x->>'amount')::bigint AS amount
FROM jsonb_array_elements(p_lines) x JOIN accounts a ON a.code = x->>'account'
LOOP
INSERT INTO postings (journal_id, account_id, amount, currency) VALUES (v_id, l.id, l.amount, l.currency);
UPDATE balances
SET balance = balance + CASE WHEN l.type IN ('asset', 'expense') THEN l.amount ELSE -l.amount END
WHERE account_id = l.id; -- the CHECK on balances refuses an overdraft
END LOOP;
IF (SELECT count(*) FROM jsonb_array_elements(p_lines)) <>
(SELECT count(*) FROM postings WHERE journal_id = v_id) THEN
RAISE EXCEPTION 'unknown account in %', p_lines;
END IF;
RETURN v_id;
END $$;
-- 4) transfer(): the common case, two lines: p_debit is debited, p_credit is credited.
-- Wallet A pays wallet B: debit wallet:A (we owe A less), credit wallet:B (we owe B more).
CREATE FUNCTION transfer(p_key text, p_debit text, p_credit text, p_amount bigint, p_kind text DEFAULT 'transfer', p_ref text DEFAULT NULL)
RETURNS bigint LANGUAGE sql AS $$
SELECT post(p_key, p_kind, p_ref,
jsonb_build_array(jsonb_build_object('account', p_debit, 'amount', p_amount),
jsonb_build_object('account', p_credit, 'amount', -p_amount)));
$$;
CREATE FUNCTION open_account(p_code text, p_type text, p_allow_negative boolean DEFAULT false) RETURNS void
LANGUAGE sql AS $$
WITH a AS (INSERT INTO accounts (code, type, allow_negative) VALUES (p_code, p_type, p_allow_negative) RETURNING id, allow_negative)
INSERT INTO balances (account_id, allow_negative) SELECT id, allow_negative FROM a;
$$;في الملف أعلاه لاحظ التالي:
- المبالغ bigint بالهللة، والقيد CHECK يمنع سطراً بمبلغ صفر، والعملة في كل سطر.
- الـ Trigger المسمى postings_balanced من نوع CONSTRAINT TRIGGER مؤجل DEFERRABLE INITIALLY DEFERRED، أي أنه يعمل عند COMMIT وليس بعد كل سطر، لأن القيد يكون غير متوازن مؤقتاً بين إضافة سطره الأول والثاني، كما يشرح توثيق CREATE TRIGGER.
- الـ Triggers المسماة append_only ترفض UPDATE وDELETE على القيود والسطور، وفي الإنتاج أضف إليها صلاحيات تمنع مستخدم التطبيق من هذه الأوامر أصلاً.
- الدالة post تبدأ بإضافة سطر القيد مع المفتاح، ثم تقفل الأرصدة المحفوظة بترتيب رقم الحساب، ثم تضيف السطور وتحدث الأرصدة، والقيد CHECK على جدول balances هو الذي يرفض السحب بأكثر من الرصيد.
- الدالة transfer تسمي الطرفين p_debit وp_credit عمداً، لأن «من» و«إلى» مربكة في الشحن: المدين في الشحن هو حساب مزود الدفع وليس العميل.
docker exec lab-case-ledger psql -U postgres -q -v ON_ERROR_STOP=1 -f /lab/ledger.sqlالحسابات والشحن والتحويل والطلب المكرر
الملف 01-accounts.sql يفتح الحسابات:
SELECT open_account('psp:clearing', 'asset', true); -- money the PSP owes us
SELECT open_account('bank:main', 'asset', true); -- our bank account
SELECT open_account('fees:psp', 'expense', true); -- what the PSP charges us
SELECT open_account('wallet:1001', 'liability'); -- what we owe customer 1001
SELECT open_account('wallet:1002', 'liability');
SELECT open_account('holds:1001', 'liability'); -- 1001's money reserved for an order
SELECT open_account('merchant:77', 'liability'); -- what we owe restaurant 77والملف 02-flow.sql يشحن المحفظة، ثم يحول، ثم يعيد التحويل بالمفتاح نفسه، ثم يستخدم المفتاح نفسه بمبلغ آخر:
-- 1001 tops up 100.00 SAR with a card; the PSP confirms with reference pay_A1
SELECT transfer('topup-pay_A1', 'psp:clearing', 'wallet:1001', 10000, 'topup', 'pay_A1');
-- 1001 sends 25.00 SAR to 1002
SELECT transfer('tr-8f3a', 'wallet:1001', 'wallet:1002', 2500);
-- the phone did not get the answer and sends the same request again
SELECT transfer('tr-8f3a', 'wallet:1001', 'wallet:1002', 2500);
-- a bug reuses the key for another amount
SELECT transfer('tr-8f3a', 'wallet:1001', 'wallet:1002', 9900);
SELECT a.code, round(b.balance / 100.0, 2) AS sar
FROM balances b JOIN accounts a ON a.id = b.account_id
WHERE b.balance <> 0 ORDER BY a.id;
SELECT j.id AS journal, j.kind, a.code, p.amount
FROM postings p JOIN journal j ON j.id = p.journal_id JOIN accounts a ON a.id = p.account_id
ORDER BY p.id;docker exec lab-case-ledger psql -U postgres -q -f /lab/01-accounts.sql -o /dev/null
docker exec lab-case-ledger psql -U postgres -q -f /lab/02-flow.sqlوالمخرج سوف يكون كما يلي:
transfer
----------
1
(1 row)
transfer
----------
2
(1 row)
transfer
----------
2
(1 row)
psql:/lab/02-flow.sql:8: ERROR: idempotency key tr-8f3a was used for a different request
CONTEXT: PL/pgSQL function post(text,text,text,jsonb) line 16 at RAISE
code | sar
--------------+--------
psp:clearing | 100.00
wallet:1001 | 75.00
wallet:1002 | 25.00
(3 rows)
journal | kind | code | amount
---------+----------+--------------+--------
1 | topup | psp:clearing | 10000
1 | topup | wallet:1001 | -10000
2 | transfer | wallet:1001 | 2500
2 | transfer | wallet:1002 | -2500
(4 rows)في المخرج أعلاه لاحظ التالي:
- الإعادة بالمفتاح tr-8f3a أعادت القيد رقم 2 نفسه، والرصيد 75.00 وليس 50.00، أي أن التحويل لم يتكرر.
- المفتاح نفسه مع مبلغ 99.00 رفض برسالة واضحة، فالخطأ في الكود يظهر بدل أن يمر بصمت.
- سطور القيود الأربعة مجموعها صفر، والرصيد الموجب لحساب psp:clearing يعني أن المزود يدين لنا بمئة ريال حتى يحولها.
نكسر القواعد عمداً
الملف 03-guards.sql يحاول أربعة أشياء يجب أن تفشل: قيد يخلق مالاً، وتعديل سطر، وحذف قيد، وتحويل بأكثر من الرصيد:
-- an entry that creates money: debit 10.00, credit 9.00
SELECT post('bad-1', 'transfer', NULL,
'[{"account":"wallet:1001","amount":1000},{"account":"wallet:1002","amount":-900}]');
-- someone "fixes" a posting by hand
UPDATE postings SET amount = 1 WHERE id = 1;
DELETE FROM journal WHERE id = 2;
-- 1001 has 75.00 and tries to send 80.00
SELECT transfer('tr-9c01', 'wallet:1001', 'wallet:1002', 8000);docker exec lab-case-ledger psql -U postgres -q -f /lab/03-guards.sqlpsql:/lab/03-guards.sql:3: ERROR: journal 5 is not balanced: 100 SAR off
CONTEXT: PL/pgSQL function check_balanced() line 8 at RAISE
psql:/lab/03-guards.sql:5: ERROR: postings is append-only: write a reversing entry instead
CONTEXT: PL/pgSQL function forbid_change() line 3 at RAISE
psql:/lab/03-guards.sql:6: ERROR: journal is append-only: write a reversing entry instead
CONTEXT: PL/pgSQL function forbid_change() line 3 at RAISE
psql:/lab/03-guards.sql:8: ERROR: new row for relation "balances" violates check constraint "balances_check"
DETAIL: Failing row contains (4, -500, f).
CONTEXT: SQL statement "UPDATE balances
SET balance = balance + CASE WHEN l.type IN ('asset', 'expense') THEN l.amount ELSE -l.amount END
WHERE account_id = l.id"
PL/pgSQL function post(text,text,text,jsonb) line 30 at SQL statementكل المحاولات الأربع رفضت من قاعدة البيانات نفسها، وليس من كود التطبيق، وهذا هو الفرق بين قاعدة تحمي نفسها وقاعدة تثق بكل من يكتب فيها، كما شرحنا في قسم القيود في مقال تصميم قواعد البيانات. ولاحظ أن رقم القيد المرفوض 5 وليس 3، لأن العداد في PostgreSQL يستهلك رقماً حتى عندما تفشل الإضافة أو يتعارض المفتاح، والفجوات في أرقام القيود طبيعية ولا تعني ضياع عملية، فلا تبن أي منطق مالي على تتابع هذه الأرقام.
الحجز والتحصيل والاسترداد
-- order #5531 for 40.00: reserve the money (authorisation hold)
SELECT transfer('hold-5531', 'wallet:1001', 'holds:1001', 4000, 'hold', 'order-5531');
-- the restaurant had one item missing: capture 35.00 only, release the other 5.00
SELECT transfer('capture-5531', 'holds:1001', 'merchant:77', 3500, 'capture', 'order-5531');
SELECT transfer('release-5531', 'holds:1001', 'wallet:1001', 500, 'release', 'order-5531');
-- next day: a 10.00 refund for a cold meal is a new entry, the capture stays as it was
SELECT transfer('refund-5531-1', 'merchant:77', 'wallet:1001', 1000, 'refund', 'order-5531');
SELECT a.code, round(b.balance / 100.0, 2) AS sar
FROM balances b JOIN accounts a ON a.id = b.account_id
WHERE a.code IN ('wallet:1001', 'holds:1001', 'merchant:77') ORDER BY a.id;
SELECT j.kind, round(SUM(p.amount) FILTER (WHERE p.amount > 0) / 100.0, 2) AS sar, j.ref
FROM journal j JOIN postings p ON p.journal_id = j.id
WHERE j.ref = 'order-5531' GROUP BY j.id ORDER BY j.id;docker exec lab-case-ledger psql -U postgres -q -f /lab/04-holds.sql transfer
----------
7
(1 row)
transfer
----------
8
(1 row)
transfer
----------
9
(1 row)
transfer
----------
10
(1 row)
code | sar
-------------+-------
wallet:1001 | 50.00
holds:1001 | 0.00
merchant:77 | 25.00
(3 rows)
kind | sar | ref
---------+-------+------------
hold | 40.00 | order-5531
capture | 35.00 | order-5531
release | 5.00 | order-5531
refund | 10.00 | order-5531
(4 rows)المحفظة كانت 75.00، حجز منها 40.00، ثم عاد 5.00، ثم استرد 10.00، فأصبحت 50.00، والمطعم حصل 35.00 ثم رد 10.00 فبقي له 25.00، وحساب الحجز رجع صفراً، والقيود الأربعة كلها مرتبطة بالمرجع order-5531.
السباق: عشرة آلاف دفعة من عشرين اتصالاً
الآن نجهز محفظة بسيطة بعمود رصيد واحد فيها 10,000.00 ريال، وندفع منها ريالاً واحداً عشرة آلاف مرة من عشرين اتصالاً متزامناً بالأداة pgbench، والنتيجة الصحيحة هي صفر بالضبط:
CREATE TABLE wallet_simple (id int PRIMARY KEY, balance bigint NOT NULL);
INSERT INTO wallet_simple VALUES (1, 1000000); -- 10,000.00 SAR, to be spent 1.00 SAR at a timeالطريقة البسيطة في race-naive.sql: نقرأ الرصيد إلى متغير بالأمر gset، ثم نكتب القيمة المحسوبة:
-- read, compute in the application, write back
SELECT balance AS bal FROM wallet_simple WHERE id = 1 \gset
UPDATE wallet_simple SET balance = :bal - 100 WHERE id = 1;docker exec lab-case-ledger psql -U postgres -q -f /lab/05-race-setup.sql
docker exec lab-case-ledger pgbench -U postgres -n -c 20 -j 4 -t 500 -f /lab/race-naive.sql postgres | grep -E 'processed|failed|retried|retries|tps'
docker exec lab-case-ledger psql -U postgres -tAc "SELECT balance FROM wallet_simple WHERE id = 1"number of transactions actually processed: 10000/10000
number of failed transactions: 0 (0.000%)
tps = 373.571128 (without initial connection time)
950000نجحت العشرة آلاف عملية دون أي خطأ، ومع ذلك بقي في المحفظة 950000 هللة، أي 9,500 ريال، فقد ضاعت 9,500 دفعة من 10,000 لأن كل طلب كتب فوق طلب آخر قرأ الرصيد نفسه. ولاحظ أن pgbench لم يبلغ عن أي فشل، وهذه أخطر صفة لهذا الخطأ: لا يظهر في السجلات ولا في المراقبة، وإنما في المطابقة بعد أيام.
والآن الطريقة نفسها مع قفل السطر في race-for-update.sql:
BEGIN;
SELECT balance AS bal FROM wallet_simple WHERE id = 1 FOR UPDATE \gset
UPDATE wallet_simple SET balance = :bal - 100 WHERE id = 1;
COMMIT;docker exec lab-case-ledger psql -U postgres -qc "UPDATE wallet_simple SET balance = 1000000 WHERE id = 1"
docker exec lab-case-ledger pgbench -U postgres -n -c 20 -j 4 -t 500 -f /lab/race-for-update.sql postgres | grep -E 'processed|failed|retried|retries|tps'
docker exec lab-case-ledger psql -U postgres -tAc "SELECT balance FROM wallet_simple WHERE id = 1"number of transactions actually processed: 10000/10000
number of failed transactions: 0 (0.000%)
tps = 558.236580 (without initial connection time)
0الرصيد صفر بالضبط، والسبب أن كل طلب انتظر الذي قبله على السطر المقفل. ثم نجرب المستوى التسلسلي دون قفل في race-serializable.sql، مرة دون إعادة ومرة مع إعادة حتى 200 مرة بالخيار max-tries:
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT balance AS bal FROM wallet_simple WHERE id = 1 \gset
UPDATE wallet_simple SET balance = :bal - 100 WHERE id = 1;
COMMIT;docker exec lab-case-ledger psql -U postgres -qc "UPDATE wallet_simple SET balance = 1000000 WHERE id = 1"
docker exec lab-case-ledger pgbench -U postgres -n -c 20 -j 4 -t 500 -f /lab/race-serializable.sql postgres | grep -E 'processed|failed|retried|retries|tps'
docker exec lab-case-ledger psql -U postgres -tAc "SELECT balance FROM wallet_simple WHERE id = 1"number of transactions actually processed: 730/10000
number of failed transactions: 9270 (92.700%)
tps = 398.468571 (without initial connection time)
927000docker exec lab-case-ledger psql -U postgres -qc "UPDATE wallet_simple SET balance = 1000000 WHERE id = 1"
docker exec lab-case-ledger pgbench -U postgres -n -c 20 -j 4 -t 500 --max-tries=200 -f /lab/race-serializable.sql postgres | grep -E 'processed|failed|retried|retries|tps'
docker exec lab-case-ledger psql -U postgres -tAc "SELECT balance FROM wallet_simple WHERE id = 1"number of transactions actually processed: 9996/10000
number of failed transactions: 4 (0.040%)
number of transactions retried: 7039 (70.390%)
total number of retries: 127888
tps = 328.683894 (without initial connection time)
400في المخرجين أعلاه لاحظ التالي:
- دون إعادة رفضت قاعدة البيانات 9,270 معاملة بخطأ التسلسل، ونجحت 730 فقط، والرصيد 927000 أي 10,000 ناقص 730 ريالاً بالضبط، فلم يضع شيء، ولكن أغلب الدفعات فشلت.
- مع الإعادة نجحت 9,996 معاملة وفشلت 4 بعد 200 محاولة، والرصيد 400 هللة أي أربعة ريالات تساوي الأربع الفاشلة، فالنتيجة صحيحة مرة أخرى.
- ولكن ثمن ذلك 127,888 إعادة، أي قرابة 13 إعادة لكل دفعة، على حساب واحد يدفع منه الجميع، لذلك فالقفل الصريح أنسب للحساب الساخن، والمستوى التسلسلي أنسب للمنطق المعقد الذي يندر فيه التعارض.
السباق نفسه على دالة الدفتر
الآن نشحن محفظتين، الأولى بعشرة آلاف ريال والثانية بخمسين ريالاً، ونضيف دالة مساعدة للمختبر فقط تحول خطأ نقص الرصيد إلى قيمة نعدها، لأن pgbench يوقف الاتصال عند أي خطأ آخر غير التسلسل والجمود:
SELECT open_account('wallet:2001', 'liability');
SELECT open_account('wallet:2002', 'liability');
SELECT transfer('topup-pay_B7', 'psp:clearing', 'wallet:2001', 1000000, 'topup', 'pay_B7'); -- 10,000.00
SELECT transfer('topup-pay_C2', 'psp:clearing', 'wallet:2002', 5000, 'topup', 'pay_C2'); -- 50.00
INSERT INTO wallet_simple VALUES (2, 5000); -- 50.00
-- lab helper: pgbench stops a client on an error, so turn "insufficient funds" into a return value
CREATE FUNCTION try_transfer(p_key text, p_debit text, p_credit text, p_amount bigint) RETURNS text
LANGUAGE plpgsql AS $$
BEGIN
PERFORM transfer(p_key, p_debit, p_credit, p_amount);
RETURN 'ok';
EXCEPTION WHEN check_violation THEN
RETURN 'insufficient';
END $$;
CREATE TABLE attempts (result text);وهذه عشرة آلاف دفعة بريال من المحفظة الأولى إلى المطعم، كل منها بمفتاح عشوائي:
\set k random(1, 9000000000000000000)
SELECT transfer('pay-' || :k, 'wallet:2001', 'merchant:77', 100);docker exec lab-case-ledger psql -U postgres -q -f /lab/06-race-ledger-setup.sql -o /dev/null
docker exec lab-case-ledger pgbench -U postgres -n -c 20 -j 4 -t 500 -f /lab/race-ledger.sql postgres | grep -E 'processed|failed|retried|retries|tps'
docker exec lab-case-ledger psql -U postgres -c "SELECT a.code, b.balance FROM balances b JOIN accounts a ON a.id = b.account_id WHERE a.code = 'wallet:2001'"number of transactions actually processed: 10000/10000
number of failed transactions: 0 (0.000%)
tps = 507.353892 (without initial connection time)
code | balance
-------------+---------
wallet:2001 | 0
(1 row)الرصيد صفر، وكل دفعة لها قيد وسطران، وسرعة 507 دفعات في الثانية على حساب واحد ساخن على هذا الجهاز قياس محلي يعطيك فكرة فقط، فكل دفعة هنا معاملة كاملة تكتب ثلاثة أسطر وتنتظر القفل وتنتظر حفظ السجل على القرص.
ثم السحب بأكثر من الرصيد: المحفظة فيها خمسون ريالاً، وعشرون اتصالاً يحاول كل منها عشر مرات أن يصرف عشرة ريالات. والطريقة البسيطة تفحص الرصيد أولاً ثم تخصم:
-- check the balance, then spend 10.00 if it is enough
SELECT balance AS bal FROM wallet_simple WHERE id = 2 \gset
\if :bal >= 1000
UPDATE wallet_simple SET balance = balance - 1000 WHERE id = 2;
\endifdocker exec lab-case-ledger pgbench -U postgres -n -c 20 -j 4 -t 10 -f /lab/race-overdraft-naive.sql postgres | grep -E 'processed|failed|retried|retries|tps'
docker exec lab-case-ledger psql -U postgres -tAc "SELECT balance FROM wallet_simple WHERE id = 2"number of transactions actually processed: 200/200
number of failed transactions: 0 (0.000%)
tps = 3748.196181 (without initial connection time)
-19000الرصيد سالب 190 ريالاً، أي أن المحفظة صرفت 240 ريالاً وفيها 50، وهذه بالضبط ثغرة Starbucks التي ذكرناها، مع أن الخصم نفسه balance - 1000 ذري، فالخطأ في أن الفحص والخصم خطوتان. والآن الشيء نفسه بدالة الدفتر:
\set k random(1, 9000000000000000000)
INSERT INTO attempts SELECT try_transfer('od-' || :k, 'wallet:2002', 'merchant:77', 1000);docker exec lab-case-ledger pgbench -U postgres -n -c 20 -j 4 -t 10 -f /lab/race-overdraft-ledger.sql postgres | grep -E 'processed|failed|retried|retries|tps'
docker exec lab-case-ledger psql -U postgres -c "SELECT result, count(*) FROM attempts GROUP BY result" -c "SELECT a.code, b.balance FROM balances b JOIN accounts a ON a.id = b.account_id WHERE a.code = 'wallet:2002'"number of transactions actually processed: 200/200
number of failed transactions: 0 (0.000%)
tps = 988.347384 (without initial connection time)
result | count
--------------+-------
ok | 5
insufficient | 195
(2 rows)
code | balance
-------------+---------
wallet:2002 | 0
(1 row)خمس دفعات نجحت، أي خمسون ريالاً بالضبط، و195 رفضت لنقص الرصيد، والرصيد صفر وليس سالباً.
المطابقة مع ملف مزود الدفع
نفترض أن مزود الدفع أرسل ملف التسوية التالي ليوم 2026-10-08، وفيه المبلغ والرسوم بالهللة:
psp_ref,amount,fee,settled_on
pay_A1,10000,180,2026-10-08
pay_B7,1000000,1750,2026-10-08
pay_C2,4500,90,2026-10-08
pay_E4,3000,60,2026-10-08والملف 08-recon.sql يضيف أولاً شحناً سجلناه ثم رفضه بنك العميل، ثم يحمل ملف التسوية، ويقارن، ويسوي المطابق فقط:
-- a top-up our app recorded, but the PSP later reversed (the customer's bank declined it)
SELECT transfer('topup-pay_D9', 'psp:clearing', 'wallet:1002', 2000, 'topup', 'pay_D9');
CREATE TABLE psp_report (psp_ref text PRIMARY KEY, amount bigint NOT NULL, fee bigint NOT NULL, settled_on date NOT NULL);
\copy psp_report FROM '/lab/psp-settlement-2026-10-08.csv' CSV HEADER
CREATE VIEW recon AS
WITH ours AS (
SELECT j.ref, p.amount
FROM journal j JOIN postings p ON p.journal_id = j.id JOIN accounts a ON a.id = p.account_id
WHERE j.kind = 'topup' AND a.code = 'psp:clearing'
)
SELECT COALESCE(o.ref, r.psp_ref) AS ref, o.amount AS ledger, r.amount AS psp, r.fee,
CASE WHEN o.ref IS NULL THEN 'missing in ledger'
WHEN r.psp_ref IS NULL THEN 'missing at PSP'
WHEN o.amount <> r.amount THEN 'amount differs'
ELSE 'matched' END AS status
FROM ours o FULL JOIN psp_report r ON r.psp_ref = o.ref;
SELECT * FROM recon ORDER BY ref;
-- settle only what matched: the bank received gross minus fee
SELECT post('settle-2026-10-08', 'settlement', 'psp-2026-10-08',
jsonb_build_array(
jsonb_build_object('account', 'bank:main', 'amount', SUM(psp - fee)),
jsonb_build_object('account', 'fees:psp', 'amount', SUM(fee)),
jsonb_build_object('account', 'psp:clearing', 'amount', -SUM(psp))))
FROM recon WHERE status = 'matched';
SELECT a.code, round(b.balance / 100.0, 2) AS sar
FROM balances b JOIN accounts a ON a.id = b.account_id
WHERE a.code IN ('psp:clearing', 'bank:main', 'fees:psp') ORDER BY a.id;docker exec lab-case-ledger psql -U postgres -q -f /lab/08-recon.sql transfer
----------
10213
(1 row)
ref | ledger | psp | fee | status
--------+---------+---------+------+-------------------
pay_A1 | 10000 | 10000 | 180 | matched
pay_B7 | 1000000 | 1000000 | 1750 | matched
pay_C2 | 5000 | 4500 | 90 | amount differs
pay_D9 | 2000 | | | missing at PSP
pay_E4 | | 3000 | 60 | missing in ledger
(5 rows)
post
-------
10214
(1 row)
code | sar
--------------+----------
psp:clearing | 70.00
bank:main | 10080.70
fees:psp | 19.30
(3 rows)في المخرج أعلاه لاحظ التالي:
- الربط FULL JOIN على مرجع المزود يظهر الحالات الأربع في استعلام واحد: مطابق، ومبلغ مختلف، وموجود عندنا فقط، وموجود عند المزود فقط.
- قيد التسوية أدخل في البنك صافي المطابق 10,080.70 ريال، وفي المصاريف رسوم المزود 19.30، وأخرج من psp:clearing الإجمالي 10,100.00.
- بقي في psp:clearing سبعون ريالاً، وهي بالضبط pay_C2 بخمسين وpay_D9 بعشرين، أي قائمة الحالات المفتوحة، أما pay_E4 فشحن نجح عند المزود ولم يصلنا إشعاره، فيجب أن يضاف رصيده للعميل بعد التحقق.
فحص الليلة
وأخيراً الاستعلامان اللذان يجب أن يعملا كل ليلة، وهما مهمة Integrity check في المعمارية الكاملة، والمتوقع منهما صفر وصفر سطور:
-- 1) the whole ledger sums to zero per currency (trial balance)
SELECT currency, SUM(amount) AS total, count(*) AS postings FROM postings GROUP BY currency;
-- 2) the cached balances equal the sum of the postings, for every account
SELECT a.code, b.balance AS cached,
COALESCE(SUM(CASE WHEN a.type IN ('asset','expense') THEN p.amount ELSE -p.amount END), 0) AS from_postings
FROM accounts a JOIN balances b ON b.account_id = a.id LEFT JOIN postings p ON p.account_id = a.id
GROUP BY a.code, b.balance, a.id
HAVING b.balance <> COALESCE(SUM(CASE WHEN a.type IN ('asset','expense') THEN p.amount ELSE -p.amount END), 0)
ORDER BY a.id;docker exec lab-case-ledger psql -U postgres -q -f /lab/07-integrity.sql currency | total | postings
----------+-------+----------
SAR | 0 | 20031
(1 row)
code | cached | from_postings
------+--------+---------------
(0 rows)بعد أكثر من عشرين ألف سطر من السباقات والتحويلات والتسوية ما زال الدفتر يساوي صفراً، ولا يوجد حساب واحد يختلف رصيده المحفوظ عن مجموع سطوره.
حذف المختبر
docker rm -f lab-case-ledgerأخطاء شائعة
- المال في float: استخدم أعداداً صحيحة بأصغر وحدة للعملة، أو numeric إذا احتجت كسوراً للفوائد والضرائب.
- فحص الرصيد ثم الخصم في خطوتين: ثغرة سحب بأكثر من الرصيد كما رأينا، فاجعل الفحص والخصم في قفل واحد أو جملة واحدة.
- تعديل قيد قديم لتصحيحه: يمحو الأثر، فصحح بقيد عكسي جديد.
- مفتاح منع التكرار خارج المعاملة: يعطيك خصماً مكرراً أو طلباً ضائعاً عند أول سقوط للسيرفر.
- قفل الحسابات بترتيب عشوائي: جمود Deadlock تحت الضغط، فاقفل بترتيب ثابت.
- المستوى التسلسلي دون إعادة في الكود: أغلب الدفعات تفشل على الحساب الساخن كما رأينا.
- المطابقة على المبلغ وليس المرجع: مبلغان متساويان ليسا عملية واحدة.
- رقم البطاقة على سيرفرك دون حاجة: يدخل كل النظام في نطاق PCI DSS.
الخلاصة
وصلنا لنهاية الموضوع، وأهم ما فيه:
- المحفظة دفتر قيود مزدوجة: كل حركة قيد مجموعه صفر، والرصيد مجموع السطور، ومحفظة العميل التزام عليك وليست مالك.
- الدفتر يقبل الإضافة فقط، والتصحيح بقيد جديد، والرصيد المحفوظ نسخة تتحقق منها كل ليلة.
- المبالغ أعداد صحيحة بالهللة مع رمز العملة.
- مفتاح منع التكرار عمود فريد في جدول القيود يحفظ في المعاملة نفسها، مع بصمة للطلب.
- في المختبر ضاعت 9,500 دفعة من 10,000 بالقراءة ثم الكتابة، وصرفت محفظة 240 ريالاً وفيها 50، بينما أعطت دالة الدفتر مع FOR UPDATE النتيجة الصحيحة في الحالتين.
- المطابقة اليومية مع مزود الدفع على المرجع، وما يبقى في الحساب الوسيط هو قائمة الحالات المفتوحة.
- تقديم المحافظ في المملكة يحتاج ترخيصاً من البنك المركزي السعودي بحسب نظام المدفوعات وخدماتها، ولائحته تطلب حماية أموال العملاء في حساب منفصل مع مطابقة يومية، وسجلات عشر سنوات.
دليلكيف يختار تطبيق التوصيل المندوب؟ محرك التوزيع Dispatchكيف تختار تطبيقات التوصيل المندوب لكل طلب، ولماذا يكون التوزيع على دفعات أفضل من إعطاء كل طلب أقرب مندوب.
دليلتتبع مواقع المندوبين لحظياً: من الهاتف إلى الخريطةكيف تستقبل مواقع آلاف المندوبين كل بضع ثوان، وتحفظ آخر موقع لكل منهم، وتوصله إلى العميل الذي ينتظر طلبه.
دليلتعلم SQL (1): ما هو SQL وكيف تكتب أول استعلامتتعلم SQL من الصفر: ما هي قاعدة البيانات العلائقية، وكيف تنشئ الجداول وتضيف البيانات وتكتب أول استعلام بأمان.
دليلتعلم SQL (3): CTE ودوال النوافذ والمعاملاتكيف تكتب استعلامات أوضح بجمل WITH ودوال النوافذ، وتحمي بياناتك بالمعاملات ومستويات العزل ومن حقن SQL.
دليلFloating Point من الداخل: كيف يخزن الحاسب الكسوركيف يحفظ معيار IEEE 754 الكسور في البتات، ولماذا تكبر الفجوة بين الأرقام وتضيع خانات المعرفات، ومتى تحتاج النوع العشري.
دليلAudit Trail: كيف تبني سجل تدقيق لا يقبل العبثكيف تصمم سجلاً يجيب عن سؤال من غير هذه الصلاحية ومتى، وتحميه من الحذف والتعديل بعد كتابته.
دليلالأنظمة الموزعة للمبتدئين: ماذا يحدث عندما تنقطع الشبكةلماذا تتصرف الأنظمة الموزعة بشكل غريب عند انقطاع الشبكة، وكيف تتفق السيرفرات على قرار واحد رغم ذلك.سجل التحديثات
- أكتوبر 2026: إضافة قسم المعمارية الكاملة: المخطط والمكونات ونموذج البيانات والواجهات وتقدير السعة ومراحل النمو.
- أكتوبر 2026: كتابة المقال، والتحقق من نظام المدفوعات وخدماتها ولائحته التنفيذية وأنواع التراخيص في دليل أنظمة البنك المركزي السعودي، وصفحة PCI DSS، وتوثيق PostgreSQL، وتشغيل المختبر على PostgreSQL 18.6 مع pgbench.