الانتقال إلى المحتوى الرئيسي
إزالة التكرار هي عملية حذف الصفوف المكررة من مجموعة بيانات. في قاعدة بيانات OLTP، يُنجَز ذلك بسهولة لأن لكل صف مفتاحًا أساسيًا فريدًا، لكن على حساب إبطاء عمليات الإدراج. إذ يجب أولًا البحث عن كل صف مُدرَج، وإذا وُجد، فيجب استبداله. صُمم ClickHouse ليوفر السرعة في إدراج البيانات. ملفات التخزين غير قابلة للتغيير، ولا يتحقق ClickHouse من وجود مفتاح أساسي مسبقًا قبل إدراج صف، لذا فإن إزالة التكرار تتطلب جهدًا إضافيًا قليلًا. وهذا يعني أيضًا أن إزالة التكرار ليست فورية، بل تتحقق لاحقًا، ما يترتب عليه بعض الآثار الجانبية:
  • في أي لحظة، قد يظل جدولك يحتوي على صفوف مكررة (صفوف لها مفتاح الفرز نفسه)
  • يحدث الحذف الفعلي للصفوف المكررة أثناء دمج الأجزاء
  • يجب أن تراعي استعلاماتك احتمال وجود صفوف مكررة
يوفّر ClickHouse تدريبًا مجانيًا حول إزالة التكرار والعديد من الموضوعات الأخرى. وتُعد Deleting and Updating Data training module نقطة انطلاق جيدة.

خيارات إزالة التكرار

تُنفَّذ إزالة التكرار في ClickHouse باستخدام محركات الجداول التالية:
  1. محرك الجدول ReplacingMergeTree: مع محرك الجدول هذا، تُزال الصفوف المكررة ذات مفتاح الترتيب نفسه أثناء عمليات الدمج. ويُعد ReplacingMergeTree خيارًا جيدًا لمحاكاة سلوك upsert (عندما تريد أن تُرجع الاستعلامات آخر صف تم إدراجه).
  2. طيّ الصفوف: يستخدم محركا الجدول CollapsingMergeTree وVersionedCollapsingMergeTree منطقًا يُلغى فيه صف موجود ويُدرج صف جديد. وهما أكثر تعقيدًا في التنفيذ من ReplacingMergeTree، لكن يمكن أن تكون الاستعلامات وعمليات التجميع أبسط في الكتابة دون الحاجة إلى القلق بشأن ما إذا كانت البيانات قد دُمجت بالفعل أم لا. ويكون محركا الجدول هذان مفيدين عندما تحتاج إلى تحديث البيانات بشكل متكرر.
نستعرض كلتا هاتين الطريقتين أدناه. ولمزيد من التفاصيل، اطّلع على Deleting and Updating Data training module المجانية والمتاحة عند الطلب.

استخدام ReplacingMergeTree لعمليات upsert

لنلقِ نظرة على مثال بسيط لجدول يحتوي على تعليقات Hacker News مع عمود views يمثّل عدد مرات مشاهدة التعليق. لنفترض أننا نُدرج صفًا جديدًا عند نشر مقال، ثم نُجري upsert بإضافة صف جديد مرة واحدة يوميًا يتضمن إجمالي عدد المشاهدات إذا زادت القيمة:
CREATE TABLE hackernews_rmt (
    id UInt32,
    author String,
    comment String,
    views UInt64
)
ENGINE = ReplacingMergeTree
PRIMARY KEY (author, id)
لنُدرِج صفَّين:
INSERT INTO hackernews_rmt VALUES
   (1, 'ricardo', 'This is post #1', 0),
   (2, 'ch_fan', 'This is post #2', 0)
لتحديث العمود views، أدرِج صفًا جديدًا بالمفتاح الأساسي نفسه (لاحظ القيم الجديدة للعمود views):
INSERT INTO hackernews_rmt VALUES
   (1, 'ricardo', 'This is post #1', 100),
   (2, 'ch_fan', 'This is post #2', 200)
