تخيّل أنك تعمل على نظام حجز سفر، يحتوي على ملايين السجلات. ولكل سجل عدة فهارس (Indexes) لتسريع عمليات البحث.
في البداية، يعمل كل شيء بكفاءة. لكن مع نمو النظام، يزداد عدد عمليات الكتابة، ويستهلك نسخ البيانات بين قواعد البيانات (Replication) مزيدًا من ال bandwidth، كما تصبح عمليات ترقية قاعدة البيانات أكثر تعقيدًا.
هل يعني ذلك أن PostgreSQL غير مناسب للأنظمة الكبيرة؟
ليس بالضرورة.
في 26 يوليو 2016، نشرت Uber تجربتها في الانتقال من PostgreSQL إلى MySQL، وشرحت كيف أثّرت بعض التفاصيل الداخلية في طريقة تخزين البيانات والتعامل مع التحديثات على بنيتها المعمارية.
لفهم قرار الانتقال، علينا أن نبدأ من الأساس: كيف يخزّن PostgreSQL البيانات، وما الذي يحدث داخل قاعدة البيانات عندما نحدّث صفًا واحدًا؟
1. البداية من طريقة تخزين البيانات
تحتاج أي قاعدة بيانات علائقية إلى تحقيق مجموعة من المتطلبات الأساسية:
- إضافة البيانات وتحديثها وحذفها.
- دعم تطور بنية البيانات (Schema Evolution).
- دعم المعاملات (Transactions)، بحيث تتمكن كل معاملة من قراءة نسخة متسقة من البيانات.
يرتبط المتطلب الثالث بمفهوم يُسمّى MVCC — Multi-Version Concurrency Control.
تعتمد الفكرة على الاحتفاظ بنسخ متعددة من الصف نفسه عند الحاجة، بحيث تتمكن المعاملات من قراءة نسخة متوافقة مع مستوى الرؤية الخاص بها، دون أن تضطر كل عملية قراءة إلى انتظار انتهاء جميع عمليات الكتابة.
لكن طريقة تنفيذ هذه الفكرة تختلف من محرك قاعدة بيانات إلى آخر. وهذه الاختلافات هي نقطة البداية لفهم تجربة Uber.
2. PostgreSQL: ماذا يحدث عند تحديث صف؟
في PostgreSQL، يُمثَّل الصف المخزّن بوحدة تُسمّى tuple.
في الحالة المعتادة، لا يستبدل PostgreSQL الصف القديم مباشرةً عند تنفيذ UPDATE، بل ينشئ نسخة جديدة منه. وقد تبقى النسخة القديمة موجودة ما دامت هناك حاجة إليها، مثل حاجة معاملة لم تنتهِ بعد إلى قراءتها.
تُعرَف كل نسخة بموقع داخلي يُسمّى ctid، وهو يحدد موقع الـ Tuple داخل الجدول، وليس الهوية المنطقية للصف التي يعتمد عليها التطبيق.
لنفترض أن لدينا جدولًا للمستخدمين:
| birth_year | last | first | id | ctid |
|---|---|---|---|---|
| 1623 | Pascal | Blaise | 1 | A |
| 780 | al-Khwārizmī | Muhammad | 4 | B |
| 1912 | Turing | Alan | 5 | C |
ولدينا ثلاثة فهارس:
- فهرس المفتاح الأساسي (Primary Key Index) على
id. - فهرس إضافي (Secondary Index) على
(first, last). - فهرس إضافي على
birth_year.
| id | ctid |
|---|---|
| 1 | A |
| 4 | B |
| 5 | C |
| first | last | ctid |
|---|---|---|
| Alan | Turing | C |
| Blaise | Pascal | A |
| Muhammad | al-Khwārizmī | B |
| birth_year | ctid |
|---|---|
| 780 | B |
| 1623 | A |
| 1912 | C |
لا يحتوي الفهرس بالضرورة على الصف كاملًا، بل يوفّر بنية تساعد قاعدة البيانات على الوصول إلى الصفوف المطلوبة بكفاءة.
في PostgreSQL، تشير إدخالات هذه الفهارس إلى موقع الـ Tuple، الذي يحدده ctid.
لنفترض أن الصف الذي يحمل id = 4 موجود حاليًا في الموقع B.
عندما ننفّذ الاستعلام التالي:
UPDATE users
SET birth_year = 770
WHERE id = 4;
ينشئ PostgreSQL نسخة جديدة من الصف في موقع آخر، وليكن I، تحتوي على قيمة سنة الميلاد الجديدة.
قد تبقى النسختان موجودتين في الوقت نفسه. وتتولى آلية MVCC تحديد النسخة التي يمكن لكل معاملة رؤيتها.
| birth_year | last | first | prev | id | ctid |
|---|---|---|---|---|---|
| 1623 | Pascal | Blaise | null | 1 | A |
| 780 | al-Khwārizmī | Muhammad | null | 4 | B |
| 1912 | Turing | Alan | null | 5 | C |
| 770 | al-Khwārizmī | Muhammad | B | 4 | I |
لكن ظهور نسخة جديدة يثير سؤالًا مهمًا: كيف ستصل الفهارس إلى النسخة الجديدة بدلًا من الاعتماد على موقع النسخة القديمة؟
3. عندما يتحول تحديث بسيط إلى عمليات كتابة متعددة
لقد غيّرنا قيمة واحدة فقط، وهي birth_year.
لكن في السيناريو الذي تناقشه Uber، يمكن أن يتطلب هذا التحديث عدة عمليات كتابة على مستوى الجدول والفهارس:
- كتابة الـ Tuple الجديدة.
- تحديث مدخل الفهرس الخاص بالمفتاح الأساسي.
- تحديث مدخل فهرس الاسم.
- تحديث مدخل فهرس سنة الميلاد.
first last ctid Alan Turing C Blaise Pascal A Muhammad al-Khwārizmī B Muhammad al-Khwārizmī I birth_year ctid 770 I 780 B 1623 A 1912 C
تأمل النقطة الثالثة: لم يتغير اسم المستخدم، ومع ذلك قد يحتاج فهرس الاسم إلى التحديث؛ لأنه يشير إلى موقع الصف الجديد، وليس إلى موقع الصف القديم.
وهنا تظهر ظاهرة تُسمّى Write Amplification.
المقصود بها أن التغيير المنطقي الصغير قد يؤدي إلى حجم أكبر من عمليات الكتابة الفعلية داخل نظام التخزين.
كلما زاد عدد الفهارس الإضافية، زادت احتمالية ارتفاع تكلفة التحديث، لأن قاعدة البيانات تحتاج إلى الحفاظ على اتساق الفهارس مع الصفوف الجديدة.
ولا تنتهي عمليات الكتابة عند هذا الحد.
يستخدم PostgreSQL أيضًا سجلًا يُسمّى WAL — Write-Ahead Log لتسجيل التغييرات اللازمة للتعافي من الأعطال، وهو ما يضيف عمليات كتابة أخرى.
ملاحظة مهمة: هذا المثال يوضّح أحد السيناريوهات الممكنة، وليس سلوكًا ثابتًا لكل تحديث. يدعم PostgreSQL آلية تُسمّى HOT (Heap-Only Tuple)، يمكنها تجنّب تحديث الفهارس عندما لا تتغير الأعمدة المفهرسة وتتحقق شروط أخرى، منها توفر مساحة مناسبة داخل صفحة الجدول.
4. من القرص إلى الشبكة: كيف تتأثر عملية Replication؟
حتى الآن، قد تبدو المشكلة مقتصرة على تكلفة الكتابة داخل قاعدة البيانات نفسها.
لكن Uber كانت تعتمد على نسخ البيانات إلى قواعد بيانات أخرى، لأغراض مثل استمرارية الخدمة والتعافي من الكوارث.
يستخدم PostgreSQL سجل WAL في آلية Streaming Replication، حيث تنتقل سجلات التغييرات إلى النسخ التابعة (Replicas)، التي تعيد تطبيقها للحفاظ على بياناتها متوافقة مع المصدر.
وبالتالي، لا يقتصر تأثير طريقة تخزين البيانات على القرص المحلي. فالتغييرات التي تسجلها قاعدة البيانات تؤثر أيضًا على كمية البيانات التي يجب نقلها إلى النسخ التابعة.
في بعض الحالات، قد يتطلب تحديث منطقي واحد تسجيل عدة تغييرات على مستوى التخزين، بدلًا من تسجيل وصف مختصر للتغيير المطلوب على مستوى الصف.
وهكذا يمكن أن ترتبط Write Amplification بزيادة حجم البيانات المتبادلة أثناء النسخ، وهو ما يمكن وصفه بـ Replication Amplification.
كان ذلك مهمًا في بنية Uber، التي احتاجت إلى نسخ البيانات بين الساحل الغربي والساحل الشرقي للولايات المتحدة، إلى جانب أرشفة سجلات WAL في خدمة تخزين خارجية، لاستخدامها في استعادة البيانات وإنشاء نسخ جديدة.
ومع زيادة حجم البيانات ومعدل الكتابة، أصبح إنتاج سجلات WAL خلال فترات الذروة أكبر من قدرة نطاق التردّد المتاح على مجاراة عملية الأرشفة في بعض الحالات.
لاحظ أن المشكلة ليست بالضرورة ضعف الشبكة، ولا أن النسخ المتماثل تصميم غير مناسب.
الفكرة الأساسية هي أن طريقة تمثيل التغييرات داخل قاعدة البيانات تؤثر في كمية البيانات التي يجب نقلها وتخزينها خارجها.
5. مشكلات أخرى واجهتها Uber
لم تتوقف تجربة Uber عند تكلفة عمليات الكتابة.
ناقش المقال مشكلات أخرى واجهتها الشركة مع PostgreSQL 9.2، منها حادثة مرتبطة بخلل برمجي في التعامل مع تغييرات الـ Timeline أثناء ترقية الخادم الأساسي (Primary)، أدت إلى تلف بيانات في بعض النسخ التابعة.
كما تناول المقال تحديات تتعلق بالتعامل مع MVCC في بيئة النسخ المتماثل، وصعوبة ترقية الإصدارات مع الاعتماد على Physical Replication.
تستحق هذه المشكلات شرحًا مستقلًا، لأن اختزالها في عبارة مثل «PostgreSQL لا يتوسع» لن يوضح الأسباب التقنية الفعلية التي دفعت Uber إلى إعادة النظر في بنيتها.
ومن المهم أيضًا الانتباه إلى السياق التاريخي: يتناول المقال تجربة حدثت في عام 2016 باستخدام PostgreSQL 9.2. ولا يصح اعتبارها دليلًا على أن الإصدارات الحالية تعاني بالضرورة من المشكلات نفسها، أو أن PostgreSQL غير مناسب للأنظمة الكبيرة.
الخلاصة: القرار المعماري يبدأ من فهم التفاصيل
الدرس الأول من تجربة Uber ليس أن كل نظام كبير يجب أن يستخدم MySQL.
بل إن فهم آليات عمل محرك قاعدة البيانات من الداخل يساعدنا على إدراك الآثار التي قد تترتب على قرارات تبدو بسيطة على مستوى التطبيق.
فتحديث قيمة واحدة قد يؤثر في الصفوف المخزّنة والفهارس وسجلات التغييرات وحركة البيانات بين النسخ التابعة.
وهنا نصل إلى السؤال الذي سنناقشه في الجزء الثاني:
ماذا لو كان الفهرس الإضافي يشير إلى المفتاح الأساسي بدلًا من الموقع الفعلي للصف؟
هل يمكن لاختلاف في طريقة ربط الفهارس بالبيانات أن يقلل تكلفة بعض عمليات التحديث؟
هذا أحد الاختلافات المهمة التي سنستكشفها عند الانتقال إلى معمارية InnoDB في MySQL.
قاموس المصطلحات
Tuple: التمثيل الداخلي لنسخة من صف داخل PostgreSQL.
ctid: معرّف يحدد الموقع الداخلي للـ Tuple داخل الجدول، وقد يتغير عند تحديث الصف.
MVCC: آلية لإدارة التزامن تتيح للمعاملات قراءة نسخ متسقة من البيانات.
Index: بنية بيانات تساعد محرك قاعدة البيانات على الوصول إلى السجلات المطلوبة بكفاءة.
Secondary Index: فهرس إضافي يُبنى على عمود أو مجموعة أعمدة لتسريع عمليات البحث.
WAL: سجل تغييرات يُستخدم في التعافي من الأعطال، ويدخل أيضًا في آلية Streaming Replication.
Replica: نسخة من قاعدة البيانات تُستخدم لأغراض مثل توزيع عمليات القراءة أو دعم استمرارية الخدمة والتعافي من الأعطال.
Write Amplification: زيادة حجم عمليات الكتابة الفعلية مقارنة بحجم التغيير المنطقي المطلوب.
Replication Amplification: وصف لزيادة حجم التغييرات التي يجب نقلها إلى النسخ التابعة نتيجة طريقة تسجيل التغييرات وتمثيلها.
Timeline: تسلسل تاريخي في PostgreSQL يميّز مسارات تطور قاعدة البيانات، ويكتسب أهمية عند حدوث عمليات ترقية أو استعادة تؤدي إلى إنشاء مسار جديد للتغييرات.
HOT (Heap-Only Tuple): آلية في PostgreSQL تسمح ببعض تحديثات الصفوف دون الحاجة إلى تحديث الفهارس، عند استيفاء شروط محددة.

