接口设计与重试机制引发的问题
什么是幂等性【数学性的概念】-----f(f(x))=f(x)
解释:
数学方面:幂等元素运行多次,还等于它原来的运算结果。
程序方面:在系统中,一个接口运行多次,与运行一次的效果是一致的。
为什么会产生接口幂等性问题
网络波动, 可能会引起重复请求。
用户重复操作,用户在操作时候可能会无意触发多次下单交易,甚至没有响应而有意触发多次交易应用。
使用了失效或超时重试机制(Nginx重试、RPC重试或业务层重试等)。
使用浏览器后退按钮重复之前的页面操作,导致重复提交表单。
注意:
不是所有接口都要求幂等性,要根据业务而定。
Select操作:不会对业务数据有影响,天然幂等。
select * from user where user_id = 1;
Delete操作:第一次已经删除,第二次也不会有影响。
delete from user where user_id = 1;
Update操作:更新操作传入数据版本号,通过乐观锁实现幂等性。
update user set username = 'zhangsan' where user_id = 1 【这个没有问题】
update user set age = age + 1 where user_id = 1 【这个回出现问题】
insert操作:此时没有唯一业务单号,使用Token保证幂等
insert into order(pkid, order_id, xx) values (1, '20210304020226953568', ...);
如何保证接口幂等性
一个方向是客户端防止重复调用
一个是服务端进行校验
接口设计与重试机制引发的问题演示_项目搭建
创建springboot类型项目:
在pom.xml中加入先关依赖:
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.6.13</version> <relativePath/> <!-- lookup parent from repository --> </parent> <groupId>com.ss.demo</groupId> <artifactId>springboottest</artifactId> <version>0.0.1-SNAPSHOT</version> <name>springboottest</name> <description>Demo project for Spring Boot</description> <properties> <java.version>1.8</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.4.2</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>5.1.48</version> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> <exclusions> <exclusion> <groupId>org.junit.vintage</groupId> <artifactId>junit-vintage-engine</artifactId> </exclusion> </exclusions> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-generator</artifactId> <version>3.5.2</version> </dependency> <!-- 模板引擎 --> <dependency> <groupId>org.apache.velocity</groupId> <artifactId>velocity-engine-core</artifactId> <version>2.0</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project> |
修改application.yml
spring: application: name: springboottest datasource: driver-class-name: com.mysql.jdbc.Driver url: jdbc:mysql://localhost:3306/usertt?useSSL=false username: root password: 123456 thymeleaf: cache: false redis: host: localhost port: 6379 mybatis-plus: mapper-locations: classpath:mapper/*.xml server: port: 9000 |
创建数据库usertt
创建表users
CREATE TABLE users( id BIGINT(20) NOT NULL COMMENT '主键ID', name VARCHAR(30) NULL DEFAULT NULL COMMENT '姓名', age INT(11) NULL DEFAULT NULL COMMENT '年龄', PRIMARY KEY (id) ); -- 添加用户数据 INSERT INTO users (id, NAME, age) VALUES (1, 'admin', 18), (2, 'root', 20), (3, 'mysql', 28), (4, 'tom', 21 ), (5, 'lili', 24) |
通过mybatis-plus工具生成相关接口和类:
在项目com.ss.demo.service的IusersService接口中进行修改操作
package com.ss.demo.service;
import com.baomidou.mybatisplus.extension.service.IService; import com.ss.demo.domain.Users;
import java.util.List;
public interface IUsersService extends IService<Users> {
/** * 查询所有用户 * @return */ List<Users> findAll();
/** * 创建用户 * @param name * @param age * @return */ Integer create(String name ,Integer age);
/** * 根据Id查询用户信息 * @param id * @return */ Users findById(Long id);
/** * 修改用户信息 * @param user * @return */ Integer update(Users user); } |
实现类UsersServiceImpl的编写:
package com.ss.demo.service.impl; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.ss.demo.domain.Users; import com.ss.demo.mapper.UsersMapper; import com.ss.demo.service.IUsersService; import org.springframework.stereotype.Service; import java.util.List;
/** * <p> * 服务实现类 * </p> */ @Service public class UsersServiceImpl extends ServiceImpl<UsersMapper, Users> implements IUsersService {
@Override public List<Users> findAll() { return baseMapper.selectList(null); }
@Override public Integer create(String name, Integer age) { Users user = new Users(); user.setName(name); user.setAge(age); return baseMapper.insert(user); }
@Override public Users findById(Long id) { return baseMapper.selectById(id); }
@Override public Integer update(Users user) { return baseMapper.updateById(user);
} } |
编写控制层在包com.ss.demo.controller中的UsersController中进行操作
package com.ss.demo.controller;
import com.ss.demo.domain.Users; import com.ss.demo.service.IUsersService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.servlet.ModelAndView;
import java.util.List;
/** * <p> * 前端控制器 * </p> * */ @Controller @RequestMapping("/users") public class UsersController {
@Autowired private IUsersService usersService;
/** * 跳转首页 * @return */ @GetMapping("/index") public ModelAndView list(){ ModelAndView modelAndView = new ModelAndView(); List<Users> all = usersService.findAll(); modelAndView.setViewName("index"); modelAndView.addObject("users",all); return modelAndView; }
/** * 跳转添加页面 * @return */ @GetMapping("/toadduser") public String toadduser() { return "add"; }
/** * 创建用户 * @param name * @param age * @return */ @PostMapping("/create") public String create(String name,Integer age){ Integer integer = usersService.create(name, age); if (integer == 1){ return "redirect:/users/index"; } return "addUser"; }
/** * 根据id查询用户操作 * @param id * @return */ @GetMapping("/findById") public ModelAndView getByUserId(Long id){ Users user = usersService.findById(id); ModelAndView modelAndView = new ModelAndView(); modelAndView.addObject("user",user); modelAndView.setViewName("update"); return modelAndView; }
/** * 修改操作 * @param user * @return */ @PostMapping("/update") public String update(Users user){ Integer update = usersService.update(user); if (update == 1){ return "redirect:/users/index"; } return "update"; } } |
创建页面:
index.html:
<!DOCTYPE html> <html lang="en" xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>Title</title> </head> <body style="text-align: center">
<div> <table border="1" align="center"> <tr> <th>id</th> <th>名字</th> <th>年纪</th> <th>操作</th> </tr> <tr th:each="u:${users}"> <td th:text="${u.id}"></td> <td th:text="${u.name}"></td> <td th:text="${u.age}"></td> <td><a th:href="@{/users/findById(id=${u.id})}">更新</a></td> </tr> </table>
<a href="/users/toadduser">注册用户</a> </div> </body> </html> |
通过链接跳转到添加页面add.html
注意我们在添加的时候可能会添加失败,是因为主键Id的问题,
实体Id改成下面方式
/** * 主键ID */ @TableId(value = "id", type = IdType.ASSIGN_ID) private Long id; |
add.html:
<!DOCTYPE html> <html lang="en"xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>注册用户</title> </head> <body style="text-align: center"> <div> <form method="post" action="/users/create"> <label>名字:</label><input name="name" type="text" placeholder="请输入名字"> <label>年纪:</label><input name="age" type="number" placeholder="请输入年纪"> <input type="submit" value="注册"> </form> </div> </body> </html> |
添加后
我们测试下载添加过程中出问题的点把controller中的添加增加5秒延时我们看下:
修改controller:
/** * 创建用户 * @param name * @param age * @return */ @PostMapping("/create") public String create(String name,Integer age) throws InterruptedException { Thread.sleep(5000L); Integer integer = usersService.create(name, age); if (integer == 1){ return "redirect:/users/index"; } return "addUser"; } |
启动项目我们在添加页面5秒内持续的进行点击操作:
上图模拟网络延迟操作
我们看我们添加完后的页面你会发现添加好多数据
接口幂等性设计_insert操作幂等性原理,其实我们就是通过分布式锁的方式解决接口幂等性的问题
请求流程
流程:
为需要保证幂等性的每一次请求创建一个唯一标识token, 先获 取token, 并将此token存入redis, 请求接口时, 将此token放到 header或者作为请求参数请求接口, 后端接口判断redis中是否 存在此token,如果存在, 正常处理业务逻辑, 并从redis中删除此 token, 那么, 如果是重复请求, 由于token已被删除, 则不能通过 校验, 返回重复提交如果不存在, 说明参数不合法或者是重复请 求, 返回提示即可。
接口幂等性设计_insert操作幂等性实现
添加Redis依赖,这里之前已经添加过了
我们在controller中修改方法toadduser生成一个token
/** * 跳转添加页面 * @return */ @GetMapping("/toadduser") public ModelAndView toadduser() { ModelAndView mav = new ModelAndView(); mav.setViewName("add"); //通过UUID来设置token String token = UUID.randomUUID().toString().replaceAll("-",""); //把数据保存到Redis中 redisTemplate.opsForValue().set(token,Thread.currentThread().getId() +""); //返回token到添加add页面 mav.addObject("token", token); return mav; } |
首先我们要修改add.html我们要生成一个token
<!DOCTYPE html> <html lang="en"xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>注册用户</title> </head> <body style="text-align: center"> <div> <form method="post" action="/users/create"> <!--把token隐藏到页面中--> <input hidden type="text" th:value="${token}" name="token" > <label>名字:</label><input name="name" type="text" placeholder="请输入名字"> <label>年纪:</label><input name="age" type="number" placeholder="请输入年纪"> <input type="submit" value="注册"> </form> </div> </body> </html> |
进行自定义注解操作,即添加了该注解的接口要实现幂等性验证。
新建包com.ss.demo.config并新建自定义接口
package com.ss.demo.config; 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 ApidempotentAnn { boolean value() default true; //这个注解有个属性默认值为true } |
创建拦截器MyInteractor在包com.ss.demo.config下
package com.ss.demo.config;
import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse;
@Component public class MyInteractor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//TODO
return false; } } |
们在controller中修改方法create加入自定义注解:
/** * 创建用户 * @param name * @param age * @return */ @ApidempotentAnn @PostMapping("/create") public String create(String name,Integer age) throws InterruptedException { Thread.sleep(5000L); Integer integer = usersService.create(name, age); if (integer == 1){ return "redirect:/users/index"; } return "addUser"; } |
在包com.ss.demo.config下设定拦截器规则类MyWebConfigurer
package com.ss.demo.config; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import java.util.ArrayList; import java.util.List; @Configuration public class MyWebConfigurer implements WebMvcConfigurer { @Autowired private MyInteractor myInteractor; //设定拦截规则 @Override public void addInterceptors(InterceptorRegistry registry) { //设定不拦截的集合 List<String> list = new ArrayList<String>(); list.add("/users/index"); list.add("/users/toadduser"); registry.addInterceptor(myInteractor).excludePathPatterns(list); } } |
我们现在要用拦截去判断在controller中哪个方法上面带了@ApidempotentAnn这个注解的我们就使用接口幂等性没有的就不用
package com.ss.demo.config; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; import org.springframework.web.method.HandlerMethod; import org.springframework.web.servlet.HandlerInterceptor; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.PrintWriter; import java.lang.reflect.Method;
@Component public class MyInteractor implements HandlerInterceptor {
@Autowired private StringRedisTemplate redisTemplate;
/** * 验证token的有效性 * @param request * @return */ private boolean checkToken(HttpServletRequest request) { // 获取token String token = request.getParameter("token"); if(token == null || "".equals(token)) { return false; } //删除redis中的token return redisTemplate.delete(token); }
@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//HandlerMethod 封装了很多属性,在访问请求方法的时候可以方便的访问到方法参数及方法上的注解 HandlerMethod handlerMethod = (HandlerMethod) handler; //获得方法,这个方法就是create方法 Method method = handlerMethod.getMethod();
//判断这个方法上有没有添加幂等性的注解 boolean flag = method.isAnnotationPresent(ApidempotentAnn.class); // 判断是否开启幂等性处理。 if(flag && method.getAnnotation(ApidempotentAnn.class).value()) { // 验证接口幂等性 boolean checkToken = this.checkToken(request); if(checkToken) { return true; //放行 } else { //返回相关的错误提示信息 response.setContentType("application/json;charset=utf-8"); PrintWriter out = response.getWriter(); out.print("重复调用"); out.close(); response.flushBuffer(); return false; } } return false; } } |
如果是修改的话:
Mapper接口:
/**
* <p>
* Mapper 接口
* </p>
*
* @author lioajingjing
* @since 2023-05-18
*/
public interface UsersMapper extends BaseMapper<Users> {
Integer updateAge(@Param("id") Long id);
}
Mapper.xml
<update id="updateAge">
update users set age= age+1 where id=#{id}
</update>
service:
/**
* 更新年龄
* @param users
* @return
*/
Integer updateAge(Users users);
serviceimpl:
@Override
public Integer updateAge(Users users) {
return usersMapper.updateAge(users.getId());
}
controller:
@RequestMapping("/update")
public String update(Users users) throws InterruptedException {
Thread.sleep(5000);
Integer index= usersService.updateAge(users);
if(index == 1){
return "redirect:/users/index";
}
return "update";
}
update.html
<!DOCTYPE html>
<html lang="en"xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>更新用户</title>
</head>
<body style="text-align: center">
<div>
<form method="post" action="/users/update">
<input name="id" hidden th:value="${user.id}">
<label>名字:</label><input name="name" type="text" th:value="${user.name}">
<label>年纪:</label><input name="age" type="number" th:value="${user.age}">
<input type="submit" value="更新">
</form>
</div>
</body>
</html>
怎么解决呢: