gRPC Keepalive 机制

一、Keepalive 解决的是什么问题

gRPC 的 Keepalive 基于 HTTP/2 PING 帧,不是 TCP keepalive

它主要解决两个问题

1、检测死连接
客户端和服务端之间的 TCP 连接看起来还在,但对端进程可能已经崩溃,网络可能已经断开,中间 NAT 也可能已经丢了连接状态,这时如果没有探测,业务代码可能要等到下一次 RPC 请求失败才知道连接不可用,而 Keepalive 会主动发送 HTTP/2 PING,如果超过 keepalive_timeout 还没有收到 ACK,就认为连接已经不可用,然后关闭连接并重新建立连接

2、保持连接活跃
gRPC 通常基于 HTTP/2 长连接,一个连接上可以承载多个 RPC stream,如果连接长时间没有数据,中间的 LB、NAT、防火墙可能会认为这是空闲连接并回收它,所以客户端可以在无活跃 RPC 时也发送 PING,让中间设备看到连接上仍然有流量,从而维持连接活跃


二、Keepalive 是连接级别,不是流级别

gRPC 的一次调用,对应 HTTP/2 上的一个 stream

unary RPC 是一次请求一次响应

客户端发送 request
服务端返回 response + trailers
这个 RPC stream 结束

流式 RPC 是在一个 stream 上持续收发多条消息

Read() 持续读取消息
Read() 返回 false
Finish() 拿到最终状态
这个 RPC stream 结束

但是无论 unary 结束,还是流式 RPC 结束,结束的都是某个 RPC stream,不是底层 HTTP/2 连接,底层连接通常会继续保留,等待后续 RPC 复用

HTTP/2 connection
  ├── stream 1:unary RPC,已结束
  ├── stream 3:server streaming RPC,已结束
  ├── stream 5:新的 unary RPC
  └── stream 7:新的 bidi streaming RPC

所以 Keepalive 维护的是这条 HTTP/2 connection,而不是某一个 stream


三、HTTP/2 PING 的底层过程

HTTP/2 PING 是连接级帧,Stream ID 必须是 0

Client                              Server
  │                                   │
  │ ─── PING frame  ────────────────→ │
  │                                   │
  │ ←── PING ACK frame ─────────────  │
  │                                   │

PING 帧的 payload 固定是 8 字节,对端返回 ACK 时必须原样带回这 8 字节

Length = 8
Type = 0x06
Flags = 0 或 ACK
Stream ID = 0
Opaque Data = 8B

HTTP/2 帧头是 9 字节,PING payload 是 8 字节,所以一个 PING 帧总共 17 字节,非常轻量

如果发送方在 keepalive_timeout 时间内没有收到 PING ACK,就认为连接不可用

发送 PING
等待 keepalive_timeout
没有收到 ACK
关闭连接
重新建连

四、客户端和服务端都可以发 PING

HTTP/2 PING 不是客户端专属,客户端和服务端都可以主动发送

客户端 -> 服务端:PING
服务端 -> 客户端:PING ACK

服务端 -> 客户端:PING
客户端 -> 服务端:PING ACK

谁发送 PING,谁负责等待 ACK 并判断是否超时

客户端发 PING,通常是为了判断服务端连接是否可用,或者让连接穿越 LB、防火墙、NAT

服务端发 PING,通常是为了判断客户端是否还在线,避免大量僵尸连接长期占用服务端资源


五、为什么客户端和服务端的配置不是直接统一

因为两端关注点不一样

客户端更关心的是快速发现连接不可用,避免下一次 RPC 请求才发现连接已经断了

服务端更关心的是防止客户端过于频繁地发送 PING,避免大量连接带来额外压力

所以服务端可能会配置一个 enforcement 规则,例如

客户端最短每 60s 才能发一次 PING

这个限制不会通过 HTTP/2 自动告诉客户端,客户端不会自动改成 60s 发一次,它仍然按照自己的 keepalive_time 发送 PING

如果客户端发得太频繁,服务端通常会记录违规次数,严重时发送 GOAWAY 或直接关闭连接,常见现象是:

GOAWAY received
debug data: too_many_pings

所以实际配置时要保证

客户端 keepalive_time >= 服务端允许的最小 PING 间隔

六、核心参数说明

1 客户端参数

