乐趣区

关于高并发:高并发下如何保证接口的幂等性

苏三说技术

「苏三说技术」维护者目前就任于某出名互联网公司,从事开发、架构和局部管理工作。实战经验丰盛,对 jdk、spring、springboot、springcloud、mybatis 等开源框架源码有肯定钻研,欢送关注,和我一起交换。

29 篇原创内容

公众号

前言


接口幂等性 问题,对于开发人员来说,是一个跟语言无关的公共问题。本文分享了一些解决这类问题十分实用的方法,绝大部分内容我在我的项目中实际过的,给有须要的小伙伴一个参考。

不晓得你有没有遇到过这些场景:

  1. 有时咱们在填写某些 form 表单 时,保留按钮不小心疾速点了两次,表中居然产生了两条反复的数据,只是 id 不一样。
  2. 咱们在我的项目中为了解决 接口超时 问题,通常会引入了 重试机制。第一次申请接口超时了,申请方没能及时获取返回后果(此时有可能曾经胜利了),为了防止返回谬误的后果(这种状况不可能间接返回失败吧?),于是会对该申请重试几次,这样也会产生反复的数据。
  3. mq 消费者在读取音讯时,有时候会读取到 反复音讯(至于什么起因这里先不说,有趣味的小伙伴,能够找我私聊),如果解决不好,也会产生反复的数据。

没错,这些都是幂等性问题。

接口幂等性 是指用户对于同一操作发动的一次申请或者屡次申请的后果是统一的,不会因为屡次点击而产生了副作用。

这类问题多发于接口的:

  • insert操作,这种状况下屡次申请,可能会产生反复数据。
  • update操作,如果只是单纯的更新数据,比方:update user set status=1 where id=1,是没有问题的。如果还有计算,比方:update user set status=status+1 where id=1,这种状况下屡次申请,可能会导致数据谬误。

那么咱们要如何保障接口幂等性?本文将会通知你答案。

1. insert 前先 select

通常状况下,在保留数据的接口中,咱们为了避免产生反复数据,个别会在 insert 前,先依据 namecode字段 select 一下数据。如果该数据已存在,则执行 update 操作,如果不存在,才执行  insert操作。

该计划可能是咱们平时在避免产生反复数据时,应用最多的计划。然而该计划不适用于并发场景,在并发场景中,要配合其余计划一起应用,否则同样会产生反复数据。我在这里提一下,是为了防止大家踩坑。

2. 加乐观锁

在领取场景中,用户 A 的账号余额有 150 元,想转出 100 元,失常状况下用户 A 的余额只剩 50 元。个别状况下,sql 是这样的:

update user amount = amount-100 where id=123;

如果呈现屡次雷同的申请,可能会导致用户 A 的余额变成正数。这种状况,用户 A 来可能要哭了。于此同时,零碎开发人员可能也要哭了,因为这是很重大的零碎 bug。

为了解决这个问题,能够加乐观锁,将用户 A 的那行数据锁住,在同一时刻只容许一个申请取得锁,更新数据,其余的申请则期待。

通常状况下通过如下 sql 锁住单行数据:

select * from user id=123 for update;

具体流程如下:

具体步骤:

  1. 多个申请同时依据 id 查问用户信息。
  2. 判断余额是否有余 100,如果余额有余,则间接返回余额有余。
  3. 如果余额短缺,则通过 for update 再次查问用户信息,并且尝试获取锁。
  4. 只有第一个申请能获取到行锁,其余没有获取锁的申请,则期待下一次获取锁的机会。
  5. 第一个申请获取到锁之后,判断余额是否有余 100,如果余额足够,则进行 update 操作。
  6. 如果余额有余,阐明是反复申请,则间接返回胜利。

须要特地留神的是:如果应用的是 mysql 数据库,存储引擎必须用 innodb,因为它才反对事务。此外,这里 id 字段肯定要是主键或者惟一索引,不然会锁住整张表。

乐观锁须要在同一个事务操作过程中锁住一行数据,如果事务耗时比拟长,会造成大量的申请期待,影响接口性能。

