در سه سال گذشته، سه بار پروژه‌ای را با هزینه‌ی سنگین بازطراحی کرده‌ام که دلیل هر سه یکی بود: طراحی نامناسب دیتابیس در روز اول. یکی از این پروژه‌ها، یک فروشگاه اینترنتی بود که جدول orders آن، نام مشتری، آدرس، و مشخصات محصول را در خودش ذخیره می‌کرد. آن روز فکر می‌کردیم کارمان ساده است — یک جدول، همه‌چیز در یک جا. شش ماه بعد که مشتری خواست گزارش «پرفروش‌ترین محصول» را ببیند، متوجه شدیم در جدول ما هیچ مفهومی به نام «محصول» وجود ندارد؛ همه‌چیز، متن آزاد است. برای درست کردن آن گزارش، مجبور شدیم کل ساختار دیتابیس و کد برنامه را بازنویسی کنیم. طراحی دیتابیس در MySQL دقیقاً همان نقطه‌ای است که این نوع فاجعه‌ها با یک تصمیم درست در روز اول، قابل پیشگیری است. در این مقاله، همان اصولی را می‌گویم که امروز در هر پروژه‌ی جدید، اول از همه به آن‌ها می‌پردازم — از نرمال‌سازی و مدل‌سازی روابط تا کلیدهای خارجی، نوع داده و اشتباهات رایج.

چرا طراحی دیتابیس مهم‌ترین تصمیم پروژه است؟

اگر تازه با MySQL آشنا می‌شوید، اول آموزش MySQL از صفر را بخوانید. اما فرض کنیم با مفاهیم پایه راحت هستید. حالا سوال اصلی این است: چرا طراحی دیتابیس، از هر تصمیم فنی دیگر مهم‌تر است؟

سه دلیل که در پروژه‌های واقعی به آن‌ها رسیده‌ام:

  • هزینه‌ی تغییر، تصاعدی است: تغییر یک ستون در روز اول، پنج دقیقه طول می‌کشد. همان تغییر در ماه ششم که کد برنامه، گزارش‌ها و APIها به آن وابسته شده‌اند، می‌تواند هفته‌ها وقت بگیرد. اگر داده‌ی موجود هم باید مهاجرت کند، هزینه‌ی واقعی می‌تواند چند برابر شود.
  • قالب در کیفیت داده اثر می‌گذارد: جدولی که اجازه می‌دهد فیلد خالی داشته باشد، بعد از شش ماه پر از رکوردهای ناقص است. طراحی درست، داده‌ی تمیز تولید می‌کند؛ طراحی نادرست، به‌سختی می‌تواند داده‌ی آلوده را پاک کند.
  • کارایی، در طراحی ذاتی است: اگر ایندکس‌ها و روابط را از اول درست چیده باشید، کوئری‌های سریع نتیجه‌ی طبیعی هستند. اگر نه، هیچ مقدار بهینه‌سازی سطح کوئری جبران نمی‌کند. اصول کامل این موضوع را در ایندکس‌گذاری در MySQL باز کرده‌ام.
دیتابیس، پایه‌ای است که بعداً روی آن، هم کد برنامه و هم داده‌ی کسب‌وکار سوار می‌شود؛ اگر پایه کج باشد، همه‌ی طبقه‌های بعدی کج خواهند بود.

مدل‌سازی داده پیش از هر خط SQL

اشتباه رایج تازه‌کارها این است که مستقیم سراغ CREATE TABLE می‌روند. تجربه‌ی من می‌گوید باید اول کاغذ و قلم بردارید — یا از ابزارهای مدل‌سازی مثل ابزارهای طراحی پایگاه داده استفاده کنید — و مدل داده را ترسیم کنید.

سه سوال کلیدی قبل از طراحی

قبل از این‌که یک جدول بسازید، سه سؤال را جواب بدهید:

  1. «موجودیت»های اصلی این پروژه چیست؟ موجودیت (Entity)، چیزی است که به‌تنهایی معنا دارد: کاربر، محصول، سفارش، دسته‌بندی. هر کدام، یک جدول جداگانه می‌شود.
  2. چه ویژگی‌هایی برای هر موجودیت مهم است؟ این ویژگی‌ها، ستون‌های جدول می‌شوند. ولی مراقب باشید: نه هر ویژگی، یک ستون است. اگر یک ویژگی، خودش چند مقدار دارد (مثل آدرس‌های متعدد یک کاربر)، به جدول جداگانه‌ای نیاز است.
  3. این موجودیت‌ها چطور به هم مرتبطند؟ یک کاربر چند سفارش دارد؟ یک سفارش چند محصول؟ این سؤالات، ساختار کلیدهای خارجی را شکل می‌دهند — همان مفهومی که در آموزش JOIN در MySQL در سطح کوئری بررسی کرده‌ام.

