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

دفتر الأستاذ للأموال Ledger: TigerBeetle وFormance وBlnk

شغلنا TigerBeetle وFormance Ledger وBlnk بنفس سيناريو الحجز والتقسيم، ورأينا كيف تحمي كل أداة الرصيد ومتى يعرف تطبيقك النتيجة.

دفتر الأستاذ للأموال Ledger: TigerBeetle وFormance وBlnk

لنفرض أن لديك شركة ناشئة تدير منصة للعمل الحر، تدفع فيها الشركات للمصممين والمطورين عبر المنصة، والمنصة تحجز المبلغ حتى يسلم المستقل العمل، ثم تحوله إليه بعد خصم عمولتها. وقد بنى الفريق في سنته الأولى دفتر قيود Ledger على PostgreSQL بالطريقة التي شرحناها في دراسة حالة المحفظة الرقمية: قيد مزدوج، ومبالغ بالهللة، ومفتاح لمنع التكرار، وقفل على الرصيد. ونجح الدفتر، ثم كبرت المنصة، فصار عند الفريق ثلاثة أسئلة في نفس الأسبوع: مدير المنتج يريد دعم الدفع على دفعات Milestones بحجوزات تنتهي تلقائياً إذا لم يسلم المستقل في موعده، ومهندس البنية لاحظ أن الحساب الواحد لعمولة المنصة صار يقفل في كل معاملة، والمؤسس سأل سؤالاً بسيطاً: هل نستمر في بناء دفترنا بأنفسنا، أم توجد أدوات جاهزة بنيت لهذا بالضبط؟

والأدوات الجاهزة موجودة فعلاً، وأشهر ثلاث منها مفتوحة المصدر: TigerBeetle، وهي قاعدة بيانات بنيت للقيود المالية وحدها، وFormance Ledger، وهو خدمة فوق PostgreSQL بلغة خاصة لوصف حركة الأموال، وBlnk، وهو دفتر فوق PostgreSQL وRedis بطابور للمعاملات. وفي هذا المقال سوف نتابع الفريق وهو يقارن بينها، لا من صفحاتها التسويقية، وإنما بتشغيل الأدوات الثلاث وتمرير نفس السيناريو على كل منها بنفس الأرقام.

دليلالمحفظة الرقمية من الداخل: دفتر القيد المزدوج Ledgerكيف تبني محفظة رقمية لا يضيع فيها ريال ولا يخصم مرتين، بالقيد المزدوج والمعاملات والمطابقة مع مزود الدفع.

سوف نناقش في هذا المقال:

  • لماذا ظهرت أدوات مخصصة للدفتر، وما المشكلة التي لا يحلها جدول في قاعدة بيانات عامة بسهولة.
  • كيف بنيت كل أداة من الداخل: أين يعيش الدفتر، وكيف يصله الطلب، ومتى تعرف النتيجة.
  • التراخيص والإصدارات ونشاط كل مشروع، ومنها تغيير كبير يجري الآن في Formance.
  • مختبر بالسيناريو نفسه على الأدوات الثلاث: إيداع وحجز وسحب أكبر من الرصيد وتقسيم ذري وطلب مكرر، ثم ماذا يحدث لحجز لم يكتمل.
  • السرعة كما قسناها، ولماذا يجب أن تقرأ بحذر.
  • كيف تنمذج حسابات المنصة في كل أداة، وكيف يتحدث تطبيقك مع الدفتر دون أن يختلفا.
  • الحجز في كل أداة، وما يحتاجه تشغيل كل منها في الإنتاج، وما تعنيه لوائح المدفوعات لاختيار الدفتر.
  • القرار الذي اتخذه الفريق، وجدول يساعدك في قرارك.

لماذا ظهرت أدوات مخصصة للدفتر؟

القيد المزدوج Double-Entry فكرة عمرها قرون: كل حركة مال تسجل مرتين، مرة كمدين Debit في حساب ومرة كدائن Credit في حساب آخر، ومجموع المدين يساوي مجموع الدائن دائماً. وتطبيقها في قاعدة بيانات علائقية ليس صعباً كفكرة، وقد بنيناه كاملاً بجداوله ودوال التحويل فيه في دراسة الحالة السابقة. والصعوبة تظهر في ثلاثة أماكن عندما يكبر النظام:

  • الحساب الساخن Hot Account: حساب عمولة المنصة أو حساب البنك يشارك في أغلب المعاملات، وكل معاملة تقفل صفه لتعديل رصيده، فتصطف المعاملات خلفه مهما كان عدد السيرفرات. وهذا ما لاحظه مهندس البنية في القصة.
  • الصحة تحت الضغط: كل قاعدة في الدفتر، مثل ألا يصبح رصيد العميل سالباً، يجب أن تبقى صحيحة مع آلاف الطلبات المتزامنة، والخطأ هنا لا يظهر كرسالة خطأ، وإنما كمال يختفي أو يتضاعف.
  • الأشياء المتكررة في كل دفتر: الحجز، والتقسيم بين عدة حسابات، ومنع التكرار، والمطابقة، وكل فريق يبنيها من جديد ويقع في نفس الأخطاء.

والأدوات الثلاث في هذا المقال إجابات مختلفة عن هذه المشكلات. فـ TigerBeetle يبدأ من فكرة أن معاملات الدفتر نوع خاص من البيانات يستحق قاعدة بيانات خاصة، بحقول ثابتة ومعالجة على دفعات Batches، وتصفه صفحته بأنه قاعدة بيانات للمعاملات المالية مبنية للسلامة والأداء. أما Formance وBlnk فيبدآن من PostgreSQL الذي تعرفه أغلب الفرق، ويضيفان فوقه نموذجاً جاهزاً للحسابات والمعاملات وواجهة HTTP، فيوفران عليك بناء ما بنيناه في دراسة الحالة، دون أن يغيرا محرك التخزين.

كيف بنيت كل أداة من الداخل؟

قبل المختبر يستحق أن ترى الأدوات الثلاث بجانب بعضها، لأن أغلب الفروق التي سوف تظهر في النتائج تأتي من هذا التصميم:

ثلاث بطاقات: TigerBeetle قاعدة بيانات مخصصة للقيود فقط، يتصل بها التطبيق عبر عميل client بـ binary protocol، بملف بيانات واحد وسجل مكرر Replicated، وحسابات وتحويلات بحقول ثابتة بلا SQL، بترخيص Apache-2.0 والإصدار 0.17.9. وFormance Ledger خدمة Go فوق PostgreSQL بواجهة HTTP API v2 ولغة Numscript، وحسابات بعناوين مثل escrow:order-1 ومعاملة متزامنة ترد بالنتيجة فوراً، بترخيص MIT للإصدار v2.4.16. وBlnk خادم وWorker فوق PostgreSQL وRedis بواجهة HTTP تضع الطلب في طابور، بأرصدة Balances ومعاملات معلقة Inflight، يقبل الطلب ثم يطبقه، بترخيص Apache-2.0 والإصدار 0.15.7
الفرق الأهم ليس في الميزات، وإنما في متى تعرف أن المعاملة سجلت

TigerBeetle: قاعدة بيانات لا تعرف إلا الحسابات والتحويلات

