這是 Go 語言從零到 Web 應用系列的第十七篇。前面我們學了 REST API 和 gRPC 這兩種通訊方式,現在來學如何用微服務架構組織大型系統。
單體 vs 微服務
單體架構(Monolith)
所有功能在一個程式中:
┌─────────────────────────┐
│ 單體應用 │
│ ┌─────┐ ┌─────┐ ┌───┐ │
│ │用戶 │ │訂單 │ │商品│ │
│ └──┬──┘ └──┬──┘ └─┬─┘ │
│ └───────┼──────┘ │
│ ┌──┴──┐ │
│ │ DB │ │
│ └─────┘ │
└─────────────────────────┘
微服務架構
每個功能是獨立的服務:
┌──────┐ ┌──────┐ ┌──────┐
│用戶 │ │訂單 │ │商品 │
│服務 │ │服務 │ │服務 │
└──┬───┘ └──┬───┘ └──┬───┘
│ │ │
┌──┴──┐ ┌──┴──┐ ┌──┴──┐
│Users│ │Orders│ │Products│
│ DB │ │ DB │ │ DB │
└─────┘ └─────┘ └──────┘
對比
| 面向 | 單體 | 微服務 |
|---|---|---|
| 開發速度(初期) | 快 | 慢(基礎設施多) |
| 部署 | 整包部署 | 獨立部署 |
| 擴展 | 整體擴展 | 按需擴展個別服務 |
| 技術選型 | 統一 | 每個服務可用不同技術 |
| 團隊組織 | 單一團隊 | 各服務獨立團隊 |
| 複雜度 | 程式碼內部 | 分散式系統 |
| 故障影響 | 全域 | 局部(做好隔離時) |
什麼時候該拆微服務?
不要一開始就用微服務。 先用單體,當出現以下訊號時再考慮拆分:
- 團隊超過 10 人,互相踩腳
- 部署一個功能要整包重新部署
- 某個模組的流量遠超其他模組
- 不同模組需要不同的擴展策略
服務拆分策略
DDD Bounded Context
用 Domain-Driven Design 的 Bounded Context 來劃定服務邊界:
以電商系統為例:
┌─────────────────────────────────────────┐
│ 電商系統 │
│ │
│ ┌──────────┐ ┌──────────┐ ┌────────┐ │
│ │ 用戶上下文│ │ 訂單上下文│ │商品上下文│ │
│ │ │ │ │ │ │ │
│ │ - 註冊 │ │ - 建立訂單│ │- 商品 │ │
│ │ - 登入 │ │ - 付款 │ │- 庫存 │ │
│ │ - 個人資料│ │ - 出貨 │ │- 分類 │ │
│ └──────────┘ └──────────┘ └────────┘ │
└─────────────────────────────────────────┘
拆分原則:
- 每個 Bounded Context 變成一個微服務
- 服務之間透過 API 通訊,不共享資料庫
- 每個服務有自己的資料存儲
Go 的專案結構
bookstore/
├── user-service/
│ ├── cmd/server/main.go
│ ├── internal/
│ │ ├── handler/
│ │ ├── service/
│ │ └── repository/
│ ├── proto/
│ └── go.mod
├── order-service/
│ ├── cmd/server/main.go
│ ├── internal/
│ └── go.mod
└── product-service/
├── cmd/server/main.go
├── internal/
└── go.mod
服務間通訊
同步通訊:HTTP
// order-service 呼叫 user-service
func (s *OrderService) CreateOrder(ctx context.Context, userID int64, items []Item) (*Order, error) {
// 驗證使用者是否存在
req, err := http.NewRequestWithContext(ctx, "GET",
fmt.Sprintf("http://user-service:8080/users/%d", userID), nil)
if err != nil {
return nil, err
}
resp, err := s.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("calling user-service: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, ErrUserNotFound
}
// 建立訂單...
return order, nil
}
同步通訊:gRPC
// order-service 用 gRPC 呼叫 user-service
func (s *OrderService) CreateOrder(ctx context.Context, userID int64, items []Item) (*Order, error) {
// gRPC client
user, err := s.userClient.GetUser(ctx, &userpb.GetUserRequest{Id: userID})
if err != nil {
st, _ := status.FromError(err)
if st.Code() == codes.NotFound {
return nil, ErrUserNotFound
}
return nil, fmt.Errorf("calling user-service: %w", err)
}
// 建立訂單...
return order, nil
}
非同步通訊:Message Queue
適用於不需要即時回應的場景:
// order-service 發布事件
type OrderCreatedEvent struct {
OrderID int64 `json:"order_id"`
UserID int64 `json:"user_id"`
Total float64 `json:"total"`
CreatedAt time.Time `json:"created_at"`
}
// 發布到 NATS
func (s *OrderService) publishOrderCreated(order *Order) error {
event := OrderCreatedEvent{
OrderID: order.ID,
UserID: order.UserID,
Total: order.Total,
CreatedAt: time.Now(),
}
data, _ := json.Marshal(event)
return s.natsConn.Publish("orders.created", data)
}
// notification-service 訂閱事件
func (s *NotificationService) subscribeOrderEvents() {
s.natsConn.Subscribe("orders.created", func(msg *nats.Msg) {
var event OrderCreatedEvent
json.Unmarshal(msg.Data, &event)
// 發送通知給使用者
s.sendEmail(event.UserID, "Your order has been placed!")
})
}
如何選擇通訊方式?
| 場景 | 建議 |
|---|---|
| 需要即時回應 | 同步(HTTP/gRPC) |
| 高效能、強型別 | gRPC |
| 對外 API | HTTP/REST |
| 事件通知(不需要回應) | 非同步(Message Queue) |
| 長時間處理的任務 | 非同步 |
微服務模式
API Gateway
所有外部請求先經過 Gateway,再路由到對應的服務:
Client → API Gateway → User Service
→ Order Service
→ Product Service
// 簡化版 API Gateway
func main() {
mux := http.NewServeMux()
// 反向代理到不同服務
userProxy := httputil.NewSingleHostReverseProxy(
&url.URL{Scheme: "http", Host: "user-service:8080"},
)
orderProxy := httputil.NewSingleHostReverseProxy(
&url.URL{Scheme: "http", Host: "order-service:8080"},
)
mux.Handle("/api/users/", userProxy)
mux.Handle("/api/orders/", orderProxy)
http.ListenAndServe(":3000", mux)
}
生產環境通常用 Kong、Traefik 或 Envoy 等成熟的 API Gateway。
Service Discovery
服務需要知道其他服務的地址。在 Kubernetes 中,DNS 自動處理。在非 K8s 環境中,可以用 Consul:
// 註冊服務到 Consul
func registerService(consulAddr string) error {
config := consulapi.DefaultConfig()
config.Address = consulAddr
client, err := consulapi.NewClient(config)
if err != nil {
return err
}
registration := &consulapi.AgentServiceRegistration{
ID: "user-service-1",
Name: "user-service",
Port: 8080,
Address: "10.0.0.5",
Check: &consulapi.AgentServiceCheck{
HTTP: "http://10.0.0.5:8080/health",
Interval: "10s",
},
}
return client.Agent().ServiceRegister(registration)
}
Circuit Breaker(斷路器)
當下游服務故障時,避免連鎖失敗:
import "github.com/sony/gobreaker"
// 建立斷路器
cb := gobreaker.NewCircuitBreaker(gobreaker.Settings{
Name: "user-service",
MaxRequests: 3, // half-open 狀態最多嘗試次數
Interval: 10 * time.Second, // closed 狀態下的統計區間
Timeout: 30 * time.Second, // open 後多久嘗試 half-open
ReadyToTrip: func(counts gobreaker.Counts) bool {
// 連續失敗 5 次就開啟斷路器
return counts.ConsecutiveFailures >= 5
},
})
// 使用斷路器
result, err := cb.Execute(func() (interface{}, error) {
return s.userClient.GetUser(ctx, &userpb.GetUserRequest{Id: userID})
})
if err != nil {
if err == gobreaker.ErrOpenState {
// 斷路器開啟中,使用降級策略
return cachedUser, nil
}
return nil, err
}
斷路器三種狀態:
- Closed(關閉):正常運作,如果錯誤率超過閾值就打開
- Open(打開):直接返回錯誤,不呼叫下游服務
- Half-Open(半開):嘗試少量請求,成功就關閉,失敗就繼續打開
Distributed Tracing(分散式追蹤)
追蹤一個請求在多個服務之間的流轉:
import (
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace"
)
func (s *OrderService) CreateOrder(ctx context.Context, req *CreateOrderRequest) (*Order, error) {
// 開始一個 span
ctx, span := otel.Tracer("order-service").Start(ctx, "CreateOrder")
defer span.End()
// 呼叫 user-service(trace context 會自動傳遞)
user, err := s.userClient.GetUser(ctx, &userpb.GetUserRequest{Id: req.UserID})
if err != nil {
span.RecordError(err)
return nil, err
}
// 呼叫 product-service
products, err := s.productClient.GetProducts(ctx, req.ProductIDs)
if err != nil {
span.RecordError(err)
return nil, err
}
// 建立訂單...
span.SetAttributes(attribute.Int64("order.id", order.ID))
return order, nil
}
搭配 Jaeger 或 Grafana Tempo 等工具,可以在 UI 上看到完整的請求鏈路。
下一步
微服務設計好了,最後一步是把它們部署到生產環境。下一篇——也是本系列的最終篇——我們來學 Kubernetes,了解如何用容器編排平台管理和部署 Go 微服務。