基于grpc幂等性实现方案与面试速记

2026-07-18

📄 gRPC 幂等 + 超时 + 重试 · 面试速记

一、核心设计原则(先背这 3 句)

  1. 超时用 Context,不用业务计时
  2. 幂等靠 request_id,不靠运气
  3. 重试只解决瞬态故障,不解决业务错误

二、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:如果让你重新设计,你会做什么改进?

参考回答:

“如果重新设计,我有几点改进方向:

  1. 使用 gRPC 原生的 Retry Policy,把重试逻辑从业务代码下沉到框架层,减少胶水代码;

  2. 接入 Service Mesh(如 Istio),利用 Envoy 的统一重试、熔断、超时和指标采集能力;

  3. 规范幂等 key 的生命周期管理,明确 TTL,并增加幂等命中率的监控;

  4. 增加熔断和限流机制,在服务过载时快速失败,保护核心链路。

    整体上希望做到:业务逻辑只关心业务,稳定性保障尽量交给基础设施。”

加分词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,把稳定性能力下沉到基础设施。”


✅ 如何使用这套答案

  • 不要背逐字稿:理解逻辑,用自己的话讲
  • 面试时“递进答”:先给结论 → 再讲原因 → 最后补一个“加分点”
  • 遇到追问:回到“资源 / 正确性 / 性能”这三个维度解释