gRPC踩坑记录

使用 gRPC 的过程中,最大的感受就是:官方文档只告诉你怎么用,不告诉你怎么不死。本文记录我在 Node.js gRPC 开发中踩过的五个坑,每一个都让我 debug 到怀疑人生。

一、字段命名陷阱:snake_case vs camelCase

这是 gRPC 新手必踩的第一坑,也是本文最初的灵感来源。

假设你有一个 .proto 文件:

syntax = "proto3";

message User {
  string user_id = 1;
  string user_name = 2;
  int64 created_at = 3;
}

你兴冲冲地在 Node.js 服务端返回数据:

// 服务端
function getUser(call, callback) {
  callback(null, {
    user_id: '12345',
    user_name: '张三',
    created_at: 1689600000
  });
}

客户端收到的却是:

{ userId: '12345', userName: '张三', createdAt: 1689600000 }

反之亦然——客户端发送 camelCase,服务端收到 snake_case。

为什么会这样?

@grpc/proto-loader 在加载 .proto 文件时,默认启用了字段名转换:

const grpc = require('@grpc/grpc-js');
const protoLoader = require('@grpc/proto-loader');

const packageDefinition = protoLoader.loadSync('user.proto', {
  longs: String,
  enums: String,
  defaults: true,
  oneofs: true
});

解决方案

方案一(推荐):服务端和客户端统一使用 camelCase

callback(null, { userId: '12345', userName: '张三', createdAt: 1689600000 });
const response = await client.getUser({ userId: '12345' });
console.log(response.userName);

方案二:设置 keepCase: true,保持 snake_case

const packageDefinition = protoLoader.loadSync('user.proto', {
  keepCase: true,
  longs: String, enums: String, defaults: true, oneofs: true
});

方案三(不推荐):混用。 一旦涉及 oneof 字段,转换逻辑就可能失效——你会收获一坨 undefined。

字段名不一致时,gRPC 不会报错,只会静默地给你 undefined。教训:接手任何 gRPC 项目,第一件事就是确认 keepCase 配置。


二、四种服务类型的选择陷阱

2.1 Unary —— 一发一收

rpc GetUser(GetUserRequest) returns (User);

常见错误:用 Unary 实现「导出报表」接口,服务端需要 30 秒生成文件,客户端等到超时。应该用 Server Streaming。

2.2 Server Streaming —— 一发多收

rpc ExportReport(ExportRequest) returns (stream ReportChunk);
// 服务端
function exportReport(call) {
  for (let i = 0; i < 10; i++) {
    call.write({ chunk: `第 ${i + 1} 页报告数据...` });
  }
  call.end();
}
// 客户端
const stream = client.exportReport({ reportId: '2023-Q2' });
stream.on('data', (chunk) => console.log('收到:', chunk.chunk));
stream.on('end', () => console.log('导出完成'));

2.3 Client Streaming —— 多发一收

rpc UploadFile(stream FileChunk) returns (UploadResult);

适用场景:上传大文件、批量写入数据。

2.4 Bidirectional Streaming —— 多发多收

rpc Chat(stream ChatMessage) returns (stream ChatMessage);

最容易踩的坑:用 Bidirectional Streaming 实现简单轮询——客户端每 5 秒发心跳。没必要,用 Unary 就够了。


三、超时:不设 deadline 的请求等于定时炸弹

// 危险!服务端宕机时这个回调永远不会触发
client.getUser({ userId: '12345' }, (err, response) => { ... });

gRPC 默认没有超时。Node.js 事件循环不会报错,内存不会释放,直到 OOM。

const deadline = new Date();
deadline.setSeconds(deadline.getSeconds() + 5);
client.getUser({ userId: '12345' }, { deadline }, (err, response) => {
  if (err) {
    console.error('请求超时:', err.message);
    return;
  }
  console.log(response);
});

生产规则:简单查询 1-3s、复杂计算 5-10s、文件传输 30-60s、流式按场景设定。

全局默认超时拦截器:

function deadlineInterceptor(defaultTimeout) {
  return function(options, nextCall) {
    if (!options.deadline) {
      const deadline = new Date();
      deadline.setSeconds(deadline.getSeconds() + defaultTimeout);
      options.deadline = deadline;
    }
    return new nextCall(options);
  };
}

四、TLS 证书配置

4.1 生产环境禁用 insecure

const client = new userProto.User('localhost:50051', grpc.credentials.createInsecure());
// 仅限本地开发!

4.2 自签名证书的正确配置

const fs = require('fs');
const server = new grpc.Server();
server.bindAsync(
  '0.0.0.0:50051',
  grpc.ServerCredentials.createSsl(
    fs.readFileSync('ca.crt'),
    [{ cert_chain: fs.readFileSync('server.crt'), private_key: fs.readFileSync('server.key') }],
    true  // mTLS
  ),
  () => server.start()
);

const client = new userProto.User(
  'localhost:50051',
  grpc.credentials.createSsl(
    fs.readFileSync('ca.crt'),
    fs.readFileSync('client.key'),
    fs.readFileSync('client.crt')
  )
);

踩坑:不传 CA 证书时报错 UNAVAILABLE 或 DNS resolution failed——让你排查网络,其实是证书不被信任。

4.3 环境判断

const credentials = process.env.NODE_ENV === 'production'
  ? grpc.credentials.createSsl(fs.readFileSync('/etc/certs/ca.crt'), ...)
  : grpc.credentials.createInsecure();

五、大消息传输:4MB 的隐形墙

gRPC 默认最大消息 4MB。超过时服务端拒绝,客户端收到 RESOURCE_EXHAUSTED。

// 服务端
const server = new grpc.Server({
  'grpc.max_receive_message_length': 20 * 1024 * 1024,
  'grpc.max_send_message_length': 20 * 1024 * 1024
});
// 客户端
const client = new userProto.User('localhost:50051', credentials, {
  'grpc.max_receive_message_length': 20 * 1024 * 1024,
  'grpc.max_send_message_length': 20 * 1024 * 1024
});

调整限制只是权宜之计。真正的大数据传输应使用 Streaming 或引用传递。


总结

gRPC 的默认行为有太多反直觉的设计:

  1. 字段名会变魔术 —— 不确认 keepCase,数据不是你发送的数据
  2. 默认无超时 —— 没有 deadline 的请求是定时炸弹
  3. TLS 报错不透明 —— 证书问题伪装成网络问题
  4. 4MB 硬限制 —— 设计接口时就要考虑消息大小

踩坑不可怕,可怕的是踩了坑还不知道为什么。