نمونه‌ی عملی: از فکر تا ساختار

فرض کنید می‌خواهید یک فروشگاه اینترنتی بسازید. قبل از هر خط SQL، فهرست موجودیت‌ها را می‌نویسید:

  • users: کاربران سایت
  • products: محصولات
  • categories: دسته‌بندی محصولات
  • orders: سفارش‌ها
  • order_items: قلم‌های سفارش
  • addresses: آدرس‌های کاربران

این فهرست، به‌تنهایی نیمی از طراحی را حل کرده است. حالا سؤال بعدی: هر کدام، چه ویژگی‌هایی دارد؟ و چطور به هم وصل می‌شوند؟ در بخش‌های بعدی به این سؤالات جواب می‌دهیم.

نرمال‌سازی: سه قاعده‌ی کلیدی

نرمال‌سازی، فرآیند ساختاردهی جدول‌ها برای کاهش افزونگی و افزایش انسجام داده است. در عمل، سه سطح اصلی نرمال‌سازی برای اکثر پروژه‌ها کافی است:

قاعده‌ی اول: هر ستون، مقدار اتمیک داشته باشد

هر سلول جدول، باید یک مقدار تک داشته باشد — نه یک لیست، نه یک مجموعه‌ی چسبیده با کاما. مثال نقض:

-- بد: لیست در یک ستون
CREATE TABLE orders (
    id INT PRIMARY KEY,
    product_ids VARCHAR(255)  -- مثلاً "1,5,12,20"
);

این طراحی، سه مشکل دارد: نمی‌توانید مستقیم بفهمید چه محصولی در این سفارش است؛ نمی‌توانید روی این ستون ایندکس بگذارید؛ و برای هر کوئری مرتبط با محصول، باید با LIKE و رشته‌بازی سر و کله بزنید. راه درست، جدول جداگانه:

CREATE TABLE order_items (
    id INT AUTO_INCREMENT PRIMARY KEY,
    order_id INT NOT NULL,
    product_id INT NOT NULL,
    quantity INT NOT NULL DEFAULT 1
);

قاعده‌ی دوم: هر جدول، کلید اصلی داشته باشد

هر ردیف جدول، باید چیزی داشته باشد که آن را از بقیه یکتا کند. این معمولاً یک ستون id است. بدون کلید اصلی، نمی‌توانید رکورد خاصی را دقیق آدرس بدهید — و در نتیجه UPDATE و DELETE می‌توانند به‌طور تصادفی چند رکورد را تغییر دهند.

قاعده‌ی سوم: ستون‌ها به کلید اصلی وابسته باشند

در جدول، فقط چیزهایی را نگه دارید که مستقیماً به رکورد مربوطند. اگر فیلدی به کلید اصلی وابسته نیست، باید در جدول دیگری باشد. مثال نقض:

-- بد: نام دسته در جدول محصولات تکرار می‌شود
CREATE TABLE products (
    id INT PRIMARY KEY,
    name VARCHAR(100),
    category_id INT,
    category_name VARCHAR(100)  -- این باید در جدول categories باشد
);

مشکل این طراحی: اگر نام یک دسته را تغییر دهید، باید در همه‌ی محصولات آن دسته هم به‌روز کنید. اگر یکی را فراموش کنید، دیتابیس شما داده‌ی متناقض دارد. راه درست:

CREATE TABLE categories (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100) NOT NULL UNIQUE
);

CREATE TABLE products (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    category_id INT NOT NULL,
    FOREIGN KEY (category_id) REFERENCES categories(id)
);

اگر با مفهوم جدول‌های مرتبط و JOIN راحت نیستید، آموزش JOIN در MySQL با مثال‌های عملی این مفهوم را روشن می‌کند.

وقتی نرمال‌سازی را می‌شکنیم

