◀ العودة إلى المدونة
PHP / Symfony

تحسين أداء PHP

نشر في 02 Jun 2024· 9 min قراءة
#PHP#Performance#OPcache

تحسين أداء PHP لبيئة الإنتاج

أداء PHP حاسم للتطبيقات ذات الحركة العالية. في CCM Benchmark، حيث تستقبل المواقع ملايين الزوار يومياً، كل جزء من الثانية له أهميته.

التحسين لا يعني إعادة كتابة كل شيء. في الغالبية العظمى من التطبيقات، تأتي المكاسب من بضعة إعدادات مختارة بعناية: OPcache مضبوط جيداً، ومُحمِّل تلقائي (autoloader) محسَّن، واستعلامات SQL تحت السيطرة، ومجموعة PHP-FPM محسوبة وفق الذاكرة المتاحة. القاعدة الذهبية واحدة في كل خطوة: قِس قبل أن تُحسِّن، ثم قِس مجدداً للتحقق من المكسب. يتبع هذا المقال هذا الترتيب، من الإعداد الأكثر مردوداً إلى الأكثر تخصصاً.

OPcache: الأساس

في كل طلب، على PHP عادةً قراءة الملفات المصدرية وتحليلها وتصريفها إلى bytecode. يحتفظ OPcache بهذا الـ bytecode في ذاكرة مشتركة، فتنفّذه الطلبات التالية مباشرة. إنه الإعداد الذي يقدّم أفضل نسبة بين الجهد والمكسب، ويجب أن يكون مفعَّلاً على كل خادم إنتاج.

; php.ini - Optimal OPcache configuration
opcache.enable=1
opcache.memory_consumption=256
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
opcache.save_comments=1
opcache.preload=/var/www/app/config/preload.php
opcache.preload_user=www-data

تفاصيل هذه الإعدادات:

  • يجب أن تتسع memory_consumption (بالميغابايت) لكل الـ bytecode الخاص بالتطبيق واعتمادياته. إن امتلأت، يتوقف OPcache عن تخزين ملفات جديدة أو يعيد التشغيل، وينخفض الأداء دون أي خطأ ظاهر.
  • يجب أن تتجاوز max_accelerated_files عدد ملفات PHP في المشروع، بما فيها vendor/. يعطي الأمر find . -name '*.php' | wc -l فكرة عن الرقم.
  • validate_timestamps=0 يلغي التحقق من تواريخ التعديل في كل طلب. المقابل: يجب إعادة تحميل PHP-FPM عند كل نشر، وإلا استمرت الشيفرة القديمة في العمل.
  • save_comments=1 ضروري إن كنت تستخدم تعليقات توضيحية (annotations) تُقرأ عبر الانعكاس (إصدارات قديمة من Doctrine أو من مكتبات التحقق).

للتحقق من الحالة الفعلية للـ cache، تُرجع opcache_get_status() نسبة الإصابة (opcache_hit_rate) والذاكرة المستخدمة وعدد مرات إعادة التشغيل. في تطبيق مستقر، يجب أن تبقى نسبة الإصابة قريبة جداً من 100%.

أكمِل ذلك بـ cache المسارات الحقيقية (realpath)، الذي يتجنب استدعاءات النظام المتكررة لحلّ مسارات الملفات:

; php.ini - realpath cache
realpath_cache_size=4096K
realpath_cache_ttl=600

التحميل المسبق (Preloading) في PHP 8

منذ PHP 7.4، يحمّل الـ preloading مجموعة من الأصناف في الذاكرة عند بدء تشغيل الخادم. وتبقى متاحة لكل الطلبات دون المرور حتى بالمُحمِّل التلقائي.

// config/preload.php
require dirname(__DIR__).'/vendor/autoload.php';

// Preload frequently used classes
$files = glob(dirname(__DIR__).'/src/Entity/*.php');
foreach ($files as $file) {
    opcache_compile_file($file);
}

مع Symfony، لا داعي لكتابة هذه القائمة يدوياً: الملف config/preload.php الذي توفره الوصفة (recipe) يتضمن var/cache/prod/App_KernelProdContainer.preload.php، المولَّد أثناء cache:warmup بالأصناف التي تستخدمها الحاوية فعلاً. احتياطان: أي تعديل على صنف محمَّل مسبقاً يستلزم إعادة تشغيل كاملة لـ PHP-FPM، والمكسب يتوقف على التطبيق. وهو غالباً متواضع بعد ضبط OPcache جيداً، لذا قِسه قبل الإبقاء عليه.

