08 / WORK / CASE STUDY
多租戶零售收銀系統
連鎖門市的收銀結帳、班次現金核對、企業客戶與電子發票整合在同一個多租戶平台,每租戶一個獨立資料庫;營運中。
- TYPE
- FULL STACK
- SCOPE
- 公司內部 · 營運中 · 全端
- STACK
- DJANGO · DRF · MYSQL · REDIS · REACT · TYPESCRIPT
- LINKS
- 公司內部專案(不公開)
01 / PROBLEM
美業連鎖門市常見的現場問題:多間門市、多台收銀機各自為政,售價、庫存與會員資料在門市之間對不上;班次交接靠紙本核對現金;企業客戶的月結請款和一般消費訂單混在一起;電子發票沒進系統,就是多一道人工作業。這套系統把收銀、班次現金核對、企業客戶管理與電子發票開立整合進同一個多租戶平台——總部用同一套後台管理所有門市,門市端有一致的操作介面。
02 / CONSTRAINTS
- 公司內部專案,程式碼不公開;前端已部署、營運中。
- 每個租戶一個獨立實體 MySQL 資料庫:中介層必須在每個請求開始時解析並綁定租戶,任何一處漏掉租戶邊界就是資料外洩或錯亂。
- 收銀熱路徑不能因網路不穩中斷交易:結帳離線時先寫本機,恢復連線後帶冪等鍵自動重試,不能重複收費。
- 與平台其他約 30 個服務共用 Redis 與 MySQL:Celery 任務必須走服務專屬佇列,否則會被其他服務的 worker 搶走執行。
03 / ARCHITECTURE
Django REST Framework 服務,是大型多服務平台的一員,自身刻意不再往下拆:收銀、購物車、班次、訂單、退款這些強關聯流程共用同一個資料庫交易邊界,比分散式交易簡單可靠。規模:8 個 app、24 個資料模型、288 個 API 路由(Django URL resolver 實測、排除框架路由)、後端約 2.7 萬行 Python;前端為獨立的 React 19 + TypeScript SPA,24 個頁面、49 個元件、94 個 API 呼叫點,約 4.5 萬行。
多租戶以 thread-local 加自訂 DB Router 實作,每租戶一庫、請求進入時綁定。Redis 快取只用在唯讀的商品參考價(TTL 120 秒),購物車金額永遠即時重算,守住交易一致性;Celery 走專屬佇列;第三方整合為綠界電子發票的開立與列印;跨服務呼叫平台的認證、會員、庫存與企業客戶服務;CI 三條 GitHub Actions,後端以 gunicorn(gthread)部署。
04 / RESPONSIBILITIES
前後端獨立開發(2026/04–07,前端 93、後端 20 個 commit 皆出自我一人;後端所在 monorepo 有其他協作者,但 POS 兩個目錄無他人提交):資料模型與多租戶機制、API、收銀與班次流程、React 前端、效能優化與部署設定。
05 / CHALLENGES → SOLUTIONS
CHALLENGE
收銀員在購物車快速連點加商品,系統開始回報 network error:20 個併發請求下 p95 拉到 427ms。根因是四層疊加——每點一次發一個請求、後端每次整車重查、結帳路徑有 3 秒逾時的同步外呼、後端只有 6 個併發處理槽。
SOLUTION
四層對應解:後端免整車重查、新增批次端點(一批品項單一交易寫入、只重算一次);唯讀參考價加 Redis 快取但金額計算絕不快取;前端 debounce 合併連點——第一版 180ms 視窗比真實連點間隔(200–400ms)還短、等於沒合併,改成「閒置 500ms 或最長 2000ms」雙保險;併發槽 6 調到 24。結果:20 併發 p95 從 427ms 降到 206ms 且零失敗,10 件商品批次送出約 74ms。
CHALLENGE
前端登入成功後,幾乎所有 API 都回 500。追查發現租戶的雪花 ID 超出 JavaScript 的安全整數範圍:JSON.parse 把它轉成 float64 近似值、差了 4——每個請求帶的租戶 ID 都是錯的,查詢落到不含業務資料表的預設資料庫。
SOLUTION
後端序列化層把該欄位強制輸出字串,前端型別同步改 string;並定下通則:任何會傳到前端、可能超過 2^53 的整數識別碼一律用字串,不等它真的出錯。
CHALLENGE
前端最初單檔打包,首屏要下載 666KB(gzip)的 JavaScript,門市網路下開機等待明顯偏長。
SOLUTION
改成路由層級懶加載,只留登入、條款與收銀台主畫面在首屏;掃碼元件移出收銀熱路徑;打包設定只固定拆 React/MUI/React Query/Router 幾個穩定套件——曾把圖表與掃碼函式庫也手動指定分包,反而因交叉引用被拉回首屏,改讓它們自然跟著懶加載邊界走。首屏 JavaScript 從 666KB 降到 310KB(-53%)。
06 / SYSTEM FACTS
288
後端 API 路由
~45k
前端 TS/TSX 行數
606
測試案例(後端+前端)
113
commits · 單一開發者
07 / LESSONS
併發問題常是疊加出來的,不是單一原因:只做 debounce,3 秒逾時的同步外呼仍會拖垮併發;只加併發槽,資料庫仍被大量小交易打滿。請求合併、快取邊界、資源配額要一起處理,單點優化只會帶來「感覺變好」的錯覺。
跨語言的數字精度是一種容易被忽略的系統邊界:雪花 ID 在資料庫與 Python 端毫無問題,只在「傳進 JavaScript」那一步壞掉,後端測試永遠抓不到。可能超過 2^53 的識別碼,一開始就該用字串傳輸。
NEXT
多租戶 B2B 批發電商平台