如何设计和实现幂等性
幂等性及其用途
如果一个操作重复执行多次,其效果(不考虑操作时间)和只执行一次是一样的,那么这个操作就叫做是幂等(idempotent)的。乍看起来,幂等操作似乎没什么用处,毕竟只有第一次执行有效。但如果在系统设计中考虑到“失败”场景的话,幂等操作是非常重要的。因为失败发生和感知失败发生是两件不同的事情。想象两个服务器进行通过网络进行通信。服务器A发送请求到服务器B,服务器B执行操作并将结果返回A。在理想的情况下,一切执行顺利。我们从服务器A的角度来看看发生了什么。首先服务器A发出一个请求,等待了一会后,服务A收到一个应答,里面的消息说明操作成功。现在我们引入失败场景。可能的失败有这么几种:
- 服务器A发送请求失败。
- 服务器B接收请求失败。
- 服务器B处理请求失败。
- 服务器B发送应答失败。
- 服务器A接收应答失败。
从服务器A的角度来看,除了第1种失败可以立刻感知外,其他的失败都存在无法被感知的可能。在一个更加实际的场景中,服务器A发送了请求,等待了一会,没有收到应答。服务器A无法判断操作是成功了还是失败了。它应该继续等待吗?还是要重新发送请求呢?选择继续等待,如果服务器B发生故障,服务器A将陷入永久停滞。选择重新发送请求,如果服务器B已经成功执行了操作(失败情形4、5),再次请求会引发重复操作。假设请求的操作是“支付100元”,重复操作的效果将是“支付200元”,这不是服务器A的真实意图。可如果这是一个幂等操作,无论重复几次效果都和执行一次相同,那么服务器A就可以放心的重发请求。这就是幂等操作的好处。幂等操作可以大大简化客户代码的失败处理逻辑,提高系统整体的稳定性。
实现幂等性
要避免重复操作,必须能唯一识别操作,因此需要为操作分配唯一编号。服务器记录下唯一编号和操作执行结果。当收到具有相同编号的请求时,不再执行操作,将第一次操作的结果返回。
activate 客户端
客户端 -> 服务器: 请求执行操作
activate 服务器
服务器 -> 服务器: 执行操作
客户端 <- 服务器: 返回操作结果
deactivate 服务器
deactivate 客户端
Operation operation = new Operation("do some thing");
OperationResult result = server.remoteExecute(operation);
public OperationResult onRequest(Operation operation) {
OperationResult result = doOperate(operation);
return result;
}
activate 客户端
客户端 -> 服务器: 请求分配唯一操作编号
activate 服务器
客户端 <- 服务器: 返回唯一操作编号
deactivate 服务器
客户端 -> 服务器: 请求执行操作
activate 服务器
服务器 -> 服务器: 检查操作是否已经执行
alt 操作已经执行
服务器 -> 服务器: 查询操作结果
else 操作尚未执行
服务器 -> 服务器: 执行操作
服务器 -> 服务器: 保存操作结果
end
客户端 <- 服务器: 返回操作结果
deactivate 服务器
deactivate 客户端
String operationNumber = server.allocateOperationNumber();
Operation operation = new Operation(operationNumber, "do some thing");
OperationResult result = server.remoteExecute(operation);
public OperationResult onRequest(Operation operation) {
Optional<OperationResult> resultOpt = findOperationResult(operation.getOperationNumber());
if (resultOpt.isPresent()) {
return resultOpt.get();
}
OperationResult result = doOperate(operation);
storeOperationResult(operation.getOperationNumber(), result);
return result;
}
可以看到幂等操作分两步:分配编号、执行操作。需要说明一下,这里提到的幂等性是从业务状态变更的角度讲的。业务状态是类似用户名、账户余额这类具有业务意义的数据。操作编号本身没有业务意义。无论分配了多少个编号,业务状态都没有改变,因此分配编号行为被认为是幂等的。在执行操作这一步,有了唯一编号就可以确认操作的执行状态,保证只有首次请求时操作才会得到执行。后续请求只是在读取首次执行的结果,也没有改变业务状态,因此也是幂等的。
改进性能
幂等操作多了一次请求,这会带来性能开销。一个改进方法是批量分配编号。每次客户端申请时,服务器返回多个编号,后续操作只需要一次网络请求。
activate 客户端
客户端 -> 服务器: 请求批量分配操作编号
activate 服务器
客户端 <- 服务器: 返回多个唯一操作编号
deactivate 服务器
客户端 -> 客户端: 在本地保存分配的唯一编号,标记为未使用
loop 本地存在未使用的操作编号
客户端 -> 客户端: 从本地选择一个未使用的编号
客户端 -> 客户端: 为操作设置唯一编号
客户端 -> 客户端: 将编号标记为已使用
activate 服务器
客户端 -> 服务器: 请求执行操作
客户端 <- 服务器: 返回操作结果
deactivate 服务器
end
deactivate 客户端
另一个方法是客户端分配编号。常见的方法有两种,一是为每个客户端分配唯一的客户端编号,由客户端自行分配本地唯一编号。这样采用一个两层结构(客户端编号,客户端本地唯一编号)作为操作编号。二是采用全局唯一编号,如SnowFlakeID。
activate 客户端
客户端 -> 客户端: 生成操作编号
客户端 -> 客户端: 为操作设置唯一编号
activate 服务器
客户端 -> 服务器: 请求执行操作
客户端 <- 服务器: 返回操作结果
deactivate 服务器
deactivate 客户端
分布式服务中的幂等性
对于单独部署的单线程服务,上面的设计已经够用了。对于多线程或分布式服务,还有一些细节需要考虑。首先是全局唯一ID生成,这已经有成熟的方案了。其次是要协调操作的执行,避免多个节点并行执行。常用的方法有:
- 锁。通过全局锁保证操作不会并行执行。
- 分片。某种类型的请求或某些客户端的请求始终向某个固定的服务器提交申请。
- 向首领提交。集群选举出首领,客户端只向首领提交请求。首领可以选择自己执行操作或者分派给其他节点执行。
编辑记录
- 2020年12月08日 新建文档。
- 2023年08月21日 增加服务代码示例;增加时序图;修改部分文字。
- 2024年02月10日 增加段落标题。