في TigerBeetle لا توجد جداول تصممها ولا SQL، وإنما نوعان فقط من السجلات: الحساب Account والتحويل Transfer، ولكل منهما حقول ثابتة كما في مرجع التحويل: معرف بحجم 128 بت، وحسابا المدين والدائن، والمبلغ، ورقم الدفتر Ledger الذي يمثل العملة، ورمز Code لنوع الحركة، وأعلام Flags تغير سلوكه، وحقول للبيانات الخاصة بك. والحساب لا يحفظ رصيداً واحداً، وإنما أربعة أرقام: المدين المعلق والمدين المرحل والدائن المعلق والدائن المرحل، والرصيد تحسبه أنت منها. وقواعد مثل «لا يتجاوز المدين الدائن» أعلام على الحساب نفسه تفرضها قاعدة البيانات في كل تحويل.

والتطبيق لا يرسل SQL، وإنما يستخدم مكتبة العميل Client للغته، وهي ترسل التحويلات في دفعات بصيغة ثنائية، والخادم يعالج الدفعة كاملة في خطوة واحدة، وهذا ما يجعل الحساب الساخن مشكلة أصغر، لأن آلاف التحويلات على نفس الحساب تمر في دفعة واحدة بدل أن تتنافس على قفل. والخادم ملف تنفيذي واحد يعمل على ملف بيانات واحد، والإنتاج عنقود من عدة نسخ Replicas تتفق على ترتيب كل معاملة، والحجم الموصى به للإنتاج كما في توثيق العنقود ست نسخ.

Formance Ledger: دفتر بلغة لوصف حركة المال

Formance Ledger خدمة مكتوبة بلغة Go تحفظ الدفتر في PostgreSQL، والحسابات فيه لا تنشأ مسبقاً، وإنما هي عناوين نصية مثل customers:c1 أو escrow:order-1 تظهر عند أول حركة عليها، والحساب الخاص world هو مصدر المال الذي يدخل الدفتر من الخارج، ويسمح له وحده بأن يصبح سالباً. والمعاملة قائمة من الحركات Postings، كل حركة من حساب إلى حساب بمبلغ وأصل Asset مثل SAR/2، والرقم بعد الشرطة عدد المنازل العشرية. وما يميزه لغة Numscript التي تصف بها حركة المال كنص، مثل «أرسل 500 ريال من الضمان، 90% للمستقل والباقي للمنصة»، والخادم يحولها إلى حركات بالأرقام ويرفضها إذا لم يكف الرصيد.

Blnk: أرصدة وطابور فوق PostgreSQL

Blnk مكتوب بلغة Go أيضاً، ويتكون من خادم HTTP وعامل Worker منفصل، والاثنان يتصلان بـ PostgreSQL للدفتر وبـ Redis للطابور. والمفاهيم فيه قريبة من لغة المنتجات المالية: دفتر Ledger، وأرصدة Balances يملك كل منها عملة، ومعاملات Transactions بين الأرصدة، والأرصدة الداخلية التي تبدأ بعلامة @ مثل @World تنشأ تلقائياً. والفرق التصميمي الأكبر أن الخادم لا يطبق المعاملة لحظة استقبالها، وإنما يضعها في الطابور ويرد بحالة QUEUED، والعامل يطبقها بعد ذلك، فتصبح APPLIED أو REJECTED. وفيه أيضاً المعاملات المعلقة Inflight للحجز كما في توثيقها، والسماح بالسحب على المكشوف لكل معاملة بالحقل allow_overdraft.

التراخيص والإصدارات: من يصون كل أداة؟

الدفتر أكثر جزء في منتج مالي لا تريد أن تغيره بعد سنتين، لذلك سأل الفريق أولاً عن الترخيص ووتيرة الإصدارات قبل أي ميزة. وهذا ما وجدناه في صفحات الإصدارات وملفات الترخيص في 27 أكتوبر 2026:

الأداةآخر إصدارالترخيصما يحتاجه للتشغيل
TigerBeetle0.17.9 في 6 يوليو 2026Apache-2.0ملف تنفيذي واحد وملف بيانات لكل نسخة
Formance Ledgerv2.4.16 في 21 أكتوبر 2026MIT في هذا الإصدارالخدمة وPostgreSQL
Blnkv0.15.7 في 24 أكتوبر 2026Apache-2.0الخادم والعامل وPostgreSQL وRedis

وفي الجدول تفصيلان يستحقان الانتباه. الأول أن آخر إصدار منشور لـ TigerBeetle في يوليو، مع أن المستودع يتحدث باستمرار، فاقرأ صفحة الإصدارات قبل أن تختار الإصدار الذي تثبته، ولا تعتمد على تاريخ آخر تعديل في الكود. والثاني أهم: الفرع الرئيسي في مستودع Formance Ledger يصف نفسه الآن بأنه Ledger v3، نظام موزع بخوارزمية Raft وتخزين Pebble بدل PostgreSQL، ولم نجد فيه ملف ترخيص في 27 أكتوبر 2026، بينما الإصدار v2.4.16 الذي شغلناه يحمل ترخيص MIT ويعمل على PostgreSQL. وهذا لا يعني أن شيئاً سيئاً سوف يحدث، ولكنه يعني أن من يختار Formance اليوم يختار خطاً يتغير معماره، فاقرأ ترخيص الإصدار الذي تثبته بالضبط، وتابع ما يعلنه المشروع عن الخط الجديد قبل أن تبني عليه.

ماذا تقول أرقام المستودعات؟

وسألنا واجهة GitHub العامة في نفس اليوم عن كل مستودع، فكان عدد النجوم 17,256 لـ TigerBeetle، و1,454 لـ Formance Ledger، و570 لـ Blnk، وآخر تحديث على الكود في 23 و27 و24 أكتوبر بالترتيب. والنجوم تقيس الانتباه لا الجودة، ولكنها تعطيك فكرة عن حجم المجتمع الذي سوف تجد عنده إجابات عندما تواجه مشكلة لم يوثقها أحد، والفرق هنا كبير. والأهم للفريق الصغير أن المستودعات الثلاثة تحدثت في نفس الأسبوع، أي أن المشاريع حية، وأن من يعمل عليها شركات تبيع خدمات حولها، وهذا يعني دعماً مدفوعاً إذا احتجته، ويعني أيضاً أن اتجاه المشروع تقرره شركة لا مجتمع، كما رأينا في خط Formance الجديد.

كيف تنمذج حسابات المنصة في كل أداة؟

قبل أي سطر كود يحتاج الفريق دليل حسابات Chart of Accounts، أي قائمة الحسابات ونوع كل منها. وفي القصة أربعة أنواع: أموال العملاء في محافظهم، وهي التزام على المنصة لأنها مال غيرها، والضمان المحجوز لكل طلب، وهو التزام أيضاً، وعمولة المنصة، وهي إيرادها، والحساب البنكي الذي تقابله الأموال فعلاً. وكل أداة تعبر عن هذا الدليل بطريقة مختلفة:

  • في TigerBeetle الحساب رقم بحجم 128 بت لا اسم له، والحقل code رقم صغير يصنفه، فيكتب الفريق جدولاً في كوده: 10 لمحافظ العملاء، و20 للضمان، و30 للإيراد، و40 للبنك، ويضع رقم الطلب أو العميل من قاعدة بياناته في الحقل user_data_128، حتى يستطيع أن يسأل «ما تحويلات هذا الطلب؟» دون جدول ربط. والتحويل أيضاً له code لنوع الحركة: إيداع، وحجز، وإفراج، وعمولة، واسترداد. وهذا يجعل التقارير ممكنة، ولكنه يعني أن معنى كل رقم يعيش في كودك لا في قاعدة البيانات.
  • في Formance الدليل هو أسماء الحسابات نفسها، فالبنية customers:{id} وescrow:{order} وplatform:fees تقرأ في كل معاملة، ويمكن أن تحمل كل معاملة وكل حساب بيانات وصفية Metadata. والخطر هنا عكس TigerBeetle: لا شيء يمنع أحداً من كتابة customer:c1 بدل customers:c1، فينشأ حساب جديد بصمت، لذلك تمر كل المعاملات بطبقة واحدة في كودك تبني الأسماء من دوال لا من نصوص يكتبها المطور.
  • في Blnk كل رصيد ينشأ صراحة بطلب، ويعود له معرف مثل bln_…، ويمكن أن يرتبط بهوية Identity للعميل وبيانات وصفية، فالربط بين المستخدم ورصيده جزء من الأداة نفسها. والثمن أن تطبيقك يجب أن يحفظ معرف كل رصيد، لأن المعاملات تشير إليه لا إلى اسم.