keepalive_time:表示多久没有活动后发送一次 PING

客户端连接空闲一段时间
达到 keepalive_time
发送 HTTP/2 PING

keepalive_timeout:表示 PING 发出后,最多等待多久

超过 keepalive_timeout 还没有收到 ACK
认为连接死亡
关闭连接并重连

permit_without_calls:表示没有活跃 RPC 时是否也发送 PING

false:没有 RPC 时不发 PING
true:无活跃 RPC 时也发 PING

2 服务端参数

max_connection_idle:表示连接空闲多久后关闭

连接长时间没有 RPC
服务端主动关闭连接
释放资源

max_connection_age:表示连接最大存活时间

连接存在时间达到上限
服务端发送 GOAWAY
客户端重新建连

这个参数常用于配合 LB 做连接再均衡

max_connection_age_grace:表示连接达到最大存活时间后,给在途请求留下的宽限时间

先发 GOAWAY
不再接收新 stream
等待已有 RPC 尽量完成
宽限期结束后关闭连接

min_time enforcement:表示服务端允许客户端发送 PING 的最小间隔

客户端 PING 太频繁
服务端认为违规
可能发送 GOAWAY 或关闭连接

七、为什么不用 TCP keepalive

TCP keepalive 工作在传输层,默认探测时间通常很长,例如 Linux 默认可能是 2 小时级别,这对 RPC 框架来说太慢

TCP keepalive
  探测太慢
  配置依赖操作系统
  只能判断 TCP 层连接状态

gRPC Keepalive 基于 HTTP/2 PING,属于协议层探测

gRPC keepalive
  可以设置为秒级
  更贴近 RPC 连接状态
  能更快发现死连接
  每次只有 17 字节,开销很小

不过要注意,Keepalive 不能乱配得太激进,否则可能被服务端认为是 ping flood


八、C++ 客户端如何配置 Keepalive

下面是一个常见客户端配置

#include <grpcpp/grpcpp.h>
#include "user.grpc.pb.h"

int main() {
    grpc::ChannelArguments args;

    // 10 秒没有活动就发送 HTTP/2 PING
    args.SetInt(GRPC_ARG_KEEPALIVE_TIME_MS, 10000);

    // PING 发出后 5 秒没有收到 ACK,就认为连接不可用
    args.SetInt(GRPC_ARG_KEEPALIVE_TIMEOUT_MS, 5000);

    // 没有活跃 RPC 时也允许发送 PING
    // 用于穿越 LB、防火墙、NAT
    args.SetInt(GRPC_ARG_KEEPALIVE_PERMIT_WITHOUT_CALLS, 1);

    // 允许没有数据帧时发送 PING
    // 0 通常表示不限制这类 PING 次数
    args.SetInt(GRPC_ARG_HTTP2_MAX_PINGS_WITHOUT_DATA, 0);

    auto channel = grpc::CreateCustomChannel(
        "dns:///user-service:8080",
        grpc::InsecureChannelCredentials(),
        args
    );

    auto stub = UserService::NewStub(channel);

    return 0;
}

这段代码的含义是

客户端维护一条 HTTP/2 长连接
如果 10 秒没有活动,就发送一次 PING
如果 5 秒内没有收到 PING ACK,就认为连接死亡
即使当前没有活跃 RPC,也允许发送 PING

如果你的服务端限制客户端最短 60 秒才能发一次 PING,那客户端这里的 10000 就太激进,应该改成 60000 或更大


九、C++ 服务端如何配置 Keepalive 和 Enforcement

服务端不仅可以自己发送 PING,也可以限制客户端 PING 的频率

#include <grpcpp/grpcpp.h>
#include "user.grpc.pb.h"

class UserServiceImpl final : public UserService::Service {
    // 实现 RPC 接口
};

