接口设计——RESTful vs RPC,版本兼容性 悲剧


《Go工程踩坑实录》第3期。前两期聊了数据库连接池和日志规范,本期说说我职业生涯里回滚次数最多的一类事故:接口版本不兼容。


00 引子:凌晨3点的回滚

那天凌晨,告警群炸了。

下游服务大面积报错,订单接口返回 "vendor_id is empty"。我们紧急回滚,排查了40分钟才发现根因——不是代码逻辑bug,是一个字段的兼容性

前一天下午,我新增了 vendor_id(供应商ID)字段。心想"不传就默认空呗",Protobuf 3 里所有字段本来也都是 optional。但业务校验层我顺手写了:

if req.VendorId == "" {
    return fmt.Errorf("vendor_id is empty")
}

问题就是这一行。老客户端没传这个字段,反序列化后默认空字符串,服务端直接拒绝。

核心冲突:接口设计不是"能调通就行",而是兼容性工程


01 RESTful 还是 RPC?这不是信仰问题

我见过太多团队为了"RESTful 更优雅"还是"gRPC 更先进"争得面红耳赤。其实这不是信仰问题,是成本问题

1.1 两种范式的本质区别

维度 RESTful RPC(gRPC/Thrift)
通信模型 资源导向(URI+HTTP Method) 动作导向(函数调用)
协议层 HTTP/1.1 或 HTTP/2 HTTP/2(gRPC)或自定义
序列化 JSON(可读但臃肿) Protobuf(紧凑但二进制)
流支持 SSE / WebSocket(额外工作) 原生双向流(gRPC)
调试成本 低(curl 就能测) 高(需要 grpcurl 或反射)
吞吐量 中(JSON 序列化开销大) 高(Protobuf + HTTP/2,同等机器下通常是 JSON 的 2-5 倍
浏览器亲和 直接支持 需要 gRPC-Web 网关

一句话总结:RESTful 是人读的,RPC 是机器读的

1.2 决策树:你的场景该选谁?

别拍脑袋,按这个流程走:

需要浏览器直接访问?
  └─ 是 → RESTful(JSON over HTTP)
  └─ 否 → 继续看

需要强类型 + 多语言 SDK(Java/Python/Node 混编)?
  └─ 是 → gRPC + Protobuf(自动生成各语言 SDK)
  └─ 否 → 继续看

内部高频通信,带宽是瓶颈?
  └─ 是 → gRPC + Protobuf(二进制体积小,HTTP/2 多路复用)
  └─ 否 → 继续看

内部服务通信,且团队全 Go?
  └─ 是 → 可选 go-zero 的 zRPC / Kitex(性能+治理)
  └─ 否 → RESTful 保平安

金句:选 RESTful 不是因为"简单",是因为调试成本低;选 gRPC 不是因为"先进",是因为契约即代码。一句话:RESTful 赢在生态,gRPC 赢在效率

1.3 Go 生态的混合实践

现实中,成熟的架构通常是分层选型

  • 对外网关:RESTful(OpenAPI/Swagger)—— 前端、第三方、移动客户端直接调
  • 内部服务:gRPC + Protobuf —— 强类型、高性能、自动生成代码
  • BFF 层:RESTful 转 gRPC —— 用 grpc-gateway 或自研网关做协议转换

反模式警示

  • ❌ 内部服务用 RESTful + JSON,性能损失 30%+
  • ❌ gRPC 服务直接暴露给前端,调试地狱
  • ❌ 一套接口同时服务内部和外部,两边都别扭

对外暴露 RESTful,内部走 gRPC,中间用网关翻译——这是 Go 生态里最稳妥的架构。


02 版本兼容性:这不是"加字段"那么简单

2.1 血案复盘:一个字段引发的事故

回到开头的回滚事件。完整的错误示范是这样的:

// v1.0
message CreateOrderRequest {
    string user_id = 1;
    int64 amount = 2;
}

// v1.1 —— 致命错误
message CreateOrderRequest {
    string user_id = 1;
    int64 amount = 2;
    string vendor_id = 3;
}

Protobuf 3 语法上,vendor_id 确实是 optional 的。但业务代码里我做了校验:

if req.VendorId == "" {
    return fmt.Errorf("vendor_id is empty")
}

老客户端(v1.0)发请求时根本没这个字段,反序列化后默认空字符串。服务端收到空字符串,直接返回错误

最坑的地方:这不是协议层崩溃,不是 panic,不是 500。是业务层的 400 错误。日志里没有堆栈,只有一行 "vendor_id is empty"。排查了 40 分钟才意识到是发布顺序问题——服务端先发了,客户端还没升级

核心教训if req.VendorId == "" 这种零值判断,在生产环境里就是兼容性炸弹。新增字段的默认值逻辑,决定了是"优雅兼容"还是"凌晨回滚"。

2.2 Protobuf 版本兼容的铁律

Protobuf 的向前兼容和向后兼容是两回事:

向前兼容(老客户端读新服务端)

  • ✅ 只新增字段,不删除旧字段
  • ✅ 新增字段给默认值,不要做强校验
  • ✅ 字段编号(=1, =2)永久不可复用
  • ✅ 不修改已有字段的类型(int32 → int64 是灾难)

向后兼容(新客户端读老服务端)

  • ✅ 新字段用零值判断,或使用 optional 关键字(Protobuf v3.15+)配合 HasXxx() 方法
  • ✅ 默认值逻辑要兜底

技术细节:Protobuf 3 早期版本(v3.0-v3.14)去掉了 has_xxx()optional 关键字,v3.15 后重新引入。如果你用较新版本,可以显式标记 optional string vendor_id = 3;,生成的 Go 代码会是指针类型,并用 HasVendorId() 判断字段是否传入。如果不确定版本,稳妥做法是用零值判断 + 默认值兜底。

正确的做法:

// v1.1 —— 正确
message CreateOrderRequest {
    string user_id = 1;
    int64 amount = 2;
    string vendor_id = 3;  // Protobuf 3 本身就是 optional
}
// 服务端兼容处理
if req.VendorId == "" {
    req.VendorId = defaultVendorID  // 兜底,不要直接报错
}

关键认知:Protobuf 的 optional 只是语法层面的,业务层的校验才是兼容性杀手。

2.3 RESTful 的版本管理策略

RESTful 接口有三种版本方案:

方案 示例 优点 缺点
URL Path /v1/orders, /v2/orders 直观,CDN 友好 URL 碎片化
Header Accept: application/vnd.api.v2+json URL 干净 调试不方便
参数 /orders?api-version=2 简单 不符合 REST 语义

推荐 URL Path 版本

// 路由注册
r.Route("/v1", func(r chi.Router) {
    r.Post("/orders", v1.CreateOrder)
})
r.Route("/v2", func(r chi.Router) {
    r.Post("/orders", v2.CreateOrder)  // 支持 vendor_id
})
// v1 handler 做兼容处理
func CreateOrder(w http.ResponseWriter, r *http.Request) {
    defer r.Body.Close()
    
    req := &CreateOrderRequest{}
    if err := json.NewDecoder(r.Body).Decode(req); err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    
    // 老版本没传 vendor_id,用默认值兜底
    if req.VendorId == "" {
        req.VendorId = defaultVendorID
    }
    
    // 同时打 deprecated 日志,推动客户端迁移
    slog.Warn("v1 create order called", "client_ip", r.RemoteAddr)
}

v1 和 v2 不是"共存",是"过渡"。老版本要设 Sunset 日期,到期强制下线。


03 字段设计的隐形炸弹

3.1 枚举:默认值陷阱

enum OrderStatus {
    PENDING = 0;    // 危险!0 是默认值
    PAID = 1;
    SHIPPED = 2;
}

新增 CANCELLED = 3 后,老客户端收到 3 不认识。有些语言的 Protobuf 库会 panic,有些会跳过。问题根源:0 被占用了,没有预留"未知状态"。

正确做法:

enum OrderStatus {
    UNKNOWN = 0;    // 安全的占位符
    PENDING = 1;
    PAID = 2;
    SHIPPED = 3;
    CANCELLED = 4;  // 新增往后加
}

如果后续删除了 CANCELLED 状态,也要用 reserved 保护编号:

enum OrderStatus {
    UNKNOWN = 0;
    PENDING = 1;
    PAID = 2;
    SHIPPED = 3;
    reserved 4;           // 冻结编号,防止复用
    reserved "CANCELLED"; // 冻结名称(但名称仍可能被误用,需团队约定)
}

注意:Enum 的 reserved 只能保护编号不被复用,无法强制阻止其他人在别处定义同名常量。团队规范 + Code Review 才是最终防线。

// 客户端必须处理 default 分支
switch status {
case pb.OrderStatus_PENDING:
    // ...
case pb.OrderStatus_PAID:
    // ...
default:
    // 不认识的状态,记录日志,不要崩溃
    slog.Warn("unknown order status", "status", status)
}

3.2 时间字段:string 还是 int64?

内部服务通信用 int64(Unix 毫秒),对外 API 用 string(ISO8601)。

// 内部服务
int64 created_at_ms = 4;  // 1705312981000

// 对外 API
string created_at = 4;    // "2024-01-15T09:23:01Z"

理由:内部服务追求性能(整数比字符串解析快),对外追求可读性。

3.3 金额:永远不要用 float

// 永远不要这么干
float64 amount = 19.99  
// 19.99 在二进制浮点里无法精确表示,实际存储约为 19.989999999999998436...

// 正确做法:分为单位
int64 amount_cents = 1999

// 或字符串(对外 API)
string amount = "19.99"

金融场景下,float 的精度误差是致命bug。用整数分或字符串,不要赌运气。


04 接口变更的流程:不是改完就发

4.1 变更分级

我把接口变更分成四级,对应不同的发布策略:

级别 变更内容 兼容性 处理方式
L1 新增可选字段 完全兼容 直接发,通知下游
L2 新增必填字段(有默认值) 向前兼容 发,老客户端无感知
L3 修改字段语义(如秒变毫秒) 不兼容 必须发新版本(v1→v2)
L4 删除字段 / 修改类型 不兼容 必须发新版本,老版本保留 3 个月

4.2 发布 checklist

每次改接口前,过一遍这个清单:

  • 是否修改了已有字段的类型或编号?
  • 新增字段是否有默认值?
  • 老客户端不升级能否正常工作?
  • 是否通知了所有下游团队?
  • 是否准备了回滚方案?

金句:接口发布不是"我改完了",是"下游确认没问题了"。

4.3 兼容性测试:自动化兜底

在 CI 里引入 buf 工具链,它是目前 Go 生态处理 Protobuf 兼容性的行业标准:

# 检测 .proto 变更是否破坏向后兼容
buf breaking --against '.git#branch=main'

buf breaking 会自动检测:是否删除了字段、是否修改了字段类型、是否复用了已删除的编号。比人工 Review 更可靠。

同时,在单元测试里用旧版本的数据验证新服务端:

// 兼容性测试:模拟半年前的旧客户端
func TestCreateOrder_Compatibility(t *testing.T) {
    // 旧版本 Client 发出的 JSON(没有 vendor_id 字段)
    oldRawJSON := `{"user_id": "123", "amount": 100}`
    
    req := &pb.CreateOrderRequest{}
    err := json.Unmarshal([]byte(oldRawJSON), req)
    require.NoError(t, err)

    // 业务逻辑验证:即使没传 vendor_id,校验层也不应报错
    err = business.ValidateOrder(req)
    assert.NoError(t, err, "旧版请求应通过基本校验")
    assert.Equal(t, "default_vendor", req.VendorId, "应该自动填充默认供应商")
}

这个测试要每次改 .proto 时自动跑。老请求能通,新字段有兜底,才是安全的发布。


05 面试官爱问的2道题

Q1:Protobuf 3 所有字段都是 optional,为什么还说"不要删字段"?

参考答案:

  • 语法层面确实都是 optional,但业务语义上字段有必填逻辑
  • 删除字段后,老客户端发的旧字段编号会被服务端忽略。如果该字段是业务关键字段(如金额),服务端拿到零值(0/""),可能导致数据错误
  • 正确做法:用 reserved 冻结编号和名称
reserved 3;
reserved "vendor_id";

Q2:微服务 A 升级了接口,B 没升级,怎么保证不挂?

参考答案:

  1. 契约优先:接口定义(.proto / OpenAPI)是唯一的真理来源,代码从契约生成
  2. 兼容性检查 CI:每次改 .proto 自动跑 buf breaking 检查,它能检测删除字段、修改类型、复用编号等破坏性行为
  3. 金丝雀发布:先发布 1% 流量,观察下游错误率
  4. 优雅降级:服务端对缺失字段用默认值,而不是直接拒绝
  5. 版本共存期:老版本保留至少 2-3 个 Sprint,给下游迁移窗口

记住这三条铁律

  1. 新增字段往后加,删除字段用 reserved
  2. 不要改已有字段的类型和语义,要改就发新版本
  3. 发布接口前,先用老请求打新服务端——通了再发

接口是服务的契约。契约不守,半夜回滚。

现在可以做的3件事

  1. 检查你项目的 Protobuf,有没有被删除但编号未 reserved 的字段
  2. 看看最近一次接口变更,下游团队是否提前知道
  3. 在 CI 里加上兼容性测试,用老请求打新服务端

你们项目遇到过接口兼容性事故吗?是怎么发现的?评论区见。

下期预告:《配置管理——环境变量、配置文件、配置中心,我到底该听谁的?》

顺便问一句:你们公司用 Apollo、Nacos、还是直接上 K8s ConfigMap?或者更简单粗暴——环境变量硬编码?留言说说,我看看哪种方案踩坑最多。


本文完。如果对你有用,点个「在看」等于告诉微信"这货值得推给别人"。

wx

关注公众号

©2017-2023 鲁ICP备17023316号-1 Powered by Hugo