- متن حزم
DataوTotalsوExtremesوLogوProfileEventsفي بروتوكول TCP الأصلي (علمًا بأن حزمةTableColumnsليست كتلة Native، إذ تحمل سلسلتين ثنائيتين، ولذلك يُشرح تخطيطها في مواصفة البروتوكول الأصلي)؛ - إخراج
SELECT ... FORMAT Nativeعبر HTTP؛ - الملفات المُصدَّرة باستخدام
INTO OUTFILE ... FORMAT Native؛ - حمولات النسخ المتماثل بين الخوادم.
نظرة عامة
كل ما ينقل الصفوف في تنسيق النقل هو كتلة: مقطع ذاتي الوصف من الصفوف مُخزَّن عمودًا تلو الآخر، إذ تأتي جميع قيم العمود 1 أولًا، ثم جميع قيم العمود 2، وهكذا. ولا تحمل الكتلة إلا الأعمدة التي يشير إليها الاستعلام، ولا تحمل الجدول كاملًا أبدًا. تُرتَّب بياناتdata الخاصة بالعمود وفقًا لـالعائلة التي ينتمي إليها نوعه. وفيما يلي هذه العائلات مرتبةً تصاعديًا بحسب تعقيد أداة فك الترميز:

- الأنواع ثابتة العرض ترتّب
dataعلى هيئة بايتات خام بحجمbytes_per_value × num_rows، دون أي تأطير على مستوى الصف. - الأنواع المركّبة (
NullableوArrayوTupleوMapوNested) لها بنية تعاودية يمكن اشتقاقها بالكامل من سلسلة النوع، فلا بادئة إصدار لها ولا حالة تمتد عبر الكتل. - الأنواع ذات الإصدارات / ذات الحالة (
LowCardinalityوJSONوVariantوDynamic) تبدأ كل كتلة غير فارغة ببادئة إصدار التسلسل/بادئة الحالة. وفي تنسيق النقلNative، تكون هذه البادئة وأي قاموس مرتبطَين بكل كتلة على حدة — فالصيغة لا تحمل أي حالة عبر الكتل (إذ يُنشئ الكاتب حالة تسلسل جديدة لكل كتلة ويضبطlow_cardinality_max_dictionary_size = 0). أما الحالة الممتدة عبر الكتل فتخص تخزين MergeTree على القرص، ولا علاقة لها بتخطيط wire الخاص بصيغة Native.
العناصر الأولية للتمثيل الثنائي
تعتمد صيغة Native على أربعة ترميزات أولية.VarUInt
عدد صحيح بدون إشارة متغير الطول يستخدم ترميز LEB-128. يحمل كل بايت 7 بتات بيانات في المواضع 0–6، وبت استمرار واحدًا في الموضع 7. تكون قيمة بت الاستمرار1 عندما تليه بايتات أخرى، و0 في البايت الأخير.
ترميز القيمة
300:
0xAC 0x02:
الأعداد الصحيحة ذات العرض الثابت
على سبيل المثال، تُرمَّز القيمة
1 من النوع UInt32 بالشكل 01 00 00 00، والقيمة -1 من النوع Int32 بالشكل FF FF FF FF.
String
سلسلة من البايتات مسبوقة بطولها:0x00، ويمكن أن تحتوي السلاسل النصية على أي قيم للبايتات، بما في ذلك محارف NUL المُضمَّنة. فعلى سبيل المثال، تُرمَّز السلسلة "ab" في صورة 02 61 62؛ ولفك ترميزها، اقرأ الطول المُرمَّز بصيغة VarUInt (2)، ثم اقرأ العدد نفسه من البايتات.
Bool
بايت واحد. تمثّل القيمة0x00 القيمة false، بينما تمثّل أي قيمة غير صفرية القيمة true (والصيغة القياسية لها هي 0x01).
بنية الكتلة والأعمدة
تخطيط wire للكتلة
BlockInfo على القناة المستخدمة، لأن الكاتب يُضبط وفق revision (راجع مراجعة البروتوكول وصيغة Native للاطلاع على الشرح الكامل، بما في ذلك كون client_protocol_version خاصًا بالإخراج فقط):
-
في بروتوكول TCP الأصلي، يكتب الخادم الكتل وفق الـ revision المتفاوض عليها للاتصال (وهي قيمة كبيرة —
DBMS_TCP_PROTOCOL_VERSION، راجعsrc/Core/ProtocolDefines.h). وتُكتبBlockInfoمتى كانت قيمة الـ revision أكبر من الصفر، وهو ما يتحقق دائمًا في أي اتصال فعلي. أما البايتhas_custom_serializationفي كل عمود (راجع تخطيط wire للأعمدة) فيُكتب ابتداءً من الـ revision54454. -
أما تنسيق الإخراج
Native— أيSELECT ... FORMAT Nativeعبر HTTP، وINTO OUTFILE ... FORMAT Native، وصيغةNativeالتي يُنتجهاclickhouse-client— فيُجري التسلسل عند الـ revision0افتراضيًا. وعند الـ revision0تُحذف البادئةBlockInfoوالبايتhas_custom_serializationمعًا، فلا تتكوّن الكتلة إلا منnum_columnsوnum_rowsوالأعمدة. غير أن هذه الـ revision ليست ثابتة عبر HTTP، إذ يمكن للعميل رفعها باستخدام معامل الاستعلام?client_protocol_version=<n>، فيعتمد الخادم تلك القيمة revision للتسلسل في الاستجابة. وعند استخدام قيمة مرتفعة بما يكفي، يتضمن إخراج HTTP البادئةBlockInfo(التي تُكتب متى كانت الـ revision أكبر من0) والبايتhas_custom_serialization(الذي يُكتب ابتداءً من الـ revision54454)، تمامًا كما في مسار TCP. لذا لا ينبغي للعملاء أن يفترضوا أن كل حمولةFORMAT Nativeعبر HTTP تكون بالـ revision0.
BlockInfo حمولةَ حزمة Data في TCP، في حين يُنتج الاستعلام نفسه عند تنفيذه عبر FORMAT Native الشكلَ الأقصر المعروض بجانبها.
BlockInfo
BlockInfo هو تسلسل من الحقول، يسبق كلًّا منها معرّف حقل من النوع VarUInt، وينتهي بمعرّف حقل قيمته0. وتنسيق wire هنا ليس ذاتي الوصف: فمعرّف الحقل لا يُرمِّز طول قيمته ولا نوعها، ولذا يجب أن يعرف القارئ مسبقًا نوع كل معرّف حقل قد يصادفه. ويعامل القارئ الخاص بـ ClickHouse أي معرّف حقل غير معروف على أنه تلف في البيانات، فيُطلق استثناءً (UNKNOWN_BLOCK_INFO_FIELD). أما التوافق المستقبلي فتتكفّل به revision البروتوكول بدلًا من ذلك: إذ لا يكتب المُرسِل حقلًا إلا إذا كانت revision المتفاوض عليها لا تقل عن الحد الأدنى لـ revision الخاص بذلك الحقل، وبذلك لا يصادف المستقبِل الأقدم أبدًا حقلًا لا يعرفه.
الحد الأدنى لـ revision للحقلين
1 و2 هو 0، ولذا يكونان موجودين كلما كُتب BlockInfo. أما الحقل 3 فلا يُكتب إلا عند revision 54480 فما فوق. فيما يلي تخطيط wire للحالة الشائعة (revision أقل من 54480):
تخطيط wire للعمود
يظهر العمودnum_columns مرة داخل الكتلة (كتلة).
تحدد أداة فك الترميز طريقة المعالجة بناءً على سلسلة
type. وكثيرًا ما تتضمن سلاسل الأنواع معلمات بين قوسين؛ فتُزيل أداة فك الترميز اللاحقة (...) لاستخراج النوع الأساسي، ثم تحلل المعلمات لتحديد الحجم أو المقياس أو النوع الداخلي. ويتطلب تحليل قائمة معلمات تتضمن أنواعًا متداخلة (مثل Tuple داخل Array) أداة تقسيم بالفواصل تراعي العمق وتتتبع تداخل الأقواس، بدلًا من التقسيم البسيط على ,.
الترميز الثنائي للنوعلا يكون الحقل
type من النوع النصي String إلا في الوضع الافتراضي. أما عند ضبط إعداد الاستعلام output_format_native_encode_types_in_binary_format = 1، فيصبح هذا الحقل ترميزًا ثنائيًا للنوع — وهو الترميز نفسه القائم على الوسوم والموثّق في الترميز الثنائي لأنواع البيانات — كما تستخدم قوائم النوع Dynamic المسطّحة الترميز الثنائي نفسه لأسماء أنواعها. وأي أداة فك ترميز تقرأ الحقل 2 دائمًا على أنه سلسلة مسبوقة بطولها ستفسّر أول وسم ثنائي للنوع على أنه طول سلسلة فتفقد التزامن، لذا يجب أن تعرف الوضع الذي يستخدمه التدفق.بالنسبة إلى الاستعلام الذي يكون فيه query_kind = SECONDARY_QUERY (المُرسَل من خادم آخر عبر remote أو Distributed أو النسخ المتماثلة المتوازية، أو عبر clickhouse-client --query_kind secondary_query)، يتجاهل كلا الطرفين output_format_native_encode_types_in_binary_format وinput_format_native_decode_types_in_binary_format في كتل ذلك الاستعلام، إذ تحمل هذه الكتل أسماء الأنواع بصيغتها النصية في كلا الاتجاهين. أما الخوادم الأقدم من هذه القاعدة فتطبّق هذه الإعدادات على الاستعلام الثانوي كما تطبّقها على أي استعلام آخر.
kind_stack والترميز المتناثر
يُعدِّد البايتkind_stack تسلسلًا غير افتراضي خاصًا بكل عمود:
تستخدم حمولة
COMBINATION تعدادًا مختلفًا. الصفوف الخمسة أعلاه رموز مضغوطة بطول بايت واحد. أما COMBINATION (0x05) فهو المخرج العام لأي مكدس لا تغطيه تلك الرموز: إذ يليه count من نوع VarUInt ثم count من الإدخالات بطول بايت واحد. وهذه الإدخالات ليست الرموز المضغوطة الواردة في الجدول، بل هي قيم ISerialization::Kind الخام:
تختلف قيم البايتات هنا عن الرموز المضغوطة: فقيمة
REPLICATED هي 0x03 في هذا التعداد المتداخل، بينما هي 0x04 كرمز مضغوط، ولا يوجد إدخال DETACHED_OVER_SPARSE، إذ يظهر هذا التركيب على هيئة إدخالين متتاليين هما SPARSE ثم DETACHED. وأي أداة فك ترميز تستمر في استخدام الجدول المضغوط للبايتات المتداخلة ستخطئ في تعيين 0x03/0x04 وتفقد التزامن.
يمثل count الطول الكامل للمكدس بما في ذلك إدخال DEFAULT الأول الذي يبدأ به كل مكدس. ولأن الرموز المضغوطة تغطي أصلًا كل مكدس مكوّن من إدخال واحد أو إدخالين، فإن قيمة count في COMBINATION لا تقل أبدًا عن ثلاثة.
صلاحية مكدس الأنواع. ترفض أداة فك الترميز المكدس غير السليم بالخطأ INCORRECT_DATA بدلًا من بناء التسلسل الذي يصفه، إذ يجب أن:
- يبدأ المكدس بـ
DEFAULT؛ - يكون المكدس تسلسلًا فرعيًا من
DEFAULTوSPARSEوREPLICATEDوDETACHED، وهو الترتيب الذي تُغلِّف به الأنواع بعضها بعضًا. فيمكن أن يقعSPARSEداخلREPLICATEDلا العكس، ويأتيDETACHEDدائمًا في النهاية لأنColumnBLOBيحتوي على الصيغة المُسلسَلة لكل ما تحته. ويعني هذا أيضًا أن أي نوع لا يتكرر، مما يجعلcountمحدودًا بعدد الأنواع.
ColumnSparse مُغلَّفًا إذا كان عمود قيمه نفسه متناثرًا أو متماثلًا.
kind_stack التعاودي لأعمدة Tuple. حمولة kind_stack المذكورة أعلاه هي البايت (أو تسلسل COMBINATION) الخاص بمعلومات التسلسل لعمود واحد. أما Tuple فيحمل SerializationInfoTuple، الذي يكتب أولًا حمولة مكدس الأنواع الخاصة بالـ Tuple، ثم يكتب حمولة كاملة لمكدس الأنواع لـكل عنصر بالترتيب؛ وتقرأ أداة فك الترميز البنية التعاودية نفسها. وبذلك تكون بايتات الحقل 4 لـ Tuple(A, B, C) هي [tuple_kind][A_kind][B_kind][C_kind]، وتكون حمولة كل عنصر بدورها تعاودية إذا كان ذلك العنصر مركّبًا أيضًا. يُضبط البايت has_custom_serialization (الحقل 3) كلما كانت معلومات الـ Tuple نفسه أو معلومات أي من عناصره غير افتراضية، لذا فإن Tuple الذي لا يتميز من عناصره إلا عنصر واحد متناثر أو متماثل يستدعي هو الآخر حمولة مكدس الأنواع. وأي أداة فك ترميز تكتفي بقراءة بايت التعداد الأول لـ Tuple ستتوقف قبل الأوان، وتقرأ خطأً بايتات أنواع العناصر المتبقية على أنها بيانات أعمدة.
تنسيق wire المتناثر. عندما تكون kind_stack = 0x01، تتكوّن data الخاصة بالعمود من دفقين مكتوبين على التوالي ضمن دفق TCP المشترك الوحيد:
- دفق الإزاحات — تسلسل من قيم
VarUInt. وكل قيمةvتكون على إحدى حالتين:vيكون فيها البت ذو الموضع 62 غير مضبوط:(v & 0x3FFFFFFFFFFFFFFF)= عدد المواضع الافتراضية التي تسبق القيمة الصريحة غير الافتراضية التالية. ويكون ذلك الموضع غير الافتراضي هوcursor + group_size، حيثcursorهو الموضع الحالي؛ ثم يتقدّمcursorبمقدارgroup_size + 1.vيكون فيها البت 62 مضبوطًا (END_OF_GRANULE_FLAG): القيمة بعد مسح العلامة = عدد المواضع الافتراضية اللاحقة لآخر قيمة غير افتراضية. ويدل ذلك على نهاية دفق الإزاحات الخاص بالكتلة.
- دفق القيم — عدد
countمن القيم غير الافتراضية المُرمَّزة بشكل متراص وفق النوع الداخلي، حيثcountهو عدد قيم VarUInt المقروءة أعلاه التي لا تدل على نهاية الحبيبة (EOG).
num_rows عنصرًا، وذلك بملء كل موضع غير صريح بالقيمة الافتراضية للنوع الداخلي (0 للأعداد الصحيحة والأعداد ذات الفاصلة العائمة، و"" لـ String، و0 يوم لـ Date، وهكذا).
يُعدّ العمود المتناثر من النوع Nullable(T) حالة خاصة، لأن القيمة الافتراضية لـ Nullable(T) هي NULL. إذ يستغني الترميز المتناثر كليًّا عن دفق null-map المعتاد في Nullable: فدفق الإزاحات يحدّد المواضع غير الافتراضية — أي المواضع غير NULL — ودفق القيم يضم تلك القيم غير NULL وحدها بشكل متراص وفق T، ويُعاد بناء كل موضع غير صريح على أنه NULL. لذا يجب على أداة فك الترميز ألّا تبحث عن null map في دفق القيم، وألّا تملأ الفجوات بقيمة 0 فعلية، بل تملؤها بـ NULL.
تنسيق wire للنوع Replicated. عندما تكون kind_stack = 0x04، تكون بيانات العمود data قاموسًا، أي قائمة بقيم العناصر المميزة مع فهرس لكل صف يشير إلى موضعه في تلك القائمة (وهي بنية البحث نفسها المستخدمة في LowCardinality). وإذا كان النوع الداخلي نفسه ذا إصدار — مثل LowCardinality(T) — فإن بادئة الحالة الخاصة به تُكتب أولًا، قبل دفق الفهرس: إذ يفوّض التسلسل المتماثل مرحلة البادئة إلى النوع الداخلي قبل كتابة num_rows. أما الأنواع الداخلية ذات البادئة الفارغة (الأنواع الطرفية والأنواع المركّبة البسيطة) فلا تضيف أي بايتات هنا.
elements[indexes[i]] لكل صف إخراج i. أما الأنواع الداخلية المركّبة فتُعالَج تعاوديًا: إذ تُجسَّد قائمة العناصر بالنوع الداخلي أولًا، ثم تُفهرَس. وتشمل الأنواع الداخلية المدعومة الأنواع الطرفية، وNullable(T)، وArray(T)، وTuple(...)، وMap(K, V)، وNested(...) (حيث يُوسَّع كل حقل كما في Array)، وLowCardinality(T) (حيث يُحتفَظ بالقاموس المشترك، ولا تُفهرَس إلا المفاتيح الخاصة بكل عنصر).
تنسيق wire المنفصل. يظهر DETACHED (0x02) وDETACHED_OVER_SPARSE (0x03) فعلًا أثناء النقل عبر الشبكة، فهما ليسا داخليين بحتين. ففي مسار TCP، عند تفعيل الضغط وكون قيمة revision المتفاوض عليها لا تقل عن DBMS_MIN_REVISON_WITH_PARALLEL_BLOCK_MARSHALLING (v54478)، يمر الـ column بثلاث خطوات:
- يُغلَّف كل column مؤهَّل (أي غير
const، وغيرTuple، وموجود في كتلة تضم أكثر من صف واحد) داخلColumnBLOBيحتفظ بالـ column بعد ترتيبه وضغطه مسبقًا خارج خيط التنفيذ الرئيسي. - يُضاف
DETACHEDإلى مكدس الأنواع الخاص بالـ column المُغلَّف. - تُكتب
dataالخاصة بالـ column على هيئة حجم blob بصيغةVarUInt، يليه عدد مطابق له تمامًا من بايتات الـ blob.
{DEFAULT, SPARSE, DETACHED}، ويُسلسَل على هيئة DETACHED_OVER_SPARSE. وعند فك ترميز column كهذا، يقرأ العميل طول الـ blob وبايتاته، ثم يفك ضغط الـ blob لاستعادة حمولة الـ column الداخلي (راجع ملاحظة ColumnBLOB في قسم الضغط).
DETACHED أحادي الاتجاه: فلا يمكن أن يظهر إلا في كتل النتائج التي يرسلها الخادم، ولا يُطبَّق إلا على عمود كامل من المستوى الأعلى، ولا يظهر أبدًا كأحد أنواع العناصر داخل Tuple. وخلافًا للأنواع الأخرى، فإنه يجعل العمود في وقت التشغيل من النوع ColumnBLOB بدلًا من عمود من النوع المُعلَن، ولذلك لا يقبله المستقبِل إلا إذا كان مسار المعالجة لديه يعيد تحويل الـ blob إلى صيغته الأصلية. ولهذا يرفض الخادم DETACHED في أي كتلة يرسلها العميل، سواء أكانت بيانات جدول خارجي أم قيمًا مفردة أم بيانات إدراج، ويُرجع الخطأ INCORRECT_DATA.
بدائل الكتلة
تشترك جميع حزم عائلة Data في تنسيق wire نفسه الخاص بالكتلة، ولا تختلف البدائل فيما بينها إلا في عدد الأعمدة والصفوف:أمثلة على مستوى البايت
جميع الأمثلة في هذا القسم مأخوذة من مسار حزمة Data عبر TCP، ولذلك تتضمن البادئةBlockInfo والبايت has_custom_serialization. أما عبر FORMAT Native فتكون الكتل نفسها أقصر، وسنورد الصيغة المختصرة المكافئة حيثما كان ذلك مفيدًا.
كتلة فارغة (مع BlockInfo)، حجمها الإجمالي 8 بايتات:
SELECT 1 عن column واحد باسم "1" من النوع UInt8، دون أي صفوف. وعندما يكون إصدار البروتوكول ≥ 54454، يُضاف البايت has_custom_serialization:
FORMAT Native (الـ revision 0)، لا تتضمّن كتلة النتيجة نفسها BlockInfo ولا البايت has_custom_serialization، إذ يبلغ حجم SELECT 1 FORMAT Native 11 بايت:
FORMAT Native: إذ لا يُصدر تنسيق الإخراج هذا كتلًا فارغة.)
revision البروتوكول وصيغة Native
يتحدد شكل دفق البايتات بصيغة Native قبل كل شيء بحسب revision البروتوكول التي يعمل بها كلٌّ من الكاتب والقارئ. ولا تَرِد revision في أي موضع من البايتات نفسها — إذ لا يوجد حقل لـ revision في تنسيق النقل — ومع ذلك فهي التي تحدد ما إذا كانت عدة ميزات ستظهر من الأساس. ولذلك يتعين على أداة فك الترميز أن تعرف revision التي كُتبت بها الحمولة قبل أن تتمكن من تحليلها. وبما أن revision غير موجودة في الدفق، فلا بد أن يتفق القارئ والكاتب عليها بوسيلة أخرى. وrevision قيمة واحدة من النوعUInt64، يستقبلها كلٌّ من NativeWriter وNativeReader وسيطًا في المُنشئ. ويسميها الكاتب client_revision بينما يسميها القارئ server_revision، لكنها الرقم نفسه في الحالتين. وأحدث revision يعرفها هذا الإصدار هي DBMS_TCP_PROTOCOL_VERSION (راجع src/Core/ProtocolDefines.h).
ما الذي تتحكم فيه المراجعة (revision)
تخضع كل ميزة لعتبة من نوعDBMS_MIN_REVISION_WITH_*. لا يُصدر الكاتب الميزة إلا عندما تبلغ مراجعته (revision) تلك العتبة، ويبحث عنها القارئ وفق القاعدة ذاتها تمامًا، فيبقى الطرفان متزامنين؛ أما إذا أخطأت في تحديد المراجعة (revision) على أي من الجانبين فسيفقدان التزامن. البوابات المهمة لصيغة Native هي:
وهذا يجعل الـ revision
0 الترميز الأكثر تحفّظًا في كل شيء تقريبًا: إذ لا يحمل الدفق أي BlockInfo، ولا بايت has_custom_serialization، ويستخدم الإصدار V1 من Dynamic/JSON، ولا يتضمن إصدارًا لدوال التجميع، ويحتوي على DateTime مجرّد مع حذف معلمة المنطقة الزمنية. وتُستثنى من ذلك إصدارات حالة دوال التجميع عند الـ revision 0: فلا يوجد طرف مقابل يُشتقّ منه الإصدار، إذ تكتب الدفقَ الجهةُ نفسها التي أنتجته وتعيد قراءته (ملف بيانات StripeLog، أو نسخة احتياطية لـ Set/Join، أو ملف بصيغة Native). لذا يحتفظ الكاتب بالإصدار المثبّت على النوع (مع التصريح به في سلسلة النوع كالمعتاد) بدلًا من خفض الحالة إلى الإصدار 0، ولا يُكتب بالإصدار 0 إلا النوع الذي لا يثبّت أي إصدار.
أما LowCardinality فهو الاستثناء الوحيد، وهو استثناء مهم. فشرط الكاتب هو remove_low_cardinality || (client_revision && client_revision < DBMS_MIN_REVISION_WITH_LOW_CARDINALITY_TYPE). ويكمن السرّ في الجزء الأول client_revision &&: فعندما تساوي قيمة الـ revision 0 تمامًا، يُقيَّم الشرط بأكمله فورًا إلى false وفق مبدأ التقييم المختصر (short-circuit).
لذلك عند الـ revision 0، وهي القيمة الافتراضية لـ FORMAT Native، لا يُزال LowCardinality(T)، بل تبقى سلسلة نوعه وبادئة الحالة الخاصة بكل كتلة في الدفق، ويعيد القارئ ذو الـ revision 0 قراءتهما مباشرةً. ولا تُطبَّق الإزالة إلا عند revision غير صفرية أقل من 54405، أو عند فرضها بصرف النظر عن الـ revision.
ويتم هذا الفرض عبر الخيار remove_low_cardinality. ولا يضبطه إخراج FORMAT Native أبدًا، في حين يضبطه مسار البروتوكول الأصلي عندما تكون low_cardinality_allow_in_native_format = 0 (القيمة الافتراضية 1). بعبارة أخرى، يغيّر هذا الإعداد إخراج البروتوكول الأصلي، لكنه لا يؤثر إطلاقًا في FORMAT Native.
الخلاصة العملية: قد يحتوي دفق FORMAT Native الافتراضي على LowCardinality بشكل مشروع، فلا تفترض أنه ميزة غائبة عند الـ revision 0.
مصدر الـ revision بحسب طريقة انتقال البيانات
يمكن أن تنتقل بايتات Native ذاتها عبر مسارات مختلفة: بروتوكول TCP الأصلي، أو طلب HTTP، أو ملف على القرص. ولكل مسار طريقته الخاصة في تحديد الـ revision. وثمة أمر ينبغي الانتباه إليه: يُهيَّأ جانبا القراءة والكتابة كلٌّ على حدة، ولذلك قد ينتهي بهما الأمر إلى قيمتَي revision مختلفتين.بروتوكول TCP الأصلي — قيمة متفاوض عليها، في كلا الاتجاهين
في بروتوكول TCP الأصلي، تُستمد قيمة revision من مصافحة Hello. يرسل العميلDBMS_TCP_PROTOCOL_VERSION، ويردّ الخادم بقيمته الخاصة، ومن ثمّ يُجري كل طرف التسلسل وفق قيمة revision التي أعلنها الطرف الآخر: إذ يبني الخادم NativeReader/NativeWriter الخاصين به استنادًا إلى client_tcp_protocol_version، في حين يستخدم العميل قيمة server_revision التي تلقّاها. لا توجد عملية min صريحة، غير أن أيًّا من الطرفين لا يستطيع إرسال ميزة لم يُنفّذها، ولذلك يتحدد سقف كل اتجاه فعليًا بالطرف الأقدم من الطرفين.
عندما يعمل الطرفان على الإصدار الحديث نفسه، يستقر الاتجاهان على قيمة revision نفسها (DBMS_TCP_PROTOCOL_VERSION، راجع src/Core/ProtocolDefines.h) وتكون جميع البوابات مفعّلة. هذه هي الحالة الشائعة، لكنها ليست مضمونة. فإذا اختلفت إصدارات الطرفين أو كان أحدهما من جهة خارجية، فقد يعمل الاتجاهان بقيمتَي revision مختلفتين، ولذا يجب تقييم البوابات لكل اتجاه على حدة: يكون BlockInfo حاضرًا عند أي قيمة revision غير صفرية، أما البقية — بما فيها has_custom_serialization — فلا تظهر إلا حين تبلغ قيمة revision الفعلية لذلك الاتجاه الحدود الخاصة بها. فعلى سبيل المثال، الطرف الذي يعلن قيمة revision أقل من 54454 لا يرسل البايت has_custom_serialization ولا يستقبله.
إخراج FORMAT Native — revision 0 افتراضيًا، مع إمكانية رفعها عبر HTTP
تستخدم صيغة الإخراج Native revision 0 افتراضيًا. ويشمل ذلك SELECT ... FORMAT Native عبر HTTP، وINTO OUTFILE ... FORMAT Native، وإخراج Native الذي يكتبه clickhouse-client؛ ففي كل حالة من هذه الحالات، يمرّر مصنع الإخراج FormatSettings::client_protocol_version مباشرةً إلى NativeWriter، الذي يتحكم في بنية التأطير الخاصة بـ BlockInfo / has_custom_serialization وكذلك في تخطيط String القائم على الإزاحات. وعليه، فإن استعلام SELECT ... FORMAT Native عبر HTTP مع قيمة مرفوعة لـ client_protocol_version (≥ 54492) يكتب التخطيط القائم على الإزاحات، بينما تُبقي revision الافتراضية 0 على التخطيط المحمول الذي تُرمَّز فيه كل قيمة على حدة.
غير أن هذه القيمة الافتراضية ليست نهاية المطاف عند استخدام HTTP، إذ يمكن للعميل رفعها عبر معلمة الاستعلام ?client_protocol_version=<n>، التي يعاملها معالج HTTP على أنها معلمة محجوزة لا إعداد SQL: فهي تُخزَّن في سياق الاستعلام، ثم تنسخها طبقة الصيغة إلى FormatSettings. وعند ضبطها بقيمة مرتفعة بما يكفي، يبدأ إخراج FORMAT Native عبر HTTP بتضمين البادئة BlockInfo والبايت has_custom_serialization، تمامًا كما في مسار TCP — لذا لا تفترض أن حمولة FORMAT Native عبر HTTP تكون دائمًا بـ revision 0.
ولأن المعلمة تُخزَّن في سياق الاستعلام بأكمله، فإنها تصل إلى كل NativeWriter يُنشأ لذلك الطلب، لا إلى دفق النتائج وحده. فعلى سبيل المثال، تؤدي عملية كتابة من جهة الخادم مثل INSERT INTO FUNCTION file('x.native', 'Native', ...) SELECT ... عبر HTTP مع قيمة مرفوعة لـ ?client_protocol_version= إلى كتابة الملف بتلك revision. وهذا سلوك قائم منذ زمن طويل (إذ يسبق ظهور تسلسل String القائم على الإزاحات، وينطبق كذلك على BlockInfo وhas_custom_serialization)، وهو غير متناظر: فجهة القراءة (الموضحة أدناه) لا تُرفع revision الخاصة بها بالطريقة نفسها، ولذلك تتعذّر إعادة قراءة مثل هذا الملف عبر file(...). لا تُضمِّن client_protocol_version في أي طلب يكتب بيانات يُراد قراءتها لاحقًا. أما INTO OUTFILE وإخراج clickhouse-client المحلي فلا يتوفر لهما مثل هذا الخيار، ويبقيان عند revision 0.
إدخال FORMAT Native — دائمًا بـ revision 0
تُنشئ صيغة الإدخال Native قارئها NativeReader دائمًا عند revision 0. فهي لا تتوقع أبدًا بادئة BlockInfo، ولا تقرأ البايت has_custom_serialization إطلاقًا، وتفترض دائمًا التسلسل الافتراضي، سواء أكانت تحلّل متن استعلام INSERT ... FORMAT Native، أم تقرأ ملف Native، أم تستنتج المخطط. ولا يرفع ?client_protocol_version=<n> قيمة revision الإدخال، إذ لا يقرأ هذه المعلمة سوى جانب الإخراج.
ولأن revision الإدخال يساوي 0 دائمًا، فإن صيغة الإدخال تقرأ دائمًا تخطيط String الذي تُرمَّز فيه كل قيمة على حدة؛ أما الدفق المكتوب بتخطيط الإزاحات (عبر بروتوكول TCP الأصلي، أو عبر SELECT ... FORMAT Native مع رفع قيمة client_protocol_version) فلا يمكن قراءته مجددًا عبر إدخال FORMAT Native، وهو عدم التماثل نفسه الذي نجده في تأطير BlockInfo.
تداعيات الكتابة ثم القراءة
بالنسبة إلىFORMAT Native، تكون revision الافتراضية 0 في كلا الطرفين. فالبيانات المكتوبة بواسطة SELECT ... FORMAT Native عند revision 0 يمكن قراءتها مباشرةً في INSERT ... FORMAT Native دون أي مفاجآت.
أما الدفق المُنتَج بـ revision إخراج مرفوعة (?client_protocol_version=<n> في SELECT) فيحمل بنية التأطير BlockInfo وhas_custom_serialization وتخطيط String القائم على الإزاحات، ولا تستطيع صيغة الإدخال قراءة أيٍّ منها لأنها تعمل دائمًا عند revision 0. لذا لا يكون رفع client_protocol_version منطقيًا إلا لمستهلك يتعامل ببروتوكول TCP الأصلي، لا لعملية كتابة ثم قراءة باستخدام FORMAT Native عبر HTTP.
أما الملفات فهي الحالة غير المتناظرة. إذ إن رفع ?client_protocol_version= في عملية INSERT INTO FUNCTION file(...) من جهة الخادم (أو s3، أو url) يكتب الملف بتلك revision، في حين أن قراءة file(...) تحلّل دائمًا عند revision 0، ولذلك يتعذّر قراءة مثل هذا الملف بعد كتابته. والحل إما أن تحذف client_protocol_version من طلب الكتابة ليُكتب الملف بـ revision 0 العادية، وإما أن تنقل البيانات عبر بروتوكول TCP الأصلي، حيث يستخدم كل اتجاه revision المتفاوض عليها أثناء المصافحة.
revision البروتوكول مقابل إصدار التسلسللا تخلط بين revision البروتوكول وإصدار التسلسل. فـ revision هنا تسري على مستوى الاتصال أو الطلب بأكمله، ولا تظهر أبدًا في البايتات. أما إصدار التسلسل فهو خاص بكل عمود، وتحمله الأنواع ذات الإصدارات، ويُكتب في كل كتلة غير فارغة. فـ revision تحدّد ما إذا كانت ميزة ما موجودة أصلًا، بينما يحدّد إصدار التسلسل، داخل العمود ذي الإصدار، أيّ متغيّر من ترميز ذلك النوع بعينه يأتي بعده.
أنواع البيانات
يوثّق هذا القسم ترميز wire للأنواع التي يمكن لصيغة Native حملها ضمنdata الخاصة بالعمود، مقسّمةً إلى أربع عائلات يتزايد فيها تعقيد أداة فك الترميز تدريجيًا. يُعدّ النوعان AggregateFunction(func, ...) وQBit(T, N[, stride]) من أنواع الأعمدة الصالحة في Native، إلا أن لكلٍّ منهما حمولة خاصة بالدالة أو بالنوع تقع خارج نطاق هذا القسم؛ وسننبّه إليهما أدناه في المواضع التي قد يُظنّ فيها خطأً أنهما أسماء بديلة.
الأنواع ذات العرض الثابت
تشغل كل قيمة عددًا ثابتًا من البايتات. ويشغل عمود مكوَّن منM صفًا bytes_per_row × M بايتًا بالضبط في تنسيق النقل، متلاصقةً دون أي فواصل أو حشو.
أنواع الأعداد الصحيحة
تمثّل الأنواعUInt8–UInt256 وInt8–Int256 ترميزات ثنائية مباشرة لقيم الأعداد الصحيحة، إذ تقرأ أداة فك الترميز bytes_per_row × num_rows بايت وتفسّرها وفقًا للنوع.
مثال على عمود من النوع UInt32 يحتوي على القيم [1, 256, 65536]:
Int32 يحتوي على القيم [-1, 42]:
Float32 و Float64
أعداد ذات فاصلة عائمة ثنائية قياسية وفق معيار IEEE 754: بطول 4 بايتات أحادية الدقة (binary32) و8 بايتات بدقة مزدوجة (binary64)، وكلاهما بترتيب little-endian. وتُحفَظ قيم NaN و±Infinity و±0.0 والقيم دون الطبيعية (subnormals) كما هي عند كتابتها ثم قراءتها (round-trip)، دون أي تطبيع.
القيمة 1.5 من النوع Float32 (0x3FC00000):
1.5 من النوع Float64 (0x3FF8000000000000):
BFloat16
صيغة الفاصلة العائمة الدماغية (brain-floating-point): وهي البتات الـ 16 العليا منFloat32 وفق معيار IEEE 754 — بت واحد للإشارة، و8 بتات للأس، و7 بتات للجزء العشري. تشغل كل قيمة بايتين بترتيب little-endian، وتحمل النمط الخام المؤلف من 16 بت. لاستعادة القيمة العددية، وسّعها مجددًا إلى Float32 بوضع النمط في النصف الأعلى وتصفير النصف الأدنى (أي تفسير bits << 16 على أنه Float32)؛ وعندئذٍ تُنسَّق القيمة الموسّعة نصيًا بالطريقة نفسها التي يُنسَّق بها Float32.
قيمة BFloat16 1.5 (النمط 0x3FC0، وهو النصف الأعلى من Float32 0x3FC00000):
Bool
متوافق في تنسيق النقل معUInt8: بايت واحد لكل صف، حيث 0x00 = false و0x01 = true. غير أن سلسلة النوع في تنسيق النقل هي Bool حرفيًا (وليست UInt8)، لذا يجب على أداة فك الترميز التي توجّه المعالجة وفقًا لسلسلة النوع أن تتعرّف عليها بشكل مستقل.
عمود من النوع Bool يحتوي على القيم [true, false, true]:
Date و Date32
يُرمِّز كلاهما التواريخ في صورة عدد صحيح يمثّل عدد الأيام منذ حقبة Unix1970-01-01، ولا يتضمن أيٌّ منهما مكوّنًا زمنيًا.
القيمة
1970-01-02 من النوع Date (يوم واحد):
Date32 1900-01-01 (-25567 يومًا):
DateTime
متوافق على مستوى التمثيل الثنائي (wire) معUInt32: طابع زمني لـ Unix بالثواني، يُخزَّن في 4 بايتات بترتيب little-endian. قد يظهر النوع بالصيغة DateTime أو DateTime('Timezone')؛ ولا تؤثر المنطقة الزمنية إلا في طريقة العرض، فهي ليست جزءًا من القيمة المُرسَلة. وبذلك يُنتج عمودان من النوع DateTime بمعلمات منطقة زمنية مختلفة البايتات نفسها للّحظة الزمنية ذاتها. تُزيل أداة فك الترميز لاحقة المعلمة (...) وتعالج العمود على أنه UInt32.
القيمة 2024-03-15 14:30:00 UTC من النوع DateTime('UTC') (الطابع الزمني 1710513000):
DateTime64(scale[, timezone])
8 بايت: قيمة Int64 بترتيب little-endian تمثّل عدد وحدات tick بمقياس10^-scale ثانية منذ حقبة Unix. تُحدَّد المعلمة scale (من 0 إلى 9) ضمن سلسلة النوع، وهي التي تحدد وحدة الوقت:
يظهر النوع بالصيغة
DateTime64(s) (ضمنيًا بالمنطقة الزمنية الافتراضية للخادم) أو DateTime64(s, 'TimezoneName') (بمنطقة زمنية صريحة تُستخدم للعرض فقط). تمثّل القيم السالبة وحدات tick سابقة للحقبة.
القيمة 2024-01-15 12:30:45.123 UTC من النوع DateTime64(3, 'UTC') (1705321845123 ms):
DateTime64(0) 2024-01-15 12:30:45 UTC (1705321845 ثانية):
Time و Time64(scale)
مدة زمنية بحساب الساعة، لا لحظة زمنية محددة. النوعTime عدد ثوانٍ بإشارة، يُخزَّن في 4 بايتات بصيغة Int32 بترتيب little-endian؛ أما Time64(scale) فعدد tick بإشارة وفق المقياس العشري المحدد (0–9)، يُخزَّن في 8 بايتات بصيغة Int64 بترتيب little-endian، أي بالبنية الثنائية نفسها المستخدمة في DateTime64.
الصيغة النصية هي [-]HH:MM:SS[.fraction]، لكن حقل الساعات، على خلاف DateTime، لا يعود إلى الصفر بعد 24 ساعة، بل يمثّل العدد الإجمالي للساعات وقد يتجاوز 23. الحد الأقصى للمقدار المعروض هو 999:59:59 (أي 3599999 ثانية)؛ وأي مقدار أكبر من ذلك تُعرض ثوانيه عند هذا الحد مع الإبقاء على الكسر المخزَّن كما هو، فعدد tick قدره 3700000.25 مثلًا يُعرض بالشكل 999:59:59.250000. أما عملية CAST فتُقيّد القيمة المخزَّنة بأكبر tick يمكن تمثيله في المقياس الهدف (999:59:59.999999 في حالة Time64(6))، لا بأكبر ثانية كاملة، في حين قد تُخلّف العمليات الحسابية في العمود عدد tick خارج النطاق لا يُطبَّق عليه الحد إلا عند العرض. ولا يؤثر شيء من ذلك في البايتات المنقولة، فهي مجرد العدد الصحيح بإشارة.
القيمة 45296 من النوع Time (12:34:56):
Time64(3) هي 45296789 tick (12:34:56.789):
النوعان
Time وTime64 تجريبيان، ويتطلّب استخدامهما ضبط allow_experimental_time_time64_type = 1 على الخادم.Interval
Interval<Unit> — IntervalSecond وIntervalMinute وIntervalHour وIntervalDay وIntervalWeek وIntervalMonth وIntervalQuarter وIntervalYear وIntervalNanosecond وغيرها. تشترك جميع الوحدات في ترميز wire واحد: يُخزَّن العدد بوصفه Int64 بإشارة (signed) بطول 8 بايت وبترتيب little-endian. ولا تظهر الوحدة إلا في سلسلة النوع، فهي لا تغيّر بايتات wire ولا الشكل النصي، الذي يقتصر على العدد الصحيح وحده. ويكفي مسار واحد في أداة فك الترميز للتعامل مع جميع الوحدات.
القيمة 5 من النوع IntervalDay:
UUID
16 بايت لكل قيمة. ترميز wire ليس البايتات القياسية الستة عشر بترتيب big-endian، بل تُعكس بايتات كلٍّ من النصفين (8 بايت لكل منهما) على حدة. النموذج المنطقي هو معرّف بطول 128 بت بصيغته النصية القياسيةxxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx، حيث تُكتب البايتات عادةً بترتيب big-endian. أما نموذج النقل فيأخذ هذه البايتات القياسية الستة عشر، ويقسمها إلى نصفين طول كلٍّ منهما 8 بايت، ثم يكتب كل نصف بترتيب little-endian:
- البايتات المنقولة من 0 إلى 7 = البايتات القياسية من 0 إلى 7 بترتيب معكوس.
- البايتات المنقولة من 8 إلى 15 = البايتات القياسية من 8 إلى 15 بترتيب معكوس.
550e8400-e29b-41d4-a716-446655440000:
IPv4 وIPv6
نوعان من العناوين مترابطان، لكن لكلٍّ منهما طريقة ترميز مختلفة. يشغلIPv4 أربعة بايتات، ويُرمَّز على هيئة UInt32 بترتيب little-endian يحمل العنوان المعياري ذا 32 بت (أي القيمة (a << 24) | (b << 16) | (c << 8) | d المستخرجة من a.b.c.d). وبذلك تكون البايتات المنقولة (wire bytes) هي بايتات ترتيب الشبكة بترتيب معكوس.
192.168.1.10 (القيمة المعيارية ذات 32 بت هي 0xC0A8010A):
IPv6 مساحة 16 بايت، ويُكتب كما هو بترتيب بايتات الشبكة دون أي تبديل، أي بترتيب البايتات نفسه الذي تُنتجه inet_pton(AF_INET6, ...).
2001:db8::1:
u32 لتيسير العمليات الحسابية وإجراء استعلامات نطاق موجزة، في حين يحتفظ IPv6 بتخطيط ترتيب بايتات الشبكة (network order) المعتمد في معظم واجهات برمجة التطبيقات الخاصة بالشبكات.
Enum8 و Enum16
متوافقان على مستوى التمثيل الثنائي (wire) معInt8 وInt16 على الترتيب: بايت واحد أو بايتان لكل صف، مع تمثيل المتمم الثنائي (two’s complement) بترتيب little-endian في النوع ذي 16 بت. ويرد التعيين الكامل للقيم ضمن سلسلة النوع:
(...) وتتعامل مع القيمة على أنها Int8 / Int16، إذ إن البايتات المنقولة ليست سوى الفهرس العددي الصحيح. أما العميل الذي يعرض الوسم، فيستخرج خريطة 'name' = value من سلسلة النوع ويحتفظ بها إلى جانب العمود، لأن العدد الصحيح وحده لا يكفي لاستعادة الوسم. ويعرض الإخراج النصي الوسم (active) بدلًا من الفهرس، محاطًا بعلامتي اقتباس مفردتين ('active') عندما يكون الـ enum متداخلًا ضمن نوع مركّب. ولمّا كانت الخريطة لا تُستعاد من عمود الأعداد الصحيحة، فلا بد من الاحتفاظ بها في حالة أنواع enum المتداخلة، مثل Array(Enum8(...)) أو Map(Enum16(...), V).
عمود من النوع Enum8('active' = 1, 'inactive' = 2) يحتوي على [active, inactive, active]:
Enum16(...) تساوي 30000:
Decimal(P, S)
عدد صحيح ذو إشارة مضروب في قوة للعدد 10. تُحدِّد الدقةP عرض العدد الصحيح بالبايت ضمنيًا، أما المقياس S فهو الأس السالب (أي عدد الأرقام بعد الفاصلة العشرية). وكلاهما مُضمَّن في سلسلة النوع.
ترميز wire هو العدد الصحيح الأساسي ممثَّلًا بالمتمم الثنائي بترتيب little-endian، أما القيمة العشرية المنطقية فهي
wire_integer × 10^(-S).
يُصدر ClickHouse دائمًا Decimal(P, S) أيًّا كانت طريقة التصريح عن النوع؛ إذ تُوحَّد Decimal32(S) وDecimal64(S) وغيرها جميعًا إلى Decimal(P, S) في تنسيق النقل (مع ضبط P على الحد الأقصى الطبيعي للعرض: 9، 18، 38، 76). وبالتالي فإن أداة فك الترميز التي لا تتعرّف إلا على Decimal(P, S) تغطي جميع الصيغ التي يُصدرها الخادم.
القيمة 123.4567 من النوع Decimal(9, 4) ← العدد الصحيح الأساسي 1234567:
-1.5 من النوع Decimal(18, 1) ← العدد الصحيح الأساسي -15:
123.4567 من النوع Decimal(38, 4) (16 بايت إجمالًا):
Nothing
لا يحمل النوعNothing أي قيم. ولا يظهر عمليًا إلا بوصفه النوع الداخلي لـ Nullable(Nothing)، وهو ما يُرجعه الخادم لتعبير مثل SELECT NULL لا قيمة صالحة له سوى غياب القيمة. ومن الناحية المفاهيمية، فهو نوع وحدة (unit type).
في تنسيق النقل، يشغل هذا النوع بايتًا نائبًا واحدًا لكل صف بالضبط. يُصدر الخادم حرف ASCII '0' (0x30)، لكن أداة إلغاء التسلسل تتجاهل هذه البايتات؛ فمحتواها غير محدد، ويجب ألا تعتمد أدوات فك الترميز على أي قيمة بعينها. ولأن عدد البايتات المكتوبة يساوي num_rows × 1، فإن قيمة num_rows في ترويسة العمود تحدد وحدها مقدار البيانات الواجب قراءتها.
يحافظ تخصيص بايت لكل صف على ثبات خاصية الكتلة: إذ يمتد كل عمود على طول يمكن اشتقاقه من num_rows، فتواصل أدوات فك الترميز الفحص قُدُمًا دون الحاجة إلى بادئات طول لكل خلية. ولأن النوع المحيط Nullable يُبلغ دائمًا عن كل موضع بأنه NULL، فلا تُفحص العناصر النائبة أبدًا.
عمود من النوع Nullable(Nothing) يحتوي على 3 صفوف (جميعها NULL):
Nullable (راجع Nullable)؛ أما البايتات الثلاثة الداخلية فتمثّل حمولة Nothing التي تتخطاها أداة فك الترميز.
الأنواع ذات الطول المتغير
تحمل كل قيمة طولها الخاص بها في تنسيق النقل.String
سلسلة النوع:String. لعمود String تخطيطا wire، ويُختار أحدهما وفقًا لـ revision البروتوكول (راجع ما الذي تتحكم فيه الـ revision). والـ revision هي القيمة المتفاوض عليها في بروتوكول TCP الأصلي، أو قيمة client_protocol_version في حالة إخراج FORMAT Native (القيمة الافتراضية 0)؛ أما الإدخال بصيغة FORMAT Native فيكون دائمًا بالـ revision 0، في حين تستخدم صيغة Buffers دائمًا التخطيط القائم على كل قيمة على حدة.
التخطيط القائم على كل قيمة على حدة — يُستخدم عندما تكون الـ revision أقل من DBMS_MIN_REVISION_WITH_STRING_WITH_SIZE_STREAM_SERIALIZATION (54492)، ويشمل ذلك الـ revision الافتراضية 0 لـ FORMAT Native: ويتألف من سلسلة تضم num_rows من تسلسلات البايتات، كلٌّ منها مسبوق بطوله:
0x00. ويبلغ إجمالي عدد البايتات التي يشغلها العمود Σ (varuint_size(len_i) + len_i) محسوبًا على جميع الصفوف.
مثال لعمود يضم 3 سلاسل نصية ["ab", "", "c"] (بإجمالي 6 بايتات):
54492 وما بعده: دفقان متتاليان، أولهما إزاحات البايتات التراكمية، ثم تليها جميع البيانات:
Array قيم الإزاحة الخاصة به عبر البروتوكول الأصلي. تمثّل الإزاحة i موضع نهاية القيمة i في كتلة البيانات (blob)، ومن ثَمّ يكون حجم القيمة i هو offset[i] - offset[i-1] (حيث offset[-1] = 0)، وتساوي الإزاحة الأخيرة الحجم الكلي لكتلة البيانات. تقرأ أداة فك الترميز 8 × num_rows بايت من قيم الإزاحة، ثم تقرأ كتلة البيانات كاملةً دفعةً واحدة، مستخدمةً الإزاحة الأخيرة طولًا دقيقًا لها، وهذا يتيح حجز المخزن المؤقت مسبقًا بدقة والنسخ المجمّع بدلًا من التحليل صفًّا صفًّا، دون الحاجة إلى أي تحويل من الأحجام إلى قيم الإزاحة. أما العمود الذي يكون فيه num_rows = 0 فلا يُضيف أي بايتات إلى أيٍّ من الدفقين.
السلاسل النصية الثلاث نفسها ["ab", "", "c"] (27 بايت إجمالًا):
String في الكتلة، بما في ذلك String المتداخل داخل الأنواع المركّبة (Array(String)، وNullable(String)، وMap، وTuple، وVariant، وDynamic، وJSON)، باستثناء حالة واحدة: يُكتب قاموس عمود LowCardinality دائمًا بالتسلسل المتداخل الافتراضي، ولذلك تظل قيم String فيه محتفظة بالتخطيط لكل قيمة (per-value layout) في جميع قيم revision. ويشمل ذلك LowCardinality(String)، وLowCardinality(Nullable(String))، وأي قاموس LowCardinality آخر يحتوي على قيم String.
في كلا التخطيطين، يتعامل String في ClickHouse مع البيانات بوصفها بايتات لا نصوصًا: فلا يُشترط أن تكون بترميز UTF-8 صالح، ويمكن أن تحتوي القيمة على أي بايتات، بما فيها محرف NUL المضمّن. لذا، فإن أداة فك الترميز التي تحوّل القيم إلى نوع سلسلة UTF-8 يجب عليها إما أن تتحقق من صلاحيتها عند القراءة، أو أن تمرّر البايتات الخام كما هي إلى الشيفرة المستدعية.
FixedString(N)
سلسلة النوع:FixedString(N)، حيث N عدد صحيح موجب (مثل FixedString(16)). يتكوّن العمود من N × num_rows بايت خام بالضبط، دون بادئات طول ودون فواصل. تستخرج أداة فك الترميز قيمة N من سلسلة النوع، ثم تقرأ هذا العدد من البايتات لكل صف.
عندما يُدرج استعلام SQL قيمة أقصر من N بايت (مثل CAST('abc' AS FixedString(5)))، يُكمل الخادم القيمة من اليمين ببايتات NUL (0x00) حتى تبلغ الطول المُعلَن. وتُعدّ بايتات الحشو هذه جزءًا من القيمة المخزَّنة، وتُرسَل في تنسيق النقل كما هي؛ أما إزالتها فمسؤولية جهة العميل. وكما هو الحال مع String، فإن FixedString(N) أقرب إلى مصفوفة بايتات منه إلى نص، ويُستخدم عادةً للمعرّفات ثابتة العرض، أو بايتات العناوين، أو ملخصات التجزئة.
قيمتان من النوع FixedString(3) هما ["abc", "de\0"] (بإجمالي 6 بايتات):
الأنواع المركبة
تغلّف الأنواع المركبة نوعًا داخليًا واحدًا أو أكثر، وتشترك في نموذج نقل موحّد: عدة دفقات لكل عمود. فالعمود المنطقي الواحد يُرمَّز في صورة تسلسلين أو أكثر من البايتات، يُقرأ كلٌّ منها على حدة، ثم تُوصَل ببعضها على التوالي. وتشترك هذه الأنواع في ثلاث خصائص بنيوية:- شكل ثابت لكل مخطط. تتحدد البنية بالكامل من سلسلة النوع وقت فك الترميز. فالنوع
Array(UInt32)له دائمًا تخطيط الدفقات نفسه في كل كتلة. - لا بادئة إصدار خاصة بها. لا يضيف الغلاف المركب بحد ذاته أي بايت إصدار؛ إذ يظل تأطيره (الإزاحات، وnull-map، ودفقات العناصر) مستقرًا عبر إصدارات ClickHouse. ينطبق هذا على الغلاف فقط — راجع الملاحظة الخاصة بمرحلة البادئة أدناه بشأن الأنواع الداخلية ذات الإصدارات.
- لا حالة خاصة بها عبر الكتل. تأطير الغلاف ذاتي الوصف بالكامل في كل كتلة؛ وأي مسألة تتعلق بالحالة عبر الكتل مصدرها نوع داخلي ذو إصدار، لا الغلاف.
SerializationArray ينفّذ مرحلة البادئة لنوعه الداخلي قبل كتابة إزاحات المصفوفة، وكذلك تفعل الأنواع Tuple وMap وNested وNullable عبر تسلسلات عناصرها (إذ ينفّذ Nullable البادئة الداخلية قبل null map الخاصة به).
ولذلك، عندما يغلّف نوع مركب نوعًا ذا إصدار/حالة (LowCardinality أو Variant أو Dynamic أو JSON)، تُصدَر بادئة الإصدار/الحالة الخاصة بذلك النوع الداخلي أولًا، قبل إزاحات الغلاف وحمولة عناصره. فعلى سبيل المثال، يُرتَّب Array(LowCardinality(String)) على النحو التالي: [LowCardinality state prefix] ← [array offsets] ← [flattened LowCardinality element payload]، لا بالإزاحات أولًا.
وأداة فك الترميز التي تقرأ الإزاحات قبل تنفيذ مرحلة البادئة الداخلية ستفقد التزامن مع أي نوع مركب يحتوي على LowCardinality أو Variant أو Dynamic أو JSON. أما إذا كان كل نوع داخلي نوعًا طرفيًا بسيطًا أو نوعًا مركبًا آخر بلا إصدار، فلا تُصدر مرحلة البادئة أي بايتات، وينطبق الوصف الوارد أدناه، القائم على البدء بالإزاحات، كما هو تمامًا.
Nullable(T)
سلسلة النوع:Nullable(InnerType). أمثلة: Nullable(UInt32)، Nullable(String)، Nullable(FixedString(16))، Nullable(DateTime('UTC')).
على غرار الأنواع المركّبة الأخرى، يُحيل Nullable مرحلة البادئة إلى التسلسل الداخلي للنوع المضمَّن قبل كتابة خريطة القيم الفارغة (null map): فإذا كان النوع الداخلي ذا إصدار، تُرسَل بادئة الحالة الخاصة به أولًا. ولذلك يبدأ Nullable(Tuple(LowCardinality(String))) ببادئة الحالة الخاصة بـ LowCardinality، لا بخريطة القيم الفارغة. أما إذا كان النوع الداخلي نوعًا طرفيًا (leaf) أو نوعًا آخر غير ذي إصدار، فلا تُرسِل مرحلة البادئة أي بايتات.
يتألف تخطيط wire من مرحلة البادئة الداخلية (وتكون فارغة ما لم يكن النوع الداخلي ذا إصدار)، يليها دفقان متتاليان تتقدّمهما خريطة القيم الفارغة (null-map):
num_rows بايت بالضبط، أي بايت واحد لكل صف:
يحتوي دفق القيم على الترميز القياسي للنوع الداخلي لـجميع الصفوف البالغ عددها
num_rows، بما فيها مواضع القيم الفارغة. وعلى أداة فك الترميز أن تقرأ بايتات العنصر النائب في مواضع القيم الفارغة كي تتقدّم في الدفق، لكن عليها الرجوع إلى null-map قبل تفسير أي قيمة بعينها. ونظرًا لأن المُرسِلين قد يكتبون أي بايتات في مواضع القيم الفارغة، فلا يجوز لأدوات فك الترميز الاعتماد على قيمة محددة للعنصر النائب.
قيم العنصر النائب بحسب عائلة النوع الداخلي:
يمكن أن يرد
Nullable(T) داخل Array وTuple وMap وNested، ومن الشائع استخدام Array(Nullable(T)) وTuple(Nullable(T1), T2). غير أنه لا يمكن تداخل قابلية القيم الفارغة مع نفسها، إذ يرفض الخادم Nullable(Nullable(T)).
مثال على Nullable(UInt8) يضم ثلاثة صفوف [5, NULL, 9] (بإجمالي 6 بايتات):
Nullable(String) يحتوي على ثلاثة صفوف ["hello", NULL, "world"] (بإجمالي 15 بايت):
Array(T)
سلسلة النوع:Array(InnerType). أمثلة: Array(UInt32) وArray(String) وArray(Nullable(UInt32)) وArray(Array(UInt8)).
يتكوّن تخطيط wire من مرحلة البادئة الخاصة بالنوع الداخلي (وتكون فارغة ما لم يكن النوع الداخلي ذا إصدار)، يليها دفقان متتاليان، أولهما دفق الإزاحات:
num_rows قيمة UInt64 بالضبط بترتيب little-endian، وتمثّل كل قيمة منها موضع النهاية التراكمي في دفق القيم بعد عناصر ذلك الصف:
- فهرس بداية العناصر للصف
N=offsets[N - 1](أو0عندما يكونN == 0). - فهرس نهاية العناصر (غير شامل) للصف
N=offsets[N]. - عدد عناصر الصف
N=offsets[N] - offsets[N - 1].
offsets[num_rows - 1] إجمالي عدد العناصر في جميع الصفوف، ويضمّ دفق القيم هذا العدد من القيم الداخلية متتاليةً دون فواصل.
الإزاحات رتيبة غير متناقصة؛ فتساوي إزاحتين متتاليتين يدل على صف فارغ، وينبغي لأداة فك الترميز أن ترفض الإزاحات غير الرتيبة وتعدّها تلفًا في البيانات. أما العمود الفارغ (num_rows == 0) فلا يُكتب له أي بايت، فلا يوجد دفق إزاحات ولا دفق قيم. ويجوز أن تكون الأنواع الداخلية من أي نوع، بما في ذلك الأنواع المركّبة الأخرى؛ فالأنواع Array(Array(T)) وArray(Tuple(...)) وArray(Nullable(T)) كلها صالحة.
Array(UInt32) بالصفوف [[10, 20, 30], [], [40, 50]] (44 بايت إجمالًا):
0 للصف 0). وتدل الإزاحتان المتتاليتان المتساويتان على صف فارغ:

