4.解决方案
4.1 inset之前先select
通常情况下,在保存数据的接口中,我们为了防止产生重复数据,一般会在insert前,先根据name字段select一下数据。如果该数据已存在,则执行update操作,如果不存在,才执行 insert操作。
4.2 数据库唯一索引
大多数情况下,我们为了防止数据重复提交,我们都会在表中添加唯一索引,这个一个非常简单而且有奇效的方案。
alter table order
add UNIQUE KEY t_code
(code
);
加了唯一索引之后,第一次请求数据可以插入成功。但后面的相同请求,插入数据时会报Duplicate entry ‘002’ for key 'order.t_code异常,表示唯一索引有冲突。
4.3 Token机制
针对客户端连续点击或者调用方的超时重试等情况,例如提交订单,此种操作就可以用 Token 的机制实现防止重复提交。简单的说就是调用方在调用接口的时候先向后端请求一个全局 ID(Token),请求的时候携带这个全局 ID 一起请求**(Token 最好将其放到 Headers 中)**,后端需要对这个 Token 作为 Key,用户信息作为 Value 到 Redis 中进行键值内容校验,如果 Key 存在且 Value 匹配就执行删除命令,然后正常执行后面的业务逻辑。如果不存在对应的 Key 或 Value 不匹配就返回重复执行的错误信息,这样来保证幂等操作。
该方案跟之前的所有方案都有点不一样,需要两次请求才能完成一次业务操作。
第一次请求获取token
第二次请求带着这个token,完成业务操作。
4.4 悲观锁机制
比如某银行有个转账场景,用户A手里有200块钱,想转出100元,正常情况下,用户A转账之后只剩下了100元,SQL如下:
update user amount = amount-100 where id=888;
但是实际情况下,并非如此,如果有个相同的请求进来,用户A的账户就会一直扣减,直到变成负数。这种情况,用户A直接哭死,在业务场景中也是不允许出现的。
通常情况下通过如下sql锁住单行数据:
select * from user id=888 for update;
「具体步骤:」
多个请求同时根据id查询用户信息。
判断余额是否不足100,如果余额不足,则直接返回余额不足。
如果余额充足,则通过for update再次查询用户信息,并且尝试获取锁。
只有第一个请求能获取到行锁,其余没有获取锁的请求,则等待下一次获取锁的机会。
第一个请求获取到锁之后,判断余额是否不足100,如果余额足够,则进行update操作。
如果余额不足,说明是重复请求,则直接返回成功。
悲观锁需要在同一个事务操作过程中锁住一行数据,如果事务耗时比较长,会造成大量的请求等待,影响接口性能。
此外,每次请求接口很难保证都有相同的返回值,所以不适合幂等性设计场景,但是在防重场景中是可以的使用的。
4.5 乐观锁机制
因为悲观锁是比较消耗的性能的操作,那么我们为了提高接口性能,完全可以使用乐观锁。需要再表中添加一个version字段。
比如:
alter table user add version int(2);
每当我们更新数据的时候,需要对我们的版本号+1
update user set amount = amount+100,version=version+1 where id = 888 and version =1
更新数据的同时version+1,然后判断本次update操作的影响行数,如果大于0,则说明本次更新成功,如果等于0,则说明本次更新没有让数据变更。
等到下一个请求过来的时候,依然回去执行这行SQL,此时发现,根本不可能满足,version= 1 这个条件,因为version值已经修改了,那么前面必定已经成功过一次,后面都是重复的请求
「具体步骤:」
先根据id查询用户信息,包含version字段
根据id和version字段值作为where条件的参数,更新用户信息,同时version+1
判断操作影响行数,如果影响1行,则说明是一次请求,可以做其他数据操作。
如果影响0行,说明是重复请求,则直接返回成功。
5.代码实践
以上我们都是给出了一些大概的解决方案跟思路,接下来Leo哥大家以Token机制为例,用代码实现如果解决接口的幂等性。
首先我们来回顾一下Token机制的整个流程。
首先准备一个springboot工程项目,只需要添加两个依赖即可。
package org.javatop.idempotent.token;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
/**
* @author : Leo
* @version 1.0
* @date 2024-02-01 21:03
* @description :
*/
@Component
public class RedisService {
@Autowired
private RedisTemplate redisTemplate;
public boolean setEx(String key, Object value, Long expireTime) {
boolean result = false;
try {
ValueOperations ops = redisTemplate.opsForValue();
ops.set(key, value);
redisTemplate.expire(key, expireTime, TimeUnit.SECONDS);
result = true;
} catch (Exception e) {
e.printStackTrace();
}
return result;
}
/**
* 判断key是否存在
* @param key key
* @return
*/
public boolean exists(String key) {
return Boolean.TRUE.equals(redisTemplate.hasKey(key));
}
/**
* 删除key
* @param key key
* @return
*/
public boolean remove(String key) {
if (exists(key)) {
return Boolean.TRUE.equals(redisTemplate.delete(key));
}
return false;
}
}
主要是生成一个全局唯一不重复的Token,以及前端请求过来被拦截后需要检验token的方法。
package org.javatop.idempotent.token;
import io.micrometer.common.util.StringUtils;
import jakarta.servlet.http.HttpServletRequest;
import org.javatop.idempotent.exception.IdempotentException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.UUID;
/**
* @author : Leo
* @version 1.0
* @date 2024-02-01 21:01
* @description :
*/
```java
@Component
public class TokenService {
@Autowired
RedisService redisService;
public String createToken() {
String uuid = UUID.randomUUID().toString();
redisService.setEx(uuid, uuid, 10000L);
return uuid;
}
public boolean checkToken(HttpServletRequest request) throws IdempotentException {
String token = request.getHeader("token");
if (StringUtils.isEmpty(token)) {
token = request.getParameter("token");
if (StringUtils.isEmpty(token)) {
throw new IdempotentException("token 不存在");
}
}
if (!redisService.exists(token)) {
throw new IdempotentException("重复的操作");
}
boolean remove = redisService.remove(token);
if (!remove) {
throw new IdempotentException("重复的操作");
}
return true;
}
}
「自定义幂等注解」
我们自定义一个幂等注解,来对我们想要幂等性一致的接口进行标识。
```java
/**
* @author : Leo
* @version 1.0
* @date 2024-02-01 21:17
* @description : 幂等注解
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AutoIdempotent {
在拦截器中,我们解析出所有的请求,标注有幂等注解的请求,我们去检验他的token,然后来决定下一步操作。
package org.javatop.idempotent.interceptor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.javatop.idempotent.annotation.AutoIdempotent;
import org.javatop.idempotent.exception.IdempotentException;
import org.javatop.idempotent.token.TokenService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
/**
- @author : Leo
- @version 1.0
- @date 2024-02-01 21:14
- @description :
*/
@Component
public class IdempotentInterceptor implements HandlerInterceptor {
@Autowired
TokenService tokenService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (!(handler instanceof HandlerMethod)) {
return true;
}
HandlerMethod handlerMethod = (HandlerMethod) handler;
AutoIdempotent idempotent = handlerMethod.getMethod().getAnnotation(AutoIdempotent.class);
if (idempotent != null) {
try {
return tokenService.checkToken(request);
} catch (IdempotentException e) {
throw e;
}
}
return true;
}
首先生成一个Token,然后把这个token放到hello接口的请求头上面。
可以看到,第一次可以正常访问接口
但当你第二次访问该接口的时候,已经提示你操作重复了。因为在我们第一次访问接口之后,就把Redis中的token删除了。