承接的问题
前十一章把一条 gRPC 调用从建立连接到可观测都走了一遍,但有一个问题一直悬着:服务上线之后,契约怎么改?Book 少一个字段、多一个字段、某个字段从 int32 换成枚举,哪一种客户端会立刻崩?如果新旧两个版本必须同时在线,流量又该按什么规则分流?
版本策略的本质不是「加一个 v2 后缀」,而是把变更分成两类:线格式兼容的演进和必须并存的破坏性变更。前者应该在一个服务里悄悄完成,后者才值得付出双份代码、双份测试的代价。判断标准只有一条:旧客户端在不重新编译、不重新发版的前提下,还能不能正确解析新服务端的响应。
本节先拆开三种版本载体,再给出 linkd-v3 的兼容矩阵,最后落到两段代码:服务端如何拒绝「声明版本与实际方法不一致」的调用,网关如何按 x-api-version 选择 stub。
核心机制一:版本到底写在哪里
gRPC 的线格式由 Protobuf 决定,而 Protobuf 的兼容性由字段号 + 类型决定,和字段名、包名、服务名都没有关系。于是「版本」可以出现在三个完全不同的层次上,混用就会出问题。
载体一:proto package。 例如 plume.linkd.v3.catalog.v1 与 plume.linkd.v3.catalog.v2。包名进入 method 的完整路径,/plume.linkd.v3.catalog.v2.Library/GetBook 与 v1 是两个完全不同的方法,服务端可以同时注册。它适合破坏性变更,代价是消息类型也要跟着复制一份,或者写转换层。
载体二:service 名。 同一个 package 下放 Library 和 LibraryV2。method 路径同样不同,服务端同样能并存,但消息可以复用。它适合「只换一两个 RPC 的返回结构」的场景,缺点是同一个 package 里长期躺着两套服务定义,读 proto 的人容易分不清哪套是主线。
载体三:字段号。 在同一个 message 里追加字段、给旧字段标 reserved,不新增 service。它适合绝大多数日常演进:加一个 cover_url、加一个 page_count、加一个枚举值。只要不改字段号和类型,旧客户端会忽略不认识的字段,新客户端对旧服务端缺失的字段拿到默认值。
linkd-v3 的选择是:能用字段号解决的,绝不升 service;必须改语义或改类型时,才在同一 package 下新增 service,并把旧 service 标 deprecated。 包名整体升级留到「协议族换代」这种级别,不轻易使用。
x-api-version 是第四样东西,但它不是线格式的一部分。它只是调用方对「我以为自己在调哪个版本」的声明,供网关路由、指标分组和 mismatch 检测使用。服务端判断版本时,必须以实际 method 路径为准,不能拿 header 当契约。
核心机制二:只加不改不删
把兼容规则写成矩阵,评审时逐条对照,比口头约定可靠。
| 变更 | 线格式兼容 | 说明 |
|---|---|---|
| 追加字段(新字段号) | 是 | 旧端忽略,新端拿默认值 |
| 删除字段 | 否 | 必须先 reserved 字段号和字段名 |
| 改字段号 | 否 | 等同于删旧加新 |
| 改字段类型 | 否 | 即使宽度相同也不行,int32 与 sint32 的编码就不同 |
| 改字段名 | 是 | 线格式不变,但 JSON 映射、反射和代码可读性会受影响 |
| 追加枚举值 | 是 | 旧端收到未知值会保留在 unknown fields |
| 删除枚举值 | 否 | 必须 reserved,且旧值不得复用 |
| 追加 RPC | 是 | 旧客户端不调用即可 |
| 删除 RPC | 否 | 先标 deprecated,观察调用量归零再下线 |
| 改 RPC 的请求/响应类型 | 否 | 需要新 service 或新 method |
两个容易踩的细节。
第一,删除字段必须同时保留字段号和字段名。只写 reserved 6; 挡不住后来的人复用 6;只写 reserved "price"; 挡不住别人把 price 换个类型放回来。两个都写,才能让编译器在有人犯错时报错:
message Book {
reserved 6 to 9;
reserved "price", "currency";
string isbn = 1;
string title = 2;
string author = 3;
int32 stock = 4;
string cover_url = 5;
}
第二,枚举的 0 值必须留给 UNSPECIFIED,并且永远不赋予业务含义。proto3 的枚举第一个值必须是 0;如果 0 表示「平装」,那么旧客户端不传这个字段时,服务端会把「没填」误判成「平装」。新增枚举值只能追加在末尾,不能插在中间,也不能复用已删除的数值。
enum BookFormat {
BOOK_FORMAT_UNSPECIFIED = 0;
BOOK_FORMAT_PAPERBACK = 1;
BOOK_FORMAT_HARDCOVER = 2;
BOOK_FORMAT_EBOOK = 3;
}
字段号和枚举值是 wire 上的地址,一旦发布就属于公共契约。改它们不是「重构」,是换了一套协议。
核心机制三:v1 与 v2 并存的形态
假设业务要求把 Book.stock 从「一个整数」升级为「可售 + 预订」的结构。这是类型变更,字段号 4 不能直接换类型,只能新增 message 和 service。下面是 v2 的最小定义,放在同一个 package 里,复用请求类型:
syntax = "proto3";
package plume.linkd.v3;
option java_package = "com.plume.linkd.v3.api";
option java_multiple_files = true;
message StockLevel {
int32 available = 1;
bool backordered = 2;
}
message BookV2 {
string isbn = 1;
string title = 2;
string author = 3;
StockLevel stock = 4;
string cover_url = 5;
}
service LibraryV2 {
rpc GetBook(GetBookRequest) returns (BookV2);
}
注意 BookV2.stock 仍然占 4,但它在另一个 message 里,所以不违反「字段号不可改类型」。Book 保持原样,v1 客户端继续工作;v2 客户端调用 /plume.linkd.v3.LibraryV2/GetBook。两个 service 共享 GetBookRequest,因为请求没有变化。
服务端实现两个 BindableService,内部都委托给同一个领域对象,避免两套业务逻辑:
package com.plume.linkd.v3.server;
import com.plume.linkd.v3.api.Book;
import com.plume.linkd.v3.api.BookV2;
import com.plume.linkd.v3.api.GetBookRequest;
import com.plume.linkd.v3.api.LibraryGrpc;
import com.plume.linkd.v3.api.LibraryV2Grpc;
import com.plume.linkd.v3.api.StockLevel;
import io.grpc.stub.StreamObserver;
public final class CatalogService
extends LibraryGrpc.LibraryImplBase {
private final Catalog catalog;
public CatalogService(Catalog catalog) {
this.catalog = catalog;
}
@Override
public void getBook(GetBookRequest request,
StreamObserver<Book> observer) {
try {
CatalogItem item = catalog.find(request.getIsbn());
observer.onNext(Book.newBuilder()
.setIsbn(item.isbn())
.setTitle(item.title())
.setAuthor(item.author())
.setStock(item.available())
.build());
observer.onCompleted();
} catch (NotFoundException e) {
observer.onError(ErrorReasons.bookNotFound(request.getIsbn()));
}
}
}
final class CatalogServiceV2 extends LibraryV2Grpc.LibraryV2ImplBase {
private final Catalog catalog;
CatalogServiceV2(Catalog catalog) {
this.catalog = catalog;
}
@Override
public void getBook(GetBookRequest request,
StreamObserver<BookV2> observer) {
try {
CatalogItem item = catalog.find(request.getIsbn());
observer.onNext(BookV2.newBuilder()
.setIsbn(item.isbn())
.setTitle(item.title())
.setAuthor(item.author())
.setStock(StockLevel.newBuilder()
.setAvailable(item.available())
.setBackordered(item.backordered())
.build())
.setCoverUrl(item.coverUrl())
.build());
observer.onCompleted();
} catch (NotFoundException e) {
observer.onError(ErrorReasons.bookNotFound(request.getIsbn()));
}
}
}
两个实现都只做「领域对象到 proto」的映射,库存扣减、缓存、权限校验留在 Catalog 里。这样 v1 下线时删掉一个类即可,不会牵动业务代码。
注册时把两个 service 都挂到同一个端口:
Server server = NettyServerBuilder.forPort(9093)
.addService(new CatalogService(catalog))
.addService(new CatalogServiceV2(catalog))
.intercept(new ApiVersionInterceptor())
.build()
.start();
在 linkd-v3 里落地:声明必须与方法一致
网关会在 metadata 里带 x-api-version,但服务端不能盲信它。拦截器读取实际 method 的完整路径,推导出版本,再和 header 比对;不一致就立即拒绝,避免「header 写 v1、实际调了 v2」导致指标和计费错位。
package com.plume.linkd.v3.server;
import io.grpc.Metadata;
import io.grpc.ServerCall;
import io.grpc.ServerCallHandler;
import io.grpc.ServerInterceptor;
import io.grpc.Status;
public final class ApiVersionInterceptor implements ServerInterceptor {
private static final Metadata.Key<String> API_VERSION =
Metadata.Key.of("x-api-version", Metadata.ASCII_STRING_MARSHALLER);
@Override
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
ServerCall<ReqT, RespT> call,
Metadata headers,
ServerCallHandler<ReqT, RespT> next) {
String declared = headers.get(API_VERSION);
String method = call.getMethodDescriptor().getFullMethodName();
String actual = method.startsWith("plume.linkd.v3.LibraryV2/")
? "v2" : "v1";
if (declared != null && !declared.equals(actual)) {
call.close(Status.INVALID_ARGUMENT.withDescription(
"x-api-version=" + declared + " conflicts with " + method),
new Metadata());
return new ServerCall.Listener<ReqT>() {};
}
return next.startCall(call, headers);
}
}
header 缺省时按实际 method 处理,不拒绝,因为老客户端不一定带这个键。只有「显式声明了错误版本」才是调用方的 bug。拒绝用 INVALID_ARGUMENT 而不是 FAILED_PRECONDITION:这是请求参数错误,重试同一个请求没有意义。
客户端侧,网关按 header 选择 stub。版本只在一次调用的作用域内有效,用 Context 传递,避免把版本塞进每个方法签名:
package com.plume.linkd.v3.gateway;
import io.grpc.CallOptions;
import io.grpc.Channel;
import io.grpc.ClientCall;
import io.grpc.ClientInterceptor;
import io.grpc.Context;
import io.grpc.ForwardingClientCall;
import io.grpc.Metadata;
import io.grpc.MethodDescriptor;
public final class ApiVersionClientInterceptor implements ClientInterceptor {
private static final Metadata.Key<String> API_VERSION =
Metadata.Key.of("x-api-version", Metadata.ASCII_STRING_MARSHALLER);
private static final Context.Key<String> CURRENT =
Context.key("linkd.api-version");
public static Context withVersion(String version) {
return Context.current().withValue(CURRENT, version);
}
@Override
public <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall(
MethodDescriptor<ReqT, RespT> method,
CallOptions options,
Channel next) {
String version = CURRENT.get();
return new ForwardingClientCall.SimpleForwardingClientCall<>(
next.newCall(method, options)) {
@Override
public void start(Listener<RespT> listener, Metadata headers) {
if (version != null) {
headers.put(API_VERSION, version);
}
super.start(listener, headers);
}
};
}
}
网关调用时显式限定作用域:
public BookView getBook(String isbn, String version) {
if (!version.equals("v1") && !version.equals("v2")) {
throw new IllegalArgumentException("unsupported api version: " + version);
}
Context previous = ApiVersionClientInterceptor.withVersion(version).attach();
try {
if (version.equals("v2")) {
BookV2 book = v2Stub.withDeadlineAfter(800, TimeUnit.MILLISECONDS)
.getBook(GetBookRequest.newBuilder().setIsbn(isbn).build());
return BookView.from(book);
}
Book book = v1Stub.withDeadlineAfter(800, TimeUnit.MILLISECONDS)
.getBook(GetBookRequest.newBuilder().setIsbn(isbn).build());
return BookView.from(book);
} finally {
previous.attach();
}
}
Context 是线程绑定的。如果中间切换到线程池再调用 stub,attach() 的作用域不会自动过去,需要显式传播。gRPC 的 stub 调用本身会捕获当前 Context,所以只要在同一个线程里 attach 后立即发起调用,就能带上 header。
常见错误与边界
错误一:把 x-api-version 当契约。 客户端声明 v2,实际调用的 method 却是 v1,服务端如果只看 header 就会按 v2 的语义处理 v1 的请求。header 只能做路由提示和观测维度,method 路径才是事实。
错误二:改字段类型但保留字段号。 有人觉得 int32 改成 bool 只影响语义,实际上 wire type 从 varint 变成 varint 也一样会解析出错误值,更不用说 int32 与 string 的 wire type 不同。类型变更必须换字段号,旧号 reserved。
错误三:删除 proto 字段后直接复用字段号。 旧客户端可能还在发这个字段,新服务端会把旧数据解析成新含义。删除必须 reserved 号与名,至少保留到确认没有旧版本客户端在线。
错误四:v2 上线就删 v1。 移动端、合作方、内部脚本的升级节奏不可控。v1 要先标 deprecated,用指标观察每个 method 的调用量,归零并保留一个发布周期后再下线。
边界一:字段名兼容不等于 JSON 兼容。 protojson 默认用字段名做键,改名会改变 JSON 输出;如果网关直接把 proto 转 JSON 暴露给浏览器,改名就是破坏性变更。此时应使用 json_name 固定外部键名。
边界二:unknown fields 不是无限保险。 proto3 会保留未知字段,但经过某些 JSON 转换、手工拷贝或动态消息处理后会丢失。跨版本传递消息时,不要依赖 unknown fields 做业务判断。
边界三:一个 service 同时承载太多版本会拖慢启动。 每个 BindableService 都要注册 method、生成 descriptor,版本数量应有上限。超过三个大版本时,考虑拆成独立的 proto 模块和独立端口。
小结
- 版本有三种载体:package、service 名、字段号。日常演进用字段号,破坏性变更才新增 service,package 整体升级留给协议族换代。
- 兼容性的硬约束是字段号和类型:只加不改不删,删除必须
reserved号和名,枚举 0 值留给UNSPECIFIED。 - v1/v2 并存时,服务端注册两个 service、共享领域逻辑;
x-api-version只做声明,实际 method 路径才是版本事实,不一致就拒绝。 - 下线旧版本靠指标判断,不靠日期;字段名、JSON 映射、unknown fields 都有各自边界。
下一节把「少量流量切到 v2」落实到路由:按百分比、租户或请求 ID 哈希选版本,并给出回滚开关与指标对比口径。
阅读导航:上一节 11.3 追踪、采样与 exemplar | 下一节 12.2 灰度路由与流量切分
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。