نرمال‌سازی، همیشه انتخاب درست نیست. در برخی سناریوها، عمداً نرمال‌سازی را می‌شکنیم — یعنی عمداً داده‌ی تکراری نگه می‌داریم. سه سناریوی رایج که در پروژه‌های واقعی به آن‌ها رسیده‌ام:

  • بهینه‌سازی گزارش‌های پیچیده: اگر مرتب گزارش «فروش روزانه» را از ترکیب چند جدول بزرگ می‌سازید و کوئری کند است، می‌توانید یک جدول خلاصه (daily_sales_summary) بسازید که از قبل محاسبه شده است.
  • ذخیره‌ی وضعیت تاریخی: در جدول order_items، معمولاً باید نام و قیمت محصول در لحظه‌ی خرید ذخیره شود، حتی اگر بعداً تغییر کنند. این عمداً تکرار است، ولی برای صحت گزارش‌های تاریخی ضروری است.
  • ذخیره‌سازی موقت نتایج محاسباتی: مثلاً یک ستون total_amount در جدول orders که از جمع order_items می‌آید، اگر مرتب استفاده شود، می‌تواند ذخیره شود تا کوئری‌های گزارش سریع‌تر شوند.

سه قاعده‌ی طلایی برای نرمال‌سازی‌شکنی:

  1. حتماً مسئول به‌روزرسانی را مشخص کنید: چه کسی مسئول همگام‌سازی داده‌ی تکراری است؟ تریگر، کد برنامه، یا زمان‌بند؟ اگر مشخص نیست، دیر یا زود داده‌ی متناقض می‌شود.
  2. فقط وقتی واقعاً لازم است: اگر با نرمال‌سازی هم کوئری سریع است، به سراغ denormalization نروید. پیچیدگی نگهداری، هزینه‌ای است که در پروژه‌های تازه‌کار زیاد دست‌کم گرفته می‌شود.
  3. مستندسازی کنید که چرا شکسته‌اید: بدون این مستندسازی، دو سال بعد هیچ‌کس نمی‌فهمد چرا یک ستون تکراری در جدول هست.

کلید اصلی: طراحی درست

کلید اصلی، ستون یا ترکیبی از ستون‌ها است که هر رکورد جدول را یکتا می‌کند. سه نوع کلید اصلی که در پروژه‌های واقعی دیده‌ام:

۱) کلید اصلی عددی خودافزا (AUTO_INCREMENT)

CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    ...
);

انتخاب پیش‌فرض من در ۹۵٪ موارد. مزایا: ساده، سریع، مصرف فضای کم. عیب: در سیستم‌های توزیع‌شده، هماهنگی بین چند سرور مشکل می‌شود.

۲) UUID

CREATE TABLE users (
    id CHAR(36) PRIMARY KEY,
    ...
);

مزیت: می‌توانید قبل از درج، شناسه را بسازید (مثلاً در کلاینت). عیب: فضای بیشتر (۳۶ بایت در برابر ۴ بایت INT) و در ایندکس، کمی کندتر. در پروژه‌های واقعی، اگر سیستم توزیع‌شده دارید یا نیاز به privacy دارید (مثل URLهای پشت‌صحنه)، UUID انتخاب درستی است.

۳) کلید ترکیبی

CREATE TABLE user_roles (
    user_id INT NOT NULL,
    role_id INT NOT NULL,
    PRIMARY KEY (user_id, role_id)
);

در جدول‌های میانی (junction tables) که رابطه‌ی چند‌به‌چند را نشان می‌دهند، کلید ترکیبی انتخاب طبیعی است. در جدول‌های موجودیت اصلی، معمولاً از آن پرهیز می‌کنم چون اضافه‌کردن ستون به کلید اصلی، کار سنگینی است.

یک نکته‌ی مهم در انتخاب نوع کلید اصلی: اگر با INT کار می‌کنید، تصمیم بین INT (تا ۲.۱ میلیارد) و BIGINT (تا ۹.۲ کوینتیلیون) را جدی بگیرید. اگر جدول ممکن است بعداً از مرز ۲ میلیارد رکورد رد شود (مثلاً جدول لاگ یا تراکنش‌های یک پلتفرم بزرگ)، از همان اول BIGINT انتخاب کنید — تغییر نوع کلید اصلی روی جدول حجیم، یک کابوس است.

کلید اصلی، شناسنامه‌ی هر رکورد است؛ اگر از روز اول درست انتخاب نشود، سال بعد که به میلیون‌ها رکورد رسیده‌اید، اصلاحش هزینه‌ای چند صد‌برابری دارد.

کلید خارجی و روابط بین جدول‌ها

کلید خارجی (Foreign Key)، ستونی است که به کلید اصلی جدول دیگری اشاره می‌کند. این ابزار، انسجام داده را در سطح دیتابیس تضمین می‌کند:

CREATE TABLE orders (
    id INT AUTO_INCREMENT PRIMARY KEY,
    user_id INT NOT NULL,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,

    CONSTRAINT fk_orders_user
        FOREIGN KEY (user_id) REFERENCES users(id)
        ON DELETE CASCADE
        ON UPDATE CASCADE
);

