- فهم الطريقة التي تُنشأ بها عروض المشروع وجداوله في ClickHouse.
- تحميل البيانات باستخدام seeds والتحكم في أنواع ClickHouse وبنية الجدول.
- تهيئة نموذج جدول بمحرك ClickHouse ومفتاح فرز وتقسيم.
- تحويل جدول إلى نموذج incremental واختيار strategy تزايدية.
- إنشاء snapshot.
- استخدام العروض المجسدة في ClickHouse.
قبل أن تبدأ
اتبع أولاً ملف README الخاص بـ ClickHouse/jaffle-shop-clickhouse، فهو يوضح كيفية إعداد المشروع باستخدام dbt Core 1.x أو dbt OSS أو dbt v2 أو منصة dbt، وكيفية توجيهه إلى ClickHouse محلي (docker) أو إلى ClickHouse Cloud، وكيفية تحميل بيانات العينة باستخدامdbt seed، وكيفية تشغيل أول dbt build. وبعد أن يكتمل dbt build بنجاح، عد إلى هنا للاطلاع على الأمثلة والتهيئات الخاصة بـ ClickHouse.
بعد إتمام خطوات README، ينبغي أن تكون لديك قاعدتا بيانات في ClickHouse:
raw: جداول المصدر الستة المحمَّلة من ملفات CSV بواسطةdbt seed(raw_customers،raw_orders،raw_items،raw_products،raw_stores،raw_supplies).jaffle_shop(قيمةschemaفي ملف الـ profile الخاص بك): ستة عروض staging (stg_*) وسبعة جداول mart (customers،orders،order_items،products،locations،supplies،metricflow_time_spine).
schema مختلفاً، فاستبدل jaffle_shop في الاستعلامات أدناه بالقيمة الخاصة بك.
dbt Core 1.x وdbt OSS وdbt v2 ومنصة dbt. كل أمر وكل نموذج في هذا الدليل هو نفسه في جميعها. اختُبرت الأمثلة باستخدام dbt Core 1.12 مع
dbt-clickhouse 1.10، وباستخدام dbt OSS 2.0 مع ClickHouse 26.8؛ ويعمل dbt v2 بالـ adapter نفسه، وتعمل منصة dbt بـ dbt v2. مخرجات الـ console المعروضة مأخوذة من dbt Core 1.x، وقد أُشير إلى المواضع القليلة التي يختلف فيها سلوك المحركين. راجع صفحة dbt OSS وdbt v2 ومنصة dbt لمعرفة الحالة الراهنة لـ adapter الإصدار v2، وراجع Connect ClickHouse في توثيق dbt للبدء على منصة dbt.clickhouse client، أو SQL console في ClickHouse Cloud، أو عميل SQL الذي تختاره.
كيف يتم تجسيد المشروع
يهيّئ Jaffle Shop تجسيداته فيdbt_project.yml: نماذج staging تكون views، أما marts فتكون tables.
CREATE OR REPLACE VIEW في كل تشغيل. وهو لا يخزّن أي بيانات، لذا لا يكلّف بناؤه شيئًا، لكن كل استعلام يُنفَّذ عليه يُشغّل SQL الخاص بالنموذج على الجداول المصدر. ويحتفظ ClickHouse بكود SQL المُصرَّف للنموذج في تعريف العرض:
INSERT INTO ... SELECT باستخدام SQL الخاص بالـ model، ثم يبادله بشكل ذرّي مع الإصدار السابق. وأداء الاستعلام هنا أفضل بكثير منه في العرض، لكن على حساب التخزين وإعادة بناء الجدول بالكامل في كل مرة. ألقِ نظرة على الجدول الذي أنشأه dbt لمستودع orders:
MergeTree، كما أنه لا يُعلِن عن sorting key، لذا يستخدم الـ adapter العبارة ORDER BY tuple()، أي أن البيانات غير مُرتَّبة على الإطلاق. وهذا مقبول في مشروع تجريبي، أما في جدول حقيقي فستحتاج إلى تحديد كليهما، وهو ما تتناوله الأقسام التالية. وتسرد صفحة materializations كل تهيئة جدول يدعمها الـ adapter.
تحميل البيانات باستخدام seeds
يستخدم Jaffle Shop seeds في dbt لتحميل بياناته الخام من ملفات CSV الموجودة فيseeds/jaffle-data. صُمّمت seeds للبيانات المرجعية الصغيرة والثابتة (جداول الرموز وعمليات الربط)، وليست مخصصة لتحميل warehouse؛ لكن المشروع يستخدمها لتسهيل الأمر عليك كي تبدأ دون الحاجة إلى أداة ingestion أخرى، ولهذا تبقى seeds معطّلة ما لم تمرّر --vars '{"load_source_data": true}'.
ومع ذلك، تظل seeds مكانًا جيدًا لتتعلّم كيف يُنشئ dbt جداول ClickHouse. فهو يستنتج نوع column لكل column في ملف CSV، وتختلف الأنواع المستنتجة بين المحركات:
وعندما يكون النوع مهمًا، ثبّته باستخدام
column_types. وقد فعل المشروع ذلك بالفعل مع column المسمى opened_at في seed raw_stores ضمن dbt_project.yml:
engine وorder_by وpartition_by. على سبيل المثال، لفرز الـ seed المسمى raw_orders حسب وقت الطلب وتقسيمه شهرياً، أضف ملف properties إلى جانب ملفات CSV، أي seeds/jaffle-data/_raw_orders.yml:
استخدم ملف properties لتهيئة seeds الخاصة بـ ClickHouse بدلاً من المفتاحين
+order_by أو +engine ضمن seeds: في dbt_project.yml. يقبل dbt Core 1.x كلا الشكلين، أما dbt v2 فلا يتعرّف عليهما إلا في ملف properties ويرفض مفاتيح dbt_project.yml مع الرسالة Unrecognized key ... Custom keys must go under +meta.dbt seed --full-refresh بحذف الجدول وإعادة إنشائه، لذا شغّله قبل بناء أي شيء يعتمد مباشرةً على بيانات الجدول، مثل الـ عرض مُجسَّد الوارد لاحقًا في هذا الدليل.
إعداد جدول لـ ClickHouse
يُعد الـ mart المسمىorders نقطة البداية الطبيعية: فهو الجدول الذي يستعلم منه mart المسمى customers ومقاييس المشروع، كما أنه جدول على نمط الأحداث يتضمن timestamp. أضف كتلة config في أعلى ملف models/marts/orders.sql لتحديد المحرك ومفتاح الفرز ومخطط التقسيم:
materialized='table' ما يحدده dbt_project.yml أصلاً لطبقة marts، وهو ما يجعل النموذج ذاتي الوصف عند تحويله لاحقاً إلى الوضع التزايدي. أعد بناء هذا النموذج فقط:
engine وorder_by وpartition_by، تقبل نماذج الجداول أيضًا primary_key وttl وsettings وquery_settings وprojections وindexes، ويمكن أن تحمل الأعمدة codec وttl عبر عقد النموذج (model contract). وجميعها موضّحة في صفحة materializations.
إنشاء model تزايدي
إعادة بناءorders من الصفر في كل تشغيل أمر مقبول مع 62,000 row، لكنه غير مناسب لـ table ينمو بملايين الـ rows يوميًا. أما التجسيد التزايدي في dbt فلا يعالج سوى الـ rows التي تغيّرت منذ التشغيل الأخير. ويتطلب تحويل model الخاص بـ orders إضافتين:
unique_key: الـ column الذي يعرّف الـ row، وهوorder_idهنا. ويستخدمه الـ adapter لاستبدال الـ rows التي تُعالج مرة أخرى بدلًا من تكرارها.- filter تزايدي: عبارة
whereمُحاطة بـ{% if is_incremental() %}تختار الـ rows المطلوب معالجتها فقط. وتُطبَّق في التشغيلات التزايدية، لا عند بناء الـ table للمرة الأولى (أو عند إعادة بنائه باستخدام--full-refresh). وبما أن الطلبات تحمل timestamp، فإن الـ filter يقارن قيمةordered_atبأحدث قيمة موجودة بالفعل في الـ table، والتي يُشار إليها عبر المتغير{{ this }}.
models/marts/orders.sql بحيث تصبح كتلة config ونهاية الـ model كما يلي:
stg_orders القيمة ordered_at إلى مستوى اليوم، لذا يستخدم الـ filter المعامل >=: ففي كل تشغيل تُعاد معالجة اليوم الأخير بالكامل، وبفضل unique_key تُستبدل الـ rows المحمَّلة مسبقًا بدلًا من تكرارها. وهذا ما يجعله آمنًا للطلبات التي تصل لاحقًا في اليوم نفسه.
شغّل الـ model. الـ table موجود بالفعل، لذا فإن هذا التشغيل الأول يُعد تشغيلًا incremental أصلًا: تُعاد معالجة اليوم الأخير فقط.
nutellaphone who dis? بسعر 11.00، والضريبة هي 6% الخاصة بـ Philadelphia، لذا تبقى اختبارات البيانات في المشروع ناجحة. شغّل المشروع بالكامل حتى تتعرّف عروض staging وجدول order_items على الصفوف الجديدة قبل أن يتعرّف عليها orders:
customers، الذي أُعيد بناؤه منه، يتعرّف على العميل الجديد:
البنية الداخلية
يُظهر سجل الاستعلامات في ClickHouse العبارات التي نفّذها الـ adapter لإجراء التحديث التزايدي:- يُنشأ جدول
orders__dbt_new_dataويُدرج فيه استعلام SQL الخاص بالـ model، بما في ذلك الـ filter التزايدي. في التشغيل أعلاه، كُتب 378 صفاً: 377 طلباً من آخر يوم تم تحميله مسبقاً، إضافةً إلى الطلب الجديد. - يُنشأ جدول
orders__dbt_tmpبالبنية نفسها لجدولorders، وتُنسخ إليه جميع صفوفordersالتي لا يوجدorder_idالخاص بها فيorders__dbt_new_data. - تُدرج جميع صفوف
orders__dbt_new_dataفيorders__dbt_tmp. والخطوتان 2 و3 هما ما يستبدل صفوف آخر يوم بدلاً من تكرارها. - يُحذف
orders__dbt_new_data. - يُبدَّل
orders__dbt_tmpمعordersباستخدام عبارةEXCHANGE TABLESالذرية (عبر إعادة تسمية وسيطة إلىorders__dbt_backup)، وبذلك يحتويordersالآن على النسخة الجديدة. - تُحذف النسخة القديمة.
Append strategy
تُدرج استراتيجيةappend الصفوف التي يحددها الـ model مباشرةً في الـ target table. فلا تُنشأ أي temporary tables ولا يُنسخ أي شيء، ما يجعلها أقل تكلفة ما يمكن أن يكون عليه تشغيل incremental. لكن الثمن هو عدم إجراء أي deduplicate أيضًا: فإذا حدّد الـ filter الخاص بـ incremental صفًا موجودًا بالفعل في الـ table، فستحصل عليه مرتين. استخدمها للبيانات غير القابلة للتغيير ذات النمط الحدثي، وتأكد من أن الـ filter لا يحدد إلا الصفوف الجديدة فعليًا.
ومع ordered_at المقتطع على مستوى اليوم، يعني ذلك تغيير الـ filter إلى >. عدّل الـ model:
orders هي عبارة INSERT INTO jaffle_shop.orders ... SELECT ... واحدة تتضمّن SQL الخاص بالـ model والـ filter التزايدي، وقد كتبت صفًّا واحدًا.
استراتيجية الحذف والإدراج
تاريخيًا، لم يقدّم ClickHouse سوى دعم محدود لعمليات التحديث والحذف، على هيئة mutations غير متزامنة. وقد تكون هذه العمليات مكثفة جدًا من ناحية IO ويُفضّل تجنّبها عمومًا. وقد أضاف ClickHouse 22.8 ميزة lightweight deletes، وأضاف ClickHouse 25.7 ميزة lightweight updates. وبفضلهما، يظهر أثر عبارة الحذف أو التحديث الواحدة فورًا من منظور المستخدم، رغم أن تجسيدها يتم بشكل غير متزامن. تعتمد استراتيجيةdelete+insert على lightweight deletes وتُهيَّأ عبر المعلمة incremental_strategy:
- يُنشأ جدول مؤقت (
orders__dbt_new_data_<run_id>) وتُدرج فيه الصفوف التي يحددها النموذج. - يُنفَّذ أمر
DELETEعلىordersلكلorder_idموجود في الجدول المؤقت. - تُدرج صفوف الجدول المؤقت في
orders. - يُحذف الجدول المؤقت.
استراتيجية insert overwrite (تجريبية)
تستبدل استراتيجيةinsert_overwrite تقسيمات كاملة، لذا فهي تتطلّب تهيئة partition_by مثل التهيئة الشهرية المستخدمة في orders. وهي تنفّذ الخطوات التالية:
- إنشاء staging table (
orders__dbt_new_data_<run_id>) بالبنية نفسها المستخدمة فيorders. - إدخال الصفوف التي حدّدها الـ model فقط في الـ staging table.
- سرد التقسيمات الموجودة في الـ staging table من
system.parts. - استبدال تلك التقسيمات تحديدًا في
ordersباستخدامALTER TABLE ... REPLACE PARTITION ... FROMمن الـ staging table. - حذف الـ staging table.
- أسرع من الاستراتيجية الافتراضية لأنه لا ينسخ الجدول بالكامل.
- أكثر أمانًا من الاستراتيجيات الأخرى لأنه لا يعدّل الجدول الأصلي إلى أن تكتمل عملية INSERT بنجاح؛ فإذا حدث فشل في منتصف العملية يبقى الجدول الأصلي كما هو.
- يطبّق أفضل ممارسات هندسة البيانات المتمثلة في “ثبات التقسيمات”، ما يبسّط معالجة البيانات التزايدية والمتوازية وعمليات التراجع وغيرها.
microbatch وon_schema_change.
إنشاء snapshot
تسجّل snapshots في dbt كيفية تغيّر صفوف جدول قابل للتعديل بمرور الوقت، بما يتيح للمحللين الاطلاع على حالة البيانات في أي لحظة سابقة. وهي تُطبّق الأبعاد بطيئة التغيّر من النوع 2: إذ يُخزَّن كل إصدار من الصف مع الفاصل الزمني الذي كان صالحًا خلاله. يُعد مستودع البياناتcustomers مرشحًا جيدًا لذلك: فالحقول count_lifetime_orders وlifetime_spend وcustomer_type تتغير جميعها كلما قدّم العميل طلبًا جديدًا. قبل المتابعة، أعد ضبط الـ model المسمى orders على استراتيجية الـ incremental الافتراضية الواردة في قسم incremental (بإزالة incremental_strategy='append' وإعادة الـ filter إلى >=)، حتى يتم التقاط الطلبات التي تُقدَّم لاحقًا في اليوم نفسه.
تُعرَّف اللقطات (snapshots) بصيغة YAML اعتبارًا من dbt 1.9. أنشئ الملف snapshots/customers_snapshot.yml:
check الـ columns المدرجة بين اللقطة الحالية والـ source في كل تشغيل، وتسجّل version جديدة عند تغيّر أي منها. وإذا كان الـ model يحتوي على column من نوع timestamp موثوق يمثّل “آخر تحديث”، فإن الـ strategy timestamp أقل تكلفة: اضبط strategy: timestamp وupdated_at: <column>. أما last_ordered_at في Jaffle Shop فهو مقتطع إلى مستوى اليوم، لذا لن يرصد طلبًا ثانيًا في اليوم نفسه، ولهذا يستخدم هذا المثال check.
خُذ أول snapshot:
generate_schema_name الخاص بالمشروع كل relation في schema الهدف بالنسبة للأهداف غير الإنتاجية، لذا فإن إعداد schema على الـ snapshot لا يسري إلا مع الهدف prod. ويحتوي الجدول على صف واحد لكل عميل، إلى جانب عمودَي التتبع الخاصين بـ dbt وهما dbt_valid_from وdbt_valid_to؛ ويكون الأخير NULL بالنسبة للإصدار الحالي من الصف:
orders وcustomers الطلب الجديد، ثم خُذ snapshot ثانيًا:
dbt_valid_to الخاصة بها، أما النسخة الجديدة، التي باتت تمثّل عميلاً returning لديه طلبان، فهي مفتوحة. ولم يطرأ أي تغيير على Danny، لذا بقي صفّه كما هو:
customers_snapshot__snapshot_upsert ثم يستبدلها باستخدام EXCHANGE TABLES (أو عبر drop وrename في الحالات التي لا يستطيع فيها الخادم تبديل الجداول)، بحيث يرى القُرّاء إمّا النسخة السابقة أو النسخة الجديدة من الـ snapshot. راجع قسم snapshot في صفحة materializations للاطلاع على مرجع التهيئة.
استخدام العروض المُجسَّدة
كل ما سبق يتطلّب تنفيذdbt run لإدخال البيانات الجديدة إلى الـ models. أما العروض المُجسَّدة في ClickHouse فتعمل بطريقة مختلفة: فهي بمثابة insert triggers. فكل كتلة من الصفوف تُدرَج في الـ source table يحوّلها الـ SELECT الخاص بالـ view ثم تُكتب في الـ target table، دون أي scheduling. ويوفّر الـ adapter الوصول إليها عبر الـ materialization المسمّى materialized_view.
أنشئ الملف models/marts/daily_store_revenue.sql ليحتوي على عدد الطلبات والإيرادات لكل متجر ولكل يوم، بالقراءة مباشرةً من جدول الطلبات الخام:
engine وorder_by على الجدول الهدف. ويجمع SummingMergeTree الأعمدة الرقمية للصفوف التي تتشارك نفس مفتاح الفرز (sorting key) عند دمج أجزاء البيانات، وهو تحديدًا ما يتطلبه التجميع لكل يوم ولكل متجر.
_mv، والتي تشير إلى الـ target table عبر عبارة TO. وافتراضيًا (catchup=True)، تم أيضًا إجراء backfill للـ target table بالطلبات الموجودة:
sum() وGROUP BY عن قصد: فمحرك SummingMergeTree لا يدمج الصفوف ذات المفتاح نفسه إلا عند دمج أجزاء البيانات في الخلفية، ولذلك يظل طلبا Brooklyn صفّين منفصلين في الجدول حتى ذلك الحين. لذا اجمع البيانات دائمًا عند القراءة (أو استخدم FINAL) مع محركات الجمع والتجميع. وفي الوقت نفسه، لا يزال النموذج التزايدي orders يحتوي على طلب واحد فقط لـ Danny حتى تنفيذ dbt run التالي.
تُبقي عمليات dbt run اللاحقة على الجدول الهدف وبياناته، ولا تحدّث سوى تعريف العرض، باستخدام ALTER TABLE ... MODIFY QUERY عندما يسمح التغيير بذلك، لذا من الآمن الإبقاء على النموذج ضمن المشروع. أما dbt run --full-refresh فيعيد بناء الجدول الهدف ويملؤه بالبيانات التاريخية من جديد (ما لم تكن قيمة catchup هي False). وتغطي صفحة العروض المجسّدة ما تبقّى: تغييرات المخطط عبر on_schema_change، وتعطيل الملء التاريخي باستخدام catchup، والعروض المجسّدة القابلة للتحديث، وتغذية عدة عروض للهدف نفسه، وتعريف الجدول الهدف كنموذج مستقل بذاته.