團主選一家店開團,參與者從菜單點品項。截止時,系統依每個人點餐的金額從餘額扣款,而扣款前這筆金額會先被凍結,這裡其實有兩條很重要的規則:
問題是:如果店家在下單之後改了價格,訂單到底應該看哪一個價格?
直覺做法是只存 menus,order_items 只記住使用者點了哪個菜單項目、點了幾份,價格和品名,需要時再 JOIN menus:
SELECT m.product_name, m.unit_price, oi.quantity,
m.unit_price * oi.quantity AS subtotal
FROM order_items oi
JOIN menus m ON oi.menu_id = m.menu_id
WHERE oi.order_id = ?
價格(unit_price)和品名(product_name)只存一份,資料比較乾淨,如果店家改名或改價,所有地方 JOIN 到的資料也會自動更新,但是這樣會讓歷史訂單跟著菜單一起變。
先看兩種情況:
1.ord-002 尚未結算,alice 下單時珍珠奶茶是 55 元;店家之後改成 65 元。若重新 JOIN menus,截止時就會算成 65 元,變成「55 元下單、65 元扣款」。
2.ord-001 已經結算,alice 當時買了 70 元的招牌鍋貼和 35 元的酸辣湯,流水帳記錄扣款 105 元;店家之後把鍋貼改成 80 元。重新 JOIN 後,訂單明細會變成 115 元,和實際扣款對不上。
問題的根源其實不是 JOIN,而是兩張表記錄的是不同時間點的事實。
資料表menus負責記錄「這家店現在賣什麼、賣多少?」,而order_items則是負責記錄「使用者當時買了什麼、多少錢?」如果訂單只保存 menu_id,就等於拿「現在的菜單」去回答「當時買了什麼」,因此這裡不能只追求資料正規化,而要保存一份下單當下的快照(snapshot)。
下單時,從 menus 讀出品名和價格,複製到 order_items:
// 4. Insert order item with snapshot
OrderItem item = new OrderItem();
item.setOrderId(request.getOrderId());
item.setUserId(userId);
item.setMenuId(request.getMenuId());
item.setProductName(menu.getProductName());
item.setUnitPrice(menu.getUnitPrice());
item.setQuantity(request.getQuantity());
orderItemMapper.insert(item);
menu_id 仍然保留,因為它可以告訴我們「這個品項原本對應哪個菜單項目」,只是價格和品名不再依賴 menus 的目前狀態。
subtotal 由資料庫根據 unit_price * quantity 計算,之後凍結金額、結算扣款、通知訊息與訂單明細,都只讀 order_items:
| 用途 | 使用的訂單資料 |
|---|---|
| 凍結金額、可用餘額 | subtotal |
| 結算扣款 | subtotal |
| 結算通知 | subtotal |
| 訂單明細 | product_name、unit_price、subtotal |
這裡有一個容易忽略的設計原則:
下單之後,凡是需要知道「當時買了什麼、多少錢」的地方,都不能再回頭查
menus。
目前專案只有建立訂單時會讀 menus,這也是這個設計成立的前提。如果未來某個查詢又偷偷改成 JOIN menus 取得價格,店家一改價,歷史訂單就會再次被影響。