目录
幂等性
- 什么是幂等性
- 幂等性: f(f(x)) = f(x)
- 幂等元素运行多次,还等于它原来的运算结果
- 在系统中,一个接口运行多次,与运行一次的效果是一致的
- 幂等性设计的核心思想
- 通过唯一的业务单号保证幂等性
- select、update、delete、insert和混合操作的接口幂等性
接口设计与重试机制引发的问题
接口幂等性
- 提交订单按钮如何防止重覆提交?
- 表单录入页如何防止重覆提交?
- 微服务接口,客户端重试时,会对业务数据产生影响吗?
- 什么情况下需要幂等性
- 重覆提交、接口重试、前端操作抖动等
- 业务场景
- 用户多次点击提交订单,后台应只生成一个订单
- 支付时,由于网络问题重发,应该只扣一次钱
- 并非所有的接口都需要幂等性,要根据业务而定
- 如何保证幂等性的策略有哪些?
- 非并发情况下:
- 查询业务单号有没有操作过,没有则执行操作
- 并发的情况下:
- 整个操作过程加锁 (分布式锁)
- 操作
- Select 不会对业务数据有影响,天然幂等性
- Delete 第一次已经删除,第二也不会有影响
-
- Update 更新操作传入数据版本号,通过乐观锁实现幂等性
-
- Insert 此时没有唯一业务单号,使用Token保证幂等性
- 混合 找到操作的唯一业务单号,有则可使用分布式锁,若没有通过Token保证幂等性
操作
准备工作
DDL
CREATE TABLE `t_user` (
`id` int NOT NULL AUTO_INCREMENT,
`username` varchar(255) NOT NULL,
`sex` int NOT NULL DEFAULT '0' COMMENT '0::未填写 1:男 2:女',
`age` int DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
1-2 Delete操作的幂等性
- 根据唯一业务员去删除
- 第一次删除时,已将数据删除
- 第二次再次执行时,由于找不到记录,所以返回的结果是0,对业务数据没有影响。可在删除前进行数据的查询。
- 删除操作没有唯一业务号,则要看具体的业务需求
- 例如:删除所有审核未通过的商品
- 第一次执行,将所有未通过审核的商品删除
- 在第二次执行前,又有新的商品未审核通过
- 执行第二次删除操作,新的审核通过的商品要不要删除?
- 根据业务需求而定
流程与代码
- 创建用户表
- 创建服务层
- 创建控制层
- 编写HTML
- 插入一条记录到数据库
- 访问:http://localhost:8080/user/userList
依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
配置
logging.pattern.dateformat=HH:mm:ss
spring.thymeleaf.cache=false
业务层
@Slf4j
@Service
public class UserService {
@Resource
private UserMapper userMapper;
public List<User> getAllUser() {
return userMapper.selectAllUsers();
}
public int delUser(Integer userId) {
User user = userMapper.selectByPrimaryKey(userId);
if (user != null) {
log.info("用户存在,用户为:[{}]",userId);
return userMapper.deleteByPrimaryKey(userId);
}
log.info("用户不存在,用户为:[{}]",userId);
return 0;
}
}
DAO
@Select("select * from t_user")
@ResultMap("BaseResultMap")
List<User> selectAllUsers();
控制层
@Controller
@RequestMapping("user")
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
public class UserController {
private final UserService userService;
@RequestMapping("userList")
public String userList(ModelMap map) {
List<User> users = userService.getAllUser();
map.addAttribute("users", users);
return "user/user-list";
}
@RequestMapping("delUser")
@ResponseBody
public Map<String, Object> delUser(@RequestParam Integer userId) {
int result = userService.delUser(userId);
Map<String, Object> map = new HashMap<>(16);
// 通过返回给前端的 status 来作为幂等标识
map.put("status", result);
return map;
}
}
user-list.html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<title>user-list</title>
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
</head>
<body>
<table border="1" style="margin: 0 auto;width: 300px">
<thead>
<tr>
<th>用户ID</th>
<th>用户名</th>
<th>性别</th>
<th>年龄</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr th:each="user:${users}">
<td th:text="${user.id}"></td>
<td th:text="${user.username}"></td>
<td th:text="${user.sex}"></td>
<td th:text="${user.age}"></td>
<td>
<button type="button" name="del" th:attr="userId=${user.id}">
删除
</button>
</td>
</tr>
</tbody>
</table>
<script>
$(function () {
$(document).on("click", "button[name=del]", function () {
var userId = $(this).attr("userId");
$.get("/user/delUser?userId=" + userId, function (reps) {
if (reps.status === 1) {
setTimeout(function () {
location.reload();
},5000);
}
})
})
});
</script>
</body>
</html>
跟住用户ID删除,通过返回给前端的 status 业务号来作为幂等标识
1-3 Update操作的幂等性
- 根据唯一业务号去更新数据的情况
- 用户查询出要修改的数据,系统将数据返回页面,将数据版本号放入隐藏域
- 用户修改数据,点击提交,将版本号一同提交给后台
- 后台使用版本号作为更新条件
- update set version = version +1, xxx=$ where id = xxx and version = $
- 更新操作没有唯一业务号,可以使用Token机制
流程与代码
删除旧生成的 pojo、Mapper、Mapper.xml, 在使用 Mybatis-Generator 重新生成
DDL
CREATE TABLE `t_user` (
`id` int NOT NULL AUTO_INCREMENT,
`username` varchar(255) NOT NULL,
`sex` int NOT NULL DEFAULT '0' COMMENT '0::未填写 1:男 2:女',
`age` int DEFAULT NULL,
`update_count` int NOT NULL DEFAULT '0',
`version` int NOT NULL DEFAULT '1' COMMENT '乐观锁',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
业务层
@Slf4j
@Service
public class UserService {
@Resource
private UserMapper userMapper;
public List<User> getAllUser() {
return userMapper.selectAllUsers();
}
public int delUser(Integer userId) {
User user = userMapper.selectByPrimaryKey(userId);
if (user != null) {
log.info("用户存在,用户为:[{}]", userId);
return userMapper.deleteByPrimaryKey(userId);
}
log.info("用户不存在,用户为:[{}]", userId);
return 0;
}
public User selectById(Integer userId) {
return userMapper.selectByPrimaryKey(userId);
}
public int updateUser(User user) {
return userMapper.updateUser(user);
}
}
dao - 追加
@Select("select * from t_user")
@ResultMap("BaseResultMap")
List<User> selectAllUsers();
int updateUser(User record);
mapper - 追加
<update id="updateUser">
update t_user
<set>
<if test="username != null">
username = #{username,jdbcType=VARCHAR},
</if>
<if test="sex != null">
sex = #{sex,jdbcType=INTEGER},
</if>
<if test="age != null">
age = #{age,jdbcType=INTEGER},
</if>
update_count = update_count + 1,
version = version + 1,
</set>
where id = #{id,jdbcType=INTEGER}
and
version = #{version,jdbcType=INTEGER}
</update>
控制层 - 追加
/**
* 查询用户信息,可用于修改用户时候表单返回的默认值
*
* @param map
* @param userId
* @return
*/
@RequestMapping("userDetail")
public String userDetail(ModelMap map, @RequestParam Integer userId) {
User user = userService.selectById(userId);
map.addAttribute("user", user);
return "user/user-detail";
}
@RequestMapping("updateUser")
public String updateUser(User user) throws InterruptedException {
System.out.println("更新操作");
Thread.sleep(5000);
userService.updateUser(user);
return "redirect:/user/userList";
}
user-list.html
追加: 更新次数返显、点击修改按钮事件
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<title>user-list</title>
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
</head>
<body>
<table border="1" style="margin: 0 auto;width: 300px">
<thead>
<tr>
<th>用户ID</th>
<th>用户名</th>
<th>性别</th>
<th>年龄</th>
<th>更新次数</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr th:each="user:${users}">
<td th:text="${user.id}"></td>
<td th:text="${user.username}"></td>
<td th:text="${user.sex}"></td>
<td th:text="${user.age}"></td>
<td th:text="${user.updateCount}"></td>
<td>
<button type="button" name="del" th:attr="userId=${user.id}">删除</button>
<button type="button" name="update" th:attr="userId=${user.id}">修改</button>
</td>
</tr>
</tbody>
</table>
<script>
$(function () {
$(document).on("click", "button[name=del]", function () {
var userId = $(this).attr("userId");
$.get("/user/delUser?userId=" + userId, function (reps) {
if (reps.status === 1) {
setTimeout(function () {
location.reload();
}, 5000);
}
})
})
$(document).on("click", "button[name=update]", function () {
var userId = $(this).attr("userId");
location.href = "/user/userDetail?userId=" + userId;
})
});
</script>
</body>
</html>
user-detail.html
追加: version的隐藏域、更新次数的返显
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<title>user-detail</title>
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
</head>
<!--居中-->
<body style="text-align: left">
<form style="margin: 0 auto 0 300px" method="post" action="/user/updateUser">
<!--隐藏域-->
<input type="hidden" name="id" th:attr="value=${user.id}">
<input type="hidden" name="version" th:attr="value=${user.version}">
用户名:<input type="text" name="username" th:attr="value=${user.username}"> <br>
性别:
<select name="sex">
<option value="0" th:attr="selected=${user.sex == 0}">未知</option>
<option value="1" th:attr="selected=${user.sex == 1}">男性</option>
<option value="2" th:attr="selected=${user.sex == 2}">女性</option>
</select>
<br>
年龄:<input type="number" name="age" th:attr="value=${user.age}">
<br>
更新次数:<span th:text="${user.updateCount}"></span>
<br>
<button type="submit">提交</button>
</form>
</body>
</html>
1-6 Insert操作的幂等性
- 有唯一业务号的Insert操作,例如:秒杀,商品ID+用户ID
- 可通过分布式锁,保证接口幂等
- 业务执行完成后,不进行锁释放,让其过期自动释放
- 没有唯一业务号的Insert操作,比如:用户注册,点击多次
- 使用Token机制,保证幂等性
- 进入到注册页时,后台统一生成Token,返回前台隐藏域中
- 用户在页面点击提交时,将Token一同传入后台
- 使用Token获取分布式锁,完成Insert操作
- 执行完成后,不释放锁,等待过期自动释放
混合操作的幂等性
- 混合操作,一个接口包含多种操作
- 同样可以使用Token机制
流程与代码
模拟多用户请求注册的场景,通过用户名或Token作为幂等性的唯一业务号
- 注入curator客户端依赖,如果出现zookeeper版本过低,需要额外添加zk依赖
- 编写业务层方法
- 编写zookeeper客户端配置类
- zookeeper安装与运行
- 编写控制层方法
- user-detail.html 追加Token隐藏域
curator客户端
<dependency>
<groupId>org.apache.curator</groupId>
<artifactId>curator-recipes</artifactId>
<version>4.2.0</version>
</dependency>
业务层 - 追加方法
public int insertUser(User user, String token) throws Exception {
int result = 0;
// user.getUsername() 作为唯一业务号
// InterProcessMutex lock = new InterProcessMutex(zkClient, "/" + user.getUsername());
// 获取锁对象 使用token作为唯一标识号
InterProcessMutex lock = new InterProcessMutex(zkClient, "/" + token);
boolean isLock = lock.acquire(30, TimeUnit.SECONDS);
if (isLock) {
// 获取到锁就插入
result = userMapper.insertSelective(user);
// 释放锁
lock.release();
}
// 反之没有获取到锁就返回 0
return result;
}
zookeeper客户端配置类
@Configuration
public class ZkConfig {
/**
* https://github.com/apache/zookeeper
* https://repo1.maven.org/maven2/org/apache/zookeeper/zookeeper/
*
* @return Client
*/
@Bean(initMethod = "start", destroyMethod = "close")
public CuratorFramework getCuratorFramework() {
// //重试策略, 参数1:等待时间, 参数2:重试次数
RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000, 3);
// 创建zookeeper客户端连接
return CuratorFrameworkFactory.newClient("192.168.8.246:2181", retryPolicy);
}
}
zookeeper安装与运行
# 下载
wget https://mirrors.bfsu.edu.cn/apache/zookeeper/zookeeper-3.6.2/apache-zookeeper-3.6.2-bin.tar.gz
# 解压
tar -zvxf apache-zookeeper-3.6.2-bin.tar.gz
# 改名
mv apache-zookeeper-3.6.2-bin zookeeper-3.6.2
# 创建数据存储文件夹
mkdir -p zookeeper-3.6.2/data
# 复制配置文件
cp -R zookeeper-3.6.2/conf/zoo_sample.cfg zookeeper-3.6.2/conf/zoo.cfg
# 修改数据存储文件夹路径
vim /opt/zookeeper-3.6.2/conf/zoo.cfg
# 运行
/opt/zookeeper-3.6.2/bin/zkServer.sh start
# 查看状态
/opt/zookeeper-3.6.2/bin/zkServer.sh status
控制层
添加用户注册方法
@RequestMapping("register")
public String register(ModelMap map) {
String token = UUID.randomUUID().toString();
tokenSet.add(token);
map.addAttribute("user", new User());
map.addAttribute("token", token);
return "user/user-detail";
}
微调更新方法
@RequestMapping("updateUser")
public String updateUser(User user, String token) throws Exception {
Thread.sleep(5000);
if (user.getId() != null) {
System.out.println("更新操作");
userService.updateUser(user);
} else {
if (tokenSet.contains(token)) {
System.out.println("添加操作");
userService.insertUser(user, token);
} else {
throw new Exception("token 不存在");
}
}
return "redirect:/user/userList";
}
html - 添加隐藏域
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<title>user-detail</title>
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
</head>
<!--居中-->
<body style="text-align: left">
<form style="margin: 0 auto 0 300px" method="post" action="/user/updateUser">
<!--隐藏域-->
<input type="hidden" name="id" th:attr="value=${user.id}">
<input type="hidden" name="version" th:attr="value=${user.version}">
<input type="hidden" name="token" th:attr="value=${token}">
用户名:<input type="text" name="username" th:attr="value=${user.username}"> <br>
性别:
<select name="sex">
<option value="0" th:attr="selected=${user.sex == 0}">未知</option>
<option value="1" th:attr="selected=${user.sex == 1}">男性</option>
<option value="2" th:attr="selected=${user.sex == 2}">女性</option>
</select>
<br>
年龄:<input type="number" name="age" th:attr="value=${user.age}">
<br>
更新次数:<span th:text="${user.updateCount}"></span>
<br>
<button type="submit">提交</button>
</form>
</body>
</html>