蓋大樓才會遇到的問題
當你的專案從「一頁式網站」長成「多功能系統」,有些觀念會從「聽過就好」變成「不懂就踩坑」。這一章把第三章簡單提過的概念展開講清楚。
異步寫入(Async):三道菜同時做
你在餐廳點了三道菜:沙拉、牛排、甜點。
同步做法:廚房做完沙拉 → 端出來 → 做牛排 → 端出來 → 做甜點 → 端出來。你等到天荒地老。
異步做法:三道菜同時開工!沙拉區切菜、烤爐烤牛排、甜點師做提拉米蘇。誰先做好誰先上桌。效率大幅提升。
程式的世界也是這樣。同步(Synchronous)是一件事做完才做下一件;異步(Asynchronous)是多件事同時做,誰先完成誰先回報。
用文字畫出來大概長這樣:
[查使用者資料] ──等 2 秒──> 完成
[查訂單紀錄] ──等 3 秒──> 完成
[寄通知信] ──等 1 秒──> 完成
總共花了:2 + 3 + 1 = 6 秒
──────────────────────────────
異步流程(同時開工)
[查使用者資料] ──等 2 秒──> 完成 ┐
[查訂單紀錄] ──等 3 秒──> 完成 ├─> 全部到齊,繼續!
[寄通知信] ──等 1 秒──> 完成 ┘
總共花了:3 秒(取最慢的那個)
看起來異步完勝?沒錯,大部分情況下異步確實更快。但問題來了——
異步的坑:順序不一定對
回到餐廳的比喻:如果甜點比牛排先做好,但甜點的淋醬需要用牛排的肉汁呢?甜點先到你桌上,但它還沒淋醬——這就出問題了。
你寫了一個「註冊流程」:
1. 在資料庫建立使用者帳號
2. 用這個帳號的 ID 去建立預設設定
3. 寄歡迎信給使用者
如果三步同時跑(異步),第二步可能比第一步先完成——但帳號還沒建好,哪來的 ID?
結果:程式爆掉,因為它找不到一個還不存在的帳號。
Race Condition:搶最後一個座位
Race Condition(競爭條件)是異步最經典的問題。想像這個場景:
電影院最後一個座位。小明和小華同時在不同的電腦上訂票。兩個人幾乎同一秒按下「訂票」。系統查了一下:「有一個空位?有!」→ 兩個人都成功訂到了。但實際上只有一個座位。
小華查:有 1 個空位 → 訂!
結果:2 個人搶到 1 個座位
有人到現場沒位子坐
小華查:座位被鎖 → 等一下...
小明完成 → 解鎖 → 空位 = 0
小華查:沒位子了 → 顯示已售完
正確!一個座位只賣一次
「這個功能可能有 race condition,幫我加上 lock 機制」或
「這幾個步驟有順序依賴,不能用 Promise.all,要依序執行」
SSOT:全公司只有一本電話簿
SSOT 是 Single Source of Truth 的縮寫,中文叫「單一真理來源」。聽起來很哲學,但其實超級實用。
假設公司有三個部門,每個部門各自維護一份員工電話簿。
小明換了手機號碼,HR 部門更新了,但業務部和工程部還是舊號碼。
三個月後,業務部打小明電話打不通,客戶的案子卡住了——
問題出在哪?同一筆資料存了三份,只更新了一份。
程式裡也是一模一樣的問題。來看一個實際案例:
實際問題:商品價格存了三個地方
const price = 299; // 寫死在程式裡
後端 API:
return { price: 350 }; // 上次改了忘記通知前端
資料庫:
product.price = 399; // 最新的價格
結果:客戶看到 299,結帳變 350,
帳務對帳是 399。三個數字都不一樣。
product.price = 399;
後端 API:
return db.getProduct(id); // 去 DB 查
前端:
const data = await fetch('/api/product');
// 顯示後端回傳的價格
結果:不管在哪裡看,都是 399。
改一個地方,全部同步更新。
SSOT 的原則很簡單:每一筆資料只存在一個地方,其他需要這筆資料的地方,都去那裡查。
SSOT 的常見應用場景
| 場景 | 真理來源(SSOT) | 其他地方怎麼做 |
|---|---|---|
| 商品價格 | 資料庫 | 前端、後端都去 DB 查 |
| 使用者權限 | 後端的權限表 | 前端只負責顯示,不自己判斷 |
| 設定值 | 環境變數 / 設定檔 | 程式啟動時讀取,不寫死在程式裡 |
| UI 狀態 | 狀態管理工具(如 Redux) | 各元件去 store 讀取,不各自維護 |
「幫我檢查一下,有沒有同一筆資料被存在多個地方的情況」
耦合(Coupling):積木 vs 膠水模型
想像你蓋了一個模型城堡:
用積木蓋的(低耦合):每一塊都是獨立的。你拆掉一棟塔樓,城牆還是好好的。想換一個更酷的塔樓?直接拔掉舊的、插上新的。
用膠水黏的(高耦合):所有零件都黏死在一起。你想拆掉一棟塔樓?不好意思,旁邊的城牆、護城河、吊橋全部一起碎掉。因為它們都黏在一起了。
軟體的世界裡,「耦合」就是指兩個功能之間的依賴程度。依賴越深,耦合越高;依賴越少,耦合越低。
高耦合的痛:改 A 壞 B C D
這是新手最常遇到的地獄場景。你跟 Claude 說「幫我改一下登入頁面的按鈕顏色」,結果改完之後:
- 首頁的 header 跑版了
- 結帳頁面的按鈕消失了
- 手機版的選單打不開了
為什麼?因為這些功能全部擠在同一個檔案裡,共用同一段 CSS,牽一髮動全身。
- 登入邏輯
- 商品列表
- 購物車計算
- 寄信功能
- 金流串接
改登入 → 購物車壞了
改金流 → 寄信功能掛了
全部糾纏在一起,無法單獨維護
- auth/login.js → 只管登入
- products/list.js → 只管商品
- cart/calculate.js → 只管購物車
- email/send.js → 只管寄信
- payment/stripe.js → 只管金流
改登入 → 其他功能不受影響
改金流 → 寄信功能照樣運作
各模組獨立,改一個不會壞另一個
怎麼降低耦合
公司裡,業務部需要工程部做一個功能。正確做法是寫一張需求單(API),工程部按照需求單來做。
錯誤做法是業務部的人直接跑去工程部的桌上翻資料、改程式碼。這樣工程部根本不知道什麼被改了,下次部署直接爆炸。
模組之間的溝通,應該透過明確定義的介面(API),而不是直接互相觸碰內部邏輯。
降低耦合的三個基本原則:
- 功能拆成獨立模組:每個檔案只做一件事。登入歸登入、購物車歸購物車。
- 模組之間用 API 溝通:A 模組需要 B 模組的資料?透過 B 暴露的 function 來拿,不要直接去改 B 的內部變數。
- 不要共用全域變數:就像不要讓所有部門共用一張桌子。每個模組管好自己的資料。
「我不想改 A 的時候影響到 B,幫我用 interface 把它們解耦」
「這個檔案太大了,幫我按功能拆分」
前端 vs 後端:什麼計算該放哪裡
這是很多新手搞不清楚的問題:某段邏輯到底該寫在前端還是後端?
菜單上可以印「參考價格」,讓客人大概知道要花多少錢。但結帳金額一定要收銀台算。
為什麼?因為菜單在客人手上,客人可以塗改。但收銀台在店裡面,客人碰不到。
前端 = 菜單(在使用者的電腦上,使用者看得到、改得到)
後端 = 收銀台(在伺服器上,使用者碰不到)
判斷原則:誰能被竄改?
前端的程式碼跑在使用者的瀏覽器裡。任何懂一點技術的人,都可以打開瀏覽器的開發者工具,修改前端的 JavaScript。所以——
| 類型 | 放前端 | 放後端 |
|---|---|---|
| 日期格式化 | 可以。純展示,沒安全問題 | 不需要 |
| 表單欄位驗證 | 可以做,提升使用者體驗 | 一定要!前端驗證可以被繞過 |
| 訂單金額計算 | 可以顯示「預估金額」 | 一定要!最終金額只能後端算 |
| 使用者權限判斷 | 不行!使用者可以改前端 | 一定要!權限只能後端決定 |
| 密碼加密 | 不行!密碼不能在前端處理 | 一定要! |
| 搜尋結果排序 | 可以做客戶端排序 | 資料量大時要在後端排序 |
前端可以做「預覽」和「使用者體驗優化」,但最終的計算和判斷,必須在伺服器上完成。
記住:前端是給使用者看的,後端是給你自己用的。使用者看得到的東西,都有可能被竄改。
一個真實的災難案例
某購物網站把折扣計算寫在前端 JavaScript 裡。結果有人用瀏覽器的開發者工具,把折扣從「打九折」改成「打一折」,然後送出訂單。後端沒有重新驗算,直接按前端傳來的金額扣款。
一台原價三萬的筆電,用三千塊就買走了。
「幫我確認所有跟金額有關的邏輯都在 server side」
「前端的權限檢查只是 UX,後端的 middleware 才是真正的守門員」