الانتقال إلى المحتوى الرئيسي

المقدمة

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

مفاهيم ClickHouse

قبل تطبيق أي من تحسينات الأداء الموضَّحة في هذا الدليل، من المهم الإلمام ببعض مفاهيم ClickHouse الأساسية. في ClickStack، يرتبط كل مصدر بيانات مباشرةً بجدول ClickHouse واحد أو أكثر. عند استخدام OpenTelemetry، ينشئ ClickStack ويدير مجموعة من الجداول الافتراضية التي تخزّن بيانات السجلات والتتبعات والمقاييس. إذا كنت تستخدم مخططات مخصّصة أو تدير جداولك بنفسك، فقد تكون هذه المفاهيم مألوفة لديك بالفعل. أما إذا كنت تكتفي بإرسال البيانات عبر OpenTelemetry Collector، فسيتم إنشاء هذه الجداول تلقائيًا، وهي الموضع الذي ستُطبَّق فيه جميع تحسينات الأداء الموضَّحة أدناه.
نوع البياناتالجدول
السجلاتotel_logs
التتبعاتotel_traces
المقاييس (Gauge)otel_metrics_gauge
المقاييس (المجاميع)otel_metrics_sum
المقاييس (Histogram)otel_metrics_histogram
المقاييس (Histogram أُسّي)otel_metrics_exponentialhistogram
المقاييس (الملخّص)otel_metrics_summary
الجلساتhyperdx_sessions
تُنسب الجداول إلى قواعد بيانات في ClickHouse. وبشكل افتراضي، تُستخدم قاعدة البيانات default — ويمكن تعديل ذلك في OpenTelemetry collector.
ركّز على السجلات والتتبعاتفي معظم الحالات، يتركّز ضبط الأداء على جداول السجلات والتتبعات. ورغم أنه يمكن تحسين جداول المقاييس لأغراض التصفية، فإن مخططاتها مصمَّمة عمدًا لأحمال العمل على نمط Prometheus، ولا تحتاج عادةً إلى تعديل في سيناريوهات الرسم البياني القياسية. أما السجلات والتتبعات، فعلى العكس، فتدعم نطاقًا أوسع من أنماط الوصول، ولذلك تستفيد أكثر من الضبط. أما بيانات الجلسات فلها تجربة استخدام ثابتة، ونادرًا ما يحتاج مخططها إلى تعديل.
كحد أدنى، ينبغي أن تفهم أساسيات ClickHouse التالية:
المفهومالوصف
الجداولكيف تقابل مصادر البيانات في ClickStack جداول ClickHouse الأساسية. تستخدم الجداول في ClickHouse في الغالب المحرك MergeTree.
الأجزاءكيف تُكتب البيانات في أجزاء غير قابلة للتغيير ثم تُدمج بمرور الوقت.
الأقسامتجمع الأقسام أجزاء بيانات الجدول ضمن وحدات منطقية منظَّمة. ويسهُل إدارة هذه الوحدات والاستعلام عنها وتحسينها.
عمليات الدمجالعملية الداخلية التي تدمج الأجزاء معًا لتقليل عدد الأجزاء التي يجب الاستعلام عنها. وهي ضرورية للحفاظ على أداء الاستعلام.
الحبيباتأصغر وحدة بيانات يقرؤها ClickHouse ويستبعدها أثناء تنفيذ الاستعلام.
المفاتيح الأساسية (مفاتيح الترتيب)كيف يحدّد مفتاح ORDER BY تخطيط البيانات على القرص، والضغط، واستبعاد البيانات أثناء الاستعلام.
تُعد هذه المفاهيم محورية لأداء ClickHouse. فهي تحدد كيفية كتابة البيانات، وكيفية تنظيمها على القرص، ومدى كفاءة ClickHouse في تخطّي قراءة البيانات وقت الاستعلام. وكل تحسين في هذا الدليل، سواء كان أعمدة materialized، أو skip indexes، أو المفاتيح الأساسية، أو projections، أو materialized views، يعتمد على هذه الآليات الأساسية. يُنصح بمراجعة وثائق ClickHouse التالية قبل الشروع في أي عملية ضبط: يمكن تطبيق جميع التحسينات الموضَّحة أدناه مباشرةً على الجداول الأساسية باستخدام ClickHouse SQL القياسي، سواء عبر SQL Console في ClickHouse Cloud أو من خلال ClickHouse client.

التحسين 1. تحويل السمات التي يُستعلَم عنها كثيرًا إلى أعمدة مُجسَّدة

أول تحسين وأبسطه لمستخدمي ClickStack هو تحديد السمات التي يكثر الاستعلام عنها في LogAttributes وScopeAttributes وResourceAttributes، ثم تحويلها إلى أعمدة من المستوى الأعلى باستخدام الأعمدة المُجسَّدة. وغالبًا ما يكون هذا التحسين وحده كافيًا لتوسيع نطاق عمليات نشر ClickStack إلى عشرات التيرابايت يوميًا، وينبغي تطبيقه قبل النظر في تقنيات ضبط أكثر تقدمًا.

لماذا نُجسِّد السمات

يخزّن ClickStack البيانات الوصفية مثل labels الخاصة بـ Kubernetes، والبيانات الوصفية للخدمة، والسمات المخصّصة في أعمدة Map(String, String). ورغم أن هذا يوفّر مرونة، فإن الاستعلام عن المفاتيح الفرعية داخل map له تأثير مهم على الأداء. عند الاستعلام عن مفتاح واحد من عمود Map، يجب على ClickHouse قراءة عمود map بالكامل من disk. وإذا كانت map تحتوي على عدد كبير من المفاتيح، فإن ذلك يؤدي إلى IO غير ضروري واستعلامات أبطأ مقارنة بقراءة عمود مخصّص. يساعد تجسيد السمات التي يُستعلم عنها كثيرًا على تجنّب هذا العبء الإضافي، وذلك عبر استخراج القيمة عند insert time وتخزينها كعمود مستقل. الأعمدة المُجسَّدة:
  • تُحتسب تلقائيًا أثناء عمليات الإدراج
  • لا يمكن تعيينها صراحةً في عبارات INSERT
  • تدعم أي expression في ClickHouse
  • تتيح تحويل النوع من String إلى أنواع رقمية أو من نوع Date أكثر كفاءة
  • تُمكّن من استخدام skip indexes والمفتاح الأساسي
  • تقلّل reads من disk عبر تجنّب الوصول الكامل إلى map
يكتشف ClickStack تلقائيًا الأعمدة المُجسَّدة المستخرجة من map ويستخدمها بشفافية أثناء تنفيذ الاستعلامات، حتى عندما يواصل المستخدمون الاستعلام عن مسار السمة الأصلي.

مثال

