gRPC 与 HTTP/JSON 性能对比:以 Go、Gin 和 gRPC 为例
gRPC 与 HTTP/JSON 性能对比:以 Go、Gin 和 gRPC 为例
本文比较的是两种具体实现:gRPC + Protobuf + HTTP/2 与 Gin + JSON + HTTP/1.1,而不是抽象意义上的“RPC 与 HTTP”。gRPC 是 RPC 框架,并使用 HTTP/2 作为传输协议;Gin 是基于 Go
net/http的 Web 框架。
一、结论先行
- 在本文这组本机环回测试中,Gin/HTTP 在单并发、100 B 负载下延迟更低;并发提高或负载增大后,gRPC 的吞吐量更高。
- 该结果只能说明指定代码、配置和机器上的表现,不能直接推广为“gRPC 一定比 HTTP 快”或“RPC 一定比 HTTP 快”。
- 性能差异来自整套技术栈,包括序列化格式、HTTP 版本、连接模型、客户端实现和框架开销,不能只归因于某一个因素。
- 生产选型不能只看 QPS,还要考虑浏览器兼容性、接口可调试性、流式通信、类型约束、生态和运维成本。
二、对比范围与公平性
| 维度 | gRPC 方案 | Gin/HTTP 方案 | 本文控制方式 |
|---|---|---|---|
| 接口模型 | RPC:service/method | HTTP API:POST /process | 请求和响应字段保持一致 |
| 传输协议 | HTTP/2 | 明文 HTTP/1.1 | 两边均不启用 TLS |
| 序列化 | Protobuf | JSON | 均计入编码和解码耗时 |
| 连接 | 复用 gRPC channel | 复用 http.Client 和连接池 | 不为每个请求新建客户端 |
| 压缩 | 关闭 | 关闭 | 避免压缩策略影响结果 |
| 业务逻辑 | 返回固定结果 | 返回固定结果 | 不访问数据库或外部服务 |
| 测试位置 | 本机环回 | 本机环回 | 主要衡量框架、协议栈和 CPU 开销 |
需要特别说明:HTTP/2 多路复用和 HTTP/1.1 多连接代表两套方案的典型工作方式,并不意味着它们使用了完全相同的连接数量。因此,这是一项“默认推荐用法”的对比,不是单变量实验。
三、服务端实现
3.1 Protobuf 定义
// proto/benchmark.proto
syntax = "proto3";
package benchmark;
option go_package = "rpc-vs-http/pb;pb";
message Request {
string id = 1;
string payload = 2;
int64 timestamp = 3;
}
message Response {
string id = 1;
string message = 2;
int64 timestamp = 3;
bool success = 4;
}
service BenchmarkService {
rpc Process(Request) returns (Response);
}
3.2 gRPC 与 Gin 处理函数
// gRPC 服务端
type grpcServer struct {
pb.UnimplementedBenchmarkServiceServer
}
func (s *grpcServer) Process(
ctx context.Context,
req *pb.Request,
) (*pb.Response, error) {
return &pb.Response{
Id: req.Id,
Message: "processed",
Timestamp: time.Now().UnixNano(),
Success: true,
}, nil
}
// HTTP/JSON 请求和响应使用显式类型,避免 map 带来的额外变量。
type httpRequest struct {
ID string `json:"id"`
Payload string `json:"payload"`
Timestamp int64 `json:"timestamp"`
}
type httpResponse struct {
ID string `json:"id"`
Message string `json:"message"`
Timestamp int64 `json:"timestamp"`
Success bool `json:"success"`
}
func ginHandler(c *gin.Context) {
var req httpRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, httpResponse{
ID: req.ID,
Message: "processed",
Timestamp: time.Now().UnixNano(),
Success: true,
})
}
3.3 启动服务
func main() {
lis, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatal(err)
}
grpcSrv := grpc.NewServer()
pb.RegisterBenchmarkServiceServer(grpcSrv, &grpcServer{})
go func() {
log.Println("gRPC server listening on :50051")
if err := grpcSrv.Serve(lis); err != nil {
log.Fatal(err)
}
}()
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.POST("/process", ginHandler)
log.Println("Gin server listening on :8080")
if err := r.Run(":8080"); err != nil {
log.Fatal(err)
}
}
四、客户端基准测试设计
4.1 参数
var (
concurrency = flag.Int("c", 50, "并发数")
total = flag.Int("n", 10000, "总请求数")
payloadSize = flag.Int("s", 100, "payload 字段长度(字节)")
)
-s 表示业务字段 payload 的长度,并不是完整请求的线上字节数。JSON、Protobuf、HTTP 头和 HTTP/2 帧都会产生额外开销。若要分析带宽,应分别记录编码后的消息大小和实际网络流量。
4.2 连接复用
// gRPC:整个测试复用一个 channel。
conn, err := grpc.NewClient(
"localhost:50051",
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
log.Fatal(err)
}
defer conn.Close()
grpcClient := pb.NewBenchmarkServiceClient(conn)
// HTTP:整个测试复用同一个 Client 和 Transport。
transport := &http.Transport{
Proxy: nil,
MaxIdleConns: *concurrency + 10,
MaxIdleConnsPerHost: *concurrency + 10,
MaxConnsPerHost: *concurrency + 10,
IdleConnTimeout: 90 * time.Second,
DisableCompression: true,
}
defer transport.CloseIdleConnections()
httpClient := &http.Client{
Transport: transport,
Timeout: 5 * time.Second,
}
连接对象必须复用。否则,连接建立成本会掩盖协议与序列化本身的差异。
4.3 统一计时边界
原代码在 HTTP 请求发出前完成了 json.Marshal,并且只丢弃响应体,没有进行 JSON 解码;而 gRPC 的 Protobuf 编解码发生在 client.Process 内。这会让 HTTP 路径少计一部分工作量。
若目标是比较端到端调用成本,两边应采用相同语义:从客户端编码开始计时,到客户端完成响应解码结束。
// gRPC 单次请求:Protobuf 编解码包含在 Process 内。
func callGRPC(
client pb.BenchmarkServiceClient,
payload string,
) (time.Duration, error) {
req := &pb.Request{
Id: "req",
Payload: payload,
Timestamp: time.Now().UnixNano(),
}
started := time.Now()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := client.Process(ctx, req)
latency := time.Since(started)
if err != nil {
return latency, err
}
if !resp.GetSuccess() {
return latency, errors.New("gRPC response unsuccessful")
}
return latency, nil
}
// HTTP 单次请求:JSON 编码、网络往返和 JSON 解码均在计时范围内。
func callHTTP(
client *http.Client,
url string,
payload string,
) (time.Duration, error) {
req := httpRequest{
ID: "req",
Payload: payload,
Timestamp: time.Now().UnixNano(),
}
started := time.Now()
body, err := json.Marshal(req)
if err != nil {
return time.Since(started), err
}
httpReq, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return time.Since(started), err
}
httpReq.Header.Set("Content-Type", "application/json")
httpResp, err := client.Do(httpReq)
if err != nil {
return time.Since(started), err
}
var resp httpResponse
decodeErr := json.NewDecoder(httpResp.Body).Decode(&resp)
closeErr := httpResp.Body.Close()
latency := time.Since(started)
if decodeErr != nil {
return latency, decodeErr
}
if closeErr != nil {
return latency, closeErr
}
if httpResp.StatusCode != http.StatusOK || !resp.Success {
return latency, fmt.Errorf("unexpected HTTP response: %s", httpResp.Status)
}
return latency, nil
}
实际程序应确保任何分支都关闭 HTTP 响应体,并避免在压测热路径中逐条打印错误日志。逐条日志会显著干扰性能,建议只累加错误数并在一轮结束后输出。
4.4 统计口径
至少记录以下指标:
- 尝试请求数、成功数、失败数和错误率;
- 成功吞吐量:
success / elapsed.Seconds(); - 平均延迟以及 P50、P95、P99;
- 每轮测试的 Go、Gin、gRPC 版本,CPU、操作系统、
GOMAXPROCS; - 服务端 CPU、内存分配和 GC 情况。
平均值容易被少量慢请求影响,生产场景通常更应关注 P95 和 P99。
五、执行方法
# 1. 启动服务端
go run server/main.go
# 2. 在另一个终端运行客户端
# 单并发、小负载
go run client/main.go -c 1 -n 5000 -s 100
# 高并发、小负载
go run client/main.go -c 100 -n 50000 -s 100
# 高并发、较大负载
go run client/main.go -c 100 -n 50000 -s 4096
推荐每个场景先预热,再独立运行 5~10 轮,交替或随机安排 gRPC 与 HTTP 的执行顺序,最终报告中位数和波动范围。客户端与服务端若运行在同一进程或同一台机器上,还可能争用 CPU;更贴近生产的测试应将两者部署到独立机器,并分别测试同可用区和跨网络环境。
六、样本结果
下表沿用原始实测数据。由于原基准程序的计时和统计口径存在偏差,这些数值适合保留为历史样本,但修正代码后应重新测试,不能将两组结果直接混用。
| 场景(并发 / 总数 / payload) | 方案(较优方案加粗) | QPS 中位数 | 平均延迟(ms) | P50 | P99 |
|---|---|---|---|---|---|
| 单并发、小负载(1 / 5,000 / 100 B) | gRPC | 2,632 | 0.380 | 0* | 3.574 |
| Gin/HTTP | 4,787 | 0.209 | 0* | 1.131 | |
| 高并发、小负载(100 / 50,000 / 100 B) | gRPC | 51,992 | 1.920 | 1.583 | 5.232 |
| Gin/HTTP | 32,624 | 3.062 | 0.523 | 40.037 | |
| 高并发、较大负载(100 / 50,000 / 4 KiB) | gRPC | 32,709 | 3.055 | 2.628 | 7.848 |
| Gin/HTTP | 16,951 | 5.895 | 1.447 | 51.765 |
从中位数看:
- 单并发小负载下,Gin/HTTP 的 QPS 比 gRPC 高约 82%,平均延迟低约 45%。
- 高并发小负载下,gRPC 的 QPS 比 Gin/HTTP 高约 59%,P99 低约 87%。
- 高并发 4 KiB 负载下,gRPC 的 QPS约为 Gin/HTTP 的 1.93 倍,P99 低约 85%。
- Gin/HTTP 在高并发场景下 P50 较低,但 P95、P99 明显升高,说明延迟分布存在较长的尾部;gRPC 的尾延迟更加稳定。
P99 表示只有 1% 的请求的延迟比这个值更慢,基本反应了最差用户体验是什么水平
七、结果分析
-
7.1 单并发、小负载
在单并发、100 B payload 场景中,Gin/HTTP 的 QPS 中位数为 4,787,gRPC 为 2,632,前者高约 82%;Gin/HTTP 的平均延迟为 0.209 ms,gRPC 为 0.380 ms,前者低约 45%。
这说明在本次本机环回测试中,Gin/HTTP 对小消息、串行请求的处理成本更低。可能的原因包括:
- gRPC 调用需要处理 HTTP/2 帧、gRPC 元数据和 Protobuf 编解码;
- 极小消息下,协议和框架的固定成本占总耗时的比例更高;
- Gin/HTTP 在单连接、无 TLS、无复杂中间件的场景下路径相对简单。
不过,该场景的各轮结果波动较明显:Gin/HTTP 的 QPS 在 3,277~7,273 之间,gRPC 在 2,021~2,710 之间。因此,这一结论只适用于当前机器和测试配置,不应推广为“HTTP 的单请求延迟一定低于 gRPC”。
此外,两种方案的 P50 都出现了
0,这是 Windows 计时分辨率造成的量化现象,不能将其解释为真正的零延迟。单并发场景应主要参考总耗时、QPS、平均延迟以及 P95、P99。7.2 高并发、小负载
在 100 并发、100 B payload 场景中,gRPC 的 QPS 中位数为 51,992,Gin/HTTP 为 32,624,gRPC 高约 59%;gRPC 的平均延迟为 1.920 ms,Gin/HTTP 为 3.062 ms,前者低约 37%。
两种方案的延迟分布存在一个值得注意的差异:
- Gin/HTTP 的 P50 为 0.523 ms,低于 gRPC 的 1.583 ms;
- Gin/HTTP 的 P95 和 P99 分别达到 16.747 ms 和 40.037 ms;
- gRPC 的 P95 和 P99 分别只有 3.945 ms 和 5.232 ms。
这说明 Gin/HTTP 的多数请求完成得很快,但存在一部分明显较慢的请求,从而拉高了平均延迟并形成较长的尾部。相比之下,gRPC 的中位延迟虽然更高,但延迟分布更加集中,尾延迟更稳定。
HTTP/2 可以在一条连接上并发承载多个 stream,而 HTTP/1.1 通常依赖多条连接支撑并发,这可能减少连接调度和管理成本。但多路复用并不意味着没有竞争:多个 stream 仍会共享连接带宽、流量控制窗口和服务端资源,达到并发 stream 上限后也可能发生排队。
因此,更准确的结论是:在本次高并发测试中,gRPC 在吞吐量、平均延迟和尾延迟方面表现更好,但 Gin/HTTP 的 P50 更低。
7.3 高并发、较大负载
payload 从 100 B 增加到 4 KiB 后:
- gRPC 的 QPS 从 51,992 降至 32,709,下降约 37%;
- Gin/HTTP 的 QPS 从 32,624 降至 16,951,下降约 48%;
- gRPC 的平均延迟从 1.920 ms 增至 3.055 ms;
- Gin/HTTP 的平均延迟从 3.062 ms 增至 5.895 ms。
在 4 KiB 场景中,gRPC 的 QPS约为 Gin/HTTP 的 1.93 倍,平均延迟低约 48%。gRPC 的 P99 为 7.848 ms,而 Gin/HTTP 达到 51.765 ms,前者低约 85%。
值得注意的是,Gin/HTTP 的 P50 为 1.447 ms,仍低于 gRPC 的 2.628 ms,但它的 P95 和 P99 显著更高。这进一步说明 Gin/HTTP 的延迟分布存在较长尾部:大部分请求较快,但少量慢请求严重影响平均延迟和整体吞吐量。
Protobuf 通常比 JSON 更紧凑,二进制编解码也可能减少 CPU 和内存分配开销;HTTP/2 多路复用也可能降低高并发下的连接管理成本。但仅凭当前数据不能确定具体瓶颈。性能差异也可能来自:
- JSON 编解码及额外内存拷贝;
- HTTP/1.1 多连接调度;
- Gin、
net/http与 gRPC 的内部实现; - Go 调度器、内存分配和 GC;
- Windows 网络栈及计时器分辨率。
要进一步确认原因,应采集编码后消息大小、CPU profile、allocation profile、GC 指标和连接状态等数据。当前结果能够支持的结论是:随着并发和 payload 增大,gRPC 的吞吐量下降幅度更小,并表现出明显更稳定的尾延迟。
八、选型建议
| 场景 | 更适合的方案 | 主要原因 |
|---|---|---|
| 内部微服务、高并发、强类型接口 | gRPC | IDL 与代码生成、HTTP/2、多语言支持 |
| 流式上传下载、双向流 | gRPC | 原生支持客户端流、服务端流和双向流 |
| 浏览器直接访问、开放 API | HTTP/JSON | 兼容性和可调试性更好,接入门槛较低 |
| CRUD 为主、性能压力一般 | HTTP/JSON | 开发、排障和运维通常更直接 |
| 同时服务内部与外部调用方 | 组合使用 | 内部使用 gRPC,网关对外提供 HTTP/JSON |
最终应基于真实业务模型测试,包括实际消息结构、鉴权、TLS、中间件、错误处理、网关、链路追踪和网络延迟。空业务处理函数的微基准适合解释协议栈成本,但不能替代生产容量测试。
九、参考资料
更多推荐




所有评论(0)