接口幂等设计
背景
那天下班前,突然群里说你们系统有bug, 你们的打款按钮怎么点了没反应啊? ,没过一会群里又有人反应,给用户重复打款了!,这消息一出群里立马炸锅了,跟钱相关绝非小事,通知相关人员,经排查可知,运营人员点了没反应又点了一次,谁知道后端开发竟然没有重复校验再次打款,给公司造成了很大经济损失,由此可见,我们开发时有些业务一定要做幂等处理。
幂等概念
幂等就是不管你执行多少遍,结果都是一样的。用函数表达就是f(f(x))=f(x)。
幂等问题4种根源
- 重复点击
- 恶意狂刷
- 失败重试(超时、异常)
- 重复消息(重复发送,重复消费)
需考虑幂等的3种业务场景
- 钱相关的业务(例如:抢红包,订单支付/退款,理财购买/赎回,转账等等)
- 类似钱相关的业务(例如:积分发放/使用,能量领取/使用,某某币发放/扣减等等)
- 库存相关的业务(例如:秒杀,抢购,抽奖等等)
需考虑幂等的2种操作
其实我们对业务进行深入的思考,可以把业务操作抽象为这4种操作:增、删、改和查,就是平时所说的CRUD。不管进行多少次的查和删结果都一样,可见查和删是天然的幂等,而需要我们处理的就是增和改这2种操作了。
3个层面的幂等解决方案
web层
按钮置灰/load状态
跳转结果页
- 应用层
token机制
token机制可分两步,第一步:客户端在调业务接口前,先从服务端获取token令牌,服务端把token令牌存入redis;第二步:客户端携带token调用业务接口,一般会放入请求头header中,服务端拿到token后,去redis执行删除操作,删除成功,继续后面业务处理;删除失败,则返回重复执行。如下图所示:
缺点:每次业务请求,都需要一次额外的请求。比如:在真实环境中,1W个请求有10个重复,而我们需要为了这10个请求,需要付出9990个额外请求,值吗? 个人认为:这个方案在实际工作应该用得最少。要想用的话,可以用在更新操作中,token可以随着详情接口一起下发,来减少一次获取token请求。
基于redis实现(set NX PX)
具体流程:服务端接收到请求后,根据请求参数确定唯一key,通过set NX PX命令写到redis中,写入成功,则执行下面业务逻辑,写入失败,则返回重复执行。
确定唯一key的方法如下:
1) 可以用请求参数中某一个或几个来确定唯一key
2)md5(所有请求参数)来确定唯一key
3)用户id+方法名来确定唯一key
去重表(记录表)
防重表通常是指各种记录表,如:订单记录表,支付记录表,退款记录表,积分记录表等等。流程:查询防重表是否存在记录,如果不存在,则执行下面业务逻辑,否则,返回重复执行。
状态机
很多业务会一个业务流转状态,每个状态都会有前置状态和后置状态。以订单为例,已支付的前置状态只能是待支付,而取消状态的前置状态只能是待支付,因此,可以通过这种状态机的流转来控制请求的幂等。
- 数据库层
新增操作: 唯一索引
更新操作:乐观锁
幂等总结
实际工作中,用得比较多就是基于redis实现的,最后数据库做兜底策略。
幂等实战
模拟恶意狂刷退款接口,能否成功拦截多次打款情况。
public BaseResponse<BoolResult> refund(RefundReq refundReq) {
String orderNo = refundReq.getOrderNo();
// 基于redis实现幂等
Boolean result = redisClient.setNx("refund:" + orderNo,orderNo, 5000);
if (!result) {
throw new RuntimeException("重复执行!");
}
OrderInfo orderInfo = baseMapper.selectOne(new LambdaQueryWrapper<OrderInfo>().eq(OrderInfo::getOrderNo,orderNo));
if (orderInfo == null) {
throw new RuntimeException("订单不存在!");
}
if (1 != orderInfo.getStatus().intValue()) {
throw new RuntimeException("只有支付订单才能退款!");
}
if (0 != orderInfo.getRefundStatus().intValue()) {
throw new RuntimeException("只有未退款的订单才能退款!");
}
RefundInfo refundInfo = new RefundInfo();
refundInfo.setOrderNo(orderNo);
refundInfo.setStatus(0);
refundInfo.setAmount(orderInfo.getAmount());
refundInfoMapper.insert(refundInfo);
return BaseResponse.ok(new BoolResult(true));
}
结果如下:
本文暂时没有评论,来添加一个吧(●'◡'●)