Java并发编程学习之高并发下如何保证接口的幂等性

  • 前言
  • 问题分析
  • 解决方案
  • insert 前先 select
  • 加悲观锁
  • 加乐观锁
  • 加唯一索引
  • 建防重表
  • 加状态机
  • 加分布式锁
  • 加Token
  • 总结
  • 参考链接


前言

  • 概念
    幂等就是一个操作,不论执行多少次,产生的效果和返回的结果都是一样的
    接口幂等性是指用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因为多次点击而产生了副作用
  • 场景介绍
  1. 前端保存按钮重复点击,表里新增重复数据,但是自增主键id不相同。
  2. 项目中解决接口超时问题,通常会引入重试机制。首次请求超时(网关自动熔断响应超时),此时后台接口还在处理业务逻辑可能已经处理成功,于是对该请求重试几次,这样也会产生重复数据。
  3. MQ消费者在读取消息时,有时会读到重复消息,如果处理不好也会产生重复数据。

问题分析

  • insert操作
    这种情况下多次请求,可能会产生重复数据
  • update操作
    如果只是单纯更新数据是没有问题,只不过每次都会执行一次更新操作。例如
update test_table set vno = 1 where id = 1;

但是如果有计算,这种情况下多次请求,可能会导致数据错误。

update test_table set vno = vno + 1 where id = 1;

解决方案

insert 前先 select

  • 思路分析
    日常开发中,在保存数据接口设计时,为防止产生重复数据,一般会在insert前先根据须保持唯一的属性值如id_card作为条件,查询一下表中数据是否存在。
    如果存在则执行update操作,如果不存在,才执行insert操作。
  • 流程图分析
  • Java支付幂等性 java中的幂等性_Java支付幂等性

  • 优劣分析
    日常开发中防止产生重复数据,使用最多的方案,但不适用于并发场景
    并发场景下,要配合其他方案一起使用,否则同样会产生重复数据。

加悲观锁

  • 思路分析
    支付场景中,用户A的账户有余额 150 元,想转出 100 元,正常情况下用户 A 的余额只剩 50 元,样例SQL
update user amount = amount - 100 where id = 1;

如果出现多次相同的请求,可能会导致用户A的余额一路递减为负数。为解决这个问题,可以加悲观锁,将用户A的那行数据锁住,在同一时间只允许一个请求获得锁,更新数据,其他请求则等待

通过SQL锁住单行数据

select * from user where id = 1 for update;
  • 流程图分析
  • 优劣分析
    如果使用的是MySQL数据库,存储引擎必须用innodb,因为它才支持事务。此外,这里id字段一定要是主键或者唯一索引
    悲观锁需要在同一个事务操作过程中锁住一行数据,如果事务耗时比较长,会造成大量的请求等待,影响接口性能
    每次请求接口很难保证都有相同的返回值,所以不适用于幂等性设计场景,在防重场景中可以使用。
  • 其他
    防重设计幂等设计其实是有区别的:
  1. 防重设计主要为了避免产生重复数据,对接口返回没有太多要求
  2. 幂等设计除了避免产生重复数据之外,还要求每次请求都返回一样的结果

加乐观锁

  • 思路分析
    悲观锁存在接口性能问题,为了提升接口性能,考虑使用乐观锁。需要在表中增加一个timestamp 或 version 字段,这里以 version字段为例
-- 1. 更新数据前先查询一下数据  
    select id, amount, version from user id = 1;

    -- 2. 如果数据存在,假设查到的 version 等于 1,再使用 id 和 version 字段作为查询条件更新数据
    update user set amount = amount + 100 , version = version + 1 where id = 1 and version = 1;

    -- 3. 更新数据的同时 version + 1,然后判断本次 update 操作的影响行数,如果大于 0,则说明本次更新没有发生数据变更

    -- 4. 由于第一次请求 version 等于 1 是可以成功的,操作成功后 version 变成 2 了。这时如果并发的请求过来,再执行相同的 SQL:
    update user set amount = amount + 100 , version = version + 1 where id = 1 and version = 1;

    -- 5. 此时update操作不会真正更新数据,最终SQL的执行结果影响行数是 0。因为 version 已经变成 2 了,where 中的 version = 1 无法满足条件。但为了保证接口幂等性,可以直接返回成功,因为 version 值已经修改了,那么之前肯定已经成功过一次(根据需要判断是否在修改前校验 id 编号是否存在),后面则为重复请求。


    --
  • 流程图分析
  • 优劣分析
    这种方案对处理 update 操作较 悲观锁 更适用实际开发。

