在API中实现幂等性

最近想着给公司优化下接口,发现好多连幂等性都没有做处理,特别是下单、支付、退款这些接口,瑟瑟发抖。准备改造一波!


什么是幂等性

幂等性:(Idempotence)。首先幂等性是数学和计算机科学中某些操作的特性,什么特性呢?就是如果使用相同的输入参数多次调用它,则不会产生额外的影响。

A request method is considered “idempotent” if the intended effect on the server of multiple identical requests with that method is the same as the effect for a single such request. (RFC 7231)

举个例子:

对于转账,因为种种原因对同一个请求进行了多次发送多次执行,其结果只会成功转账一次,其他都不会生效。

幂等性带来优劣

实现幂等性带来的好处: 可以解决因为某些原因请求多次提交,产生多次影响结果的情况。比如 下单、请求支付、转账、消息发送等等场景

实现幂等性带来的坏处: 增加了实现的复杂度,复杂了原来的业务逻辑同时还增加了运维的成本

如何实现

以我们公司的退款(退积分)实现为例来讲下怎么实现幂等性操作。

网上提供了好多种方式,大概总结了下,分别说下每种实现存在的一些问题

使用悲观锁

实现:请求进入的时候开启事务,然后查询订单并加锁,然后判断订单是否符合条件等等,符合进行账户余额累加这里也要加锁,在全部执行完提交或回滚

缺点:中间环节可能会执行时间很长,容易产生锁住整表服务挂掉等

使用乐观锁

实现:乐观锁在大部分时间是不会锁表的,只有在更新的时候才会锁表。一般是通过版本号来实现的。

比如在账户表中增加 version 字段当作版本号。在进行退款的时候传递 version 字段给服务器。然后退款操作中判断订单状态,符合进行退款,在更新账户余额的时候 使用 update table set amout=amout+xxx,version=version+1 where uuid = xxx and version = xxx

即使多个重复请求进来,因为进来的version是一样的,而此时数据库中对应数据的version已经变了,已经被上一个成功执行的结果给+1了,所以那个修改条件是不成立的。也就不会更新数据进行多次增加金额

缺点:要记得使用主键或唯一索引来更新,这时是行锁不然容易变成表锁。

防重表

实现:建立一张防重表,比如可以使用订单号为唯一索引。在请求退款的时候根据订单号向防重表中添加一条订单退款记录。因为是唯一索引,所以当重发的请求进来的时候添加订单记录的话就会失败,这个时候就可以返回操作失败给前端。而第一个进入的可以完成退款操作,在退款完成后删除防重表中的记录。

缺点:多维护一个表,业务逻辑变的复杂许多。

使用token

实现:在每次退款之前需要去申请一个token,然后把token缓存起来,在发起退款的时候携带token回来,服务器校验token是否存在,存在的话就删除token,然后进行退款,流程结束。而多进入的请求因为token失效而不会执行退款操作。

缺点:流程比上面防重表更复杂

实现过程

本着不折腾不快乐的精神,我决定使用 Token+Redis 的是实现方式来改进代码.下面是一些伪代码实现

1.实现生成Token方法

1
2
3
4
5
6
7
8
9
10
11
12
function getToken($userUuid, $orderNumber)
{
$token = md5($userUuid.$orderNumber);
Redis::setex($userUuid.$orderNumber, 300, $token);
if ($setResult) {
return ['status' => true, 'data' => $token];
} else {
return ['status' => true, 'data' => '', 'message' => '设置token失败'];
}
}

2.修改退款逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public function refund($params)
{
try {
// ...省略数据校验
$token = $params['token'];
$userUuid = $params['user_uuid'];
$orderNumber = $params['order_number'];
$tokenKey = $userUuid.$orderNumber;
if ($token != Redis::get($tokenKey)) {
return ['status' => false, 'message' => '网络繁忙请稍后重试'];
}
if (Redis::delete($tokenKey)) {
// ...省略退款逻辑
} else {
return ['status' => false, 'message' => '请勿重复提交'];
}
} catch (\Exception $exception) {
return ['status' => false, 'message' => 'have some exception:'.$exception->getMessage()];
}
}
注意:需要使用删除redis-key的方式来判断token是否存在,如果你要使用先查询token,判断token存在,然后操作退款,在退款成功后在删除token这种流程的话,仔细想想在并发的时候还是会有多个请求进来,比如同时查询到都token存在。

参考

stackoverflow

microsoft-api

restfulapi

What is an idempotent operation?


-------------The End-------------