📄 gRPC 幂等 + 超时 + 重试 · 面试速记
一、核心设计原则(先背这 3 句)
- 超时用 Context,不用业务计时
- 幂等靠 request_id,不靠运气
- 重试只解决瞬态故障,不解决业务错误
二、Proto 定义(必会)
message CreateTaskRequest {
string request_id = 1; // 幂等键(客户端生成)
int64 job_id = 2;
bytes payload = 3;
}
✅ request_id 是灵魂
- UUID / Snowflake
- 重试不变
- 非业务字段
三、Go 客户端(超时 + 重试)
关键代码骨架
for attempt := 0; attempt < 3; attempt++ {
callCtx, cancel := context.WithTimeout(ctx, 100*time.Millisecond)
defer cancel() // ✅ 释放定时器,防 context 泄漏
resp, err := client.CreateTask(callCtx, &pb.CreateTaskRequest{
RequestId: requestID, // ✅ 不变
})
}
重试策略(面试必问)
| 项目 | 做法 |
|---|---|
| 重试条件 | DeadlineExceeded / Unavailable / ResourceExhausted |
| 不重试 | InvalidArgument / NotFound / Internal |
| 退避 | 指数退避(100ms → 200ms → 400ms) |
| 幂等 | request_id 不变 |
| 上限 | ≤ 3 次 |
✅ 加分句:
“重试只解决网络抖动和瞬时过载,不解决逻辑错误。”
四、C++ 服务端(Deadline + 幂等)
核心逻辑
if (context->IsCancelled()) {
return Status(StatusCode::CANCELLED, "request cancelled");
}
// 1. 幂等校验
if (store.Get(request_id, task_id)) {
return SUCCESS; // ✅ 已处理,直接返回
}
// 2. 计算中定期检查
if (context->IsCancelled()) abort();
// 3. 写 DB(唯一索引兜底)
DB.Insert(request_id, ...);
// 4. 记录幂等结果
store.Set(request_id, task_id);
幂等存储选型
| 层级 | 技术 | 作用 |
|---|---|---|
| 权威源 | DB 唯一索引 | 防并发、防丢失 |
| 加速层 | Redis SETNX + TTL | 降低 DB 压力 |
| 本地缓存 | LRU(可选) | 单机优化 |
✅ 面试金句:
“内存去重是性能优化,DB 唯一索引才是正确性兜底。”
五、Context Cancel 的意义(高频)
Go 侧
cancel():释放定时器,防止 context leak- gRPC 不负责调用 cancel
- RPC 结束后立即释放最优
C++ 侧
IsCancelled():感知客户端放弃- 不能保证 100% 阻止写 DB
- 用于节省算力,不是正确性依赖
✅ 一句话总结:
“Go 的 cancel 管资源,C++ 的 IsCancelled 管效率,DB 的唯一索引管正确性。”
六、P99 延迟(必考)
P99 延迟:99% 的请求响应时间都不超过该值,反映长尾体验。
- 平均延迟会掩盖问题
- 超时一般设为 P99 × 1.5 ~ 2
- 本例:P99=150ms → 超时=200~300ms
七、面试标准自我介绍(30 秒版)
“我在项目中用 gRPC 搭建 Go 与 C++ 的微服务通信,接口通过 request_id 保证幂等。Go 侧使用
context.WithTimeout设置 RPC 超时,配合指数退避重试;C++ 侧通过IsCancelled及时中止计算,并通过 DB 唯一索引 + Redis 缓存实现幂等校验。整体 P99 延迟控制在 50ms 以内,避免了重复写和重试风暴。”
八、面试官最爱追问(速记答案)
| 问题 | 一句话答案 |
|---|---|
| 为什么不用 REST? | 内部调用,Protobuf 性能高、强类型、支持流式 |
| request_id 冲突怎么办? | UUID/Snowflake,冲突概率极低,DB 唯一索引兜底 |
| 服务重启幂等记录在哪? | Redis + DB,内存只做缓存 |
| cancel 后还会写 DB 吗? | 可能,但唯一索引保证最终一致 |
| 哪些错误能重试? | 只有网络/过载类(UNAVAILABLE / DEADLINE_EXCEEDED) |
九、一张图(口述用)
“Go 发请求 → 带 request_id + 100ms 超时 → C++ 先查幂等 → 计算中查 IsCancelled → 写 DB → 返回 → Go 超时重试 → request_id 不变 → 幂等命中直接返回。”
Q&A
Q1:为什么选 gRPC,而不是 REST 或 Thrift?
参考回答(30 秒版):
“我们的场景是 Go 主服务调用 C++ 的计算服务,属于内部微服务通信。相比 REST,gRPC 基于 Protobuf,序列化效率高、包体小,而且强类型能在编译期发现问题。另外 gRPC 原生支持流式调用和 HTTP/2 多路复用,适合高并发场景。相比 Thrift,gRPC 的生态更好,和 Kubernetes、Service Mesh(比如 Istio)集成更成熟,所以我们最终选择了 gRPC。”
✅ 加分词:强类型 HTTP/2 流式调用 云原生
Q2:RPC 超时是怎么设计的?为什么不在 C++ 侧自己计时?
参考回答:
“我们把超时放在 Go 侧,通过
context.WithTimeout设置 RPC 超时,而不是在 C++ 侧自己计时。原因是 gRPC 的 Deadline 是可以跨语言传播的:Go 侧超时后,gRPC 框架会自动向 C++ 发送 Cancel 请求,C++ 可以通过IsCancelled()及时中止计算。如果在 C++ 侧自己计时并返回‘服务忙’,不仅和 gRPC 的超时语义重复,还可能导致连接未正确回收、资源泄漏等问题。用 gRPC 原生的 Deadline 更符合框架设计哲学。”
✅ 加分词:Deadline 传播 框架语义 IsCancelled
Q3:重试时如何保证幂等?具体怎么实现?
参考回答:
“我们是通过幂等键(Idempotency Key)来解决的。在 proto 中我们定义了一个
request_id字段,由 Go 客户端生成 UUID,在一次原始请求及其所有重试中保持不变。C++ 服务端收到请求后,先通过
request_id查询是否已经处理过:如果 Redis 或数据库中已经存在,就直接返回之前的结果,不再执行业务逻辑;如果不存在,才执行计算并写入 DB。存储上我们用 Redis + DB 的两层结构:Redis 用来加速校验,DB 的唯一索引作为最终兜底,确保即使 Redis 失效也不会产生重复写。”
✅ 加分词:request_id 不变 唯一索引兜底 读写顺序
Q4:Go 侧为什么要调用 cancel()?不调用会怎样?
参考回答:
“
context.WithTimeout内部会创建一个定时器,用来在超时后关闭 Done 通道。cancel()的作用就是立刻释放这个定时器以及相关的 context 资源。如果不调用cancel(),即使 RPC 已经成功返回,这个定时器也会等到超时时间到达后才能被 GC,造成 context 泄漏,在高并发场景下会增加 runtime 压力。gRPC 只会监听 context 的状态,并不会帮我们调用cancel(),所以这是调用方的责任。”
✅ 加分词:context leak 定时器提前释放 调用方责任
Q5:哪些 gRPC 错误码会重试?哪些不会?
参考回答:
“我们只对瞬态故障进行重试,前提是接口本身是幂等的。
会重试的错误码包括:
DEADLINE_EXCEEDED:超时UNAVAILABLE:服务暂时不可用RESOURCE_EXHAUSTED:服务过载(会配合指数退避)不会重试的有:
INVALID_ARGUMENT/NOT_FOUND:业务错误,重试无意义INTERNAL:系统错误,通常需要人工介入CANCELLED:客户端主动取消另外我们会使用指数退避策略,避免重试风暴。”
✅ 加分词:瞬态故障 指数退避 重试风暴
Q6:什么是 P99 延迟?你们超时时间是怎么定的?
参考回答:
“P99 延迟指的是 99% 的请求响应时间都不超过这个值,用来衡量系统的长尾延迟。相比平均延迟,P99 更能反映极端情况下的用户体验。在我们的系统中,这个接口的 P99 大约是 50ms,我们把 RPC 超时设置为 100ms~150ms,也就是 P99 的 2~3 倍。这样既能容忍偶发的慢请求,又能防止慢请求长时间占用资源。”
✅ 加分词:长尾延迟 超时预算 SLA
Q7:C++ 侧用同步还是异步?IsCancelled() 一定能阻止写 DB 吗?
参考回答:
“我们在 C++ 侧使用的是同步模型,但在关键路径上会周期性检查
IsCancelled()。需要注意的是,IsCancelled()是 best-effort 的,并不能 100% 保证在写 DB 前检测到取消。比如取消信号到达的同时,DB 写入刚好发生,就可能出现竞态。因此我们把IsCancelled()当作性能优化手段,用来及时中止计算、节省资源,而正确性最终依赖 DB 的唯一索引来保证。也就是说,取消解决的是效率问题,幂等解决的是正确性问题。”
✅ 加分词:best-effort 竞态条件 正确性 vs 效率
Q8:如果让你重新设计,你会做什么改进?
参考回答:
“如果重新设计,我有几点改进方向:
使用 gRPC 原生的 Retry Policy,把重试逻辑从业务代码下沉到框架层,减少胶水代码;
接入 Service Mesh(如 Istio),利用 Envoy 的统一重试、熔断、超时和指标采集能力;
规范幂等 key 的生命周期管理,明确 TTL,并增加幂等命中率的监控;
增加熔断和限流机制,在服务过载时快速失败,保护核心链路。
整体上希望做到:业务逻辑只关心业务,稳定性保障尽量交给基础设施。”
✅ 加分词:Retry Policy Service Mesh 熔断限流 关注点分离
🧠 面试“串联话术”(把 8 个答案串成一段)
“我们在项目中使用 gRPC 作为 Go 和 C++ 之间的通信框架,主要看中它的强类型和 HTTP/2 特性。超时方面,我们统一在 Go 侧通过
context.WithTimeout设置,并利用cancel()及时释放资源,C++ 侧则通过IsCancelled()感知取消。为了保证重试时的数据一致性,我们在 proto 中引入request_id作为幂等键,服务端通过 Redis + DB 唯一索引来保证只执行一次。重试只针对DEADLINE_EXCEEDED等瞬态错误,并使用指数退避避免风暴。在性能方面,我们以 P99 延迟为基准设置超时时间。整体设计上,IsCancelled()负责效率,幂等和唯一索引负责正确性。如果重新设计,我会更倾向于使用 gRPC Retry Policy 和 Service Mesh,把稳定性能力下沉到基础设施。”
✅ 如何使用这套答案
- 不要背逐字稿:理解逻辑,用自己的话讲
- 面试时“递进答”:先给结论 → 再讲原因 → 最后补一个“加分点”
- 遇到追问:回到“资源 / 正确性 / 性能”这三个维度解释