والقاعدة المشتركة التي كتبها الفريق قبل أي تجربة: كل حساب له نوع واحد، وكل حركة لها نوع واحد، والمبلغ دائماً بالهللة، ولا يوجد حساب «متنوع» تذهب إليه الفروقات، لأن هذا الحساب هو أول مكان تختفي فيه الأموال من التقارير.

المختبر: نفس السيناريو على الأدوات الثلاث

السيناريو مأخوذ من القصة كما في الصورة التالية: عميل يودع 1000 ريال، ثم يدفع 500 لطلب عند مستقل فتحجز في حساب ضمان Escrow، ثم يحاول دفع 800 لطلب ثان والمتاح 500 فقط، ثم يسلم المستقل فيقسم المبلغ: 450 له و50 عمولة للمنصة. وقبل التقسيم الصحيح نجرب تقسيماً خاطئاً بـ 450 و90، أي أكثر من المحجوز، ويجب أن يفشل كاملاً لا أن ينجح نصفه. ثم نرسل نفس الطلب مرتين. وكل المبالغ بالهللة لأن المال لا يحفظ بالكسور كما شرحنا في دراسة الحالة، فالألف ريال 100000، وكل البيانات تجريبية.

سيناريو المختبر: البنك أو مزود الدفع يودع T1 1000.00 في محفظة العميل، ثم T2 حجز 500.00 بخط متقطع إلى الضمان Escrow، ثم من الضمان 450.00 إلى المستقل و50.00 إلى عمولة المنصة، وتحتها الحالات التي جربناها: إيداع ثم حجز 500 ثم طلب ثان بـ 800 والمتاح 500 ثم تقسيم 450 + 90 يجب أن يفشل كاملاً ثم تقسيم 450 + 50 ينجح ثم نفس الطلب مرتين ثم 10,000 تحويل لقياس السرعة، والمبالغ بالهللة 1000.00 ريال = 100000
نفس الأرقام على الأدوات الثلاث، حتى تكون المقارنة في السلوك لا في السيناريو

وشغلنا كل أداة في Containers بالبادئة sck- على Docker Engine 29.8.0 في Docker Desktop على Windows، والـ Images صغيرة: 41.7 ميجابايت لـ TigerBeetle، و255 لـ Formance Ledger، و307 لـ Blnk، مع postgres:18-alpine وredis:7-alpine الموجودتين على الجهاز.

TigerBeetle 0.17.9

TigerBeetle يحتاج خطوتين: تهيئة ملف البيانات Format ثم التشغيل. وعلى Docker Desktop يحتاج الـ Container الخيار seccomp=unconfined لأن الخادم يستخدم واجهة io_uring في Linux، والخيار --development يخفف بعض شروط التشغيل لبيئة التجربة:

docker volume create sck-tb-data
docker run --rm --security-opt seccomp=unconfined -v sck-tb-data:/data ghcr.io/tigerbeetle/tigerbeetle:0.17.9 \
  format --cluster=0 --replica=0 --replica-count=1 --development /data/0_0.tigerbeetle
docker run -d --name sck-tb --security-opt seccomp=unconfined -p 127.0.0.1:18956:3000 -v sck-tb-data:/data \
  ghcr.io/tigerbeetle/tigerbeetle:0.17.9 start --addresses=0.0.0.0:3000 --development /data/0_0.tigerbeetle

وكتبت التهيئة في السجل أنها حجزت ملفاً بحجم 1.06 جيجابايت مسبقاً، وكتب الخادم عند التشغيل أنه حجز 1453 ميجابايت من الذاكرة، وقسنا الـ Container بعد التجربة فكان 1.45 جيجابايت. أي أن TigerBeetle يحجز ذاكرته ومساحته من البداية بدل أن ينمو تدريجياً، وهذا تصميم مقصود، ولكنه يعني أن أصغر تجربة تحتاج جهازاً بذاكرة كافية. ثم كتبنا السيناريو بمكتبة Node.js الرسمية tigerbeetle-node بنفس رقم الإصدار، وهذا جزء منه:

const MUST = AccountFlags.debits_must_not_exceed_credits;
await client.createAccounts([acct(A.bank), acct(A.customer, MUST), acct(A.escrow, MUST), acct(A.freelancer, MUST), acct(A.fee, MUST)]);
await send('T1 deposit 1000.00 SAR (bank -> customer)', [tr(101n, A.bank, A.customer, 100000n)]);
await send('T2 hold 500.00 (customer -> escrow, pending)', [tr(102n, A.customer, A.escrow, 50000n, TransferFlags.pending)]);
await send('T3 second order 800.00 while 500 is held', [tr(103n, A.customer, A.escrow, 80000n)]);
await send('T4 post the hold', [tr(104n, 0n, 0n, 50000n, TransferFlags.post_pending_transfer, 102n)]);
await send('T5 linked split 450 + 90 (escrow has 500)', [tr(105n, A.escrow, A.freelancer, 45000n, TransferFlags.linked), tr(106n, A.escrow, A.fee, 9000n)]);
await send('T6 linked split 450 + 50', [tr(107n, A.escrow, A.freelancer, 45000n, TransferFlags.linked), tr(108n, A.escrow, A.fee, 5000n)]);
await send('T7 the same deposit again (id 101)', [tr(101n, A.bank, A.customer, 100000n)]);
await send('T8 id 101 again with another amount', [tr(101n, A.bank, A.customer, 99999n)]);

والدفتر في TigerBeetle رقمه 682، وهو الرمز الرقمي للريال السعودي في معيار ISO 4217، والسبب أن الحقل ledger يفصل العملات، فلا يقبل تحويلاً بين حسابين من دفترين مختلفين. والمخرج سوف يكون كما يلي:

accounts created,created,created,created,created
T1 deposit 1000.00 SAR (bank -> customer)    created
T2 hold 500.00 (customer -> escrow, pending) created
== after the hold
  bank       credits       0 debits  100000 pending debits      0 -> balance -100000
  customer   credits  100000 debits       0 pending debits  50000 -> balance  100000
  escrow     credits       0 debits       0 pending debits      0 -> balance       0
  freelancer credits       0 debits       0 pending debits      0 -> balance       0
  fee        credits       0 debits       0 pending debits      0 -> balance       0
T3 second order 800.00 while 500 is held     exceeds_credits
T4 post the hold                             created
T5 linked split 450 + 90 (escrow has 500)    linked_event_failed, exceeds_credits
T6 linked split 450 + 50                     created, created
T7 the same deposit again (id 101)           exists
T8 id 101 again with another amount          exists_with_different_amount
== final balances (halalas)
  bank       credits       0 debits  100000 pending debits      0 -> balance -100000
  customer   credits  100000 debits   50000 pending debits      0 -> balance   50000
  escrow     credits   50000 debits   50000 pending debits      0 -> balance       0
  freelancer credits   45000 debits       0 pending debits      0 -> balance   45000
  fee        credits    5000 debits       0 pending debits      0 -> balance    5000

