想象你接手了一家即将连锁扩张的餐厅。三家分店要开在不同的城市,后厨用不同的方言、前台用不同的收银系统。你干的第一件事不是招人,而是编一本统一的“菜单规格书”:每道菜的编号、食材、分量、出菜顺序全部写死,所有分店都按它执行。紧接着,你设计了传菜电梯(一条通道同时传很多菜)、门口验票员(查会员卡)、记账先生(记录每位客人进出);客人等太久,服务生会看表催菜,催三次还不上就换菜;厨师长每天巡查哪家灶台能开火、谁在装病;打烊时先挂“停止接单”的牌子,等最后一桌客人吃完才关灯;总部还备了一本“分店名录”,每30秒各店汇报一次“我还开着”;大堂经理则负责把订单轮流派给不超载的分店。这套从“菜单规格书”到“派单管理”的完整体系,就是 gRPC 微服务开发与治理的缩影。你接下来要学的每一个概念,都已经在这家餐厅里悄悄上演过了。

岗位关联

掌握 gRPC 服务定义与治理能力,是后端工程师从“会写接口”走向“能搭生产级微服务”的关键一步。无论你用的是 Go 还是 Java,这套方法论都能直接落地到真实项目中。

目录
  1. 可视化演示
  2. 服务定义规范
  3. 通信机制原理
  4. 拦截器治理
  5. 超时与重试
  6. 健康检查
  7. 优雅退出
  8. 服务注册发现
  9. 负载均衡
  10. AI 应用场景
  11. Python 演示
  12. 常见误区
  13. 面试题

可视化演示

动态演示gRPC微服务开发与治理实践的核心机制,通过架构/数据/状态/性能四种模式展示关键概念与交互流程。

本节配有交互动效(进入视口后自动加载,约 3KB)。

gRPC微服务开发与治理实践:gRPC gRPC服务定义规范与Prot gRPC拦截器与日志鉴权中间件 核心机制可视化 gRPC微服务开发与治理实践整体架构与关键组件

服务定义规范

连锁餐厅扩张的第一件事,不是招厨师,而是统一菜单。每家分店、每个供应商、每套收银系统,都按同一本规格书办事:菜品编号、食材、分量、出菜顺序全部写死。这本规格书就是 gRPC 里的 .proto 文件——它是整个微服务体系的“契约”。

单体应用时代,两个模块在一个进程里,函数直接互相调用,接口长什么样大家心里有数。微服务拆开后,服务可能用 Go 写、用 Java 写、用 Python 写,必须把“接口长什么样”固化下来。.proto 用中立的 IDL(接口描述语言)定义消息和 RPC 方法,再由 protoc 工具一键生成各语言代码。这样,服务端和客户端虽然语言不同,却像两家分店拿着同一本菜单,永远对得上。

proto3 语法很简洁:message 定义请求/响应的结构,每个字段由“类型 + 名称 + 唯一编号”构成。字段编号是线上传输时的身份证,一旦发布就绝不能改,否则新旧版本会鸡同鸭讲。语法还支持四种 RPC 方法:普通一元调用、服务端流、客户端流、双向流,对应“点一盘菜、端一桌菜、客边点厨边做”等不同场景。

protobuf 在传输时把字段编号和线缆类型拼成一个 key,规则如下:

有了统一规格书,后厨按新做法做菜、前台按规格书点单,两端再无错位。“契约先行”换来的是团队并行开发互不阻塞——这正是微服务的第一块基石。

