結論先講
很多人覺得資料庫設計就是正規化到第三正規型,然後就完事了。但在真實的生產環境裡,你還要處理索引效能、資料遷移、命名規範、刪除策略、備份恢復這些看似不起眼但出事就要你命的東西。
資料庫是你系統的根基。應用程式可以重寫,框架可以換,但資料遺失了就是遺失了。這篇從實務角度整理出好的資料庫設計該具備的 11 個面向。
體檢清單
1. 適當的正規化(但知道何時反正規化)
正規化減少資料冗餘,反正規化提升查詢效能。重點是你知道自己在做什麼。
-- 正規化:訂單和商品分開
CREATE TABLE orders (
id UUID PRIMARY KEY,
user_id UUID REFERENCES users(id),
total_amount DECIMAL(10,2),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE order_items (
id UUID PRIMARY KEY,
order_id UUID REFERENCES orders(id),
product_id UUID REFERENCES products(id),
quantity INT NOT NULL,
unit_price DECIMAL(10,2) NOT NULL
);
-- 反正規化:在訂單項目裡存商品快照
CREATE TABLE order_items (
id UUID PRIMARY KEY,
order_id UUID REFERENCES orders(id),
product_id UUID REFERENCES products(id),
product_name VARCHAR(200) NOT NULL, -- 快照!商品改名不影響歷史訂單
product_price DECIMAL(10,2) NOT NULL, -- 快照!
quantity INT NOT NULL
);- 至少到第三正規型
- 效能需要時才反正規化,並且記錄原因
- 有明確的資料冗餘管理策略
2. 索引策略
索引不是越多越好。 每個索引都會拖慢寫入速度,佔用磁碟空間。
-- 常見的索引類型
CREATE INDEX idx_users_email ON users(email); -- 單欄位
CREATE INDEX idx_orders_user_date ON orders(user_id, created_at); -- 複合索引
CREATE UNIQUE INDEX idx_users_email_unique ON users(email); -- 唯一索引
CREATE INDEX idx_articles_title ON articles USING gin(to_tsvector('chinese', title)); -- 全文搜尋- 所有 WHERE、JOIN、ORDER BY 常用的欄位都有索引
- 複合索引的欄位順序正確(最常篩選的在前面)
- 沒有重複或無用的索引
- 定期檢查 slow query log
3. Migration 系統
- 每次 schema 變更都有 migration 檔
- Migration 可以 rollback
- 有 seed data(開發和測試環境用)
- Migration 檔在版本控制裡
# Alembic migration 範例
def upgrade():
op.add_column('users', sa.Column('phone', sa.String(20), nullable=True))
op.create_index('idx_users_phone', 'users', ['phone'])
def downgrade():
op.drop_index('idx_users_phone', 'users')
op.drop_column('users', 'phone')4. 命名規範
| 項目 | 建議 | 範例 |
|---|---|---|
| Table | 複數、snake_case | users、order_items |
| Column | snake_case | first_name、created_at |
| Primary Key | id | id |
| Foreign Key | {table_singular}_id | user_id、order_id |
| Index | idx_{table}_{columns} | idx_users_email |
| Boolean | is_ 或 has_ 前綴 | is_active、has_verified |
| Timestamp | _at 後綴 | created_at、deleted_at |
- 全專案命名規範一致
- 有文件記錄命名規範
- 不用保留字當欄位名(
order、group、user)
5. UUID vs Auto-increment
| 特性 | UUID | Auto-increment |
|---|---|---|
| 全域唯一 | 是 | 否(表內唯一) |
| 安全性 | 不可猜測 | 可推測總數量 |
| 效能(插入) | 略慢(隨機 IO) | 快(順序寫入) |
| 效能(索引) | 較差(B-tree 分裂) | 好 |
| 分散式 | 天生支援 | 需要額外機制 |
| 大小 | 16 bytes | 4-8 bytes |
-- 推薦:UUIDv7(時間排序的 UUID)
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- ...
);
-- 或者用 ULID
-- 兼具 UUID 的全域唯一性和 auto-increment 的排序性- 對外暴露的 ID 用 UUID(安全考量)
- 內部關聯可以用 auto-increment(效能考量)
- 考慮 UUIDv7 或 ULID(兼顧排序性)
6. 軟刪除 vs 硬刪除
-- 軟刪除
ALTER TABLE users ADD COLUMN deleted_at TIMESTAMPTZ;
-- 查詢時自動排除
CREATE VIEW active_users AS
SELECT * FROM users WHERE deleted_at IS NULL;
-- 硬刪除
DELETE FROM users WHERE id = '...';| 特性 | 軟刪除 | 硬刪除 |
|---|---|---|
| 資料可恢復 | 是 | 否 |
| 查詢複雜度 | 較高(要加 WHERE) | 低 |
| 磁碟空間 | 持續增長 | 不會 |
| GDPR 合規 | 需要定期清理 | 天生合規 |
| 適用場景 | 商業資料、需要審計 | 暫存資料、日誌 |
- 重要商業資料用軟刪除
- 軟刪除有定期清理機制
- ORM 層有全域 filter(不用每個查詢都記得加條件)
7. 稽核欄位(Audit Trail)
每張表都該有的欄位:
CREATE TABLE articles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(200) NOT NULL,
content TEXT,
-- ... 業務欄位
-- 稽核欄位
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES users(id),
updated_by UUID REFERENCES users(id),
deleted_at TIMESTAMPTZ, -- 軟刪除
version INT NOT NULL DEFAULT 1 -- 樂觀鎖
);
-- 自動更新 updated_at
CREATE TRIGGER update_articles_updated_at
BEFORE UPDATE ON articles
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();- 所有表都有
created_at、updated_at - 重要表有
created_by、updated_by - 敏感操作有完整的 audit log table
8. Connection Pooling
應用程式 → Connection Pool (PgBouncer/HikariCP) → 資料庫
# 為什麼需要?
# - 資料庫連線建立成本高(TCP handshake + auth)
# - 資料庫有最大連線數限制(PostgreSQL 預設 100)
# - 微服務架構下,連線數很容易爆
- 有 connection pool(PgBouncer、HikariCP 等)
- Pool size 有根據負載調整
- 有 connection timeout 設定
- 監控連線使用率
9. 備份策略
- 自動定期備份(至少每日)
- 備份有異地存放
- 定期測試備份恢復(這點最多人忽略)
- 有 point-in-time recovery(WAL archiving)
- 備份有加密
# PostgreSQL 備份範例
# 邏輯備份
pg_dump -Fc mydb > backup_$(date +%Y%m%d).dump
# 恢復
pg_restore -d mydb backup_20260315.dump
# WAL 歸檔(支援 PITR)
archive_command = 'cp %p /backup/wal/%f'10. 監控
- Slow query log 有開啟並定期檢查
- 連線數監控
- 磁碟空間監控
- 鎖等待(lock wait)監控
- Replication lag 監控(如果有 replica)
-- PostgreSQL 查看 slow queries
SELECT query, calls, mean_exec_time, total_exec_time
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 10;
-- 查看目前連線
SELECT count(*), state
FROM pg_stat_activity
GROUP BY state;11. Read Replicas
- 讀寫分離(Write → Primary、Read → Replica)
- 應用程式層有 router 自動分流
- 監控 replication lag
- 知道 eventual consistency 的影響
資料庫選型比較
| 特性 | PostgreSQL | MySQL | MongoDB | Redis |
|---|---|---|---|---|
| 型別 | RDBMS | RDBMS | Document DB | Key-Value |
| JSON 支援 | JSONB(很強) | JSON(普通) | 天生 JSON | 有限 |
| 全文搜尋 | 內建 | 內建(基本) | 內建 | 不支援 |
| 擴展性 | 垂直為主 | 垂直為主 | 水平擴展 | 水平擴展 |
| 交易 | 完整 ACID | 完整 ACID | 有限 | 有限 |
| 適用場景 | 通用、複雜查詢 | 通用、Web 應用 | 彈性 schema | 快取、佇列 |
| 授權 | PostgreSQL License | GPL | SSPL | BSD |
2026 年的建議: 如果你不知道選什麼,選 PostgreSQL。它幾乎什麼都能做,而且做得很好。
FAQ
Q1: 正規化要做到第幾正規型?
實務上做到第三正規型(3NF)就夠了。BCNF、4NF、5NF 的邊際效益太低,而且會讓 schema 過度拆分、查詢變複雜。
Q2: UUID 真的比 auto-increment 好嗎?
看場景。如果你的 ID 會暴露給外部(API URL 裡的 /users/123),用 UUID 比較安全。如果是內部用的 join table,auto-increment 效能更好。UUIDv7 是一個很好的折衷方案。
Q3: 什麼時候該用 NoSQL?
當你的資料結構非常不固定(例如使用者設定、IoT sensor data)、或需要水平擴展到多台機器時。但大部分 web 應用,關聯式資料庫就夠用了。不要因為「NoSQL 比較潮」就跳進去。
Q4: 備份多久做一次?
至少每天一次完整備份。如果資料很重要(金融、醫療),考慮 WAL 歸檔做到 point-in-time recovery,這樣可以恢復到任意時間點。最重要的是:定期測試恢復流程。
Q5: Connection pool size 設多少?
PostgreSQL 官方建議公式:connections = (core_count * 2) + effective_spindle_count。一般來說,每個 application instance 設 10-20 就好。設太大反而會因為 context switching 降低效能。
系列導航
| # | 文章 | 狀態 |
|---|---|---|
| 01 | 好的前端專案該有什麼?一張體檢表 | |
| 02 | 好的後端框架需要具備哪些功能? | |
| 03 | 好的 API 該長什麼樣? | |
| 04 | 好的資料庫設計需要什麼?不只是正規化 | 📍 你在這裡 |
| 05 | 好的基礎建設需要什麼? | |
| 06 | CD Pipeline 需要什麼? | |
| 07 | 好的監控系統需要什麼? | |
| 08 | 好的開發者體驗(DX)需要什麼? |