不码不疯魔 2023-02-08 00:45 发表于四川
收录于合集
#接口重复提交1个
#幂等性1个
#接口幂等性2个
#高薪应用场景3个
20
23
不疯魔不成活,大家好呀,我是科哥,江湖ID 不码不疯魔
真实场景:在一次工作中进行成品出库创建成品出库单时,手抖了一下,重复点击了两次确定(提交表单)。结果很神奇的发现居然产生了两笔一模一样的数据(流水号都一样),当时就很懵逼,稍作思考,想想应该是在同一时刻创建了两个出库单。感觉很有意思(因为之前没有遇到过,写代码的时候也没有考虑到这个问题的发生),后面换了一种方式复现场景:提交表单时多次点击Enter按钮,还是会产生多笔重复数据。
分析大致的来源有以下可能对于这样的脏数据,初步分析大致的来源有以下可能:
-
前端/客户端因为手抖在很短的时间内重复点击了或者用户填写完成提交,很多时候会因网络波动没有及时对用户做出提交成功响应,致使用户认为没有成功提交,然后一直点提交按钮,这时就会发生重复提交表单请求;
-
黑客或恶意用户使用 postman 等网络工具,重复恶意提交表单;
-
大部分RPC框架[比如Dubbo],为了防止网络波动超时等造成的请求失败,都会添加重试机制,导致一个请求提交多次;
-
当使用 MQ 消息中间件时候,如果Consumer消费超时或者producer发送了消息但由于网络原因未收到ACK导致消息重发,都会导致重复消费;
这些情况都可能会导致表单重复提交,造成数据重复,比如订单表,重复提交订单数据所造成的问题,可能不仅仅是数据上的混乱,也会造成业务混乱。那么问题来了,该如何防止用户重复提交数据呢?其实很简单,只要保证接口幂等性设计即可。
问
什么是接口幂等?
在计算机中编程中,一个幂等操作的特点是其任意多次执行所产生的影响均与第一次执行的影响相同。
接口的幂等性实际上就是接口可重复调用,在调用方多次调用的情况下,接口最终得到的结果是一致的。有些接口可以天然的实现幂等性,比如查询接口,对于查询来说,查询一次和两次,对于系统来说,没有任何影响,查出的结果也是一样。
问
哪些接口需要幂等?
幂等性的实现与判断需要消耗一定的资源,因此不应该给每个接口都增加幂等性判断,要根据实际的业务情况和操作类型来进行区分。例如,在进行查询操作和删除操作时就无须进行幂等性判断。
查询操作查一次和查多次的结果都是一致的,因此无须进行幂等性判断。删除操作也是一样,删除一次和删除多次都是把相关的数据进行删除(这里的删除指的是条件删除而不是删除所有数据),因此也无须进行幂等性判断。
所以到底哪些接口需要幂等?关于这个问题需要从具体业务出发,但是也有规律可循如下表:
问
如何实现幂等?
01
前端拦截
前端拦截是指通过 Web 站点的页面进行请求拦截,比如在用户点击完“提交”按钮后,可以把按钮设置为不可用或者隐藏状态,避免用户重复点击。
该方法可以解决用户误操作提交两次表单所产生的重复提交问题。但前端拦截有一个致命的问题,如果是懂行的程序员或者黑客可以直接绕过页面的 JS 执行,直接模拟请求后端的接口,这样的话,前端的这些拦截就不能生效了。因此除了前端拦截一部分正常的误操作之外,后端的验证必不可少。
02
请求唯一ID+数据表增加唯一索引约束
下面以防止重复提交订单为例,向大家介绍最简单的、成本最低的解决办法。先来看一张图,这张图就是本次方案的核心流程图。
实现的逻辑,流程如下:
1. 当用户进入订单提交界面的时候,调用后端获取请求唯一ID,并将唯一ID值埋点在页面里面;
2. 当用户点击提交按钮时,后端检查这个唯一ID是否用过,如果没有用过,继续后续逻辑;如果用过,就提示重复提交
3. 最关键的一步操作,就是把这个唯一ID 存入业务表中,同时设置这个字段为唯一索引类型,从数据库层面做防止重复提交
防止重复提交的大体思路如上,实践代码如下!
2.1 给数据库表增加唯一键约束
以订单表为例,新增一个request_id字段,并设置为唯一约束,结构如下:
CREATE TABLE tb_order (
id bigint(20) unsigned NOT NULL,
order_no varchar(100) NOT NULL,
....
request_id varchar(36) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uniq_request_id (request_id) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
2.2 编写获取请求唯一ID的接口
@RestController
@RequestMapping("api")
public class CommonController {
/**
* 获取getRequestId
* @return
*/
@RequestMapping("getRequestId")
public ResResult getRequestId(){
String uuid = UUID.randomUUID().toString();
return ResResult.getSuccess(uuid);
}
}
2.3 业务提交的时候,检查唯一ID
@RestController
@RequestMapping("order")
public class OrderController {
@Autowired
private OrderService orderService;
/**
* 下单
* @param request
* @return
*/
@PostMapping(value = "order/confirm")
public ResResult confirm(@RequestBody OrderConfirmRequest request){
//调用订单下单相关逻辑
if(StringUtils.isEmpty(request.getRequestId())){
return ResResult.getSysError("请求ID不能为空!");
}
if(request.getRequestId().length() != 36){
return ResResult.getSysError("请求ID格式错误!");
}
//检查当前请求唯一ID,是否已经存在,如果存在,再提交就是重复下单
Order source = orderService.queryByRequestId(request.getRequestId());
if(Objects.nonNull(source)){
return ResResult.getSysError("当前订单已经提交成功,请勿重复提交");
}
orderService.confirm(request);
return ResResult.getSuccess();
}
}
对于下单流量不算高的系统,可以采用这种请求唯一ID+数据表增加唯一索引约束的方式,来防止接口重复提交!虽然简单粗暴,但是十分有效!
可能有的人会问,看上面的代码生成请求唯一 ID 很简单,为啥不直接前端生成一个请求唯一ID,然后提交呢?
之所以把获取请求唯一ID的生成规则放在后端,好处就是生成规则可以自己定义,也并不一定要用uuid来生成,也可以用雪花算法,或者自己设计一套计算规则,保证当前业务提交时请求ID是唯一的,比如事先生成唯一的订单号,作为请求唯一ID,然后再提交,规则放在后端来生成,会更加灵活!
03
分布式锁+全局唯一的ID=Redis+Token
分布式锁实现:分布式锁实现解决JVM锁实现单机锁局限问题
具体流程步骤:
1. 客户端先请求服务端,会拿到一个能代表这次请求业务的唯一字段
2. 将该字段以 SETNX 的方式存入 redis 中,并根据业务设置相应的超时时间
3. 如果设置成功,证明这是第一次请求,则执行后续的业务逻辑
4. 如果设置失败,则代表已经执行过当前请求,直接返回
Token实现:生成唯一ID
具体流程步骤:
1. 客户端会先发送一个请求去获取 token,服务端会生成一个全局唯一的 ID 作为 token 保存在 redis 中,同时把这个 ID 返回给客户端
2. 客户端第二次调用业务请求的时候必须携带这个 token
3. 服务端会校验这个 token,如果校验成功,则执行业务,并删除 redis 中的 token
4. 如果校验失败,说明 redis 中已经没有对应的 token,则表示重复操作,直接返回指定的结果给客户端
注意:
对 redis 中是否存在 token 以及删除的代码逻辑建议用 Lua 脚本实现,保证原子性
全局唯一 ID 可以用百度的 uid-generator、美团的 Leaf 去生成
总结:幂等性不但可以保证程序正常执行,还可以杜绝一些垃圾数据以及无效请求对系统资源的消耗。推荐使用分布式锁来实现,这样的解决方案更加通用。
最后给大家分享一句话:看到未来的人,更懂得为自己的IT梦想奋斗!
不码不疯魔
深耕IT技术,从事多年大项目开发+多年IT教育培训高级讲师,分享我的工作经验与教育经验。更加关注底层码农、自学、培训、转行,专注项目实战,坚持输出干货,想靠技术和才华苟且的程序员。
38篇原创内容
公众号