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

الوحدة 3 — المستندات: CRUD والإصدارات والاستيراد الضّخم بـ_bulk

Cluster يستجيب، والمفاهيم مُثبَّتة. يستطيع Sami أخيرًا التّعامل مع المستندات: إنشاء مقال، وتعديله، واسترجاعه، وحذفه، وفهم كيف يتفادى Elasticsearch عمليّات الكتابة المتزامنة التي تدهس البيانات. ثمّ، مع Inès، يُفهرس الكوربوس الكامل — 200 853 مقالًا في أقلّ من ثلاثين ثانية بفضل واجهة _bulk.

إنشاء مستند وقراءته وتعديله وحذفه

كلّ استعلامات الوحدة تُلصَق في Kibana Dev Tools. نتمرّن أوّلًا على فهرس صغير مخصَّص حتّى لا نخلط بالكوربوس الحقيقيّ.

PUT bac_a_sable
{
"settings": { "number_of_shards": 1, "number_of_replicas": 0 },
"mappings": {
"properties": {
"titre": { "type": "text" },
"auteur": { "type": "keyword" },
"vues": { "type": "integer" },
"date": { "type": "date", "format": "yyyy-MM-dd" }
}
}
}

PUT بـ_id مختار: مُتماثل التّطبيق

PUT bac_a_sable/_doc/1
{
"titre": "Change Is Here. Climate Change.",
"auteur": "Inès",
"vues": 0,
"date": "2026-09-09"
}

ردّ نموذجيّ:

{
"_index": "bac_a_sable",
"_id": "1",
"_version": 1,
"result": "created",
"_shards": { "total": 1, "successful": 1, "failed": 0 },
"_seq_no": 0,
"_primary_term": 1
}

أعد الاستعلام نفسه: يصير result هو updated، ويقفز _version إلى 2، ويتزايد _seq_no. PUT /index/_doc/1 يستبدل المستند كلّيًّا؛ هذه هي الكتابة المُتماثلة التّطبيق التي تريدها حين تملك مفتاحًا عمليًّا (رقم مقال، مرجع منتج).

POST بلا _id: يُولّده Elasticsearch

POST bac_a_sable/_doc
{
"titre": "Trump Abandons Commitment To 2-State Solution",
"auteur": "Sami",
"vues": 0,
"date": "2026-09-09"
}

_id هو معرِّف من عشرين محرفًا بترميز base64 (OFm5s5UB…). مفيد حين لا يكون لمستنداتك مفتاح عمليّ؛ يجب تجنّبه ما إن يوجد خطر لإعادة الاستيراد، وإلّا ستُنشئ تكرارات مع كلّ تشغيل.

PUT _create: يرفض إن كان _id موجودًا

PUT bac_a_sable/_create/1
{ "titre": "Doublon", "auteur": "Test", "vues": 0, "date": "2026-09-09" }

يُعيد خطأ version_conflict_engine_exception لأنّ 1 موجود. يُستعمل حين تُريد ضمان « لا تدهس شيئًا أبدًا ».

GET مستند دقيق

GET bac_a_sable/_doc/1

يُعيد _source (الـJSON المُرسَل) مع البيانات الوصفيّة _seq_no و_primary_term و_version وfound: true. _id غير موجود يُعيد found: false مع حالة HTTP 404.

بديلان مفيدان:

GET bac_a_sable/_source/1

يُعيد _source فقط، بلا غلاف.

GET bac_a_sable/_doc/1?_source_includes=titre,auteur

يسترجع مجموعة فرعيّة من الحقول — مفيد حين يكون _source ضخمًا.

POST _update مع doc: دمج جزئيّ

POST bac_a_sable/_update/1
{
"doc": {
"vues": 12
}
}

يتغيّر الحقل vues وحده؛ وتبقى الحقول الأخرى. يُعيد Elasticsearch قراءة المستند ويطبّق الترقيع ويُعيد الفهرسة. result يُعيد updated أو noop إن كان المحتوى مطابقًا لما هو مطلوب.

POST _update مع script: زيادة ذرّيّة

لزيادة عدد المشاهدات عند كلّ قراءة، دون الحاجة إلى القراءة ثمّ الكتابة:

POST bac_a_sable/_update/1
{
"script": {
"source": "ctx._source.vues += params.n",
"lang": "painless",
"params": { "n": 1 }
}
}