في المخرج أعلاه لاحظ التالي:

  • بعد الحجز: رصيد العميل المرحل ما زال 1000 ريال، والـ 500 في عمود المدين المعلق، والسطر T3 يبين أن TigerBeetle يحسب المعلق عند التحقق من الرصيد: 800 أكبر من 1000 ناقص 500، فرفضها بـ exceeds_credits. أي أن الحجز يمنع إنفاق نفس المال مرتين دون أن يخرج من الرصيد بعد.
  • التقسيم الذري: في T5 التحويل الأول وحده صحيح، والثاني يتجاوز المحجوز، والعلم linked يربطهما في سلسلة، فرفض الأول بـ linked_event_failed لأن الثاني فشل، ولم يسجل شيء، وهذا ما يشرحه توثيق الأحداث المرتبطة.
  • منع التكرار بالمعرف: المعرف 101 أرسل مرتين بنفس البيانات فعاد exists دون أن يتضاعف الإيداع، وبمبلغ مختلف عاد exists_with_different_amount. والمعرف هنا يولده تطبيقك لا قاعدة البيانات، ولذلك يعمل كمفتاح منع التكرار تلقائياً، وهذه الفكرة نفسها في توثيق الإرسال الموثوق.
  • الأرصدة النهائية: مجموع الأرصدة صفر، فالبنك سالب 1000 والبقية موجبة بنفس المجموع، وهذا هو القيد المزدوج دون أن نكتب سطراً لفرضه.

ماذا لو لم يسلم المستقل؟ حجز بمهلة

هذا هو طلب مدير المنتج في القصة: حجز ينتهي وحده إذا لم يكتمل. وفي TigerBeetle للتحويل المعلق حقل timeout بالثواني كما في توثيق التحويل على مرحلتين، فجربنا حجزاً بمهلة ثانيتين، ثم انتظرنا أربع ثوان، ثم حاولنا ترحيله:

deposit 100.00                created
hold 100.00, timeout 2 s      created
  customer pending debits 10000
after 4 s: pending debits 0
post the expired hold         pending_transfer_expired
a new purchase of 100.00      created

بعد المهلة عاد المبلغ متاحاً دون أي استدعاء من تطبيقك، ورفض الترحيل المتأخر بـ pending_transfer_expired، ونجحت عملية شراء جديدة بنفس المبلغ. وهذا يلغي نوعاً كاملاً من الأخطاء في القصة: المهمة المجدولة التي يجب أن تبحث عن الحجوزات القديمة وتلغيها، والتي تنسى حجزاً إذا توقفت ليلة واحدة. ولكنه ينقل السؤال إلى مكان آخر: يجب أن يعرف نظامك أن الحجز انتهى، لأن TigerBeetle لا يرسل لك إشعاراً، فتقرأ حالة التحويل المعلق قبل أن تعد المستقل بشيء.

Formance Ledger v2.4.16

Formance يحتاج PostgreSQL، والخدمة تنشئ جداولها عند أول تشغيل بالخيار --auto-upgrade، وكلمة المرور تأتي من ملف .env لا من الأمر المكتوب:

docker network create sck-ledger
docker run -d --name sck-fm-db --network sck-ledger -e POSTGRES_PASSWORD=$PG_PASSWORD -e POSTGRES_DB=ledger postgres:18-alpine
docker run -d --name sck-fm --network sck-ledger -p 127.0.0.1:18957:3068 ghcr.io/formancehq/ledger:v2.4.16 \
  serve --postgres-uri "postgres://postgres:$PG_PASSWORD@sck-fm-db:5432/ledger?sslmode=disable" --auto-upgrade --experimental-features

ثم أنشأنا دفتراً باسم escrow2 ومررنا نفس السيناريو بواجهة HTTP. والحجز هنا معاملة عادية إلى حساب الضمان، لأن Formance لا يحتوي تحويلاً معلقاً أصيلاً، والتقسيم الصحيح كتبناه بـ Numscript:

send [SAR/2 50000] (
  source = @escrow:order-1
  destination = {
    90% to @freelancers:f1
    remaining to @platform:fees
  }
)

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

create ledger 204
T1 deposit 1000.00 (world -> customer)         200 tx 1
T2 hold 500.00 (customer -> escrow:order-1)    200 tx 2
T3 second order 800.00 with 500 left           400 INSUFFICIENT_FUND: unexpected error while forging log: failed to execute program: failed
T5 split 450 + 90 in one transaction           400 INSUFFICIENT_FUND: unexpected error while forging log: failed to execute program: failed
T6 Numscript split 90% / remaining             200 tx 3
T7 deposit again, same reference dep-1         409 CONFLICT: unexpected error while forging log: failed to insert transaction: dupl
T8 top-up with Idempotency-Key                 200 tx 5
T9 the same request and key again              200 tx 5
T10 same key, different amount                 400 VALIDATION: invalid idempotency hash when using idempotency key 'topup-2026-10-27-
T11 revert the hold (tx 2) after the release   400 INSUFFICIENT_FUND: unexpected error while forging log: insufficient fund for escrow:order
T12 revert the top-up (refund)                 201 tx 6
== transaction ids: 1 2 3 5(reverted) 6
== balances (halalas)
  customers:c1     input  120000 output   70000 -> balance   50000
  escrow:order-1   input   50000 output   50000 -> balance       0
  freelancers:f1   input   45000 output       0 -> balance   45000
  platform:fees    input    5000 output       0 -> balance    5000
  world            input   20000 output  120000 -> balance -100000

في المخرج أعلاه لاحظ التالي:

  • الرد متزامن: كل طلب عاد بنتيجته النهائية، رقم معاملة أو خطأ، فلا تحتاج أن تسأل لاحقاً هل طبقت.
  • الرصيد والتقسيم: T3 وT5 رفضا بـ INSUFFICIENT_FUND، والتقسيم الخاطئ في معاملة واحدة بحركتين لم يسجل منه شيء، لأن المعاملة كلها تنجح أو تفشل.
  • طريقتان لمنع التكرار: المرجع reference فريد في الدفتر، فرفض تكراره بـ 409. ومفتاح Idempotency-Key في الترويسة يتصرف بشكل مختلف، فالطلب المكرر بنفس المفتاح أعاد نفس المعاملة رقم 5 بنجاح، وهذا ما يريده من يعيد المحاولة بعد انقطاع الشبكة، ونفس المفتاح بمبلغ مختلف رفض لأن الخادم يقارن بصمة الطلب.
  • رقم المعاملة 4 مفقود: المحاولة المكررة بالمرجع استهلكت الرقم ثم فشلت، فلا تفترض أن أرقام المعاملات متتالية بلا فجوات، وهذا مهم لمن يبني تقارير المطابقة على تسلسل الأرقام.
  • العكس Revert: عكس الحجز بعد الإفراج عنه فشل لأن حساب الضمان صار فارغاً، وهذا صحيح، فلا يمكن إعادة مال صرف. أما عكس الإيداع الإضافي فنجح كمعاملة جديدة رقم 6، والمعاملة 5 بقيت في الدفتر بعلامة أنها عكست، فلا شيء يحذف.

Blnk 0.15.7

Blnk يحتاج خادماً وعاملاً، وكلاهما يقرأ الإعداد من متغيرات البيئة، فلا حاجة لملف إعداد فيه كلمة المرور:

docker run -d --name sck-bl-db --network sck-ledger -e POSTGRES_PASSWORD=$PG_PASSWORD -e POSTGRES_DB=blnk postgres:18-alpine
docker run -d --name sck-bl-redis --network sck-ledger redis:7-alpine
E="-e BLNK_DATA_SOURCE_DNS=postgres://postgres:$PG_PASSWORD@sck-bl-db:5432/blnk?sslmode=disable -e BLNK_REDIS_DNS=sck-bl-redis:6379 -e BLNK_SERVER_PORT=5001"
docker run -d --name sck-bl --network sck-ledger -p 127.0.0.1:18958:5001 $E --entrypoint /bin/sh jerryenebeli/blnk:0.15.7 -c "blnk migrate up && blnk start"
docker run -d --name sck-bl-worker --network sck-ledger $E --entrypoint blnk jerryenebeli/blnk:0.15.7 workers

وفي السجل عند التشغيل تحذير صريح بأن مصادقة الواجهة معطلة لأن server.secure قيمته false، وأن البحث معطل لأن Typesense غير مضبوط، وهذا مقبول للتجربة فقط. وأول محاولة للسيناريو فشلت كلها برسالة description: cannot be blank، فالوصف حقل إلزامي في كل معاملة. وبعد إضافته كان المخرج كما يلي:

T1 deposit 1000.00 (@World -> customer)        201 QUEUED -> still QUEUED
T2 hold 500.00 (customer -> escrow, inflight)  201 QUEUED -> still QUEUED
== after the hold
  customer   balance  100000 inflight  -50000 inflight debit   50000
  escrow     balance       0 inflight   50000 inflight debit       0
  freelancer balance       0 inflight       0 inflight debit       0
  fee        balance       0 inflight       0 inflight debit       0
T3 second order 800.00 while 500 is held       201 QUEUED -> still QUEUED
T4 commit the hold                             200 QUEUED
T5 split 90% / 10% from escrow                 201 QUEUED -> still QUEUED
T6 the same deposit again (reference dep-1)    201 QUEUED -> still QUEUED
== final balances (halalas)
  customer   balance   50000 inflight       0 inflight debit       0
  escrow     balance       0 inflight       0 inflight debit       0
  freelancer balance   45000 inflight       0 inflight debit       0
  fee        balance    5000 inflight       0 inflight debit       0

كل طلب عاد بـ 201 أو 200 وحالة QUEUED، حتى الطلب الذي يتجاوز الرصيد والطلب المكرر، فالرد لا يقول هل نجحت المعاملة. والعبارة still QUEUED من السكربت نفسه، فقد كان يسأل عن رقم المعاملة الذي عاد في الرد عشر ثوان، والحالة بقيت كما هي، وسوف نرى السبب بعد قليل. لذلك قرأنا قائمة المعاملات بعد أن عمل العامل، فوجدنا أن كل معاملة سجلت مرتين: الأصل بحالة QUEUED، ونسخة بنفس المرجع ولاحقة _q بالحالة الحقيقية، وكانت حالة T3 كما يلي:

