از یک جدول اکسلی تا یک مدل درست
بیشتر دیتابیسهای مشکلدار از یک «جدول اکسل بزرگ» شروع شدهاند: هر ردیف یک فروش، با نام مشتری، تلفن، نام فرش، قیمت و شهر بافت. نرمالسازی یعنی هر واقعیت فقط یک جا ذخیره شود تا تغییرش هم یک جا باشد.
سه قدم اول
| فرم | قاعده | نقض رایج |
|---|---|---|
| 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).