寫給 go 開發者的 gRPC 教程 - 超時控制


導言

一個合理的超時時間是非常必要的,它能提高用戶體驗,提高服務器的整體性能,是服務治理的常見手段之一

爲什麼要設置超時

用戶體驗:很多 RPC 都是由用戶側發起,如果請求不設置超時時間或者超時時間不合理,會導致用戶一直處於白屏或者請求中的狀態,影響用戶的體驗

資源利用:一個 RPC 會佔用兩端(服務端與客戶端)端口、cpu、內存等一系列的資源,不合理的超時時間會導致 RPC 佔用的資源遲遲不能被釋放,因而影響服務器穩定性

綜上,一個合理的超時時間是非常必要的。在一些要求更高的服務中,我們還需要針對 DNS 解析、連接建立,讀、寫等設置更精細的超時時間。除了設置靜態的超時時間,根據當前系統狀態、服務鏈路等設置自適應的動態超時時間也是服務治理中一個常見的方案。

客戶端的超時

連接超時

還記得我們怎麼在客戶端創建連接的麼?

conn, err := grpc.Dial("127.0.0.1:8009",
    grpc.WithInsecure(),
)
if err != nil {
    panic(err)
}

// c := pb.NewOrderManagementClient(conn)

// // Add Order
// order := pb.Order{
//  Id:          "101",
//  Items:       []string{"iPhone XS""Mac Book Pro"},
//  Destination: "San Jose, CA",
//  Price:       2300.00,
// }
// res, err := c.AddOrder(context.Background()&order)
// if err != nil {
//  panic(err)
// }

如果目標地址127.0.0.1:8009無法建立連接,grpc.Dial()會返回錯誤麼?這裏直接放結論:不會的,grpc 默認會異步創建連接,並不會阻塞在這裏,如果連接沒有創建成功會在下面的 RPC 調用中報錯。

如果我們想控制連接創建時的超時時間該怎麼做呢?

於是實現如下,當然使用context.WithDeadline()效果也是一樣的。連接如果在 3s 內沒有創建成功,則會返回context.DeadlineExceeded錯誤

ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()

conn, err := grpc.DialContext(ctx, "127.0.0.1:8009",
 grpc.WithInsecure(),
 grpc.WithBlock(),
)
if err != nil {
 if err == context.DeadlineExceeded {
        panic(err)
    }
    panic(err)
}

服務調用的超時

和上面連接超時的配置類似。無論是普通RPC還是流式RPC,服務調用的第一個參數均是context.Context

所以可以使用context.Context來控制服務調用的超時時間,然後使用status來判斷是否是超時報錯,關於status可以回顧之前講過的錯誤處理

ctx, cancel = context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()

// Add Order
order := pb.Order{
 Id:          "101",
 Items:       []string{"iPhone XS""Mac Book Pro"},
 Destination: "San Jose, CA",
 Price:       2300.00,
}
res, err := c.AddOrder(ctx, &order)
if err != nil {
 st, ok := status.FromError(err)
 if ok && st.Code() == codes.DeadlineExceeded {
  panic(err)
 }
 panic(err)
}

攔截器中的超時

普通RPC還是流式RPC攔截器函數簽名第一個參數也是context.Context,我們也可以在攔截器中修改超時時間。錯誤處理也是和服務調用是一樣的

需要注意的是context.WithTimeout(context.Background(), 100*time.Second)。因爲 Go 中context.Context向下傳導的效果,我們需要基於context.Background()創建新的context.Context,而不是基於入參的ctx

func unaryClientInterceptor(ctx context.Context, method string, req, reply interface{},
 cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {

 ctx, cancel := context.WithTimeout(context.Background(), 100*time.Second)
 defer cancel()

 // Invoking the remote method
 err := invoker(ctx, method, req, reply, cc, opts...)
    if err != nil {
        st, ok := status.FromError(err)
        if ok && st.Code() == codes.DeadlineExceeded {
            panic(err)
        }
        panic(err)
    }

 return err
}

服務端的超時

連接超時

服務端也可以控制連接創建的超時時間,如果沒有在設定的時間內建立連接,服務端就會主動斷連,避免浪費服務端的端口、內存等資源

s := grpc.NewServer(
 grpc.ConnectionTimeout(3*time.Second),
)

服務實現中的超時

服務實現函數的第一個參數也是context.Context,所以我們可以在一些耗時操作前對context.Context進行判斷:如果已經超時了,就沒必要繼續往下執行了。此時客戶端也會收到上文提到過的超時error

func (s *server) AddOrder(ctx context.Context, orderReq *pb.Order) (*wrapperspb.StringValue, error) {
 log.Printf("Order Added. ID : %v", orderReq.Id)

 select {
 case <-ctx.Done():
  return nil, status.Errorf(codes.Canceled, "Client cancelled, abandoning.")
 default:
 }

 orders[orderReq.Id] = *orderReq

 return &wrapperspb.StringValue{Value: "Order Added: " + orderReq.Id}, nil
}

很多庫都支持類似的操作,我們要做的就是把context.Context透傳下去,當context.Context超時時就會提前結束操作了

db, err := gorm.Open()
if err != nil {
    panic("failed to connect database")
}

db.WithContext(ctx).Save(&users)

攔截器中的超時

在服務端的攔截器裏也可以修改超時時間

func unaryServerInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
 ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
 defer cancel()

 // Invoking the handler to complete the normal execution of a unary RPC.
 m, err := handler(ctx, req)

 return m, err
}

超時傳遞

一個正常的請求會涉及到多個服務的調用。從源頭開始一個服務端不僅爲上游服務提供服務,也作爲下游的客戶端

如上的鏈路,如果當請求到達某一服務時,對於服務 A 來說已經超時了,那麼就沒有必要繼續把請求傳遞下去了。這樣可以最大限度的避免後續服務的資源浪費,提高系統的整體性能。

grpc-go實現了這一特性,我們要做的就是不斷的把context.Context傳下去

// 服務A
func main(){
    ctx, cancel = context.WithTimeout(context.Background(), 3*time.Second)
 defer cancel()
    
    client.ServiceB(ctx)
}
// 服務B
func ServiceB(ctx context.Context){
    client.ServiceC(ctx)
}
// 服務C
func ServiceC(ctx context.Context){
    client.ServiceD(ctx)
}

在每一次的context.Context透傳中, timeout 都會減去在本進程中耗時,導致這個 timeout 傳遞到下一個 gRPC 服務端時變短,當在某一個進程中已經超時,請求不會再繼續傳遞,這樣即實現了所謂的 超時傳遞

關於超時傳遞的實現可以參考下面的參考資料中的鏈接

總結

通過使用context.Context,我們可以精細化的控制 gRPC 中服務端、客戶端兩端的建連,調用,以及在攔截器中的超時時間。同時 gRPC 還提供了超時傳遞的能力,讓超時的請求不繼續在鏈路中往下傳遞,提高鏈路整體的性能。


參考資料

[1]

示例代碼 : https://github.com/liangwt/grpc-example

[2]

Golang gRPC 學習 (04): Deadlines 超時限制 : https://www.cnblogs.com/jiujuan/p/13499915.html

[3]

gRPC 系列——grpc 超時傳遞原理: https://xiaomi-info.github.io/2019/12/30/grpc-deadline/

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/bh2iRcPllEwPwY4MjA5iHQ