key=(field_number≪3)  ∣  wire_type\text{key} = (\text{field\_number} \ll 3) \;|\; \text{wire\_type}
这个公式说明:每个字段在二进制流里的“门牌号”由字段编号左移三位后,再与线缆类型拼接而成。收发双方靠它唯一认出字段。
  1. 字段编号 field_numberproto 里每个字段从 1 开始递增编号,它是字段在二进制流中的身份证,发布后不可修改。
    field_number∈{1,2,3,… }\text{field\_number} \in \{1, 2, 3, \dots\}
  2. 左移三位把字段编号乘以 8,为低三位腾出空间,等价于编程里的 << 3 运算。
    field_number≪3=field_number×8\text{field\_number} \ll 3 = \text{field\_number} \times 8
  3. 线缆类型 wire_type低三位表示字段的编码方式,0 是变长整数,1 是 64 位定长,2 是长度前缀型。
    wire_type∈{0,1,2,3,4,5}\text{wire\_type} \in \{0, 1, 2, 3, 4, 5\}
  4. 按位或合并把高位的字段号与低位的线缆类型拼成完整的 key,消息接收方按 key 反解出字段。
    key=high_bits∨low_bits\text{key} = \text{high\_bits} \lor \text{low\_bits}

通信机制原理

规格书写好了,菜怎么传?传统 HTTP/1.1 像老式传菜窗口,一次只能传一盘菜,传完还得重新开窗。gRPC 则用上了 HTTP/2 的“传菜电梯”:一个电梯井里多个轿厢(stream)同时上下,门住复用做到极致。同一时刻,成百上千个 RPC 在一条 TCP 连接上并肩而行。

这就是 gRPC 选型 HTTP/2 的原因:多路复用消灭了“一个请求一个连接”的浪费。同时,数据用 protobuf 二进制编码,比 JSON 文本小一个数量级,序列化也更快。一个 RPC 的完整旅程被分成四段:客户端把对象编码成二进制、跨越网络、服务端处理业务、再解码回对象。用户感知的延迟,是这四段之和。

优化延迟时,先要看清哪一段占比最大。跨机房场景里网络往往是绝对大头,所以很多团队选择就近部署或压缩消息;而二进制编码的红利,体现在同样吞吐下 CPU 占用更低、带宽消耗更小。理解这个分解式,你就能在排障时准确说出“慢在网络还是慢在服务”。

传菜电梯不是魔法,但它把“一窗一菜”改成了“一井多厢”,高峰翻台率立刻不同。理解了这四段延迟,你就知道 gRPC 为什么快,也知道快在哪里。

Trpc=Tserialize+Tnetwork+Tserve+TdeserializeT_{\text{rpc}} = T_{\text{serialize}} + T_{\text{network}} + T_{\text{serve}} + T_{\text{deserialize}}
一个 RPC 请求的端到端耗时,等于序列化、网络传输、服务端处理、反序列化四段耗时的总和。
  1. 序列化耗时把内存对象变成 protobuf 二进制的时间,消息越大、字段越多,耗时越久。
    Tserialize∝message_sizeT_{\text{serialize}} \propto \text{message\_size}
  2. 网络耗时数据包跨机器、跨机房的往返时间,包含排队、传输与传播延迟,通常是最大头。
    Tnetwork=RTT+TqueueT_{\text{network}} = \text{RTT} + T_{\text{queue}}
  3. 服务处理耗时服务端执行真实业务逻辑的时间,包括查数据库、调下游等。
    Tserve=Tbiz+TdownstreamT_{\text{serve}} = T_{\text{biz}} + T_{\text{downstream}}
  4. 反序列化耗时接收方把二进制还原成内存对象的时间,GPU、CPU 性能影响明显。
    Tdeserialize≈TserializeT_{\text{deserialize}} \approx T_{\text{serialize}}

拦截器治理

每家门口都站着两道人:记账先生把每位客人的进出时间记在本上,这是日志拦截器;验票员核对会员卡和预约信息,这是鉴权拦截器。他们不碰后厨的锅,但每位客人进门都要过这一关——这种“横切”在 gRPC 里就叫拦截器(interceptor)。

单体时代,日志和鉴权代码散落在每个业务方法里,改了十处漏了一处。gRPC 允许你在客户端和服务端注册拦截器,在 RPC 调用前后统一插入逻辑。常见职责包括:记录日志、校验 token、注入链路追踪 ID、捕获 panic、做限流。一个请求从进入服务端开始,要依次穿过这些拦截器,最后才到达业务方法。

多个拦截器像洋葱一样一层套一层:最外层先执行“进入逻辑”,然后调用内层,直到最核心的业务处理完成,再逐层执行“退出逻辑”。这种组合方式让治理能力可按需拼装——先鉴权再记日志,还是先记日志再鉴权,取决于你希望非法请求要不要留下痕迹。