Array(String) بالصفوف [["a", "bb"], []] (20 بايت إجمالًا):
Array(Array(UInt32)) ذو الصفوف [[[1,2]], [], [[3], [4,5]]] البنيةَ نفسها على نحوٍ متداخل:
- الإزاحات الخارجية:
[1, 1, 3]؛ إذ يحتوي الصف 0 على مصفوفة داخلية واحدة، والصف 1 على 0، والصف 2 على 2. - يفكّ
Array(UInt32)الأوسط ترميز 3 صفوف بالإزاحات[2, 3, 5]. - يفكّ
UInt32الأعمق ترميز 5 قيم:[1, 2, 3, 4, 5].
Tuple(T1, T2, …)
سلسلة النوع:Tuple(T1, T2, ..., Tn). أمثلة: Tuple(UInt32, String)، Tuple(Int32)، Tuple(Array(UInt32), String)، Tuple(UInt8, Tuple(Int32, String)). يدعم ClickHouse أيضًا Tuple المسمّاة باستخدام Tuple(a UInt32, b String)؛ والأسماء مجرد بيانات وصفية لا تؤثر في تنسيق wire.
يتكوّن تخطيط wire من مرحلة البادئة الخاصة بالعناصر (إذ يضيف كل عنصر ذي إصدار بادئة الحالة الخاصة به وفق ترتيب التصريح، وتكون هذه المرحلة فارغة للعناصر التي لا إصدار لها)، تليها N من التدفقات المتتالية، بواقع تدفق واحد لكل نوع عنصر، وفق ترتيب التصريح:
num_rows قيمةً بالضبط. لا توجد بادئة طول، ولا دفق إزاحات، ولا فواصل بين الدفقات. ولا يُكتب للعمود الفارغ (num_rows == 0) أي بايت في أي دفق. ويمكن أن تكون أنواع العناصر من أي نوع، بما في ذلك الأنواع المركّبة الأخرى؛ إذ إن Tuple(Tuple(...), ...) وTuple(Array(...), ...) وTuple(Nullable(T1), T2) كلها صالحة.
والـ Tuple الخالي من العناصر Tuple() صالح أيضًا، وهو ينشأ من تعبيرات مثل SELECT tuple() أو CAST(x AS Tuple()). ولأنه لا يملك دفقات عناصر، فإنه يُسلسَل بطريقة Nothing نفسها: بايت عنصر نائب واحد (0x30، أي '0' في ASCII) لكل صف، تتجاهله أداة إلغاء التسلسل. ويُستمد عدد الصفوف من ترويسة الكتلة، تمامًا كما في Nothing.
Tuple(UInt8, UInt8) مع 3 صفوف (1,4), (2,5), (3,6):
[1, 2, 3] للعنصر 0 والقيمة [4, 5, 6] للعنصر 1.
Tuple(UInt32, String) بصفّين (10, "a") و(20, "bb") (بإجمالي 13 بايت):
Map(K, V)
سلسلة النوع:Map(KeyType, ValueType). أمثلة: Map(String, UInt32)، Map(String, Array(UInt32))، Map(UInt8, Tuple(Int32, String))، Map(Array(String), Int8). لا يفرض تنسيق wire أي قيد على أيٍّ من النوعين، إذ يمكن أن يكون كلٌّ من K وV أيَّ نوع مدعوم، بما في ذلك الأنواع المركّبة. (اختلفت قواعد ClickHouse على مستوى SQL بشأن أنواع المفاتيح المقبولة من إصدار إلى آخر؛ لذا راجع توثيق SQL الخاص بإصدار الخادم المستهدف.)
يتطابق تخطيط wire بايتًا ببايت مع Array(Tuple(K, V))، ولذلك يبدأ بـمرحلة البادئة الداخلية (وتكون فارغة ما لم يكن K أو V ذا إصدار تسلسل):
total_pairs = offsets[num_rows - 1] (أو 0 عندما يكون num_rows == 0). ولدفق الإزاحات الدلالات نفسها المتبعة في Array. والمفاتيح محاذاة موضعيًا مع القيم: فالزوج i هو (keys[i], values[i]).
يُمثِّل ClickHouse عمود Map في الذاكرة على هيئة مصفوفة من صفوف مرتبة، غير أن نظام الأنواع يعرضه نوعًا مستقلًا لتيسير الاستخدام في SQL (m['key']، mapKeys، mapValues). وتنسيق wire ما هو إلا تسلسل مباشر لهذا التخزين، ولذلك فإن Map وArray(Tuple(K, V)) قابلان للتبادل بايتًا ببايت.
الإزاحات رتيبة غير متناقصة، ويحتوي كلٌّ من دفق المفاتيح ودفق القيم على total_pairs قيمة بالضبط. ولا يُكتب أي بايت للعمود الفارغ. وعادةً ما تكون المفاتيح مميزة ضمن الصف الواحد، لكن هذه قاعدة دلالية لا يفرضها تنسيق wire: إذ يسمح تنسيق wire بكتابة المفاتيح المكررة وقراءتها مجددًا دون تغيير، ولا تُحسم التكرارات وفق الدلالات على جانب الخادم إلا عندما تستهلك الصفَّ دالةٌ تتعامل مع Map.
Map(UInt8, UInt8) مع صفين {1:10, 2:20}، {3:30} (22 بايت إجمالًا):
i بقراءة keys[i] وvalues[i] معًا.
النوع Map(String, UInt32) مع صف واحد {'a':1, 'b':2} (بإجمالي 20 بايت):
Nested(name1 T1, name2 T2, …)
يتوقف تمثيلNested أثناء النقل على قيمة الإعداد flatten_nested من جهة الخادم، ما يُفضي إلى حالتين مختلفتين.

