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_indexesidx_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 是最早發現問題的方式。