N+1 Query
最常見的 ORM 陷阱。查詢 10 個 users,每個 user 再查一次 posts——11 次 DB 查詢代替了可以用 1 次 JOIN 解決的事。
# Django ORM 的 N+1
users = User.objects.all() # 1 次查詢
for user in users:
print(user.posts.count()) # 每個 user 又查一次,N 次
# 修法:prefetch_related
users = User.objects.prefetch_related('posts').all() # 2 次查詢搞定偵測方式:Django Debug Toolbar、SQLAlchemy echo=True、TypeORM 的 logging: "all"。在 staging 環境監控 query 數量,超過 50 次/request 就要審查。
EAV Schema(Entity-Attribute-Value)
「我不知道用戶會存什麼屬性,所以用一個萬用表」:
-- EAV 設計(反例)
CREATE TABLE user_attributes (
user_id INT,
attribute_name VARCHAR,
attribute_value TEXT
);
-- 存一個用戶的 name、email、age 要 3 rows問題:無法有效建索引、查詢需要多次 JOIN 或 pivot、無法用 DB 的型別系統做驗證。
修法:如果屬性集合是動態的,用 JSONB(PostgreSQL)——有彈性且可以建 GIN 索引。如果屬性集合是已知的,就建正常的欄位。
不加索引 / 加了沒用的索引
沒加:全表掃描在小資料時沒感覺,資料量上去後 API timeout。
加了沒用:
- Low cardinality 欄位加了索引(
is_active只有 0/1,加索引無效) - 複合索引順序錯了(
(email, created_at)的索引不能用在WHERE created_at = X) - 索引欄位被函式包裹(
WHERE YEAR(created_at) = 2024不走索引)
定期用 pg_stat_user_indexes 找 idx_scan = 0 的索引,清掉它們(索引有維護成本)。
用 DB 當 Message Queue
SELECT ... FOR UPDATE SKIP LOCKED 可以實作一個基本的任務佇列,但這讓 DB 承受了它不擅長的工作負載——高頻的鎖競爭、大量短期 row 的 insert/delete/update。
DB 的 VACUUM 機制不是為了處理百萬級的短暫 row 設計的。當任務量上去,DB 的 I/O 和鎖競爭會影響到主業務查詢。
修法:用 Redis(簡單佇列)或 RabbitMQ / Kafka(複雜場景)。如果任務量小(<100 jobs/minute)且已有 DB,繼續用可以接受,但要知道這個設計的上限。
每 Request 重新建立 DB 連線
建立 TCP 連線 + DB 握手需要幾十到幾百毫秒。如果每個 HTTP request 都這樣做,DB 連線時間可能佔 API latency 的 50% 以上。
修法:Connection pool(SQLAlchemy pool、pgBouncer、HikariCP)。一個連線建立後,被後續 request 複用。設定要合理:pool_size 不是越大越好,受 DB 的 max_connections 限制。
ORM 預設 Lazy Loading 在迴圈裡
類似 N+1,但更隱蔽。ORM 的 lazy loading 在你第一次訪問關聯物件時才查詢——如果在迴圈裡訪問,就等於 N+1。
# SQLAlchemy lazy loading(反例)
for order in orders:
print(order.user.name) # 每次訪問 .user 就查一次
# 修法:eager loading
from sqlalchemy.orm import joinedload
orders = db.query(Order).options(joinedload(Order.user)).all()JSON Column 當成反正規化的主表
把所有東西塞進一個 JSON 欄位,「彈性」地儲存:
-- 反例
CREATE TABLE users (
id INT,
data JSONB -- 把 name, email, preferences, settings 全塞這裡
);問題:無法建有效的複合索引、跨欄位的查詢笨拙、schema 沒有強制類型。
什麼時候用 JSON/JSONB 是合理的:真正動態的半結構化資料(用戶自定義欄位、API response 快取)。核心業務屬性不要放 JSON。
Migration 沒有 Rollback Plan
ALTER TABLE orders ADD COLUMN discount_rate DECIMAL——成功了。但 deploy 之後發現 bug 需要 rollback,但 migration 沒有寫 down migration,或者 down migration 會刪除已有資料。
原則:
- 每個 migration 要有可執行的 rollback
- Destructive migration(刪欄位、刪表)要分兩步:先 deploy code 不使用那個欄位,再 migration 刪掉
- Migration 要在 staging 環境先跑過,確認 rollback 也能跑
這些 anti-pattern 大多數在小資料量時不顯眼,在流量上去後才暴露。定期的 EXPLAIN ANALYZE review 和 slow query log 是最早發現問題的方式。