يحتوي الجدول الآن على 4 صفوف:
SELECT *
FROM hackernews_rmt
┌─id─┬─author──┬─comment─────────┬─views─┐
│  2 │ ch_fan  │ This is post #2 │     0 │
│  1 │ ricardo │ This is post #1 │     0 │
└────┴─────────┴─────────────────┴───────┘
┌─id─┬─author──┬─comment─────────┬─views─┐
│  2 │ ch_fan  │ This is post #2 │   200 │
│  1 │ ricardo │ This is post #1 │   100 │
└────┴─────────┴─────────────────┴───────┘
تُظهر المربعات المنفصلة أعلاه في المخرجات الجزأين الفعليين في الخلفية — لم تُدمج هذه البيانات بعد، لذا لم تُزل الصفوف المكررة بعد. لنستخدم الكلمة المفتاحية FINAL في استعلام SELECT، ما يؤدي إلى دمج منطقي لنتيجة الاستعلام:
SELECT *
FROM hackernews_rmt
FINAL
┌─id─┬─author──┬─comment─────────┬─views─┐
│  2 │ ch_fan  │ This is post #2 │   200 │
│  1 │ ricardo │ This is post #1 │   100 │
└────┴─────────┴─────────────────┴───────┘
تتضمن النتيجة صفَّين فقط، والصف الأخير المُدرَج هو الذي يُعاد.
يكون استخدام FINAL مناسبًا إذا كانت كمية البيانات صغيرة. أما إذا كنت تتعامل مع كمية كبيرة من البيانات، فربما لا يكون استخدام FINAL الخيار الأفضل. لنناقش خيارًا أفضل للعثور على أحدث قيمة في عمود.

تجنّب FINAL

لنُحدِّث العمود views مرة أخرى لكلٍّ من الصفّين الفريدين:
INSERT INTO hackernews_rmt VALUES
   (1, 'ricardo', 'This is post #1', 150),
   (2, 'ch_fan', 'This is post #2', 250)
يحتوي الجدول الآن على 6 صفوف، لأن عملية دمج فعلية لم تتم بعد (وما حدث فقط هو الدمج وقت الاستعلام عند استخدام FINAL).
SELECT *
FROM hackernews_rmt
┌─id─┬─author──┬─comment─────────┬─views─┐
│  2 │ ch_fan  │ This is post #2 │   200 │
│  1 │ ricardo │ This is post #1 │   100 │
└────┴─────────┴─────────────────┴───────┘
┌─id─┬─author──┬─comment─────────┬─views─┐
│  2 │ ch_fan  │ This is post #2 │     0 │
│  1 │ ricardo │ This is post #1 │     0 │
└────┴─────────┴─────────────────┴───────┘
┌─id─┬─author──┬─comment─────────┬─views─┐
│  2 │ ch_fan  │ This is post #2 │   250 │
│  1 │ ricardo │ This is post #1 │   150 │
└────┴─────────┴─────────────────┴───────┘
بدلاً من استخدام FINAL، لنستخدم بعض منطق الأعمال — نحن نعلم أن العمود views يزداد دائماً، لذا يمكننا اختيار الصف ذي القيمة الأكبر باستخدام الدالة max بعد التجميع حسب الأعمدة المطلوبة:
SELECT
    id,
    author,
    comment,
    max(views)
FROM hackernews_rmt
GROUP BY (id, author, comment)
┌─id─┬─author──┬─comment─────────┬─max(views)─┐
│  2 │ ch_fan  │ This is post #2 │        250 │
│  1 │ ricardo │ This is post #1 │        150 │
└────┴─────────┴─────────────────┴────────────┘
قد يكون التجميع كما هو موضح في الاستعلام أعلاه أكثر كفاءة بالفعل (من حيث أداء الاستعلام) من استخدام الكلمة المفتاحية FINAL. تتوسع Deleting and Updating Data training module في شرح هذا المثال، بما في ذلك كيفية استخدام عمود version مع ReplacingMergeTree.

استخدام CollapsingMergeTree لتحديث الأعمدة بشكل متكرر

