المعمارية · فصل ٨

المزامنة: الـ SQL الحقيقة والسيرش يلحق

Outbox · CDC · eventual consistency · reindex
🎯 الهدف: تحدّث السعر في SQL وتوصّله لـ Elasticsearch من غير ما التحديث يضيع لو السيرش وقع لحظة الطلب.

نفس المنتج، شكلين

في SQL المنتج متوزع: جدول المنتج، البراند، القسم، المخزون. في Elasticsearch عايز مستند واحد جاهز للبحث. اليوزر بيفلتر بكلمة “Apple” مش برقم brand_id.

sql/search-doc.sql
SELECT p.id, p.sku, p.name, p.description,       b.name AS brand, c.slug AS category,       p.price, p.rating, i.stock, p.ram_gb, p.storage_gbFROM products pJOIN brands b ON b.id = p.brand_idJOIN categories c ON c.id = p.category_idJOIN inventory i ON i.product_id = p.idWHERE p.id = $1;

ده تمن الـ denormalize: لو اسم البراند اتغير، كل مستندات البراند ده لازم تتكتب تاني في Elasticsearch. التمن مقبول لأن البحث بيقرا أكتر بكتير ما اسم البراند بيتغير.

التحديث الساذج بيسيّب الداتا متخالفة

أبسط طريق: UPDATE في SQL، وبعدين es.index. لو الأول نجح والتاني فشل (الشبكة، أو Elasticsearch بيعيد تشغيل)، السعر في الداتابيز جديد والفهرس قديم. اليوزر هيشوف سعر غلط. ومفيش حد هيحاول تاني، لأن الطلب خلّص عند الـ API.

متخلّصش طلب اليوزر على خطوتين من غير ما تسجّل إن التانية لسه مطلوبة.

Outbox: الحدث يتسجل مع البيزنس

Transactional outbox: نفس الـ transaction تحدّث المنتج وتكتب صف في جدول outbox. يا الاتنين ينجحوا يا الاتنين يترجعوا. عملية تانية، الـ indexer، تقرأ الصفوف وتحدّث Elasticsearch.

التحديث والحدث بيتسجلوا مع بعض
  1. Express ← Postgres: transaction واحدة
  2. Postgres ← Indexer: صف pending
  3. Indexer ← Postgres: sent بعد النجاح
  4. Indexer ← Elasticsearch: index بنفس الـ id
sql/outbox.sql
CREATE TABLE outbox (  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,  event_type text NOT NULL,  payload jsonb NOT NULL,  status text NOT NULL DEFAULT 'pending',  created_at timestamptz NOT NULL DEFAULT now());
src/outbox.js
export async function updatePrice(pool, { id, price }) {  const client = await pool.connect();  try {    await client.query("BEGIN");    await client.query("UPDATE products SET price = $1 WHERE id = $2", [price, id]);    await client.query(      "INSERT INTO outbox (event_type, payload) VALUES ($1, $2::jsonb)",      ["product.updated", JSON.stringify({ id })],    );    await client.query("COMMIT");  } catch (err) {    await client.query("ROLLBACK");    throw err;  } finally {    client.release();  }}export async function claimBatch(pool) {  const { rows } = await pool.query(    `UPDATE outbox SET status = 'sending'     WHERE id IN (       SELECT id FROM outbox       WHERE status = 'pending'       ORDER BY id       FOR UPDATE SKIP LOCKED       LIMIT 20     )     RETURNING id, payload`,  );  return rows;}export async function publishBatch(pool, es, loadSearchDoc, rows) {  for (const row of rows) {    const id = String(row.payload.id);    const product = await loadSearchDoc(pool, row.payload.id);    if (product) {      await es.index({ index: "products", id, document: product });    } else {      await es.delete({ index: "products", id }).catch((err) => {        if (err.statusCode !== 404) throw err;      });    }    await pool.query("UPDATE outbox SET status = 'sent' WHERE id = $1", [row.id]);  }}

claimBatch يعلّم الصفوف sending جوه statement واحدة، فـ indexer تاني مش هياخدها (SKIP LOCKED). publishBatch يكتب Elasticsearch وبعدين يعلّم sent. الكتابة بنفس id المنتج upsert: لو العملية وقعت بعد نجاح الإندكس وقبل التعليم، المحاولة الجاية تكتب نفس المستند. التعليم قبل النجاح بيضيّع الحدث.

لو الصف فضل sending لأن العملية ماتت في النص، رجّعه pending بعد دقيقتين. متعلّمش الصف sent قبل ما Elasticsearch يرد.

Broker و CDC لما الكتابة مش من باب واحد

الـ indexer يقدر يحدّث Elasticsearch على طول، أو يحط الحدث على Kafka أو RabbitMQ ولو أكتر من مستهلك محتاجه. الـ broker مش هو اللي بيحمي الـ transaction. الحماية إن الصف اتكتب في SQL مع تعديل المنتج.

CDC زي Debezium بيقرأ سجل Postgres نفسه ويبعت التغييرات. مفيد لما أكتر من برنامج يعدّل المنتجات، ومش متطمن إن كل مسار فاكر يكتب outbox. التمن نظام زيادة تشغّله وتراقبه.

الاسم بتاع التأخير ده eventual consistency: الداتابيز اتحدثت دلوقتي، والسيرش هيشوف السعر بعد لحظات. قول كده في الإنترفيو بصراحة. صفحة المنتج تقدر تقرأ السعر من SQL لو الفرق ده مؤذي، وتسيب Elasticsearch للقائمة والترتيب.

الـ refresh الافتراضي كمان بيزوّد حوالي ثانية بعد ما الـ index ينجح. التأخير ده غير تأخير الـ outbox.

إعادة بناء الإندكس من غير ما تقفل البحث

تغيير الـ mapping أو الـ analyzer محتاج إندكس جديد. ابني products-v2، املىه، وبعدين بدّل alias اسمه products من القديم للجديد. الـ API بيدور على الـ alias، فمش بيعرف إن اللي تحت اتغير.

await es.indices.updateAliases({  actions: [    { remove: { index: "products-v1", alias: "products" } },    { add: { index: "products-v2", alias: "products" } },  ],});

أول تحميل للعشر مليون منتج يبقى بـ bulk، مش طلب index لكل منتج. راجع رد الـ bulk: errors: true يعني فيه عناصر فشلت حتى لو الطلب نفسه رجع ٢٠٠.

🧪 Lab

ارسم على ورقة طلب “غيّر السعر لـ ٥٠٠٠٠” في ثلاث تصاميم: تحديث مزدوج، outbox، و CDC. في كل تصميم اكتب إيه اللي يحصل لو Elasticsearch راجع ٥٠٣ في اللحظة دي. التصميم الأول بيخلّص الطلب والداتا متخالفة. التانيين بيسيّبوا أثر يتتعاد. الفرق بين outbox و CDC: مين اللي بيطلّع الأثر، التطبيق ولا سجل الداتابيز.

✅ الخلاصة — Checklist

🧠 اختبر نفسك
١) ليه الـ outbox في نفس transaction بتاع UPDATE السعر؟
عشان يا السعر والحدث يتحفظوا سوا يا الاتنين يترجعوا. مفيش سعر جديد من غير أثر هيتعمله index.
٢) إيه اللي يخلّي إعادة الإندكس آمنة لو اتكررت؟
استخدام id المنتج كـ id للمستند. الكتابة التانية بتستبدل نفس المستند.
٣) امتى CDC أنسب من outbox جوه التطبيق؟
لما أكتر من كاتب يعدّل الداتابيز ومش ضامن إن كلهم هيكتبوا الحدث.