Database Diagrams: คู่มือปฏิบัติสำหรับออกแบบและจัดทำเอกสาร Schema
Database diagram หนึ่งภาพมีค่ากว่าเอกสารประกอบเป็นพันคำ เมื่อวิศวกรใหม่เข้าร่วมทีม diagram ของ schema ที่วาดไว้อย่างดีสื่อสาร data model ได้เร็วกว่า wiki page ใดๆ เมื่อออกแบบ feature ใหม่ ERD คร่าวๆ บน whiteboard ช่วยค้นพบความไม่สอดคล้องก่อนที่จะเขียน SQL แม้แต่บรรทัดเดียว และเมื่อเกิด incident ตี 3 diagram ที่แสดง foreign key relationships อย่างชัดเจนคือความแตกต่างระหว่างการแก้ไขที่รวดเร็วกับการเดาสุ่มนานหนึ่งชั่วโมง
คู่มือนี้ครอบคลุมทุกอย่างตั้งแต่ระบบ notation พื้นฐานไปจนถึง tooling เชิงปฏิบัติ พร้อม diagram จริงที่ใช้ Mermaid และ PlantUML ซึ่งสามารถนำไปใส่ในเอกสารของคุณได้ทันที
ทำไม Database Diagrams ถึงสำคัญ
Database diagrams ทำหน้าที่สำคัญสามประการในขั้นตอนการทำงานของทีม software
การสื่อสาร: diagram ทำให้สมมติฐานที่ซ่อนอยู่กลายเป็นสิ่งที่มองเห็นได้ order จำเป็นต้องมี user เสมอหรือไม่ หรือสามารถ checkout แบบ guest ได้? product สามารถอยู่ใน category มากกว่าหนึ่งได้ไหม? คำถามเหล่านี้มองเห็นได้ทันทีใน diagram และมักถูกมองข้ามในเอกสารแบบร้อยแก้ว
การ Onboarding: สมาชิกทีมใหม่ใช้เวลาหลายวันหรือหลายสัปดาห์สร้าง mental model ของ data layer diagram ที่ครอบคลุมเพียงภาพเดียวย่นย่อเวลานั้นลงอย่างมาก วิศวกรที่เข้าใจ data model เขียน query ได้ดีกว่า ออกแบบ API ได้ดีกว่า และทำให้เกิด data-consistency bug น้อยกว่า
Design Reviews: การตรวจสอบ schema diagram ระหว่างการวางแผนช่วยค้นพบปัญหา — foreign key ที่ขาดหาย relationship ที่กำกวม ปัญหา normalization — ก่อนที่มันจะถูกฝังใน migration ที่ยากต่อการยกเลิก
ประเภทของ Database Diagrams
Entity-Relationship Diagrams (ERDs)
ERD คือประเภทที่พบบ่อยที่สุดของ database diagram ซึ่ง model entities (ตาราง) attribute (คอลัมน์) และ relationship ระหว่างกัน ERD มีอยู่สองระดับ:
- Conceptual ERD: เฉพาะ entities และ relationships เท่านั้น ไม่มีประเภทคอลัมน์ ไม่มี key ใช้สำหรับการหารือในระดับสูงกับผู้มีส่วนได้ส่วนเสียที่ไม่ใช่ด้านเทคนิค
- Logical ERD: เพิ่มชื่อ attribute และชนิดข้อมูล primary key และ foreign key แต่ยังคงเป็น database-agnostic
Physical Database Diagrams
Physical diagram คือ logical ERD ที่แปลงมาสำหรับ database engine เฉพาะ มันแสดงประเภทคอลัมน์ที่แน่นอน (VARCHAR(255), BIGINT, TIMESTAMPTZ) indexes constraints (NOT NULL, UNIQUE, CHECK) และ feature เฉพาะของ engine เช่น partitioning หรือ tablespaces นี่คือสิ่งที่คุณสร้างจาก tool อย่าง DBeaver หรือ pgAdmin โดย reverse-engineering จาก database ที่ใช้งานจริง
Logical vs Physical Models
| ด้าน | Logical Model | Physical Model |
|---|---|---|
| ผู้ชม | Architects, analysts | DBAs, backend engineers |
| ประเภทข้อมูล | Generic (String, Integer) | Engine-specific (TEXT, INT8) |
| Keys | Conceptual PK/FK | Index definitions จริง |
| Portability | Database-agnostic | Engine-specific |
| Use case | Design, communication | Implementation, migration |
Standard Notations
Chen Notation
Peter Chen แนะนำ ERD notation ดั้งเดิมในปี 1976 ใน Chen notation:
- สี่เหลี่ยม แทน entities (เช่น
USER,ORDER) - รูปเพชร แทน relationships (เช่น
PLACES) - วงรี แทน attributes (เช่น
email,created_at) - สี่เหลี่ยมคู่ แทน weak entities (entities ที่ไม่สามารถมีอยู่ได้โดยไม่มี parent)
- รูปเพชรคู่ แทน identifying relationships
Chen notation เหมาะสำหรับงานวิชาการและ conceptual modeling ระดับสูง ในทางปฏิบัติมันจะเทอะทะสำหรับ schema ที่มีมากกว่า 10–15 entities เนื่องจากรูปร่างเพชรและเส้นที่เชื่อมต่อสร้างความวุ่นวายทางสายตา
Crow’s Foot / IE Notation
Crow’s Foot (หรือเรียกว่า Information Engineering notation) คือมาตรฐานที่โดดเด่นในเครื่องมือ database มืออาชีพ มันวางสัญลักษณ์ cardinality ของ relationship ตรงบนเส้นที่เชื่อมต่อโดยตรง:
||— หนึ่งอย่างแน่นอน|<— หนึ่งหรือมากกว่า (the “crow’s foot”)o|— ศูนย์หรือหนึ่งo<— ศูนย์หรือมากกว่า
Crow’s Foot diagrams ดูสะอาดตากว่า Chen notation สำหรับ schema ขนาดใหญ่ ทุก tool ที่คุณจะพบในทางปฏิบัติ — DBeaver, pgAdmin, DataGrip, dbdiagram.io — ใช้ Crow’s Foot เป็นค่าเริ่มต้น
UML Class Diagrams สำหรับ Databases
UML class diagrams ยืมมาจาก object-oriented design แต่ทำงานได้ดีสำหรับ database modeling ตารางกลายเป็น classes คอลัมน์กลายเป็น attributes และ relationships แสดงด้วย UML multiplicity notation (1, *, 0..1, 1..*) ถ้าทีมของคุณใช้ UML สำหรับ architecture diagrams อยู่แล้ว การใช้มันสำหรับ data layer ทำให้ทุกอย่างสอดคล้องกัน
IDEF1X Notation
IDEF1X (Integration Definition for Information Modeling) คือ notation เชิงทางการที่ใช้ในระบบภาครัฐและอวกาศ มันแยกแยะระหว่าง identifying relationships (ที่ primary key ของ child entity รวม primary key ของ parent) และ non-identifying relationships คุณจะพบ IDEF1X น้อยมากนอกบริบทองค์กรหรืออุตสาหกรรมที่ถูกควบคุม
การวาด ERD ด้วย Mermaid
Mermaid syntax แบบ erDiagram render ได้โดยตรงใน GitHub Markdown, GitLab, Notion และ platform เอกสารสมัยใหม่ส่วนใหญ่ มันใช้ Crow’s Foot notation โดยอัตโนมัติ
Basic Relationships: One-to-Many
erDiagram
USER {
bigint id PK
varchar email UK
varchar name
timestamp created_at
}
ORDER {
bigint id PK
bigint user_id FK
varchar status
decimal total_amount
timestamp created_at
}
ADDRESS {
bigint id PK
bigint user_id FK
varchar street
varchar city
varchar country
}
USER ||--o{ ORDER : "places"
USER ||--o{ ADDRESS : "has"
syntax ||--o{ อ่านว่า: entity ด้านซ้ายมีหนึ่ง instance ที่สัมพันธ์กับศูนย์หรือมากกว่า instance ทางขวา — one-to-many แบบคลาสสิก
Many-to-Many กับ Junction Table
Many-to-many relationships จำเป็นต้องมีตารางกลาง (junction table) ใน relational database เสมอ ERD ควรแสดงสิ่งนี้อย่างชัดเจน:
erDiagram
PRODUCT {
bigint id PK
varchar name
text description
decimal price
bigint category_id FK
}
TAG {
bigint id PK
varchar name UK
varchar slug UK
}
PRODUCT_TAG {
bigint product_id FK
bigint tag_id FK
}
CATEGORY {
bigint id PK
varchar name UK
bigint parent_id FK
}
PRODUCT }o--o{ TAG : "tagged with"
PRODUCT_TAG }|--|| PRODUCT : "belongs to"
PRODUCT_TAG }|--|| TAG : "references"
PRODUCT }o--|| CATEGORY : "belongs to"
CATEGORY ||--o{ CATEGORY : "parent of"
Complete E-Commerce Schema
diagram นี้แสดง e-commerce data model ระดับ production พร้อม users, orders, products และ order line items ที่เชื่อมต่อกัน:
erDiagram
USER {
bigint id PK
varchar email UK
varchar name
varchar password_hash
boolean is_active
timestamp created_at
timestamp updated_at
}
ADDRESS {
bigint id PK
bigint user_id FK
varchar street
varchar city
varchar state
varchar country
varchar postal_code
boolean is_default
}
PRODUCT {
bigint id PK
varchar sku UK
varchar name
text description
decimal price
int stock_quantity
bigint category_id FK
timestamp created_at
}
CATEGORY {
bigint id PK
varchar name UK
bigint parent_id FK
}
ORDER {
bigint id PK
bigint user_id FK
bigint shipping_address_id FK
varchar status
decimal subtotal
decimal tax
decimal total
timestamp created_at
timestamp updated_at
}
ORDER_ITEM {
bigint id PK
bigint order_id FK
bigint product_id FK
int quantity
decimal unit_price
decimal line_total
}
PAYMENT {
bigint id PK
bigint order_id FK
varchar provider
varchar status
decimal amount
varchar transaction_ref
timestamp paid_at
}
USER ||--o{ ADDRESS : "has"
USER ||--o{ ORDER : "places"
CATEGORY ||--o{ PRODUCT : "contains"
CATEGORY ||--o{ CATEGORY : "parent of"
ORDER }o--|| ADDRESS : "ships to"
ORDER ||--o{ ORDER_ITEM : "contains"
ORDER ||--o{ PAYMENT : "paid via"
PRODUCT ||--o{ ORDER_ITEM : "included in"
การวาดด้วย PlantUML
syntax !define ของ PlantUML สร้าง diagram คุณภาพสิ่งพิมพ์และ integrate กับ Confluence, IntelliJ IDEA และ VS Code สำหรับ database schema ใช้ keyword entity
Blog Platform Schema
@startuml blog-schema
!define TABLE(name,desc) class name as "desc" << (T,#FFAAAA) >>
!define PK(x) <b><color:#b8861b>x</color></b>
!define FK(x) <color:#aaaaaa>x</color>
hide methods
hide stereotypes
TABLE(users, "users") {
PK(id) BIGSERIAL
--
email VARCHAR(255) UNIQUE NOT NULL
username VARCHAR(100) UNIQUE NOT NULL
password_hash VARCHAR(255) NOT NULL
display_name VARCHAR(200)
bio TEXT
avatar_url TEXT
is_active BOOLEAN DEFAULT true
created_at TIMESTAMPTZ NOT NULL
updated_at TIMESTAMPTZ NOT NULL
}
TABLE(posts, "posts") {
PK(id) BIGSERIAL
--
FK(author_id) BIGINT NOT NULL
title VARCHAR(500) NOT NULL
slug VARCHAR(500) UNIQUE NOT NULL
excerpt TEXT
content TEXT NOT NULL
status VARCHAR(20) DEFAULT 'draft'
published_at TIMESTAMPTZ
view_count INT DEFAULT 0
created_at TIMESTAMPTZ NOT NULL
updated_at TIMESTAMPTZ NOT NULL
}
TABLE(comments, "comments") {
PK(id) BIGSERIAL
--
FK(post_id) BIGINT NOT NULL
FK(author_id) BIGINT NOT NULL
FK(parent_id) BIGINT
content TEXT NOT NULL
is_approved BOOLEAN DEFAULT false
created_at TIMESTAMPTZ NOT NULL
}
TABLE(tags, "tags") {
PK(id) BIGSERIAL
--
name VARCHAR(100) UNIQUE NOT NULL
slug VARCHAR(100) UNIQUE NOT NULL
description TEXT
}
TABLE(post_tags, "post_tags") {
FK(post_id) BIGINT NOT NULL
FK(tag_id) BIGINT NOT NULL
--
created_at TIMESTAMPTZ NOT NULL
}
users "1" --> "0..*" posts : author_id
users "1" --> "0..*" comments : author_id
posts "1" --> "0..*" comments : post_id
comments "0..1" --> "0..*" comments : parent_id
posts "1" --> "0..*" post_tags : post_id
tags "1" --> "0..*" post_tags : tag_id
@enduml
E-Commerce Physical Schema พร้อม Indexes
@startuml ecommerce-physical
!define TABLE(name,desc) class name as "desc" << (T,#AADDFF) >>
!define PK(x) <b><color:#b8861b>x</color></b>
!define FK(x) <color:#888888>x</color>
!define IDX(x) <color:#336699><i>x</i></color>
hide methods
hide stereotypes
TABLE(users, "users") {
PK(id) BIGSERIAL PRIMARY KEY
--
email VARCHAR(255) UNIQUE NOT NULL
password_hash VARCHAR(255) NOT NULL
created_at TIMESTAMPTZ DEFAULT now()
==indexes==
IDX(idx_users_email) ON (email)
}
TABLE(orders, "orders") {
PK(id) BIGSERIAL PRIMARY KEY
--
FK(user_id) BIGINT NOT NULL
status VARCHAR(30) NOT NULL DEFAULT 'pending'
total NUMERIC(12,2) NOT NULL
created_at TIMESTAMPTZ DEFAULT now()
==indexes==
IDX(idx_orders_user_id) ON (user_id)
IDX(idx_orders_status) ON (status)
IDX(idx_orders_created_at) ON (created_at DESC)
}
TABLE(products, "products") {
PK(id) BIGSERIAL PRIMARY KEY
--
FK(category_id) BIGINT
sku VARCHAR(100) UNIQUE NOT NULL
name VARCHAR(500) NOT NULL
price NUMERIC(10,2) NOT NULL
stock_qty INT NOT NULL DEFAULT 0
==indexes==
IDX(idx_products_sku) ON (sku)
IDX(idx_products_category) ON (category_id)
}
TABLE(order_items, "order_items") {
PK(id) BIGSERIAL PRIMARY KEY
--
FK(order_id) BIGINT NOT NULL
FK(product_id) BIGINT NOT NULL
quantity INT NOT NULL
unit_price NUMERIC(10,2) NOT NULL
line_total NUMERIC(12,2) GENERATED ALWAYS AS (quantity * unit_price) STORED
==indexes==
IDX(idx_oi_order_id) ON (order_id)
IDX(idx_oi_product_id) ON (product_id)
}
users "1" --> "0..*" orders : user_id
orders "1" --> "1..*" order_items : order_id
products "1" --> "0..*" order_items : product_id
@enduml
ตัวอย่างจริง: Blog Platform Schema
มาออกแบบ blog platform ที่สมบูรณ์ตั้งแต่ต้น ความต้องการนั้นตรงไปตรงมา:
- Users เขียน posts; posts มี slug สำหรับ URL ที่เป็นมิตรกับ SEO
- Posts สามารถ comment ได้; comments เป็นแบบ threaded (parent/child)
- Posts ถูก tag; tag นำมาใช้ซ้ำได้ข้าม posts
- timestamps ทั้งหมดใช้ประเภทที่รับรู้ timezone
ERD ใน Mermaid
erDiagram
USERS {
bigint id PK
varchar email UK
varchar username UK
varchar password_hash
varchar display_name
text bio
text avatar_url
boolean is_active
timestamptz created_at
timestamptz updated_at
}
POSTS {
bigint id PK
bigint author_id FK
varchar title
varchar slug UK
text excerpt
text content
varchar status
timestamptz published_at
int view_count
timestamptz created_at
timestamptz updated_at
}
COMMENTS {
bigint id PK
bigint post_id FK
bigint author_id FK
bigint parent_id FK
text content
boolean is_approved
timestamptz created_at
}
TAGS {
bigint id PK
varchar name UK
varchar slug UK
text description
}
POST_TAGS {
bigint post_id FK
bigint tag_id FK
timestamptz created_at
}
USERS ||--o{ POSTS : "writes"
USERS ||--o{ COMMENTS : "authors"
POSTS ||--o{ COMMENTS : "receives"
COMMENTS ||--o{ COMMENTS : "parent of"
POSTS ||--o{ POST_TAGS : "has"
TAGS ||--o{ POST_TAGS : "used in"
SQL DDL ในสาม Dialects
-- PostgreSQL
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL,
username VARCHAR(100) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
display_name VARCHAR(200),
bio TEXT,
avatar_url TEXT,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uq_users_email UNIQUE (email),
CONSTRAINT uq_users_username UNIQUE (username)
);
CREATE TABLE posts (
id BIGSERIAL PRIMARY KEY,
author_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(500) NOT NULL,
slug VARCHAR(500) NOT NULL,
excerpt TEXT,
content TEXT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft','published','archived')),
published_at TIMESTAMPTZ,
view_count INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT uq_posts_slug UNIQUE (slug)
);
CREATE TABLE comments (
id BIGSERIAL PRIMARY KEY,
post_id BIGINT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
author_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
parent_id BIGINT REFERENCES comments(id) ON DELETE CASCADE,
content TEXT NOT NULL,
is_approved BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE tags (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
slug VARCHAR(100) NOT NULL,
description TEXT,
CONSTRAINT uq_tags_name UNIQUE (name),
CONSTRAINT uq_tags_slug UNIQUE (slug)
);
CREATE TABLE post_tags (
post_id BIGINT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
tag_id BIGINT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (post_id, tag_id)
);
-- Indexes
CREATE INDEX idx_posts_author_id ON posts(author_id);
CREATE INDEX idx_posts_status ON posts(status);
CREATE INDEX idx_posts_published_at ON posts(published_at DESC)
WHERE status = 'published';
CREATE INDEX idx_comments_post_id ON comments(post_id);
CREATE INDEX idx_comments_parent_id ON comments(parent_id);-- MySQL
CREATE TABLE users (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL,
username VARCHAR(100) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
display_name VARCHAR(200),
bio TEXT,
avatar_url TEXT,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)
ON UPDATE CURRENT_TIMESTAMP(6),
UNIQUE KEY uq_users_email (email),
UNIQUE KEY uq_users_username (username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE posts (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
author_id BIGINT UNSIGNED NOT NULL,
title VARCHAR(500) NOT NULL,
slug VARCHAR(500) NOT NULL,
excerpt TEXT,
content LONGTEXT NOT NULL,
status ENUM('draft','published','archived') NOT NULL DEFAULT 'draft',
published_at DATETIME(6),
view_count INT UNSIGNED NOT NULL DEFAULT 0,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)
ON UPDATE CURRENT_TIMESTAMP(6),
UNIQUE KEY uq_posts_slug (slug),
KEY idx_posts_author_id (author_id),
KEY idx_posts_status (status),
KEY idx_posts_published_at (published_at),
CONSTRAINT fk_posts_author FOREIGN KEY (author_id)
REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE comments (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
post_id BIGINT UNSIGNED NOT NULL,
author_id BIGINT UNSIGNED NOT NULL,
parent_id BIGINT UNSIGNED,
content TEXT NOT NULL,
is_approved TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
KEY idx_comments_post_id (post_id),
KEY idx_comments_parent_id (parent_id),
CONSTRAINT fk_comments_post FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
CONSTRAINT fk_comments_author FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_comments_parent FOREIGN KEY (parent_id) REFERENCES comments(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE tags (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
slug VARCHAR(100) NOT NULL,
description TEXT,
UNIQUE KEY uq_tags_name (name),
UNIQUE KEY uq_tags_slug (slug)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE post_tags (
post_id BIGINT UNSIGNED NOT NULL,
tag_id BIGINT UNSIGNED NOT NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (post_id, tag_id),
CONSTRAINT fk_pt_post FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
CONSTRAINT fk_pt_tag FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;-- SQLite
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
display_name TEXT,
bio TEXT,
avatar_url TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now','utc')),
updated_at TEXT NOT NULL DEFAULT (datetime('now','utc'))
);
CREATE TABLE posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
excerpt TEXT,
content TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft','published','archived')),
published_at TEXT,
view_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now','utc')),
updated_at TEXT NOT NULL DEFAULT (datetime('now','utc'))
);
CREATE TABLE comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
parent_id INTEGER REFERENCES comments(id) ON DELETE CASCADE,
content TEXT NOT NULL,
is_approved INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now','utc'))
);
CREATE TABLE tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
slug TEXT NOT NULL UNIQUE,
description TEXT
);
CREATE TABLE post_tags (
post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (datetime('now','utc')),
PRIMARY KEY (post_id, tag_id)
);
CREATE INDEX idx_posts_author_id ON posts(author_id);
CREATE INDEX idx_posts_status ON posts(status);
CREATE INDEX idx_comments_post ON comments(post_id);ORM Model Definitions
blog schema เดิมแสดงเป็น ORM models สำหรับ stack ที่พบบ่อยที่สุดสี่แบบ:
// Prisma (TypeScript)
// schema.prisma
model User {
id BigInt @id @default(autoincrement())
email String @unique @db.VarChar(255)
username String @unique @db.VarChar(100)
passwordHash String @map("password_hash") @db.VarChar(255)
displayName String? @map("display_name") @db.VarChar(200)
bio String?
avatarUrl String? @map("avatar_url")
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
posts Post[]
comments Comment[]
@@map("users")
}
model Post {
id BigInt @id @default(autoincrement())
authorId BigInt @map("author_id")
title String @db.VarChar(500)
slug String @unique @db.VarChar(500)
excerpt String?
content String
status String @default("draft") @db.VarChar(20)
publishedAt DateTime? @map("published_at") @db.Timestamptz
viewCount Int @default(0) @map("view_count")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
comments Comment[]
tags PostTag[]
@@index([authorId])
@@index([status])
@@map("posts")
}
model Comment {
id BigInt @id @default(autoincrement())
postId BigInt @map("post_id")
authorId BigInt @map("author_id")
parentId BigInt? @map("parent_id")
content String
isApproved Boolean @default(false) @map("is_approved")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
parent Comment? @relation("CommentThread", fields: [parentId], references: [id])
children Comment[] @relation("CommentThread")
@@index([postId])
@@map("comments")
}
model Tag {
id BigInt @id @default(autoincrement())
name String @unique @db.VarChar(100)
slug String @unique @db.VarChar(100)
description String?
posts PostTag[]
@@map("tags")
}
model PostTag {
postId BigInt @map("post_id")
tagId BigInt @map("tag_id")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
@@id([postId, tagId])
@@map("post_tags")
}// JPA / Hibernate (Java/Spring Boot)
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 255)
private String email;
@Column(nullable = false, unique = true, length = 100)
private String username;
@Column(name = "password_hash", nullable = false, length = 255)
private String passwordHash;
@Column(name = "display_name", length = 200)
private String displayName;
@Column(columnDefinition = "TEXT")
private String bio;
@Column(name = "avatar_url", columnDefinition = "TEXT")
private String avatarUrl;
@Column(name = "is_active", nullable = false)
private boolean isActive = true;
@Column(name = "created_at", nullable = false,
columnDefinition = "TIMESTAMPTZ DEFAULT now()")
private OffsetDateTime createdAt;
@Column(name = "updated_at", nullable = false,
columnDefinition = "TIMESTAMPTZ DEFAULT now()")
private OffsetDateTime updatedAt;
@OneToMany(mappedBy = "author", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Post> posts = new ArrayList<>();
}
@Entity
@Table(name = "posts", indexes = {
@Index(name = "idx_posts_author_id", columnList = "author_id"),
@Index(name = "idx_posts_status", columnList = "status")
})
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "author_id", nullable = false)
private User author;
@Column(nullable = false, length = 500)
private String title;
@Column(nullable = false, unique = true, length = 500)
private String slug;
@Column(columnDefinition = "TEXT")
private String excerpt;
@Column(nullable = false, columnDefinition = "TEXT")
private String content;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private PostStatus status = PostStatus.DRAFT;
@Column(name = "published_at",
columnDefinition = "TIMESTAMPTZ")
private OffsetDateTime publishedAt;
@Column(name = "view_count", nullable = false)
private int viewCount = 0;
@OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Comment> comments = new ArrayList<>();
@OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
private List<PostTag> tags = new ArrayList<>();
}# SQLAlchemy 2.x (Python)
from __future__ import annotations
from datetime import datetime
from typing import Optional
from sqlalchemy import (
BigInteger, Boolean, Column, DateTime, ForeignKey,
Index, Integer, String, Text, UniqueConstraint, func
)
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
username: Mapped[str] = mapped_column(String(100), nullable=False, unique=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
display_name: Mapped[Optional[str]] = mapped_column(String(200))
bio: Mapped[Optional[str]] = mapped_column(Text)
avatar_url: Mapped[Optional[str]] = mapped_column(Text)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
posts: Mapped[list["Post"]] = relationship(back_populates="author", cascade="all, delete-orphan")
comments: Mapped[list["Comment"]] = relationship(back_populates="author", cascade="all, delete-orphan")
class Post(Base):
__tablename__ = "posts"
__table_args__ = (
UniqueConstraint("slug", name="uq_posts_slug"),
Index("idx_posts_author_id", "author_id"),
Index("idx_posts_status", "status"),
)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
author_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
title: Mapped[str] = mapped_column(String(500), nullable=False)
slug: Mapped[str] = mapped_column(String(500), nullable=False)
excerpt: Mapped[Optional[str]] = mapped_column(Text)
content: Mapped[str] = mapped_column(Text, nullable=False)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="draft")
published_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
view_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
author: Mapped["User"] = relationship(back_populates="posts")
comments: Mapped[list["Comment"]] = relationship(back_populates="post", cascade="all, delete-orphan")
post_tags: Mapped[list["PostTag"]] = relationship(back_populates="post", cascade="all, delete-orphan")
class Comment(Base):
__tablename__ = "comments"
__table_args__ = (
Index("idx_comments_post_id", "post_id"),
Index("idx_comments_parent_id", "parent_id"),
)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
post_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("posts.id", ondelete="CASCADE"), nullable=False)
author_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
parent_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("comments.id", ondelete="CASCADE"))
content: Mapped[str] = mapped_column(Text, nullable=False)
is_approved: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
post: Mapped["Post"] = relationship(back_populates="comments")
author: Mapped["User"] = relationship(back_populates="comments")
parent: Mapped[Optional["Comment"]] = relationship(remote_side="Comment.id", back_populates="children")
children: Mapped[list["Comment"]] = relationship(back_populates="parent")
class Tag(Base):
__tablename__ = "tags"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(100), nullable=False, unique=True)
slug: Mapped[str] = mapped_column(String(100), nullable=False, unique=True)
description: Mapped[Optional[str]] = mapped_column(Text)
post_tags: Mapped[list["PostTag"]] = relationship(back_populates="tag", cascade="all, delete-orphan")
class PostTag(Base):
__tablename__ = "post_tags"
post_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("posts.id", ondelete="CASCADE"), primary_key=True)
tag_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("tags.id", ondelete="CASCADE"), primary_key=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
post: Mapped["Post"] = relationship(back_populates="post_tags")
tag: Mapped["Tag"] = relationship(back_populates="post_tags")// Entity Framework Core (C# / .NET)
public class User
{
public long Id { get; set; }
public string Email { get; set; } = null!;
public string Username { get; set; } = null!;
public string PasswordHash { get; set; } = null!;
public string? DisplayName { get; set; }
public string? Bio { get; set; }
public string? AvatarUrl { get; set; }
public bool IsActive { get; set; } = true;
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
public ICollection<Post> Posts { get; set; } = [];
public ICollection<Comment> Comments { get; set; } = [];
}
public class Post
{
public long Id { get; set; }
public long AuthorId { get; set; }
public string Title { get; set; } = null!;
public string Slug { get; set; } = null!;
public string? Excerpt { get; set; }
public string Content { get; set; } = null!;
public PostStatus Status { get; set; } = PostStatus.Draft;
public DateTimeOffset? PublishedAt { get; set; }
public int ViewCount { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
public User Author { get; set; } = null!;
public ICollection<Comment> Comments { get; set; } = [];
public ICollection<PostTag> PostTags { get; set; } = [];
}
public class Comment
{
public long Id { get; set; }
public long PostId { get; set; }
public long AuthorId { get; set; }
public long? ParentId { get; set; }
public string Content { get; set; } = null!;
public bool IsApproved { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public Post Post { get; set; } = null!;
public User Author { get; set; } = null!;
public Comment? Parent { get; set; }
public ICollection<Comment> Children { get; set; } = [];
}
// EF Core configuration (IEntityTypeConfiguration<T> pattern)
public class PostConfiguration : IEntityTypeConfiguration<Post>
{
public void Configure(EntityTypeBuilder<Post> builder)
{
builder.ToTable("posts");
builder.HasKey(p => p.Id);
builder.Property(p => p.Title).HasMaxLength(500).IsRequired();
builder.Property(p => p.Slug).HasMaxLength(500).IsRequired();
builder.HasIndex(p => p.Slug).IsUnique();
builder.HasIndex(p => p.AuthorId);
builder.HasIndex(p => p.Status);
builder.Property(p => p.Status).HasConversion<string>();
builder.HasOne(p => p.Author)
.WithMany(u => u.Posts)
.HasForeignKey(p => p.AuthorId)
.OnDelete(DeleteBehavior.Cascade);
}
}Best Practices
Naming Conventions
ความสม่ำเสมอสำคัญกว่า convention เฉพาะที่คุณเลือก เลือกหนึ่งแบบและบังคับใช้ทุกตาราง:
- ชื่อตาราง: lowercase snake_case พหูพจน์ (
users,order_itemsไม่ใช่UserหรือorderItem) - ชื่อคอลัมน์: lowercase snake_case (
created_at,user_idไม่ใช่createdAtหรือUserId) - Primary keys: ใช้
idเสมอ — ไม่ใช่user_idในตารางusersไม่ใช่pk_user - Foreign keys:
{referenced_table_singular}_id—user_id,post_id,category_id - Boolean columns: นำหน้าด้วย
is_,has_, หรือcan_—is_active,has_verified_email - Timestamps:
created_atและupdated_atทุกตาราง ต้องเป็นแบบรับรู้ timezone เสมอ - Junction tables: รวมชื่อตารางทั้งสองตามตัวอักษร —
post_tags(ไม่ใช่tag_postsหรือposts_to_tags) - Indexes:
idx_{table}_{columns}—idx_posts_author_id,idx_orders_status_created_at - Constraints:
uq_{table}_{column},fk_{table}_{referenced_table},chk_{table}_{column}
เมื่อไรควร Normalize vs Denormalize
Third Normal Form (3NF) คือจุดเริ่มต้นที่เหมาะสำหรับ OLTP schema ส่วนใหญ่ Normalize จนกว่าจะเจ็บปวด แล้วค่อย denormalize อย่างมีกลยุทธ์
Normalize เมื่อ:
- ข้อมูลถูกเขียนบ่อย (normalization ลด write amplification)
- Data integrity มีความสำคัญ — คุณรับความไม่สม่ำเสมอไม่ได้
- Data model ยังอยู่ระหว่างการพัฒนา — normalized schema เปลี่ยนแปลงได้ง่ายกว่า
Denormalize เมื่อ:
- Read performance มีความสำคัญ และข้อมูลถูกอ่านมากกว่าเขียนมาก
- คุณต้องการหลีกเลี่ยง join ที่มีค่าใช้จ่ายสูงข้ามตารางขนาดใหญ่ (เช่น reporting dashboards)
- คุณใช้คอลัมน์ JSONB เพื่อเก็บข้อมูล semi-structured ที่แตกต่างกันในแต่ละแถว
- คุณพิสูจน์แล้วว่า normalization เป็น bottleneck จากการ profiling จริง — ไม่ใช่แค่การสันนิษฐาน
pattern เชิงปฏิบัตินิยมที่พบบ่อย: เก็บ write path ให้ fully normalized อย่างสมบูรณ์ และใช้ materialized views หรือ read replicas ที่มีโครงสร้าง denormalized สำหรับ read path ของคุณ
การจัดทำเอกสาร Constraints
Constraints ไม่ใช่แค่กลไกการบังคับใช้ของ database — มันคือเอกสาร constraint ทุกอันบอกผู้อ่านบางอย่างเกี่ยวกับ business rules ที่ฝังอยู่ใน schema:
NOT NULLบอกว่าฟิลด์นั้นจำเป็นตาม business domainUNIQUEบอกว่าไม่มีสองแถวที่แสดง logical entity เดียวกันได้CHECKบอกว่าค่าต้องตอบสนอง business ruleFOREIGN KEYบอกว่า entity นี้ไม่สามารถมีอยู่ได้โดยไม่มี parentDEFAULTบอกว่าระบบจ่ายค่าอะไรเมื่อ application ไม่ได้ระบุ
ตั้งชื่อ constraints ของคุณอย่างชัดเจนแทนที่จะพึ่งพาชื่อที่สร้างอัตโนมัติ CONSTRAINT uq_users_email UNIQUE (email) อ้างอิงได้ง่ายกว่ามากใน error messages และ migrations กว่า users_email_key
การ Version-Control การเปลี่ยนแปลง Schema
ปฏิบัติกับ migrations เหมือน code artifacts ชั้นหนึ่ง:
- ใช้ migration tool — Flyway, Liquibase, Alembic, Prisma Migrate หรือ Django migrations อย่าใช้การเปลี่ยนแปลง schema ด้วยมือใน production
- หนึ่งการเปลี่ยนแปลงต่อ migration — migration ที่เพิ่มคอลัมน์และสร้าง index คือสองการเปลี่ยนแปลงเชิง logic แยกมันออกจากกันเพื่อให้ rollback แม่นยำ
- เขียนทั้ง up และ down migrations — แม้คุณจะไม่เคย rollback up migration เลย down migration ก็จัดทำเอกสาร inverse operation และบังคับให้คุณคิดผ่านมัน
- อย่าแก้ไข migration ที่ commit แล้ว — committed migrations คือประวัติที่ไม่เปลี่ยนแปลง แก้ไขด้วย migration ใหม่
- ตรวจสอบ migrations เหมือน code — รวมการเปลี่ยนแปลง schema ใน pull requests migration ที่ drop คอลัมน์ที่มีข้อมูลสมควรได้รับการตรวจสอบอย่างเดียวกับการเปลี่ยนแปลง code ที่เกี่ยวกับความปลอดภัย
- เก็บ diagram ไว้พร้อมกับ migrations — เก็บ
docs/schema.mmdหรือdocs/schema.pumlใน repository ของคุณและอัปเดตมันเป็นส่วนหนึ่งของ PR เดียวกันกับทุก migration
การเปรียบเทียบ Tools
| Tool | เหมาะสำหรับ | จุดแข็ง | ข้อจำกัด |
|---|---|---|---|
| dbdiagram.io | เอกสารด่วน, การแชร์ | syntax DBML รวดเร็ว output สวยงาม shareable links | Online เท่านั้น free tier จำกัด |
| DBeaver | Multi-database reverse engineering | รองรับ 80+ databases ER diagram จาก live DB Community edition ฟรี | การ export diagram อาจมีปัญหา |
| pgAdmin | งานเฉพาะ PostgreSQL | built-in ERD tool, query plan visualizer, integrate กับ PG อย่างแน่นหนา | PostgreSQL เท่านั้น |
| DataGrip | Power users, IDE integration | schema visualization ยอดเยี่ยม smart migrations, multi-DB | ต้องชำระเงิน (JetBrains subscription) |
| draw.io / diagrams.net | Custom diagrams, presentations | ฟรีทั้งหมด offline ปรับแต่งได้สูง integrate กับ Confluence | ด้วยมือ — ไม่มี database reverse engineering |
| Mermaid | Docs-as-code, Git-native | render ใน GitHub/GitLab/Notion, version-controlled, plain text | การปรับแต่งภาพจำกัด |
| PlantUML | Enterprise/Confluence docs | ควบคุม layout ได้มาก CI/CD friendly, IntelliJ integration | ต้องการ Java หรือ server rendering |
| Lucidchart | Team collaboration | real-time collaboration, database import, Jira/Confluence plugins | ต้องชำระเงิน |
สำหรับทีมส่วนใหญ่ การผสมผสานเชิงปฏิบัติคือ: Mermaid สำหรับ diagrams ที่ฝังอยู่ใน Git repositories (ใน README.md, ADRs, PR descriptions) และ DBeaver หรือ DataGrip สำหรับ reverse-engineering และสำรวจ databases ที่ใช้งานจริง
บทสรุป
Database diagram ที่อยู่ใน repository ของคุณพร้อมกับ migration files มีคุณค่ามากกว่า diagram ที่ฝังอยู่ใน Confluence page ซึ่งอัปเดตครั้งสุดท้ายเมื่อสองปีที่แล้วอย่างหาที่เปรียบไม่ได้ tooling ของ diagram ที่ดีที่สุดสำหรับทีมวิศวกรรมคือที่ปฏิบัติกับ diagram เหมือน code — version-controlled, diffable, reviewable และ render อัตโนมัติใน platform ที่ทีมของคุณใช้อยู่แล้ว
เริ่มต้นด้วย Mermaid erDiagram ใน folder docs/ ของโปรเจ็กต์ของคุณวันนี้ เพิ่ม CI step ที่ตรวจสอบ Mermaid syntax ทำให้การอัปเดต diagram เป็นส่วนที่จำเป็นของกระบวนการเปลี่ยนแปลง schema ของคุณ ใน 6 เดือน วิศวกรทุกคนในทีมของคุณจะขอบคุณคุณ — รวมถึงตัวคุณเองในตอนตี 3 ระหว่าง incident