لغة painless معزولة، ومتوافقة بين الإصدارات، والتّرتيب على مستوى shard يجعل العمليّة ذرّيّة. هي الطّريقة النّظيفة لتجميع العدّادات بلا سباق بين الاستعلامات.

DELETE

DELETE bac_a_sable/_doc/1

يُميّز المستند كمحذوف. يُنظّفه shard ماديًّا عند merge التّالي في Lucene. عدّاد docs.deleted في _cat/indices يرتفع ثمّ يهبط بعد merge.

_mget: عدّة مستندات في استعلام واحد

GET bac_a_sable/_mget
{ "ids": ["1", "2", "3"] }

يُعيد مصفوفة docs مع، لكلّ معرِّف، found وربّما _source. مفيد لإعادة تحميل عشرين مفتاحًا دفعة واحدة.

الإصدارات المتفائلة: _seq_no و_primary_term

يُريد Karim تعديل مقال من الـAPI. Léa، بالتّوازي، تفعل الشّيء نفسه من Kibana. كيف نتفادى أن تدهس الكتابة الأبطأ الكتابةَ الأحدث؟ يعرض Elasticsearch زوجَين من القيم:

  • _version: عدد صحيح يتزايد عند كلّ كتابة، معلوماتيّ؛
  • _seq_no + _primary_term: الهويّة الحقيقيّة للنّسخة، محمولة على shard.

بروتوكول التّحكّم المتفائل يسع جملةً واحدة: اقرأ، ثمّ اكتب مُمرِّرًا _seq_no و_primary_term اللذين قرأتهما. إن كتب عميل آخر بين قراءتك وكتابتك، يُرفض استعلامك بـversion_conflict_engine_exception. تُعيد القراءة، وتُعيد حسابك، وتُعيد الكتابة.

GET bac_a_sable/_doc/1

نُلاحظ في الرّدّ _seq_no: 5، _primary_term: 1. نُحدِّث:

PUT bac_a_sable/_doc/1?if_seq_no=5&if_primary_term=1
{
"titre": "Titre revu",
"auteur": "Inès",
"vues": 99,
"date": "2026-09-09"
}

إن لم يتحرّك المستند، يمرّ الاستعلام. وإلّا، الحالة 409: version_conflict_engine_exception. أعد القراءة، وأعد الكتابة. هذه الآليّة هي ما يحمي عدّاداتك وحالاتك دون الحاجة إلى قفل.

لا معاملات على عدّة مستندات

لا يعرف Elasticsearch المعاملات ACID بين عدّة مستندات. كلّ مستند ذرّيّ منعزل؛ اتّساق عدّة مستندات معًا يُدار على الجانب التّطبيقيّ (idempotence، وإعادة التّشغيل). لنموذج معاملات حقيقيّ، Neo4j (الوحدة 12) أو قاعدة علائقيّة هي الأدوات المناسبة.

واجهة _bulk: صيغة NDJSON

إرسال 200 853 مقالًا بـ200 853 POST يستغرق ساعات. _bulk يقبل دفعات من آلاف العمليّات في استعلام HTTP واحد.

الصّيغة تُسمّى NDJSON (« newline-delimited JSON »): سطر عمليّة، سطر مستند، سطر جديد، سطر عمليّة، سطر مستند، سطر جديد. كلّ سطر ينتهي بـ\n، بما في ذلك الأخير. لا مصفوفة، ولا فواصل بين الكائنات.

POST _bulk
{ "index": { "_index": "bac_a_sable", "_id": "10" } }
{ "titre": "Un article", "auteur": "Léa", "vues": 0, "date": "2026-09-09" }
{ "index": { "_index": "bac_a_sable", "_id": "11" } }
{ "titre": "Un autre", "auteur": "Karim", "vues": 0, "date": "2026-09-09" }
{ "delete": { "_index": "bac_a_sable", "_id": "10" } }
{ "update": { "_index": "bac_a_sable", "_id": "11" } }
{ "doc": { "vues": 42 } }

أربع عمليّات ممكنة: index (يستبدل)، create (يفشل إن وُجد)، update (ترقيع)، delete (بلا سطر مستند). يُعيد Elasticsearch مصفوفة items بطول عدد العمليّات نفسه، مع حالة كلّ واحدة.

السّطر الأخير، فخّ كلاسيكيّ

