在微服務架構下,服務提供者和服務消費者執行在不同物理機上的不同行程內。相比於本地方法呼叫,跨行程呼叫需要透過遠程方法呼叫(RPC, Remote Procedure Call)來實作。

RPC 呼叫的基本原理#

從本地呼叫到遠程呼叫#

在單體應用時,一次服務呼叫發生在同一台機器上的同一個行程內部(本地方法呼叫)。進行服務化拆分之後,服務提供者和服務消費者執行在不同的行程,需要解決以下四個問題:

問題說明
網路連接用戶端和服務端如何建立網路連接?
請求處理服務端如何處理請求?
通信協議資料傳輸採用什麼協議?
序列化資料該如何序列化和反序列化?

RPC 呼叫流程#

sequenceDiagram
    participant C as 用戶端<br/>(Consumer)
    participant S as 服務端<br/>(Provider)

    C->>C: 1. 序列化請求資料
    C->>S: 2. 通過網路傳輸

    Note over S: 3. 反序列化請求資料<br/>處理業務邏輯<br/>序列化響應資料

    S->>C: 4. 通過網路返回
    C->>C: 5. 反序列化響應資料

網路連接方式#

HTTP 通信#

HTTP 通信基於應用層 HTTP 協議,而 HTTP 協議又基於傳輸層 TCP 協議。

連接過程

  1. 三次握手:建立連接
  2. 資料傳輸:發起 HTTP 呼叫
  3. 四次揮手:斷開連接

適用場景

  • 跨業務平台之間的服務協議
  • 對外開放的服務介面
  • 省去溝通服務協議的成本

Socket 通信#

Socket 通信基於 TCP/IP 協議的封裝,建立連接需要一對套接字。

通信過程分為四個步驟

步驟用戶端操作服務端操作
1. 伺服器監聯-bind() + listen()
2. 用戶端請求connect()-
3. 連接確認-accept()
4. 資料傳輸send()/receive()receive()/send()

連接管理#

網路不總是可靠的,常見的處理手段:

1. 鏈路存活檢測

用戶端定時發送心跳檢測訊息(通常是 ping 請求)給服務端:

  • 如果服務端連續 n 次心跳檢測沒有回覆
  • 或者超過規定的時間沒有回覆
  • 則認為鏈路已失效,需要重新建立連接

2. 斷連重試

多種情況會導致連接斷開(用戶端主動關閉、服務端宕機、網路故障等):

  • 不能立刻完成重連
  • 要等待固定的間隔後再發起重連
  • 避免服務端連接回收不及時,用戶端瞬間重連請求太多

建議使用成熟的開源通信框架如 Netty、MINA 等,它們經過業界大規模應用驗證,是很可靠的方案。

服務端請求處理模型#

服務端如何處理用戶端請求,通常有三種方式:

同步阻塞方式(BIO)#

原理:用戶端每發一次請求,服務端就生成一個執行緒去處理。

優點

  • 編程簡單直觀,易於理解

缺點

  • 當用戶端請求很多時,需要創建大量執行緒
  • 可能達到系統最大執行緒數瓶頸

適用場景:連接數比較小的業務場景

同步非阻塞方式(NIO)#

原理:通過 I/O 多路復用技術處理,把多個 I/O 的阻塞複用到同一個 select 的阻塞上。

優點

  • 單執行緒可以同時處理多個用戶端請求
  • 開銷小,不用為每個請求創建執行緒

缺點

  • 編程比較複雜

適用場景:連接數比較多並且請求消耗比較輕的業務場景(如聊天伺服器)

異步非阻塞方式(AIO)#

原理:用戶端發起 I/O 操作後立即返回,真正的 I/O 讀寫由內核完成,完成後通知用戶端。

優點

  • 用戶端無需等待,不存在阻塞問題

缺點

  • 編程難度最大,程式不易於理解

適用場景:連接數比較多而且請求消耗比較重的業務場景(如相冊伺服器)

處理模型對比表
特性BIONIOAIO
阻塞方式同步阻塞同步非阻塞異步非阻塞
執行緒模型一連接一執行緒多路復用回調通知
編程難度簡單中等複雜
適用連接數少量大量大量
適用請求類型任意輕量級重量級