门迎和验票员不炒菜,但缺了他们,连锁店开十家就有十家烂账。拦截器的精髓,正是把治理能力横切到所有接口,让业务代码保持干净。

C(x)=f1(f2(⋯fn(h(x))⋯ ))C(x) = f_1\bigl(f_2\bigl(\cdots f_n(h(x))\cdots\bigr)\bigr)
多层拦截器形成的洋葱模型:请求从最外层拦截器进入,层层向内,最终执行业务函数,再原路返回。
  1. 业务处理函数 h整条链的最内层是真实的 RPC 服务方法,它是被包裹的“菜芯”。
    h:request→responseh: \text{request} \to \text{response}
  2. 逐层包裹每个拦截器把下一层包住,外层先执行请求前的动作(如验票、记录时间)。
    fi(next)=before→next→afterf_i(\text{next}) = \text{before} \to \text{next} \to \text{after}
  3. 洋葱式返回从里向外依次执行每个拦截器的退出动作,整个执行轨迹如同一颗洋葱的切面。
    C(x)=f1(f2(⋯h(x)⋯ ))C(x) = f_1(f_2(\cdots h(x)\cdots))

超时与重试

客人点了菜,厨房迟迟不上。服务生不会干等——他看表:5 分钟催一次,再 10 分钟催一次,催三次还不上就换菜或退菜。这个节奏就是“超时 + 重试”。但如果客人每 10 秒催一次,后厨全员手忙脚乱,反而更慢。

gRPC 里,每个 RPC 都应该设置 deadline(超时时间),绝不能让调用无限等下去。超时之后选择重试,但重试会放大请求量——一个请求失败后,一百个客户端同时重试,就是一场“重试风暴”。因此必须配合指数退避:第一次等 0.2 秒,第二次等 0.4 秒,第三次等 0.8 秒,给下游留出喘息时间。

指数退避还有个封顶值,防止等待时间长到影响体验。更关键的一条铁律:只有幂等接口才值得重试。“幂等”意思是重复执行和只执行一次结果相同——比如查询、冻结操作;而“扣款”这类非幂等操作,重试可能导致重复扣钱。用 Go 时,ctx.WithTimeout 会把超时传给整条调用链,如果整体 deadline 已所剩无几,重试也应主动让位。

好大堂经理懂得“催一催,歇一歇”。超时重试不是死磕,而是有节奏的博弈——既保障客人体感,又不把后厨逼到停摆。

Twait=min⁡(Tmax,  Tbase×2i)T_{\text{wait}} = \min\bigl(T_{\text{max}},\; T_{\text{base}} \times 2^{i}\bigr)
第 i 次重试前的等待时间,按基础值指数翻倍,但最多不超过封顶值 T_max。
  1. 基础等待 T_base第一次重试前的最短等待时间,通常设为 0.1~0.5 秒。
    Tbase=0.2 sT_{\text{base}} = 0.2\,\text{s}
  2. 指数增长 2^i第 i 次重试的等待时间翻倍,让下游有时间恢复。
    Twait(i)=Tbase×2iT_{\text{wait}}(i) = T_{\text{base}} \times 2^{i}
  3. 封顶上限 T_max等待时间最长不超过 T_max,避免退避时间过长拖垮体感。
    Twait=min⁡(Tmax,Tbase×2i)T_{\text{wait}} = \min(T_{\text{max}}, T_{\text{base}} \times 2^{i})

健康检查

厨师长每天都要巡查各家分店:“你在岗吗?”——这是存活探针;“你的灶台能开火吗?”——这是就绪探针。有人在岗但灶台坏了,绝不能安排他接单。总部的调度系统,全靠这份巡查记录决定把客人引到哪家店。

微服务里,健康检查是注册中心和负载均衡的基础。gRPC 定义了标准健康检查协议 grpc.health.v1:服务端实现 Health 服务,客户端或探针周期性调用 Check/Watch,返回状态只有两种——SERVING(可以服务)和 NOT_SERVING(不能服务)。Kubernetes 里的 liveness 和 readiness 探针,正是站在 gRPC 健康协议两侧。