flatten_nested = 1 (القيمة الافتراضية للخادم). إذا أُنشئ الجدول بالإعدادات الافتراضية، فإن Nested ليس نوعًا من أنواع النقل (wire type)، إذ يخزّن الخادم العمود ويعرضه على هيئة N من أعمدة Array(T_i) المتوازية ذات أسماء منقّطة (outer.field1 وouter.field2 وهكذا). وبالنسبة لطبقة الصيغة لا جديد في ذلك، فكل عمود منقّط ما هو إلا Array عادية:
flatten_nested = 0. عند إنشاء الجدول باستخدام flatten_nested = 0، يظهر العمود في تنسيق النقل عمودًا واحدًا سلسلةُ نوعه Nested(name1 T1, name2 T2, ...)، ويكون تخطيطه بعد سلسلة النوع مطابقًا بايتًا ببايت لـ Array(Tuple(T1, T2, ..., Tn))، بما في ذلك مرحلة البادئة الداخلية، ومن ثَمّ فإن أي حقل ذي إصدار T_i يُصدِر بادئة الحالة الخاصة به أولًا، قبل الإزاحات. يستخدم المثال أدناه حقولًا بلا إصدار، ولذلك تكون مرحلة البادئة فارغة:
Nested بأسماء الحقول (a، b)، بينما لا يحمل Array(Tuple) هذه الأسماء كخانات مسمّاة.
سلسلة النوع في الحالة B هي قائمة من أزواج (الاسم، النوع) مفصولة بفواصل. تفصل أول مسافة بيضاء بين الاسم ونوعه؛ وقد يحتوي النوع نفسه على مزيد من المسافات البيضاء والفواصل والأقواس، لذا يتطلب التحليل أداة التقسيم نفسها المستخدمة مع Tuple، والتي تراعي مستوى التداخل. تخطيط wire:
total_elements = offsets[num_rows - 1] (أو 0 عندما تكون num_rows == 0). قيم الإزاحة رتيبة غير متناقصة، ويحتوي كل دفق حقل على total_elements قيمة بالضبط. ويشترط الخادم عند الإدراج (INSERT) أن تحمل جميع الحقول داخل الصف الواحد العدد نفسه من العناصر. أما العمود الفارغ فلا يُكتب له أي بايت.
Nested(a UInt8, b String) بصفين هما [(10,'x'),(20,'y')] و[(30,'z')] (25 بايت بعد سلسلة النوع):
الأسماء البديلة للأنواع
بعض الأنواع ليست سوى أسماء بديلة بحتة: إذ يرسل الخادم الاسم البديل في ترويسة العمود، غير أن البايتات التي تليه تعود إلى نوع أساسي. تربط أداة فك الترميز الاسم البديل بذلك النوع وتعيد استخدام المرمِّز الخاص به، دون الحاجة إلى أي تنسيق wire جديد. الأنواع الجغرافية أسماء بديلة لمصفوفات وصفوف مرتبة متداخلة:
وبذلك يُفك ترميز عمود
Point تمامًا كما يُفك ترميز Tuple(Float64, Float64) (ويُعرض بالشكل (1,2))، ويُفك ترميز Ring كما يُفك ترميز Array(Tuple(Float64, Float64)) ([(0,0),(1,1)])، وهكذا صعودًا في التسلسل الهرمي.
Geometry هو أيضًا اسم بديل، لكنه بديل لـ Variant لا لمصفوفة متداخلة: فحمولته هي الـ variant المكوّن من الأنواع الجغرافية السبعة المذكورة أعلاه. لا تحمل ترويسة العمود سوى سلسلة النوع Geometry، ولا تفصّل مكونات الـ variant، لذا يتعيّن على أداة فك الترميز توسيعه بنفسها. وخلافًا لـ Variant الذي يكتبه المستخدم صراحةً، فإن مميّزات Geometry لا تتبع الترتيب الأبجدي للأسماء: فهي ثابتة لضمان التوافق، ولا تُضاف الأنواع الجغرافية الجديدة إلا في النهاية. والتعيين كالتالي: 0 = LineString، 1 = MultiLineString، 2 = MultiPolygon، 3 = Point، 4 = Polygon، 5 = Ring، 6 = MultiPoint (أُضيف في الإصدار 26.7، ولذا لن تحتوي التدفقات الواردة من الخوادم الأقدم على المميّز 6 أصلًا). ثم يُفك ترميز كل قيمة مختارة عبر اسمها الجغرافي البديل المذكور أعلاه (وتستخدم NULL مميّز NULL الخاص بـ Variant، وهو 255).
SimpleAggregateFunction(func, T) اسم بديل لنوع قيمته T. فهو يخزّن قيمة تجميعية مكتملة مسبقًا، ولذلك فإن صيغته على wire وطريقة عرضه مطابقتان تمامًا لـ T (إذ يُفك ترميز SimpleAggregateFunction(sum, UInt64) على أنه UInt64). ولا يُعدّ اسمًا بديلًا بهذه الطريقة إلا الشكل ذو نوع القيمة الواحد؛ وقد يكون النوع الأساسي نفسه نوعًا مركّبًا.
ثمة نوعان مرتبطان ليسا اسمين بديلين، رغم أنهما نوعا أعمدة
Native صالحان، إذ يمكن للعميل مثلاً أن يستقبل عمود AggregateFunction ناتجاً عن مُركِّب -State أو عن تجميع موزّع. غير أن لكل منهما حمولته المتخصصة التي تقع خارج نطاق هذه الصفحة:- يحتفظ
AggregateFunction(func, ...)بحالة تجميع وسيطة (لا بقيمة نهائية)، ويختلف تخطيطها الثنائي باختلاف الدالة التجميعية وإصدارها. وقد يتضمن اسم الدالة معلمات، تُطبع كل منها في صورة قيمة حرفية SQL، كما فيAggregateFunction(topK(3), UInt64). وتطبع بعض الدوال التجميعية لاحقة::Typeصريحة على المعلمة، كما فيAggregateFunction(topK('3'::Decimal32(0)), UInt64)، لأن قيمDecimalوالأعداد الصحيحة الواسعة لا صيغة حرفية مجردة لها تصلح للطباعة؛ وقيام الدالة بذلك من عدمه سمةٌ للدالة نفسها لا للتنسيق، لذا يجب على أداة فك الترميز التي تفحص المعلمات أن تقبل الصيغتين كلتيهما. أما أداة فك الترميز التي لا تحتاج إلا إلى أنواع الوسيطات فلا تتأثر بذلك. وتكتب الدالة التجميعية ذات الإصدارات إصدار الحالة غير الصفري صراحةً بوصفه الوسيطة الأولى في سلسلة النوع —AggregateFunction(1, quantileDeterministic, UInt64, UInt64)— في حين لا يُطبع الإصدار0، فتبقى سلسلة النوعAggregateFunction(quantileDeterministic, UInt64, UInt64). وفي الاتصال المتفاوض عليه (أي حين تكون revision أعلى من0)، يحدد الكاتب الإصدار استناداً إلى مراجعة البروتوكول وحدها، ولا يعتمد مطلقاً على إصدار قد يكون مثبّتاً على نوعه المحلي، لأن الإصدار0لا يمكن تمييزه أثناء النقل عن “عدم الإعلان عن أي إصدار”. ولهذا قد تعلن الدالة نفسها عن إصدارات مختلفة باختلاف الاتصالات، ويجب على القارئ أن يستمد الإصدار من سلسلة النوع لا أن يفترض أنه الأحدث. أما عند revision0فلا يوجد نظير يُشتق له إصدار، إذ يصف الدفق نفسه بنفسه ويقرؤه من كتبه؛ لذا يبقى الإصدار المثبّت على النوع محفوظاً في الدفق (وفي سلسلة النوع المُعلنة) ولا يُخفَض إلى0. وتصبحquantileDeterministic(ومعها الصيغتانquantilesDeterministicوmedianDeterministic) ذات إصدارات بدءاً من revision54491: إذ يضيف الإصدار1حقلاًUInt8 skip_degreeفي نهاية الحالة، بينما تبقى بادئتها مطابقة بايتاً ببايت للإصدار0. وفيما دون هذه المراجعة يُصدر الكاتب حالات بالإصدار0، فلا يتأثر النظير الأقدم. وتصبحuniqذات إصدارات بدءاً من revision54493: إذ يضيف الإصدار1بايت أعلام في بداية تخطيط الإصدار0، وعندما يكون البت0من الأعلام مضبوطاً، يُلحق في النهاية عيّنة إضافية صغيرة من قيم التجزئة الكاملة بطول 64 بت، تُطبَّق على كل منها عملية XOR مع الثابت0x9E3779B97F4A0000(UInt8 skip_degree، ثمVarUInt count، ثم القيم ذات الثمانية بايتات)، مما يحافظ على دقة التقدير عند الأعداد الأصلية التي تتجاوز ملياري قيمة؛ أما حالة الإصدار0فلا تحمل سوى العيّنة الرئيسية بطول 32 بت. ويشير البت1من الأعلام إلى أن عيّنة 64 بت غير مكتملة، أي أن الحالة قد استوعبت حالة بالإصدار0لم تُعرض عناصرها قط على عيّنة 64 بت؛ وعندئذٍ لا تُكتب أي عيّنة 64 بت، ويجب على القارئ أن يُجري التقدير استناداً إلى العيّنة الرئيسية بطول 32 بت وحدها (وأن يواصل الإشارة إلى ذلك في الحالات التي يكتبها لاحقاً)؛ ولذا فالبتان متنافيان، ويجب على القارئ رفض أي حالة تضبط كليهما. ولا يوجد أي بت آخر معرَّف، ويجب على القارئ رفض أي حالة تضبط بتاً غيرهما. وينطبق ذلك كله أيضاً على الحالة المتداخلة داخل نوع حاوٍ، مثلArrayأوTupleأوMapأوNullableأوVariant(حيث يغيّر الإصدار المكتوب صراحةً للبديل سلسلة النوع دون أن يغيّر ترتيب المميّز)، أو داخل غلافNested/SimpleAggregateFunction. كما ينطبق على مُركِّبات الدوال التجميعية: فالمُركِّب التمريري المطبَّق على دالة ذات إصدارات — مثلuniqIfوuniqArrayوuniqForEachوuniqMapوuniqArgMin/uniqArgMaxوuniqOrNull/uniqOrDefaultوuniqResample، والمُكيِّف الضمني لوسيطاتNullable— يمرّر الإصدار إلى الحالات المتداخلة التي تتضمنها حمولته، ويكتب الإصدار الحالي للدالة المتداخلة صراحةً في سلسلة نوع الحالة الجديدة تماماً كما تفعل الدالة المجردة (AggregateFunction(1, uniqIf, UInt64, UInt8))، ويشتق الإصدار من مراجعة البروتوكول في الاتصال المتفاوض عليه بالطريقة نفسها. ولأن الاسم المخصص للغلاف يتضمن نسخة ثانية من نوع وسيطته، يجب على الكاتب إعادة بناء تلك النسخة بإصدار حالة التجميع المتفاوض عليه نفسه، وإلا فقد تعلن ترويسته عن الإصدار1لحمولة مكتوبة بالإصدار0. وثمة حالة حدّية واحدة فيVariantلا يمكن تمثيلها أثناء النقل إطلاقاً: فعندما يختلف بديلان في إصدار الحالة المكتوب صراحةً فقط (وهو أمر جائز في نوع العمود، لأن سلسلتي النوع مختلفتان)، فإن إعادة تعيين إصدار كليهما وفق المراجعة المتفاوض عليها ستدمجهما في بديل واحد، لذا يجب على المُرسل أن يُفشل عملية النقل بدلاً من الإعلان عن إصدارات لم تُكتب بها الحمولة. ويُعدDynamicاستثناءً مقصوداً من قاعدة اشتقاق الإصدار من المراجعة: إذ يعلن عمودDynamicعن أنواعه المتداخلة بنفسه، عبر قوائم أنواعه الخاصة وعبر الترميز الثنائي للأنواع، الذي يتضمن حقل إصدار صريحاً يحمل إصدار الحالة المكتوب صراحةً (وبهذه الطريقة تُضمِّن قيم المتغير المشترك نوعها حتى أثناء التخزين). وهذه الإعلانات جزء من بيانات العمود ذاتها، وهي متطابقة لكل نظير ولكل وسيط، لذا لا يُعاد كتابتها أبداً وفق مراجعة متفاوض عليها. وعليه، تُكتب الحالة المتداخلة داخل قيمةDynamicبالإصدار المحدد في نوعها (وهو ما تحمله الحالة الجديدة، لأن نتيجة استعلام-Stateتكتب إصدارها صراحةً)، وبالإصدار0إذا لم يحدد النوع أي إصدار، وذلك على جميع الاتصالات والوسائط دون استثناء. - يخزّن
QBit(T, N[, stride])متجهاً بعد تبديل مستويات البتات فيه، لخدمة أحمال عمل البحث المتجهي. ويرد توثيق تخطيط دفقه أثناء النقل (دفقات مستويات بتاتFixedStringمرتبة حسب المجموعة أولاً، وعددهاelement_size * (N / stride)عند تحديدstrideصراحةً) وترميزه الثنائي للنوع (الوسم0x36، أو0x37QBitWithStrideعندما يكونstride != N) في صفحة نوع البياناتQBitوفي المرجع الخاص بـالترميز الثنائي للأنواع، فلا يضطر قارئNativeإلى استخلاصها من شيفرة C++ المصدرية.
الأنواع ذات الإصدارات
تحمل الأنواع ذات الإصدارات، ضمن التمثيل المُرسَل عبر الاتصال، بادئةً تُحدِّد إصدار التسلسل وتُبيّن أيّ صيغة من صيغ الترميز تليها. وقد تستخدم أيضًا عدة تدفقات (كما هو الحال في الأنواع المركّبة). وفي تمثيلNative المُرسَل عبر الاتصال، تكون البادئة وأي قاموس مرتبطَين بكل كتلة على حدة، إذ لا تحتفظ هذه الأنواع بأي حالة تمتد عبر الكتل (راجع ملاحظة البادئة الخاصة بكل كتلة أدناه)؛ أما حالة التسلسل الممتدة عبر الكتل فلا توجد إلا في الدفق المخزَّن على القرص الخاص بـ MergeTree.
هذه الأنواع أكثر تعقيدًا بكثير من الأنواع المركّبة ذات البنية الثابتة، ولذا يمكن للعميل الذي يستهدف استعلامات تحليلية بسيطة أن يؤجّل دعمها.
إصدار التسلسل: المفهوم
إصدار التسلسل هو رقم إصدار يُنقل ضمن البيانات المُرسَلة، ويكون خاصًا بكل نوع وبكل عمود، ويحدد أيّ صيغة من صيغ ترميز النوع يستخدمها المُرسِل. وهو أول ما يرد في بادئة الحالة الخاصة بالعمود، فتقرؤه أداة فك الترميز أولًا ثم توجّه بقية العمود إلى المحلّل المناسب. وهو يختلف عن إصدار البروتوكول:
تكتب معظم الأنواع ذات الإصدارات رقمَ الإصدار بصيغة UInt64 بترتيب little-endian مباشرةً قبل أي بيانات أخرى في بادئة الحالة، بينما يستخدم عدد قليل منها VarUInt أو UInt8. تقرأ أداة فك الترميز الإصدار أولًا وترفض القيم غير المعروفة؛ إذ يدل الإصدار الأعلى على صيغة أحدث لدى المُرسِل لا تفهمها أداة فك الترميز، وأي خطأ في تحليلها يُفسد كل البايتات التالية.
تُرسَل بادئة الحالة في بداية كل كتلة يزيد عدد صفوفها على صفر، مباشرةً قبل حمولة تلك الكتلة.
لا يحتفظ كاتب Native وقارئه بحالة التسلسل بين الكتل: إذ ينشئ
NativeWriter حالة تسلسل جديدة ويكتب بادئة حالة لكل كتلة عمود غير فارغة يكتبها، وينشئ NativeReader حالة إلغاء تسلسل جديدة ويقرؤها لكل كتلة غير فارغة يقرؤها (ويتخطى كلاهما البادئة كليًا عندما يكون rows == 0).
ولذلك لا تُرسِل كتل الترويسة (rows = 0) والكتل الفارغة أي شيء، ويجب على أداة فك الترميز أن تقرأ بادئة الحالة من جديد في بداية كل كتلة غير فارغة. أما أداة فك الترميز التي تقرأ البادئة مرة واحدة فقط وتعدّ الكتل اللاحقة حمولةً بلا بادئة، فستقرأ بادئة الكتلة التالية على أنها بيانات وتفقد التزامن:

مرجع إصدارات التسلسل
ملاحظات جديرة بالانتباه حول هذا الجدول:
- القيم ليست متتالية. يستخدم
Dynamicالقيم1و2و3و4، إذ تقابلV3القيمة4وتقابلFLATTENEDالقيمة3. فالرقم الأعلى لا يعني بالضرورة إصدارًا أحدث. - بعض القيم خاصة بصيغة Native فقط. توجد
Object::STRINGوObject::FLATTENEDوDynamic::FLATTENEDلضمان التوافق عبر البروتوكول الأصلي مع العملاء الذين لا يدعمون Object/Dynamic دعمًا كاملًا. ولا تظهر هذه القيم في التخزين على القرص في MergeTree. V3خاص بالتخزين على القرص فقط. أُدرجObject::V3وDynamic::V3لأن قيمتيهما مخصّصتان ولا يجوز إعادة استخدامهما، لا لأن أداة فك الترميز قد تصادفهما: فـClickHouse لا يرسلهما أبدًا عبرNative، بل يرفضهما فيه مع الخطأINCORRECT_DATA. وكل ما يحملانه — الإصدار الفرعي لمنطقة shared-data فيObject::V3وتخطيطات الحاويات والحبيبات الخاصة به — يصف جزء بيانات لا كتلة، ولذلك يقع خارج نطاق هذه الصفحة. أما العملاء الذين يستهلكون بروتوكول TCP الأصلي فلا يرون سوىV1أوV2أوSTRINGأوFLATTENED.
LowCardinality(T)
أبسط الأنواع ذات الإصدارات، إذ يستبدل column مكوّنًا منN من القيم الداخلية بقاموس صغير من القيم المميزة، إضافةً إلى N من الفهارس التي تشير إلى هذا القاموس.
سلسلة النوع: LowCardinality(InnerType). أمثلة: LowCardinality(String)، LowCardinality(FixedString(4))، LowCardinality(Nullable(String)).
sharedDictionariesWithAdditionalKeys؛ أما القيم الأخرى فمحجوزة.
البيانات الوصفية لكل كتلة من نوع UInt64، وهي حقل بتات:
في الاستجابة المعتادة لاستعلام يتضمن كتلة بيانات واحدة لكل column، تكون قيمة البيانات الوصفية
0x600 (HasAdditionalKeys + NeedUpdateDictionary).
قيم القاموس هي dict_size قيمةً مُرمَّزة باستخدام النوع الداخلي T. ويحجز القاموس الخانات الأولى للقيم الخاصة: فالـ column غير القابل للقيم الفارغة يحجز خانة واحدة (تحتوي dict[0] على القيمة الافتراضية للنوع الداخلي، مثل "" في حالة String)، وتبدأ القيم المميزة الفعلية من dict[1].
أما في حالة LowCardinality(Nullable(T)) فيظل القاموس مُرمَّزًا بالنوع T العادي (دون دفق null-map)، لكن تُحجز خانتان اثنتان: dict[0] هي وسم NULL وdict[1] هي القيمة الافتراضية للنوع الداخلي (مثل "" في حالة String)؛ وتبدأ القيم المميزة الفعلية من dict[2]. ويشير مفتاح الصف ذي القيمة NULL إلى dict[0]، وتُكتب تلك الخانة في تنسيق النقل بالبايتات الافتراضية للنوع الداخلي.
المفاتيح فهارس تشير إلى مواضع في القاموس؛ وحجم كل فهرس 1 << key_type_code بايت (1 أو 2 أو 4 أو 8)، وتُعاد بناء القيمة N بوصفها dict[keys[N]].
يمثل keys_count عدد قيم LowCardinality في المستوى التعاودي الحالي، وليس بالضرورة عدد صفوف الكتلة. ففي column LowCardinality على المستوى الأعلى يتساوى العددان. أما عندما يكون LowCardinality متداخلًا ضمن نوع مركّب، فإن العدد هو عدد القيم المسطّحة الذي يمرّره النوع المركّب إلى المستوى الأدنى: ففي Array(LowCardinality(String)) بثلاثة صفوف تضم خمسة عناصر إجمالًا، تكون قيمة keys_count هي 5 لا 3؛ وفي Map(K, LowCardinality(V)) تساوي إجمالي عدد الأزواج، وهكذا. ويجب على أداة فك الترميز أن تأخذ keys_count من هذا الحقل بدلًا من افتراض أنه يساوي عدد صفوف الكتلة. وعندما يكون هذا العدد المسطّح صفرًا — كما في كتلة جميع مصفوفاتها فارغة — فإن مرحلة بيانات LowCardinality لا تكتب شيئًا على الإطلاق: إذ لا توجد سوى بادئة الحالة (المُصدَرة في مرحلة البادئة للأنواع المركّبة)، دون أن تليها بيانات وصفية أو قاموس أو keys_count.
تُقرأ بادئة الحالة في بداية كل كتلة يزيد عدد صفوفها على صفر، أما كتل الترويسة (rows = 0) والكتل الفارغة فلا يُكتب لها شيء. وداخل الكتلة، تساوي قيمة keys_count عدد الصفوف، وتساوي قيمة dict_size عدد القيم في دفق القاموس، ويشغل كل مفتاح 1 << key_type_code بايت.
في صيغة
Native، تحمل كل كتلة قاموسًا مستقلًا خاصًا بها — إذ لا توجد حالة قاموس مشتركة بين الكتل. يضبط Native writer القيمة low_cardinality_max_dictionary_size = 0، ولذلك لا ينشئ SerializationLowCardinality قاموسًا مشتركًا أبدًا: فكل كتلة غير فارغة تكتب مفاتيحها بوصفها مفاتيح إضافية خاصة بالكتلة دون ضبط NeedGlobalDictionaryBit (البيانات الوصفية 0x600)، ويرفض Native reader البت NeedGlobalDictionaryBit عندما تكون قيمة native_format هي true. لذا يجب على أداة فك الترميز إعادة تعيين القاموس عند كل كتلة وقراءة عناصر dict_size الموجودة في تلك الكتلة؛ إذ إن الإبقاء على قاموس من كتلة سابقة سيؤدي إلى قراءة مفاتيح الكتلة التالية قراءةً خاطئة. (الاحتفاظ بقاموس LC عبر الكتل مسألة تخص تخزين MergeTree على القرص، ولا علاقة لها بتخطيط wire لصيغة Native.)LowCardinality(String) مع القيم ['a', 'b', 'a', 'c', 'b']:
dict[1], dict[2], dict[1], dict[3], dict[2] = ["a", "b", "a", "c", "b"].
يُظهر النوع LowCardinality(Nullable(String)) مع القيم ['a', NULL, '', 'b'] الخانتين المحجوزتين كلتيهما — dict[0] لقيمة NULL، وdict[1] للقيمة الافتراضية المتمثلة في الـ string الفارغ:
dict[2] = "a"، وdict[0] = NULL، وdict[1] = ""، وdict[3] = "b"، أي ["a", NULL, "", "b"]. وكلٌّ من dict[0] وdict[1] بايتاتٌ فارغة في تنسيق النقل؛ فكون القيمة NULL ناتجٌ عن إشارة المفتاح إلى الخانة 0، لا عن البايتات نفسها.
JSON (Tier 1: String fallback)
يدعم النوعJSON في ClickHouse عدة أشكال من ترميز wire (راجع مرجع إصدارات التسلسل). ويُعد Tier 1 أبسطها: فعند ضبط إعداد الاستعلام output_format_native_write_json_as_string = 1، يحوّل الخادم كل قيمة JSON إلى تمثيلها النصي المُسلسَل، ويُخرج الـ column من النوع String مسبوقًا بوسم الحالة.
سلسلة النوع: JSON.
1. أما القيم الأخرى فتشير إلى ترميزات مختلفة لـ JSON/Object: 0 = V1، و2 = V2 (الترميز الافتراضي عبر بروتوكول TCP الأصلي)، و3 = FLATTENED، و4 = V3 (راجع مرجع إصدارات التسلسل). فإذا صادفت أداة فك الترميز هنا قيمة غير 1، فهذا يعني أن البيانات التي تعالجها ليست بصيغة String fallback. تُقرأ البادئة في بداية كل كتلة يزيد عدد صفوفها على 0، أما دفق القيم فهو column قياسي من نوع String يضم num_rows صفًا.
قيمة JSON هي '{"a":1}' (صف واحد):
{"a":1}، مع إبقاء العدد الصحيح عددًا صحيحًا. ولأن هذا النص مجرد قيمة من النوع String، يتلقى العميل JSON لتمريره كما هو دون تفسير محتواه، لكنه لا يستعيد المسارات الفردية وأنواع ClickHouse الخاصة بها؛ فالحفاظ الدقيق على نوع كل مسار يتطلب استخدام ترميز Tier 2 الموضح أدناه.
Variant(T1, T2, …)
اتحاد مُميَّز: يحمل كل صف قيمةً من نوع واحد فقط من الأنواع البديلة، أو NULL. ويحمل كل صف مميّزًا عامًا بحجم بايت واحد يحدد نوعه، ثم تُخزَّن القيم الخاصة بكل نوع بشكل متراص، بحيث يُخصَّص لكل نوع بديل تتابع واحد متصل. سلسلة النوع:Variant(T1, T2, ...). يُوحِّد الخادم الترتيب إلى صيغته القياسية (إذ تُرتَّب الأنواع البديلة حسب الاسم)، ولذلك تأتي سلسلة النوع المستقبَلة وقد سُردت فيها الأنواع وفق ترتيب المميّز العام: فالمميّز 0 يختار النوع الأول في القائمة، و1 يختار النوع الثاني، وهكذا. أما 255 (NULL_DISCRIMINATOR) فيعني أن قيمة الصف NULL. لا تكون عناصر Variant من النوع Nullable أبدًا، لأن تمثيل NULL من مهام المميّز. أمثلة: Variant(String, UInt64)، Variant(Array(UInt8), String).
تحمل بادئة الحالة وضع المميّزات بصيغة UInt64 LE: القيمة 0 = BASIC (يُكتب مميّز كل صف كما هو)، والقيمة 1 = COMPACT (ترميز الحبيبات بطول التتابع). يستخدم الخادم افتراضيًا الوضع BASIC عبر البروتوكول الأصلي (use_compact_variant_discriminators_serialization = false)، ولا يتناول هذا المستند سوى الوضع BASIC.
r ذو المميّز d (≠ 255) القيمة الموجودة عند الفهرس counter[d] من سلسلة قيم النوع البديل d، ثم يُزاد counter[d] بمقدار واحد. أما الصفوف ذات المميّز 255 فهي NULL ولا تستهلك أي قيمة من أي سلسلة، ولذلك يساوي مجموع العدّادات الخاصة بالأنواع عددَ الصفوف غير NULL.
تُقرأ بادئة الحالة (وهي قيمة UInt64 الخاصة بالوضع) في بداية كل كتلة يزيد عدد صفوفها على 0؛ أما كتل الترويسة والكتل الفارغة فلا تُصدر شيئًا. ويكون كل مميّز غير NULL أصغر من عدد الأنواع البديلة، ويُفكّ ترميز النوع البديل i لعدد count[i] من الصفوف بالضبط.
عناصر Variant التي تكون هي نفسها ذات حالة (
LowCardinality وVariant وDynamic وJSON) تُصدر بادئة الحالة الخاصة بها في مرحلة بادئة الحالة الخاصة بكل عنصر، بعد قيمة UInt64 الخاصة بالوضع. أما الأنواع الطرفية والأنواع المركّبة البسيطة (Array وTuple وMap المكوّنة من أنواع طرفية) فبادئات حالتها فارغة، ويمكن تركيبها بحرية.Variant(String, UInt64) بالقيم [42, 'hi', NULL] (يضع الترتيب القياسي String قبل UInt64، ولذلك فإن المميّز 0 = String، و1 = UInt64):
42؛ الصف 1 = String run[0] = "hi"؛ الصف 2 = NULL.
يؤدي دفق المميّزات دور الفهرس؛ إذ يسحب كل مميّز غير NULL القيمة التالية من التسلسل الكثيف الخاص بنوعه، في حين لا يستهلك 255 (NULL) أي قيمة. وبالطريقة نفسها يُعاد بناء Dynamic، الذي لا يختلف إلا في كيفية ترميز NULL:

