Skip to content

RPC、重试与故障边界 ​

RPC 把远端工作包装为函数调用,但不能消除网络。一次调用通常经历编码、发送、排队、执行、持久化、回复、解码。任何阶段出错,调用者看到的可能都只是一个超时。

超时有三种完全不同的原因 ​

设请求是 counter += 1:请求可能根本没到;也可能到了但尚未执行;还可能已经执行并持久化,只是回复丢失。客户端无法仅凭等待 1 秒没有结果区分这些状态。因此超时的语义是“结果未知”,不能自动解释为操作失败或回滚。

解决方式先从应用语义出发。put(x, 7) 连续执行两次通常与一次效果相同,但 append、扣款、发邮件等不天然幂等。即便 put 本身幂等,不同请求交错时也不能随意重排,例如先 put(x,7),再 put(x,8),最后重试旧请求可能把状态写回 7。

请求身份必须跨越重试 ​

用 (client_id, request_id) 作为请求身份,把结果表与业务状态作为同一个状态机更新:

python
def apply(request):
    key = (request.client_id, request.request_id)
    if key in completed:
        return completed[key]
    result = execute(request.command)
    completed[key] = result
    return result

这段伪代码只说明原子边界。若业务数据更新成功而结果表没保存,恢复后仍会重复执行;若仅 leader 在内存中保存去重结果,切换 leader 也会丢失它。因此去重信息需要和数据一起复制、持久化和快照化。

同一个请求身份必须始终对应同一命令。重试不能每次生成新编号。如果客户端允许多个并发请求,服务器只记一个“最大编号”可能误丢迟到的小编号;可保存窗口或独立结果记录。本仓库模型保存所有已执行请求,清晰但内存不会自动回收;生产系统还需客户端确认、水位线和回收协议。

所谓“恰好一次”通常指明确作用域内的业务效果。仅靠网络传输不能保证任何外部副作用都恰好一次;例如状态机更新后调用独立邮件服务,还需要 outbox 或下游幂等键。

重试策略也是负载控制 ​

立即无限重试可能把暂时过载变成持续雪崩。常见做法是指数退避加随机抖动,设置总 deadline,并把取消信号向下传递。重试预算应覆盖完整操作,而不是每一层分别无限重试。超时值取决于请求成本与尾延迟;不能把跨机房请求和本机缓存查询设成相同阈值。

MapReduce:允许重复工作,控制结果发布 ​

MapReduce 将输入切片交给 map worker,生成按 reduce 分区组织的中间结果;reduce worker 合并同键值并输出结果。协调器维护任务状态、租约或超时,worker 宕机后把任务重新分配。一个慢 worker 可能与替补同时完成,因此执行重复是正常情况。

flowchart LR
  I[输入切片] --> M[Map:生成键值对]
  M --> P[分区与中间文件]
  P --> S[按键聚合]
  S --> R[Reduce]
  R --> O[原子发布结果]
查看流程图文本
flowchart LR
  I[输入切片] --> M[Map:生成键值对]
  M --> P[分区与中间文件]
  P --> S[按键聚合]
  S --> R[Reduce]
  R --> O[原子发布结果]

关键是让每次尝试先写独立临时输出,完成后再由协议决定哪份结果可见。临时文件重命名能提供本地文件系统层面的原子可见性,但不自动解决跨文件、跨机器的整体原子提交。用户函数若有不可重试的外部副作用,也不能依赖“重复执行无害”的假设。

练习 ​

服务器已经加一,但回复丢失。新 leader 只复制了 counter,没有复制去重表。重试后会怎样?

展开推演

新 leader 看不到请求已执行,可能再次加一。日志达成一致只解决命令的顺序,服务还必须在状态机层识别命令身份,并把去重状态纳入快照与迁移。

来源:MIT 6.5840、MapReduce 原论文。继续阅读一致性模型。