幂等性是什么? | Eddie'Blog
幂等性是什么?

幂等性是什么?

eddie 504 2020-12-09

目录

幂等性

  • 什么是幂等性
    • 幂等性: f(f(x)) = f(x)
    • 幂等元素运行多次,还等于它原来的运算结果
    • 在系统中,一个接口运行多次,与运行一次的效果是一致的
  • 幂等性设计的核心思想
    • 通过唯一的业务单号保证幂等性
  • select、update、delete、insert和混合操作的接口幂等性

接口设计与重试机制引发的问题

接口幂等性

  • 提交订单按钮如何防止重覆提交?
  • 表单录入页如何防止重覆提交?
  • 微服务接口,客户端重试时,会对业务数据产生影响吗?
  • 什么情况下需要幂等性
    • 重覆提交、接口重试、前端操作抖动等
  • 业务场景
    • 用户多次点击提交订单,后台应只生成一个订单
    • 支付时,由于网络问题重发,应该只扣一次钱
  • 并非所有的接口都需要幂等性,要根据业务而定
  • 如何保证幂等性的策略有哪些?
  • 非并发情况下:
    • 查询业务单号有没有操作过,没有则执行操作
  • 并发的情况下:
    • 整个操作过程加锁 (分布式锁)
  • 操作
    • Select 不会对业务数据有影响,天然幂等性
    • Delete 第一次已经删除,第二也不会有影响
      • Update 更新操作传入数据版本号,通过乐观锁实现幂等性
      • Insert 此时没有唯一业务单号,使用Token保证幂等性
    • 混合 找到操作的唯一业务单号,有则可使用分布式锁,若没有通过Token保证幂等性

操作

准备工作

Jquery CDN

基础代码

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,对业务数据没有影响。可在删除前进行数据的查询。
  • 删除操作没有唯一业务号,则要看具体的业务需求
  • 例如:删除所有审核未通过的商品
  • 第一次执行,将所有未通过审核的商品删除
  • 在第二次执行前,又有新的商品未审核通过
  • 执行第二次删除操作,新的审核通过的商品要不要删除?
  • 根据业务需求而定

流程与代码

  1. 创建用户表
  2. 创建服务层
  3. 创建控制层
  4. 编写HTML
  5. 插入一条记录到数据库
  6. 访问: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作为幂等性的唯一业务号

  1. 注入curator客户端依赖,如果出现zookeeper版本过低,需要额外添加zk依赖
  2. 编写业务层方法
  3. 编写zookeeper客户端配置类
  4. zookeeper安装与运行
  5. 编写控制层方法
  6. 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>