此外,每次申请接口很难保障都有雷同的返回值,所以不适宜幂等性设计场景,然而在防重场景中是能够的应用的。

在这里顺便说一下,防重设计 幂等设计,其实是有区别的。防重设计次要为了防止产生反复数据,对接口返回没有太多要求。而幂等设计除了防止产生反复数据之外,还要求每次申请都返回一样的后果。

3. 加乐观锁

既然乐观锁有性能问题,为了晋升接口性能,咱们能够应用乐观锁。须要在表中减少一个 timestamp 或者 version 字段,这里以 version 字段为例。

在更新数据之前先查问一下数据:

select id,amount,version from user id=123;

如果数据存在,假如查到的 version 等于 1,再应用idversion字段作为查问条件更新数据:

update user set amount=amount+100,version=version+1
where id=123 and version=1;

更新数据的同时 version+1,而后判断本次update 操作的影响行数,如果大于 0,则阐明本次更新胜利,如果等于 0,则阐明本次更新没有让数据变更。

因为第一次申请 version 等于 1 是能够胜利的,操作胜利后 version 变成 2 了。这时如果并发的申请过去,再执行雷同的 sql:

 update user set amount=amount+100,version=version+1
where id=123 and version=1;

update 操作不会真正更新数据,最终 sql 的执行后果影响行数是 0,因为version 曾经变成 2 了,where中的 version=1 必定无奈满足条件。但为了保障接口幂等性,接口能够间接返回胜利,因为 version 值曾经批改了,那么后面必然曾经胜利过一次,前面都是反复的申请。

具体流程如下:

具体步骤:

  1. 先依据 id 查问用户信息,蕴含 version 字段
  2. 依据 id 和 version 字段值作为 where 条件的参数,更新用户信息,同时 version+1
  3. 判断操作影响行数,如果影响 1 行,则阐明是一次申请,能够做其余数据操作。
  4. 如果影响 0 行,阐明是反复申请,则间接返回胜利。

4. 加惟一索引

绝大数状况下,为了避免反复数据的产生,咱们都会在表中加惟一索引,这是一个非常简单,并且无效的计划。

alter table `order` add UNIQUE KEY `un_code` (`code`);

加了惟一索引之后,第一次申请数据能够插入胜利。但前面的雷同申请,插入数据时会报 Duplicate entry '002' for key 'order.un_code 异样,示意惟一索引有抵触。

虽说抛异样对数据来说没有影响,不会造成谬误数据。然而为了保障接口幂等性,咱们须要对该异样进行捕捉,而后返回胜利。

如果是 java 程序须要捕捉:DuplicateKeyException异样,如果应用了 spring 框架还须要捕捉:MySQLIntegrityConstraintViolationException异样。

具体流程图如下:

具体步骤:

  1. 用户通过浏览器发动申请,服务端收集数据。
  2. 将该数据插入 mysql
  3. 判断是否执行胜利,如果胜利,则操作其余数据(可能还有其余的业务逻辑)。
  4. 如果执行失败,捕捉惟一索引抵触异样,间接返回胜利。

5. 建防重表

有时候表中并非所有的场景都不容许产生反复的数据,只有某些特定场景才不容许。这时候,间接在表中加惟一索引,显然是不太适合的。

针对这种状况,咱们能够通过 建防重表 来解决问题。

该表能够只蕴含两个字段:id惟一索引,惟一索引能够是多个字段比方:name、code 等组合起来的惟一标识,例如:susan\_0001。

具体流程图如下:

具体步骤:

  1. 用户通过浏览器发动申请,服务端收集数据。
  2. 将该数据插入 mysql 防重表
  3. 判断是否执行胜利,如果胜利,则做 mysql 其余的数据操作(可能还有其余的业务逻辑)。
  4. 如果执行失败,捕捉惟一索引抵触异样,间接返回胜利。

须要特地留神的是:防重表和业务表必须在同一个数据库中,并且操作要在同一个事务中。

6. 依据状态机