_bulk بلا \n نهائيّ يُرفض بـThe bulk request must be terminated by a newline. هذا هو الفخّ الكلاسيكيّ حين نكتب _bulk يدويًّا. في Kibana Dev Tools، تُضيف وحدة التّحكّم السّطر الجديد تلقائيًّا؛ أمّا في سكربت فيجب فرضه.

فهرسة 200 853 مقالًا بـ./lab.sh import-news

تُغلّف الحقيبة كلّ هذه الآليّة في أمر واحد. من مجلّد الحقيبة:

./lab.sh import-news        # macOS, Linux, WSL2, Git Bash
.\lab.ps1 import-news # Windows PowerShell

مخرج ملحوظ على جهاز عاديّ (Docker Desktop، 8 غيغابايت مخصَّصة، قرص SSD):

[import-news] démarrage
[import-news] connexion à http://elasticsearch:9200 en tant que elastic...
[import-news] cluster « veille » en état green
[import-news] jeu de données déjà présent : /data/News_Category_Dataset_v2.json (83 Mo), téléchargement sauté
[import-news] index « news » créé avec le mapping news.json
[import-news] indexation par lots de 2000 documents
[import-news] 20000 documents indexés (3 s)
[import-news] 40000 documents indexés (6 s)
[import-news] ...
[import-news] 200000 documents indexés (28 s)
[import-news] 200853 documents envoyés en 28 s
[import-news] vérification : _count = 200853 ✔
[import-news] fichier Neo4j écrit : /neo4j-import/news.csv (43 Mo)
[import-news] terminé. Dans Kibana → Dev Tools : GET news/_count

تحقّق في Dev Tools:

GET news/_count
{ "count": 200853, "_shards": { "total": 1, "successful": 1, "skipped": 0, "failed": 0 } }

مئة وأربعون ميغابايت من التّخزين لـ200 853 مستندًا، وفهرسة في أقلّ من دقيقة. هذا ما يعد به _bulk مضبوطٌ جيّدًا على cluster ورشة بعقدة واحدة — على cluster إنتاج مصمَّم جيّدًا، نتحدّث عن عشرات الآلاف من المستندات في الثّانية وعقدة.

قراءة موجَّهة لـimporter/import_news.py

يسع السّكربت في نحو مئتَي وخمسين سطرًا من Python القياسيّة — دون تبعيّة واحدة تُثبَّت على جهازك، كلّ شيء يعمل في حاوية. نستعرضه دالّة بدالّة: كلّ خيار يخفي درسًا.

es(method, chemin, corps=…): عميل HTTP بيتيّ الصّنع

دالّة وحيدة تُنفّذ كلّ استدعاءات REST. تحمل مصادقة Basic، وتُعيد المحاولة خمس مرّات بتأخير أُسّيّ عند timeouts وعند 429 Too Many Requests، وتفكّ ترميز JSON للردّ. احفظ الدّرس: حين قد يُعيد خدمة 429 تحت حِمل ثقيل، ينهار عميل ساذج؛ إعادة المحاولة مع backoff هي الحدّ الأدنى.

telecharger(): تحميل مُتماثل التّطبيق

إن كان /data/News_Category_Dataset_v2.json موجودًا ويزن أكثر من عشرة ميغابايت، تتخطّى الدّالّة التّحميل. وإلّا، تُحمّل إلى ملفّ .part وتُعيد التّسمية في النّهاية (حركة ذرّيّة على نظام الملفّات). قطع التّشغيل يترك .part غير مكتمل، سيُدهس عند التّشغيل التّالي؛ لا تجد أبدًا News_Category_Dataset_v2.json مقطوعًا إلى النّصف.

creer_index(): فهرس نظيف مع كلّ تشغيل

إن كان الفهرس news موجودًا، تحذفه الدّالّة وتُعيد إنشاءه بـelasticsearch/mappings/news.json. الاستيراد إذن مُتماثل التّطبيق في الاتّجاهَين: تشغيلان متتاليان يُعطيان الحالة نفسها بالضّبط. هذا يتفادى فخّ إعادة الاستيراد الذي يُضاف إلى السّابق ويُضخّم العدد بصمت.

المُتماثلة التّطبيق: لماذا هي جوهريّة