order-2_q REJECTED 80000 txn_6861b368-b974-4790-80e6-fab3446f02a8 {"QUEUED_PARENT_TRANSACTION":"txn_6861b368-b974-4790-80e6-fab3446f02a8","blnk_rejection_reason":"failed to apply transac
order-2 QUEUED 80000  {"QUEUED_PARENT_TRANSACTION":"txn_6861b368-b974-4790-80e6-fab3446f02a8","queue_lane":"normal","queue_pair_key":"bln_540a
failed to apply transaction to balances: insufficient funds in source balance

أي أن الرفض صحيح، ولكنه يصل لاحقاً وفي سجل آخر. ثم جربنا التكرار وحده بمرجع جديد، لنعرف ماذا يحدث للطلب الثاني بالضبط:

first  201 QUEUED txn_932b332a-cb28-4d30-bf89-062458cfcbc8
second 201 QUEUED txn_1e222698-d3b4-486f-a654-46aad0ab81ec
stored dup-check-1_q APPLIED txn_ba585105-be7b-4d56-8783-98b4eb7af28c
stored dup-check-1 QUEUED txn_932b332a-cb28-4d30-bf89-062458cfcbc8
GET second id -> {"error":"Transaction with ID 'txn_1e222698-d3b4-486f-a654-46aad0ab81ec' not found","error_detail":{"code":"TXN_NOT_FOUN

الطلب الثاني أخذ رداً بنجاح ورقم معاملة جديداً، ثم لم يسجل شيء، وطلب المعاملة بهذا الرقم يعيد «غير موجودة»، وفي سجل العامل رسالة بأن المرجع مستخدم. فالمال لم يتضاعف، وهذا هو المهم، ولكن التطبيق الذي يحفظ رقم المعاملة من الرد ثم يسأل عنه لاحقاً سوف يجد رقماً لا وجود له. والدرس في Blnk أن مصدر الحقيقة حالة المعاملة بعد الطابور، فإما أن تسأل عنها بالمرجع، أو أن تستقبل الإشعارات Webhooks التي يرسلها، لا أن تعتمد على رد الإنشاء.

جدول ردود الأدوات الثلاث: الطلب الثاني 800 والمتاح 500 في TigerBeetle exceeds_credits وفي Formance INSUFFICIENT_FUND 400 وفي Blnk 201 QUEUED ثم REJECTED، وتقسيم 450 + 90 من 500 في TigerBeetle linked_event_failed لا شيء يسجل وفي Formance INSUFFICIENT_FUND لا شيء يسجل وفي Blnk لم نجربه بهذه الصيغة، ونفس المعرف أو المرجع مرة ثانية في TigerBeetle exists وفي Formance CONFLICT 409 وفي Blnk 201 QUEUED ثم لا يسجل شيء، ونفس المعرف بمبلغ مختلف في TigerBeetle exists_with_different_amount وفي Formance مع Idempotency-Key 400 VALIDATION، والحجز ثم الإلغاء أو الانتهاء في TigerBeetle pending بمهلة انتهى بعد 2 ث وفي Formance لا حجز أصيل حساب escrow وفي Blnk inflight ثم commit أو void
الثلاث تحمي الرصيد، والفرق في متى وكيف يعرف تطبيقك النتيجة

ما واجهناه أثناء التشغيل

لم تمر التجربة دون مشكلات، وكل منها يخبرك بشيء عن الأداة قبل أن تواجهه في الإنتاج:

  • مكتبة TigerBeetle تغيرت: كتبنا السكربت أول مرة بأسماء الأخطاء التي في أمثلة قديمة على الإنترنت، فرفض Node.js تحميله لأن الإصدار 0.17.9 من المكتبة لا يصدر CreateTransferError، وإنما CreateTransferStatus، والنتيجة لكل تحويل حالة واحدة، والنجاح فيها created. لذلك ثبت رقم إصدار المكتبة مع رقم إصدار الخادم، واقرأ صفحة تغييرات الواجهة في التوثيق عند كل ترقية.
  • حد الدفعة في TigerBeetle: دفعة من ألف تحويل رفضت بـ ERR_TOO_MUCH_DATA، فنزلنا حتى قبلت دفعة من 250 كما ذكرنا في قسم السرعة.
  • الوصف إلزامي في Blnk: كل معاملات السيناريو رفضت أول مرة لأن الحقل description فارغ، والرفض هنا جاء فوراً بالرمز 400 لأنه تحقق من صيغة الطلب قبل الطابور، بعكس رفض الرصيد الذي يأتي بعده.
  • حد الطلب المجمع في Formance: طلب Bulk من ألف معاملة رفض بالرمز 413 والرسالة bulk size exceeded, max size is 100، فصارت الدفعة مئة معاملة.
  • مرشح لم يعمل كما ظننا: في Formance طلبنا المعاملة بمرجعها بإضافة reference إلى رابط القائمة، فعادت آخر معاملة لا المعاملة المطلوبة، وعكسنا بها الإيداع الإضافي بالخطأ في أول تشغيل. فأعدنا السيناريو في دفتر جديد، وصرنا نعكس المعاملة برقمها مباشرة. والدرس أن عملية العكس في دفتر حقيقي لا تبنى على بحث لم تتأكد من نتيجته.

السرعة: ماذا قسنا، ولماذا يجب أن تقرأ بحذر؟

سؤال مهندس البنية في القصة كان عن الحساب الساخن، فقسنا أبسط حالة له: آلاف التحويلات إلى نفس الحساب، مرة بطلب لكل تحويل، ومرة بأكبر دفعة يقبلها كل أداة في إعدادنا. والنتائج كما يلي:

الأداةطريقة الإرسالالنتيجة في بيئتنا
TigerBeetle10,000 تحويل بدفعات من 250287 ms، أي 34,899 في الثانية
TigerBeetle500 تحويل، طلب لكل تحويل2,221 ms، أي 4.44 ms لكل تحويل
Formance100 معاملة، طلب لكل معاملة14,582 ms، أي 145.82 ms لكل معاملة
Formance10,000 معاملة بطلبات Bulk من 100775,336 ms، أي 13 في الثانية
Blnk100 معاملة، طلب لكل معاملةالقبول في 1,594 ms، أي 15.94 ms لكل طلب، والرصيد كامل بعد 3,160 ms
Blnk10,000 معاملة بطلبات Bulk من 1000الرد في 1,611 ms، والرصيد 5,121 بعد 5 دقائق، والعشرة آلاف كلها بعد نحو 9.5 دقيقة
نتائج السرعة في بيئتنا: TigerBeetle بدفعات من 250 بمعدل 34,899 في الثانية وطلب لكل تحويل 4.44 ms، وFormance طلب لكل معاملة 145.82 ms وBulk من 100 بمعدل 13 في الثانية، وBlnk طلب لكل معاملة 15.94 ms للقبول وBulk من 1000 بنحو 17 في الثانية حتى التطبيق، مع ملاحظة أن البيئة Docker Desktop على Windows وتشغيل واحد وTigerBeetle بالوضع --development
الفرق بين الأدوات أكبر من أي خطأ في القياس، ولكن الأرقام المطلقة من بيئتنا وحدها

وقبل أن تبني قراراً على هذه الأرقام، هذه حدودها:

  • البيئة: Docker Desktop على Windows، وعلى الجهاز أحمال أخرى، والقرص يمر بطبقة WSL، وكل رقم من تشغيل واحد. ولكي نعرف هل البطء في القرص، قسنا في نفس Container الخاص بـ PostgreSQL مئة عملية إدخال بالتزام Commit مستقل لكل منها، فأخذت 0.41 ثانية، أي نحو 4 ملي ثانية لكل التزام، وهذا أقل كثيراً من 145 ملي ثانية لكل معاملة في Formance، فالفرق ليس في الكتابة على القرص وحدها، وإنما في العمل الذي تقوم به الخدمة لكل معاملة.
  • الحساب الساخن: كل التحويلات إلى نفس الحساب، وهذه أسوأ حالة لأي دفتر يقفل الرصيد، وأفضل حالة لـ TigerBeetle الذي يعالج الدفعة دفعة واحدة. ولو وزعنا التحويلات على آلاف الحسابات لاختلفت الأرقام.
  • Blnk تحت الضغط: الدفعات الكبيرة أخذت ردها في ثانية ونصف، ثم احتاج العامل نحو تسع دقائق ونصف حتى طبق العشرة آلاف كلها، وفي سجله آلاف التحذيرات بأن تجميع المعاملات Coalescing فشل فرجع إلى معالجة كل معاملة وحدها، وبعض إعادات المحاولة بسبب انتظار الأقفال. وكل المعاملات طبقت في النهاية، ولكن الفرق بين لحظة الرد ولحظة التطبيق هو بالضبط ما يجب أن يعرفه من يعرض الرصيد للمستخدم. ولم نضبط أي إعداد للعامل، فقد يتغير هذا مع عدد عمال أكبر أو إعداد مختلف.
  • TigerBeetle والدفعات: دفعة من 250 تحويلاً نجحت، ودفعات من 254 وما فوق رفضت في إعدادنا بالخطأ ERR_TOO_MUCH_DATA، مع أن مكتبة العميل نفسها تعرف حداً أكبر بكثير في اختباراتها، والأرجح أنه من حجم الرسائل في الوضع --development، ولم نبحث فيه أكثر، فاعرف الحد في إعدادك قبل أن تصمم الدفعات.

والخلاصة التي كتبها الفريق من هذه الأرقام ليست «TigerBeetle أسرع بألف مرة»، وإنما أن الحساب الساخن مشكلة تصميم قبل أن يكون مشكلة سرعة: الأداة التي تجمع المعاملات في دفعات تتجاوزه، والأدوات فوق PostgreSQL تحتاج تصميماً يقلل التنافس عليه، مثل تقسيم حساب العمولة إلى عدة حسابات فرعية تجمع في التقارير.

الحجز في كل أداة

الحجز Hold هو سبب المشروع في القصة، والأدوات الثلاث تتعامل معه بثلاث طرق كما في الصورة التالية:

ثلاث بطاقات: TigerBeetle تحويل معلق بـ flags.pending وtimeout، المبلغ في debits_pending للعميل ولا يحسب في المتاح، ثم post_pending_transfer أو void_pending_transfer أو انتهاء المهلة، وبعد المهلة pending_transfer_expired. وFormance حساب ضمان customers:c1 إلى escrow:order-1، الحجز معاملة عادية والإفراج معاملة ثانية، والإلغاء معاملة عكسية Revert، ولا مهلة تلقائية، وRevert بعد الإفراج INSUFFICIENT_FUND. وBlnk معاملة Inflight بـ inflight: true، وinflight_balance سالب عند العميل وموجب عند الضمان، ثم PUT /transactions/inflight/{id} بالحالة commit أو void، ويطبق في الطابور مثل أي معاملة
الحجز الأصيل يحمي من النسيان، وحساب الضمان يجعل كل خطوة معاملة ظاهرة

وليست طريقة أفضل من الأخرى في كل حالة. فالحجز الأصيل بمهلة، كما في TigerBeetle، يناسب حجوزات البطاقات والدفع على مراحل حيث يجب أن يعود المال وحده، وقد شرحنا التفويض والتحصيل في البطاقات في مقال الدفع بالبطاقة. أما حساب الضمان في Formance فيجعل كل خطوة معاملة مستقلة تظهر في الدفتر وفي كشف الحساب، وهذا ما تحتاجه المحاسبة عندما يبقى المال محجوزاً أسابيع، لا دقائق. وفي Blnk الحجز أصيل، ولكن بدون مهلة في التجربة التي قمنا بها، وتطبيقه وإلغاؤه يمران بالطابور مثل أي معاملة، فتنطبق عليه نفس ملاحظة الرد المؤجل.

ما يحتاجه تشغيل كل أداة في الإنتاج

  • TigerBeetle: عنقود من عدة سيرفرات، والموصى به ست نسخ، ولكل نسخة قرص وذاكرة محجوزان مسبقاً كما رأينا، ومراقبة وترقية بإجراءات خاصة به في توثيق التشغيل. والأهم أنه لا يحفظ إلا الحسابات والتحويلات وحقولاً رقمية قليلة لبياناتك، فاسم العميل ووصف الطلب وكل ما سواهما يبقى في قاعدة بياناتك العادية، وتربط بينهما بالمعرفات. أي أنك تشغل قاعدتي بيانات بدل واحدة، وتصمم الربط بينهما بعناية.
  • Formance Ledger: خدمة بلا حالة فوق PostgreSQL، فتشغيلها مثل أي خدمة ويب، والتوافرية والنسخ الاحتياطي هي توافرية PostgreSQL ونسخه. والسؤال المفتوح هو خط v3 الذي ذكرناه، فخطط للترقية قبل أن تبدأ.
  • Blnk: خادم وعامل وPostgreSQL وRedis، وكلها يجب أن تعمل لكي تطبق المعاملات، فإذا توقف العامل بقيت المعاملات مقبولة في الطابور دون أن تطبق، ولذلك راقب طول الطابور كما تراقب الخادم نفسه. وفعل المصادقة قبل أي شيء آخر، لأن الإعداد الافتراضي يحذر من أنها معطلة.

واستهلاك الذاكرة بعد التجربة كان: 1.45 جيجابايت لـ TigerBeetle المحجوزة من البداية، و25 ميجابايت لخدمة Formance و128 لقاعدتها، و103 لخادم Blnk و72 للعامل و122 لقاعدته و14 لـ Redis.

كيف يتحدث تطبيقك مع الدفتر؟

الدفتر في كل الأدوات الثلاث خدمة منفصلة عن قاعدة بيانات التطبيق، وهذا يطرح سؤالاً لم يكن موجوداً عندما كان الدفتر جداول في نفس PostgreSQL: الطلب في قاعدة التطبيق والمال في الدفتر، فماذا لو نجح أحدهما وفشل الآخر؟ والطريقة التي اعتمدها الفريق في تجربته ثلاث خطوات ثابتة:

  1. معرف التحويل من الطلب نفسه: قبل استدعاء الدفتر يحسب التطبيق معرف التحويل من رقم الطلب ونوع الحركة، فإذا انقطعت الشبكة بعد الإرسال وأعاد المحاولة، أرسل نفس المعرف، ورأينا في المختبر أن TigerBeetle يرد بـ exists وFormance يعيد نفس المعاملة بنفس Idempotency-Key. أي أن إعادة المحاولة آمنة ما دام المعرف ثابتاً.
  2. حالة الطلب تتبع الدفتر لا العكس: الطلب في قاعدة التطبيق يبدأ بحالة «بانتظار الدفتر»، ولا يصبح «محجوزاً» إلا بعد أن يؤكد الدفتر الحجز، وفي Blnk يعني ذلك انتظار الحالة بعد الطابور أو الإشعار، لا رد الإنشاء.
  3. مهمة تصالح بين الاثنين: كل بضع دقائق تبحث المهمة عن طلبات بقيت «بانتظار الدفتر» أكثر من مدة معقولة، وتسأل الدفتر بالمعرف: إن وجدت الحركة حدثت الطلب، وإن لم تجدها أعادت إرسالها بنفس المعرف. وهذا هو نفس منطق المطابقة الذي شرحناه في دراسة الحالة، ولكن بين خدمتين من خدماتك بدل خدمتك ومزود الدفع.

وأما عرض الرصيد للمستخدم فيقرأ من الدفتر مباشرة في TigerBeetle وFormance، أما في Blnk فالرصيد الذي تقرؤه هو ما طبقه العامل حتى الآن، والمعاملات التي في الطابور لم تظهر بعد، ورأينا في القياس أن الفرق قد يكون دقائق تحت ضغط على حساب واحد. لذلك تعرض الواجهة في هذه الحالة الرصيد مع المعاملات المعلقة باسمها، بدل رقم يتغير فجأة بعد دقائق.

ماذا تعني اللوائح لاختيار الدفتر؟

المنصة في القصة تمسك أموال العملاء حتى التسليم، وهذا يقربها من خدمات الدفع التي ينظمها في المملكة نظام المدفوعات وخدماتها، وقد لخصنا في دراسة الحالة متطلبات من لائحته التنفيذية تمس التصميم مباشرة، منها حماية أموال العملاء في حساب منفصل تطابق يومياً، والاحتفاظ بسجلات العمليات والقيود عشر سنوات على الأقل دون تعديل. وهل تحتاج منصتك ترخيصاً سؤال لمستشار قانوني لا لهذا المقال، ولكن المتطلبات نفسها تعطيك ثلاثة أسئلة تسألها لأي أداة دفتر:

  • هل يمكن تعديل حركة بعد تسجيلها؟ في TigerBeetle التحويل لا يعدل ولا يحذف، والتصحيح تحويل جديد، وفي Formance رأينا أن العكس معاملة جديدة والأصل يبقى بعلامة أنه عكس، وفي Blnk كل خطوة في الطابور سجل مستقل كما رأينا في السجلين لكل معاملة. وهذا يقترب في الأدوات الثلاث من وصف اللائحة لسجل لا يقبل التعديل.
  • هل تستطيع المطابقة اليومية مع البنك؟ هذا يحتاج أن تخرج من الدفتر كل حركات اليوم على حساب البنك بسهولة، وأن تطابقها مع كشف البنك، وهو موضوع مقال المطابقة في هذه المرحلة.
  • أين تحفظ البيانات، وكم تبقى؟ الأدوات الثلاث مفتوحة المصدر وتعمل على سيرفرك داخل المملكة إن شئت، أما مدة الاحتفاظ فهي سياسة النسخ الاحتياطي والأرشفة عندك، لا ميزة في الأداة.

القرار في القصة

بعد المختبر عاد الفريق إلى أسئلته الثلاثة. فالحجز الذي ينتهي وحده موجود أصيلاً في TigerBeetle، والحساب الساخن يتجاوزه بالدفعات، ولكن الانتقال إليه يعني قاعدة بيانات ثانية بإجراءات تشغيل لا يعرفها أحد في الفريق، وإعادة تصميم كل ما يقرأ الأرصدة. وFormance أقرب لما بناه الفريق، فوق PostgreSQL نفسه، وNumscript يجعل قواعد العمولة نصاً يقرؤه مدير المنتج، ولكن خط v3 يجعل الاختيار اليوم رهاناً على ترقية لا يعرف شكلها. وBlnk أقرب للغة المنتج وفيه الحجز والمطابقة، ولكن الرد المؤجل يغير طريقة عمل كل شاشة تعرض الرصيد.

فاتخذ الفريق قراراً من خطوتين: يبقى على دفتره الحالي، ويضيف إليه الحجز بمهلة وحساب عمولة مقسم إلى عدة حسابات فرعية لتقليل التنافس، ويكتب طبقة واحدة في الكود تمر بها كل حركة مال، بحيث يصبح الانتقال إلى أداة مخصصة لاحقاً تغيير طبقة لا تغيير منتج. وحدد الرقم الذي يعيد عنده القرار: عندما يتجاوز زمن المعاملة على الحساب الساخن حداً يكتبه في المراقبة، يجرب TigerBeetle على نسخة من حركة يوم حقيقي. وهذا ليس حكماً على الأدوات، وإنما على توقيتها في شركة بحجمه.

وإذا قرر الانتقال يوماً؟

كتب الفريق أيضاً خطة الانتقال حتى لا يرتجلها لاحقاً، والفكرة أن الدفتر الجديد لا يصبح المرجع في يوم واحد. ففي المرحلة الأولى تكتب الطبقة الواحدة في الكود كل حركة في الدفترين معاً، والقديم هو المرجع، ومهمة ليلية تقارن رصيد كل حساب في الاثنين وتنبه عند أي فرق، وهذا ما يسمى التشغيل الظلي Shadow Mode. وعندما تمر أسابيع بلا فرق، تنقل أرصدة الافتتاح بقيد واحد لكل حساب في تاريخ محدد، ويصبح الجديد هو المرجع للقراءة، ويبقى القديم للكتابة فترة أخرى قبل إيقافه. والسبب في كل هذا البطء أن خطأ الدفتر لا يظهر كتعطل، وإنما كرقم مختلف يكتشفه محاسب بعد شهر، وتصحيحه بعد شهر أصعب كثيراً من اكتشافه في ليلة.

جدول يساعدك في القرار

إذا كان عندكفالأقربوالسبب
حجم معاملات كبير على حسابات قليلة ساخنة، وفريق يدير قواعد بياناتTigerBeetleالدفعات تتجاوز التنافس على الحساب، والقواعد تفرضها قاعدة البيانات
فريق يعرف PostgreSQL ويريد رداً فورياً وقواعد مال مقروءةFormance Ledgerمعاملة متزامنة، وNumscript للتقسيم والنسب، مع متابعة خط v3
منتج محافظ أو إقراض يريد مفاهيم جاهزة وحجزاً ومطابقةBlnkأرصدة ومعاملات معلقة جاهزة، بشرط بناء التطبيق على الحالة بعد الطابور
حجم صغير ودفتر يعمل على PostgreSQLدفترك الحاليكما في دراسة الحالة، مع طبقة واحدة لحركة المال تسهل الانتقال لاحقاً

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

أخطاء شائعة عند تبني أداة للدفتر

  • الوثوق برد الإنشاء: في الأدوات التي تعمل بطابور، الرد بنجاح يعني أن الطلب استقبل لا أنه طبق، ورأينا في Blnk رقم معاملة لا وجود له بعد الطابور.
  • معرف عشوائي عند كل محاولة: إذا ولد التطبيق معرفاً جديداً في كل إعادة محاولة، فقد ألغى منع التكرار الذي تقدمه الأداة، والمعرف يجب أن يأتي من الطلب نفسه.
  • الأرقام العشرية: إرسال 499.99 كرقم عشري بدل 49999 هللة، وكل الأدوات الثلاث تعمل بأعداد صحيحة أو بدقة تحددها أنت، فالتحويل مرة واحدة في الطبقة الواحدة.
  • حساب واحد لكل شيء: حساب عمولة واحد يمر عليه كل شيء يصبح الحساب الساخن الذي رأيناه في القياس، والتقسيم إلى حسابات فرعية قرار في التصميم من البداية.
  • نسيان ما خارج الدفتر: الدفتر يعرف المبالغ، ولا يعرف لماذا ردت الشركة مبلغاً أو من وافق على الاسترداد، وهذه البيانات في نظامك مربوطة بمعرف الحركة.
  • اختيار الأداة قبل الدليل: الأداة لا تختار عنك أنواع الحسابات ولا قواعدها، وأي أداة سوف تنفذ دليل حسابات سيئاً بدقة تامة.

قائمة تحقق قبل أن تختار أداة للدفتر

البندكيف تتحقق منه
الطلب المرفوض يرفض قبل أن تعرض النتيجة للمستخدمرد متزامن، أو انتظار الحالة النهائية بالمرجع
التقسيم بين عدة حسابات ذريتقسيم يتجاوز الرصيد لا يسجل منه شيء
الطلب المكرر لا يضاعف المالنفس الطلب مرتين، ونفس المعرف بمبلغ مختلف
الحجز يعود إذا لم يكتملمهلة أصيلة، أو مهمة مجدولة مراقبة
الحساب الساخن له تصميمدفعات، أو حسابات فرعية للعمولة
ترخيص الإصدار الذي تثبته بالضبطملف الترخيص في وسم الإصدار، لا في الفرع الرئيسي
المبالغ بأصغر وحدة للعملةالهللة، والعملة في حقل لا يسمح بالخلط
طبقة واحدة في الكود لكل حركة ماللا يستدعي الدفتر من أكثر من مكان

الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

  • TigerBeetle قاعدة بيانات للقيود فقط بحقول ثابتة ودفعات، وFormance خدمة فوق PostgreSQL بلغة Numscript، وBlnk خادم وعامل فوق PostgreSQL وRedis بطابور.
  • الثلاث رفضت السحب الأكبر من الرصيد والتقسيم الذي يتجاوزه، ولم تضاعف المال عند التكرار، والفرق أن Blnk يرد بـ QUEUED دائماً ويعطي النتيجة لاحقاً، حتى للطلب المكرر الذي أخذ رقماً لا وجود له.
  • الحجز بمهلة في TigerBeetle عاد وحده بعد ثانيتين، ورفض ترحيله المتأخر بـ pending_transfer_expired، وFormance يعتمد حساب ضمان، وBlnk معاملات Inflight.
  • في بيئتنا مرت 10,000 تحويلة في TigerBeetle بدفعات من 250 بأقل من ثلث ثانية، مقابل دقائق في الأداتين الأخريين على حساب ساخن، والأرقام من Docker Desktop وتشغيل واحد.
  • الفرع الرئيسي في Formance يتحول إلى v3 بمعمار مختلف وبلا ملف ترخيص حتى 27 أكتوبر 2026، فاعتمد على ترخيص الإصدار الذي تثبته.
  • القرار يعتمد على حجمك وفريقك، وطبقة واحدة لحركة المال في كودك تجعل الانتقال لاحقاً ممكناً.

والمقالات المرتبطة:

دليلالمحفظة الرقمية من الداخل: دفتر القيد المزدوج Ledgerكيف تبني محفظة رقمية لا يضيع فيها ريال ولا يخصم مرتين، بالقيد المزدوج والمعاملات والمطابقة مع مزود الدفع.دليلكيف يعمل الدفع بالبطاقة؟ التفويض Authorization والتسويةمن ضغطة «ادفع» إلى آخر اعتراض: التفويض و3D Secure والتحصيل والتسوية والاسترداد والاعتراض، مع مختبر لأحداث مزود الدفع ومطابقتها.دليلكيف تنتقل الأموال بين البنوك؟ المقاصة Clearing والتسويةماذا يحدث بين ضغطة «ادفع» ووصول المال: الدفع والمقاصة والتسوية الإجمالية والصافية، مع محاكاة يوم من التسويات بين ستة بنوك.دليلمنظومة المدفوعات في السعودية: سريع وSARIE ومدى وسدادالأنظمة الوطنية للمدفوعات في المملكة كما تصفها مصادر البنك المركزي، وما يعنيه كل نظام للمطور، مع تحليل تقارير نقاط البيع الأسبوعية.

سجل التحديثات

  • أكتوبر 2026: كتابة المقال وتشغيل TigerBeetle 0.17.9 مع tigerbeetle-node 0.17.9، وFormance Ledger v2.4.16 وBlnk 0.15.7 مع postgres:18-alpine وredis:7-alpine، على Docker Engine 29.8.0، والإصدارات والتراخيص كما ظهرت في 27 أكتوبر 2026.
Could not load content
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.