結論先講

「什麼時候該拆微服務」這個問題,沒有人給過具體數字。 所以我們自己測。答案是:我們的單體在 UV 10 就開始出問題。不是程式有 bug,是單一進入點的架構在 bcrypt + DB 查詢 + 檔案處理全擠在同一個 process 時,10 個人同時操作就到極限了。

這一步怎麼來的:數據全部拿到了,結論也整理了。最後回到那個最初的問題——我們的單體 UV 10 就報錯,所以我們到底該不該拆微服務?這三篇是我們自己走過的路。


起點:一個單體電商

我們的 proto 是一個標準的單體應用——單一進入點,所有功能塞在同一個服務裡:

proto(單體)
├── Auth(註冊/登入/JWT)
├── Product(商品 CRUD)
├── Order(訂單)
├── Payment(付款)
├── File(圖片上傳)
└── Notification(通知)

做出來的東西就是 prd 裡面的 ec 那個規模——一個完整的電商系統。功能都有,跑起來都正常。

直到我們跑了壓測。


UV 10 就報錯

壓測跑下去,UV 10 就開始出問題。

不是「UV 1000 才開始慢」那種教科書式的瓶頸。是 10 個人同時操作就報錯

為什麼?因為單一進入點下,所有操作共享同一個 uvicorn worker pool:

用戶 A: Register(bcrypt 100ms,佔住 CPU)
用戶 B: 上傳商品圖片(檔案 I/O,佔住記憶體)
用戶 C: 查詢訂單(DB 查詢,等連線)
用戶 D: 登入(又一個 bcrypt,CPU 排隊)
... 用戶 E~J 全部在排隊

bcrypt 把 CPU 佔住,其他所有操作一起排隊。10 個人就足以讓 response time 飆到 timeout。

這就是 第 6 篇 Bcrypt 是萬惡之源 在真實應用中的體現——不是「壓測框架的問題」,是「你的單體就是這麼脆弱」。


覺得該拆了

看到 UV 10 就報錯,第一反應是「是不是程式有 bug」。花了一週排查——沒有 bug,就是架構問題。

這時候有兩個選擇:

選擇 A:不拆,硬撐

  • 加 Redis cache(讀取場景 +6.5 倍,回顧 第 21 篇
  • 加 PM2 多 worker(+93%)
  • 把 bcrypt rounds 降低
  • 加 nginx 做 connection buffering

這些「免費午餐」可以把天花板從 UV 10 推到 UV 50-100。但 100 之後還是同一個問題。

選擇 B:拆微服務

把 Auth、Product、Order、File 拆成獨立服務。每個服務有自己的 process、自己的資源分配。bcrypt 只佔 Auth Service 的 CPU,不影響 Product Service。

我們選了 B。不是因為 A 不好——A 是短期的正確答案。但我們知道遲早要做 B,不如現在就開始。


怎麼拆的

拆分依據:by Domain

micro-service/
├── auth-service    → 註冊/登入/JWT/密碼
├── post-service    → 商品/文章 CRUD
├── order-service   → 訂單/付款
├── file-service    → 圖片/檔案上傳下載
└── notification-service → Email/推播

說是 by domain,但老實說界線有一點模糊。例如「用戶個人資料」算 auth-service 還是 user-service?「商品圖片」算 product-service 還是 file-service?

最後的原則是:誰擁有資料,誰就是那個 service。 密碼歸 auth、商品歸 post、檔案歸 file。邊界模糊的就先放一起,等有明確理由再拆。

通訊方式:REST + 共用 DB

服務之間用 REST 通訊。沒有上 gRPC——因為團隊是 Python 背景,REST 最熟悉也最直接。

DB 是共用的——所有服務連同一個 MySQL。不是最佳實踐(教科書說每個服務要有自己的 DB),但在我們的規模下,共用 DB 可以避免跨服務資料同步的噩夢。


拆完之後踩的坑

坑 1:跨服務 Transaction

單體時代,下訂單可以一個 transaction 搞定:

# 單體:一個 transaction
async with db.begin() as session:
    order = Order(user_id=user_id, product_id=product_id)
    session.add(order)
    await session.execute(
        update(Product).where(Product.id == product_id).values(stock=Product.stock - 1)
    )
    payment = Payment(order_id=order.id, amount=amount)
    session.add(payment)
    # commit or rollback — 全部成功或全部失敗

拆成微服務之後,Order、Product、Payment 在不同的 service。你不能跨 service 做 transaction。

解法:Event-Driven Architecture。

Order Service: 建立訂單(pending)→ 發事件 "order.created"
Product Service: 收到事件 → 扣庫存 → 發事件 "stock.updated"
Payment Service: 收到事件 → 處理付款 → 發事件 "payment.completed"
Order Service: 收到事件 → 更新訂單狀態為 "completed"

在做單體的時候,完全不覺得需要 event-driven。拆了微服務之後才發現:沒有 event-driven,微服務根本做不下去。

坑 2:監控和 Log 變成噩夢

單體時代,看一個 log 檔就知道發生什麼事。微服務之後:

  • Auth Service 的 log 在 container A
  • Order Service 的 log 在 container B
  • File Service 的 log 在 container C

一個用戶的操作橫跨 3 個 service,你要手動拼湊 3 份 log 才能還原完整流程。

我們後來整理了一套監控:

  • GitLab CI/CD: 自動建構和部署
  • Grafana: 效能監控 dashboard
  • Portainer: Docker 容器管理
  • 集中 log: 所有 service 的 log 送到同一個地方

這些在單體時代完全不需要。微服務的「運維稅」比想像中高很多。

坑 3:很多東西要微調

UV 10 報錯不只是「拆了就好」。拆完之後每個 service 都需要個別調校:

  • Auth Service 需要更多 CPU(bcrypt)
  • File Service 需要更多記憶體(圖片處理)
  • Post Service 需要更多 DB 連線(讀取密集)

這些微調在單體時代不需要——反正全部共享同一池資源。微服務把資源分配的責任交給了你自己。


壓測平台就是在這個背景下誕生的

拆完微服務之後,問題變成:

  • Auth Service 要配多少 CPU 才夠?
  • Post Service 的 DB 連線池要開多大?
  • File Service 給 2 GB 記憶體夠不夠?

沒有人能回答這些問題,除非你實際去測。

更根本的問題是:「什麼 UV 該拆微服務」這個問題,網路上沒有具體數字。 有人說「百萬用戶才需要」,有人說「10 個人就該拆」。沒有人拿數據說話。

所以我們決定自己蓋一個壓測平台,用 9 個後端框架在完全相同的條件下測,拿數據回答這些問題。整個平台加上前期規劃,來來回回花了兩個月。

最意外的發現:Django 和 FastAPI 在 UV 10 就有機會壞掉,而且上限也不高。 這呼應了我們單體的經驗——如果你的應用有 bcrypt,UV 10 就可能出問題不是個案,而是結構性的。


下一篇

企業裡的微服務現實:Cache 硬撐、Kafka 當 REST 用 — 大部分公司的「微服務」跟教科書寫的不一樣。Cache 拿來硬撐效能、RabbitMQ/Kafka 被當成 REST API 用、「說要做微服務但其實只是把 code 拆到不同 repo」。從壓測數據看這些做法的真實代價。


本系列文章

完整 68 篇目錄見 系列首頁

← 上一篇:Queue、Redis Cache、Cold Start:最後三場測試 → 下一篇:企業裡的微服務現實:Cache 硬撐、Kafka 當 REST 用