إعادة البناء التي خفّضت زمن الاستجابة 90%
إعادة بناء Backend لمنصة خدمة ميدانية حول الطريقة التي تُقرأ بها بياناته فعلًا
- الجهة
- شركة كلين لايف
- الدور
- مهندس Backend
- المدة
- ديسمبر 2024 — حتى الآن
- تاريخ النشر
- 30 أغسطس 2026
- NestJS
- TypeScript
- PostgreSQL
- Redis
- BullMQ
- TypeORM
- Zoho FSM
يصبح الـ Backend بطيئًا بطريقتين: إما أن شيئًا فيه يؤدي عملًا أكثر مما ينبغي، أو أنه يؤدي القدر الصحيح من العمل في المكان الخطأ. وهذا النظام كان يفعل الاثنين معًا، وكان كلٌّ منهما يحجب الآخر.
السياق
تدير كلين لايف عملياتها الميدانية عبر منصة متكاملة مع Zoho FSM: تُرسَل المهام إلى الفنيين، وتعود السجلات التي تنتجها تلك المهام. وأنا أعمل على الـ Backend الذي يقف خلفها: طبقة البيانات، وتكامل المدفوعات، وكل ما يجري خارج دورة الطلب.
بُنيت المنصة بسرعة، وكانت تعمل. وهذه عادةً هي اللحظة التي يبدأ فيها نموذج البيانات بتحصيل فوائد القرارات التي مكّنت من إطلاقه.
المشكلة
صارت عمليات القراءة هي عنق الزجاجة. فجلب البيانات — وهو العمل الذي ينتظره أحدهم دائمًا — كان أبطأ ما يفعله النظام.
هذا النوع من البطء لا يتوزع بالتساوي، بل يقع على من ينظر إلى الشاشة، وهو في منصة خدمة ميدانية موظف التوزيع الذي يقرر ما يحدث تاليًا. وكان بطئًا بنيويًا: تكلفته تنمو مع نمو البيانات، أي أنه كان سيزداد سوءًا من تلقاء نفسه، ولم يكن أي تحسين فوق طبقة البيانات ليعكس ذلك.
أين كان يذهب الوقت
أربعة أسباب، وكانت تتراكم فوق بعضها:
- نما المخطط حول عملية الكتابة. تشكّلت السجلات وفق طريقة وصولها لا وفق طريقة الاستعلام عنها، فصارت القراءة العادية مضطرة إلى إعادة تجميعها.
- لم تكن الفهارس مطابقة لمسارات الوصول. فالاستعلامات التي تبدو رخيصة كانت تمسح الجداول.
- كانت البيانات نفسها تُجلب مرارًا، وكل عملية جلب تدفع الثمن كاملًا.
- كان العمل الذي لا يلزم أن يحدث داخل الطلب يحدث داخله، فينتظره الطلب.
كل سبب منها وحده يمكن احتماله. لكن اجتماعها يعني أن قراءة واحدة قد تكون بطيئة لأربعة أسباب مستقلة، فإصلاح أحدها بالكاد يحرّك الرقم — وهذا بالضبط ما جعل المشكلة تعمّر طويلًا.
مسار الطلب
Client
NestJS API
PostgreSQL
مخطط مُشكَّل للكتابة، وفهارس لا تطابق مسارات الوصول
Heavy work
يُنفَّذ داخل الطلب؛ فينتظره الطلب، ويظهر أي فشل للمستخدم
كل قراءة تدفع ثمن المخطط، والفهرس الناقص، والعمل المتزامن — في كل طلب.
التشخيص
حين تتراكم أربعة أسباب، يصبح ترتيب العمل هو المنهج كله. فإن غيّرت أشياء عدة دفعة واحدة، لم يعد القياس يخبرك بشيء: يتحرك الرقم، ولا تستطيع أن تقول أيُّ تغيير حرّكه — ولا إن كان أحدها قد أساء بينما غطّى عليه غيره.
لذلك: القراءات أولًا، لأنها الموضع الذي جاءت منه الشكوى. ثم السؤال الذي يشطر المشكلة نصفين: هل يُنفَق الوقت داخل قاعدة البيانات أم حولها؟ وكل ما بعد ذلك استبعادٌ متدرج: تغيير واحد، ثم قياس، ثم إبقاؤه أو التراجع عنه.
القيود
- كان النظام يعمل في الإنتاج. والعمليات الميدانية لا تتوقف من أجل إعادة بناء.
- كان التكامل مع Zoho FSM ثابتًا. فهو يحدد جزءًا من شكل البيانات، ولم يكن قابلًا لإعادة التفاوض.
- أي مخطط جديد كان يجب أن يكون قابلًا للوصول إليه عبر migration من المخطط القائم، وفيه بيانات إنتاجية.
- وكان يجب أن يبقى نظامًا يستطيع الفريق العمل عليه. فالـ Backend الأسرع الذي لا يفهمه إلا شخص واحد هو Backend أسوأ.
ما الذي تغيّر
- أُعيد تصميم مخطط PostgreSQL حول مسارات القراءة — أي حول الطريقة التي تُطلب بها البيانات فعلًا، لا الطريقة التي تصل بها.
- بُنيت الفهارس لتلك المسارات، بعد أن استقرت بما يكفي ليستحق الأمر فهرستها.
- وُضع تخزين مؤقت على Redis أمام النتائج التي تحتمل قدرًا يسيرًا من التقادم، مع تسمية موحّدة للمفاتيح، بحيث يصبح إبطال التخزين أمرًا يُستدل عليه لا أمرًا يُبحث عنه.
- انتقل كل ما لا يلزم حدوثه داخل الطلب إلى طوابير BullMQ، مع إدارة لإعادة المحاولة وأقفال موزعة تمنع أن يلتقط عاملان الوحدة نفسها من العمل.
والترتيب ليس عرضيًا. فالتخزين المؤقت لاستعلام سيئ يخفيه لا يصلحه، وفهرسة مخطط أنت على وشك تغييره عملٌ ضائع.
مسار الطلب
Client
NestJS API
Redis cache
مفاتيح موحّدة؛ تخدم ما يحتمل التقادم اليسير
PostgreSQL
مخطط مُشكَّل للقراءة، ومفهرس للمسارات التي تُنفَّذ فعلًا
خارج الطلب
BullMQ queue
Workers
إعادة محاولة وأقفال موزعة؛ وحدة عمل واحدة لعامل واحد
PostgreSQL · Zoho FSM
الفشل هنا يُعاد تنفيذه، ولا يُعرض على أحد
لم يعد مسار الطلب يحتوي إلا على العمل الذي ينتظره شخص فعلًا.
القرارات الأساسية
- 01
تغيير نموذج البيانات قبل الكود
الإصلاحات على مستوى التطبيق لمشكلة في نموذج البيانات لا تشتري أكثر من معامل ثابت. فالمخطط هو الذي يحدد ما تستطيع قاعدة البيانات فعله بثمن رخيص، ولا شيء يُكتب فوقه يستطيع نقض ذلك.
المقايضةتغيير المخطط يعني migration على بيانات حيّة، وهو ما جعله أخطر أجزاء المشروع. اشترى أكبر تحسين وحمل أكبر مخاطرة، وكلاهما كان القرار نفسه.
- 02
تخزين مؤقت انتقائي، بمفاتيح موحّدة
لم يوضع خلف Redis إلا ما يحتمل أن يكون متقادمًا لوقت قصير. والقيمة ليست في التخزين المؤقت بقدر ما هي في الاتفاق: كل مفتاح يتبع الشكل نفسه، فيصبح إبطال أي منها قرارًا تتخذه لا بحثًا تضطر إلى إجرائه.
المقايضةكل قيمة مخزَّنة مؤقتًا هي وعدٌ بإبطالها في وقتها الصحيح. والبيانات المتقادمة حين كان يجب أن تكون حديثة خللٌ أصعب في الاكتشاف بكثير من استعلام بطيء.
- 03
إخراج العمل من الطلب بدل تسريعه في مكانه
العمل الذي لا ينتظره أحد لا ينبغي أن يقع في المسار الذي ينتظره الناس. والعمل الموضوع في طابور ينال أيضًا ما لا يناله الاستدعاء المتزامن أبدًا: فرصة إعادة المحاولة.
المقايضةصارت الواجهة تعود قبل اكتمال العمل، فوجب على المنتج أن يعبّر بصدق عن حالة “قيد التنفيذ”. كما انتقل الفشل من شاشة المستخدم إلى طابور يحتاج من يراقبه.
- 04
قفل وحدة العمل، لا مسار الكود
أقفال موزعة مفاتيحها مبنية على العمل نفسه، وتتبع اتفاق التسمية ذاته المستخدم في التخزين المؤقت، فلا يمكن التقاط الوحدة نفسها مرتين مهما بلغ عدد العمال.
المقايضةالقفل رهانٌ على مدة صلاحيته. إن كانت قصيرة عادت الازدواجية، وإن كانت طويلة احتجز عاملٌ متعطل المهمة إلى أن تنتهي المدة.
الأداء
≈90%
انخفاض في زمن جلب البيانات
قياس نسبي، وهو الأثر المجمّع للتغييرات الأربعة مجتمعة لا لأي واحد منها. أما الأرقام المطلقة فهي داخلية لدى كلين لايف ولا تُنشر هنا.
- قبل
- 100
- بعد
- 10
زمن جلب البيانات نسبيًا، مع اعتبار ما قبل التغيير = 100
| قبل | بعد | |
|---|---|---|
| زمن جلب البيانات | خط الأساس | أقل بنحو 90% |
| المخطط | مُشكَّل وفق طريقة كتابة السجلات | مُشكَّل وفق طريقة قراءتها |
| الفهارس | لا تطابق الاستعلامات التي تُنفَّذ | مبنية لمسارات الوصول التي تُنفَّذ فعلًا |
| القراءات المتكررة | تذهب إلى PostgreSQL في كل مرة | تُخدَم من Redis حيث يُقبل التقادم اليسير |
| العمل غير الضروري في الطلب | داخل الطلب | على طوابير BullMQ، مع إدارة إعادة المحاولة |
| العمال المتزامنون | حالات تزاحم كنا نواجهها | أقفال موزعة؛ وتوقفت حالات التزاحم |
| فشل المهام | متكرر | انخفض |
المقايضات
- صار Redis الآن على مسار القراءات المخزَّنة مؤقتًا وعلى مسار كل قفل. وهو اعتماد لم يكن في النظام من قبل، وقابل للفشل.
- بعض البيانات متقادمة قليلًا بالتصميم. وتحديد أيّها كان قرارًا يخص المنتج بقدر ما يخص التقنية.
- العمل غير المتزامن أسهل في التشغيل وأصعب في القراءة. فتتبّع وحدة عمل واحدة صار يعني تتبّعها عبر حدود الطابور.
- صار المخطط أفضل للقراءة وأكثر كلفة قليلًا في الكتابة. وهذا هو الجانب الصحيح من المقايضة لهذا النظام، وسيكون الجانب الخطأ لنظام آخر.
النتيجة
- انخفض زمن جلب البيانات بنحو 90%.
- صار العمل الذي كان يُبطئ الطلبات يجري على الطوابير، حيث يُعاد تنفيذ الفشل بدل أن يُعرض على أحد.
- توقفت حالات التزاحم التي كنا نواجهها، وانخفض فشل المهام.
- ويحمل نظام المهام نفسه الآن العمل المجدول: يمكن تسجيل المهام وإعادة جدولتها أثناء التشغيل دون إعادة تشغيل الخادم، ويُسجَّل كل تنفيذ.
ما خرجت به
- كان الوقت في طبقة البيانات لا في كود التطبيق. وقد صحّ ذلك في كل مشكلة أداء عملت عليها بعدها، ولهذا صار أول ما أنظر فيه.
- كان إخراج العمل من مسار الطلب أجدى من تسريع العمل نفسه. فأسرع صورة لأي مهمة هي التي لا ينتظرها أحد.
- الاتفاق المحيط بالتخزين المؤقت أهم من التخزين نفسه. فالمفاتيح الموحّدة هي ما يحوّل الإبطال من بحثٍ إلى قرار.
- الـ migrations هي الجزء الخطر في تغيير كهذا. وهذا سبب كبير في أنني حين أراجع الكود اليوم أقرأ أي migration بتأنٍّ أكثر من أي شيء آخر في التغيير.