在实际开发里,gRPC 服务跟人一样,总会有状态不好的时候:网络闪断、服务重启、实例被流量冲垮,都是家常便饭。如果客户端没有重试机制,一次小小的抖动就可能让整个请求失败,用户看到的就是一个刺眼的报错。Envoy 作为一个非常流行的代理层,可以在请求路径上帮我们自动完成重试,今天我们就把这件事掰开揉碎聊清楚。
一、重试机制到底解决了什么问题
1.1 一个让人头大的场景
想象一下,你正在做一个电商下单功能。用户点完“支付”按钮,请求要经过网关,再转到订单服务。如果订单服务正在做滚动发布,旧实例已经摘除,新实例还没完全就绪,这时候请求就会撞上“服务不可用”。如果客户端不做任何处理,用户就只能再点一次。运气好的话,第二次点的时候新实例已经起来了,成功了;运气不好,用户连续点几次,全失败,然后就开始骂产品经理。
有了自动重试,客户端不需要知道服务端刚才到底经历了什么。当 Envoy 发现上游请求失败时,它会自己把请求再发一次。只要服务端恢复,这一次大概率就成功了。用户体验上,唯一的感受是“稍微慢了一点,但没报错”。
1.2 gRPC 与 HTTP 重试的差异
以前很多同学做 HTTP 接口的重试,喜欢直接看状态码,比如碰到 502、503 就重试,碰到 200 就认为成功。gRPC 的套路不太一样。gRPC 请求虽然也是跑在 HTTP/2 上,但它的成功和失败是通过一个叫做 grpc-status 的 header 来传递的。这个 status 是个数字,不同的数字代表不同的错误类型。
比如常见的 14 表示 Unavailable,也就是“服务不可用”;8 表示 ResourceExhausted,意思是“服务端资源被耗尽了”;4 表示 DeadlinedExceeded,意思是“超时了”。这些错误有的适合重试,有的不适合。盲目地见错就重试,会把一个本来只是“参数不对”的请求重复发送好几遍,除了给服务端添乱,没有任何好处。
这里就引出了 Envoy 的价值:它可以帮我们制定一个“什么情况允许重试”的规则,然后由代理层统一执行,业务代码不用每次去判断。
二、Envoy 是怎么帮我们做重试的
2.1 Envoy 在服务网格里的位置
在微服务架构里,Envoy 经常作为 sidecar 部署在应用旁边。所有的进出流量都会先经过 Envoy,再由它转发给实际的服务。这个位置有个好处:重试逻辑可以放在 Envoy 里,而不是塞进每一个服务的业务代码中。你只需要配置好重试策略,不管是 Java 写的服务还是 Go 写的服务,都能享受到同样的重试能力。
2.2 重试策略里的几个关键配置
Envoy 的重试策略主要在虚拟主机的路由中配置。你需要关心几个点:
- retry_on:告诉 Envoy 哪些错误条件可以触发重试。
- num_retries:最多重试几次,不包含第一次。
- retry_back_off:重试之间的退避时间,避免连续怼请求。
- per_try_timeout:每一次尝试的超时时间。
2.3 关联知识:gRPC 状态码该如何选择
刚才提到 retry_on 需要写明触发条件。Envoy 支持很多条件,比如 connect-failure(连接失败)、reset(连接被重置)、refused-stream(流被拒绝)。对 gRPC 来说,最常用的是这几个:
- unavailable:对应 gRPC 的 Unavailable,服务暂时不可用。
- resource-exhausted:对应 gRPC 的 ResourceExhausted,服务端太忙。
- retriable-status-codes:这是一个通配,配合其他条件使用。
retriable-status-codes 是一个比较特殊的开关,它允许你自定义哪些 gRPC 状态码值得重试。在 Envoy 配置里,你可以在 retry_on 后面写 retriable-status-codes,然后在 retry_policy 里通过 retry_status_codes 字段列出具体的状态码,比如 14、8 等。这样你就可以把重试条件控制得非常精确。
不建议把 internal 加入重试条件。Internal 通常表示代码内部出错了,比如空指针、数据库连接异常。这种错误重试也没用,反而会掩盖真正的 Bug。简而言之,只有那些“可能是瞬时状态导致”的错误,才值得重试。
三、动手写一个完整的例子
3.1 环境准备
这次示例统一使用 Go 技术栈,Envoy 版本使用 1.30,Go 版本使用 1.21 以上。你需要先把 proto 接口定义好,并用 protoc 生成代码。假设我们已经有一个 hello.proto 文件,里面定义了一个 SayHello 方法。
下面先给出 proto 文件。
// 技术栈:Go (Protobuf 定义)
syntax = "proto3";
package hello;
// 定义请求消息
message HelloRequest {
string name = 1; // 用户名
}
// 定义响应消息
message HelloReply {
string message = 1; // 回复内容
}
// 定义服务
service Greeter {
// 接收请求,返回响应
rpc SayHello(HelloRequest) returns (HelloReply);
}
注意,这个 proto 文件本身不是 Go 语言,但它是 Go 服务生成的输入文件。在实际项目中,你可以用下面的命令生成对应的源码。
# 技术栈:Go 命令行
protoc --go_out=. --go-grpc_out=. hello.proto
3.2 服务端代码
服务端的逻辑很简单:前两次调用故意返回 Unavailable,第三次才返回成功。这样做是为了方便演示重试效果。下面是服务端代码。
// 技术栈:Go (gRPC 服务端)
package main
import (
"context"
"log"
"net"
"sync/atomic"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
pb "your-project/gen/hello" // 假设生成的包
)
// 计数器,用来记录当前是第几次调用
var callCount int32
// server 是 Greeter 服务的实现体
type server struct {
pb.UnimplementedGreeterServer
}
// SayHello 是业务方法
func (s *server) SayHello(ctx context.Context, req *pb.HelloRequest) (*pb.HelloReply, error) {
n := atomic.AddInt32(&callCount, 1)
// 前两次调用模拟服务不稳定
if n <= 2 {
log.Printf("第 %d 次调用,返回 Unavailable", n)
return nil, status.Error(codes.Unavailable, "服务还没准备好")
}
log.Printf("第 %d 次调用,成功返回", n)
return &pb.HelloReply{Message: "你好," + req.Name}, nil
}
func main() {
// 监听 50051 端口
lis, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatalf("监听端口失败: %v", err)
}
// 创建 gRPC 服务
s := grpc.NewServer()
// 注册服务实现
pb.RegisterGreeterServer(s, &server{})
log.Println("gRPC 服务端已启动,监听 :50051")
if err := s.Serve(lis); err != nil {
log.Fatalf("服务启动失败: %v", err)
}
}
3.3 Envoy 配置文件
下面这个配置会让 Envoy 监听 8080 端口,把请求转发到 50051 端口,同时配置了重试策略。注意看 retry_policy 部分。
# 技术栈:Envoy 静态配置
static_resources:
listeners:
- name: grpc_listener
address:
socket_address:
address: 0.0.0.0
port_value: 8080
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
codec_type: AUTO
stat_prefix: grpc
route_config:
name: local_route
virtual_hosts:
- name: grpc_service
domains: ["*"]
routes:
- match:
prefix: "/"
route:
cluster: grpc_backend
# 重试策略核心配置
retry_policy:
# 哪些错误需要重试
retry_on: "unavailable,resource-exhausted,connect-failure,retriable-status-codes"
# 最多重试 3 次(第一次失败后)
num_retries: 3
# 退避时间:第一次等 0.05 秒,之后最多等 0.5 秒
retry_back_off:
base_interval: 0.05s
max_interval: 0.5s
# 每次尝试的超时时间
per_try_timeout: 10s
http_filters:
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
clusters:
- name: grpc_backend
type: STRICT_DNS
lb_policy: ROUND_ROBIN
typed_extension_protocol_options:
envoy.extensions.upstreams.http.v3.HttpProtocolOptions:
"@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions
explicit_http_config:
http2_protocol_options: {}
load_assignment:
cluster_name: grpc_backend
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: 127.0.0.1
port_value: 50051
3.4 客户端代码
客户端只需要连接 Envoy 的 8080 端口,至于重试的事情完全交给 Envoy 处理。注意,客户端代码里看不到任何重试逻辑,因为重试已经下沉到代理层了。
// 技术栈:Go (gRPC 客户端)
package main
import (
"context"
"log"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
pb "your-project/gen/hello" // 假设生成的包
)
func main() {
// 连接 Envoy,而不是直接连接后台服务
conn, err := grpc.NewClient("localhost:8080",
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithDefaultCallOptions(grpc.WaitForReady(true)),
)
if err != nil {
log.Fatalf("连接 Envoy 失败: %v", err)
}
defer conn.Close()
client := pb.NewGreeterClient(conn)
// 设置一个总超时,避免无限等待
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
// 发起调用,实际会经过 Envoy
resp, err := client.SayHello(ctx, &pb.HelloRequest{Name: "小明"})
if err != nil {
log.Fatalf("调用失败: %v", err)
}
log.Printf("收到响应: %s", resp.Message)
}
3.5 验证重试是否生效
想要确认重试真的生效,最简单的办法是观察服务端日志。如果服务端连续打印了两条 Unavailable 错误,然后打印一条成功,说明 Envoy 确实做了重试。还可以看 Envoy 的访问日志,它会记录每次请求的返回码和重试次数。你也可以故意把服务端停掉,观察客户端是否在超时时间内一直等待,这也能帮助你理解重试的边界。
四、重试机制的注意事项
4.1 幂等性是重试的命根子
不是所有请求都适合重试。假设你调用的是一个“扣款”接口,第一次请求已经成功了,只是响应在网络上丢了,客户端以为失败,触发重试,结果又扣了一笔钱。这就很麻烦了。所以,只有幂等的接口才能放心地重试。什么叫幂等?简单说,就是同一个请求执行一次和执行多次,最终结果是一样的。对你自己的服务来说,最好通过请求 ID + 服务端去重来保证幂等。
4.2 别让重试变成雪崩
如果所有客户端都在同一个瞬间发出大量重试,服务端本来就已经很吃力,现在被更多的请求打过来,很可能直接崩溃。这就是传说中的“重试风暴”。解决思路有几种:一是让每次重试之间间隔递增,不能像打机关枪一样;二是限制总的重试次数;三是在 Envoy 或者客户端配置“重试预算”,比如只能重试总请求量的 1%。Envoy 的路由中可以通过 retry_budget 配置来设置预算。
4.3 超时设置要合理
重试次数和每次尝试的超时要一起考虑。假如你的接口通常耗时 1 秒,你设置了 5 次重试,每次尝试的超时都是 10 秒,极端情况下一个请求要等 50 多秒,这显然不行。建议把 per_try_timeout 设置成略大于正常耗时的值,同时客户端也设置一个总的超时上下文。gRPC 有一个内置的“截止时间”概念,客户端在调用时传入一个带截止时间的上下文,这样所有重试加在一起也不会超过总时间。注意,还要把 Envoy 的重试超时设置好,避免和客户端的超时互相打架。
4.4 只重试值得重试的错误
你可以自己写一个小函数来判断错误是否值得重试,作为逻辑兜底。虽然不是必须的,但在某些场景下能帮我们减少无谓的网络浪费。下面是一个用 Go 写的简单判断函数。
// 技术栈:Go (错误判断示例)
func shouldRetry(err error) bool {
st, ok := status.FromError(err)
if !ok {
return false
}
switch st.Code() {
case codes.Unavailable, codes.ResourceExhausted, codes.Cancelled:
// 服务不可用、资源耗尽、请求被取消,都值得再试一次
return true
default:
// 其他错误比如 InvalidArgument、Internal,不重试
return false
}
}
注意,这个函数只是一个参考。实际项目中,Envoy 已经帮你做了大部分重试判断,但如果你的客户端还保留了额外的重试逻辑,就要特别小心:不要让客户端和 Envoy 双重重试叠加,否则流量会被放大好多倍。
4.5 设计服务端时如何配合重试
既然重试一定会带来重复请求,服务端最好在设计接口时就把幂等这件事考虑进去。常用的办法是让客户端在请求里带上一个全局唯一的请求 ID,服务端处理前先查一下这个 ID 是不是已经处理过。如果处理过,直接返回之前的结果,不再重复执行业务逻辑。这样即使 Envoy 重试了很多次,最终的效果也相当于只执行了一次。对于 gRPC 来说,我们可以把请求 ID 放在 metadata 中传递。服务端在接收消息时,通过 gRPC 的 metadata 机制取出来,然后走一遍查重逻辑。注意,查重本身要放在事务里,否则并发请求同时到达时还是会出现重复。
五、应用场景和技术优缺点
5.1 适合用 Envoy 重试的场景
比较典型的场景有三种。第一,微服务之间的调用,经常因为发布、重启、网络闪断而出现瞬时失败,用 Envoy 重试可以平滑度过。第二,跨机房的请求,偶尔因为链路质量不好而丢包,重试能明显提升成功率。第三,在故障演练或混沌工程中,故意让服务实例崩溃,然后观察重试是否能把请求恢复正常。
5.2 优点
用 Envoy 做重试最明显的好处是“透明”。业务代码根本不用关心重试逻辑,只需要照常发起 gRPC 请求就行。其次是统一管理,运维可以在一个地方修改策略,不用跑到几十个服务里改代码。再者,它跟语言无关,不管是 Go、Java、Python 还是 Node.js,只要接入 Envoy,重试行为就能保持一致。
5.3 缺点
缺点也很突出。首先是延迟增加,一次失败重试至少多一次往返,在极端情况下会让整个调用链路变慢。其次是流量放大,重试会带来额外的请求压力,服务端必须有能力承载这些额外流量。最后是复杂度变高,重试如果没有和幂等性结合,可能会带来数据不一致等严重的业务问题。所以,重试不是免费午餐,我们要用对地方。
六、文章总结
说到底,Envoy 的自动重试机制是微服务高可用的一把利器。它能让我们在面对网络抖动、服务重启等小故障时,不用靠用户手动刷新来碰运气。但使用它的时候,必须想清楚三个问题:什么错误值得重试,重试多少次才不会压垮服务,服务端是否已经做好幂等。把握好这三点,你就能在生产环境中放心地让 Envoy 帮你去“再试一次”。
以上就是对 Envoy 配合 gRPC 自动重试机制的完整梳理。希望你在实际项目中能把它用得又稳又准。
评论
围绕“开发中使用Envoy,如何处理gRPC通信的自动重试机制问题?”参与讨论