سه نکته‌ی مهم در کلید خارجی که در پروژه‌های واقعی به آن‌ها رسیده‌ام:

۱) رفتار هنگام حذف و به‌روزرسانی

گزینهمعنیکاربرد
CASCADEحذف/به‌روزرسانی در جدول مرتبط هم انجام می‌شودوقتی رابطه، وابستگی کامل است
SET NULLمقدار کلید خارجی خالی می‌شودوقتی رابطه اختیاری است
RESTRICTاگر رکورد وابسته وجود داشت، حذف را ممنوع می‌کندپیش‌فرض امن
NO ACTIONمشابه RESTRICTتفاوتش با RESTRICT در زمان بررسی است

انتخاب من در پروژه‌های واقعی: برای روابط وابستگی کامل (مثل order_items به orders)، CASCADE؛ برای روابط اختیاری، SET NULL؛ و برای روابط حساس، RESTRICT تا اشتباهات غیرقابل‌بازگشت پیشگیری شود.

۲) ضرورت ایندکس روی کلید خارجی

نکته‌ی مهمی که در پروژه‌های واقعی بارها دیده‌ام و نادیده گرفته می‌شود: MySQL به‌طور خودکار روی کلیدهای خارجی ایندکس نمی‌سازد (برخلاف بعضی دیتابیس‌های دیگر). این یعنی اگر روی user_id در جدول orders ایندکس نگذارید، کوئری‌های JOIN به‌طور کامل کند می‌شوند:

CREATE TABLE orders (
    ...
    user_id INT NOT NULL,
    INDEX idx_orders_user (user_id),
    FOREIGN KEY (user_id) REFERENCES users(id)
);

اصول کامل این نکته و تأثیرش در JOINهای بزرگ را در ایندکس‌گذاری در MySQL باز کرده‌ام.

۳) گاهی کلید خارجی را تعریف نمی‌کنیم

در بعضی پروژه‌های بزرگ، عمداً از کلید خارجی استفاده نمی‌کنیم. دلیلش: هر INSERT و UPDATE باید این قید را بررسی کند و این کار روی جدول‌های پربازدید، هزینه دارد. در عوض، انسجام را در لایه‌ی برنامه تضمین می‌کنیم. این انتخاب در پروژه‌های کوچک و متوسط توصیه نمی‌شود، ولی در برخی معماری‌های big-scale، انتخاب درستی است.

سه نوع رابطه: یک‌به‌یک، یک‌به‌چند، چند‌به‌چند

در مدل‌سازی داده، سه نوع رابطه وجود دارد که هرکدام ساختار متفاوتی می‌سازند:

رابطه‌ی یک‌به‌چند

رایج‌ترین نوع رابطه. یک کاربر، چند سفارش دارد. یک دسته، چند محصول دارد. ساختار استاندارد: کلید خارجی در جدول سمت «چند»:

CREATE TABLE categories (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100) NOT NULL
);

CREATE TABLE products (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    category_id INT NOT NULL,
    INDEX idx_products_category (category_id),
    FOREIGN KEY (category_id) REFERENCES categories(id)
);

رابطه‌ی چند‌به‌چند

یک محصول، چند برچسب دارد؛ یک برچسب، به چند محصول می‌خورد. ساختار: یک جدول میانی:

CREATE TABLE tags (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(50) NOT NULL UNIQUE
);

CREATE TABLE product_tags (
    product_id INT NOT NULL,
    tag_id INT NOT NULL,
    PRIMARY KEY (product_id, tag_id),
    FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE,
    FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
);

سه نکته‌ی مهم در جدول میانی که در پروژه‌های واقعی به آن‌ها رسیده‌ام:

  • کلید ترکیبی، بهترین انتخاب است: (product_id, tag_id) به‌عنوان کلید اصلی، خودش جلوی تکراری‌شدن رابطه را می‌گیرد.
  • در بعضی موارد، ستون اضافه می‌خواهد: اگر جدول میانی، ویژگی خودش دارد (مثل تاریخ اضافه‌شدن برچسب)، یک ستون id خودافزا هم اضافه کنید — بله، کلید ترکیبی هم می‌ماند، ولی با یک شناسه‌ی عددی جداگانه در کنارش.
  • ایندکس روی ستون دوم: اگر برعکس هم جستجو می‌کنید (مثلاً «همه‌ی محصولاتی که این برچسب را دارند»)، ایندکس جداگانه‌ای روی tag_id لازم است.

رابطه‌ی یک‌به‌یک