通信協議#

協議的組成#

通信協議定義了一個「契約」,使服務消費者和提供者能夠達成共識。協議通常包括兩個部分:

部分內容
訊息頭協議的公共字段以及用戶擴展字段
訊息體傳輸資料的具體內容

HTTP 協議#

HTTP 是一種開放的協議,各大網站的伺服器和瀏覽器之間的資料傳輸大都採用這種協議。

HTTP 響應報文示例

HTTP/1.1 200 OK                    ← 狀態行
Server: Apache/2.4.1               ← 訊息頭開始
Content-Length: 1234
Content-Type: text/html
Date: Mon, 22 Jun 2020 09:30:00 GMT
                                   ← 空行分隔
<!DOCTYPE html>                    ← 訊息體開始
<html>
<body>...</body>
</html>

私有協議#

除了開放的 HTTP 協議,還有定製的私有協議(如 Dubbo 協議)。

Dubbo 協議頭結構

字段大小說明
Magic2 bytes魔數,固定值
Request/Response Flag1 bit請求/響應識別
2-Way1 bit是否需要返回值
Event1 bit是否為事件訊息
Serialization5 bits序列化方式識別
Status1 byte響應狀態碼
Request ID8 bytes請求唯一識別
Data Length4 bytes訊息體長度

無論是開放的還是私有的協議,服務消費者按照契約編碼資料,服務提供者按照契約解碼資料,然後處理請求並返回結果。

序列化與反序列化#

為什麼需要序列化#

網路傳輸的耗時取決於:

  1. 網路帶寬的大小
  2. 資料傳輸量

對資料進行序列化(編碼)的主要目的是減小資料傳輸量

序列化方式的分類#

文本類協議

  • XML
  • JSON

二進制類協議

  • Protocol Buffers (PB)
  • Thrift
  • Hessian

選擇序列化方式的考量因素#

因素說明
資料結構支援支援的資料結構類型越豐富越好(如 Map、List 等)
跨語言支援是否支援跨語言呼叫(Java 序列化只支援 Java)
壓縮比序列化後的資料大小
序列化速度序列化和反序列化的效能
可讀性序列化後的資料是否可讀
主流序列化方式對比
方式類型壓縮比速度跨語言可讀性適用場景
JSON文本對外 API
XML文本組態檔案
PB二進制內部 RPC
Thrift二進制跨語言 RPC
Hessian二進制Java RPC
Java二進制Java 內部

PB 序列化的壓縮比和速度都比 JSON 高很多,適合對效能和儲存空間要求高的系統;JSON 可讀性更好,更適合對外部提供服務。

服務描述方式#

RESTful API#

主要用於 HTTP 或 HTTPS 協議的介面定義,常用 Swagger 或 Wiki 進行管理。

特點

  • 學習成本低
  • 適合跨部門、跨業務平台、對外開放的服務

示例

@Path("/rest")
public interface RestfulService {
    @GET
    @Produces(MediaType.APPLICATION_JSON)
    List<User> getUsers(@QueryParam("uid") int uid);

    @POST
    @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
    @Produces(MediaType.APPLICATION_JSON)
    Response add(@FormParam("id") int id, @FormParam("name") String name);
}

XML 組態#

多用於 RPC 協議的服務描述,通過 XML 組態檔案定義介面名、參數以及返回值類型。

特點

  • 私有 RPC 框架常用
  • 效能比 HTTP 協議高
  • 對業務程式碼侵入性較高
  • 組態變更時,服務提供者和消費者都要更新

適用場景:公司內部聯繫比較緊密的業務之間

對於 XML 組態方式的服務描述,如果應用到多個部門之間的介面格式約定,有變更時最好是新增介面,不要對原有的介面格式做變更。

IDL 檔案#

IDL(Interface Description Language)是介面描述語言,通過中立的方式描述介面,使不同語言編寫的程式可以相互通信。

主流 IDL

  • Thrift:Facebook 開源
  • gRPC/Protobuf:Google 開源

Protobuf 示例

// 定義服務
service Greeter {
    rpc SayHello (HelloRequest) returns (HelloReply) {}
}

// 定義請求訊息
message HelloRequest {
    string name = 1;
}

