PreventStaleDatabaseReads في Laravel 13.38: حل قراءة البيانات القديمة بعد الكتابة
ميدلوير PreventStaleDatabaseReads في Laravel 13.38 يمنع المستخدم من رؤية بيانات قديمة بعد حفظ تعديله عندما تفصل قراءة القاعدة عن الكتابة.
يحدث ذلك بسبب تأخر النسخ بين القاعدة الرئيسية والـ replica، وهو من أكثر الأخطاء إرباكًا لأنه لا يظهر في بيئة التطوير. في هذا المقال نشرح كيف يعمل الميدلوير، ومتى تستخدمه، وما حدوده.
ما المشكلة؟ تأخر النسخ المقروءة (Replica Lag)
عندما يكبر التطبيق، يضيف كثيرون قاعدة بيانات رئيسية للكتابة ونسخة أو أكثر للقراءة فقط. Laravel يدعم ذلك عبر مفاتيح read وwrite في إعدادات الاتصال. المشكلة أن النسخ إلى الـ replica ليس لحظيًا، ويحدث بينهما تأخر قد يكون جزءًا من الثانية في الوضع الطبيعي وقد يطول في أوقات الضغط.
والنتيجة سيناريو مألوف: المستخدم يعدّل ملفه الشخصي، يعيد التطبيق توجيهه إلى صفحة الملف، فتُقرأ البيانات من replica لم تستلم التعديل بعد، فيظن أن الحفظ فشل. وعلى مستوى الـ jobs قد يقرأ job سجلًا أُنشئ للتو فلا يجده.
ما الحلول القائمة قبل 13.38؟
Laravel يوفر منذ سنوات خيار sticky في إعداد الاتصال: إذا نُفّذت عملية كتابة خلال دورة الطلب الحالية، فإن القراءات اللاحقة في الطلب نفسه تذهب إلى اتصال الكتابة. هذا يغطي ما بعد الكتابة مباشرة داخل الطلب، لكنه لا يغطي الطلب التالي الذي يأتي بعد إعادة التوجيه، وهو بالضبط ما يحدث في نمط Post/Redirect/Get.
وهناك أدوات يدوية، مثل Model::onWriteConnection() وuseWritePdo() لطلب القراءة من اتصال الكتابة في استعلام محدد. فعالة، لكنك تضطر لتذكّرها في كل مكان.
ما الذي يضيفه PreventStaleDatabaseReads؟
بحسب الـ pull request رقم 62078 (من jackbayliss، ودُمج في 19 أكتوبر 2026)، فهو ميدلوير اختياري يعتمد على الـ session. فكرته:
- ينفّذ الطلب كالمعتاد ثم يفحص هل عدّل الاتصال بيانات خلال الطلب.
- إن حدث تعديل، يخزّن الوقت الحالي في الـ session تحت المفتاح
_database_modified_at. - خلال نافذة زمنية قصيرة (ثانيتان افتراضيًا بحسب وصف الـ PR) تُوجَّه قراءات هذا المستخدم إلى اتصال الكتابة، فيرى تعديلاته.
- بعد انتهاء النافذة تعود القراءات إلى الـ replica، وبقية المستخدمين لا يتأثرون ولا يضغطون على القاعدة الرئيسية.
بهذا يصبح الحل على مستوى المستخدم لا على مستوى الطلب الواحد، وهو الفرق الجوهري عن sticky.
طريقة الاستخدام
على مستوى التطبيق كله، من bootstrap/app.php:
use Illuminate\Foundation\Http\Middleware\PreventStaleDatabaseReads;
->withMiddleware(function (Middleware $middleware) {
$middleware->web(append: [
PreventStaleDatabaseReads::class,
]);
})
أو على مجموعة مسارات محددة:
Route::middleware(PreventStaleDatabaseReads::class)->group(function () {
Route::get('/posts/{post}', [PostController::class, 'show']);
Route::put('/posts/{post}', [PostController::class, 'update']);
});
ولتغيير مدة النافذة بالثواني، يوفر الميدلوير الدالة الساكنة for:
PreventStaleDatabaseReads::for(15)
اختر المدة بحسب تأخر النسخ الذي تقيسه فعلًا في بنيتك. ذكر وصف الـ PR أن التأخر عادة بين 0 و1 ثانية لكنه وصل أحيانًا إلى نحو 15 ثانية، فالقيمة الافتراضية قد لا تكفي في بنيتك.
ملاحظة: لم أستطع من صفحة الـ PR التأكد من توقيع الـ constructor نفسه، فراجع الكود في إصدارك قبل الاعتماد على التفاصيل.
إعداد القراءة والكتابة في Laravel (تذكير سريع)
الفصل بين الاتصالين يتم في config/database.php بالشكل المعتاد، ويُفضَّل تفعيل sticky معه:
'mysql' => [
'read' => ['host' => ['192.168.1.1']],
'write' => ['host' => ['196.168.1.2']],
'sticky' => true,
// بقية الإعدادات...
],
لاحظ أن الميدلوير الجديد مخصص لهذا النوع من الإعداد، فلا يفيدك إن لم تفصل الاتصالين أصلًا.
الدالة المساعدة usingWritePdo
أضاف الإصدار نفسه (PR رقم 62099) الدالة usingWritePdo على كائن Connection. حسب وصف الـ PR، فهي تقبل callback وتنفّذه مع توجيه القراءات إلى الـ write PDO، ثم تعيد الحالة السابقة حتى لو رمى الـ callback استثناءً. أي أنها نسخة محدودة النطاق، مقابل useWriteConnectionWhenReading التي تبقى مفعّلة حتى تُلغيها بنفسك. المثال المتوقع بحسب الوصف:
$posts = DB::connection()->usingWritePdo(function () use ($user) {
return $user->posts()->get();
});
الصيغة أعلاه مبنية على وصف الـ PR وليس على نص التنفيذ، فتحقق من التوقيع الدقيق في الكود قبل استخدامها. وينبّه الـ PR إلى أن النطاق الذي يوفره onWriteConnection لا يمتد إلى الـ relations مثل posts()، وهذه الدالة تعالج هذه الحالة.
قيود يجب معرفتها
ذكر كاتب الـ PR والمراجعون عدة نقاط:
- يعتمد على الـ session، فهو غير مناسب للـ APIs التي لا تستخدم جلسات.
- يتتبع الاتصال الافتراضي فقط حاليًا.
- سباق محتمل: إذا كتب طلب طويل متزامن الـ session بعد وضع العلامة، قد تُستبدل قيمة
_database_modified_at. وصف أحد المراجعين الأمر بأنه نادر.
وتذكّر أن الميدلوير يحمّل اتصال الكتابة بقراءات إضافية بعد كل تعديل، فلا تجعل النافذة أطول مما تحتاج.
كيف تتأكد أنه يعمل؟
جرّب السيناريو الذي يسبب المشكلة: عدّل سجلًا ثم أعد التوجيه فورًا إلى صفحة تعرضه. إن كان لديك بيئة اختبار بنسخة مقروءة متأخرة عمدًا (يمكن تأخير النسخ في MySQL أو PostgreSQL للاختبار)، فستلاحظ الفرق بين وجود الميدلوير وغيابه. وراقب بعد التفعيل نسبة القراءات التي تذهب إلى القاعدة الرئيسية، لأن الهدف أن تبقى النافذة قصيرة فيستمر الـ replica في تحمل معظم الحمل.
هل أحتاج إليه؟
استخدمه إذا كنت تفصل القراءة عن الكتابة فعلًا، ولاحظت شكاوى مثل «حفظت ولم يظهر التغيير». وإن كان لديك اتصال واحد فلا فائدة منه. جرّبه أولًا على مجموعة مسارات حساسة (الملف الشخصي، الإعدادات، الدفع) وقِس تأخر النسخ عندك قبل اختيار المدة.
ما الجديد الآخر في 13.38؟
الإصدار صدر في 20 أكتوبر 2026 وفيه 25 تغييرًا بحسب صفحة الإصدارات على GitHub. أبرز ما سوى ما سبق:
- دعم
CountCrashesAsExceptionsللـ listeners والـ mailables والإشعارات المُصفّفة (queued). - إطلاق الحدث
UniqueJobSkippedعند تخطي بث حدث فريد. - إطلاق قفل الـ unique عند فشل جدولة job.
- قاعدة
uniqueتقبل قيمًا عشرية فيwhere. - إصلاحات لـ
whereBetweenمع collections، وmode()مع الأعداد العشرية.
لم تُصنّف ملاحظات الإصدار أي تغيير على أنه كاسر، لكن بعض الإصلاحات تغيّر سلوكًا قائمًا، فاقرأ الملاحظات كاملة قبل الترقية.
الخلاصة
PreventStaleDatabaseReads حل بسيط لمشكلة قديمة: بدل أن تعالج كل استعلام يدويًا، تضيف ميدلوير واحدًا فيرى كل مستخدم تعديلاته فورًا. قِس تأخر النسخ عندك، حدد النافذة المناسبة، وفعّله على المسارات التي يتبع فيها المستخدم الكتابة بقراءة مباشرة.