存活性回答“进程是否还活着,要不要重启”,就绪性回答“是否具备服务能力,要不要摘流量”。二者常常同时配置:数据库连接池耗尽时,进程活着但就绪失败,流量被摘除,服务不崩溃、也不误报。判定逻辑通常用连续成功/失败的计数来消除网络抖动带来的误判,而不是看单次结果。

厨师长巡查不是走过场:把“人活着”和“能干活”分开记录,调度系统才能准确地把客人往哪家店引。这是微服务稳定性的第一道防线。

S(t)={serving,succ(t)≥Nnot_serving,fail(t)≥MS(t)=\begin{cases}\text{serving}, & \text{succ}(t) \ge N\\\text{not\_serving}, & \text{fail}(t) \ge M\end{cases}
健康状态采用“连续计数”判定:连续成功 N 次才转为可服务,连续失败 M 次才转为不可服务,避免偶发抖动导致误判。
  1. 连续成功计数每次健康检查通过就加一,计数达到 N 才判定为可以服务。
    succ(t)=succ(t−1)+1\text{succ}(t) = \text{succ}(t-1) + 1
  2. 连续失败计数检查失败连续累计 M 次才判定为不可服务,单次超时不会误伤。
    fail(t)=fail(t−1)+1\text{fail}(t) = \text{fail}(t-1) + 1
  3. 状态机转换从 serving 到 not_serving 或反向,都需越过阈值才切换。
    S∈{serving,  not_serving}S \in \{\text{serving},\; \text{not\_serving}\}

优雅退出

打烊从来不是拉闸断电。一家好餐厅会在门口挂出“停止接单”的牌子,让最后一桌客人安心吃完,后厨收拾停当,才熄灯关门。直接断电的结果,是吃一半的客人掀桌。发布和扩缩容时同理。

进程收到 SIGTERM 信号后,如果立刻退出,所有在途 RPC 全部中断,调用方会看到一串“连接被重置”。优雅退出有一套固定流程:先把实例从注册中心摘除或标记为不健康,新流量不再进来;再关闭监听端口;然后等待已经在途的请求处理完成;最后释放资源、退出进程。

Go 里常用 signal.NotifyContext 捕获 SIGTERM/SIGINT。排空请求要设一个最长宽限时间——有些请求可能永远挂起,比如下游已死。宽限期内等存量请求跑完,超时就强制退出,这叫“有底线的温柔”。排空时长的估算,取决于在途请求的最大剩余耗时。

打烊方式体现一家店的管理水平:挂“暂停接单”、等客人吃完、才熄灯。优雅退出让每一次发布都像按点下班,而不是断电式跑路。

Tdrain=Tgrace+max⁡j∈JtjT_{\text{drain}} = T_{\text{grace}} + \max_{j \in J} t_j
优雅退出的总耗时等于宽限期加上所有在途请求剩余时间的最大值,既保证存量请求完成,又防止无限等待。
  1. 宽限期 T_grace系统允许排空的最长时间,是优雅退出的“最后通牒”。
    Tgrace=10 sT_{\text{grace}} = 10\,\text{s}
  2. 在途请求耗时 t_j每个正在处理中的请求,从当前时刻到完成的剩余时间。
    tj=Tcomplete(j)−tnowt_j = T_{\text{complete}}(j) - t_{\text{now}}
  3. 取最大值求和排空至少覆盖最慢的那个请求,再加上宽限兜底。
    Tdrain=Tgrace+max⁡j∈JtjT_{\text{drain}} = T_{\text{grace}} + \max_{j \in J} t_j

服务注册发现

连锁餐厅开到十家,总部得有本“分店名录”:每家店开业时登记地址、电话,每 30 秒汇报一次“我还开着”;若超时未报,总部就把这家店从名录里划掉。客人想知道“附近哪家店能订”,翻这本名录即可,不用背 IP 地址。

微服务实例数量多、动态变化,硬编码地址迟早踩坑。服务启动时向注册中心(etcd、Consul、Nacos 等)注册自己的地址,并以心跳周期续约;客户端启动时拉取实例列表,并订阅变更通知。一个节点宕机后,超过租约时间没续约,就会被注册中心剔除。这套机制让“找到能用的服务”变成一件自动的事。

注册中心自己是整个系统的命门,必须高可用。etcd 用 Raft 一致性算法保证多个节点数据一致,任何写入都要多数派确认。这里有个设计权衡:心跳周期太短,注册中心压力大;太长,故障感知慢,通常设 10 秒心跳、30 秒租约。客户端侧则用本地缓存加 Watch 推送,降低对注册中心的轮询压力。

没有名录,客人只能靠背地址找店;店倒了,他们还傻等超时。有了注册中心,地址自动登记、宕机自动划掉——动态环境下的“找得到、找得准”全靠它。

Q=⌊n2⌋+1Q = \left\lfloor \frac{n}{2} \right\rfloor + 1
这是 etcd 集群达成写入一致所需的最少节点数(多数派)。5 个节点的集群,至少需要 3 个节点确认。
  1. 节点总数 netcd 集群中所有节点的数量,代表系统的容错上限。
    n=5n = 5
  2. 向下取整n 除以 2 后取整数部分,去掉小数。
    ⌊n2⌋=2\left\lfloor \frac{n}{2} \right\rfloor = 2
  3. 加一形成多数派超过半数才能达成一致提交,保证数据不因部分节点故障而丢失。
    Q=2+1=3Q = 2 + 1 = 3

负载均衡

大堂经理手上一摞订单,怎么派给各家分店?最公平是轮流——轮询策略;有的店位置好、人力足,就多派些——权重分配;有的店眼下正忙,就少派——最少连接。这就是负载均衡的日常智慧。

gRPC 的负载均衡通常做在客户端:客户端从注册中心拿到实例列表后,用某种策略选出一个实例发起调用。相比中心化网关统一转发,客户端负载均衡少一跳网络开销,延迟更低。但代价是每个客户端都得实现“选谁”的逻辑和故障转移能力。

常见策略有三种:加权轮询按权重分配概率,适合实例配置不均的场景;一致性哈希让相同 key(比如用户 ID)稳定打到同一实例,利于本地缓存;最少连接则实时感知实例负载,避免热点倾斜。选好实例后,客户端会与它建立 HTTP/2 长连接并复用,实例列表变更时自动剔除或加入。

大堂经理不亲自炒菜,但把订单派得均匀,各家都不超载。负载均衡就是派单的艺术——平凡的轮流背后,藏着对权重、哈希和实时负载的深刻理解。

Pi=wi∑j=1nwjP_i = \frac{w_i}{\sum_{j=1}^{n} w_j}
加权轮询中,实例 i 被选中的概率等于它的权重占全部实例权重总和的比例。
  1. 权重 w_i每个实例的权重,表示其承载能力,配置高的实例权重更大。
    wi={10,20,10}w_i = \{10, 20, 10\}
  2. 权重求和把所有实例的权重累加起来,作为概率分布的分母。
    ∑j=1nwj=40\sum_{j=1}^{n} w_j = 40
  3. 占比即概率每个实例被选中的概率等于其权重除以总权重,权重越大命中率越高。
    Pi=2040=0.5P_i = \frac{20}{40} = 0.5

AI 应用场景

电商订单服务的统一日志与鉴权拦截

阶段:服务治理 / 可观测性

解决的问题:传统做法是在每个业务方法里重复写日志和鉴权代码,散落各处、难以维护,还容易漏写导致安全漏洞。使用 gRPC 拦截器后,日志记录、JWT 校验、链路追踪全部横切到统一层,业务代码零侵入。

具体案例:某电商订单服务在 Go 技术栈中实现了一个 UnaryServerInterceptor,先做 token 校验与日志记录,再进入业务逻辑。所有订单、库存、支付接口自动获得审计能力,排查线上问题时通过 trace ID 一键串联整条调用链。

大促扩容时的服务注册与负载均衡

阶段:服务注册发现 / 弹性伸缩

解决的问题:传统硬编码服务地址在扩容时完全失效——新实例加进来没人知道,下线实例还继续被打流量。接入 etcd 注册中心后,新实例启动自动注册,客户端实时感知并参与轮询,扩容缩容秒级生效。

具体案例:双 11 大促前,商品服务从 10 个实例扩容到 30 个。每个新 Pod 启动后向 etcd 注册自己的地址并开始发心跳,客户端通过 Watch 收到变更,加权轮询策略自动把流量分摊到新实例,峰值 QPS 平稳翻倍。

支付回调的超时重试与优雅退出

阶段:故障治理 / 发布变更

解决的问题:支付回调第三方网关偶发超时,若无限重试会引发重试风暴;发布时直接杀进程又会丢在途回调。通过 context 设置 5 秒超时、最多重试 3 次并按 0.2/0.4/0.8 秒指数退避,同时回收 SIGTERM 后先摘流量再排空存量回调,做到发布零丢失。

具体案例:某支付中心在 K8s 上滚动发布时,每个 Pod 收到 SIGTERM 后先向注册中心注销,再等待在途回调处理完成,宽限期 10 秒兜底。上线后发布期间支付回调成功率保持在 99.99%。

Python 演示

python86 行
import randomimport timefrom functools import wraps  class ServiceNode:    """模拟一个 gRPC 服务实例"""     def __init__(self, name):        self.name = name        self._healthy = True     def handle(self, message):        return "reply[%s]: got %s" % (self.name, message)  class Registry:    """模拟注册中心: 维护全部服务实例"""     def __init__(self):        self._nodes = []     def register(self, node):        self._nodes.append(node)     def healthy_nodes(self):        return [n for n in self._nodes if n._healthy]  class LoadBalancer:    """模拟 gRPC 客户端负载均衡: 只向健康节点轮询派发"""     def __init__(self, registry):        self._registry = registry        self._cursor = 0     def pick(self):        nodes = self._registry.healthy_nodes()        if not nodes:            raise ConnectionError("no healthy node")        node = nodes[self._cursor % len(nodes)]        self._cursor += 1        return node  def retry_with_backoff(max_retry=3, base_wait=0.2):    """模拟 gRPC 重试策略: 指数退避, 不要无限重试"""     def decorator(func):        @wraps(func)        def wrapped(*args, **kwargs):            for i in range(max_retry):                try:                    return func(*args, **kwargs)                except ConnectionError:                    wait = min(2.0, base_wait * (2 ** i))                    print("retry %d, wait %.1fs" % (i + 1, wait))                    time.sleep(wait)            raise ConnectionError("give up after %d retries" % max_retry)         return wrapped     return decorator  def main():    registry = Registry()    for name in ["node-a", "node-b", "node-c"]:        registry.register(ServiceNode(name))    registry._nodes[2]._healthy = False  # 节点c被健康检查标记为宕机     lb = LoadBalancer(registry)     @retry_with_backoff(max_retry=3, base_wait=0.1)    def call_rpc(message):        node = lb.pick()        if random.random() < 0.4:            raise ConnectionError("network jitter")        return node.handle(message)     for i in range(6):        print("call %d -> %s" % (i, call_rpc("order-%d" % i)))  if __name__ == "__main__":    main()
目的:用纯 Python 模拟 gRPC 微服务的核心治理链路——注册中心维护实例、健康检查剔除宕机节点、客户端负载均衡轮询派发、失败时指数退避重试。
  1. self._healthy = True每个服务实例启动时默认健康,就像新店开业时厨师全部在岗。
  2. def healthy_nodes注册中心只把健康节点交给负载均衡器,宕机节点从源头被隔离。
  3. nodes[self._cursor % len(nodes)]轮询下标并用取模回绕,实现公平的“轮流派单”。
  4. registry._nodes[2]._healthy = False模拟健康检查发现第三个节点宕机,将其标记为不可用。
  5. @retry_with_backoff(max_retry=3, base_wait=0.1)装饰器为调用叠加重试策略,限定最多 3 次。
  6. wait = min(2.0, base_wait * (2 ** i))指数退避但封顶 2 秒,给下游留喘息时间。
  7. if random.random() < 0.4模拟网络抖动导致的部分失败,触发重试逻辑。

常见误区

误区有些同学把 .proto 当作文档写,代码改了却不同步更新 proto,认为 gRPC 照样能跑。反正两端都是自己人,能调通就行。

正解.proto 不是文档,而是契约加代码生成器的输入。服务端和客户端的桩代码都由它生成,两端各自改动后,字段编号、消息结构一旦不一致,轻则解析错乱,重则直接报错。就像菜单上把“宫保鸡丁”改成“宫保鸡块”,后厨按新做法做,客人按旧菜名点,端上来永远对不上。

为何常见:单体时代改接口只需同步改两端代码,习惯了“文档滞后也能跑”的思维。微服务把接口契约独立出来后,这个习惯就成了隐患。

误区认为只要注册了拦截器,所有请求都会被日志记录、被鉴权保护,完全不关心拦截器的执行顺序和异常处理。

正解拦截器是洋葱嵌套结构,顺序直接影响结果:先鉴权再记日志,非法请求不产生日志;反过来则可能记录了攻击行为。拦截器里若发生 panic 且没有 recover,整条调用链都会崩掉,所以通常还需要一个恢复拦截器兜底。

为何常见:只看拦截器能“横切加代码”,没看到它是多层中间件嵌套执行的。等到排查问题时才发现,日志里全是未授权请求,真正的攻击记录反而不存在。

误区“失败就再来一次”很符合直觉,于是把重试次数设到 10 次、20 次,认为总有一次会成功,服务就稳了。

正解重试会放大请求量,服务已过载时,所有客户端同时重试会直接压垮下游,形成“重试风暴”。而且非幂等接口(如扣款)重试会导致重复扣钱。正确做法是限制重试次数、使用指数退避,并且只对幂等接口开启重试。

为何常见:单机时代“重试一下就好”的经验深入人心,忽略了分布式系统里流量放大的雪崩效应。

误区认为只要进程没有退出,服务就是健康的,于是只配置存活探针,不配置就绪探针。

正解存活不等于能接流量。数据库连接池耗尽、下游依赖挂掉时,进程依然活着但已经无法正常服务。就绪探针专门回答“能不能接单”这个问题,失败了就摘除流量,让服务有时间恢复而不被流量拖死。

为何常见:单机时代“进程挂了才是故障”是常识。微服务里“半死不活”的状态才是常态,这超出了很多初学者的经验范围。

面试题

Q1. gRPC 中,客户端与服务端之间那份“共同接口约定”定义在什么文件里?

单选题 · easy

  • A..proto 接口定义文件← 正确答案
  • B..json 配置描述文件
  • C..yaml 部署清单文件
  • D..xml 消息路由文件

答案:A。.proto 是 gRPC 的 IDL 契约文件,服务端和客户端代码都由它生成,相当于连锁餐厅的“菜单规格书”。JSON、YAML、XML 通常用于配置或数据传输,不承担接口契约生成代码的职责。

Q2. gRPC 通信的底层传输协议是?

单选题 · easy

  • A.HTTP/2 长连接复用← 正确答案
  • B.HTTP/1.1 短连接
  • C.WebSocket 双工通道
  • D.UDP 快速丢包传输

答案:A。gRPC 基于 HTTP/2,利用一条 TCP 连接多路复用承载大量并发 RPC,就像传菜电梯一个井里多轿厢同时运行。HTTP/1.1 不支持多路复用,WebSocket 主要面向浏览器,UDP 则根本不属于 HTTP 体系。

Q3. 下列哪一项不属于 gRPC 拦截器的典型职责?

单选题 · mid

  • A.为每个请求统一记录日志
  • B.对请求做身份令牌校验
  • C.修改核心业务计算结果← 正确答案
  • D.向调用链注入追踪 ID

答案:C。拦截器擅长“横切”能力——日志、鉴权、链路追踪、限流、异常恢复都不碰业务逻辑本身。修改核心业务计算结果属于业务代码的职责,把它塞进拦截器会让洋葱模型变得混乱,就像让门迎替厨师炒菜。

Q4. 配置 gRPC 重试策略时,必须同时考虑哪些因素?

多选题 · mid

  • A.目标接口是否具备幂等性← 正确答案
  • B.重试次数的合理上限← 正确答案
  • C.指数退避的等待时间← 正确答案
  • D.将重试次数设为无穷

答案:ABC。非幂等接口重试可能导致重复扣款,所以幂等性是前提;重试次数必须有上限,并配合指数退避防止重试风暴。把重试次数设为无穷会在服务故障时放大流量直至雪崩,是典型的反模式。

Q5. 健康检查中“就绪探针”通过后,表示服务处于什么状态?

单选题 · mid

  • A.进程保持存活运行中
  • B.可以接收新的业务流量← 正确答案
  • C.磁盘与内存资源充足
  • D.历史日志已完整归档

答案:B。就绪探针回答“炉灶能不能开火”,通过后调度系统才会把流量派进来。进程存活是存活探针的范畴,磁盘内存由监控系统负责,日志归档与就绪毫无关系。记住:存活是在岗,就绪是能接单。

Q6. 优雅退出时,正确的执行顺序是?

单选题 · hard

  • A.先关闭端口,再排空在途请求,最后释放资源
  • B.先杀掉进程,再清理系统连接,最后提交数据
  • C.先从注册中心摘除,再拒绝新请求,最后排空存量← 正确答案
  • D.先清理本地缓存,再退出进程,最后断开数据库

答案:C。优雅退出的标准顺序是:先从注册中心摘除或标记不健康(停止接新单),再关闭监听端口拒绝新请求,然后等待在途请求排空,最后释放资源退出。直接关闭端口或杀进程会让存量请求被迫中断,如同打烊时直接断电赶客。

Q7. etcd 集群有 5 个节点,至少需要几个节点确认才能完成写入?

单选题 · hard

  • A.至少 2 个节点确认
  • B.至少 3 个节点确认← 正确答案
  • C.至少 4 个节点确认
  • D.必须 5 个节点确认

答案:B。etcd 用 Raft 算法保证一致性,写入需要多数派确认,公式 Q = ⌊n/2⌋ + 1。对 5 节点集群,⌊5/2⌋ + 1 = 3。这保证最多能容忍 2 个节点故障而不丢数据,是注册中心高可用的数学根基。

Q8. 常用的 gRPC 客户端负载均衡策略有哪些?

多选题 · mid

  • A.加权轮询分配策略← 正确答案
  • B.一致性哈希策略← 正确答案
  • C.最少连接数优先← 正确答案
  • D.永远绑定首台机器

答案:ABC。加权轮询按权重分配概率、一致性哈希让相同 key 稳定命中同一实例、最少连接优先把请求派给空闲实例,三者都是成熟策略。永远绑定首台机器等于没有负载均衡,单点故障时整个服务不可用。

Q9. 服务雪崩中的“重试风暴”通常由什么直接引发?

单选题 · extreme

  • A.所有调用方同时发起重试← 正确答案
  • B.服务端主动拒绝超载流量
  • C.客户端日志异步批量写入
  • D.监控系统开启持续采样

答案:A。当下游服务变慢,大量客户端在同一时刻超时并同时重试,请求量被瞬间放大数倍,直接把下游压垮,这就是重试风暴。服务端拒绝超载流量是保护措施而非诱因,日志和监控系统均与风暴无直接关联。

Q10. 下列哪些属于 gRPC 服务治理的范畴?

多选题 · easy

  • A.超时控制与重试策略← 正确答案
  • B.服务注册与发现机制← 正确答案
  • C.日志与鉴权拦截能力← 正确答案
  • D.前端页面渲染优化

答案:ABC。服务治理关注微服务运行时的稳定性与可管理性,包括超时重试、注册发现、健康检查、优雅退出、负载均衡、日志鉴权等。前端页面渲染属于客户端展示层,与后端服务治理无关。