البرمجة متوسط 10 دقيقة

كيفية إضافة الفهارس الصحيحة لقاعدة البيانات

الفهارس هي أقوى أداة أداء متاحة للمطور دون لمس كود التطبيق — فهرس واحد في المكان الصحيح يمكنه تحويل استعلام من 10 ثوانٍ إلى 10 ميللي ثانية. وهي أيضاً الأكثر إساءة في الاستخدام: قليلة جداً والاستعلامات تزحف، كثيرة جداً والكتابة تتباطأ.

يغطي هذا الدليل ميكانيكية فهارس B-tree، وكيفية تحديد الأعمدة التي تحتاجها، وترتيب الفهارس المركّبة، والفهارس المغطية، وفهارس UNIQUE، وكيفية اكتشاف الفهارس التي تُهدر مساحة القرص وتُثقل الكتابة.

الخطوات

  1. 1

    افهم ما يفعله فهرس B-tree

    فهرس B-tree هو نسخة مرتبة من عمود أو أكثر مُخزَّنة منفصلة عن الجدول. حين ينفّذ MySQL الشرط WHERE email = 'alice@example.com'، بدون فهرس يقرأ كل صف (مسح كامل للجدول). مع فهرس على email، يتنقل عبر B-tree — عادةً 3-4 عقد لجدول مليون صف — ويقفز مباشرة إلى الصفوف المطابقة.

    المقايضة: الفهرس يستهلك مساحة قرص، وكل INSERT/UPDATE/DELETE يجب أن يُحدّث بنية الفهرس بالإضافة إلى بيانات الصف. فهارس أكثر = قراءات أسرع، كتابة أبطأ، تخزين أكثر.

    sql
    -- Without an index on email: scans all rows
    SELECT id, name FROM users WHERE email = 'alice@example.com';
    -- EXPLAIN → type: ALL, rows: 500000
    
    -- After adding an index
    CREATE INDEX idx_users_email ON users (email);
    
    -- Same query
    SELECT id, name FROM users WHERE email = 'alice@example.com';
    -- EXPLAIN → type: ref, rows: 1
  2. 2

    حدّد الأعمدة التي تحتاج فهرساً

    مرشحات الفهرسة هي الأعمدة التي تظهر في:

    • جمل WHERE: WHERE status = ?، WHERE user_id = ?
    • شروط JOIN: JOIN orders ON orders.user_id = users.id — كلا طرفي JOIN يحتاجان فهرساً
    • ORDER BY: ORDER BY created_at DESC — الفهرس يمنع filesort
    • GROUP BY: مشابه لـ ORDER BY

    ليس كل عمود يستحق فهرساً. is_published بقيمة منطقية مع 90% من الصفوف قيمتها 1 لديها انتقائية شبه معدومة — الفهرس لن يُستخدم لأن مسح تلك الصفوف أسرع من اجتياز الفهرس.

    sql
    -- High selectivity = good index candidate
    -- email: every value is unique → perfect
    CREATE INDEX idx_users_email ON users (email);
    
    -- slug: unique per record → perfect
    CREATE UNIQUE INDEX idx_posts_slug ON posts (slug);
    
    -- user_id on orders: one user has many orders → good
    CREATE INDEX idx_orders_user_id ON orders (user_id);
    
    -- Low selectivity = poor index candidate — avoid
    -- is_published TINYINT(1): only 2 distinct values → bad
    -- gender ENUM('m','f','other'): only 3 values → likely bad
  3. 3

    ابنِ الفهارس المركّبة بالترتيب الصحيح

    فهرس مركّب على (a, b, c) يمكن استخدامه لاستعلامات على a، أو a + b، أو a + b + c — لكن ليس لـ b وحده أو c وحده. هذه قاعدة البادئة اليسرى.

    ترتيب الأعمدة الصحيح للفهرس المركّب:

    1. شروط المساواة أولاً (status = ?)
    2. شروط النطاق ثانياً (created_at > ?)
    3. أعمدة ORDER BY أخيراً

    هذا الترتيب يطابق كيفية تضييق MySQL لمساحة البحث.

    sql
    -- Query: WHERE status = 'active' AND created_at > '2024-01-01' ORDER BY created_at DESC
    
    -- Bad order: range before equality wastes the index
    CREATE INDEX idx_bad ON orders (created_at, status);
    
    -- Good order: equality first, range/sort second
    CREATE INDEX idx_orders_status_created ON orders (status, created_at);
    -- MySQL uses (status) to narrow to 'active' rows,
    -- then (created_at) to scan in order — no filesort needed
    
    -- Verify with EXPLAIN
    EXPLAIN SELECT id FROM orders
    WHERE status = 'active'
      AND created_at > '2024-01-01'
    ORDER BY created_at DESC;
  4. 4

    استخدم الفهارس المغطية لتجنب جلب الصفوف

    الفهرس المغطي يحتوي على كل الأعمدة التي يحتاجها الاستعلام — SELECT وWHERE وORDER BY. يمكن لـ MySQL الإجابة على الاستعلام كلياً من الفهرس دون لمس الجدول الرئيسي. في EXPLAIN يظهر هذا كـ Extra: Using index.

    الفهارس المغطية مفيدة بشكل خاص في الاستعلامات عالية الحركة التي تقرأ مجموعة صغيرة ومحددة من الأعمدة. أضف أعمدة SELECT في نهاية الفهرس (بعد أعمدة WHERE وORDER BY).

    sql
    -- Query reads only id, status, created_at from orders
    SELECT id, status, created_at
    FROM orders
    WHERE user_id = 42
    ORDER BY created_at DESC;
    
    -- A covering index includes all referenced columns
    CREATE INDEX idx_orders_covering
        ON orders (user_id, created_at, status, id);
    --            ^^^^^^^ WHERE   ^^^^^^^^^^^ ORDER BY   ^^^^^^^^ SELECT
    
    -- EXPLAIN will now show: Extra = Using index
    -- No table lookup required — 100% served from the index
  5. 5

    أضف فهارس UNIQUE للمفاتيح الطبيعية

    فهرس UNIQUE يخدم غرضين: يُطبّق قيد التفرد على مستوى قاعدة البيانات (لا مجرد التطبيق) وهو فهرس B-tree عادي يُسرّع عمليات البحث. أضف فهرس UNIQUE على أي عمود يجب أن يكون فريداً بالتعريف: email، slug، username، invoice_number.

    قيد قاعدة البيانات يصطاد حالات التسابق والأخطاء التي تُفوّتها الفحوصات على مستوى التطبيق — طلبان متزامنان لا يمكنهما كلاهما إدراج نفس البريد الإلكتروني إن كان فهرس UNIQUE موجوداً.

    sql
    -- Single-column unique index
    ALTER TABLE users ADD UNIQUE INDEX idx_users_email_unique (email);
    
    -- Composite unique index (combination must be unique)
    -- e.g. a user can only follow a given tag once
    ALTER TABLE user_tag_follows
        ADD UNIQUE INDEX idx_user_tag_unique (user_id, tag_id);
    
    -- In a Laravel migration
    Schema::table('users', function (Blueprint $table) {
        $table->unique('email');
    });
    
    Schema::table('user_tag_follows', function (Blueprint $table) {
        $table->unique(['user_id', 'tag_id']);
    });
  6. 6

    أضف فهارس دائماً لأعمدة المفاتيح الخارجية

    InnoDB ينشئ قيد المفتاح الخارجي لكنه لا ينشئ تلقائياً فهرساً على عمود الإسناد في بعض الإعدادات. بدون فهرس على عمود FK، كل DELETE أو UPDATE على الجدول الأب يُطلق مسحاً كاملاً للجدول الابن للتحقق من الصفوف التابعة.

    تحقق بعد تشغيل الـ migrations: إن كان عمود FK يفتقر إلى فهرس، أضفه يدوياً.

    sql
    -- Check for un-indexed foreign keys
    SELECT
        kcu.TABLE_NAME,
        kcu.COLUMN_NAME,
        kcu.CONSTRAINT_NAME
    FROM information_schema.KEY_COLUMN_USAGE kcu
    LEFT JOIN information_schema.STATISTICS s
        ON  s.TABLE_SCHEMA = kcu.TABLE_SCHEMA
        AND s.TABLE_NAME   = kcu.TABLE_NAME
        AND s.COLUMN_NAME  = kcu.COLUMN_NAME
    WHERE kcu.REFERENCED_TABLE_NAME IS NOT NULL
      AND kcu.TABLE_SCHEMA = 'your_database'
      AND s.INDEX_NAME IS NULL;
    -- Returns FK columns that have no index — add one for each result
    
    -- In a Laravel migration, this is always safe
    Schema::table('orders', function (Blueprint $table) {
        $table->index('user_id');  // index the FK
        $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
    });
  7. 7

    أضف الفهارس عبر Laravel Migrations

    في Laravel، تُضاف الفهارس في ملفات migration على كائن Blueprint. الصياغة واضحة وملف migration يعمل كسجل دائم لكل فهرس في المخطط.

    php
    <?php
    
    use Illuminate\Database\Migrations\Migration;
    use Illuminate\Database\Schema\Blueprint;
    use Illuminate\Support\Facades\Schema;
    
    return new class extends Migration
    {
        public function up(): void
        {
            Schema::table('orders', function (Blueprint $table) {
                // Single-column index
                $table->index('user_id');
    
                // Composite index (equality + range)
                $table->index(['status', 'created_at'], 'idx_orders_status_created');
    
                // Unique index
                $table->unique('reference_number');
    
                // Covering index — include additional columns
                $table->index(['user_id', 'created_at', 'status', 'id'], 'idx_orders_covering');
            });
        }
    
        public function down(): void
        {
            Schema::table('orders', function (Blueprint $table) {
                $table->dropIndex('idx_orders_status_created');
                $table->dropIndex('idx_orders_covering');
                $table->dropUnique(['reference_number']);
            });
        }
    };
  8. 8

    اكتشف الفهارس غير المستخدمة واحذفها

    الفهارس غير المستخدمة تستهلك مساحة القرص وتُبطئ كل عملية كتابة بصمت. يتتبع MySQL استخدام الفهارس في مخطط sys — بعد أسبوع من حركة مرور الإنتاج، أي فهرس بصفر قراءات هو مرشح للحذف.

    sql
    -- Find indexes never used in queries (MySQL 5.7+ with sys schema)
    SELECT
        object_schema,
        object_name AS table_name,
        index_name
    FROM sys.schema_unused_indexes
    WHERE object_schema = 'your_database'
    ORDER BY object_name;
    
    -- Also check for duplicate / redundant indexes
    SELECT * FROM sys.schema_redundant_indexes
    WHERE table_schema = 'your_database';
    -- e.g. index on (a) is redundant if (a, b) already exists
    -- because (a, b) satisfies all queries that (a) alone would
    
    -- Drop an index
    ALTER TABLE orders DROP INDEX idx_old_unused_index;
    -- Or in Laravel
    -- $table->dropIndex('idx_old_unused_index');