يتضمن تحديث عمود حذف صف موجود واستبداله بقيم جديدة. كما رأيت بالفعل، فإن هذا النوع من التعديل في ClickHouse يحدث في النهاية أثناء عمليات الدمج. وإذا كان لديك عدد كبير من الصفوف التي تحتاج إلى تحديث، فقد يكون من الأكثر كفاءة فعليًا تجنب ALTER TABLE..UPDATE والاكتفاء بإدراج البيانات الجديدة إلى جانب البيانات الحالية. يمكننا إضافة عمود يوضح ما إذا كانت البيانات قديمة أم جديدة… وفي الواقع، يوجد بالفعل محرك جدول يطبق هذا السلوك بكفاءة كبيرة، لا سيما أنه يحذف البيانات القديمة تلقائيًا نيابةً عنك. لنرَ كيف يعمل ذلك. لنفترض أننا نتتبع عدد مشاهدات تعليق على Hacker News باستخدام نظام خارجي، وكل بضع ساعات ندفع البيانات إلى ClickHouse. نريد حذف الصفوف القديمة وأن تمثل الصفوف الجديدة الحالة الجديدة لكل تعليق على Hacker News. يمكننا استخدام CollapsingMergeTree لتنفيذ هذا السلوك. لنعرّف جدولًا لتخزين عدد المشاهدات:
CREATE TABLE hackernews_views (
    id UInt32,
    author String,
    views UInt64,
    sign Int8
)
ENGINE = CollapsingMergeTree(sign)
PRIMARY KEY (id, author)
لاحظ أن الجدول hackernews_views يحتوي على عمود من النوع Int8 باسم sign، ويُشار إليه باسم عمود sign. اسم عمود الإشارة اختياري، لكن نوع البيانات Int8 مطلوب، ولاحظ أنه تم تمرير اسم العمود إلى مُنشئ جدول CollapsingMergeTree. ما عمود الإشارة في جدول CollapsingMergeTree؟ إنه يمثّل حالة الصف، ولا يمكن أن تكون قيمة عمود الإشارة إلا 1 أو -1. وإليك آلية عمله:
  • إذا كان لصفَّين المفتاح الأساسي نفسه (أو ترتيب الفرز إذا كان مختلفًا عن المفتاح الأساسي)، لكن كانت قيم عمود الإشارة فيهما مختلفة، فإن آخر صف أُدرج بقيمة +1 يصبح صف الحالة، وتُلغي الصفوف الأخرى بعضها بعضًا
  • تُحذف الصفوف التي يُلغي بعضها بعضًا أثناء عمليات الدمج
  • تُحتفَظ بالصفوف التي لا تملك زوجًا مطابقًا
لنُضِف صفًا إلى الجدول hackernews_views. وبما أنه الصف الوحيد لهذا المفتاح الأساسي، فسنضبط حالته على 1:
INSERT INTO hackernews_views VALUES
   (123, 'ricardo', 0, 1)
لنفترض الآن أننا نريد تغيير عمود المشاهدات. تُدرِج صفَّين: أحدهما يُلغي الصفَّ الحالي، والآخر يحتوي على الحالة الجديدة للصف:
INSERT INTO hackernews_views VALUES
   (123, 'ricardo', 0, -1),
   (123, 'ricardo', 150, 1)
يحتوي الجدول الآن على 3 صفوف ذات المفتاح الأساسي (123, 'ricardo'):
SELECT *
FROM hackernews_views
┌──id─┬─author──┬─views─┬─sign─┐
│ 123 │ ricardo │     0 │   -1 │
│ 123 │ ricardo │   150 │    1 │
└─────┴─────────┴───────┴──────┘
┌──id─┬─author──┬─views─┬─sign─┐
│ 123 │ ricardo │     0 │    1 │
└─────┴─────────┴───────┴──────┘
لاحظ أن استخدام FINAL يُرجع صف الحالة الحالي:
SELECT *
FROM hackernews_views
FINAL
┌──id─┬─author──┬─views─┬─sign─┐
│ 123 │ ricardo │   150 │    1 │
└─────┴─────────┴───────┴──────┘
لكن بالطبع، لا يُنصح باستخدام FINAL مع الجداول الكبيرة.
القيمة المُمرَّرة للعمود views في مثالنا ليست مطلوبة فعليًا، ولا يلزم أن تتطابق مع القيمة الحالية لـ views في الصف القديم. بل يمكنك في الواقع إلغاء صف باستخدام المفتاح الأساسي فقط والقيمة ‎-1:
INSERT INTO hackernews_views(id, author, sign) VALUES
   (123, 'ricardo', -1)

تحديثات آنية من خيوط تنفيذ متعددة

مع جدول CollapsingMergeTree، تُلغي الصفوف بعضها بعضًا باستخدام عمود الإشارة، وتُحدَّد حالة الصف بناءً على آخر صف تم إدراجه. لكن قد يصبح هذا إشكاليًا إذا كنت تُدرِج الصفوف من خيوط تنفيذ مختلفة، إذ قد تُدرَج الصفوف بترتيب غير متسق. لذلك فإن الاعتماد على الصف “الأخير” لا ينجح في هذه الحالة. وهنا تبرز فائدة VersionedCollapsingMergeTree — فهو يطوي الصفوف مثل CollapsingMergeTree تمامًا، لكنه بدلًا من الاحتفاظ بآخر صف تم إدراجه، يحتفظ بالصف ذي أعلى قيمة في عمود الإصدار الذي تحدده. لنلقِ نظرة على مثال. لنفترض أننا نريد تتبّع عدد مشاهدات تعليقات Hacker News لدينا، وأن البيانات تتحدّث باستمرار. نريد أن تستخدم التقارير أحدث القيم من دون فرض عمليات الدمج أو انتظار اكتمالها. نبدأ بجدول مشابه لـ CollapsedMergeTree، لكننا نضيف عمودًا لتخزين إصدار حالة الصف:
CREATE TABLE hackernews_views_vcmt (
    id UInt32,
    author String,
    views UInt64,
    sign Int8,
    version UInt32
)
ENGINE = VersionedCollapsingMergeTree(sign, version)
PRIMARY KEY (id, author)
لاحظ أن الجدول يستخدم VersionsedCollapsingMergeTree كمحرّك، ويحدّد عمود الإشارة وعمود الإصدار. إليك كيفية عمل الجدول:
  • يحذف كل زوج من الصفوف لهما المفتاح الأساسي نفسه والإصدار نفسه، مع اختلاف الإشارة
  • لا يهم الترتيب الذي أُدرجت به الصفوف
  • لاحظ أنه إذا لم يكن عمود الإصدار جزءًا من المفتاح الأساسي، فإن ClickHouse يضيفه ضمنيًا إلى المفتاح الأساسي باعتباره الحقل الأخير
