掌握gRPC核心原理与高效实战技巧
优化前:卡得不行
我接手这个项目的时候,前端是 Vue + TypeScript,后端是 Go 写的微服务,通过 gRPC 通信。本来以为这种组合性能应该不错,结果上线后用户反馈「列表加载慢」「操作没响应」。我自己点了几下,好家伙,一个简单的用户信息拉取接口,从点击到出数据要 4-5 秒,某些批量操作甚至直接超时。
更离谱的是,并发稍微高一点,服务器 CPU 直接飙到 90% 以上,日志里一堆 context deadline exceeded 和 transport is closing。这哪是 gRPC,简直是 grPC——龟速。
找到瘼颈了!
一开始我以为是网络问题,毕竟跨机房调用。但我把服务都部署在本地 Docker 里跑,还是慢。那就不是网络的事儿了。
我上了 grpcurl 直接调接口测延迟,发现单次调用平均 1.8s,最高的能到 3s。接着我开了 gRPC 的 WithInsecure() 和 WithBlock(),加了拦截器打日志,发现大部分时间花在序列化和反序列化上。
再一查 proto 文件,好家伙,有个接口返回的是嵌套 5 层的结构体,里面还有 repeated 字段,总字段数超过 80 个,传输的数据包动辄几 MB。客户端那边解析也慢,尤其是移动端,直接卡死。
我还用 pprof 跑了一下后端服务,发现 CPU 占用最高的居然是 proto.Marshal 和 jsonpb 的转换逻辑。原来是我们在中间层为了兼容老系统,硬是把 gRPC 返回转成 JSON HTTP 输出了一层……这一来一回,序列化两次,纯属自废武功。
动刀优化:这几个改动最狠
我先砍掉了中间那层 JSON 转换,让前端直接走 gRPC-web。这个改完,单次调用降到 1.2s 左右。虽然还有点慢,但至少有救了。
然后我重点优化了 proto 定义。原来的写法太“全量”了,不管前端需不需要,一股脑全塞进去。我跟产品对了一遍,发现 80% 的字段其实只在管理后台用,普通用户根本看不到。于是我把大结构拆了,做了按场景返回的多个 message。
优化前的 proto:
message UserInfo {
string id = 1;
string name = 2;
string email = 3;
string avatar = 4;
repeated Role roles = 5;
repeated Permission permissions = 6;
map<string, Setting> settings = 7;
repeated LoginLog login_logs = 8;
// 还有几十个……
}
优化后拆成:
message UserBaseInfo {
string id = 1;
string name = 2;
string avatar = 3;
}
message UserPublicInfo {
string id = 1;
string name = 2;
string avatar = 3;
int32 follower_count = 4;
bool is_following = 5;
}
message UserAdminInfo {
string id = 1;
string name = 2;
string email = 3;
repeated Role roles = 4;
map<string, Setting> settings = 5;
repeated LoginLog login_logs = 6;
}
然后在 service 里根据角色和请求来源返回不同的 message。光这一招,数据体积从平均 2.3MB 降到 80KB 以内。
客户端别傻等:流式处理 + 缓存
另一个问题是,前端一次性请求太多数据,比如一个接口要拉 1000 条记录,gRPC 一气呵成返回,客户端内存直接爆了。
我改成 server-side streaming:
service DataService {
rpc ListItems(ListItemsRequest) returns (stream ListItem);
}
Go 后端这么写:
func (s *dataService) ListItems(req *ListItemsRequest, stream DataService_ListItemsServer) error {
rows, err := db.Query("SELECT id, name FROM items LIMIT 1000")
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var item ListItem
if err := rows.Scan(&item.Id, &item.Name); err != nil {
return err
}
// 边查边发,不攒着
if err := stream.Send(&item); err != nil {
return err
}
}
return nil
}
前端用 gRPC-web 的 stream 支持,一边接收一边更新 UI,用户体验从「卡住 5 秒」变成「数据滚动出现」,感知上快多了。
另外我加了简单的内存缓存,用 lru.Cache 缓住高频请求。比如用户详情页,10 秒内重复请求直接走缓存。配合 gRPC 的 deadline 控制,避免雪崩。
连接复用不能少
原来前端每次调用都新建一个 gRPC 连接,而且没设超时。这就导致大量 TIME_WAIT 连接堆积,后端负载飙升。
我把客户端改成全局复用一个 conn:
// grpc-client.js
let client = null
export function getGrpcClient () {
if (!client) {
client = new UserServiceClient('https://jztheme.com', null, {
'grpc.http2.throttle': false,
'grpc.default_authority': 'jztheme.com'
})
}
return client
}
同时在 Go 服务端启用了 Keepalive:
kaep := keepalive.EnforcementPolicy{
MinTime: 5 * time.Second,
PermitWithoutStream: true,
}
kasp := keepalive.ServerParameters{
MaxConnectionIdle: 15 * time.Second,
MaxConnectionAge: 30 * time.Second,
MaxConnectionAgeGrace: 5 * time.Second,
Time: 5 * time.Second,
Timeout: 2 * time.Second,
}
server := grpc.NewServer(
grpc.KeepaliveEnforcementPolicy(kaep),
grpc.KeepaliveParams(kasp),
)
连接数从峰值 3000+ 降到了 200 左右,服务器压力肉眼可见地小了。
性能数据对比
改完之后我重新压测了一波,用 ghz 跑 100 并发持续 1 分钟,结果如下:
- 平均延迟:从 1800ms → 86ms
- TPS:从 57 → 1140
- 99% 延迟:从 3200ms → 210ms
- 服务 CPU 占用:从 85%-95% → 20%-35%
- 单次传输体积:从 2.3MB → 平均 68KB
最关键的是,用户不再投诉了。之前说「点一下要等半天」,现在都说「怎么突然变丝滑了」。
还有点小毛病,但不影响大局
也不是所有都完美。gRPC-web 在某些旧浏览器上有兼容问题,我们最终上了 envoy 做代理转换。另外 streaming 模式下错误处理要小心,曾经因为一个 panic 导致整个 stream 断掉,后来加了 recover 才稳住。
还有一个坑是 metadata 传递,我们用 JWT 做认证,一开始放在 header 里传,但 gRPC-web 对 custom header 有限制,折腾了半天才发现要用 Authorization 标准头。
总结:别让 gRPC 变“龟速”
gRPC 性能本身不差,但用不好反而比 REST 还慢。我的经验是:
- 别传大结构,按需返回
- 别频繁建连,复用是王道
- 别忽略流式能力,尤其大数据量
- 别省序列化开销,proto 就该好好设计
这次优化前后花了大概一周,主要是前期定位花时间。改完后系统稳了,我也能睡个好觉了。
以上是我踩坑后的总结,希望对你有帮助。有更好的方案欢迎评论区交流。

暂无评论