در این نوع، هر رکورد جدول A، حداکثر یک رکورد در جدول B دارد. مثال: هر کاربر، یک پروفایل تفصیلی. ساختار: کلید خارجی با قید UNIQUE، یا کلید اصلی مشترک:

CREATE TABLE user_profiles (
    user_id INT PRIMARY KEY,
    bio TEXT,
    website VARCHAR(255),
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

نکته‌ی مهم: رابطه‌ی یک‌به‌یک، اگر فیلدها کم و کوتاه باشند، معمولاً نیازی به جدول جداگانه ندارد — می‌توانید همان ستون‌ها را در جدول اصلی بگذارید. جدول جداگانه وقتی معنا دارد که فیلدها سنگین باشند (مثل TEXT یا BLOB) یا دسترسی به آن‌ها کم باشد.

انتخاب نوع داده

انتخاب نوع داده، یک تصمیم هم‌زمان روی سه محور است: صحت، حافظه، و کارایی. جدول زیر، انتخاب‌های من در پروژه‌های واقعی:

دادهنوع پیشنهادیدلیل
شناسه اصلیINT UNSIGNED یا BIGINT UNSIGNEDمثبت، به‌اندازه‌ی کافی بزرگ
مبلغ مالیDECIMAL(12, 2)دقت دقیق، بدون خطای شناور
نام (فارسی)VARCHAR(100) با utf8mb4طول واقعی، فضای کمتر از VARCHAR(255)
ایمیلVARCHAR(150)ایمیل‌های استاندارد، حداکثر ۲۵۴ کاراکتر
توضیحات بلندTEXT یا MEDIUMTEXTبیش از ۶۵٬۵۳۵ کاراکتر
وضعیت با مقادیر ثابتENUMفضای کم، ولی تغییرش سنگین است
وضعیت با مقادیر متغیرجدول مرجع + کلید خارجیانعطاف برای افزودن مقادیر جدید
تاریخ رویدادTIMESTAMPدر UTC ذخیره، تبدیل به منطقه‌ی زمانی جاری
تاریخ تولدDATEبدون ساعت و منطقه‌ی زمانی
پرچم بله/خیرTINYINT(1)ساده و فضای کم

سه نکته‌ی مهم در انتخاب نوع داده که در پروژه‌های واقعی به آن‌ها رسیده‌ام:

  • برای پول، هرگز FLOAT یا DOUBLE: این انواع، اعداد را با نمایش باینری ذخیره می‌کنند و در محاسبات اعشاری خطای کوچکی می‌سازند. مبلغ ۱۰.۲۵ ممکن است به‌عنوان ۱۰.۲۴۹۹۹۹ ذخیره شود. حتماً DECIMAL.
  • طول VARCHAR را دقیق تعیین کنید: تفاوت VARCHAR(50) و VARCHAR(255) در جداول حجیم، محسوس است. طول واقعی مورد نیازتان را انتخاب کنید.
  • TIMESTAMP یا DATETIME: TIMESTAMP در UTC ذخیره می‌شود و در خواندن، به منطقه‌ی زمانی جاری تبدیل می‌شود — برای زمان رویدادها (ثبت سفارش) مناسب است. DATETIME بدون منطقه‌ی زمانی ذخیره می‌شود — برای داده‌های تاریخی (تاریخ تولد) مناسب است.

اصول کامل این انتخاب‌ها و تأثیرشان روی حجم و سرعت، در بهینه‌سازی جداول MySQL باز کرده‌ام.

قیدها و قواعد داده

قیدها (Constraints)، قواعدی هستند که روی ستون‌ها اعمال می‌شوند و صحت داده را در سطح دیتابیس تضمین می‌کنند. اینکه قواعد را در سطح دیتابیس اعمال کنید یا در کد برنامه، یک تصمیم معماری است که در پروژه‌های واقعی به این نتیجه رسیده‌ام: هر دو لازم است، ولی دیتابیس، خط دفاعی آخر است.

CREATE TABLE products (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    slug VARCHAR(120) NOT NULL UNIQUE,
    price DECIMAL(12, 2) NOT NULL CHECK (price >= 0),
    stock INT NOT NULL DEFAULT 0 CHECK (stock >= 0),
    status ENUM("draft", "published", "archived") DEFAULT "draft",
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,

    INDEX idx_products_status (status, created_at)
);

پنج قید اصلی که در پروژه‌های واقعی دائماً استفاده می‌کنم:

  • NOT NULL: اگر ستونی نباید خالی باشد، این قید را بگذارید. مثال: نام محصول، قیمت. اگر بعداً NULL بگذارید، جایی که انتظار مقدار دارید، دیتابیس پاسخ نامناسبی می‌دهد.
  • DEFAULT: مقدار پیش‌فرض. برای فیلدهایی مثل status یا created_at، بی‌نظیر است — کد شما لازم نیست هر بار این مقادیر را پاس بدهد.
  • UNIQUE: برای ستون‌هایی که نباید تکراری باشند، مثل ایمیل یا slug.
  • CHECK: برای منطق‌های ساده مثل «قیمت نباید منفی باشد». توجه: در نسخه‌های قدیمی MySQL، CHECK اعمال نمی‌شد، ولی در ۸.۰.۱۶ به بعد فعال شده.
  • کلید خارجی: همان‌طور که در بخش قبل گفتیم، برای تضمین انسجام روابط.

یک نکته‌ی عملی: در پروژه‌های تیمی، قیدهای دیتابیس، ابزار عالی برای جلوگیری از باگ‌های پنهان هستند. اگر کد برنامه اشتباهاً یک مقدار غیرمنفی می‌فرستد، دیتابیس جلوی آن را می‌گیرد و شما سریع مشکل را پیدا می‌کنید. اگر قید نباشد، داده‌ی آلوده وارد می‌شود و ماه‌ها بعد خودش را نشان می‌دهد.

ایندکس‌گذاری در سطح طراحی

ایندکس، ابزار سرعت است ولی در سطح طراحی دیتابیس، تصمیم‌های مهمی وجود دارد که باید از همان اول گرفته شوند. سه اشتباه رایج که در پروژه‌های واقعی دیده‌ام:

۱) فراموش کردن ایندکس روی کلیدهای خارجی

