نسخ القراءة في Laravel: لماذا يرى المستخدم بيانات قديمة بعد الحفظ
إذا كان تطبيق Laravel لديك يرسل الكتابة إلى خادم قاعدة بيانات واحد والقراءة إلى نسخ متماثلة، فقد تصادف خطأ لا يظهر أبدًا على جهازك: يعدّل المستخدم عنوان مقال، ويحفظ، ويعود إلى الصفحة فيجد العنوان القديم. لم يفشل شيء. القراءة ذهبت إلى نسخة لم تنسخ التغيير بعد. والإصدار Laravel 13.38 الصادر في أكتوبر 2026 يضيف أداتين لهذه الحالة بالضبط.
لماذا لا ينقذك sticky
اتصال القراءة والكتابة في config/database.php له خيار اسمه sticky. عند تفعيله، إذا كتب الطلب شيئًا ذهبت القراءات التي تليه إلى خادم الكتابة. المشكلة في النطاق: يستمر ذلك حتى نهاية الطلب نفسه فقط. والنموذج المعتاد طلب PUT يعيد التوجيه إلى GET، أي طلبان. إعادة التوجيه تبدأ باتصال جديد، فتقرأ الصفحة التي تعرضها بعد الحفظ من نسخة متماثلة من جديد.
الحل للمستخدمين: PreventStaleDatabaseReads
الوسيط الجديد يتذكّر الكتابة عبر الطلبات بوضع طابع زمني في الجلسة. بعد أن يكتب طلبٌ في الاتصال الافتراضي، تذهب قراءات هذا المستخدم إلى خادم الكتابة لمدة ثانيتين، ثم تعود إلى النسخ. أضفه إلى مجموعة web في bootstrap/app.php:
use Illuminate\Foundation\Http\Middleware\PreventStaleDatabaseReads;
->withMiddleware(function (Middleware $middleware) {
$middleware->web(append: [
PreventStaleDatabaseReads::class,
]);
})
أو جرّبه على بعض المسارات أولًا، بنافذة أطول إن احتاجت نسخك ذلك:
Route::middleware(PreventStaleDatabaseReads::for(15))->group(function () {
// routes where a user edits and then views
});
اختر مدة النافذة من مقاييس تأخر النسخ عندك، لا بالتخمين. النافذة الأقصر من التأخر الحقيقي تسمح بمرور البيانات القديمة. وهناك ثلاثة حدود تستحق المعرفة قبل الاعتماد عليه: يحتاج إلى جلسة، ويتتبع الاتصال الافتراضي وحده، ويغطي المستخدم الذي كتب فقط. مستخدم آخر يفتح الصفحة نفسها قد يحصل على النسخة الأقدم.
الحل في الكود: DB::usingWritePdo()
بعض الكود يحتاج بيانات حديثة أيًا كان من كتبها، مثل مهمة تعمل مباشرة بعد أن يحدّث webhook صفًا. الدالة onWriteConnection() موجودة من قبل، لكنها تنطبق على الاستعلام الذي تبدؤه فقط. حمّل علاقة من النتيجة وسيذهب استعلامها إلى نسخة. الدالة الجديدة ترسل كل قراءة داخل الدالة الممرَّرة إلى خادم الكتابة، ثم تعيد الإعداد السابق بعدها، حتى لو رمت الدالة استثناءً:
$drafts = DB::usingWritePdo(function () use ($email) {
$user = User::query()->where('email', $email)->first();
return $user?->posts()->whereNull('published_at')->get();
});
الكود الموجود أصلًا داخل DB::transaction() لا يحتاجها، فاستعلامات المعاملة تستخدم خادم الكتابة على أي حال.
عمّال الطوابير مشكلتهم معكوسة
في طلب الويب ينتهي sticky مع الاستجابة. أما عملية queue:work فتحتفظ بالاتصال نفسه بين المهام، فمهمة واحدة تكتب قد ترسل قراءات كل المهام التالية إلى خادم الكتابة حتى يُعاد تشغيل العامل. أضاف Laravel 13.36 إعادة ضبط اختيارية: Worker::$flushStickyConnections = true داخل boot() في مزوّد خدمة. هي معطّلة افتراضيًا، فراجع المهام التي تقرأ بعد أن تكتب مهمة أخرى مباشرة قبل تفعيلها. ومع مشغّل الطوابير database، أعطِ جدول المهام اتصالًا خاصًا عبر DB_QUEUE_CONNECTION، وإلا فإن حجز المهمة يعيد رفع العلامة من جديد.
اختبره دون أن تخدع نفسك
كل سطر في سجل الاستعلامات يحمل المفتاح readWriteType بقيمة read أو write، فيستطيع الاختبار أن يحفظ، ثم يطلب الصفحة، ثم يتحقق من أن أول استعلام كان write. استخدم DatabaseMigrations أو DatabaseTruncation في هذا الاختبار. السمة RefreshDatabase تلفّ كل اختبار في معاملة، وكل استعلام في معاملة يستخدم خادم الكتابة، فينجح التحقق حتى بدون الوسيط.
الخطوة التالية: إن كنت تشغّل نسخًا متماثلة، فابحث عن أسوأ تأخر سجّلته، وأضف PreventStaleDatabaseReads إلى المسارات التي يعدّل فيها المستخدم ثم يعرض، واجعل النافذة أطول من ذلك التأخر، واكتب اختبار readWriteType بدون RefreshDatabase. الميزتان من إسهام Jack Bayliss في #62078 و#62099. المصدر: مقال Laravel News عن منع القراءات القديمة بعد الكتابة.
ملاحظات القراء