فصل ۳: طراحی دیتابیس — نوع داده، کلیدها و نرمال‌سازی

نرمال‌سازی ۱NF تا ۳NF و طراحی دیتابیس فروشگاه فرش

از یک جدول اکسلی تا یک مدل درست

بیشتر دیتابیس‌های مشکل‌دار از یک «جدول اکسل بزرگ» شروع شده‌اند: هر ردیف یک فروش، با نام مشتری، تلفن، نام فرش، قیمت و شهر بافت. نرمال‌سازی یعنی هر واقعیت فقط یک جا ذخیره شود تا تغییرش هم یک جا باشد.

سه قدم اول

فرمقاعدهنقض رایج
1NFهر خانه یک مقدار اتمی؛ بدون گروه تکرارشوندهستون items با مقدار «فرش۱،فرش۲» یا ستون‌های item1، item2، item3
2NFدر کلید ترکیبی، هر ستون به کل کلید وابسته باشددر order_items با کلید (order_id, carpet_id)، ستون carpet_title فقط به carpet_id وابسته است
3NFستون غیرکلیدی به ستون غیرکلیدی دیگر وابسته نباشددر customers، ستون province از city به دست می‌آید

نتیجه‌ی ناهنجاری‌ها: تلفن مشتری در ۴۰ ردیف تکرار شده و بعد از تغییر شماره فقط ۳۹ تا اصلاح می‌شود (ناهنجاری به‌روزرسانی)؛ نمی‌توان فرش جدیدی را قبل از اولین فروش ثبت کرد (ناهنجاری درج)؛ با حذف تنها فروش یک مشتری، خود مشتری هم گم می‌شود (ناهنجاری حذف).

طراحی: فروشگاه فرش

CREATE TABLE cities (
  id SMALLINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  name VARCHAR(40) NOT NULL, province VARCHAR(40) NOT NULL,
  UNIQUE KEY uq_city (province, name)
);

CREATE TABLE customers (
  id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  full_name VARCHAR(120) NOT NULL,
  mobile CHAR(11) NOT NULL UNIQUE,
  city_id SMALLINT UNSIGNED NULL,
  FOREIGN KEY (city_id) REFERENCES cities (id)
);

CREATE TABLE designs (                         -- نقشه/طرح: لچک‌ترنج، افشان، هریس
  id SMALLINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  name VARCHAR(60) NOT NULL UNIQUE
);

CREATE TABLE products (
  id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  sku VARCHAR(20) NOT NULL UNIQUE,
  title VARCHAR(150) NOT NULL,
  design_id SMALLINT UNSIGNED NOT NULL,
  reeds SMALLINT UNSIGNED NULL,
  width_cm SMALLINT UNSIGNED NOT NULL, length_cm SMALLINT UNSIGNED NOT NULL,
  list_price DECIMAL(15,0) NOT NULL CHECK (list_price >= 0),
  FOREIGN KEY (design_id) REFERENCES designs (id)
);

CREATE TABLE orders (
  id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  customer_id INT UNSIGNED NOT NULL,
  status VARCHAR(20) NOT NULL DEFAULT 'draft',
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  total_amount DECIMAL(15,0) NOT NULL DEFAULT 0,   -- دنرمال‌سازی آگاهانه
  FOREIGN KEY (customer_id) REFERENCES customers (id),
  INDEX ix_orders_status_created (status, created_at)
);

CREATE TABLE order_items (
  order_id BIGINT UNSIGNED NOT NULL,
  product_id INT UNSIGNED NOT NULL,
  qty SMALLINT UNSIGNED NOT NULL CHECK (qty > 0),
  unit_price DECIMAL(15,0) NOT NULL,               -- قیمت در لحظه‌ی فروش
  PRIMARY KEY (order_id, product_id),
  FOREIGN KEY (order_id) REFERENCES orders (id) ON DELETE CASCADE,
  FOREIGN KEY (product_id) REFERENCES products (id)
);

دنرمال‌سازی آگاهانه

ستون total_amount از روی اقلام قابل محاسبه است، پس از نظر نظری تکراری است. اما فهرست سفارش‌ها با جمع مبلغ روزی هزاران بار خوانده می‌شود و محاسبه‌ی آن با JOIN و SUM گران است. این یک تصمیم است، نه تنبلی: باید سازوکار همگام‌سازی داشته باشد (در یک تراکنش با درج اقلام، یا Trigger) و گاهی با یک کوئری بررسی شود که با جمع واقعی می‌خواند.

نکته‌هایی که کمتر کسی می‌داند

  • unit_price در order_items تکرار قیمت محصول نیست؛ یک واقعیت تاریخی است («این فرش به این قیمت فروخته شد»). اگر آن را از products بخوانید، با هر تغییر قیمت فاکتورهای قدیمی عوض می‌شوند.
  • آدرس ارسال هم همین‌طور است: آدرس را هنگام ثبت سفارش در خود سفارش کپی کنید؛ مشتری ممکن است فردا اسباب‌کشی کند.
  • برای ویژگی‌های متغیر، الگوی EAV (جدول attribute/value) معمولاً به کوئری‌های کابوس‌وار می‌رسد؛ در MySQL 8 یک ستون JSON با ستون تولیدشده‌ی ایندکس‌دار اغلب انتخاب بهتری است.
  • در جدول واسط، ترتیب ستون‌های کلید اصلی ترکیبی مهم است: (order_id, product_id) پرس‌وجوی «اقلام یک سفارش» را سریع می‌کند، و برای «سفارش‌های یک محصول» ایندکس جدا روی product_id لازم است (کلید خارجی خودش آن را می‌سازد).
  • نام‌گذاری یکدست (جمع برای جدول، _id برای کلید خارجی، snake_case) را از روز اول قانون کنید؛ MySQL روی لینوکس به بزرگی و کوچکی نام جدول حساس است و روی ویندوز نیست (lower_case_table_names).

برای ذخیره‌ی پیشرفت و شرکت در آزمون، وارد شوید — رایگان است.