實作計畫書 · 青嶼科技後端平台

把訂單流程從單體 Rails 拆成獨立服務

需求 幫我寫一份能直接交付給工程師的訂單服務拆解計畫。要有時程切片、從 client 到資料層的流量圖、最關鍵的程式片段、以及一份風險表。要能在手機上滑著看完 — 我會原樣轉發給負責實作的人。
預估工時
約 3 週
影響範圍
4 個 repo
新增資料表
3 張
Feature flag
orders_svc_v1
01

時程切片

拆四刀上線、每一刀都能單獨 review 且都在 flag 之下。切到第三刀之前,使用者完全感覺不到差異 — 第四刀才把流量切過去。

第 1 週 · 一~二

抽 schema 與 API 契約

新建 ordersorder_itemsorder_events 三張表到獨立 schema,定義 gRPC proto 與錯誤碼。先讓資料工程師簽 contract,後面才動。

orders-svc/protodb/migration_0118platform-rfc-042
第 1 週 · 三~五

服務骨架與雙寫

OrdersService 起在 K8s,monolith 端加 OrderWriter facade 同時寫舊表與新服務、用 diff 比對結果但不採信新服務輸出。先觀察 72 小時。

orders-svcmonolith/appshadow-write
第 2 週 · 一~四

讀流量轉向與 outbox

GET /orders/:id 與 list endpoint 改讀新服務、舊表淪為 fallback。新服務寫入時用 outbox pattern 發 order.created 到 Kafka,下游帳務 / 物流訂閱。

orders-svc/outboxkafka/order-eventsbilling-svc
第 3 週 · 一~五

寫流量切換、舊路徑下線、runbook

flag 由內部帳號 → 5% → 25% → 100% 漸進放量,每階段卡 24h 看 error rate 與 p99。完成後砍掉 monolith 端 OrderWriter 與 shadow diff、補 on-call runbook。

launchdarklygrafana/ordersrunbook
02

資料流

實線是同步寫入路徑、虛線是非同步事件路徑。新服務寫完自己的表後才寫 outbox,由 relay 保證最少一次送達下游 — API 永遠不等 Kafka。

checkout-web Next.js 前端 api-gateway flag 路由分流 OrdersService Go · gRPC orders DB postgres · 含 outbox 表 outbox relay poll → kafka 下游消費者 billing / fulfillment monolith reader 舊報表 fallback POST /orders gRPC call 交易內寫 outbox 舊路徑備援 poll outbox order.created 回頭 ack 清 outbox row

實線 = 同步請求路徑;橘色虛線 = 非同步事件擴散。同步路徑完全不依賴 Kafka 是否健康。

03

介面草圖

不是最終視覺、只是讓 reviewer 跟我先對齊 admin 後台會看到的「訂單詳情頁」跟「拆分狀態儀表板」應該長什麼樣。

A · 內部後台訂單詳情頁
訂單 #QY-2026-08731
客戶:杜小姐 · 金額 NT$ 4,280 · 狀態:已出貨
CS
客服 陳怡君 2 小時前
這筆是新服務寫的(標籤顯示 source=orders-svc),但舊 admin 報表還沒看到,要等下個小時的 ETL,已先回覆客戶。
EN
工程 黃柏均 37 分鐘前
確認過了 — outbox row 35 秒就清掉,下游有收到。報表落後是 ETL 還沒接新 Kafka topic,週四前修。
寫一則內部備註…
送出
B · Shadow diff 儀表板側欄
!
欄位不符 orders-svc vs monolithdiscount_applied 在折扣碼是空字串時新舊輸出不同(null vs 0)
!
延遲超標 p99 = 412ms — 從 14:20 起 OrdersService p99 高過閾值,懷疑 connection pool 太小
OK
過去 1 小時 14,208 筆寫入比對 — 100% 一致,diff queue 已清空。
04

關鍵程式

最容易寫錯的兩段:outbox 表的 schema(必須跟業務寫入同一個交易內),以及 monolith 端那層雙寫 facade(要能比 diff 但不能讓新服務拖慢主流程)。

orders-svc/migrations/0118_orders_outbox.sql
create table orders (
  id           uuid primary key default gen_random_uuid(),
  customer_id  uuid not null,
  total_cents  bigint not null check (total_cents >= 0),
  status       text not null default 'pending',
  created_at   timestamptz not null default now(),
  updated_at   timestamptz not null default now()
);

create table order_outbox (
  id          bigserial primary key,
  aggregate   uuid not null,           -- order id
  event_type  text not null,
  payload     jsonb not null,
  created_at  timestamptz not null default now(),
  sent_at     timestamptz                  -- relay 寫回
);

create index outbox_unsent
  on order_outbox (id) where sent_at is null;
monolith/app/services/order_writer.rb
class OrderWriter
  def create(params)
    legacy = Order.create!(params)         # 主路徑、必成功

    # 影子寫入:失敗只記 log、不擋使用者
    Thread.new do
      begin
        shadow = OrdersClient.create(
          customer_id: legacy.customer_id,
          total_cents: legacy.total_cents,
          idempotency_key: legacy.id     # 用舊 id 當去重
        )
        ShadowDiff.record(legacy, shadow)
      rescue => e
        Metrics.incr("orders.shadow.error",
                     tags: [e.class.name])
      end
    end

    legacy
  end
end
05

風險與對策

風險
嚴重度
對策
寫入重複:retry 時新服務寫了第二筆,下游收到兩個 order.created 事件、帳務多扣一次款。
所有寫入強制帶 idempotency_key(用 legacy order id 或 client 的 request id),DB 層 unique index 兜底;relay 也用 outbox row id 做下游去重。
資料漂移:shadow 階段新舊兩邊跑著跑著資料就不一致,切換時某些欄位語意已偏。
ShadowDiff 每小時跑 sampling 比對、不只比寫入當下;任何欄位連續 3 天有 diff 自動阻擋下一階段 ramp。
Outbox relay 落後:相依下游服務以為訂單沒成立、客服收到投訴。
relay lag > 60 秒 PagerDuty 告警;admin 訂單頁顯示 events_pending 標籤,讓客服第一時間知道是延遲不是漏單。
06

待決議

退款流程要不要這次一起搬,還是下一期?
退款牽涉到金流第三方、跨表交易,搬過去得連 refundspayment_intents 一起動。傾向 v1 不動退款、保留 monolith 處理 — 但這意味著 3 個月內訂單在兩邊各有半套。
需與 · 金流組 對齊,第 2 切片前敲定
下游消費者要不要強制升級到新 schema 才開放 100%?
舊 webhook payload 缺 line_items[].sku_variant,BI 那邊已經依賴新欄位寫了報表。要嘛全部 100% 前升級完、要嘛維持兩種 payload 半年。
需與 · 平台組 + BI 對齊,第 3 切片前敲定