مُحمِّل تلقائي محسَّن

في الإنتاج، يستطيع Composer توليد خريطة أصناف (classmap) كاملة، ما يتجنب التحقق من وجود الملفات على القرص لكل صنف:

composer install --no-dev --optimize-autoloader --classmap-authoritative
composer dump-env prod
APP_ENV=prod php bin/console cache:warmup

يطلب الخيار --classmap-authoritative من المُحمِّل ألا يبحث عن الأصناف إلا في الـ classmap. إنه سريع، لكن الصنف المولَّد وقت التنفيذ والغائب عن الـ classmap لن يُعثَر عليه. ويُصرِّف composer dump-env prod ملفات .env في ملف PHP، ما يتجنب تحليلها في كل طلب.

التحليل باستخدام Blackfire

التحسين دون تحليل (profiling) مجرد تخمين. يُظهر المحلِّل بدقة أين يُستهلك الوقت والذاكرة: دالة بدالة، واستعلام SQL باستعلام، واستدعاء HTTP باستدعاء. صُمِّم Blackfire ليكون قابلاً للاستخدام في الإنتاج دون أي كلفة إضافية خارج أوقات التحليل. يتم التثبيت عبر مستودع APT الرسمي:

# Add the Blackfire repository (Debian/Ubuntu)
wget -q -O - https://packages.blackfire.io/gpg.key | sudo dd of=/usr/share/keyrings/blackfire-archive-keyring.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/blackfire-archive-keyring.asc] http://packages.blackfire.io/debian any main" | sudo tee /etc/apt/sources.list.d/blackfire.list
sudo apt update

# Install the Blackfire agent and the PHP probe
sudo apt install blackfire blackfire-php
sudo blackfire agent:config

# Profile a request
blackfire curl http://localhost/api/products

اقرأ نتيجة التحليل بدءاً من العُقد ذات الوقت «الحصري» الأعلى: فهي التي تستهلك فعلاً. الاستدعاء المتكرر مئات المرات، وغالباً ما يكون استعلام SQL داخل حلقة، هو الإشارة الأكثر شيوعاً. إن لم يكن Blackfire خياراً متاحاً، فإن محلِّل Xdebug أو الإضافة مفتوحة المصدر SPX يقدمان معلومات مماثلة، ويُستحسن حصرهما في بيئات التطوير.

تحسينات Doctrine

في تطبيق Symfony، تكون قاعدة البيانات دائماً تقريباً أكبر مصدر للكلفة. الروافع الأساسية:

  • تفعيل cache الاستعلامات وcache النتائج
  • استخدام استعلامات DQL مع اختيارات جزئية
  • تجنّب التحميل الكسول (lazy loading) عبر روابط (joins) صريحة
  • ضبط cache المستوى الثاني

يُضبط cache الاستعلامات (ترجمة DQL إلى SQL) وcache النتائج في config/packages/doctrine.yaml. وتفعّل وصفة Symfony أصلاً إعداداً من هذا النوع لبيئة الإنتاج:

when@prod:
    doctrine:
        orm:
            query_cache_driver:
                type: pool
                pool: doctrine.system_cache_pool
            result_cache_driver:
                type: pool
                pool: doctrine.result_cache_pool

    framework:
        cache:
            pools:
                doctrine.result_cache_pool:
                    adapter: cache.app
                doctrine.system_cache_pool:
                    adapter: cache.system

ثم يُفعَّل cache النتائج استعلاماً بعد استعلام:

// Doctrine query cache
$query = $em->createQuery('SELECT p FROM App\Entity\Product p')
    ->enableResultCache(3600, 'products_list');

$results = $query->getResult();

فكّر في الإبطال: حين يتغير منتج، احذف المُدخل المعني، مثلاً عبر $em->getConfiguration()->getResultCache()?->deleteItem('products_list'). الـ cache دون استراتيجية إبطال ينتهي دائماً بتقديم بيانات قديمة.

تبقى المشكلة الأكثر شيوعاً هي «N+1»: استعلام لتحميل قائمة، ثم استعلام إضافي لكل عنصر بمجرد الوصول إلى علاقة. الربط الصريح مع addSelect يحمّل كل شيء دفعة واحدة، والاختيار نحو DTO يتجنب ملء كيانات كاملة حين لا نحتاج إلا إلى بضعة حقول:

// Explicit join: categories are loaded in the same query
$products = $em->createQueryBuilder()
    ->select('p', 'c')
    ->from(Product::class, 'p')
    ->leftJoin('p.category', 'c')
    ->where('p.active = :active')
    ->setParameter('active', true)
    ->getQuery()
    ->getResult();

// Partial selection into a DTO, without entity hydration
$items = $em->createQuery(
    'SELECT NEW App\Dto\ProductListItem(p.id, p.name, p.price) FROM App\Entity\Product p'
)->getResult();

للمعالجة على دفعات (استيراد، تصدير)، استخدم toIterable() واستدعِ $em->clear() بانتظام كي لا يحتفظ الـ EntityManager بآلاف الكائنات في الذاكرة. ولا تنسَ الفهارس: يعرض شريط تصحيح Symfony كل استعلام، والأمر EXPLAIN على أبطئها يكشف بسرعة فهرساً مفقوداً.

ضبط PHP-FPM

يدير PHP-FPM مجموعة من العمليات التي تعالج الطلبات. إن قلّت العمليات اصطفّت الطلبات في طابور؛ وإن كثرت نفدت ذاكرة الخادم وبدأ يلجأ إلى الـ swap، وهذا أسوأ بكثير.

; PHP-FPM configuration for high performance
pm = dynamic
pm.max_children = 50
pm.start_servers = 10
pm.min_spare_servers = 5
pm.max_spare_servers = 20
pm.max_requests = 500

هذه القيم لا تُنسَخ بل تُحسَب. تساوي pm.max_children تقريباً الذاكرة المتاحة لـ PHP مقسومة على متوسط ذاكرة العملية الواحدة. مع 4 جيغابايت مخصصة لـ PHP وعمليات بحجم 80 ميغابايت، نصل إلى 50. وتُقاس الذاكرة الفعلية للعمليات على الخادم:

# Average memory (in MB) of PHP-FPM processes
ps -o rss= -C php-fpm8.3 | awk '{ sum += $1; n++ } END { if (n) printf "%.0f\n", sum / n / 1024 }'

يعيد pm.max_requests تدوير كل عملية بعد 500 طلب، ما يحدّ من أثر أي تسرّب محتمل للذاكرة. ولمعرفة ما إذا كانت المجموعة محسوبة بشكل صحيح، فعِّل صفحة الحالة وسجلّ الطلبات البطيئة:

; Pool monitoring
pm.status_path = /fpm-status
request_slowlog_timeout = 5s
slowlog = /var/log/php-fpm/slow.log

إن أظهرت صفحة الحالة كثيراً max children reached أو طابوراً غير فارغ (listen queue)، فالمجموعة أصغر من اللازم، أو الطلبات بطيئة جداً. عندها يبيّن الـ slowlog بدقة أي دالة كانت تعيق التنفيذ. احمِ رابط صفحة الحالة كي لا يكون متاحاً إلا داخلياً.

قائمة تحقق للإنتاج

  1. OPcache مفعَّل، بنسبة إصابة قريبة من 100% ودون إعادات تشغيل غير متوقعة.
  2. APP_ENV=prod وAPP_DEBUG=0، وcache Symfony مُسخَّن مسبقاً عند النشر.
  3. مُحمِّل تلقائي محسَّن واعتماديات التطوير مستبعدة.
  4. لا N+1 في الصفحات الرئيسية، وفهارس على الأعمدة المستخدمة في التصفية أو الترتيب.
  5. مجموعة PHP-FPM محسوبة وفق الذاكرة المقيسة، مع تفعيل صفحة الحالة والـ slowlog.
  6. تحليل Blackfire (أو ما يعادله) للصفحات الحرجة قبل كل تحسين وبعده.

أبعد من PHP نفسها، تبقى الرافعة الأقوى غالباً ألا نشغّل PHP أصلاً: cache HTTP (ترويسات Cache-Control، أو وكيل عكسي، أو CDN) للصفحات العامة، ونقل المعالجات الثقيلة إلى عمّال (workers) غير متزامنين. الطلب الذي يُقدَّم من الـ cache لا يكلّف شيئاً تقريباً، مهما كانت جودة الشيفرة التي خلفه.