MySQL به‌طور خودکار روی Foreign Keyها ایندکس نمی‌سازد. اگر شما هم نگذارید، هر JOIN روی آن جدول، تبدیل به یک full table scan می‌شود. این اشتباه، در کوئری‌های گزارش، تفاوت بین یک ثانیه و یک دقیقه است.

۲) ایندکس روی همه ستون‌ها

در سمت دیگر طیف، بعضی تیم‌ها روی همه‌ی ستون‌ها ایندکس می‌گذارند. این کار، سرعت INSERT و UPDATE را به‌شدت پایین می‌آورد چون هر عملیات باید همه‌ی ایندکس‌ها را هم به‌روز کند. قاعده‌ی من: ایندکس روی ستون‌هایی که در WHERE، JOIN، ORDER BY و GROUP BY پرتکرار هستند.

۳) نادیده‌گرفتن ترتیب در ایندکس ترکیبی

در ایندکس ترکیبی، ترتیب ستون‌ها مهم است. ایندکس (user_id, created_at) برای کوئری «همه‌ی سفارش‌های کاربر X به ترتیب تاریخ» عالی است، ولی برای «همه‌ی سفارش‌ها در تاریخ Y» بی‌فایده. ترتیب ستون‌ها را بر اساس پرتکرارترین الگوی کوئری انتخاب کنید — که در مثال ما، user_id در ابتدا است چون کاربر با فیلتر کاربر می‌آید.

اصول کامل انتخاب ایندکس و تحلیل تأثیرش، در ایندکس‌گذاری در MySQL با مثال‌های عملی آمده است. اگر با کوئری‌های کند درگیرید، بهینه‌سازی کوئری‌های MySQL مسیر تحلیل EXPLAIN را نشان می‌دهد.

charset و soft delete

دو تصمیم کوچک که در پروژه‌های واقعی، اثر بزرگی دارند:

۱) charset و collation

برای مخاطب فارسی‌زبان، همیشه utf8mb4 با utf8mb4_unicode_ci یا utf8mb4_persian_ci:

CREATE TABLE products (
    ...
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

اگر با خطای Incorrect string value روبرو شده‌اید یا متون فارسی به‌شکل عجیب ذخیره می‌شوند، رفع خطای Incorrect string value در MySQL مسیر دقیق مهاجرت را نشان می‌دهد.

۲) Soft Delete به‌جای Hard Delete

در پروژه‌های واقعی، به‌ندرت داده را واقعاً حذف می‌کنیم. به‌جایش، یک ستون deleted_at می‌گذاریم و در کوئری‌ها، WHERE deleted_at IS NULL شرط می‌گذاریم:

CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    email VARCHAR(150) NOT NULL UNIQUE,
    deleted_at DATETIME DEFAULT NULL,
    INDEX idx_users_active (deleted_at, created_at)
);

