نمط الخطوات الأربع
يكمن العيب في أربع خطوات متسلسلة غير ذرية:
// DEFECT
Value value = cache.get(key);
if (value == null) {
value = expensiveCompute(key);
cache.put(key, value);
}
return value;
الخطوة 1: التحقق من الكاش. الخطوة 2: فشل. الخطوة 3: الحساب. الخطوة 4: التخزين. جميع الخطوات الأربع: غير ذرية. بين الخطوة 1 والخطوة 4، يمكن لأي عدد من الخيوط تنفيذ الخطوة 1 ورؤية null.
فخ الـ Idempotency
المنطق الذي يحمي هذا النمط: 'لا مشكلة إذا قام خيطان بحساب وتخزين نفس القيمة. النتيجة تكون idempotent. لا يحدث تلف للبيانات.'
هذا المنطق: صحيح من حيث الصحة. قاتل من حيث التكلفة.
عند 1,000 خيط في حالة cache miss: ينفذ كل خيط من الـ 1,000 expensiveCompute(key). إذا كان expensiveCompute يستعلم قاعدة بيانات، تُطلق 1,000 استعلام قاعدة بيانات في وقت واحد. وإذا استدعى خدمة خارجية، تُطلق 1,000 طلب HTTP في وقت واحد. ينهار النظام الذي ينتج نتائج صحيحة تحت تكلفة إنتاجها.
ثلاثة محفزات
يحدث Thundering Herd عندما ينتقل مفتاح الـ cache من حالة warm إلى cold في وقت واحد عبر العديد من الخيوط:
Cold start: إعادة تشغيل الخدمة مع cache فارغ. موجة الطلبات الأولى: كل مفتاح يفشل. يتم الحساب للجميع في وقت واحد.
Service restart: إعادة تشغيل متدرجة تعيد تعيين الـ cache عبر النسخ. يتم إعادة توزيع الحركة على النسخ الباردة.
TTL expiry: ينتهي صلاحية مفتاح عالي الحركة. تتحقق N خيط، وتفشل جميعها، وتحسب جميعها قبل أن يخزن الخيط الأول النتيجة.
جميع المحفزات الثلاثة: مرتبطة بارتفاعات حركة المرور. يُفعَّل القطيع عندما تصل حركة المرور إلى ذروتها ويكون الـ cache باردًا. أسوأ وقت ممكن.
مثال Elasticsearch EnrichCache
Elasticsearch EnrichCache: ينص التعليق الموثق على 'عدم القفل عمدًا للبساطة... لا مشكلة إذا أعدنا وضع نفس المفتاح/القيمة في حالة سباق.' عند 10,000 مستند في الثانية مع enrich cache بارد: تصل جميع الطلبات الـ10,000 إلى enrich index في وقت واحد. يواجه enrich index، المصمم للبحث العرضي، 10,000 استعلام متزامن. يصبح العنقود غير مستقر.
المنطق المتعلق بالـ idempotency: صحيح في تعليق الكود. كارثي عند 10,000 مستند في الثانية.
الارتباط بـ MOAD-0001
MOAD-0001 (Sedimentary Defect) يُنشئ عنق زجاجة O(N²) في الأنظمة عالية الإنتاجية. إصلاح MOAD-0001 (من O(N²) إلى O(N)) يُحرر محطة العمل. الإنتاجية الأسرع ترسل المزيد من الطلبات إلى المراحل اللاحقة. ذاكرات التخزين المؤقتة اللاحقة، التي كانت محمية سابقًا بسبب عنق زجاجة MOAD-0001، تتلقى الآن ارتفاعات حركة مرور مترابطة. يُفعَّل MOAD-0005 في ذاكرات التخزين المؤقتة التي لم تُفعَّل فيها من قبل. إصلاح MOAD واحد؛ يُمهّد الطريق للآخر.
ما الذي يخطئ فيه فخ Idempotency
تعليق Elasticsearch يمثل تفكيرًا هندسيًا دقيقًا مطبقًا على السؤال الخطأ. Idempotency: خاصية حقيقية تستحق التفكير فيها. الفخ: التوقف عند الصحة دون الاستمرار في حساب التكلفة.
computeIfAbsent و singleflight
الحل: اجعل عملية التحقق والحساب ذرية. يحسب خيط واحد. تنتظر جميع الخيوط الأخرى النتيجة.
Java: computeIfAbsent
// عيب: أربع خطوات غير ذرية
Value value = cache.get(key);
if (value == null) {
value = expensiveCompute(key);
cache.put(key, value);
}
return value;
// FIX: فحص ذري وتنفيذ
return cache.computeIfAbsent(key, k -> expensiveCompute(k));
computeIfAbsent: إذا كان المفتاح غائباً، يتم الحساب مرة واحدة بالضبط، يُخزن، ويُرجع. جميع الخيوط الأخرى التي تستدعي computeIfAbsent لنفس المفتاح تنتظر الحساب الأول. لا يوجد حساب N مرة. لا يوجد زحام.
Go: singleflight.Group
var g singleflight.Group
func getOrCompute(key string) (Value, error) {
v, err, _ := g.Do(key, func() (interface{}, error) {
return expensiveCompute(key)
})
return v.(Value), err
}
singleflight: إذا كانت عملية حسابية لمفتاح معين قيد التنفيذ بالفعل، فإن جميع المستدعين لنفس المفتاح ينتظرون ويشاركون النتيجة الوحيدة. عملية حساب واحدة، N من المنتظرين، نتيجة واحدة مشتركة. تجريد 'flight': إزالة التكرار في الطلبات قيد التنفيذ.
القفل مقابل singleflight
قفل ساذج لكل مفتاح يسلسل: الخيط 1 يحسب، الخيط 2 ينتظر، الخيط 3 ينتظر. بعد انتهاء الخيط 1، يدخل الخيط 2 ويتحقق من الذاكرة المؤقتة (يصيب). يدخل الخيط 3 ويتحقق من الذاكرة المؤقتة (يصيب). N-1 عمليات اكتساب قفل وقراءات ذاكرة مؤقتة.
singleflight يزيل التكرار: الخيط 1 يحسب، والخيوط من 2 إلى N تنتظر جميعها على نتيجة الخيط 1. لا اكتساب قفل منفصل. لا قراءات ذاكرة مؤقتة منفصلة. عملية حساب واحدة، نتيجة واحدة، موزعة على N من المنتظرين. عمليات أقل من قفل لكل مفتاح.
كلاهما يمنع الاندفاع. singleflight يمنع العمل الزائد بشكل أكثر شمولاً.
إعادة كتابة النمط
تطبيق الإصلاح على سيناريو ملموس.
// ذاكرة تخزين مؤقت لملف تعريف المستخدم في خدمة Java ذات حركة مرور عالية
public UserProfile getProfile(String userId) {
UserProfile profile = profileCache.get(userId);
if (profile == null) {
profile = database.loadProfile(userId); // مكلف: استعلام قاعدة بيانات 50ms
profileCache.put(userId, profile);
}
return profile;
}
تتم إعادة تشغيل الخدمة كل صباح في الساعة 2 صباحًا. في الساعة 8 صباحًا، يطلب 10,000 مستخدم ملفاتهم الشخصية في وقت واحد.