加唯一索引

  • 思路分析
    绝大多数情况下,为防止重复数据产生,会在表中加唯一索引,这是简单且有效的方案。
alter table `order` add UNIQUE KEY `IND_ORDER_U1`(`code`);

添加唯一索引后,第一次请求数据可以插入成功。但后面的相同请求,插入数据时会报Duplicate entry ‘002’ for key ‘order.IND_ORDER_U1’ 异常,表示唯一索引有冲突。

虽然抛异常对数据来说没有影响,不会造成错误数据。但是为了保证接口的幂等性,需要对指定异常进行捕获,然后返回成功

Java程序需要捕获:DuplicateKeyException异常,如果使用了Spring框架还需要捕获:MysQLIntegrityConstraintViolationException异常

  • 流程图分析
  • 优劣分析
    日常开发中常用

建防重表

  • 思路分析
    有时候表中并非所有场景下都不允许产生重复数据,只有某些特定场景下不允许产生重复数据,这时无法直接在表中加唯一索引,可以通过建防重表来解决问题。
    防重表可以只包含两个字段:id 和 唯一索引唯一索引可以是多个字段比如:name、code等组合起来的唯一标识,susan_0001.
  • 流程图分析
  • 优劣分析
    防重表和业务表必须在同一个数据库中,并且操作要在同一个事务中

加状态机

  • 思路分析
    以订单表为例:1-下单、2-已支付、3-完成、4-撤销等状态。通过表中字段状态保证接口幂等性
    将 id = 1 的订单状态由已支付,变成完成状态。
update order set status = 3 where id = 1 and status = 2;

第一次请求时,该订单的状态是已支付,值是2。所以该 update 语句可以正常更新数据,SQL 执行结果的影响行数是 1,订单状态变成了 3。

后面有相同的请求过来,再执行相同的 SQL 时,由于订单状态变成了 3。再用 status=2 作为条件,无法查询出需要更新的数据,所以最终 SQL 执行结果的影响行数是 0,即不会真正的更新数据。但为了保证接口幂等性,影响行数是 0 时,接口也可以直接返回成功(根据需要判断是否在修改前校验 id + status 是否存在)。

  • 流程图分析
  • 优劣分析
    该方案等同于加乐观锁,且仅限于要更新的表有状态字段,并且刚好要更新状态字段的这种特殊情况,并非所有场景都适用,

加分布式锁

  • 思路分析
    前面介绍过的加唯一索引或者加防重表,本质是使用了数据库的分布式锁,也属于分布式锁的一种。但由于数据库分布式锁的性能不太好,我们可以改用:Redis 或 ZooKeeper
    现在很多公司分布式配置中心改用 Apollo 或 Nacos,已经很少用 ZooKeeper 了,我们以Redis 为例介绍分布式锁
    目前主要有三种方式实现 Redis 的分布式锁:
  1. setNx 命令;
  2. set 命令;
  3. Redission 框架。
  • 流程图分析
  1. 用户通过浏览器发起请求,服务端会收集数据,并且生成订单号 code 作为唯一业务字段;
  2. 使用 redis 的 set 命令,将该订单 code 设置到 Redis 中,同时设置超时时间;
  3. 判断是否设置成功,如果设置成功,说明是第一次请求,则进行数据操作;
  4. 如果设置失败,说明是重复请求,则直接返回成功。
  • 优劣分析
    分布式锁一定要设置一个合理的过期时间如果设置过短,无法有效的防止重复请求。如果设置过长,可能会浪费redis的存储空间,需要根据实际业务情况而定。

加Token

  • 思路分析
    使用 token 的方案。该方案跟之前的所有方案都有点不一样,需要两次请求才能完成一次业务操作
  1. 第一次请求获取 token;
  2. 第二次请求带着这个 token,完成业务操作。
  • 流程图分析
  1. 用户访问页面时,浏览器自动发起获取 token 请求;
  2. 服务端生成 token,保存到 Redis 中,然后返回给浏览器;
  3. 用户通过浏览器发起请求时,携带该 token;
  4. 在 Redis 中查询该 token 是否存在,如果不存在,说明是第一次请求,有则后续的数据操作;
  5. 如果存在,说明是重复请求,则直接返回成功;
  6. 在 Redis 中 token 会在过期时间之后,被自动删除。

获取token

Java支付幂等性 java中的幂等性_java_02

具体业务操作

Java支付幂等性 java中的幂等性_Java支付幂等性_03

如果是防重设计,修改流程图

Java支付幂等性 java中的幂等性_分布式_04

  • 优劣分析
    token 必须是全局唯一。

总结

参考链接