int main() {
    UserServiceImpl service;

    grpc::ServerBuilder builder;
    builder.AddListeningPort(
        "0.0.0.0:8080",
        grpc::InsecureServerCredentials()
    );

    builder.RegisterService(&service);

    // 连接空闲 15 分钟后关闭
    builder.AddChannelArgument(
        GRPC_ARG_MAX_CONNECTION_IDLE_MS,
        15 * 60 * 1000
    );

    // 连接最大存活 30 分钟
    // 到时间后服务端会让客户端重连
    builder.AddChannelArgument(
        GRPC_ARG_MAX_CONNECTION_AGE_MS,
        30 * 60 * 1000
    );

    // 到达最大存活时间后,再给 5 秒处理在途请求
    builder.AddChannelArgument(
        GRPC_ARG_MAX_CONNECTION_AGE_GRACE_MS,
        5000
    );

    // 服务端也可以主动发送 PING
    builder.AddChannelArgument(
        GRPC_ARG_KEEPALIVE_TIME_MS,
        5 * 60 * 1000
    );

    // 服务端发送 PING 后,1 秒内没有 ACK 就认为连接异常
    builder.AddChannelArgument(
        GRPC_ARG_KEEPALIVE_TIMEOUT_MS,
        1000
    );

    // Enforcement:限制客户端 PING 的最小间隔
    // 客户端在没有数据时,如果 PING 间隔小于 5 秒,可能被认为违规
    builder.AddChannelArgument(
        GRPC_ARG_HTTP2_MIN_RECV_PING_INTERVAL_WITHOUT_DATA_MS,
        5000
    );

    // 允许无活跃 RPC 时的 PING
    builder.AddChannelArgument(
        GRPC_ARG_KEEPALIVE_PERMIT_WITHOUT_CALLS,
        1
    );

    auto server = builder.BuildAndStart();
    server->Wait();

    return 0;
}

这里最重要的是区分两个概念

GRPC_ARG_KEEPALIVE_TIME_MS
表示本端多久主动发一次 PING

GRPC_ARG_HTTP2_MIN_RECV_PING_INTERVAL_WITHOUT_DATA_MS
表示本端允许对方最短多久发一次 PING

一个是主动探测策略,一个是对对方的约束策略


十、在代码中如何感知流断开和连接断开

1 unary RPC

unary RPC 调用返回后,这次 RPC stream 就结束了

grpc::ClientContext context;
GetUserRequest request;
GetUserResponse response;

grpc::Status status = stub->GetUser(&context, request, &response);

if (status.ok()) {
    // 本次 unary RPC 正常结束
} else {
    // 本次 RPC 异常结束
    // 可能是服务端返回错误,也可能是连接异常
}

unary 的结束不代表 HTTP/2 连接断开,连接仍然可能被 channel 继续复用

2 服务端流式 RPC

grpc::ClientContext context;
ListUserRequest request;
ListUserResponse response;

auto reader = stub->ListUsers(&context, request);

while (reader->Read(&response)) {
    // 持续读取服务端推送的消息
}

// Read 返回 false,说明这个 RPC stream 没有更多消息
grpc::Status status = reader->Finish();

if (status.ok()) {
    // 流式 RPC 正常结束
} else {
    // 流式 RPC 异常结束
}

这里的 Read() 返回 false 只说明这个 RPC stream 结束,不说明 HTTP/2 connection 一定断开

3 客户端主动取消流

grpc::ClientContext context;

auto stream = stub->Chat(&context);

// 某个时刻不想继续这个 RPC
context.TryCancel();

客户端取消某个 RPC,底层通常会让这个 stream 结束,但不一定关闭整条 HTTP/2 连接

4 服务端判断客户端取消

grpc::Status Chat(
    grpc::ServerContext* context,
    grpc::ServerReaderWriter<ChatResponse, ChatRequest>* stream
) override {
    ChatRequest request;

    while (stream->Read(&request)) {
        if (context->IsCancelled()) {
            // 客户端取消了 RPC,或者连接异常导致 RPC 被取消
            return grpc::Status::CANCELLED;
        }

        // 处理消息
    }

    return grpc::Status::OK;
}

5 底层 HTTP/2 连接断开

gRPC 业务代码一般拿不到底层 HTTP/2 connection 对象

连接断开通常表现为这条连接上的 RPC 全部失败,常见状态码是

UNAVAILABLE
CANCELLED
DEADLINE_EXCEEDED
INTERNAL

例如

grpc::Status status = reader->Finish();

if (!status.ok()) {
    std::cout << "error code: "
              << status.error_code()
              << std::endl;

    std::cout << "error message: "
              << status.error_message()
              << std::endl;
}

如果多个 RPC 同时返回 UNAVAILABLE,很可能是底层 HTTP/2 连接断开或服务端不可达

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