程式碼走讀:計數器為什麼不能 load–mutate–save
這個站的澆灌與到訪計數背後是一張極簡的表:noteId、views、waters。它的 repository 是全站唯一寫原生 SQL 的地方,值得走讀一次「為什麼」。
錯誤版本:教科書寫法的競態
DDD 的直覺寫法是 load–mutate–save:
ts
const stats = await repo.findByNoteId(noteId); // 讀:views = 41
stats.views += 1; // 算:42
await repo.save(stats); // 寫:42
兩個請求同時進來:都讀到 41、都算出 42、都寫回 42——兩次瀏覽只算了一次。計數器的每次更新都依賴「當下的值」,read 與 write 之間的空隙就是丟失更新的窗口。低流量看不出來,但錯的設計不會因為沒被抓到就變對。
正確版本:把加法交給資料庫
sql
INSERT INTO note_stats ("noteId", views, waters)
VALUES ($1, 1, 0)
ON CONFLICT ("noteId")
DO UPDATE SET views = note_stats.views + 1
一條語句做完三件事:列不存在就建(首次互動 lazy 建立,不用 backfill 零值列)、存在就 在資料庫端做 views + 1。加法發生在 DB 內部、持有列鎖的瞬間——兩個併發請求會被排序成 41→42→43,一次都不丟。
澆灌的變體多一個尾巴:
sql
... DO UPDATE SET waters = note_stats.waters + 1
RETURNING views, waters
RETURNING 讓「更新後的最新值」跟寫入同一趟往返回來——前端按下澆灌要立刻顯示新數字,不用再查一次。
這裡不走 domain entity 是刻意的
計數是讀者遙測,不是筆記的領域不變量——筆記的成熟度、發佈狀態才是 aggregate 要守的東西。把 views 塞進 Note entity,每次瀏覽都要載入整個 aggregate 才能 +1,還把純淨的領域模型跟高頻寫入綁在一起。輕量資料配輕量通道:一個獨立的小 port、兩條原生 SQL,剛剛好。
讀到這裡若有一點收穫,替這顆種子澆一次水——匿名、一人一次。
⇄ 被這些筆記引用