很多时候业务表是有状态的,比方订单表中有:1- 下单、2- 已领取、3- 实现、4- 撤销等状态。如果这些状态的值是有法则的,依照业务节点正好是从小到大,咱们就能通过它来保障接口的幂等性。

如果 id=123 的订单状态是 已领取 ,当初要变成 实现 状态。

update `order` set status=3 where id=123 and status=2;

第一次申请时,该订单的状态是 已领取 ,值是2,所以该update 语句能够失常更新数据,sql 执行后果的影响行数是1,订单状态变成了3

前面有雷同的申请过去,再执行雷同的 sql 时,因为订单状态变成了 3,再用status=2 作为条件,无奈查问出须要更新的数据,所以最终 sql 执行后果的影响行数是 0,即不会真正的更新数据。但为了保障接口幂等性,影响行数是0 时,接口也能够间接返回胜利。

具体流程图如下:

具体步骤:

  1. 用户通过浏览器发动申请,服务端收集数据。
  2. 依据 id 和以后状态作为条件,更新成下一个状态
  3. 判断操作影响行数,如果影响了 1 行,阐明以后操作胜利,能够进行其余数据操作。
  4. 如果影响了 0 行,阐明是反复申请,间接返回胜利。

次要特地留神的是,该计划仅限于要更新的 表有状态字段 ,并且刚好要更新 状态字段 的这种非凡状况,并非所有场景都实用。

7. 加分布式锁

其实后面介绍过的 加惟一索引 或者 加防重表 ,实质是应用了 数据库 分布式锁 ,也属于分布式锁的一种。但因为 数据库分布式锁 的性能不太好,咱们能够改用:rediszookeeper

鉴于当初很多公司分布式配置核心改用 apollonacos,曾经很少用 zookeeper 了,咱们以 redis 为例介绍分布式锁。

目前次要有三种形式实现 redis 的分布式锁:

  1. setNx 命令
  2. set 命令
  3. Redission 框架

每种计划各有利弊,具体实现细节我就不说了,有趣味的敌人能够加我微信找我私聊。

具体流程图如下:

具体步骤:

  1. 用户通过浏览器发动申请,服务端会收集数据,并且生成订单号 code 作为惟一业务字段。
  2. 应用 redis 的 set 命令,将该订单 code 设置到 redis 中,同时设置超时工夫。
  3. 判断是否设置胜利,如果设置胜利,阐明是第一次申请,则进行数据操作。
  4. 如果设置失败,阐明是反复申请,则间接返回胜利。

须要特地留神的是:分布式锁肯定要设置一个正当的过期工夫,如果设置过短,无奈无效的避免反复申请。如果设置过长,可能会节约 redis 的存储空间,须要依据理论业务状况而定。

8. 获取 token

除了上述计划之外,还有最初一种应用 token 的计划。该计划跟之前的所有计划都有点不一样,须要两次申请能力实现一次业务操作。

  1. 第一次申请获取token
  2. 第二次申请带着这个token,实现业务操作。

具体流程图如下:

第一步,先获取 token。

第二步,做具体业务操作。

具体步骤:

  1. 用户拜访页面时,浏览器主动发动获取 token 申请。
  2. 服务端生成 token,保留到 redis 中,而后返回给浏览器。
  3. 用户通过浏览器发动申请时,携带该 token。
  4. 在 redis 中查问该 token 是否存在,如果不存在,阐明是第一次申请,做则后续的数据操作。
  5. 如果存在,阐明是反复申请,则间接返回胜利。
  6. 在 redis 中 token 会在过期工夫之后,被主动删除。

以上计划是针对幂等设计的。

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

须要特地留神的是:token 必须是全局惟一的。

最初说一句(求关注,别白嫖我)

如果这篇文章对您有所帮忙,或者有所启发的话,帮忙扫描下发二维码关注一下,您的反对是我保持写作最大的能源。

求一键三连:点赞、转发、在看。

关注公众号:【苏三说技术】,在公众号中回复:面试、代码神器、开发手册、工夫治理有超赞的粉丝福利,另外回复:加群,能够跟很多 BAT 大厂的前辈交换和学习。

 集体公众号

 集体微信

退出移动版