在微服務架構下,服務提供者和服務消費者執行在不同物理機上的不同行程內。相比於本地方法呼叫,跨行程呼叫需要透過遠程方法呼叫(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 協議。
連接過程:
- 三次握手:建立連接
- 資料傳輸:發起 HTTP 呼叫
- 四次揮手:斷開連接
適用場景:
- 跨業務平台之間的服務協議
- 對外開放的服務介面
- 省去溝通服務協議的成本
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 讀寫由內核完成,完成後通知用戶端。
優點:
- 用戶端無需等待,不存在阻塞問題
缺點:
- 編程難度最大,程式不易於理解
適用場景:連接數比較多而且請求消耗比較重的業務場景(如相冊伺服器)
處理模型對比表
| 特性 | BIO | NIO | AIO |
|---|---|---|---|
| 阻塞方式 | 同步阻塞 | 同步非阻塞 | 異步非阻塞 |
| 執行緒模型 | 一連接一執行緒 | 多路復用 | 回調通知 |
| 編程難度 | 簡單 | 中等 | 複雜 |
| 適用連接數 | 少量 | 大量 | 大量 |
| 適用請求類型 | 任意 | 輕量級 | 重量級 |
通信協議#
協議的組成#
通信協議定義了一個「契約」,使服務消費者和提供者能夠達成共識。協議通常包括兩個部分:
| 部分 | 內容 |
|---|---|
| 訊息頭 | 協議的公共字段以及用戶擴展字段 |
| 訊息體 | 傳輸資料的具體內容 |
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 協議頭結構:
| 字段 | 大小 | 說明 |
|---|---|---|
| Magic | 2 bytes | 魔數,固定值 |
| Request/Response Flag | 1 bit | 請求/響應識別 |
| 2-Way | 1 bit | 是否需要返回值 |
| Event | 1 bit | 是否為事件訊息 |
| Serialization | 5 bits | 序列化方式識別 |
| Status | 1 byte | 響應狀態碼 |
| Request ID | 8 bytes | 請求唯一識別 |
| Data Length | 4 bytes | 訊息體長度 |
無論是開放的還是私有的協議,服務消費者按照契約編碼資料,服務提供者按照契約解碼資料,然後處理請求並返回結果。
序列化與反序列化#
為什麼需要序列化#
網路傳輸的耗時取決於:
- 網路帶寬的大小
- 資料傳輸量
對資料進行序列化(編碼)的主要目的是減小資料傳輸量。
序列化方式的分類#
文本類協議:
- 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 的讀模型更新——這也是事件驅動架構在微服務中如此核心的原因。