مزیت soft delete: امکان بازیابی، حفظ تاریخچه، و جلوگیری از حذف‌های ناخواسته. عیبش: پیچیدگی کوئری‌ها (باید همیشه شرط WHERE deleted_at IS NULL بگذارید) و رشد جدول. در پروژه‌های واقعی، اگر داده‌ی شما ارزش تاریخی دارد یا با قوانین حفظ داده سر و کار دارید، این انتخاب درستی است.

نسخه‌بندی ساختار دیتابیس

در پروژه‌های واقعی، ساختار دیتابیس ثابت نمی‌ماند. ستون‌های جدید اضافه می‌شوند، ایندکس‌ها تغییر می‌کنند، و گاهی جدول‌ها بازطراحی می‌شوند. نسخه‌بندی ساختار دیتابیس، ابزار ضروری برای تیم‌های چندنفره است.

سه رویکرد که در پروژه‌های واقعی به‌کارم آمده:

۱) فایل‌های migration با ابزار ORM

Django، SQLAlchemy و Laravel، هرکدام سیستم migration داخلی دارند. برای هر تغییر در ساختار، یک فایل migration ساخته می‌شود و در مخزن کد نگه داشته می‌شود. این رویکرد، در پروژه‌های بزرگ رایج است چون امکان بازگشت به نسخه‌های قبل را هم می‌دهد. اگر با پایتون کار می‌کنید، اصول مشابه در آموزش جنگو برای مبتدیان و اتصال پایتون به MySQL آمده است.

۲) فایل‌های SQL شماره‌دار

رویکرد ساده‌تر: پوشه‌ای به نام migrations/ با فایل‌هایی مثل 001_create_users.sql، 002_add_email_column.sql. مزیتش این است که مستقل از زبان و ابزار است، و در پروژه‌های چندزبانه (مثلاً PHP + Python) کار می‌کند.

۳) ابزار تخصصی migration

ابزارهایی مثل Flyway یا Liquibase، برای مدیریت migration در پروژه‌های بزرگ مناسبند — خصوصاً که امکان rollback و مدیریت نسخه‌ها را در سطح پیشرفته‌تری می‌دهند. ولی برای پروژه‌های کوچک، اضافه‌کاری است.

سه قاعده‌ی طلایی در migration که در پروژه‌های واقعی به آن‌ها رسیده‌ام:

  • هرگز migration اجراشده را تغییر ندهید: اگر یک migration را روی staging اجرا کرده‌اید، دیگر تغییرش ندهید. یک migration جدید برای اصلاح بنویسید. تغییر migration قدیمی، باعث می‌شود دیتابیس توسعه‌دهنده‌های مختلف، ساختار متفاوتی داشته باشند.
  • قبل از هر migration روی تولید، بکاپ بگیرید: حتی برای تغییرات کوچک. یک ALTER TABLE اشتباه روی جدول حجیم، می‌تواند ساعت‌ها سایت را از کار بیندازد. اصول پشتیبان‌گیری و بازیابی در پشتیبان‌گیری از MySQL آمده است.
  • migration را در ساعت کم‌ترافیک اجرا کنید: تغییرات ساختاری، جدول را قفل می‌کنند. اگر جدول شما میلیون‌ها رکورد دارد، این قفل می‌تواند دقایق طول بکشد.
دیتابیس، هیچ‌وقت در وضعیت نهایی نیست؛ همیشه در حال تکامل است. migration، ابزار مدیریت این تکامل است — نه یک بار برای همیشه، بلکه در هر sprint.

اشتباهاتی که در پروژه‌های واقعی دیده‌ام