انظر إلى مخطط ClickStack الافتراضي للتتبعات، حيث تُخزَّن بيانات Kubernetes الوصفية في ResourceAttributes:
CREATE TABLE IF NOT EXISTS ${DATABASE}.otel_traces
(
    `Timestamp` DateTime64(9) CODEC(Delta(8), ZSTD(1)),
    `TraceId` String CODEC(ZSTD(1)),
    `SpanId` String CODEC(ZSTD(1)),
    `ParentSpanId` String CODEC(ZSTD(1)),
    `TraceState` String CODEC(ZSTD(1)),
    `SpanName` LowCardinality(String) CODEC(ZSTD(1)),
    `SpanKind` LowCardinality(String) CODEC(ZSTD(1)),
    `ServiceName` LowCardinality(String) CODEC(ZSTD(1)),
    `ResourceAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
    `ScopeName` String CODEC(ZSTD(1)),
    `ScopeVersion` String CODEC(ZSTD(1)),
    `SpanAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
    `Duration` UInt64 CODEC(ZSTD(1)),
    `StatusCode` LowCardinality(String) CODEC(ZSTD(1)),
    `StatusMessage` String CODEC(ZSTD(1)),
    `Events.Timestamp` Array(DateTime64(9)) CODEC(ZSTD(1)),
    `Events.Name` Array(LowCardinality(String)) CODEC(ZSTD(1)),
    `Events.Attributes` Array(Map(LowCardinality(String), String)) CODEC(ZSTD(1)),
    `Links.TraceId` Array(String) CODEC(ZSTD(1)),
    `Links.SpanId` Array(String) CODEC(ZSTD(1)),
    `Links.TraceState` Array(String) CODEC(ZSTD(1)),
    `Links.Attributes` Array(Map(LowCardinality(String), String)) CODEC(ZSTD(1)),
    `__hdx_materialized_rum.sessionId` String MATERIALIZED ResourceAttributes['rum.sessionId'] CODEC(ZSTD(1)),
    `SampleRate` UInt64 MATERIALIZED greatest(toUInt64OrZero(SpanAttributes['SampleRate']), 1) CODEC(T64, ZSTD(1)),
    `ResourceAttributeItems` Array(String) ALIAS arrayMap((arr) -> concat(arr.1, '=', arr.2), ResourceAttributes::Array(Tuple(String, String))),
    `SpanAttributeItems` Array(String) ALIAS arrayMap((arr) -> concat(arr.1, '=', arr.2), SpanAttributes::Array(Tuple(String, String))),
    INDEX idx_trace_id TraceId TYPE bloom_filter(0.001) GRANULARITY 1,
    INDEX idx_rum_session_id __hdx_materialized_rum.sessionId TYPE bloom_filter(0.001) GRANULARITY 1,
    INDEX idx_res_attr_key mapKeys(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
    INDEX idx_res_attr_items ResourceAttributeItems TYPE text(tokenizer = 'array'),
    INDEX idx_span_attr_key mapKeys(SpanAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
    INDEX idx_span_attr_items SpanAttributeItems TYPE text(tokenizer = 'array'),
    INDEX idx_duration Duration TYPE minmax GRANULARITY 1,
    INDEX idx_lower_span_name lower(SpanName) TYPE tokenbf_v1(32768, 3, 0) GRANULARITY 8
)
ENGINE = MergeTree
PARTITION BY toDate(Timestamp)
ORDER BY (ServiceName, SpanName, toDateTime(Timestamp))
TTL toDate(Timestamp) + ${TABLES_TTL}
SETTINGS index_granularity = 8192, ttl_only_drop_parts = 1;
يمكن للمستخدم تصفية التتبعات باستخدام صيغة Lucene، مثل ResourceAttributes.k8s.pod.name:"checkout-675775c4cc-f2p9c": ويؤدي ذلك إلى مسند SQL مشابه لما يلي:
ResourceAttributes['k8s.pod.name'] = 'checkout-675775c4cc-f2p9c'
لأن هذا يصل إلى مفتاح ضمن Map، يتعيّن على ClickHouse قراءة العمود ResourceAttributes كاملًا لكل صف مطابق — وقد يكون ذلك ضخمًا جدًا إذا كانت Map تحتوي على مفاتيح كثيرة. إذا كان الاستعلام عن هذه السمة يتكرر كثيرًا، فينبغي تجسيدها كعمود من المستوى الأعلى. لاستخراج اسم الكبسولة وقت الإدراج، أضِف عمودًا مُجسَّدًا:
ALTER TABLE otel_v2.otel_traces
ADD COLUMN PodName String
MATERIALIZED ResourceAttributes['k8s.pod.name']
اعتبارًا من الآن فصاعدًا، ستخزّن البيانات الجديدة اسم الكبسولة في عمود مخصّص، PodName. يمكن للمستخدمين الآن الاستعلام عن أسماء الكبسولات بكفاءة باستخدام بناء جملة Lucene، مثل PodName:"checkout-675775c4cc-f2p9c" بالنسبة إلى البيانات المُدرجة حديثًا، يُلغي هذا الحاجة إلى الوصول إلى map بالكامل ويقلّل بشكل كبير من عمليات الإدخال/الإخراج. ومع ذلك، حتى إذا واصل المستخدمون الاستعلام باستخدام مسار السمة الأصلي مثل ResourceAttributes.k8s.pod.name:"checkout-675775c4cc-f2p9c"، فإن ClickStack سيُعيد كتابة الاستعلام تلقائيًا داخليًا لاستخدام العمود المُجسَّد PodName، أي باستخدام الشرط:
PodName = 'checkout-675775c4cc-f2p9c'
يضمن ذلك استفادة المستخدمين من هذا التحسين من دون الحاجة إلى تغيير لوحات المعلومات أو التنبيهات أو الاستعلامات المحفوظة.
افتراضيًا، تُستبعَد الأعمدة المُجسَّدة من SELECT * queries. وهذا يحافظ على القاعدة الثابتة التي تقضي بإمكانية إعادة إدراج نتائج الاستعلام دائمًا في الجدول.

تخزين البيانات التاريخية بشكل مادي

لا تُطبَّق الأعمدة المُجسَّدة تلقائيًا إلا على البيانات التي أُدرِجت بعد إنشاء العمود. أما البيانات الموجودة مسبقًا، فستعود الاستعلامات على العمود المُجسَّد تلقائيًا وبشكل شفاف إلى القراءة من الـMap الأصلية. إذا كان أداء البيانات التاريخية بالغ الأهمية، فيمكن تعبئة العمود بالبيانات التاريخية باستخدام mutation، على سبيل المثال.
ALTER TABLE otel_v2.otel_traces
MATERIALIZE COLUMN PodName
يُعيد هذا كتابة الأجزاء الموجودة لملء العمود. تعمل عمليات الـ mutation بخيط تنفيذ واحد لكل جزء، وقد تستغرق وقتًا مع مجموعات البيانات الكبيرة. وللحد من التأثير، يمكن حصر عمليات الـ mutation في partition معيّن:
ALTER TABLE otel_v2.otel_traces
MATERIALIZE COLUMN PodName
IN PARTITION '2026-01-02'
يمكن متابعة تقدّم عمليات الـ mutation باستخدام الجدول system.mutations، على سبيل المثال.
SELECT *
FROM system.mutations
WHERE database = 'otel'
  AND table = 'otel_traces'
ORDER BY create_time DESC;
انتظر حتى تصبح قيمة is_done = 1 لعملية الـ mutation المقابلة.
تسبّب عمليات الـ mutation حملاً إضافيًا على IO وCPU، لذا ينبغي استخدامها عند الضرورة فقط. وفي كثير من الحالات، يكفي ترك البيانات الأقدم تتقادم طبيعيًا بمرور الوقت، والاعتماد على تحسينات الأداء للبيانات المُدخلة حديثًا.

التحسين 2. إضافة فهارس التخطي

بعد تجسيد السمات كثيرة الاستعلام، يكون التحسين التالي هو إضافة فهارس تخطي البيانات لتقليل كمية البيانات التي يحتاج ClickHouse إلى قراءتها أثناء تنفيذ الاستعلام بشكل أكبر. تتيح فهارس التخطي لـ ClickHouse تجنّب فحص كتل كاملة من البيانات عندما يتمكن من تحديد عدم وجود قيم مطابقة. وعلى خلاف الفهارس الثانوية التقليدية، تعمل فهارس التخطي على مستوى الحبيبات، وتكون أكثر فعالية عندما تستبعد مرشحات الاستعلام أجزاء كبيرة من مجموعة البيانات. وعند استخدامها بشكل صحيح، يمكنها تسريع التصفية على السمات عالية الكاردينالية بشكل ملحوظ من دون تغيير دلالات الاستعلام. انظر إلى مخطط تتبعات الافتراضي في ClickStack، الذي يتضمن فهارس تخطي:
CREATE TABLE IF NOT EXISTS ${DATABASE}.otel_traces
(
    `Timestamp` DateTime64(9) CODEC(Delta(8), ZSTD(1)),
    `TraceId` String CODEC(ZSTD(1)),
    `SpanId` String CODEC(ZSTD(1)),
    `ParentSpanId` String CODEC(ZSTD(1)),
    `TraceState` String CODEC(ZSTD(1)),
    `SpanName` LowCardinality(String) CODEC(ZSTD(1)),
    `SpanKind` LowCardinality(String) CODEC(ZSTD(1)),
    `ServiceName` LowCardinality(String) CODEC(ZSTD(1)),
    `ResourceAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
    `ScopeName` String CODEC(ZSTD(1)),
    `ScopeVersion` String CODEC(ZSTD(1)),
    `SpanAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
    `Duration` UInt64 CODEC(ZSTD(1)),
    `StatusCode` LowCardinality(String) CODEC(ZSTD(1)),
    `StatusMessage` String CODEC(ZSTD(1)),
    `Events.Timestamp` Array(DateTime64(9)) CODEC(ZSTD(1)),
    `Events.Name` Array(LowCardinality(String)) CODEC(ZSTD(1)),
    `Events.Attributes` Array(Map(LowCardinality(String), String)) CODEC(ZSTD(1)),
    `Links.TraceId` Array(String) CODEC(ZSTD(1)),
    `Links.SpanId` Array(String) CODEC(ZSTD(1)),
    `Links.TraceState` Array(String) CODEC(ZSTD(1)),
    `Links.Attributes` Array(Map(LowCardinality(String), String)) CODEC(ZSTD(1)),
    `__hdx_materialized_rum.sessionId` String MATERIALIZED ResourceAttributes['rum.sessionId'] CODEC(ZSTD(1)),
    `SampleRate` UInt64 MATERIALIZED greatest(toUInt64OrZero(SpanAttributes['SampleRate']), 1) CODEC(T64, ZSTD(1)),
    `ResourceAttributeItems` Array(String) ALIAS arrayMap((arr) -> concat(arr.1, '=', arr.2), ResourceAttributes::Array(Tuple(String, String))),
    `SpanAttributeItems` Array(String) ALIAS arrayMap((arr) -> concat(arr.1, '=', arr.2), SpanAttributes::Array(Tuple(String, String))),
    INDEX idx_trace_id TraceId TYPE bloom_filter(0.001) GRANULARITY 1,
    INDEX idx_rum_session_id __hdx_materialized_rum.sessionId TYPE bloom_filter(0.001) GRANULARITY 1,
    INDEX idx_res_attr_key mapKeys(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
    INDEX idx_res_attr_items ResourceAttributeItems TYPE text(tokenizer = 'array'),
    INDEX idx_span_attr_key mapKeys(SpanAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
    INDEX idx_span_attr_items SpanAttributeItems TYPE text(tokenizer = 'array'),
    INDEX idx_duration Duration TYPE minmax GRANULARITY 1,
    INDEX idx_lower_span_name lower(SpanName) TYPE tokenbf_v1(32768, 3, 0) GRANULARITY 8
)
ENGINE = MergeTree
PARTITION BY toDate(Timestamp)
ORDER BY (ServiceName, SpanName, toDateTime(Timestamp))
TTL toDate(Timestamp) + ${TABLES_TTL}
SETTINGS index_granularity = 8192, ttl_only_drop_parts = 1;
تركّز هذه الفهارس على ثلاثة أنماط شائعة:
  • تصفية السلاسل النصية عالية الكاردينالية، مثل TraceId، ومعرّفات الجلسات، ومفاتيح السمات أو قيمها
  • تصفية المفاتيح الفرعية في Map، المسرَّعة بواسطة فهارس نصية على أعمدة *AttributeItems
  • التصفية على النطاقات الرقمية، مثل مدة span
يستخدم جدول logs فهارس text(tokenizer = 'array') في كامل الجدول بدلًا من مرشحات Bloom، ويضيف فهرس text(tokenizer = 'splitByNonAlpha') على lower(Body) لأغراض البحث النصي الكامل. راجع “الجداول والمخططات التي يستخدمها ClickStack” للاطلاع على DDL الكامل.

مرشحات Bloom

تُعد فهارس مرشح Bloom أكثر أنواع فهارس التخطي استخدامًا في ClickStack. وهي مناسبة جدًا للأعمدة النصية عالية الكاردينالية، وعادةً ما تحتوي على عشرات الآلاف على الأقل من القيم المميزة. يُعد معدل الإيجابيات الكاذبة البالغ 0.01 مع granularity بقيمة 1 نقطة بداية افتراضية جيدة، إذ يوازن بين الحمل الإضافي للتخزين وفعالية الاستبعاد. استكمالًا للمثال الوارد في التحسين 1، افترض أنه تمت مادية اسم الكبسولة في Kubernetes من ResourceAttributes:
ALTER TABLE otel_traces
ADD COLUMN PodName String
MATERIALIZED ResourceAttributes['k8s.pod.name']
يمكن بعد ذلك إضافة فهرس تخطٍّ من نوع Bloom filter لتسريع عمليات التصفية على هذا العمود:
ALTER TABLE otel_traces
ADD INDEX idx_pod_name PodName
TYPE bloom_filter(0.01)
GRANULARITY 1
بمجرد إضافته، يجب تنفيذ materialize لفهرس التخطي - راجع “إجراء materialize لفهرس التخطي.” بمجرد إنشائه وتنفيذ materialize له، يمكن لـ ClickHouse تخطي حبيبات كاملة من المؤكد أنها لا تحتوي على اسم الكبسولة المطلوب، مما قد يقلل كمية البيانات المقروءة أثناء استعلامات مثل PodName:"checkout-675775c4cc-f2p9c". تكون bloom filters أكثر فعالية عندما يكون توزيع القيم بحيث تظهر قيمة معيّنة في عدد صغير نسبيًا من الأجزاء. ويحدث هذا غالبًا بشكل طبيعي في أعباء عمل observability، حيث ترتبط البيانات الوصفية مثل أسماء الكبسولات أو معرّفات التتبّع أو معرّفات الجلسات بالوقت، وبالتالي تتجمع وفق مفتاح الترتيب الخاص بالجدول. وكما هو الحال مع جميع فهارس التخطي، ينبغي إضافة bloom filters بشكل انتقائي والتحقق منها في مقابل أنماط الاستعلام الفعلية لضمان أنها توفّر فائدة قابلة للقياس - راجع “تقييم فعالية فهرس التخطي.”

الفهارس النصية

توفّر الفهارس النصية بديلاً لمرشحات Bloom. مرشح Bloom هو بنية احتمالية يمكنها استبعاد الحبيبات بشكل قاطع، لكنه ينطوي على معدل من الإيجابيات الكاذبة، لذلك يجب مع ذلك تحميل الحبيبات التي لا يستبعدها وتقييمها وفق شرط WHERE. الفهارس النصية هي فهارس معكوسة تربط الرموز بإزاحات دقيقة داخل الجزء. ونظراً لأنها تقيّم الإزاحات بدلاً من الحبيبات ولا تنتج أي إيجابيات كاذبة، فيمكنها عادةً تلبية شرط WHERE من دون تحميل العمود الأساسي. ويُعرف هذا التحسين باسم القراءة المباشرة. وبما أن تحميل البيانات يكون غالباً أكبر عامل في زمن الاستعلام، فإن القراءة المباشرة يمكن أن تقلّل كمون الاستعلام بشكل ملحوظ. إضافةً إلى ذلك، يمكن أيضاً الاستعلام عن الفهارس النصية نفسها، ما يوفّر الإكمال التلقائي وأدوات الفحص الداخلي الأخرى في ClickStack. يغطي مُقسِّما الرموز التاليان معظم أنماط ClickStack:
مُقسِّم الرموزيُستخدم من أجلالعمود النموذجي
arrayفهرسة عناصر Array(String) كرموز كاملةmapKeys(...), *AttributeItems
splitByNonAlphaالبحث النصي الكامل على مستوى الكلمات في السلاسل النصية الحرةBody, lower(Body), SpanName

مُقسِّم الرموز Array لأعمدة Map والأعمدة من النوع Array

يفهرس مخطط السجلات الافتراضي mapKeys ومصفوفات العناصر المُجسَّدة باستخدام مُقسِّم الرموز array:
INDEX idx_log_attr_key mapKeys(LogAttributes) TYPE text(tokenizer = 'array'),
INDEX idx_log_attr_items LogAttributeItems TYPE text(tokenizer = 'array')
يتحوّل كل مفتاح في Map (أو كل عنصر في المصفوفة) إلى token واحد. ثم تؤدي التصفية على مفتاح attribute معروف إلى استبعاد أي صف لا يحتوي عليه، من دون فحص عمود Map نفسه. وهذه هي الآلية التي تجعل تحسين القراءة المباشرة لـ Map مجدياً.

splitByNonAlpha لمحتوى السجل

يستفيد البحث بالنص الكامل في العمود Body من فهرس نصي splitByNonAlpha. ويعرّف ClickStack هذا الفهرس على lower(Body) بحيث تتمكّن عمليات بحث Lucene المتجاهلة لحالة الأحرف من استخدامه:
INDEX idx_lower_body lower(Body) TYPE text(tokenizer = 'splitByNonAlpha')
عندما يكتشف ClickStack فهرس text(tokenizer = 'splitByNonAlpha') على lower(Body)، فإنه يعيد كتابة استعلامات Lucene ذات العمود الضمني مثل error أو "connection refused" إلى hasAllTokens(lower(Body), lower(...))، ما يتيح للفهرس تنفيذها من دون قراءة عمود Body بالكامل. بالنسبة إلى معظم أحمال عمل سجلات observability، فهذا أكبر تسريع منفرد متاح لعمليات التصفية.
فهارس النص مقابل tokenbf_v1نوع الفهرس الأقدم tokenbf_v1 (الذي لا يزال مستخدمًا في مخطط تتبعات الافتراضي لـ lower(SpanName)) متشابه وظيفيًا، لكنه مُهمَل في ClickHouse 26.2 والإصدارات الأحدث. ينبغي أن تستخدم فهارس البحث النصي الجديدة text(tokenizer = ...).
للحصول على مرجع أكثر تفصيلًا حول خيارات مُقسِّم الرموز، والمعالِجات المسبقة، والتحقق، راجع وثائق البحث النصي الكامل.

الفهارس النصية في مخطط السجلات الافتراضي

يأتي مخطط otel_logs الافتراضي، المُزامَن من المصدر الأصلي، متضمّنًا كل فهرس نصي نوقش أعلاه: text(tokenizer = 'array') على TraceId، وعلى كل من mapKeys(...) ومصفوفات *AttributeItems، وtext(tokenizer = 'splitByNonAlpha') على lower(Body) لأغراض البحث النصي الكامل. للاطلاع على DDL القياسي، راجع “الجداول والمخططات المستخدمة بواسطة ClickStack”؛ كما يَرِد أدناه المخطط نفسه.
CREATE TABLE IF NOT EXISTS ${DATABASE}.otel_logs
(
  `Timestamp` DateTime64(9) CODEC(Delta(8), ZSTD(1)),
  `TraceId` String CODEC(ZSTD(1)),
  `SpanId` String CODEC(ZSTD(1)),
  `TraceFlags` UInt8,
  `SeverityText` LowCardinality(String) CODEC(ZSTD(1)),
  `SeverityNumber` UInt8,
  `ServiceName` LowCardinality(String) CODEC(ZSTD(1)),
  `Body` String CODEC(ZSTD(1)),
  `ResourceSchemaUrl` LowCardinality(String) CODEC(ZSTD(1)),
  `ResourceAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
  `ScopeSchemaUrl` LowCardinality(String) CODEC(ZSTD(1)),
  `ScopeName` String CODEC(ZSTD(1)),
  `ScopeVersion` LowCardinality(String) CODEC(ZSTD(1)),
  `ScopeAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
  `LogAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
  `EventName` String CODEC(ZSTD(1)),
  `__hdx_materialized_k8s.cluster.name` LowCardinality(String) MATERIALIZED ResourceAttributes['k8s.cluster.name'] CODEC(ZSTD(1)),
  `__hdx_materialized_k8s.container.name` LowCardinality(String) MATERIALIZED ResourceAttributes['k8s.container.name'] CODEC(ZSTD(1)),
  `__hdx_materialized_k8s.deployment.name` LowCardinality(String) MATERIALIZED ResourceAttributes['k8s.deployment.name'] CODEC(ZSTD(1)),
  `__hdx_materialized_k8s.namespace.name` LowCardinality(String) MATERIALIZED ResourceAttributes['k8s.namespace.name'] CODEC(ZSTD(1)),
  `__hdx_materialized_k8s.node.name` LowCardinality(String) MATERIALIZED ResourceAttributes['k8s.node.name'] CODEC(ZSTD(1)),
  `__hdx_materialized_k8s.pod.name` LowCardinality(String) MATERIALIZED ResourceAttributes['k8s.pod.name'] CODEC(ZSTD(1)),
  `__hdx_materialized_k8s.pod.uid` LowCardinality(String) MATERIALIZED ResourceAttributes['k8s.pod.uid'] CODEC(ZSTD(1)),
  `__hdx_materialized_deployment.environment.name` LowCardinality(String) MATERIALIZED ResourceAttributes['deployment.environment.name'] CODEC(ZSTD(1)),
  `ResourceAttributeItems` Array(String) ALIAS arrayMap((arr) -> concat(arr.1, '=', arr.2), ResourceAttributes::Array(Tuple(String, String))),
  `ScopeAttributeItems` Array(String) ALIAS arrayMap((arr) -> concat(arr.1, '=', arr.2), ScopeAttributes::Array(Tuple(String, String))),
  `LogAttributeItems` Array(String) ALIAS arrayMap((arr) -> concat(arr.1, '=', arr.2), LogAttributes::Array(Tuple(String, String))),
  INDEX idx_trace_id TraceId TYPE text(tokenizer = 'array'),
  INDEX idx_res_attr_key mapKeys(ResourceAttributes) TYPE text(tokenizer = 'array'),
  INDEX idx_res_attr_items ResourceAttributeItems TYPE text(tokenizer = 'array'),
  INDEX idx_scope_attr_key mapKeys(ScopeAttributes) TYPE text(tokenizer = 'array'),
  INDEX idx_scope_attr_items ScopeAttributeItems TYPE text(tokenizer = 'array'),
  INDEX idx_log_attr_key mapKeys(LogAttributes) TYPE text(tokenizer = 'array'),
  INDEX idx_log_attr_items LogAttributeItems TYPE text(tokenizer = 'array'),
  INDEX idx_lower_body lower(Body) TYPE text(tokenizer = 'splitByNonAlpha')
)
ENGINE = MergeTree
PARTITION BY toDate(Timestamp)
ORDER BY (toStartOfFiveMinutes(Timestamp), ServiceName, Timestamp)
TTL toDateTime(Timestamp) + ${TABLES_TTL}
SETTINGS index_granularity = 8192, ttl_only_drop_parts = 1, enable_block_number_column = 1, enable_block_offset_column = 1;

فهارس MinMax

تخزّن فهارس MinMax القيمتَين الدنيا والعليا لكل granule، وهي خفيفة جدًا. وهي فعّالة بشكل خاص مع الأعمدة الرقمية واستعلامات النطاق. ورغم أنها قد لا تسرّع كل استعلام، فإن كلفتها منخفضة، وغالبًا ما يكون من المفيد إضافتها إلى الحقول الرقمية. تعمل فهارس MinMax بأفضل صورة عندما تكون القيم الرقمية مرتبة بطبيعتها أو محصورة ضمن نطاقات ضيقة داخل كل جزء. لنفترض أنه يُستعلَم كثيرًا عن offset في Kafka من SpanAttributes:
SpanAttributes['messaging.kafka.offset']
يمكن تخزين هذه القيمة فعليًا ثم تحويلها إلى نوع رقمي:
ALTER TABLE otel_traces
ADD COLUMN KafkaOffset UInt64
MATERIALIZED toUInt64(SpanAttributes['messaging.kafka.offset'])
يمكن بعد ذلك إضافة فهرس MinMax:
ALTER TABLE otel_traces
ADD INDEX idx_kafka_offset KafkaOffset TYPE minmax GRANULARITY 1
يتيح ذلك لـ ClickHouse تخطي الأجزاء بكفاءة عند التصفية حسب نطاقات OFFSET في Kafka، على سبيل المثال عند تصحيح مشكلات تأخر المستهلك أو سلوك إعادة التشغيل. ومرة أخرى، يجب إضفاء الطابع المادي على الفهرس قبل أن يصبح متاحًا.

تنفيذ فهرس التخطي ماديًا

بعد إضافة فهرس تخطٍّ، لا يُطبَّق إلا على البيانات المُستوعبة حديثًا. ولن تستفيد البيانات التاريخية من الفهرس حتى يُنفَّذ عليها ماديًا بشكل صريح. إذا كنت قد أضفت بالفعل فهرس تخطٍّ، على سبيل المثال:
ALTER TABLE otel_traces ADD INDEX idx_kafka_offset KafkaOffset TYPE minmax GRANULARITY 1;
يجب عليك إنشاء الفهرس بشكلٍ صريح للبيانات الموجودة:
ALTER TABLE otel_traces MATERIALIZE INDEX idx_kafka_offset;
تجسيد فهارس التخطييكون تجسيد فهرس التخطي عادةً خفيفًا وآمنًا، خاصةً مع فهارس MinMax. أما فهارس مرشح Bloom على مجموعات البيانات الكبيرة، فقد يفضّل المستخدمون تجسيدها على مستوى كل partition على حدة للتحكم في استهلاك الموارد بشكل أفضل، على سبيل المثال:
ALTER TABLE otel_v2.otel_traces
MATERIALIZE INDEX idx_kafka_offset
IN PARTITION '2026-01-02';
يُنفَّذ تجسيد فهرس التخطي على شكل mutation. ويمكن متابعة تقدّمه باستخدام جداول النظام.

SELECT *
FROM system.mutations
WHERE database = 'otel'
  AND table = 'otel_traces'
ORDER BY create_time DESC;
انتظر حتى تصبح قيمة is_done = 1 لعملية الـ mutation المقابلة. بعد اكتمال ذلك، تأكد من إنشاء بيانات الفهرس:
SELECT database, table, name,
       data_compressed_bytes,
       data_uncompressed_bytes,
       marks_bytes
FROM system.data_skipping_indices
WHERE database = 'otel'
  AND table = 'otel_traces'
  AND name = 'idx_kafka_offset';
تشير القيم غير الصفرية إلى أن الفهرس قد تمّت ماديته بنجاح. من المهم أن يؤثر حجم فهرس التخطي مباشرةً في أداء الاستعلامات. إذ إن فهارس التخطي الكبيرة جدًا، بحجم عشرات أو حتى مئات الجيجابايت، قد تستغرق وقتًا ملحوظًا لتقييمها أثناء تنفيذ الاستعلام، ما قد يقلل فائدتها أو حتى يلغيها تمامًا. عمليًا، تكون فهارس minmax صغيرة جدًا عادةً ومنخفضة الكلفة عند تقييمها، مما يجعل ماديتها آمنة في الغالب. أما فهارس مرشح Bloom، فقد يزداد حجمها بشكل كبير بحسب cardinality وgranularity واحتمال الإيجابيات الكاذبة. يمكن تقليل حجم مرشح Bloom بزيادة المعدل المسموح للإيجابيات الكاذبة. على سبيل المثال، تؤدي زيادة مُعامِل الاحتمال من 0.01 إلى 0.05 إلى إنتاج فهرس أصغر يُقيَّم بسرعة أكبر، لكن على حساب تقليم أقل فعالية. ورغم أنه قد يتم تخطي عدد أقل من granules، فقد يتحسن زمن استجابة الاستعلام الإجمالي بسبب سرعة تقييم الفهرس. لذلك، فإن ضبط مُعامِلات مرشح Bloom يُعد تحسينًا يعتمد على workload، وينبغي التحقق منه باستخدام query patterns فعلية وأحجام بيانات مماثلة لبيئة production. لمزيد من التفاصيل حول فهارس التخطي، راجع الدليل “فهم فهارس ClickHouse لتخطي البيانات.”

تقييم فعالية فهارس التخطي

أكثر الطرق موثوقيةً لتقييم تقليص فهارس التخطي هي استخدام EXPLAIN indexes = 1، إذ يوضّح عدد الأجزاء والحبيبات التي جرى استبعادها في كل مرحلة من مراحل تخطيط الاستعلام. في معظم الحالات، يُفترض أن ترى انخفاضًا كبيرًا في عدد الحبيبات عند مرحلة Skip، ويفضَّل أن يحدث ذلك بعد أن يكون المفتاح الأساسي قد قلّص نطاق البحث بالفعل. تُقيَّم فهارس التخطي بعد تقليص الأقسام وتقليص المفتاح الأساسي، لذا يُقاس أثرها بأفضل شكل مقارنةً بالأجزاء والحبيبات المتبقية. يؤكد EXPLAIN ما إذا كان هذا التقليص يحدث، لكنه لا يضمن بالضرورة تحسنًا فعليًا في السرعة. فتقييم فهارس التخطي له كلفة، لا سيما إذا كان الفهرس كبيرًا. احرص دائمًا على إجراء benchmark للاستعلامات قبل إضافة الفهرس وتجسيده ماديًا وبعد ذلك، للتأكد من وجود تحسينات فعلية في الأداء. على سبيل المثال، تأمّل فهرس التخطي الافتراضي من نوع مرشح Bloom للحقل TraceId والمضمَّن في مخطط تتبعات الافتراضي:
INDEX idx_trace_id TraceId TYPE bloom_filter(0.001) GRANULARITY 1
يمكنك استخدام EXPLAIN indexes = 1 لمعرفة مدى فعاليته في استعلام انتقائي:
EXPLAIN indexes = 1
SELECT *
FROM otel_v2.otel_traces
WHERE (ServiceName = 'accounting')
  AND (TraceId = 'aeea7f401feb75fc5af8eb25ebc8e974');

ReadFromMergeTree (otel_v2.otel_traces)
Indexes:
  PrimaryKey
    Keys:
      ServiceName
    Parts: 6/18
    Granules: 255/35898
  Skip
    Name: idx_trace_id
    Description: bloom_filter GRANULARITY 1
    Parts: 1/6
    Granules: 1/255
في هذه الحالة، يقلّص مرشح المفتاح الأساسي مجموعة البيانات بشكل كبير أولًا (من 35898 وحدة granule إلى 255)، ثم يختصر مرشح Bloom ذلك أكثر إلى وحدة granule واحدة (1/255). وهذا هو النمط المثالي لفهارس التخطي: إذ يضيّق تقليص المفتاح الأساسي نطاق البحث، ثم يزيل فهرس التخطي معظم ما تبقّى. للتحقق من الأثر الفعلي، اختبر أداء الاستعلام بإعدادات مستقرة وقارن زمن التنفيذ. استخدم FORMAT Null لتجنّب الحمل الإضافي الناتج عن تسلسل النتائج، وعطّل ذاكرة التخزين المؤقت لشروط الاستعلام للحفاظ على قابلية تكرار التشغيل:
SELECT *
FROM otel_traces
WHERE (ServiceName = 'accountingservice') AND (TraceId = '4512e822ca3c0c68bbf5d4a263f9943d')
SETTINGS use_query_condition_cache = 0
2 rows in set. Elapsed: 0.025 sec. Processed 8.52 thousand rows, 299.78 KB (341.22 thousand rows/s., 12.00 MB/s.)
Peak memory usage: 41.97 MiB.
الآن نفّذ الاستعلام نفسه مع تعطيل فهارس التخطي:
SELECT *
FROM otel_traces
WHERE (ServiceName = 'accountingservice') AND (TraceId = '4512e822ca3c0c68bbf5d4a263f9943d')
FORMAT Null
SETTINGS use_query_condition_cache = 0, use_skip_indexes = 0;
0 rows in set. Elapsed: 0.702 sec. Processed 1.62 million rows, 56.62 MB (2.31 million rows/s., 80.71 MB/s.)
Peak memory usage: 198.39 MiB.
يضمن تعطيل use_query_condition_cache عدم تأثر النتائج بقرارات التصفية المخزنة مؤقتًا، كما أن ضبط use_skip_indexes = 0 يوفّر خط أساس واضحًا للمقارنة. وإذا كان التقليم فعّالًا وكانت كلفة تقييم الفهرس منخفضة، فمن المفترض أن يكون الاستعلام المفهرس أسرع بشكل ملحوظ، كما في المثال أعلاه.
إذا أظهر EXPLAIN قدرًا محدودًا من تقليم الحبيبات، أو كان فهرس التخطي كبيرًا جدًا، فقد تُلغي كلفة تقييم الفهرس أي فائدة محتملة. استخدم EXPLAIN indexes = 1 للتأكد من حدوث التقليم، ثم أجرِ اختبارًا معياريًا للتأكد من تحسّن الأداء من البداية إلى النهاية.

متى تُضاف فهارس التخطي

ينبغي إضافة فهارس التخطي بشكل انتقائي، استنادًا إلى أنواع عوامل التصفية التي يستخدمها المستخدمون بكثرة وبنية البيانات داخل الأجزاء والحبيبات. والهدف هو استبعاد عدد كافٍ من الحبيبات بما يعوّض تكلفة تقييم الفهرس نفسه، ولهذا يُعدّ إجراء اختبارات قياس الأداء على بيانات تشبه بيانات بيئة الإنتاج أمرًا أساسيًا. بالنسبة إلى الأعمدة الرقمية المستخدمة في عوامل التصفية، يكون فهرس التخطي minmax خيارًا جيدًا في معظم الحالات. فهو خفيف الوزن، منخفض الكلفة عند التقييم، ويمكن أن يكون فعّالًا مع شروط النطاق — ولا سيما عندما تكون القيم مرتبة ترتيبًا تقريبيًا أو محصورة ضمن نطاقات ضيقة داخل الأجزاء. وحتى عندما لا يفيد minmax في نمط استعلام معيّن، فإن العبء الإضافي له يكون منخفضًا عادةً بما يكفي ليبقى الاحتفاظ به خيارًا معقولًا. بالنسبة إلى الأعمدة النصية، فالأفضل استخدام فهارس النص حيثما كانت مدعومة؛ وإلا فارجع إلى Bloom filters. تُسرّع فهارس النص عوامل التصفية نفسها الخاصة بالمساواة وIN التي تُسرّعها Bloom filters، كما تتيح أيضًا الشروط المعتمدة على الرموز (hasToken, hasAllTokens, has) المستخدمة في البحث النصي الكامل وفي تحسين القراءة المباشرة لـ Map. وفي العناقيد الأقدم التي لا تدعم فهارس النص بعد، تظل Bloom filters خيارًا قويًا. تكون Bloom filters أكثر فعالية مع الأعمدة النصية عالية الكاردينالية، حيث يكون تكرار كل قيمة منخفضًا نسبيًا، ما يعني أن معظم الأجزاء والحبيبات لا تحتوي على القيمة المطلوب البحث عنها. وكقاعدة عامة، تكون Bloom filters أكثر جدوى عندما يحتوي العمود على ما لا يقل عن 10,000 قيمة مميزة، وغالبًا ما تحقق أفضل أداء مع 100,000+ قيمة مميزة. كما تكون أكثر فعالية عندما تكون القيم المطابقة متجمعة في عدد صغير من الأجزاء المتسلسلة، وهو ما يحدث عادةً عندما يكون العمود مترابطًا مع مفتاح الترتيب. ومرة أخرى، قد تختلف النتائج من حالة إلى أخرى — فلا شيء يغني عن الاختبار العملي في بيئة واقعية.

التحسين 3. القراءة المباشرة لـ Map

عندما تُجري تصفية على مفتاح فرعي في Map مثل LogAttributes['k8s.pod.name'] = 'checkout'، يجب على ClickHouse قراءة عمود LogAttributes بالكامل من نوع Map من القرص وفكّ بيانات كل صف لتقييم الشرط. إن تجسيد السمات كثيرة الاستعلام يحل هذه المشكلة للمفاتيح التي تعرفها مسبقًا، لكنه لا يتوسّع ليتعامل مع السمات غير المحددة مسبقًا التي يصفّي المستخدمون عليها عند الحاجة. حتى إذا كان المخطط يحتوي على فهارس على mapKeys وmapValues، فإن هذه الفهارس تستطيع إخبارك ما إذا كان الصف يحتوي على مفتاح معيّن، وما إذا كان يحتوي على قيمة معيّنة، لكنها لا تستطيع تحديد ما إذا كان المفتاح والقيمة ينتميان إلى الإدخال نفسه. بعبارة أخرى، يجيب mapKeys عن mapContainsKey(ResourceAttributes, 'foo') ويجيب mapValues عن mapContainsValue(ResourceAttributes, 'bar')، لكن لا يجيب أيٌّ منهما عن ResourceAttributes['foo'] = 'bar'. من خلال دمج المفاتيح والقيم في عمود واحد من نوع Array(String)، يتيح تحسين القراءة المباشرة لـ Map الإجابة عن ResourceAttributes['foo'] = 'bar' من دون تحميل الـ Map الفعلي. وغالبًا ما تكون قيم Map كبيرة، ويزداد حجمها مع زيادة حجم البيانات. وعند دمج ذلك مع إعادة كتابة للاستعلام على مستوى التطبيق، تصبح مرشحات المساواة على أي مفتاح فرعي في Map استدعاءً واحدًا لـ has(...) مدعومًا بذلك الفهرس، من دون أي إلغاء تسلسل لـ Map في وقت الاستعلام. بالإضافة إلى ذلك، فإن تكلفة التخزين الوحيدة هي تكلفة فهرس النص، لأن العمود الفعلي هو عمود ALIAS ولا يتم تخزينه. هذا التحسين تلقائي. يوفّر ClickStack الأعمدة والفهارس اللازمة في جدولَي السجلات والتتبعات الافتراضيين، ويعيد كتابة مرشحات الفهرسة الفرعية لـ Map في وقت التشغيل عندما يدعم ClickHouse server المتصل الميزة الأساسية. وإذا كان مخططك لا يحتوي على هذه الأعمدة، أو كانت لديك أعمدة Map إضافية تريد تسريعها إلى جانب الإعدادات الافتراضية، فتابع القراءة لتمكينها.

المخطط

لكل عمود Map تريد تسريع الوصول إليه، يعرّف ClickStack عمود Array(String) ALIAS يربط كل مفتاح بقيمته باستخدام =:
ALTER TABLE otel_logs
ADD COLUMN LogAttributeItems Array(String)
ALIAS arrayMap(
  (arr) -> concat(arr.1, '=', arr.2),
  LogAttributes::Array(Tuple(String, String))
)
تعني صيغة ALIAS أن المصفوفة لا تضيف أي بايتات على القرص. ويحسبها ClickHouse في وقت تنفيذ الاستعلام وعند بناء الفهرس. يخزّن فهرس التخطي text(tokenizer = 'array') على العمود ALIAS رمزًا واحدًا لكل زوج key=value، ويستخدم ClickHouse ذلك لاستبعاد الحبيبات من دون الرجوع إلى Map المصدر:
ALTER TABLE otel_logs
ADD INDEX idx_log_attr_items LogAttributeItems
TYPE text(tokenizer = 'array')
بعد إنشاء الفهرس على جدول موجود، قم بعمل تجسيد له حتى تتمكن البيانات التاريخية من الاستفادة منه (راجع “عمل تجسيد لـ skip index”). تأتي مخططات ClickStack الافتراضية بهذه الأعمدة والفهارس:
Tableأعمدة ALIASText indexes
otel_logsResourceAttributeItems, ScopeAttributeItems, LogAttributeItemsidx_res_attr_items, idx_scope_attr_items, idx_log_attr_items
otel_tracesResourceAttributeItems, SpanAttributeItemsidx_res_attr_items, idx_span_attr_items

إعادة كتابة الاستعلام

عندما يضع المستخدم عامل تصفية على مفتاح فرعي في Map عبر واجهة ClickStack أو SDK، يعيد ClickStack كتابة:
LogAttributes['k8s.pod.name'] = 'checkout'
إلى:
has(LogAttributeItems, concat('k8s.pod.name', '=', 'checkout'))
تستفيد الصيغة المعاد كتابتها من الفهرس النصي على LogAttributeItems، وتستبعد صفوفًا كاملة لا تحتوي على الرمز key=value، ولا تفك تسلسل الـ Map المصدر LogAttributes مطلقًا للصفوف غير المطابقة. بالنسبة إلى أعباء عمل الرصد عالية الكاردينالية، يحقق هذا عادةً خفضًا بمقدار مرتبة كاملة في I/O مقارنةً بالوصول عبر الفهرسة الفرعية لـ Map. تتم إعادة الكتابة تلقائيًا — فالاستعلامات المحفوظة ولوحات المعلومات والتنبيهات التي تشير إلى LogAttributes['key'] تستفيد من هذه الزيادة في السرعة من دون أي تغيير.

متطلبات إصدار ClickHouse

تتطلب إعادة كتابة الاستعلام إصدارًا من ClickHouse يدعم الاستبعاد المباشر على مستوى الرموز في أعمدة المصفوفات المفهرسة نصيًا. يكتشف ClickStack إصدار الخادم المتصل (SELECT version(), ويُخزَّن مؤقتًا لكل اتصال) ولا يُصدر الصيغة المُعاد كتابتها إلا إذا كان إصدار الخادم عند الحد المطلوب أو أعلى منه. أما الخوادم الأقدم فتعود تلقائيًا إلى صيغة الفهرسة الأصلية لـ Map.
فرع ClickHouseالحد الأدنى للإصدار
26.226.2.19.43
26.326.3.12.3
26.426.4.3.37
26.5+جميع الإصدارات
لماذا ALIAS وليس MATERIALIZEDتمثل مصفوفة items عرضًا للبيانات الموجودة أصلًا في العمود Map. فتخزينها مرتين — مرة في Map ومرة في المصفوفة — يضاعف عمليات I/O الخاصة بالكتابة من دون إتاحة أنماط استعلام جديدة. كما أن الفهرس النصي على العمود ALIAS يُبنى وقت الإدراج من بيانات المصدر نفسها، لذا لا تضيف عملية التحسين إلى القرص إلا المساحة التي يشغلها الفهرس.

التحسين 4. تعديل المفتاح الأساسي

يُعد المفتاح الأساسي أحد أهم مكوّنات تحسين أداء ClickHouse لمعظم أعباء العمل. ولضبطه بفاعلية، يجب أن تفهم كيف يعمل وكيف يتفاعل مع أنماط الاستعلام لديك. وفي النهاية، ينبغي أن يتوافق المفتاح الأساسي مع الطريقة التي يصل بها المستخدمون إلى البيانات، وخصوصًا الأعمدة التي تُستخدم معها التصفية في أغلب الأحيان. ومع أن المفتاح الأساسي يؤثر أيضًا في الضغط وتخطيط التخزين، فإن غرضه الأساسي هو تحسين أداء الاستعلامات. في ClickStack، تكون المفاتيح الأساسية الجاهزة افتراضيًا محسّنة بالفعل لأنماط الوصول الأكثر شيوعًا في observability ولتحقيق ضغط جيد. وقد صُممت المفاتيح الافتراضية لجداول السجلات والتتبعات والمقاييس لتؤدي جيدًا في مسارات العمل المعتادة. تكون التصفية على الأعمدة التي تظهر مبكرًا ضمن المفتاح الأساسي أكثر كفاءة من التصفية على الأعمدة التي تظهر لاحقًا. وبينما يكون الإعداد الافتراضي كافيًا لمعظم المستخدمين، توجد حالات يمكن فيها أن يؤدي تعديل المفتاح الأساسي إلى تحسين الأداء لأعباء عمل محددة.
ملاحظة حول المصطلحاتفي هذا المستند، يُستخدم مصطلح “مفتاح الترتيب” بالتبادل مع “المفتاح الأساسي”. ومن الناحية الدقيقة، يختلفان في ClickHouse، لكنهما في ClickStack يشيران عادةً إلى الأعمدة نفسها المحددة في عبارة ORDER BY للجدول. ولمزيد من التفاصيل، راجع وثائق ClickHouse حول اختيار مفتاح أساسي يختلف عن مفتاح الفرز.
قبل تعديل أي مفتاح أساسي، نوصي بشدة بقراءة دليلنا لفهم كيفية عمل الفهارس الأساسية في ClickHouse: يختلف ضبط المفتاح الأساسي بحسب الجدول ونوع البيانات. فقد لا ينطبق تغيير يفيد جدولًا ونوع بيانات معينين على غيرهما. والهدف دائمًا هو التحسين لنوع بيانات محدد، مثل السجلات. غالبًا ما ستُحسِّن جداول السجلات والتتبعات. ومن النادر أن تحتاج أنواع البيانات الأخرى إلى تغييرات في المفتاح الأساسي. فيما يلي المفاتيح الأساسية الافتراضية لجداول ClickStack الخاصة بالسجلات والتتبعات.
  • السجلات (otel_logs) - (toStartOfFiveMinutes(Timestamp), ServiceName, Timestamp)
  • التتبعات (otel_traces) - (ServiceName, SpanName, toDateTime(Timestamp))
راجع “الجداول والمخططات المستخدمة بواسطة ClickStack” لمعرفة المفاتيح الأساسية التي تستخدمها الجداول الخاصة بأنواع البيانات الأخرى. جداول التتبعات محسّنة للتصفية حسب اسم الخدمة واسم span، ثم حسب الطابع الزمني. وتبدأ جداول السجلات بحاوية زمنية مدتها خمس دقائق بحيث تصل عمليات المسح ضمن النطاق الزمني إلى الفهرس الأساسي أولًا، ثم تُضيّق النطاق حسب اسم الخدمة داخل كل حاوية — وهو تخطيط يناسب سير العمل الشائع: “ماذا حدث خلال آخر N دقائق للخدمة X”. وعلى الرغم من أن الترتيب الأمثل هو تطبيق عوامل التصفية وفق ترتيب المفتاح الأساسي، فإن الاستعلامات لا تزال تستفيد كثيرًا من التصفية على أيٍّ من هذه الأعمدة بأي ترتيب، مع قيام ClickHouse باستبعاد البيانات غير اللازمة قبل قراءتها. عند اختيار مفتاح أساسي، توجد أيضًا اعتبارات أخرى لاختيار الترتيب الأمثل للأعمدة. راجع “اختيار مفتاح أساسي.” ينبغي تغيير المفاتيح الأساسية لكل جدول على حدة. فما يكون مناسبًا للسجلات قد لا يكون مناسبًا للتتبعات أو المقاييس.

اختيار مفتاح أساسي

أولًا، حدِّد ما إذا كانت أنماط الوصول لديك تختلف بشكل ملحوظ عن الإعدادات الافتراضية لجدول معيّن. على سبيل المثال، إذا كنت في الغالب تُصفّي السجلات حسب عقدة Kubernetes قبل اسم الخدمة، وكان ذلك يمثّل سير عمل رئيسيًا، فقد يبرّر هذا تغيير المفتاح الأساسي.
تعديل المفتاح الأساسي الافتراضيتكون المفاتيح الأساسية الافتراضية كافية في معظم الحالات. يجب إجراء التغييرات بحذر، وفقط مع فهم واضح لأنماط الاستعلامات. قد يؤدي تعديل المفتاح الأساسي إلى تراجع الأداء في مسارات عمل أخرى، لذا يُعد الاختبار أمرًا ضروريًا.
بمجرد استخراج الأعمدة المطلوبة، يمكنك البدء في تحسين مفتاح الترتيب/المفتاح الأساسي. يمكن تطبيق بعض القواعد البسيطة للمساعدة في اختيار مفتاح الترتيب. قد تتعارض القواعد التالية أحيانًا، لذا خذها في الاعتبار بهذا الترتيب. واستهدف اختيار 4 إلى 5 مفاتيح كحد أقصى عبر هذه العملية:
  1. اختر الأعمدة التي تتوافق مع عوامل التصفية الشائعة وأنماط الوصول لديك. إذا كنت تبدأ عادةً تحقيقات Observability بالتصفية حسب عمود معيّن، مثل اسم الـ pod، فسيُستخدم هذا العمود كثيرًا في عبارات WHERE. أعطِ الأولوية لإدراج هذه الأعمدة في مفتاحك على حساب الأعمدة الأقل استخدامًا.
  2. فضّل الأعمدة التي تساعد، عند التصفية، على استبعاد نسبة كبيرة من إجمالي الصفوف، مما يقلّل كمية البيانات التي يلزم قراءتها. غالبًا ما تكون أسماء الخدمات ورموز الحالة مرشّحات جيدة — وفي الحالة الثانية، فقط إذا كنت تُصفّي بحسب قيم تستبعد معظم الصفوف. فعلى سبيل المثال، التصفية حسب الرموز 200 ستطابق، في معظم الأنظمة، معظم الصفوف، بخلاف أخطاء 500 التي ستنطبق على مجموعة فرعية صغيرة.
  3. فضّل الأعمدة التي يُرجَّح أن تكون شديدة الارتباط بأعمدة أخرى في الجدول. سيساعد ذلك على ضمان تخزين هذه القيم أيضًا بشكل متجاور، مما يحسّن الضغط.
  4. يمكن جعل عمليات GROUP BY (التجميعات الخاصة بالمخططات) وORDER BY (الفرز) للأعمدة الموجودة في مفتاح الترتيب أكثر كفاءة من حيث استهلاك الذاكرة.
بعد تحديد المجموعة الفرعية من الأعمدة الخاصة بمفتاح الترتيب، يجب تحديدها بترتيب معيّن. ويمكن لهذا الترتيب أن يؤثر بشكل كبير في كلٍّ من كفاءة التصفية على الأعمدة الثانوية للمفتاح في الاستعلامات ونسبة الضغط لملفات بيانات الجدول. وبوجه عام، من الأفضل ترتيب المفاتيح تصاعديًا حسب الكاردينالية. ويجب موازنة ذلك مع حقيقة أن التصفية على الأعمدة التي تظهر لاحقًا في مفتاح الترتيب ستكون أقل كفاءة من التصفية على الأعمدة التي تظهر مبكرًا في الـ tuple. وازن بين هذين الأمرين وراعِ أنماط الوصول لديك. والأهم من ذلك، اختبر البدائل. ولمزيد من الفهم لمفاتيح الترتيب وكيفية تحسينها، يُوصى بقراءة “اختيار مفتاح أساسي.” وللحصول على فهم أعمق لضبط المفتاح الأساسي وبُنى البيانات الداخلية، راجع “مقدمة عملية إلى primary indexes المتفرقة في ClickHouse.”

تغيير المفتاح الأساسي

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

إنشاء جدول جديد

CREATE TABLE otel_logs_temp AS otel_logs
PRIMARY KEY (SeverityText, ServiceName, Timestamp)
ORDER BY (SeverityText, ServiceName, Timestamp)
مفتاح الترتيب مقابل المفتاح الأساسيلاحظ أنه في المثال أعلاه، يجب تحديد PRIMARY KEY وORDER BY. في ClickStack، يكونان متطابقين تقريبًا في معظم الحالات. يتحكم ORDER BY في التخطيط الفعلي للبيانات، بينما يحدد PRIMARY KEY الفهرس المتناثر. وفي حالات نادرة جدًا ومع أحمال العمل الكبيرة للغاية، قد يختلفان، لكن ينبغي لمعظم المستخدمين إبقاؤهما متوافقين.
2

EXCHANGE وحذف الجدول

تُستخدم عبارة EXCHANGE لتبديل أسماء الجداول بشكل ذري. بعد ذلك، يمكن حذف الجدول المؤقت (الذي أصبح الآن الجدول الافتراضي القديم).
EXCHANGE TABLES otel_logs_temp AND otel_logs
DROP TABLE otel_logs_temp
ومع ذلك، لا يمكن تعديل المفتاح الأساسي في جدول موجود. فتغييره يتطلب إنشاء جدول جديد. يمكن استخدام العملية التالية لضمان الاحتفاظ بالبيانات القديمة مع استمرار الاستعلام عنها بشفافية (باستخدام مفتاحها الحالي في ClickStack UI، إذا لزم الأمر)، مع إتاحة البيانات الجديدة عبر جدول جديد مُحسَّن وفقًا لأنماط وصول المستخدمين. ويضمن هذا النهج عدم الحاجة إلى تعديل مسارات إدخال البيانات، إذ تظل البيانات تُرسل إلى أسماء الجداول الافتراضية، وتبقى جميع التغييرات شفافة للمستخدمين.
نادرًا ما تكون إعادة تحميل البيانات التاريخية الموجودة إلى جدول جديد مجدية على نطاق واسع. فعادةً ما تكون تكلفة المعالجة وعمليات IO مرتفعة، ولا تبرر مكاسب الأداء. وبدلًا من ذلك، اترك البيانات الأقدم تنتهي صلاحيتها عبر TTL، بينما تستفيد البيانات الأحدث من المفتاح المُحسَّن.
يُستخدم أدناه المثال نفسه، حيث يُجعل SeverityText العمود الأول في المفتاح الأساسي. وفي هذه الحالة، يُنشأ جدول للبيانات الجديدة مع الاحتفاظ بالجدول القديم لأغراض التحليل التاريخي.
1

إنشاء جدول جديد

أنشئ الجدول الجديد بالمفتاح الأساسي المطلوب. لاحظ اللاحقة _23_01_2025 — عدّلها لتكون التاريخ الحالي. على سبيل المثال:
CREATE TABLE otel_logs_23_01_2025 AS otel_logs
PRIMARY KEY (SeverityText, ServiceName, Timestamp)
ORDER BY (SeverityText, ServiceName, Timestamp)
2

إنشاء جدول Merge

لا يخزّن محرك Merge (ويجب عدم الخلط بينه وبين MergeTree) البيانات بنفسه، لكنه يتيح القراءة من أي عدد من الجداول الأخرى في الوقت نفسه.
CREATE TABLE otel_logs_merge
AS otel_logs
ENGINE = Merge(currentDatabase(), 'otel_logs*')
تفترض currentDatabase() أن الأمر يُشغَّل في قاعدة البيانات الصحيحة. وإلا، فحدّد اسم قاعدة البيانات صراحةً.
يمكنك الآن تنفيذ استعلام على هذا الجدول للتأكد من أنه يعيد بيانات من otel_logs.
3

تحديث ClickStack UI للقراءة من جدول Merge

اضبط ClickStack UI لاستخدام otel_logs_merge بصفته الجدول الخاص بمصدر بيانات السجلات.في هذه المرحلة، تستمر عمليات الكتابة إلى otel_logs باستخدام المفتاح الأساسي الأصلي، بينما تستخدم عمليات القراءة جدول Merge. لا يوجد أي تغيير مرئي للمستخدمين أو أي تأثير على استيعاب البيانات.
4

تبديل الجداول

تُستخدم الآن عبارة EXCHANGE لتبديل اسمي الجدولين otel_logs وotel_logs_23_01_2025 تبديلًا ذريًا.
EXCHANGE TABLES otel_logs AND otel_logs_23_01_2025
تتجه عمليات الكتابة الآن إلى جدول otel_logs الجديد بالمفتاح الأساسي المحدّث. وتبقى البيانات الحالية في otel_logs_23_01_2025، ولا تزال متاحة عبر جدول Merge. وتشير اللاحقة إلى تاريخ تطبيق التغيير، وتمثّل أحدث timestamp موجود في ذلك الجدول.تتيح هذه العملية تغيير المفتاح الأساسي من دون انقطاع في استيعاب البيانات ومن دون أي تأثير مرئي للمستخدمين.
يمكن تكييف هذه العملية إذا لزم إجراء مزيد من التغييرات على المفاتيح الأساسية. على سبيل المثال، إذا قررت بعد أسبوع أن SeverityNumber يجب أن يكون جزءًا من المفتاح الأساسي بدلًا من SeverityText. ويمكن تكرار العملية التالية كلما دعت الحاجة إلى تغيير المفتاح الأساسي.
1

إنشاء جدول جديد

أنشئ الجدول الجديد بالمفتاح الأساسي المطلوب. في المثال أدناه، استُخدم 30_01_2025 بوصفه لاحقة للدلالة على تاريخ الجدول. على سبيل المثال:
CREATE TABLE otel_logs_30_01_2025 AS otel_logs
PRIMARY KEY (SeverityNumber, ServiceName, TimestampTime)
ORDER BY (SeverityNumber, ServiceName, TimestampTime)
2

تبديل الجداول

تُستخدم الآن عبارة EXCHANGE لتبديل اسمي الجدولين otel_logs وotel_logs_30_01_2025 تبديلًا ذريًا.
EXCHANGE TABLES otel_logs AND otel_logs_30_01_2025
تتجه عمليات الكتابة الآن إلى جدول otel_logs الجديد بالمفتاح الأساسي المحدّث. وتبقى البيانات القديمة في otel_logs_30_01_2025، ويمكن الوصول إليها عبر جدول Merge.
الجداول الزائدةإذا كانت سياسات TTL مطبّقة، وهو ما يُنصح به، فإن الجداول ذات المفاتيح الأساسية الأقدم التي لم تعد تستقبل عمليات كتابة ستفرغ تدريجيًا مع انتهاء صلاحية البيانات. ويجب مراقبتها وتنظيفها دوريًا بمجرد أن تصبح خالية من البيانات. وحتى الآن، تتم عملية التنظيف هذه يدويًا.

تسريع البحث عن الصفوف باستخدام أعمدة الكتل

يُفعِّل مخطط السجلات الافتراضي في ClickStack إعدادين من إعدادات MergeTree لا يؤثران مباشرةً في أداء الاستعلامات، لكنهما يسرّعان بشكل ملحوظ عمليات البحث عن تفاصيل الصفوف في واجهة ClickStack:
SETTINGS enable_block_number_column = 1, enable_block_offset_column = 1
باستخدام هذه الإعدادات، يحمل كل صف في الجدول زوجًا ضمنيًا (_block_number, _block_offset) يحدده بشكل فريد داخل جزء. عند النقر على صف سجل في ClickStack UI لفتح لوحة التفاصيل، يُجري ClickStack استعلامًا لاحقًا لجلب ذلك الصف وحده. من دون أعمدة الكتل، يجب أن تتضمن عبارة WHERE الخاصة بالصف عددًا كافيًا من الأعمدة — عادةً المفتاح الأساسي بالإضافة إلى Body وSeverityText — لتمييز الصف. ومع أعمدة الكتل، يكفي المفتاح الأساسي مع _block_number و_block_offset. أما الأعمدة الكبيرة مثل Body فلا تُقرأ مطلقًا أثناء عملية البحث، مما يسرّع الاستعلام فعليًا. يكتشف ClickStack هذا الإعداد من تعليمة CREATE الخاصة بالجدول، ويُنشئ عبارة WHERE الأخف تلقائيًا عند تمكين كلا العمودين. ولا يلزم إجراء أي تغيير في إعدادات التطبيق. للاستفادة من هذا التحسين في جدول سجل أو تتبّع موجود:
ALTER TABLE otel_logs
MODIFY SETTING enable_block_number_column = 1, enable_block_offset_column = 1
تنطبق هذه الإعدادات على البيانات المكتوبة بعد ALTER. وتستمر الأجزاء الموجودة في استخدام آلية البحث القديمة لكل صف إلى أن تُعاد كتابتها عبر عملية دمج.

التحسين 5. الاستفادة من العروض المُجسَّدة

يمكن لـ ClickStack الاستفادة من العروض المُجسَّدة التزايدية لتسريع المرئيات التي تعتمد على استعلامات كثيفة التجميع، مثل حساب متوسط مدة الطلب لكل دقيقة بمرور الوقت. يمكن لهذه الميزة أن تحسّن أداء الاستعلامات بشكل كبير، وتكون فائدتها عادةً أكبر في عمليات النشر الأكبر حجمًا، عند نحو 10 تيرابايت يوميًا فما فوق، مع إتاحة التوسّع إلى نطاق بيتابايتات يوميًا. لا تزال العروض المُجسَّدة التزايدية في مرحلة Beta وينبغي استخدامها بحذر. للاطلاع على تفاصيل استخدام هذه الميزة في ClickStack، راجع دليلنا المخصص “ClickStack - Materialized Views.”

التحسين 6. الاستفادة من الإسقاطات

تمثل الإسقاطات تحسينًا أخيرًا ومتقدمًا يمكن النظر فيه بعد تقييم الأعمدة المُجسَّدة، وفهارس التخطي، والمفاتيح الأساسية، والعروض المُجسَّدة. ومع أن الإسقاطات والعروض المُجسَّدة قد تبدوان متشابهتين، فإن لكلٍّ منهما في ClickStack غرضًا مختلفًا، ويُفضَّل استخدام كل واحدة منهما في سيناريوهات مختلفة.
من الناحية العملية، يمكن النظر إلى الإسقاط على أنه نسخة إضافية مخفية من الجدول تخزن الصفوف نفسها ولكن بترتيب مادي مختلف. وهذا يمنح الإسقاط فهرسًا أساسيًا خاصًا به، منفصلًا عن مفتاح ORDER BY الخاص بالجدول الأساسي، مما يتيح لـ ClickHouse استبعاد البيانات بفاعلية أكبر لأنماط الوصول التي لا تتوافق مع الترتيب الأصلي. يمكن للعروض المُجسَّدة تحقيق أثر مشابه من خلال كتابة الصفوف صراحةً إلى جدول هدف منفصل بمفتاح ترتيب مختلف. ويتمثل الفرق الأساسي في أن الإسقاطات تُدار تلقائيًا وبشفافية بواسطة ClickHouse، بينما العروض المُجسَّدة هي جداول صريحة يجب أن يسجلها ClickStack ويختارها عمدًا. عندما يستهدف الاستعلام الجدول الأساسي، يقيّم ClickHouse التخطيط الأساسي وأي إسقاطات متاحة، ويأخذ عينات من فهارسها الأساسية، ثم يختار التخطيط القادر على إنتاج النتيجة الصحيحة مع قراءة أقل عدد من الحبيبات. ويُتخذ هذا القرار تلقائيًا بواسطة محلل الاستعلامات. لذلك، تُعد الإسقاطات في ClickStack الأنسب لـ إعادة ترتيب البيانات فقط، في الحالات التالية:
  • تكون أنماط الوصول مختلفة جوهريًا عن المفتاح الأساسي الافتراضي
  • يكون من غير العملي تغطية جميع مسارات العمل بمفتاح ترتيب واحد
  • تريد أن يختار ClickHouse التخطيط المادي الأمثل بشفافية
أما بالنسبة إلى التجميع المسبق وتسريع المقاييس، فإن ClickStack يفضّل بشدة العروض المُجسَّدة الصريحة، لأنها تمنح طبقة التطبيق تحكمًا كاملًا في اختيار العرض واستخدامه. للاطلاع على خلفية إضافية، انظر:

مثال: الإسقاطات

افترض أن جدول التتبعات لديك مُحسَّن وفق نمط الوصول الافتراضي في ClickStack:
ORDER BY (ServiceName, SpanName, toDateTime(Timestamp))
إذا كان لديك أيضًا سير عمل رئيسي يعتمد على التصفية حسب TraceId (أو يعتمد كثيرًا على التجميع والتصفية استنادًا إليه)، فيمكنك إضافة إسقاط يخزّن الصفوف مرتّبة حسب TraceId والوقت:
ALTER TABLE otel_v2.otel_traces
ADD PROJECTION prj_traceid_time
(
    SELECT *
    ORDER BY (TraceId, toDateTime(Timestamp))
);
استخدم رموز البدلفي مثال الإسقاط أعلاه، استُخدم رمز بدل (SELECT *). ومع أن اختيار مجموعة فرعية من الأعمدة قد يقلّل من الكلفة الإضافية للكتابة، فإنه يحدّ أيضًا من الحالات التي يمكن فيها استخدام الإسقاط، لأن الاستعلامات المؤهلة هي فقط تلك التي يمكن تلبيتها بالكامل من خلال هذه الأعمدة. في ClickStack، يقيّد هذا غالبًا استخدام الإسقاط بحالات محدودة جدًا. لذلك، يُنصح عمومًا باستخدام رمز بدل لتوسيع نطاق الاستخدام إلى أقصى حد.
كما هو الحال مع تغييرات تخطيط البيانات الأخرى، لا يؤثر الإسقاط إلا في الأجزاء المكتوبة حديثًا. ولتطبيقه على البيانات الحالية، قم بتمثيله فعليًا:
ALTER TABLE otel_v2.otel_traces
MATERIALIZE PROJECTION prj_traceid_time;
قد يستغرق تجسيد الإسقاط وقتًا طويلًا ويستهلك قدرًا كبيرًا من الموارد. ولأن بيانات observability تنتهي صلاحيتها عادةً عبر TTL، فلا ينبغي اللجوء إلى ذلك إلا عند الضرورة القصوى. وفي معظم الحالات، يكفي أن يقتصر تطبيق الإسقاط على البيانات المُدخلة حديثًا، مما يتيح له تحسين النطاقات الزمنية الأكثر ورودًا في الاستعلامات، مثل آخر 24 ساعة.
قد يختار ClickHouse الإسقاط تلقائيًا عندما يقدّر أنه سيفحص عددًا أقل من الحبيبات مقارنةً بالبنية الأساسية. وتكون الإسقاطات أكثر موثوقية عندما تمثل إعادة ترتيب مباشرة لمجموعة الصفوف الكاملة (SELECT *)، وعندما تتوافق فلاتر الاستعلام بدرجة كبيرة مع ORDER BY الخاص بالإسقاط. ستستفيد الاستعلامات التي تُجري تصفية حسب TraceId (وخاصةً باستخدام المساواة) وتتضمن نطاقًا زمنيًا من الإسقاط المذكور أعلاه. على سبيل المثال:
-- Fetch a specific trace quickly
SELECT *
FROM otel_traces
WHERE TraceId = 'aeea7f401feb75fc5af8eb25ebc8e974'
  AND Timestamp >= now() - INTERVAL 1 DAY
ORDER BY Timestamp;

-- Trace-scoped aggregation
SELECT
  toStartOfMinute(Timestamp) AS t,
  count() AS spans
FROM otel_traces
WHERE TraceId = 'aeea7f401feb75fc5af8eb25ebc8e974'
  AND Timestamp >= now() - INTERVAL 1 DAY
GROUP BY t
ORDER BY t;
الاستعلامات التي لا تقيّد TraceId، أو التي تعتمد أساسًا على التصفية وفق أبعاد أخرى لا تقع في مقدمة مفتاح ترتيب الإسقاط، لن تستفيد عادةً من ذلك (وقد تُقرأ عبر البنية الأساسية بدلًا من ذلك).
يمكن للإسقاطات أيضًا تخزين عمليات التجميع (على غرار العروض المُجسَّدة). في ClickStack، لا يُنصح عمومًا بعمليات التجميع المعتمدة على الإسقاطات، لأن اختيارها يعتمد على محلّل ClickHouse، كما أن استخدامَها قد يكون أصعب من حيث التحكم فيه وفهمه. وبدلًا من ذلك، يُفضَّل استخدام العروض المُجسَّدة الصريحة التي يمكن لـ ClickStack تسجيلها واختيارها عمدًا على مستوى التطبيق.
عمليًا، تكون الإسقاطات الأنسب لسير العمل الذي تنتقل فيه كثيرًا من بحث أوسع إلى التعمق التفصيلي المتمحور حول التتبّع (على سبيل المثال، جلب جميع وحدات span الخاصة بـ TraceId محدد).

التكاليف والإرشادات

  • عبء insert الإضافي: إن إسقاط SELECT * ذي مفتاح ترتيب مختلف يعني فعليًا كتابة البيانات مرتين، ما يزيد من I/O للكتابة وقد يتطلب موارد CPU إضافية ومعدل نقل أعلى للقرص للحفاظ على معدل الإدخال.
  • استخدمها باعتدال: من الأفضل حجز الإسقاطات لأنماط الوصول المتباينة فعلًا، حيث يتيح ترتيب مادي ثانٍ تقليصًا فعّالًا للبيانات لعدد كبير من الاستعلامات، مثل حالتي فريقين يستعلمان عن مجموعة البيانات نفسها بطرائق مختلفة جذريًا.
  • تحقق باستخدام اختبارات قياس الأداء: كما هو الحال مع جميع عمليات الضبط، قارن بين زمن استجابة الاستعلامات الفعلي واستخدام الموارد قبل إضافة إسقاط وتجسيده وبعده.
للاطلاع على خلفية أعمق، انظر:

الإسقاطات خفيفة الوزن باستخدام _part_offset

الإسقاطات خفيفة الوزن في ClickStack ما تزال Betaلا يُوصى باستخدام الإسقاطات خفيفة الوزن المستندة إلى _part_offset مع أعباء عمل ClickStack. فعلى الرغم من أنها تقلّل التخزين وعمليات I/O الخاصة بالكتابة، فقد تؤدي إلى زيادة الوصول العشوائي وقت الاستعلام، كما أن سلوكها في بيئات production وعلى نطاق observability ما يزال قيد التقييم. وقد تتغير هذه التوصية مع نضج هذه الميزة وتوفّر مزيد من البيانات التشغيلية.
تدعم الإصدارات الأحدث من ClickHouse أيضًا إسقاطات أكثر خفة لا تخزّن سوى مفتاح فرز الإسقاط، بالإضافة إلى مؤشّر _part_offset إلى base table، بدلًا من تكرار الصفوف كاملة. ويمكن أن يخفّض ذلك بشكل كبير overhead التخزين، كما تتيح التحسينات الأخيرة إجراء pruning على مستوى granule، مما يجعلها أقرب في سلوكها إلى الفهارس الثانوية الحقيقية. انظر:

البدائل

إذا كنت بحاجة إلى مفاتيح ORDER BY متعددة، فليست الإسقاطات الخيار الوحيد. وبحسب القيود التشغيلية والطريقة التي تريد أن يوجّه بها ClickStack الاستعلامات، ضع في اعتبارك ما يلي:
  • تهيئة مجمّع OpenTelemetry لديك للكتابة إلى جدولين بمفاتيح ORDER BY مختلفة، وإنشاء مصادر ClickStack منفصلة لكل جدول.
  • إنشاء عرض مُجسَّد كمسار نسخ؛ أي إرفاق عرض مُجسَّد بالجدول الرئيسي بحيث يحدّد الصفوف الخام ويكتبها إلى جدول ثانوي بمفتاح ORDER BY مختلف (كنمط لإزالة التطبيع أو التوجيه). أنشئ مصدرًا لهذا الجدول الهدف. يمكن العثور على أمثلة هنا.
آخر تعديل في ٢٥ يونيو ٢٠٢٦