خطّ فهرسة يُعاد تشغيله بعد عطل يجب أن يصل دومًا إلى الحالة نفسها. بوضع _id = numéro_de_ligne وحذف الفهرس قبل إعادة إنشائه، يصير import-news قابلًا للإعادة إلى ما لا نهاية بلا آثار جانبيّة. في الإنتاج، نُفضّل غالبًا إعادة الفهرسة في فهرس جديد (news-2026-09-09)، وتوجيه alias news عليه (الوحدة 8)، ثمّ حذف القديم — المنطق نفسه، بلا قطع للقراءة.

envoyer_lot(lignes): _bulk الحقيقيّ

الجسم هو مصفوفة Python من السّلاسل المُسلسَلة مسبقًا، مربوطة بـ\n، ومُشفَّرة UTF-8، ومُنهاة بـ\n. سطر العمليّة هو الحدّ الأدنى الممكن: {"index": {"_index": "news", "_id": "42"}}. سطر المستند هو JSON خامّ. معامل عنوان ?filter_path=errors,items.*.error يطلب من Elasticsearch ألّا يُعيد 2000 سطر النّجاح — فقط errors: false إن لم يكن هناك ما يُبلَّغ عنه، وتفصيل المستندات المرفوضة فقط في الحالة المعاكسة. على 200 853 مستندًا، هذا يوفّر عدّة ميغابايتات من JSON للتّحليل جانب العميل.

indexer(): دفعات من ألفَين، وCSV بالتّوازي

ألفان هو تسوية منشورة في توثيق Elasticsearch: دفعة صغيرة جدًّا (100) تُضيّع فائدة الدّفعات، ودفعة كبيرة جدًّا (20 000) تُخاطر بالرّفض 413 Request Entity Too Large وتُثقل ذاكرة العقدة المُنسّقة. ألفان نقطة توازن على مستندات قصيرة مثل مقالات News.

بالتّوازي مع _bulk، يُكتَب كلّ سطر أيضًا في /neo4j-import/news.csv (بـcsv.writer مع QUOTE_ALL). تكفي مسحة واحدة على الملفّ المصدر لتغذية المحرّكَين — هذا هو الملفّ الذي سيقرأه Neo4j في الوحدة 11 بـLOAD CSV.

verifier(attendu): _refresh أخير، ثمّ _count

ينتهي السّكربت بـ:

POST /news/_refresh
PUT /news/_settings { "index": { "refresh_interval": "1s" } }
GET /news/_count

_refresh يفرض فتح segment جديد في Lucene مرئيّ للبحث. الاستدعاء الثّاني يُعيد refresh_interval إلى 1s — كان mapping قد وضعه على 30s لتسريع الاستيراد. هذه هي الرّافعة الكبيرة الثّانية للأداء: أثناء استيراد ضخم، كلّ refresh بالافتراض إلى 1s يُعيد إنشاء segment سيتوجّب على Lucene دمجه لاحقًا. توسيع فترات refresh إلى 30s يُخفّف الضّغط على merges ويكاد يُضاعف معدّل الفهرسة. بعد انتهاء الاستيراد، نُعيد 1s لاستعادة الرّؤية في الوقت الحقيقيّ جانب الاستعلامات.

_id = رقم السّطر: مفتاح إعادة الاستيراد المُتماثلة

المقال رقم 42 من الملفّ المصدر يُفهرَس بـ_id = "42" ويُكتَب في news.csv بـid = 42. إعادة الاستيراد تُعطي بالضّبط الـ200 853 مستندًا نفسها بالمعرِّفات نفسها. سيستعمل رسم Neo4j البيانيّ في الوحدة 11 هذا الـid مفتاحًا لقيد التّفرّد: محرّكان، هويّة واحدة.

جرّب 1 — دورة CRUD كاملة

في Dev Tools، أنشئ مستندًا في bac_a_sable بـ_id = 42 وعنوان ومؤلّف على اختيارك. اقرأه. عدّله بـ_update بإضافة حقل note قيمته 5. أعد قراءته. احذفه. تأكّد من اختفائه بـ_mget.

الحلّ
PUT bac_a_sable/_doc/42
{ "titre": "Test CRUD", "auteur": "Sami", "vues": 0, "date": "2026-09-09" }

GET bac_a_sable/_doc/42

POST bac_a_sable/_update/42
{ "doc": { "note": 5 } }

GET bac_a_sable/_doc/42

DELETE bac_a_sable/_doc/42

