gRPC 与 HTTP/JSON 性能对比:以 Go、Gin 和 gRPC 为例

本文比较的是两种具体实现:gRPC + Protobuf + HTTP/2Gin + 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/methodHTTP API:POST /process请求和响应字段保持一致
传输协议HTTP/2明文 HTTP/1.1两边均不启用 TLS
序列化ProtobufJSON均计入编码和解码耗时
连接复用 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)P50P99
单并发、小负载(1 / 5,000 / 100 B)gRPC2,6320.3800*3.574
Gin/HTTP4,7870.2090*1.131
高并发、小负载(100 / 50,000 / 100 B)gRPC51,9921.9201.5835.232
Gin/HTTP32,6243.0620.52340.037
高并发、较大负载(100 / 50,000 / 4 KiB)gRPC32,7093.0552.6287.848
Gin/HTTP16,9515.8951.44751.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 的吞吐量下降幅度更小,并表现出明显更稳定的尾延迟。

八、选型建议

场景更适合的方案主要原因
内部微服务、高并发、强类型接口gRPCIDL 与代码生成、HTTP/2、多语言支持
流式上传下载、双向流gRPC原生支持客户端流、服务端流和双向流
浏览器直接访问、开放 APIHTTP/JSON兼容性和可调试性更好,接入门槛较低
CRUD 为主、性能压力一般HTTP/JSON开发、排障和运维通常更直接
同时服务内部与外部调用方组合使用内部使用 gRPC,网关对外提供 HTTP/JSON

最终应基于真实业务模型测试,包括实际消息结构、鉴权、TLS、中间件、错误处理、网关、链路追踪和网络延迟。空业务处理函数的微基准适合解释协议栈成本,但不能替代生产容量测试。

九、参考资料

Logo

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

更多推荐