Dynamic
column يُكتشف نوع قيمته في وقت التشغيل: يحتوي كل صف على قيمة من أحد أنواع مجموعةٍ تُحدَّد في وقت التشغيل، أو على NULL. وعلى خلافVariant، فإن مجموعة الأنواع ليست جزءًا من سلسلة النوع الخاصة بالـ column — بل تُحمَل في بادئة الحالة.
سلسلة النوع: Dynamic أو Dynamic(max_types=N). تحدّد المعلمة max_types الحد الأقصى لعدد الأنواع المميزة التي يتتبعها الـ column، لكنها لا تؤثر في تنسيق wire الموضّح أدناه.
لـ Dynamic أربعة أشكال ترميز: V1 = 1، وV2 = 2، وFLATTENED = 3، وV3 = 4. ويتوقف الشكل الذي يُصدره الخادم على القناة وعلى إعدادات الاستعلام:
- عبر
clickhouse-clientوFORMAT Nativeفي HTTP، تكون قيمة revision لدى الكاتب0(ما لم تُرفع باستخدامclient_protocol_version)، ولذلك يكون الشكل الافتراضي V1. - عبر بروتوكول TCP الأصلي عند قيمة revision المتفاوض عليها، يكون الشكل الافتراضي V2. يُبقي كاتب
Nativeالإحصاءات معطّلة، لذا لا تحمل حمولةV2الافتراضية أي إحصاءات لكل نوع، إذ تلي قائمةَ الأنواع مباشرةً بادئةُVariantالمتداخلة ثم البيانات. (الإحصاءات لكل نوع شأن يخص تخزين MergeTree على القرص، وليست جزءًا من تنسيق wire الخاص بـ Native.) - يتجاوز إعداد الاستعلام
output_format_native_use_flattened_dynamic_and_json_serialization = 1كلتا الحالتين، ويُصدر FLATTENED (الإصدار 3) بصرف النظر عن قيمة revision.
النطاقتقتصر هذه الصفحة على توصيف التخطيط
FLATTENED. أما التخطيطات الثنائية غير المسطّحة V1/V2/V3 فهي التمثيل الداخلي/المخزّن على القرص (قوائم أنواع مُرمَّزة ثنائيًا، وإحصاءات لكل نوع)، ولا تُوصَف هنا. ويجب على العميل الذي يريد فك ترميز Dynamic استنادًا إلى هذه الصفحة أن يطلب FLATTENED عبر ضبط output_format_native_use_flattened_dynamic_and_json_serialization = 1؛ إذ يفترض التخطيط أدناه تفعيل هذا الإعداد. ولأن بايت الإصدار يتصدّر البادئة، تستطيع أداة فك الترميز اكتشاف الترميز الفعلي الذي تلقّته، ورفض V1/V2/V3 إن كانت لا تدعم سوى FLATTENED.num_types من الأنواع إضافةً إلى خانة NULL — أي UInt8 عندما يكون num_types ≤ 255، ثم UInt16، ثم UInt32، ثم UInt64. وتُمثَّل NULL بقيمة المميّز num_types نفسها، بخلاف Variant حيث تُمثَّل NULL بالقيمة الثابتة 255. أما إعادة البناء فتتم بالمرور الكثيف نفسه المتّبع في Variant: احتفظ بعدّاد لكل نوع، فيأخذ الصف r ذو المميّز d (≠ num_types) القيمة counter[d] من تتابع قيم النوع d.
تُقرأ بادئة الحالة (الإصدار + قائمة الأنواع) في بداية كل كتلة يكون عدد صفوفها > 0؛ أما كتل الترويسة والكتل الفارغة فلا تُصدر شيئًا.
عدد مشوّهتُقرأ قيمة
num_types من الدفق قبل أي أسماء أنواع. ويجب على أداة فك الترميز أن تعاملها على أنها غير موثوقة، وألّا تستخدمها مباشرةً لتحديد حجم تخصيص الذاكرة — سواء كان العدد قريبًا من SIZE_MAX (مما قد يسبب تجاوزًا في العمليات الحسابية الوسيطة أو يُطلق استثناءً من غير نوع DB::Exception)، أو كان عددًا كبيرًا لكنه قابل للتمثيل مثل 100000000 (وهو أقل بكثير من max_size() الخاص بالمتجه، لكنه سيخصّص جيجابايتات من الذاكرة قبل قراءة اسم نوع واحد). يقرأ ClickHouse قائمة الأنواع عنصرًا تلو الآخر، ولا يستخدم سوى تلميح تخصيص مسبق محدود السقف، لذا تُرفض قيمة num_types التالفة إما بالخطأ INCORRECT_DATA (“Dynamic column has too many types”، عندما يتجاوز العدد ما تستطيع الحاوية استيعابه)، أو كخطأ قراءة عادي حين تنفد عناصر الأنواع من الدفق — ولا تُرفض أبدًا بسبب نفاد الذاكرة.لكن لا تقيّد قيمة num_types المسطّحة بالحد ColumnDynamic::MAX_DYNAMIC_TYPES_LIMIT (وقيمته 254 في ClickHouse): إذ تضم قائمة الأنواع المسطّحة كل نوع وقت تشغيل مميّز، بما في ذلك الأنواع التي فاضت إلى المتغيّر المشترك، لذا قد تسرد كتلة مسطّحة صالحة عددًا يفوق ذلك بكثير دون أن يكون ذلك خطأً. ولا ينطبق الحد MAX_DYNAMIC_TYPES_LIMIT إلا على العدد num_dynamic_types في البادئات غير المسطّحة V1/V2/V3، وهو عدد خانات المتغيّرات العادية المقيّد بهذا الحد (يتحقق منه ClickHouse هناك قبل إضافة + 1 الخاصة بالمتغيّر المشترك).الدفقات المقتطعةتحتوي سلسلة المميّزات على
num_rows عنصرًا بالضبط، ويجب على أداة فك الترميز التحقق من أنها قرأت هذا العدد كاملًا بدلًا من قبول ما يتضمنه الدفق أيًّا كان. فالسلسلة الأقصر لا تتعارض مع أي شيء يليها: إذ تُشتق عدّادات القيم لكل نوع من المميّزات المقروءة فعليًا، فتبقى متّسقة مع سلاسل القيم، كما أن السلسلة المكوّنة بالكامل من وسوم NULL لا تتطلب أي بايتات قيم على الإطلاق. ولهذا يرفض ClickHouse سلسلة المميّزات القصيرة بالخطأ INCORRECT_DATA (“Mismatch in flattened Dynamic column: N rows are expected, but the indexes stream contains only M rows”) قبل أي فحص خاص بكل نوع، لأن الـ column المُعاد بناؤه سيُستخدم حينها على أنه يضم num_rows من الصفوف بغض النظر عن حجمه الفعلي.تحمل أنواع وقت التشغيل ذات التسلسل الحافظ للحالة (
LowCardinality وVariant وDynamic وJSON) بادئات حالة متداخلة بعد قائمة أسماء الأنواع.Variant — إذ تُكتب خانات المتغيّرات العادية بترتيب DataTypeVariant (حسب اسم النوع)، فلا يطابق ترتيب الإرسال ترتيب الإدراج. غير أنها ليست دائمًا مرتّبة ترتيبًا شاملًا: فالأنواع التي فاضت إلى المتغيّر المشترك (كما في حالة Dynamic(max_types=N) مثلًا) تُضاف بعد الخانات العادية بحسب ترتيب ظهورها الأول، مما قد يُخلّ بترتيب أسماء الأنواع في ذيل القائمة. لذلك يجب على أداة فك الترميز أن تعتمد قائمة الأنواع المُرسلة مرجعًا لتعيين المميّزات، وألّا تعيد ترتيبها بنفسها. أما ما لا يجوز أن تحتويه القائمة فهو نوع مكرّر أو Nothing: فالعنصران المتساويان يُعيَّنان إلى مميّز واحد، وNothing ليس متغيّرًا أصلًا، وفي كلتا الحالتين لا يبقى لجزء من القائمة موضع يُعاد بناؤه فيه. ويرفض ClickHouse كلتا الحالتين بالخطأ INCORRECT_DATA. فمثلًا، في الصفوف [42::UInt64, "hi", NULL] يكون النوعان هما String وUInt64، ويسبق "String" القيمة "UInt64" في الترتيب، لذا تكون المميّزات 0 = String و1 = UInt64 و2 = NULL:
42؛ الصف 1 = String run[0] = "hi"؛ الصف 2 = NULL. تتبع السلاسل الخاصة بكل نوع ترتيبَ التسلسل الثنائي نفسه المتبع في قائمة الأنواع (String قبل UInt64).
JSON (Tier 2: FLATTENED Object)
هذا هو الترميز الأغنى لـ JSON: فبدلًا من تسطيح كل قيمة وتحويلها إلى نص (Tier 1)، يُقسَّم الـ column إلى columns فرعية، column فرعي لكل مسار JSON. ويُختار هذا الترميز عبر عدم طلب الحل البديل الخاص بـ Tier 1 (output_format_native_write_json_as_string = 0) مع تفعيل علامة التسلسل المسطّح (output_format_native_use_flattened_dynamic_and_json_serialization = 1)؛ وعندها يُصدر الخادم التسلسل بالإصدار 3.
هناك نوعان من المسارات:
- المسارات محددة النوع: تُعرَّف في سلسلة النوع، مثل
JSON(a UInt32, b String)، ويُفك ترميزها وفق النوع المُعرَّف لها. ويُحاط اسم المسار الذي يحتوي على نقاط بعلامات backtick في سلسلة النوع. - المسارات الديناميكية: تُكتشف أثناء وقت التشغيل، ويُفك ترميز كل منها بوصفه column من نوع Dynamic.
num_rows من القيم.
Dynamic الخاصة بالمسار الديناميكي (في مرحلة البادئة) منفصلةً عن بياناته (في مرحلة البيانات). تُقرأ بادئة الحالة في بداية كل كتلة يزيد عدد صفوفها على 0، ويحتوي كل column مسار (مُحدَّد النوع أو ديناميكي) على num_rows قيمة بالضبط. يُجمَّع كائن الصف r بقراءة قيمة كل مسار عند الفهرس r؛ أما المسار الديناميكي الذي يكون مميّز Dynamic الخاص به NULL في ذلك الصف فلا يُضيف أي مفتاح. يجب أن تكون أسماء المسارات الديناميكية فريدة، ولا يجوز أن يكرر أيٌّ منها مسارًا صرّحت به سلسلة النوع مسبقًا على أنه مُحدَّد النوع — إذ يؤدي كلا الأمرين إلى وجود قيمتين تحت مفتاح واحد في الكائن المُجمَّع، ولذلك يرفض ClickHouse الحالتين كلتيهما بالخطأ INCORRECT_DATA. وينطبق الأمر نفسه على قائمة المسارات الديناميكية في الترميزات غير المسطّحة.
أعداد مشوّهةيُقرأ
num_dynamic_paths في تخطيط FLATTENED الموثَّق هنا — وكذلك عدد المسارات الديناميكية في الترميزين غير المسطّحين V1/V2 — من الدفق قبل أسماء المسارات. (لا يوجد حقل مستقل للمسارات المسطّحة في بادئات الترميزات غير المسطّحة: فهي لا تحمل سوى عدد المسارات الديناميكية، إضافةً إلى قيمة max_dynamic_paths في V1 تُقرأ ثم تُهمَل.) وكما هو الحال مع Dynamic، يجب على أداة فك الترميز أن تعدّ هذه الأعداد غير موثوقة، وألا تعتمد عليها مباشرةً في تحديد حجم أي تخصيص للذاكرة — سواء كان العدد من فئة SIZE_MAX أو عددًا كبيرًا لكنه قابل للتمثيل. يقرأ ClickHouse أسماء المسارات إدخالًا تلو الآخر، مكتفيًا بتلميح تخصيص مسبق ذي حد أقصى، لذا يُرفض العدد التالف إما بالخطأ INCORRECT_DATA (“JSON/Object column has too many paths”، عندما يتجاوز ما تستطيع الحاوية استيعابه) أو بخطأ قراءة عادي عند نفاد أسماء المسارات من الدفق.دفقات مقتطعةتُعدّ قيم
num_rows لكل column مسار شرطًا إلزاميًا لا حدًا أعلى: يجب على أداة فك الترميز أن تتحقق من مطابقة كل column مسار، مُحدَّد النوع كان أو ديناميكيًا، لعدد صفوف الكتلة، بدلًا من الوثوق بأن الدفق سينتهي في موضعه الصحيح. فليس للكائن المُجمَّع حجم مستقل يمكن المقارنة به — إذ إن عدد صفوفه هو ببساطة ما تنتجه عملية إلغاء التسطيح — لذا يتعذّر لاحقًا اكتشاف column المسار الناقص، وتتجاوز قراءةُ الصف r منه نهايتَه. يرفضه ClickHouse بالخطأ INCORRECT_DATA (“Unexpected size of typed path / flattened path p in flattened Object column”). وفي حالة المسار الديناميكي، يأتي هذا التحقق إضافةً إلى التحقق المكافئ داخل column Dynamic الخاص به.JSON هي {"a": 42, "b": "hi"} (صف واحد، وكلا المسارين ديناميكيان). يُستنتج نوع العدد الصحيح في JSON على أنه Int64:
JSON غير المسطّح (V1/V2)
ترميزاتObject غير المسطّحة هي ما يُرسله الخادم عبر الشبكة عندما تكون علامة التسطيح معطّلة: إذ يُستخدم V1 عبر clickhouse-client / HTTP FORMAT Native (revision 0)، ويُستخدم V2 عبر بروتوكول TCP الأصلي. (أما V3، وهو الترميز الثالث غير المسطّح، فلا يُكتب إلا في أجزاء بيانات MergeTree، ويُرفض عند نقله عبر الشبكة.) تتضمن هذه الترميزات عمود بيانات مشتركة (shared-data)، ولا تتناولها هذه الصفحة. لاحظ أنها لا تتضمن إحصاءات لكل مسار عند النقل بصيغة Native: إذ يُبقي NativeWriter الإحصاءات معطّلة، فلا تحتوي بادئة البنية الخاصة بـ Object على قسم للإحصاءات، وتليها مباشرةً بايتات البادئات والبيانات الخاصة بالمسارات محددة النوع (typed) والديناميكية (dynamic) والبيانات المشتركة (shared-data). ولا تظهر الإحصاءات إلا في مسارات MergeTree على القرص التي تُفعّلها. ولفك ترميز عمود JSON اعتمادًا على هذه الصفحة، يجب على العميل اختيار أحد المستويات الموثّقة: عيّن output_format_native_write_json_as_string = 1 لاستخدام الرجوع إلى String، أو output_format_native_use_flattened_dynamic_and_json_serialization = 1 (مع output_format_native_write_json_as_string = 0) لاستخدام تخطيط FLATTENED Object.
إطار الضغط
يستطيع ClickHouse ضغط بيانات الأعمدة في دفقNative باستخدام صيغة إطار داخلية. وتخطيط الإطار الموضح أدناه مستقل عن وسيلة النقل، إذ تظهر الإطارات نفسها في بروتوكول TCP الأصلي وعبر HTTP على حد سواء، غير أن طريقة طلب الضغط وما يحيط بالإطارات يختلفان باختلاف وسيلة النقل.
- بروتوكول TCP الأصلي. الضغط اختياري ويُفعَّل لكل استعلام على حدة عبر العلامة
compressionفي حزمة الاستعلام. وعند تفعيله، يُغلَّف متن كل حزمة من الحزمDataوTotalsوExtremesوLogوProfileEvents، أي البايتات التي تلي سلسلةtable_name، بصيغة الإطار. أما غلاف الحزمة نفسه، المؤلف من رمز نوع الحزمة وسلسلةtable_name، فهو غير مضغوط، إذ يكتبهما الخادم في الدفق الخام. وكل ما يُصدرهNativeWriterيدخل في الدفق المضغوط، ولذا تكون البادئةBlockInfoأول ما يرد داخل الإطار، إلى جانب الأبعاد والأعمدة. ومن ثَمّ يجب على العميل فك ضغط الإطار قبل أن يتمكن من قراءةBlockInfo. - HTTP. يُغلِّف
SELECT ... FORMAT Native&compress=1دفق بايتاتFORMAT Nativeبأكمله بالإطارات نفسها (إذ يستخدم الخادمCompressedWriteBufferالداخلي ذاته)، ويتوقع?decompress=1الإطارات نفسها في متن إدخال بصيغةNative، فيفك ترميزها عبرCompressedReadBufferالمقابل. ولا وجود في هذا المسار لنوع حزمة TCP ولا لـtable_nameولا لغلاف الحزمة، فالحمولة المضغوطة بأكملها ليست سوى كتلNativeمؤطَّرة (ولا تظهر البادئةBlockInfoإلا إذا كانت قيمة revision المتفاوض عليها أكبر من0، تمامًا كما في التخطيط غير المضغوط أعلاه). ويختلف هذا التأطير الداخليcompress/decompressعن ضغط النقل في HTTP (Content-Encoding: gzip/zstd، المُفعَّل عبرenable_http_compression)، الذي يُغلِّف الاستجابة على مستوى طبقة HTTP ولا علاقة له بصيغة الإطار الموضحة أدناه.
FORMAT Native غير المضغوط لا بد له من إضافة طبقة الإطار هذه كي يتمكن من قراءة استجابة Native مضغوطة عبر HTTP، أو من إرسال متن طلب مع decompress=1.
صيغة الإطار
16 + compressed_size = 16 + 9 + body_size = 25 + body_size. لاحظ وجود نطاقين مختلفين: تغطي قيمة التحقق الترويسة البالغة 9 بايتات إضافةً إلى المتن، بينما يشمل compressed_size الترويسة والمتن دون قيمة التحقق نفسها:

قيم بايت الطريقة
هذه المرمِّزات الثلاثة هي التي يُنتجها الخادم عند تأطيرNative للدفق بأكمله: يستخدم إخراج HTTP مع compress=1 المرمِّز الافتراضي للخادم (ZSTD(3))، بينما يستخدم بروتوكول TCP الأصلي أحد المرمِّزات LZ4 أو ZSTD أو NONE بحسب قيمة network_compression_method. ولا يحتاج أي عميل Native عام إلا إلى إنتاج هذه المرمِّزات واستهلاكها.
يُرمِّز بايت الطريقة أيضًا المرمِّزات على مستوى العمود. تُطبَّق هذه المرمِّزات على كل عمود على حدة في مسارات MergeTree على القرص، لا على تأطير الدفق بأكمله، غير أن مسار إدخال HTTP مع
decompress=1 يأخذ المرمِّز من بايت الطريقة في كل إطار، ولذا قد يظهر أيٌّ من هذه البايتات في الإدخال بصورة مشروعة. لذلك يجب على أي أداة فك الترميز متوافقة أن تتعرّف على النطاق المُخصَّص بأكمله، وأن ترفض أي بايت لا تدعمه بدلًا من أن تُسيء قراءة المتن. أما متون هذه المرمِّزات فخاصة بكل مرمِّز وتقع خارج نطاق عقد الإطار العام هذا:
يخزّن
DoubleDelta (0x94) الفروق من الدرجة الثانية على هيئة بتات بادئة/إشارة/مقدار بترتيب البت الأعلى أولًا (MSB-first). ويحافظ فك الترميز القائم على الكلمات على هذا الترميز وعلى عدد العناصر والحشو في البايت الأخير.
0x9c (ALP) مرمِّز تجريبي (beta) بلا فقدان لـ Float32 وFloat64 (ضغط تكيّفي بلا فقدان للأعداد ذات الفاصلة العائمة). لا يمكن إنشاء جدول باستخدام CODEC(ALP) إلا عند تفعيل الإعداد enable_alp_codec، لكن بايت الطريقة يُقبَل دائمًا عند فك الضغط لتظل البيانات المكتوبة سابقًا قابلة للقراءة. أما 0x9d (SZ3) فهو مرمِّز تجريبي مع فقدان ومحدود الخطأ لـ Float32 وFloat64 وArray من هذين النوعين. لا يمكن إنشاء جدول باستخدام CODEC(SZ3) إلا عند تفعيل الإعداد enable_sz3_codec، لكن بايت الطريقة يُقبَل دائمًا عند فك الضغط لتظل البيانات المكتوبة سابقًا قابلة للقراءة. أما 0x9f (ZXC) فهو مرمِّز LZ تجريبي غير متناظر: بطيء في الضغط لكنه سريع جدًا في فك الضغط، بنسبة ضغط تقع بين LZ4 وZSTD. لا يمكن إنشاء جدول باستخدام CODEC(ZXC) إلا عند تفعيل الإعداد enable_zxc_codec، لكن بايت الطريقة يُقبَل دائمًا عند فك الضغط لتظل البيانات المكتوبة سابقًا قابلة للقراءة. وقد خُصِّص البايتان 0x99 (DeflateQpl) و0x9b (ZSTD_QPL) لمرمِّزات أُزيلت لاحقًا؛ وهما محجوزان ولا يُعاد استخدامهما.
0x9e (Quantized) مرمِّز أعمدة تجريبي لأعمدة المتجهات الكثيفة (Array(Float32) وما شابهها). وهو، مثل NONE، مرمِّز تمريري — إذ يُخزَّن المتن بدقته الكاملة كما هو — لكن وجوده يُلحق تسلسلًا يكتب دفقًا مرافقًا مُكمَّمًا ومدمجًا يُستخدم لتسريع البحث المتجهي. لا يمكن إنشاء جدول باستخدام CODEC(Quantized(...)) إلا عند تفعيل الإعداد enable_quantized_codec، ويُقبَل بايت الطريقة دائمًا عند فك الضغط.
قيمة التحقق
يستخدم ClickHouse الإصدار CityHash v1.0.2 (الإصدار التاريخي)، وليس إصدار Google CityHash الحديث؛ إذ يُنتج كلٌّ منهما مخرجات مختلفة عن الآخر. تُحسب قيمة التحقق على بايتات الترويسة التسعة (method + compressed_size + uncompressed_size) إضافةً إلى بايتات المتن البالغ عددها N، أي كل ما يقع بين قيمة التحقق ونهاية الإطار. تمثّل البايتات الثمانية الأولى من ناتج CityHash128 البالغ طوله 16 بايتًا النصفَ المنخفض (LE)، بينما تمثّل البايتات الثمانية التالية النصفَ المرتفع (LE). تعيد أداة فك الترميز حساب CityHash128 على الترويسة والمتن المستلمَين، ثم تقارن النتيجة بالبايتات الستة عشر الأولى؛ وأي عدم تطابق يُعدّ تلفًا في البيانات، فيفشل فك الترميز.حدود كل كتلة
الحمولة المضغوطة لكتلة هي دفق من إطار واحد أو أكثر، وليست بالضرورة إطارًا واحدًا. يكتب المُرسِل الكتلة المُسلسَلة عبرCompressedWriteBuffer الذي يُصدر إطارًا كلما امتلأ مخزنه المؤقت الداخلي (≈1 MB، DBMS_DEFAULT_BUFFER_SIZE)، ثم إطارًا أخيرًا عند تفريغ الكتلة. وعليه، تشغل الكتلة الصغيرة إطارًا واحدًا، بينما تشغل الكتلة الكبيرة عدة إطارات متتالية.
يسري هذا الثابت في اتجاه واحد فقط: فبما أن المُرسِل يُفرّغ المخزن المؤقت المضغوط في نهاية كل كتلة، فإن نهاية كل كتلة تتطابق مع حدّ إطار — غير أن العكس لا يصح. فحدّ الإطار الوسيط، الذي يُصدَر عند امتلاء المخزن المؤقت أثناء الكتلة، يقع في منتصف الكتلة ولا يُعدّ حدًّا لها. لذا يجب على أداة فك الترميز أن تعتمد على أبعاد الكتلة نفسها (num_columns/num_rows) لتحديد موضع انتهائها، ولا يجوز لها أن تفترض أن كل إطار يمثّل كتلة كاملة.
يستقبل المُستقبِل الإطارات على هيئة دفق: يقرأ 16 + 9 بايت، ثم يقرأ compressed_size - 9 بايت بالضبط من المتن، ويفك ضغطها إلى uncompressed_size بايت بالضبط، ثم يمرّر هذه البايتات إلى أداة فك ترميز الكتل؛ وعندما تحتاج أداة فك الترميز إلى بيانات تتجاوز ما يحويه الإطار الحالي، يسحب الإطار التالي. ولأن المُرسِل يُفرّغ البيانات عند نهاية كل كتلة، يصبح المخزن المؤقت للإطار فارغًا بعد اكتمال فك ترميز الكتلة، وتبدأ الكتلة التالية في إطار جديد.
في بروتوكول TCP الأصلي، يُكتب غلاف الحزمة — أي قيمة VarUInt التي تحدد نوع الحزمة وسلسلة table_name من النوع string — في الدفق الخام، خارج الحمولة المضغوطة؛ ولا يُؤطَّر سوى متن الكتلة (BlockInfo + الأعمدة). أما مسار compress/decompress عبر HTTP فلا يتضمن مثل هذا الغلاف، إذ يتكوّن الدفق بأكمله من كتل مؤطَّرة.
التفاوض
في بروتوكول TCP الأصلي، يُحدَّد الضغط لكل استعلام على حدة لا لكل اتصال. إذ يطلب الحقلcompression: bool في حزمة الاستعلام الضغطَ لذلك الاستعلام وحده. فيلبّي الخادم الطلب ويُرسل متون Data/Totals/Extremes/Log/ProfileEvents مضغوطةً طوال مدة الاستعلام (أما Log/ProfileEvents فبدءًا من الإصدار v54481 فقط). كما يتوقع أن تكون كتل Data الصادرة من العميل — الجداول الخارجية، ووسم نهاية البيانات الفارغ، وصفوف INSERT — مؤطَّرةً بالطريقة نفسها. وقد تختلف الاستعلامات اللاحقة على الاتصال نفسه في ذلك.
أما عبر HTTP فلا توجد حزمة الاستعلام: إذ تختار معلمة الاستعلام compress=1 إخراجًا مؤطَّرًا لذلك الطلب، بينما تُشير decompress=1 إلى أن متن الطلب مؤطَّر. ويُكتب إخراج compress=1 باستخدام المرمِّز الافتراضي للخادم (ZSTD(3)) لا باستخدام network_compression_method؛ في حين يستخلص قارئ decompress=1 المرمِّزَ من بايت الطريقة في كل إطار، ولذا يُقبل أي مرمِّز في الإدخال.
عند تفعيل الضغط، قد يمرّر الخادم الأعمدة أيضًا عبر مسار التنظيم المتوازي للكتل /
ColumnBLOB (PARALLEL_BLOCK_MARSHALLING، الإصدار v54478) في الكتل التي تحتوي على أكثر من صف واحد. لذا يجب على أي تنفيذ يضغط بيانات INSERT أن يكون مستعدًا للتعامل مع هذا المسار (أو أن يعطّله صراحةً) تجنبًا لاختلال تزامن الدفق.مسرد المصطلحات
كتلة — وحدة تبادل البيانات في صيغة Native، وهي كتلة ذاتية الوصف من الصفوف المخزّنة بشكل عمودي. راجع بنية الكتلة والعمود. BlockInfo — ترويسة البيانات الوصفية التي تسبق الكتلة في مسار حزم Data عبر TCP (وتُكتب متى كانت revision الاتصال أكبر من صفر). وهي سلسلة من الحقول الموسومة بمعرّفات حقول، ويتوقف وجود كلٍّ منها على الـ revision. لا يتضمنها تنسيق الإخراجNative، إذ يُجري التسلسل عند revision 0. راجع BlockInfo.
متن العمود — بايتات العمود التي تحمل القيم الفعلية، وتأتي بعد ترويسة العمود (الاسم، والنوع، وبايت has_custom_serialization). ويختلف تخطيطه باختلاف النوع. راجع تخطيط العمود على الشبكة.
النوع المركّب — نوع مبني من نوع داخلي واحد أو أكثر، ويُرمَّز على هيئة عدة تدفقات لكل عمود. صيغة النقل الخاصة به مستقرة ولا تخضع لإصدارات. راجع الأنواع المركّبة.
القاموس (LowCardinality) — مصفوفة القيم الفريدة التي يشير إليها عمود LowCardinality(T) عبر فهارس عددية صحيحة. راجع LowCardinality.
الكتلة الفارغة — كتلة تكون فيها num_columns = 0 وnum_rows = 0. تُستخدم قيمةً حارسة: فهي وسم لنهاية الإدخال من جهة العميل، ووسم لحدود الدفق من جهة الخادم. راجع بدائل الكتلة.
كتلة الترويسة — كتلة تكون فيها num_columns > 0 وnum_rows = 0، يرسلها الخادم أولَ حزمة Data في استجابة الاستعلام، وتُعلن عن مخطط النتيجة. راجع بدائل الكتلة.
النوع الداخلي — النوع الذي يغلّفه النوع المركّب. فالنوع الداخلي لـ Array(UInt32) هو UInt32، والنوع الداخلي لـ Nullable(T) هو T.
دفق الإزاحات — مصفوفة UInt64 من مواضع النهاية التراكمية، تستخدمها Array وMap وNested لتحديد حدود العناصر في كل صف. راجع Array.
القيمة البديلة المؤقتة — البايتات المكتوبة في مواضع القيم الفارغة ضمن دفق القيم لعمود Nullable(T). تقرؤها أداة فك الترميز لتتقدّم في الدفق، لكنها تتجاهل محتواها. راجع Nullable.
كتلة النتيجة — كتلة تكون فيها num_rows > 0، وتحمل صفوفاً فعلية من نتيجة الاستعلام. راجع بدائل الكتلة.
كتلة المخطط — مرادف لكتلة الترويسة، يُستخدم عند وصف مرحلة INSERT، حيث تُبلغ كتلة المخطط العميلَ بأشكال الأعمدة المتوقعة.
إصدار التسلسل — رقم إصدار خاص بكل نوع يُنقل عبر الشبكة، تستخدمه الأنواع ذات الإصدارات للإعلان عن بديل الترميز الذي يليه. ويختلف عن إصدار البروتوكول. راجع إصدار التسلسل: المفهوم.
بادئة الحالة — البايتات التي تسبق حمولة الكتلة في الأنواع ذات الإصدارات. تحمل إصدار التسلسل و(في حالة LowCardinality) البيانات الوصفية للقاموس الخاصة بكل كتلة. تُرسَل في بداية كل كتلة يزيد عدد صفوفها على 0، ولا يُحتفظ بها من كتلة إلى أخرى.
الدفق — سلسلة متصلة من البايتات داخل متن العمود، ترمّز مكوّناً فرعياً منطقياً واحداً (خريطة القيم الفارغة، أو مصفوفة الإزاحات، أو دفق القيم). وتضمّ الأنواع متعددة التدفقات دفقين أو أكثر متتاليين لكل عمود.