幂等概念
在实际的开发项目中,一个对外暴露的接口往往会面临,瞬间大量的重复的请求提交,如果想过滤掉重复请求造成对业务的伤害,那就需要实现幂等!
幂等的概念:
幂等性,就是一个接口, 多次发起同一个请求,,必须保证操作只能执行一次,最终的含义就是对数据库的影响只能是一次性的,不能重复处理。
哪些情况需要防止:
- 订单接口,不能多次创建订单。
- 支付接口,重复支付同一笔订单只能扣一次钱。
- 普通表单提交接口, 因为网络超时等原因多次点击提交, 只能成功一次。
- 微服务互相调用,由于网络问题,导致请求失败。feign 触发重试机制。
以 SQL 为例,有些操作是天然幂等的。
SELECT * FROM table WHER id=?,无论执行多少次都不会改变状态,是天然的幂等。
UPDATE tab1 SET col1=1 WHERE col2=2,无论执行成功多少次状态都是一致的,也是幂等操作。
delete from user where userid=1,多次操作,结果一样,具备幂等性。
insert into user(userid,name) values(1,‘a’) 如 userid 为唯一主键,即重复操作上面的业务,只 会插入一条用户数据,具备幂等性。
UPDATE tab1 SET col1=col1+1 WHERE col2=2,每次执行的结果都会发生变化,不是幂等的。
insert into user(userid,name) values(1,‘a’) 如 userid 不是主键,可以重复,那上面业务多次操 作,数据都会新增多条,不具备幂等性。
幂等解决方案
常见解决方案:
- 唯一索引 – 防止新增脏数据
- token机制 – 防止页面重复提交
- 悲观锁 – 获取数据的时候加锁(锁表或锁行)
- 乐观锁 – 基于版本号version实现,在更新数据那一刻校验数据
- 分布式锁 – redis(jedis、redisson)或zookeeper实现
- 状态机 – 状态变更, 更新数据时判断状态
token 机制
1、服务端提供了发送 token 的接口。我们在分析业务的时候,哪些业务是存在幂等问题的, 就必须在执行业务前,先去获取 token,服务器会把 token 保存到 redis 中。
2、然后调用业务接口请求时,把 token 携带过去,一般放在请求头部。
3、服务器判断 token 是否存在 redis 中,存在表示第一次请求,然后删除 token,继续执行业 务。
4、如果判断 token 不存在 redis 中,就表示是重复操作,直接返回重复标记给 client,这样 就保证了业务代码,不被重复执行。
危险性:
1、先删除 token 还是后删除 token;
(1) 先删除可能导致,业务确实没有执行,重试还带上之前 token,由于防重设计导致, 请求还是不能执行。
(2) 后删除可能导致,业务处理成功,但是服务闪断,出现超时,没有删除 token,别 人继续重试,导致业务被执行两次。
(3) 我们最好设计为先删除 token,如果业务调用失败,就重新获取 token 再次请求。
2、Token 获取、比较和删除必须是原子性
(1) redis.get(token) 、token.equals、redis.del(token)如果这两个操作不是原子,可能导致,高并发下,都 get 到同样的数据,判断都成功,继续业务并发执行 。
(2) 可以在 redis 使用 lua 脚本完成这个操作。
if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end
各种锁机制
1、数据库悲观锁
select * from xxxx where id = 1 for update。
2、数据库乐观锁
这种方法适合在更新的场景中, update t_goods set count = count -1 , version = version + 1 where good_id=2 and version = 1
3、业务层分布式锁
各种唯一约束
1、数据库唯一约束
插入数据,应该按照唯一索引进行插入,比如订单号,相同的订单就不可能有两条记录插入。
全局请求唯一 id
调用接口时,生成一个唯一 id,redis 将数据保存到集合中(去重),存在即处理过。 可以使用 nginx 设置每一个请求的唯一 id; proxy_set_header X-Request-Id $request_id;
本文实现
通过Redis+Token机制实现接口幂等性校验。
原理图:
实现思路
为需要保证幂等性,每一次请求创建一个唯一标识token,先获取token,并将此token存入Redis,请求接口时,将此token放到header或者作为请求参数请求接口,后端接口判断Redis中是否存在此token:
- 如果存在,正常处理业务逻辑, 并从redis中删除此token, 那么,如果是重复请求, 由于token已被删除,则不能通过校验,返回请勿重复操作提示。
- 如果不存在,说明参数不合法或者是重复请求,返回提示即可。
代码实现
@AutoIdempotent 注解 + 拦截器对请求进行拦截。
@ControllerAdvice 全局异常处理
封装一个操作Redis的API工具类,使用RedisTemplate进行封装,需引入Redis的stater:
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.io.Serializable;
import java.util.concurrent.TimeUnit;
@Component
public class RedisService {
@Autowired
private RedisTemplate redisTemplate;
/**
* 写入缓存
*
* @param key
* @param value
* @return
*/
public boolean set(final String key, Object value) {
boolean result = false;
try {
ValueOperations<Serializable, Object> operations = redisTemplate.opsForValue();
operations.set(key, value);
result = true;
} catch (Exception e) {
e.printStackTrace();
}
return result;
}
/**
* 写入缓存设置时效时间
*
* @param key
* @param value
* @return
*/
public boolean setEx(final String key, Object value, Long expireTime) {
boolean result = false;
try {
ValueOperations<Serializable, Object> operations = redisTemplate.opsForValue();
operations.set(key, value);
redisTemplate.expire(key, expireTime, TimeUnit.SECONDS);
result = true;
} catch (Exception e) {
e.printStackTrace();
}
return result;
}
/**
* 判断缓存中是否有对应的value
*
* @param key
* @return
*/
public boolean exists(final String key) {
return redisTemplate.hasKey(key);
}
/**
* 读取缓存
*
* @param key
* @return
*/
public Object get(final String key) {
Object result = null;
ValueOperations<Serializable, Object> operations = redisTemplate.opsForValue();
result = operations.get(key);
return result;
}
/**
* 删除对应的value
*
* @param key
*/
public boolean remove(final String key) {
if (exists(key)) {
Boolean delete = redisTemplate.delete(key);
return delete;
}
return false;
}
}
自定义注解AutoIdempotent:
自定义一个注解,定义此注解的主要目的是把它添加在需要实现幂等的方法上,凡是某个方法注解了它,都会实现自动幂等。后台利用反射如果扫描到这个注解,就会处理这个方法实现自动幂等,使用元注解ElementType.METHOD表示它只能放在方法上,etentionPolicy.RUNTIME表示它在运行时。
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface AutoIdempotent {
}
token服务接口,token创建和检验:
createToken创建token采用随机算法工具类生成随机uuid字符串,然后放入到redis中(为了防止数据的冗余保留,这里设置过期时间为10000秒,具体可视业务而定),如果放入成功,返回这个token值。
checkToken方法就是从header中获取token到值(如果header中拿不到,就从paramter中获取),如若不存在,直接抛出异常。这个异常信息可以被拦截器捕捉到,然后返回给前端。
import javax.servlet.http.HttpServletRequest;
public interface TokenService {
// 创建token
String createToken();
// 检验token
void checkToken(HttpServletRequest request);
}
import com.demo.exception.ServiceException;
import com.demo.service.RedisService;
import com.demo.service.TokenService;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
import java.util.UUID;
@Component
public class TokenServiceImpl implements TokenService {
private static final String TOKEN_NAME = "token";
@Autowired
private RedisService redisService;
@Override
public String createToken() {
String token = UUID.randomUUID().toString();
try {
redisService.setEx(token, token, 10000L);
return token;
} catch (Exception ex) {
ex.printStackTrace();
}
return null;
}
@Override
public void checkToken(HttpServletRequest request) {
String token = request.getHeader(TOKEN_NAME);
if (StringUtils.isBlank(token)) {
// header中不存在token
token = request.getParameter(TOKEN_NAME);
if (StringUtils.isBlank(token)) {
// parameter中也不存在token
throw new ServiceException("参数不合法,必须带token参数");
}
}
if (!redisService.exists(token)) {
throw new ServiceException("请勿重复操作");
}
boolean remove = redisService.remove(token);
// 必须再次判断是否移除成功,因为可能多个请求同时执行上面移除的代码,但是最终只有一个返回移除成功的,如果不判断是否移除成功,就会失去幂等性的
if (!remove) {
throw new ServiceException("请勿重复操作");
}
}
}
拦截器处理幂等:
主要的功能是拦截扫描到AutoIdempotent到注解到方法,然后调用tokenService的checkToken()方法校验token是否正确,如果捕捉到异常就将异常信息渲染成json返回给前端。
import com.demo.annotation.AutoIdempotent;
import com.demo.service.TokenService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.lang.reflect.Method;
/**
* 接口幂等性拦截器
*/
@Component
public class AutoIdempotentInterceptor implements HandlerInterceptor {
@Autowired
private TokenService tokenService;
/**
* 预处理
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
if (!(handler instanceof HandlerMethod)) {
return true;
}
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
AutoIdempotent methodAnnotation = method.getAnnotation(AutoIdempotent.class);
if (methodAnnotation != null) {
tokenService.checkToken(request);
}
return true;
}
@Override
public void postHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, ModelAndView modelAndView) throws Exception {
}
@Override
public void afterCompletion(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, Exception e) throws Exception {
}
}
配置拦截器:
继承WebMvcConfigurationSupport,主要作用就是添加autoIdempotentInterceptor到配置类中,这样我们到拦截器才能生效,注意使用@Configuration注解,这样在容器启动是时候就可以添加进入context中。
import com.demo.interceptor.AutoIdempotentInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport;
import javax.annotation.Resource;
@Configuration
public class WebConfiguration extends WebMvcConfigurationSupport {
@Resource
private AutoIdempotentInterceptor autoIdempotentInterceptor;
@Override
protected void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(autoIdempotentInterceptor);
super.addInterceptors(registry);
}
}
业务请求类:
通过/get/token
路径通过getToken()方法去获取具体的token。
调用test/idempotence
方法,这个方法上面注解了@AutoIdempotent,拦截器会拦截所有的请求,当判断到处理的方法上面有该注解的时候,就会调用TokenService中的checkToken()方法,如果捕获到异常会将异常抛出调用者。
import com.demo.annotation.AutoIdempotent;
import com.demo.service.TokenService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
@RestController
public class BusinessController {
@Resource
private TokenService tokenService;
@GetMapping("/get/token")
public String getToken() {
String token = tokenService.createToken();
return token;
}
@AutoIdempotent
@GetMapping("/test/idempotence")
public String testIdempotence() {
return "ok";
}
}
获取Token:
第一次请求:
第二次请求:
参考:
瞬间几千次的重复提交,我用 SpringBoot+Redis 扛住了
Sprinig Boot + Redis 实现接口幂等性,写得太好了!