تستخدم النوع نفسه من هذا المنطق عند كتابة الاستعلامات: اجمع حسب المفتاح الأساسي، واستخدم منطقًا مناسبًا لتجنّب الصفوف التي أُلغيت ولكن لم تُحذف بعد. لنُضف بعض الصفوف إلى جدول hackernews_views_vcmt:
INSERT INTO hackernews_views_vcmt VALUES
   (1, 'ricardo', 0, 1, 1),
   (2, 'ch_fan', 0, 1, 1),
   (3, 'kenny', 0, 1, 1)
نحدّث الآن صفّين ونحذف أحدهما. لإلغاء صف، تأكد من تضمين رقم الإصدار السابق (لأنه جزء من المفتاح الأساسي):
INSERT INTO hackernews_views_vcmt VALUES
   (1, 'ricardo', 0, -1, 1),
   (1, 'ricardo', 50, 1, 2),
   (2, 'ch_fan', 0, -1, 1),
   (3, 'kenny', 0, -1, 1),
   (3, 'kenny', 1000, 1, 2)
سنُنفّذ الاستعلام نفسه كما في السابق، والذي يضيف القيم ويطرحها بذكاء استنادًا إلى عمود الإشارة:
SELECT
    id,
    author,
    sum(views * sign)
FROM hackernews_views_vcmt
GROUP BY (id, author)
HAVING sum(sign) > 0
ORDER BY id ASC
النتيجة صفّان:
┌─id─┬─author──┬─sum(multiply(views, sign))─┐
│  1 │ ricardo │                         50 │
│  3 │ kenny   │                       1000 │
└────┴─────────┴────────────────────────────┘
لنفرض تنفيذ دمج للجدول:
OPTIMIZE TABLE hackernews_views_vcmt
يجب أن تحتوي النتيجة على صفّين فقط:
SELECT *
FROM hackernews_views_vcmt
┌─id─┬─author──┬─views─┬─sign─┬─version─┐
│  1 │ ricardo │    50 │    1 │       2 │
│  3 │ kenny   │  1000 │    1 │       2 │
└────┴─────────┴───────┴──────┴─────────┘
يُعد جدول VersionedCollapsingMergeTree مفيدًا جدًا عندما تريد تطبيق إزالة التكرار أثناء إدراج الصفوف من عدة عملاء و/أو خيوط تنفيذ.

لماذا لا تُزال الصفوف المكررة من صفوفي؟

أحد الأسباب التي قد تؤدي إلى عدم إزالة التكرار من الصفوف المُدرجة هو استخدام دالة أو تعبير غير متكرر النتائج في عبارة INSERT. على سبيل المثال، إذا كنت تُدرج صفوفًا مع العمود createdAt DateTime64(3) DEFAULT now()، فستكون صفوفك فريدة حتمًا لأن كل صف سيحصل على قيمة افتراضية فريدة للعمود createdAt. ولن يتمكن محرك الجدول MergeTree / ReplicatedMergeTree من إزالة التكرار من الصفوف، لأن كل صف مُدرج سينتج عنه checksum فريد. في هذه الحالة، يمكنك تحديد insert_deduplication_token خاص بك لكل دفعة من الصفوف لضمان ألا تؤدي عمليات الإدراج المتعددة للدفعة نفسها إلى إعادة إدراج الصفوف نفسها. يُرجى الاطلاع على الوثائق الخاصة بـ insert_deduplication_token لمزيد من التفاصيل حول كيفية استخدام هذا الإعداد.
آخر تعديل في ٢٩ يونيو ٢٠٢٦