// 定義響應訊息
message HelloReply {
    string message = 1;
}

特點

  • 主要用於跨語言平台的服務呼叫
  • 通過 protoc 插件自動生成不同語言的用戶端和服務端程式碼
IDL 的局限性

如果介面返回值的字段比較多,並且經常變化時,IDL 檔案方式不太合適:

  • 可能會造成 IDL 檔案過大難以維護
  • 只要介面返回值有變更,都需要同步所有的服務消費者更新

案例:微博內容介面返回的字段有幾百個,且有些字段不固定,不適合用 Protobuf 描述。

服務描述方式選型指南#

場景推薦方式
企業內部 + 單一語言(如 Java)XML 組態
企業內部 + 多語言平台IDL 檔案
對外開放服務RESTful API
需要高效能 + 內部呼叫IDL 檔案(Protobuf)

RPC 框架的組成#

一個完整的 RPC 呼叫框架由三部分組成:

flowchart TB
    subgraph RPC["RPC 框架"]
        direction LR
        T["通信框架<br/>(Transport)"]
        P["通信協議<br/>(Protocol)"]
        S["序列化<br/>(Serialization)"]
    end

    T --- T1["提供基礎通信能力<br/>Netty, MINA"]
    P --- P1["描述通信契約<br/>HTTP, Dubbo"]
    S --- S1["資料編/解碼<br/>JSON, PB"]

    style T fill:#e3f2fd
    style P fill:#fff3e0
    style S fill:#e8f5e9

一個通信框架可以適配多種通信協議,也可以採用多種序列化格式。例如 Dubbo 不僅支援 Dubbo 協議,還支援 RMI、HTTP 協議等,同時支援 JSON、Hessian、Java 序列化等多種格式。

Saga 與可靠事件投遞#

前面談的都是「一次呼叫」如何完成。但微服務情境下,一個業務動作往往要橫跨多個服務(下單要扣庫存、扣款、發貨),而每個服務各自持有資料庫——無法用單一資料庫交易把它們包在一起。Saga 與可靠事件投遞,就是在「不使用分散式交易」的前提下達成跨服務一致性的落地手法。

分散式交易的純理論(2PC、TCC、CAP/BASE)請見 分散式系統基礎;本節聚焦微服務通信層面的工程實作。

Saga:用補償取代回滾#

Saga 把一個跨服務的長交易拆成一連串「本地交易」,每個本地交易只操作自己服務的資料庫並提交。若中途某步失敗,就依序執行前面已完成步驟的補償交易 (Compensating Transaction) 來「語意上撤銷」,而不是資料庫層級的 rollback。

$$ T_1, T_2, \dots, T_n \quad\text{失敗於 } T_k \Rightarrow C_{k-1}, C_{k-2}, \dots, C_1 $$

Saga 有兩種協調風格:

編排式 Saga (Orchestration)#

由一個中央協調器 (Orchestrator) 明確下令每一步、並在失敗時觸發補償。

sequenceDiagram
    participant O as Saga 協調器
    participant Order as 訂單服務
    participant Stock as 庫存服務
    participant Pay as 支付服務

    O->>Order: 建立訂單
    Order-->>O: OK
    O->>Stock: 扣庫存
    Stock-->>O: OK
    O->>Pay: 扣款
    Pay-->>O: 失敗(餘額不足)
    Note over O: 觸發補償(逆序)
    O->>Stock: 補償:回補庫存
    O->>Order: 補償:取消訂單

編舞式 Saga (Choreography)#

沒有中央大腦,各服務訂閱彼此的事件、自行決定下一步,補償也靠事件驅動。

sequenceDiagram
    participant Order as 訂單服務
    participant Stock as 庫存服務
    participant Pay as 支付服務

    Order->>Order: 建立訂單
    Order-->>Stock: 發布 OrderCreated
    Stock->>Stock: 扣庫存
    Stock-->>Pay: 發布 StockReserved
    Pay->>Pay: 扣款失敗
    Pay-->>Stock: 發布 PaymentFailed
    Stock->>Stock: 補償:回補庫存
    Stock-->>Order: 發布 StockReleased
    Order->>Order: 補償:取消訂單

兩種風格對比#

