البداية · فصل ٣

الـ Invalidation والـ Consistency

delete on write · race condition · delayed double delete · versioned keys · CDC
🎯 الهدف: تعرف تمسح الكاش وقت الكتابة صح، وتفهم الـ race اللي بيرجّع بيانات قديمة وإزاي تقفله.

أصعب حاجة في الكاش

فيه جملة مشهورة في البرمجة: “فيه حاجتين بس صعبين في الـ computer science: الـ cache invalidation وتسمية الحاجات”. وهي هزار، بس الجزء الأول منها حقيقي جدًا.

  • المشكلة: الداتابيز اتغيرت، والكاش لسه فيه القيمة القديمة.
  • السؤال: إمتى وإزاي تخلي الكاش يعرف إن القيمة اتغيرت؟
  • الحلول: TTL لوحده، أو مسح وقت الكتابة، أو تحديث وقت الكتابة، أو أحداث من الداتابيز نفسها (CDC).

تمسح ولا تحدّث؟

امسح (delete on write)

بعد الـ UPDATE تعمل DEL key. القراءة الجاية هتعمل miss وتجيب القيمة الجديدة. ده الـ default اللي بنستخدمه.

حدّث (update on write)

بعد الـ UPDATE تعمل SET key newValue. أسرع للقراءة الجاية، بس لو اتنين كتبوا في نفس الوقت ممكن الكاش ياخد القيمة الغلط.

  • ليه الحذف أأمن؟ لو request A كتب 100 وrequest B كتب 200، الداتابيز ممكن تنتهي بـ 200 والكاش ياخد 100 لو الـ SET بتاع A وصل متأخر. الحذف مفيهوش ترتيب: الاتنين بيمسحوا، والقراءة الجاية بتجيب الحقيقة.
  • وكمان: لو القيمة في الكاش محسوبة من كذا جدول، أسهل تمسحها من إنك تعيد حسابها وقت كل write.

امسح بعد الداتابيز مش قبلها

  • لو مسحت الأول: بين الـ DEL والـ UPDATE، أي request هيعمل miss ويقرا القيمة القديمة من الداتابيز ويرجّعها للكاش. والكاش هيفضل غلط لحد الـ TTL.
  • فالترتيب الصح: UPDATE في الداتابيز، وبعدها DEL من الكاش.
  • ولو الـ DEL فشل؟ القيمة القديمة هتفضل لحد الـ TTL. عشان كده الـ TTL لازم يكون موجود دايمًا، وممكن تعمل retry للـ DEL أو تبعته في queue.

الـ race اللي بيفضل موجود

حتى مع الترتيب الصح، فيه حالة نادرة بترجّع بيانات قديمة:

  • إيه اللي حصل؟ A قرا القيمة القديمة قبل الـ update، بس اتأخر في الكتابة في الكاش لحد ما B خلص كتابة ومسح. فالكاش اتملى بالقيمة القديمة بعد المسح.
  • ده نادر: محتاج القراءة تبقى أبطأ من الكتابة والمسح مع بعض. بس على traffic عالي بيحصل.
  • الـ TTL بيصلّحه في الآخر: بس لحد ساعتها اليوزرز شايفين سعر غلط.

المسح مرتين (Delayed Double Delete)

  • الفكرة: امسح مرة بعد الـ update على طول، وامسح تاني بعد وقت قصير (أطول من أطول قراءة متوقعة).
  • ليه بينفع؟ المسح التاني بيشيل أي قيمة قديمة حد زي A حطها بعد المسح الأول.
  • الوقت: أطول من زمن القراءة من الداتابيز + الكتابة في الكاش. مثلًا 500ms لو الـ queries عندك تحت 100ms.
update-with-double-delete.mjs
export async function updatePrice(id, priceCents) {  await pool.query("UPDATE products SET price_cents = $2 WHERE id = $1", [id, priceCents]);  await redis.del(keyOf(id));  // المسح التاني: يشيل أي قيمة قديمة اتكتبت بعد المسح الأول  setTimeout(() => {    redis.del(keyOf(id)).catch((err) => console.error("2nd delete failed", err));  }, 500);}
  • عيبه: الـ setTimeout بيضيع لو الـ process وقع. في الأنظمة الكبيرة بتحطه في queue (delayed job) بدل الـ memory.
  • مش ضمان ١٠٠٪: لو القراءة أخدت أطول من الـ 500ms برضه هتعدّي. هو بيقلل الاحتمال جدًا بس.

غيّر اسم الـ key (Versioned Keys)

  • الفكرة: بدل ما تمسح، غيّر اسم الـ key. القيمة القديمة بتفضل موجودة بس محدش بيقراها، وبتخلص بالـ TTL.
  • إزاي؟ خزّن رقم version في الداتابيز (أو في Redis) وزوده مع كل update، والـ key يبقى product:42:v17.
  • مثال شهير: كاش لكل منتجات category. بدل ما تمسح ١٠٠٠ key، تزوّد category:5:version بـ INCR، وكل الـ keys اللي فيها الـ version القديم بقت ميتة.
namespace-version.mjs
async function categoryKey(categoryId, page) {  const v = (await redis.get(`category:${categoryId}:version`)) ?? "0";  return `category:${categoryId}:v${v}:page:${page}`;}// وقت أي تعديل في الـ categoryawait redis.incr(`category:${categoryId}:version`);
  • التمن: read زيادة لكل طلب عشان تجيب الـ version، والقيم القديمة بتاخد memory لحد الـ TTL.

المسح بالأحداث (CDC)

  • المشكلة: فيه أماكن كتير بتكتب في الداتابيز: التطبيق، الـ admin panel، scripts، migrations. لو واحد فيهم نسي يمسح الكاش، الكاش غلط.
  • الحل: Change Data Capture. حاجة بتقرا الـ log بتاع الداتابيز نفسها (الـ WAL في Postgres أو الـ binlog في MySQL) وتطلّع حدث لكل تغيير.
  • أدوات: Debezium بيقرا الـ log ويبعت على Kafka، وconsumer بيمسح الـ keys. أو Postgres LISTEN/NOTIFY مع trigger لحاجة بسيطة.
  • الميزة: أي تغيير في الداتابيز بيوصل للكاش، مهما كان مين اللي كتب.
  • العيب: infrastructure زيادة، وفيه تأخير صغير (ثواني أو أقل) بين التغيير والمسح.

اقرا اللي كتبته (read-your-writes)

  • المشكلة: يوزر عدّل اسمه، والصفحة رجعت بالاسم القديم. حتى لو التأخير ثانية، اليوزر شايف إن التعديل فشل.
  • حلول: رجّع القيمة الجديدة من الـ write request نفسه بدل ما تقرا تاني. أو اقرا من الداتابيز (مش الكاش) لليوزر اللي لسه عامل تعديل لمدة ثواني.

خلّصت الفصل لما

🧠 اختبر نفسك
١) عملت DEL قبل الـ UPDATE. إيه اللي ممكن يحصل؟
request يعمل miss بين الاتنين، ويقرا القيمة القديمة من الداتابيز ويرجّعها للكاش، فتفضل غلط لحد الـ TTL.
٢) ليه delayed double delete مش ضمان كامل؟
لو القراءة البطيئة أخدت وقت أطول من التأخير، هتكتب القيمة القديمة بعد المسح التاني.
٣) فيه admin panel قديم بيكتب في الداتابيز مباشرة ومبيمسحش الكاش. الحل؟
CDC: تقرا التغييرات من الـ log بتاع الداتابيز وتمسح الـ keys، أو على الأقل TTL قصير.