GET bac_a_sable/_mget
{ "ids": ["42"] }

_mget الأخير يُعيد docs[0].found = false. تأكّد من أنّ عدد المستندات هو ما تتوقّعه بـGET bac_a_sable/_count.

جرّب 2 — إصدار متفائل وتعارض

أنشئ مستندًا، وسجّل _seq_no و_primary_term. اكتب مرّتَين متتاليتَين بالقيم نفسها. ماذا يحدث في الاستعلام الثّاني؟

الحلّ
PUT bac_a_sable/_doc/100
{ "titre": "V1", "auteur": "Léa", "vues": 0, "date": "2026-09-09" }

احفظ _seq_no و_primary_term.

PUT bac_a_sable/_doc/100?if_seq_no=<n>&if_primary_term=<t>
{ "titre": "V2", "auteur": "Léa", "vues": 1, "date": "2026-09-09" }

نجاح: ينتقل المستند إلى V2، ويتزايد _seq_no. أعد الأمر نفسه بالقيم القديمة لـif_seq_no وif_primary_term: الحالة 409، version_conflict_engine_exception. رسالة تُذكّر بأنّه يجب إعادة القراءة قبل إعادة الكتابة.

جرّب 3 — تأكيد الاستيراد

بعد ./lab.sh import-news، نفّذ هذه الاستعلامات الثّلاثة في Dev Tools واقرأ ما تقوله:

GET news/_count
GET news/_doc/1
GET news/_search
{
"size": 1,
"query": { "match": { "headline": "climate change" } }
}
الحلّ

_count يُعيد بالضّبط 200 853. GET news/_doc/1 يُعيد المقال الأوّل من الملفّ، بحقوله headline وshort_description وcategory وauthors وlink وdate. _search على « climate change » يُعيد 2 834 نتيجة إجماليّة (hits.total.value)؛ المستند الأوّل له _score مرتفع وعنوانه « Change Is Here. Climate Change. ». أنت للتّوّ تحقّقت من أنّ الكوربوس كامل، وأنّ mapping فعّال، وأنّ محرّك البحث يستجيب.

النقاط الأساسيّة

  • PUT /index/_doc/<id> يستبدل، POST /index/_doc يُولّد _id، PUT /index/_create/<id> يرفض الدّهس.
  • POST _update يقبل doc (ترقيع جزئيّ) أو script (حساب ذرّيّ على shard).
  • التّحكّم المتفائل يجمع _seq_no و_primary_term: قابل للإعادة، دون قفل.
  • _bulk ينتظر NDJSON: عمليّة، مستند، سطر جديد، بما في ذلك السّطر الأخير.
  • ألفا مستند لكلّ دفعة نقطة توازن جيّدة للمستندات القصيرة.
  • ?filter_path=errors,items.*.error يُخفّف ردود _bulk من عدّة ميغابايتات على عمليّات الاستيراد الضّخمة.
  • تحويل refresh_interval إلى 30s أثناء الاستيراد ثمّ إلى 1s بعده يُضاعف المعدّل — تفعله الحقيبة عوضًا عنك.
  • _id = رقم السّطر يجعل الاستيراد مُتماثل التّطبيق ويُعطي هويّة ثابتة مشتركة مع Neo4j.

استكشاف الأخطاء

  • ./lab.sh import-news يُعيد Elasticsearch n'est pas démarré → healthcheck ليس على الأخضر. ./lab.sh status، ثمّ ./lab.sh logs elasticsearch إن لم تكن الحاوية healthy.
  • _bulk يُعيد 413 Request Entity Too Large → الدّفعة كبيرة جدًّا أو مستنداتك ضخمة بشكل غير عاديّ؛ خفّض حجم الدّفعة أو ارفع http.max_content_length (100 ميغابايت افتراضيًّا).
  • version_conflict_engine_exception على PUT _create → الـ_id موجود مسبقًا. هذا هو الأثر المُبتغى من _create — استعمل PUT /index/_doc/<id> إن كنت تريد الاستبدال.
  • _count يُعيد أقلّ من 200 853 بعد استيراد → انتهى _bulk بخطأ صامت؛ أعد ./lab.sh import-news (مُتماثل التّطبيق)، وإن استمرّت المشكلة، ./lab.sh doctor ثمّ ./lab.sh logs elasticsearch.

للاستزادة