維度編排式 (Orchestration)編舞式 (Choreography)
流程可見性高,邏輯集中在協調器低,散落在各服務的事件處理
耦合度服務與協調器耦合服務間透過事件鬆耦合
單點風險協調器是關鍵元件,需高可用無中央單點
複雜流程適配適合多步驟、有分支的複雜流程步驟一多容易演變成「事件風暴」
除錯難度較易,狀態集中較難,需靠分散式追蹤串聯

經驗法則:步驟少(2-4 步)、服務間天然就靠事件溝通時用編舞;流程長、分支多、需要清楚掌控狀態機時用編排。兩者可混用。

Saga 沒有隔離性 (Isolation)。補償執行前,中間狀態對外是可見的(如訂單已建立但稍後被取消)。必須用語意鎖、可交換更新或版本號等手段處理併發異常,並在 UI/業務上接受「暫時不一致」。

可靠事件投遞:訊息不能丟#

Saga 與事件驅動架構都依賴「事件一定送得出去」。但有一個經典陷阱:服務更新了自己的資料庫,接著才發訊息到訊息佇列——如果在這兩步之間崩潰,資料庫改了但訊息沒發出,狀態就永久不一致(這就是「雙寫問題 Dual Write」)。

Transactional Outbox#

解法是把「發訊息」也變成本地資料庫交易的一部分:在同一個本地交易裡,既更新業務資料表,也往同一個資料庫的 outbox 表插入一筆待發訊息。交易要嘛全成功、要嘛全失敗,從根本上消除雙寫不一致。再由一個獨立的訊息中繼 (Message Relay) 讀取 outbox 並投遞到訊息佇列。

flowchart LR
    subgraph TX["單一本地交易"]
        BIZ[("業務表\nUPDATE")]
        OUT[("outbox 表\nINSERT")]
    end
    REL["訊息中繼\n(輪詢 / CDC)"]
    MQ["訊息佇列"]

    TX --> REL
    OUT -.-> REL
    REL --> MQ
    REL -. "標記已發送/刪除" .-> OUT

    style TX fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
    style OUT fill:#fff3e0
    style MQ fill:#e8f5e9

訊息中繼讀取 outbox 有兩種做法:

  • 輪詢 (Polling Publisher):定時掃 outbox 表撈未發送的列,簡單但有延遲與掃表負載
  • 變更資料捕獲 (CDC):訂閱資料庫交易日誌 (binlog/WAL),outbox 一有新列就近即時投遞——常用工具如 Debezium 即屬此類。延遲低、不掃表,但部署較重

At-Least-Once 與冪等#

絕大多數訊息系統與 Outbox 中繼提供的是至少一次投遞 (At-Least-Once):保證不丟,但崩潰重試可能造成重複投遞。因此消費端必須做到冪等 (Idempotent)——同一則訊息處理一次與處理多次結果相同。

常見冪等手段:

  • 去重表:記錄已處理的訊息 ID,重複到達直接忽略
  • 業務唯一鍵:用訂單號等天然唯一鍵做 INSERT ... ON DUPLICATE 或樂觀鎖
  • 狀態機防護:只在合法狀態轉移時動作(如僅 CREATED → PAID,重複的付款事件因狀態已是 PAID 而被忽略)
sequenceDiagram
    participant MQ as 訊息佇列
    participant C as 消費者
    participant DB as 去重表/業務庫

    MQ->>C: 投遞訊息(msgId=A123)
    C->>DB: 查 msgId=A123 是否已處理?
    alt 已處理
        DB-->>C: 是 → 直接 ACK,不重做
    else 未處理
        DB-->>C: 否
        C->>DB: 處理業務 + 記錄 msgId=A123(同交易)
        C->>MQ: ACK
    end

「至少一次投遞 + 消費端冪等」是分散式訊息可靠性的黃金組合。追求「恰好一次 (Exactly-Once)」在端到端通常代價極高且難以真正達成;務實的目標是「不丟 (靠 Outbox) + 可重複而無害 (靠冪等)」。

Outbox 產生的事件天然可以同時驅動 Saga 協作與 Event Sourcing 與 CQRS 的讀模型更新——這也是事件驅動架構在微服務中如此核心的原因。