نصائح ومحاذير

  • عند الشك، انظر إلى <code>EXPLAIN</code> قبل وبعد إضافة الفهرس — عمود <code>type</code> يخبرك إن كان الفهرس يُستخدم فعلاً.
  • الفهارس على الأعمدة ذات القيم المتميزة القليلة (القيم المنطقية، ENUM الحالة بـ 2-3 قيم) عادةً يتجاهلها المحسّن. استخدم فهرساً مركّباً يقرن عموداً منخفض الانتقائية بعمود عالي الانتقائية.
  • في MySQL/InnoDB، المفتاح الأولي جزء من كل فهرس ثانوي (هكذا يحل InnoDB الصف من الفهرس). أبقِ مفتاحك الأولي قصيراً — UUID كبير كمفتاح أولي ينفخ كل فهرس في الجدول.
  • شغّل <code>ANALYZE TABLE table_name</code> بعد تحميل البيانات بالجملة لتحديث إحصاءات الفهرس. الإحصاءات القديمة تجعل المحسّن يختار خطة تنفيذ خاطئة.
  • كن حذراً عند إضافة فهارس لجداول إنتاج كبيرة أثناء ساعات الذروة — <code>ALTER TABLE ADD INDEX</code> يحصل على قفل metadata يمكنه تكديس الكتابات. استخدم <code>pt-online-schema-change</code> أو MySQL 8+ online DDL لإضافة فهارس بدون توقف.

خاتمة

الفهرس الصحيح في العمود الصحيح غالباً ما يكون أكبر مكسب أداء متاح دون إعادة كتابة منطق التطبيق. ابدأ بالاستعلامات التي ثبت بطؤها، استخدم EXPLAIN لتأكيد نوع الوصول، ثم أضف الحد الأدنى من الفهارس التي تغطي WHERE وORDER BY وإن احتجت SELECT. راجع الفهارس غير المستخدمة ربع سنوياً وأزل الوزن الزائد.

#Database #Indexes #Performance
العودة إلى جميع الأدلة

هل تحتاج مساعدة في مشروعك؟

احجز استشارة مجانية لمدة 30 دقيقة لمناقشة تحدياتك التقنية واستكشاف الحلول معًا.