确保接口安全:六大方案有效解决幂等性问题

文章目录

六大方案解决接口幂等问题

什么是接口幂等?

幂等(idempotency)本身是一个数学概念,常见与抽象代数中,代表一个函数或操作的结果不受其输入或者执行次数的影响,例如,f(n) = 1^n,无论 n 为多少,f(n)的值永远为 1 。

在软件开发领域,幂等对请求执行结果的一个描述,这个描述就是无论执行多少次相同的请求,产生的效果和返回的结果和发出单个请求是一样的。

举个例子🌰:

  • 有时我们在填写某些form表单时,保存按钮不小心快速点了两次,表中竟然产生了两条重复的数据,只是id不一样。
  • 我们在项目中为了解决接口超时问题,通常会引入了重试机制。第一次请求接口超时了,请求方没能及时获取返回结果(此时有可能已经成功了),为了避免返回错误的结果(这种情况不可能直接返回失败吧?),于是会对该请求重试几次,这样也会产生重复的数据。

天然幂等

那有没有有些情况是天然支持幂等的呢?当然有!比如说我要更新一个某记录的状态 status = 1 具体的 sql 为 update table set status = 1 where id = 1 这种情况,无论我执行多少次这条 sql 他的效果是一样的,这就是天然支持幂等的。

不做幂等会怎么样?

比如说用户在付款的时候,同时点击多次付款按钮,后端处理了多次扣款请求,结果导致用户的账户扣了多次钱。妥妥 p0 事故呀!

到这你又会说,前端做个置灰按钮不就行了吗,第一次付款完毕后,那用户或者恶意攻击你服务器的人直接用脚本搞你不走前端,你是防止不了的。

那接下来,我将介绍六大解决接口幂等的方案,速速点赞上车!!!

解决方案

1)insert前先select

通常情况下,在保存数据的接口中,我们为了防止产生重复数据,一般会在insert前,先根据namecode字段select一下数据。如果该数据已存在,则执行update操作,如果不存在,才执行 insert操作。

该方案可能是我们平时在防止产生重复数据时,使用最多的方案。但是该方案不适用于并发场景,在并发场景中,要配合其他方案一起使用,否则同样会产生重复数据。

2)使用唯一索引

通过在表中加上唯一索引,保证数据的唯一性。如果有重复的数据插入,会抛出DuplicateKeyException异常,程序可以捕获异常并处理。不过,这种方法只适用于插入数据的场景。

sql 复制代码
create table t_order(
	id int unsigned PRIMARY KEY AUTO_INCREMENT COMMENT "主键",
    code varchar(200) not null COMMENT "流水号",
    user_id  int unsigned COMMENT "用户id",
    amount decimal(10,2) unsigned not null COMMENT "总金额",
    UNIQUE unq_code(code)
) COMMENT="订单表";

不要依靠唯一索引来保证接口幂等,但建议使用唯一索引作为兜底,避免产生脏数据

伪代码如下:

java 复制代码
public void idempotent(OrderDO orderDO){
    try {
       // 执行核心业务...
       orderMapper.insert(orderDO);
    }
    catch(DuplicateKeyException e) {
        // 有重复的数据插入
    }
}

3)去重表加悲观锁

去重表本质上也是一种唯一索引方案。去重表是一张专门用于记录请求信息的表,其中某个字段需要建立唯一索引,用于标识请求的唯一性当客户端发出请求时,服务端会将这次请求的一些信息(如订单号、交易流水号等)插入到去重表中,如果插入成功,说明这是第一次请求,可以执行后续的业务逻辑;如果插入失败,说明这是重复请求,可以直接返回或者忽略。

sql 复制代码
CREATE TABLE deduplication_table (
    id int unsigned PRIMARY KEY AUTO_INCREMENT COMMENT "主键",
    processed_code varchar(200) not null COMMENT "已处理的订单流水号",
    -- 省略其他字段
    UNIQUE unq_processed_code(processed_code)
) COMMENT="去重表";

使用for update加锁每次查询到都是最新的数据

sql 复制代码
select * from deduplication_table where processed_code = 'xxx' for update

伪代码如下:

java 复制代码
public boolean idempotent(OrderDO orderDO){
    // 执行核心业务之前
    DoMain domain = deduplicationMapper.selectForUpdate(orderDO.getProcessedCode);
    if(doamin != null) {
        // 订单已经支付
    }
}

4)加乐观锁之版本号机制

既然悲观锁有性能问题,为了提升接口性能,我们可以使用乐观锁。需要在表中增加一个timestamp或者version字段,这里以version字段为例。

在更新数据之前先查询一下数据:

sql 复制代码
select id,amount,version from user id=123;

如果数据存在,假设查到的version等于1,再使用idversion字段作为查询条件更新数据:

sql 复制代码
update user set amount = amount + 100, version = version + 1 where id = 123 and version = 1;

更新数据的同时version+1,然后判断本次update操作的影响行数,如果大于0,则说明本次更新成功,如果等于0,则说明本次更新没有让数据变更。

由于第一次请求version等于1是可以成功的,操作成功后version变成2了。这时如果并发的请求过来,再执行相同的sql:

sql 复制代码
update user set amount = amount + 100,version = version + 1 where id = 123 and version = 1;

update操作不会真正更新数据,最终sql的执行结果影响行数是0,因为version已经变成2了,where中的version=1肯定无法满足条件。但为了保证接口幂等性,接口可以直接返回成功,因为version值已经修改了,那么前面必定已经成功过一次,后面都是重复的请求。

具体流程如下:

具体步骤:

  1. 先根据id查询用户信息,包含version字段
  2. 根据id和version字段值作为where条件的参数,更新用户信息,同时version+1
  3. 判断操作影响行数,如果影响1行,则说明是一次请求,可以做其他数据操作。
  4. 如果影响0行,说明是重复请求,则直接返回成功。

5)使用 Redisson 分布式锁

基于 MySQL 也可以实现分布式锁,但一般我们不会采用这种方式。

通常情况下,我们一般会选择基于 Redis 或者 ZooKeeper 实现分布式锁,Redis 用的要更多一点。

java 复制代码
// 唯一标识
String uniqueId = "orderId123";
// 1. 根据唯一标识生成分布式锁对象
RLock lock = redisson.getLock("lock:" + uniqueId);

try {
    // 2. 尝试获取锁(Watch Dog 自动续期机制) 
    if (lock.tryLock()) {
        // 3. 如果成功获取到锁,说明请求还没有被处理,执行业务逻辑
    } else {
        // 请求已经被处理,直接返回
    }
} finally {
    // 4. 释放锁
    lock.unlock();
}

6)Token 机制

Token 机制的核心思想是为每一次操作生成一个唯一性的凭证 token。这个 token 需要由服务端生成的,因为服务端可以对 token 进行签名和加密,防止篡改和泄露。如果由客户端生成 token,可能会存在安全隐患,比如客户端伪造或重复 token,导致服务端无法识别和校验。

这样的话,就需要两次请求才能完成一次业务操作:

  1. 请求获取服务器端 token,token 需要设置有效时间(可以设置短一点),服务端将该 token 保存起来。

  2. 执行真正的请求,将上一步获取到的 token 放到 header 或者作为请求参数。服务端验证 token 的有效性,如果有效(一般是通过删除 token 的方式来验证,删除成功则有效),执行业务逻辑,并删除 token,防止重复提交;如果无效,拒绝请求,返回提示信息。

java 复制代码
// 获取token
public String getToken(Long busId, Long userId){
    String UUID = UUID.randomUUID().toString();
    stringRedisTemplate.opsForValue().set(busId+userId, UUID, 20, TimeUnit.SECONDS)
    return UUID;
}
// 发起业务请求携带token
public void doSomeBusiness(Parameter parameter) {
    Long busId = parameter.getBusId;
    Long userId = UserContext.getUserId();
    // 判断token是否存在
 	Boolean deleted = stringRedisTemplate.delete(busId+userId);
    if(deleted) {
        // 删除成功,代表重复请求不进行操作,直接返回
        return;
    }
    // doSomeBusiness...
}

具体步骤:

  1. 用户访问页面时,浏览器自动发起获取 token 请求。
  2. 服务端生成 token,保存到 redis 中,然后返回给浏览器。
  3. 用户通过浏览器发起请求时,携带该 token。
  4. 从 redis 中尝试删除 token 如果删除失败,说明是第一次请求,做则后续的数据操作。
  5. 如果删除成功,说明是重复请求,不做任何操作。
相关推荐
爱读源码的大都督4 分钟前
Java已死?别慌,看我如何用Java手写一个Qwen Code Agent,拯救Java
java·人工智能·后端
lssjzmn4 分钟前
性能飙升!Spring异步流式响应终极指南:ResponseBodyEmitter实战与架构思考
java·前端·架构
黑客飓风19 分钟前
从基础功能到自主决策, Agent 开发进阶路怎么走?
面试·log4j·bug
LiuYaoheng20 分钟前
【Android】View 的基础知识
android·java·笔记·学习
勇往直前plus28 分钟前
Sentinel微服务保护
java·spring boot·微服务·sentinel
星辰大海的精灵28 分钟前
SpringBoot与Quartz整合,实现订单自动取消功能
java·后端·算法
小鸡脚来咯31 分钟前
一个Java的main方法在JVM中的执行流程
java·开发语言·jvm
江团1io031 分钟前
深入解析三色标记算法
java·开发语言·jvm
天天摸鱼的java工程师40 分钟前
RestTemplate 如何优化连接池?—— 八年 Java 开发的踩坑与优化指南
java·后端
在未来等你41 分钟前
Kafka面试精讲 Day 7:消息序列化与压缩策略
大数据·分布式·面试·kafka·消息队列