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

كيفية تصميم مخطط قاعدة بيانات نظيف لميزة جديدة

معظم أخطاء المخطط تحدث قبل كتابة سطر واحد من الكود — حين يتخطى أحدهم خطوة التصميم ويذهب مباشرة إلى الـ migrations. المخطط السيئ يخلق ديناً تقنياً يتراكم مع كل ميزة تُبنى فوقه.

يأخذك هذا الدليل عبر كامل عملية التفكير: من وصف الميزة، إلى استخراج الكيانات، واختيار أنواع المفاتيح والأعمدة الصحيحة، والتطبيع حتى الشكل الثالث، وربط المفاتيح الخارجية بقواعد الحذف المناسبة، ومعرفة متى تتوقف عن التطبيع وتُعيد التطبيع بوعي.

الخطوات

  1. 1

    استخراج الكيانات من وصف الميزة

    اقرأ وصف الميزة وضع خطاً تحت الأسماء — تلك هي كيانك. كل كيان يصبح جدولاً. لميزة "يمكن للمستخدمين ترك تقييمات على المنتجات" الأسماء هي المستخدمون، والتقييمات، والمنتجات. تجاهل الأفعال والصفات في هذه المرحلة؛ ركّز فقط على الأشياء التي تحتاج إلى وجود.

    اختبار سريع: إن استطعت عدّ نماذج فردية منه ("تقييم واحد"، "ثلاثة منتجات")، فهو على الأرجح كيان.

    sql
    -- Feature: "Users can leave reviews on products"
    -- Entities found:
    --   users      → already exists
    --   products   → new table
    --   reviews    → new table (joins the two)
    
    -- Avoid: storing reviews as a JSON column on products.
    -- Each row in a table should represent exactly one thing.
  2. 2

    اختيار المفاتيح الأولية بوعي

    ثلاثة خيارات، لكل منها مقايضاته:

    • Auto-increment integer — مضغوط، سريع في الـ joins، قابل للتنبؤ. يكشف عدد السجلات ويُمكّن من هجمات التعداد. مناسب للبيانات الداخلية.
    • UUID (v4) — فريد عالمياً، آمن للكشف. أكبر حجماً (16 بايت)، عشوائي — يسبب تفتت الفهرس في InnoDB. استخدم UUID_TO_BIN() لتخزينه ثنائياً.
    • ULID — كـ UUID لكن قابل للترتيب المعجمي. أفضل الخيارات لمعظم الجداول الجديدة.

    قاعدة: إن ظهر المعرّف في URL عام، استخدم ULID أو UUID. إن كان داخلياً بحتاً (جداول التقاطع، إدخالات السجلات)، فالـ auto-increment مناسب.

    sql
    -- Auto-increment (internal tables)
    CREATE TABLE order_items (
        id         BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
        order_id   BIGINT UNSIGNED NOT NULL,
        product_id BIGINT UNSIGNED NOT NULL
    );
    
    -- ULID as CHAR(26) (public-facing tables)
    CREATE TABLE products (
        id         CHAR(26)     NOT NULL PRIMARY KEY,  -- ULID
        name       VARCHAR(255) NOT NULL
    );
  3. 3

    التطبيع حتى الشكل الثالث (3NF)

    التطبيع يزيل التكرار. عليك الوصول إلى 3NF قبل شحن أي مخطط. ثلاث قواعد تُطبَّق بالترتيب:

    • 1NF: كل عمود يحمل قيمة ذرية واحدة — لا قوائم مفصولة بفواصل، لا مصفوفات JSON لنفس المفهوم.
    • 2NF: كل عمود غير مفتاحي يعتمد على المفتاح الأولي كاملاً (يهم فقط للمفاتيح المركّبة).
    • 3NF: كل عمود غير مفتاحي يعتمد فقط على المفتاح الأولي — لا على عمود غير مفتاحي آخر.

    انتهاك 3NF الكلاسيكي: تخزين category_name بجوار category_id في جدول products. إن تغير اسم الفئة ستجد بيانات غير متسقة في كل صف.

    sql
    -- Bad: category_name duplicated across rows
    CREATE TABLE products (
        id            BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
        category_id   INT NOT NULL,
        category_name VARCHAR(100) NOT NULL  -- 3NF violation
    );
    
    -- Good: category lives in its own table
    CREATE TABLE categories (
        id   INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
        name VARCHAR(100) NOT NULL
    );
    
    CREATE TABLE products (
        id          BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
        category_id INT UNSIGNED NOT NULL,
        FOREIGN KEY (category_id) REFERENCES categories(id)
    );
  4. 4

    ضبط المفاتيح الخارجية وقواعد ON DELETE

    كل مفتاح خارجي يحتاج قاعدة ON DELETE صريحة. اختيار الخاطئة يسبب صفوفاً يتيمة أو فقداناً غير مقصود للبيانات:

    • CASCADE — احذف الأب، احذف الأبناء معه. استخدمها حين لا يمكن للأبناء الوجود بدون الأب (مثلاً، عناصر الطلب تنتمي لطلب).
    • SET NULL — احذف الأب، اجعل عمود المفتاح الخارجي NULL. استخدمها حين يمكن للابن الوجود مستقلاً (مثلاً، منشور حُذف حساب كاتبه).
    • RESTRICT (الافتراضي) — امنع حذف الأب إن وُجد أبناء. استخدمها حين تريد إجبار التطبيق على التنظيف أولاً (الأأمن افتراضياً).
    sql
    CREATE TABLE reviews (
        id         BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
        user_id    BIGINT UNSIGNED NOT NULL,
        product_id BIGINT UNSIGNED NOT NULL,
        rating     TINYINT UNSIGNED NOT NULL CHECK (rating BETWEEN 1 AND 5),
        body       TEXT,
        created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    
        -- User deleted → delete their reviews
        FOREIGN KEY (user_id)    REFERENCES users(id)    ON DELETE CASCADE,
    
        -- Product deleted → delete its reviews
        FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE
    );
  5. 5

    اختيار أنواع الأعمدة بعناية

    أنواع الأعمدة لها تأثيرات حقيقية على الأداء والصحة:

    • VARCHAR(n) — اضبط n بقيمة واقعية دائماً. VARCHAR(255) افتراض كسول؛ الـ slug نادراً يتجاوز 100 حرف. يستخدم InnoDB الطول المُعلن في ذاكرة الفرز.
    • DECIMAL(p, s) مقابل FLOAT — لا تستخدم FLOAT للأموال أبداً. FLOAT لا يمثل 0.1 بدقة. استخدم DECIMAL(10, 2) للعملات.
    • TIMESTAMP مقابل DATETIMETIMESTAMP يخزّن بتوقيت UTC ويحوّل تلقائياً لتوقيت الجلسة (مناسب لـ created_at/updated_at). DATETIME يخزّن كما هو بلا منطقة زمنية — استخدمه حين تمثل القيمة مفهوماً تقويمياً لا لحظة.
    • TINYINT(1) للقيم المنطقية — MySQL ليس لديه نوع BOOL أصلي؛ TINYINT(1) هو الاصطلاح المعتمد.
    sql
    CREATE TABLE orders (
        id           BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
        user_id      BIGINT UNSIGNED NOT NULL,
        total_amount DECIMAL(10, 2)  NOT NULL,          -- not FLOAT
        currency     CHAR(3)         NOT NULL DEFAULT 'USD',
        status       ENUM('pending','paid','shipped','cancelled') NOT NULL DEFAULT 'pending',
        is_gift      TINYINT(1)      NOT NULL DEFAULT 0,
        scheduled_at DATETIME        NULL,              -- calendar date, no timezone conversion
        created_at   TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,
        updated_at   TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
    );
  6. 6

    اعتماد اصطلاحات تسمية موحدة

    التسمية غير المتسقة هي القاتل الصامت لقابلية قراءة المخطط. اختر اصطلاحاً وطبّقه على كل جدول:

    • أسماء الجداول: جمع، snake_case — order_items، product_reviews
    • أسماء الأعمدة: snake_case — first_name، created_at
    • المفاتيح الخارجية: اسم الجدول مفرداً + لاحقة _iduser_id، product_id
    • الأعمدة المنطقية: بادئة is_ أو has_is_active، has_verified_email
    • الطوابع الزمنية: دائماً created_at وupdated_at — كل جدول يحصل عليهما
    sql
    -- Good naming
    CREATE TABLE blog_posts (
        id             BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
        author_id      BIGINT UNSIGNED NOT NULL,   -- FK: singular + _id
        category_id    INT UNSIGNED    NOT NULL,
        title          VARCHAR(255)    NOT NULL,
        slug           VARCHAR(100)    NOT NULL UNIQUE,
        is_published   TINYINT(1)      NOT NULL DEFAULT 0,
        published_at   TIMESTAMP       NULL,
        created_at     TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,
        updated_at     TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
    );
  7. 7

    أعِد التطبيع فقط حين يكون لديك دليل

    إعادة التطبيع (denormalization) مقايضة مقصودة: تُدخل تكراراً لتجنب JOINs مكلفة. افعل ذلك فقط بعد أن يكون لديك استعلام بطيء فعلاً مع بيانات حقيقية. لا تُحسّن مسبقاً أبداً.

    حالات صالحة شائعة: تخزين comment_count مؤقتاً في جدول posts (يُحدَّث بـ trigger أو حدث في التطبيق)، أو تخزين لقطة متسلسلة من العنوان في صف orders حتى لا يتغير إن حدّث المستخدم عنوانه لاحقاً.

    sql
    -- Caching a count to avoid COUNT(*) on large tables
    ALTER TABLE posts ADD COLUMN comment_count INT UNSIGNED NOT NULL DEFAULT 0;
    
    -- Update it when a comment is inserted (application layer)
    -- UPDATE posts SET comment_count = comment_count + 1 WHERE id = ?;
    
    -- Snapshotting an address so order history stays accurate
    ALTER TABLE orders ADD COLUMN shipping_address JSON NOT NULL;
    -- Stored at order creation time; never changes even if user edits their profile.
  8. 8

    ارسم المخطط قبل كتابة الـ Migrations

    قبل كتابة أي migration، ضع المخطط على ورقة أو في dbdiagram.io. الرسم يجبرك على رؤية العلاقات، واكتشاف الجداول الناقصة، ورصد تعارضات التسمية قبل أن تُثبَّت في نظام التحكم بالإصدارات.

    يستخدم dbdiagram.io لغة DSL بسيطة ويُصدَّر إلى SQL. شارك رابط المخطط مع زملائك للمراجعة — إعادة هيكلة المخطط أسهل بكثير من كتابة migrations تصحيحية لاحقاً.

    javascript
    // dbdiagram.io DSL example
    Table users {
      id         bigint [pk, increment]
      email      varchar(255) [unique, not null]
      created_at timestamp
    }
    
    Table products {
      id          bigint [pk, increment]
      category_id int    [ref: > categories.id]
      name        varchar(255)
      price       decimal(10,2)
    }
    
    Table reviews {
      id         bigint [pk, increment]
      user_id    bigint [ref: > users.id]
      product_id bigint [ref: > products.id]
      rating     tinyint
      body       text
    }

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

  • كل جدول يجب أن يحتوي على <code>created_at</code> و<code>updated_at</code> — بلا استثناء. ستحتاج دائماً لمعرفة متى حدث شيء ما.
  • أنماط الحذف اللطيف (<code>deleted_at</code> timestamp) مفيدة لكنها تُضيف تعقيداً لكل استعلام. استخدمها فقط إن كنت فعلاً بحاجة إلى استرداد البيانات المحذوفة.
  • لا تخزّن القيم المحسوبة في قاعدة البيانات إلا إذا كان لديك سبب أداء مثبت. احسبها في طبقة التطبيق.
  • إن كان عمود لن يحمل سوى مجموعة ثابتة من القيم، استخدم <code>ENUM</code> أو جدول بحث — لا VARCHAR نصية حرة. القيود على مستوى قاعدة البيانات هي خط دفاعك الأخير.
  • أضف فهارس لمفاتيحك الخارجية. MySQL/InnoDB لا ينشئها تلقائياً، والمفتاح الخارجي غير المفهرس يجعل حذف الأب وتحديثه يمسح الجدول الابن بالكامل.

خاتمة

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

#Database #SQL #Design
العودة إلى جميع الأدلة

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

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