بناء تنفيذ موثوق لأوامر العملات الرقمية
موثوقية تنفيذ أوامر الكريبتو تأتي من آلات الحالة ومفاتيح عدم التكرار والتسوية المستمرة. دروس من بناء MadaiOps.
بقلم Vitor Lima
معظم منتجات تداول العملات الرقمية تبيع لك السرعة ومخططاً بيانياً. لكن الجزء الصعب ليس رسم الشموع اليابانية، بل القدرة على الإجابة، في أي جزء من الألف من الثانية، عن سؤال يبدو بسيطاً بشكل خادع: ما هي الحالة الحقيقية لأمري الآن؟ حين بدأنا ببناء MadaiOps، تطبيقنا الداخلي للأوامر وعمليات التداول، كان هذا السؤال هو ما ينظّم كل شيء. السرعة ميزة، أما الحقيقة فهي المنتج نفسه. موثوقية تنفيذ أوامر الكريبتو هي ما تملكه حين يستطيع التطبيق أن يجيب عن هذا السؤال بصدق حتى وهو في خضم تدهور أداء المنصّة، وأي تطبيق يُظهر تنفيذاً لم يحدث — أو يُخفي تنفيذاً حدث فعلاً — يدمّر الثقة بطريقة لا يمكن لأي مكسب في زمن الاستجابة أن يعوّضها.
هذه التدوينة تتحدث عن الهندسة التي تجعل تطبيق الأوامر جديراً بالثقة: آلة الحالة، وعدم التكرار (idempotency)، والتسوية عبر WebSockets، وأنماط الأعطال التي لا تظهر إلا تحت الضغط. لدينا رأي حاسم في كل ذلك، لأن الخيارات المملّة هي بالضبط ما يبقيك قادراً على الوفاء بالتزاماتك.
اجعل الأمر آلة حالة صريحة
أفضل قرار اتخذناه على الإطلاق كان أن نتوقف عن معاملة الأمر كصفٍّ في قاعدة بيانات نُجري عليه UPDATE، وأن نبدأ بمعاملته كآلة حالة منتهية (finite state machine) ذات انتقالات مُسمّاة ومُعدّدة. الأمر يعيش في حالات مثل PENDING_NEW وWORKING وPARTIALLY_FILLED وFILLED وCANCEL_REQUESTED وCANCELED وREJECTED وEXPIRED. كل انتقال هو دالة للحالة الحالية إضافةً إلى حدث وارد، والانتقالات غير المشروعة تُرفض بصوتٍ عالٍ بدل أن تُبتلع بصمت.
المردود: يصبح الغموض شأناً يُعالَج وقت التحقق بدل أن يكون حادثاً في الثانية صباحاً. الأمر الذي حالته FILLED لا يمكن أن يعود إلى WORKING أبداً. وحدث تنفيذ يصل لأمرٍ حالته CANCELED ليس "مستحيلاً" — إنه سباق حقيقي، وآلة الحالة تجبرك على أن تقرر ماذا يعني. عادةً يعني أن الإلغاء خسر السباق، وأن الأمر نُفِّذ فعلاً، وأن حالة CANCELED المحلية عندك كانت خاطئة. لو صمّمت الأوامر كصفوف قابلة للتعديل، فإن ذلك الحدث ببساطة يكتب فوق شيء ما؛ ولن تلاحظ أبداً حتى تكتشفه التسوية — أو أحد المستخدمين.
الخلاصة
عدّد حالاتك قبل أن تكتب استدعاءً واحداً لواجهة برمجية. إن لم تستطع رسم المخطط على السبورة، فأنت لم تفهم مجالك بعد، وستعلّمك المنصّة الدرس بالطريقة القاسية. خزّن الانتقالات، لا الحالة الحالية فقط. سجلّ أحداث تراكمي فقط (append-only) لما حدث يتفوّق على عمود حالة واحد قابل للتعديل، لأن السجل قابل لإعادة التشغيل بينما العمود مجرد تخمين.
عدم التكرار ليس خياراً
الشبكات تعيد المحاولة. ومنطق إعادة المحاولة عندك يعيد المحاولة. والمستخدم ينقر مرتين على "شراء". أيٌّ من هذه قد يرسل الأمر ذاته مرتين، وعلى المنصّة فإن الأمر المكرّر مالٌ حقيقي. الدفاع هو مفتاح عدم تكرار (idempotency key) يولّده العميل — معظم المنصّات تسمّيه clientOrderId أو newClientOrderId — يُرفَق بكل أمرٍ لحظة تأكيد المستخدم له، قبل أن يغادر الطلب الجهاز أو الخادم أصلاً.
القاعدة التي نفرضها: يُولَّد المفتاح مرة واحدة، ويُحفَظ محلياً قبل الاستدعاء الشبكي، ويُعاد استخدامه حرفياً في كل إعادة محاولة. إن انتهت المهلة في المحاولة الأولى دون أي رد، فإن إعادة المحاولة تحمل المفتاح ذاته، فتقوم المنصّة إما بإنشاء الأمر مرة واحدة أو بإرجاع الأمر الموجود. في كلتا الحالتين تتقارب على أمرٍ واحد. أما إن ولّدت المفتاح بعد الفشل فقد بنيت آلةً لإنتاج الأوامر المكرّرة.
هذا ينسحب على الإلغاءات والتعديلات أيضاً. طلب الإلغاء يجب أن يكون عديم التكرار كذلك: إلغاء أمرٍ مُلغى مسبقاً هو عملية لا أثر لها تُرجع نجاحاً، لا خطأً يُطلق حلقة إعادة محاولة أخرى. نعامل "الحالة النهائية المرغوبة صحيحة أصلاً" على أنها نجاح في كل مكان. يبدو الأمر تافهاً، لكنه يقضي على فئة كاملة من عواصف إعادة المحاولة.
الخلاصة
احفظ مفتاح عدم التكرار قبل أن تتصرف، لا بعد أن تنجح. الفجوة بين "لقد أرسلته" و"أعرف أنه نجح" هي بالضبط حيث يختفي المال، والمفتاح هو الشيء الوحيد الذي يُغلقها.
سوِّ باستمرار: الـ WebSocket تلميح لا حقيقة
المنصّات تدفع تحديثات الأوامر عبر WebSockets، وهي رائعة حين تعمل: تنفيذات في أقل من ثانية، وكميات تنفيذ جزئي حيّة، وتأكيدات إلغاء. لكن الـ WebSocket تدفّق قد يفوتك. ستنقطع عنك، والرسائل تصل بترتيب مغلوط، أو تُسقَط أثناء إعادة الاتصال، أو ببساطة لا تأتي أبداً لأن المنصّة متدهورة الأداء. إن كانت حالة أمرك فقط هي ما أخبرتك به الشبكة آخر مرة، فإن تطبيقك يكون مخطئاً بثقة في اللحظة التي تتعثّر فيها الشبكة.
لذلك نُشغّل حلقتين. الحلقة السريعة تستهلك الـ WebSocket وتطبّق الأحداث تفاؤلياً. والحلقة البطيئة تستقصي واجهة REST — GET /order ونقاط الأوامر المفتوحة — على فترات وبعد كل إعادة اتصال، ثم تُسوّي تلك اللقطة مقابل الحالة المحلية. REST هي مصدر الحقيقة؛ والشبكة تلميح منخفض زمن الاستجابة. حين يختلفان، تفوز REST ونسجّل التباين، لأن التباين تقرير عن خللٍ كتبه لك النظام بنفسه.
الترتيب مهم. معظم المنصّات تختم التحديثات برقم تسلسلي متزايد (monotonic sequence number) أو بحقل updateTime. نُسقِط أي حدثٍ أقدم من آخر حدثٍ طُبِّق لذلك الأمر، ما يجعل الوصول غير المرتّب غير مؤذٍ. عند إعادة الاتصال، الخطوة الصحيحة هي: أعِد الاشتراك، واسحب فوراً لقطة REST كاملة لسدّ الفجوة، ثم ثِق في التدفّق الحيّ مجدداً. تجاوز اللقطة يفوّت عليك التنفيذ الذي حدث خلال الثانيتين اللتين كنت فيهما خارج الاتصال.
الخلاصة
لا تدع تغذية آنية (real-time) تكون الكاتب الوحيد لحالتك. الشبكة تجعلك سريعاً؛ وحلقة التسوية تجعلك صحيحاً. تحتاج كليهما، وحين يتعارضان، الصحيح يهزم السريع في كل مرة.
التنفيذات الجزئية والانقطاعات: الوجهان اللذان يكشفانك
التنفيذات الجزئية هي حيث تكذب التطبيقات الساذجة بهدوء. أمرٌ بـ 1.0 BTC يُنفَّذ منه 0.3 ثم 0.4 ثم يتوقف ليس "مفتوحاً" وليس "منتهياً". إنه PARTIALLY_FILLED بـ 0.7 مُنفَّذة و0.3 متبقية، وكل رقمٍ من هذه يجب أن يُتتبَّع من أحداث التنفيذ الفردية، لا أن يُستنتَج من حقل حالة واحد. نراكِم الكمية المُنفَّذة والسعر الوسطي المرجّح بالحجم من التنفيذات المنفصلة، بحيث يظل المركز قابلاً لإعادة البناء انطلاقاً من الأساسيات. المستخدم الذي يرى "مُنفَّذ" يحتاج أن يعرف كم نُفِّذ وبأي سعر وسطي. أي شيء أقل من ذلك نصف حقيقة.
الانقطاعات تضاعف هذا. إن انقطعت في منتصف التنفيذ فإن الـ WebSocket لم يخبرك أبداً بالـ 0.4، فتصبح حالتك المحلية قديمة. وهذا بالضبط سبب وجود انضباط "إعادة الاتصال ثم اللقطة": لقطة REST تُرجع الكمية المُنفَّذة التراكمية، وحلقة التسوية تسدّ الفجوة دون أن يرى المستخدم رقماً خاطئاً قط. نمط العطل الذي نصمّم للوقاية منه ليس "التطبيق انهار" — الانهيارات صادقة. بل هو "التطبيق استمر بالعمل وأظهر شيئاً كاذباً".
الخلاصة
تتبّع الكمية المُنفَّذة التراكمية كحقيقة أساسية، واشتقّ كل شيء آخر منها. لا تُخزّن "المتبقي" أبداً كحقلٍ مستقل تعدّله. اشتقّه (ordered − executed) كي لا ينحرف عن الواقع.
الصدق تحت الضغط يهزم الذكاء
هناك إغراء دائم بأن تكون ذكياً: أن تتنبّأ بالتنفيذات قبل التأكيد، أن تُخفي أخطاءً "عابرة"، أن تُملّس مرور إعادة الاتصال كي لا ترتعش الواجهة أبداً. تعلّمنا أن نقاوم معظم ذلك. مشغّل التداول لا يريد كذبةً ناعمة؛ بل يريد أن يعرف، بصدق، أن الاتصال انقطع للتو وأن الحالة قيد التسوية. حالة مرئية تقول "جارٍ إعادة الاتصال، والتحقق من الأوامر" تساوي أكثر من واجهة سلسة يتبيّن أنها تُظهر حقيقة الأمس.
بشكلٍ ملموس، نُظهِر عدم اليقين بدل أن نتستّر عليه. حين لا تكون الحالة المحلية والمنصّة قد تسوّتا مؤخراً، يقول التطبيق ذلك. وحين يكون الأمر CANCEL_REQUESTED ولم يُؤكَّد إلغاؤه بعد، يُظهِر ذلك بالضبط، لا CANCELED سابقة لأوانها. الواجهة التفاؤلية جيدة لتطبيق قائمة مهام. أما مع المال، فالتفاؤل عبءٌ تدفع ثمنه في تذاكر الدعم والثقة المفقودة.
الخلاصة
عند الشك، أظهِر الشك. أكثر ما يبعث الطمأنينة في تطبيق مالي تحت الضغط هو أن يرفض التخمين.
خاطرة ختامية
لا شيء من هذا برّاق. لا يوجد عرضٌ تجريبي يُصفَّق فيه لعبارة "مفاتيح عدم التكرار عندنا تُحفَظ قبل الاستدعاء الشبكي". لكن الموثوقية ليست ميزة تضيفها لاحقاً. إنها مجموعة القرارات التي تتخذها مبكراً وترفض المساومة عليها، ثم تدافع عنها في كل مرة يبدو فيها الاختصار مُغرياً. ابنِ النظام الذي يقول الحقيقة حين يكون كل شيء يحترق، وستعتني الأجزاء السريعة والجميلة بنفسها.