در بازبینی طراحی دیتابیس پروژه‌های مختلف، این اشتباهات را زیاد دیده‌ام:

  • همه‌چیز در یک جدول: همان فاجعه‌ی مقدمه‌ی این مقاله. اگر مفهوم‌های مختلف (کاربر، محصول، سفارش) در یک جدول جمع شوند، گزارش‌گیری و نگهداری به کابوس تبدیل می‌شود.
  • لیست در یک ستون: ذخیره‌ی "1,5,12,20" به‌جای جدول جداگانه. این کار در روز اول ساده به‌نظر می‌رسد ولی در ماه سوم، برای هر کوئری مرتبط، باید با LIKE و رشته‌بازی سر و کله بزنید.
  • نبود کلید اصلی: بدون کلید اصلی، نمی‌توانید رکورد خاصی را دقیق آدرس بدهید و UPDATE می‌تواند چند رکورد را تغییر دهد.
  • نبود ایندکس روی کلیدهای خارجی: هر JOIN روی این ستون‌ها، تبدیل به full scan می‌شود.
  • استفاده از FLOAT برای پول: خطای محاسبه‌ی اعشاری، گزارش‌های مالی را خراب می‌کند. همیشه DECIMAL.
  • charset اشتباه: latin1 یا utf8 به‌جای utf8mb4. برای متن فارسی و ایموجی، باعث خطا یا ذخیره‌ی نادرست می‌شود.
  • تعریف نوع داده‌ی بزرگ‌تر از نیاز: VARCHAR(255) برای همه‌ی رشته‌ها، BIGINT برای همه‌ی شناسه‌ها. فضای اضافه، در نهایت به کندی می‌انجامد.
  • نبود soft delete: حذف واقعی داده‌ها، امکان بازیابی را از بین می‌برد. در پروژه‌های واقعی، همیشه یک ستون deleted_at لازم است.
  • نبود migration: تغییرات ساختاری دستی روی دیتابیس تولید، بدون مستندسازی. دو ماه بعد، هیچ‌کس نمی‌داند چرا یک ستون اضافه شده یا چرا یک جدول خاص است.
  • طراحی بدون مستندسازی: جدول‌ها و ستون‌ها بدون توضیح. یادگیری دیتابیس برای توسعه‌دهنده‌ی جدید، ساعت‌ها طول می‌کشد. یک فایل SCHEMA.md ساده، این زمان را نصف می‌کند.
  • نادیده‌گرفتن حجم رشد: جدولی که امروز هزار رکورد دارد، دو سال بعد ده میلیون دارد. تصمیم‌های طراحی، باید با دید بلندمدت گرفته شوند.
  • استفاده از ENUM برای مقادیری که تغییر می‌کنند: اگر مقادیر مجاز ممکن است تغییر کنند (مثل وضعیت سفارش که ممکن است مرحله‌ی جدیدی اضافه شود)، ENUM انتخاب بدی است. جدول مرجع، گزینه‌ی درست.

یک توصیه‌ی عملی از تجربه: قبل از این‌که یک طراحی را نهایی کنید، سه سؤال از خودتان بپرسید. اول، «اگر این پروژه دو سال دیگر ده برابر شود، این ساختار جواب می‌دهد؟» دوم، «اگر بخواهم یکی از این جدول‌ها را به سیستم دیگری منتقل کنم، مشکلی پیش می‌آید؟» سوم، «اگر یک توسعه‌دهنده‌ی جدید بدون صحبت با من، این دیتابیس را ببیند، می‌فهمد ساختارش چیست؟» اگر جواب هر سه «بله» است، طراحی شما آماده است.

سخن آخر

طراحی دیتابیس در MySQL، از یک CREATE TABLE شروع می‌شود ولی در پروژه‌های واقعی، به یک تصمیم راهبردی تبدیل می‌شود که سرنوشت پروژه را در بلندمدت تعیین می‌کند. سه نکته‌ی اصلی که در این مقاله به آن‌ها رسیدیم: اول، مدل‌سازی قبل از SQL — فهرست موجودیت‌ها و روابط، نیمی از طراحی را حل می‌کند؛ دوم، نرمال‌سازی را جدی بگیرید ولی وقتی لازم است، عمداً بشکنید — نه از سر تنبلی، بلکه با دلیل و مستندسازی؛ سوم، تصمیم‌های کوچک (نوع داده، charset، soft delete، migration) اثر بزرگ دارند — تصمیم‌های درست امروز، صرفه‌جویی‌های شش‌ماهه‌ی آینده است.

اگر امروز می‌خواهید شروع کنید، سه کار کوچک پیشنهاد می‌کنم: یک پروژه‌ی فرضی انتخاب کنید (فروشگاه، وبلاگ، شبکه اجتماعی)، روی کاغذ فهرست موجودیت‌ها و روابطش را بنویسید، و بعد شروع به ساخت جدول‌ها کنید. همین تمرین ساده، تفکر داده‌ای شما را شکل می‌دهد — مهارتی که در تمام پروژه‌های آینده به‌کارتان می‌آید. اگر تجربه‌ای از طراحی دیتابیس در پروژه‌های خودتان دارید — مخصوصاً اگر با بازطراحی سنگین روبرو شده‌اید یا تصمیم طراحی خاصی گرفته‌اید که بعداً پشیمان شدید — در دیدگاه‌ها بنویسید؛ همین نکته‌های میدانی، برای خواننده‌ی بعدی از هر مستند رسمی ارزشمندتر است. 🗄️