الخادم MySQL 8.0.32، PHP 8.2 مع pdo_mysql، وخدمة تدفع تقارير كل 10 دقائق عبر عامل طويل العمر (worker). الجدول فيه حوالي 3 ملايين صف، والتقرير يقرأ دفعات من 500 صف ويكتب ملخصًا في نفس المعاملة. الظاهرة: بعد كل بضع ساعات يعلق العامل عند PDO::commit() وينتهي بـ Lock wait timeout exceeded، مع أن الاستعلامات نفسها تعمل يدويًا في أقل من ثانية.
أول ما فعلته هو تسجيل حالة المعاملة قبل كل استدعاء، بدل التخمين:
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
function logTx(PDO $db, string $where): void {
$in = $db->inTransaction() ? 'yes' : 'no';
error_log(sprintf('[%s] tx=%s autocommit=%d', $where, $in, $db->query('SELECT @@autocommit')->fetchColumn()));
}
النتيجة كانت أن inTransaction() يقول yes بعد rollBack() نفسها. السبب في حالتي: كتلة catch كانت تعيد المحاولة باستخدام نفس كائن PDO، وبينما الاستعلام الأول فشل داخل try، كان هناك استعلام SELECT ... FOR UPDATE فتح معاملة ضمنية في مسار آخر من الكود قبل أن يصل beginTransaction(). أي أن rollBack() لم يكن يملك ما يغلقه من جهة، بينما MySQL كان لا يزال يحمل أقفال صفوف من جلسة أخرى في نفس العامل.
الحل العملي كان حصر المعاملة في دالة واحدة صغيرة، وعدم ترك أي استعلام خارجها يلمس الصفوف:
function inTx(PDO $db, callable $work): mixed {
$db->beginTransaction();
try {
$result = $work($db);
$db->commit();
return $result;
} catch (Throwable $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
throw $e;
}
}
وأضفت قبل كل دفعة سطرًا يضمن أن الكائن ليس في حالة معاملة قديمة، ثم أعدت إنشاء الاتصال إذا كان inTransaction() صحيحًا عند بداية الدورة. هذا ليس أنيقًا، لكنه يكلف أقل من تشخيص توقف يستمر ساعات.
السبب الجذري أن PDO لا يغلق المعاملة تلقائيًا عند انتهاء النطاق، وMySQL يحتفظ بالأقفال حتى يرى COMMIT أو ROLLBACK أو انتهاء الجلسة. المعاملة الضمنية من SELECT ... FOR UPDATE خارج beginTransaction() تظل مفتوحة، وتظل معها الأقفال. الثمن أن كل مسار قراءة يجب أن يمر عبر نفس الغلاف، وهذا يزيد قليلًا من الالتزام في الكود.
يفشل هذا الحل عندما يكون العامل يعيد استخدام الاتصال بعد خطأ شبكة: inTransaction() قد يعيد false بينما الخادم لم يستقبل ROLLBACK أصلًا، فتبقى الأقفال حتى تنتهي مهلة wait_timeout. أيضًا مع ATTR_PERSISTENT يصبح تنظيف الحالة أقل قابلية للتنبؤ، وقد رأيت ذلك يظهر كأقفال على صفوف لم يلمسها الطلب الحالي. وأخيرًا، في MySQL مع READ COMMITTED تنقص بعض الأقفال، لكنها لا تختفي كلها، فلا تعتمد على مستوى العزل لتغطية خطأ في إدارة المعاملة.
السؤال الذي يهمني: عندما تستخدم عاملاً طويل العمر مع PDO، هل تفحص inTransaction() عند حدود الدورة، أم تعيد إنشاء الاتصال بالكامل بين المهام؟


0 条评论
还没有评论,写下第一条。