結論先講

很多人覺得資料庫設計就是正規化到第三正規型,然後就完事了。但在真實的生產環境裡,你還要處理索引效能、資料遷移、命名規範、刪除策略、備份恢復這些看似不起眼但出事就要你命的東西。

資料庫是你系統的根基。應用程式可以重寫,框架可以換,但資料遺失了就是遺失了。這篇從實務角度整理出好的資料庫設計該具備的 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_caseusersorder_items
Columnsnake_casefirst_namecreated_at
Primary Keyidid
Foreign Key{table_singular}_iduser_idorder_id
Indexidx_{table}_{columns}idx_users_email
Booleanis_has_ 前綴is_activehas_verified
Timestamp_at 後綴created_atdeleted_at
  • 全專案命名規範一致
  • 有文件記錄命名規範
  • 不用保留字當欄位名(ordergroupuser

5. UUID vs Auto-increment

特性UUIDAuto-increment
全域唯一否(表內唯一)
安全性不可猜測可推測總數量
效能(插入)略慢(隨機 IO)快(順序寫入)
效能(索引)較差(B-tree 分裂)
分散式天生支援需要額外機制
大小16 bytes4-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_atupdated_at
  • 重要表有 created_byupdated_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 的影響

資料庫選型比較

特性PostgreSQLMySQLMongoDBRedis
型別RDBMSRDBMSDocument DBKey-Value
JSON 支援JSONB(很強)JSON(普通)天生 JSON有限
全文搜尋內建內建(基本)內建不支援
擴展性垂直為主垂直為主水平擴展水平擴展
交易完整 ACID完整 ACID有限有限
適用場景通用、複雜查詢通用、Web 應用彈性 schema快取、佇列
授權PostgreSQL LicenseGPLSSPLBSD

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 降低效能。


系列導航