幂等性及数据防重复(转载)

  • Basic, Server
  • 485 clicked

1. 幂等性

用户对同一操作发起的一次请求或者多次请求的结果是一致的,不会因为多次点击而产生了副作用。

2. 造成重复消费的原因

因为网络传输等等故障,确认信息没有传送到消息队列,导致消息队列不知道自己已经消费过该消息了,再次将该消息分发给其他的消费者。

  • 例子1
    在支付场景中,用户购买商品后支付,支付扣款成功,但是返回结果的时候网络异常,此时钱已经扣了,用户再次点击按钮,此时会进行第二次扣款,返回结果成功,用户查询余额发现多扣钱了,流水记录也变成了两条。在以前的单应用系统中,我们只需要把数据操作放入事务中即可,发生错误立即回滚,但是再响应客户端的时候也有可能出现网络中断或者异常等等。
  • 例子2
    有时我们在填写某些form表单时,保存按钮不小心快速点了两次,表中竟然产生了两条重复的数据,只是id不一样。我们在项目中为了解决接口超时问题,通常会引入了重试机制。第一次请求接口超时了,请求方没能及时获取返回结果(此时有可能已经成功了),为了避免返回错误的结果(这种情况不可能直接返回失败吧?),于是会对该请求重试几次,这样也会产生重复的数据。mq消费者在读取消息时,有时候会读取到重复消息(至于什么原因这里先不说,有兴趣的小伙伴,可以找我私聊),如果处理不好,也会产生重复的数据。

3. 幂等性问题接口

在增删改查4个操作中,尤为注意就是增加或者修改,查询对于结果是不会有改变的,删除只会进行一次,用户多次点击产生的结果一样,修改在大多场景下结果一样,增加在重复提交的场景下会出现。

  • insert/add操作,这种情况下多次请求,可能会产生重复数据。
  • update操作,如果只是单纯的更新数据,比如:update user set status=1 where id=1,是没有问题的。如果还有计算,比如:update user set status=status+1 where id=1,这种情况下多次请求,可能会导致数据错误。

4. 如何避免重复消费和数据重复问题

防重设计幂等设计,其实是有区别的。防重设计主要为了避免产生重复数据,对接口返回没有太多要求。而幂等设计除了避免产生重复数据之外,还要求每次请求都返回一样的结果。那么如何设计接口才能做到幂等性和数据不重复呢?

4.1. 加唯一索引/主键

该方法适用单次操作调用场景,比如拿到一个消息做数据库的insert操作,给它做一个唯一主键,那么就算出现重复消费的情况,就会导致主键冲突,避免数据库出现脏数据,进而做到幂等性和数据不重复

绝大数情况下,为了防止重复数据的产生,我们都会在表中加唯一索引,这是一个非常简单,并且有效的方案。alter table order add UNIQUE KEY un_code (code);加了唯一索引之后,第一次请求数据可以插入成功。但后面的相同请求,插入数据时会报Duplicate entry '002' for key 'order.un_code异常,表示唯一索引有冲突。虽说抛异常对数据来说没有影响,不会造成错误数据。但是为了保证接口幂等性,我们需要对该异常进行捕获,然后返回成功。如果是java程序需要捕获:DuplicateKeyException异常,如果使用了spring框架还需要捕获:MySQLIntegrityConstraintViolationException异常。具体流程图如下:

具体步骤:用户通过浏览器发起请求,服务端收集数据。将该数据插入mysql判断是否执行成功,如果成功,则操作其他数据(可能还有其他的业务逻辑)。如果执行失败,捕获唯一索引冲突异常,直接返回成功。

4.2. 写前判断存在

通常情况下,在保存数据的接口中,我们为了防止产生重复数据,一般会在insert前,先根据name或code字段select一下数据,即insert前先select。如果该数据已存在,则执行update操作,如果不存在,才执行 insert操作。

该方案可能是我们平时在防止产生重复数据时,使用最多的方案。但是该方案不适用于并发场景,在并发场景中,要配合其他方案一起使用,否则同样会产生重复数据。我在这里提一下,是为了避免大家踩坑。

4.3. 建防重表以及事务操作

有时候表中并非所有的场景都不允许产生重复的数据,只有某些特定场景才不允许。这时候,直接在表中加唯一索引,显然是不太合适的。针对这种情况,我们可以通过建防重表来解决问题。该表可以只包含两个字段:id 和 唯一索引,唯一索引可以是多个字段比如:name、code等组合起来的唯一标识,例如:susan_0001。具体流程图如下:

具体步骤:用户通过浏览器发起请求,服务端收集数据。将该数据插入mysql防重表判断是否执行成功,如果成功,则做mysql其他的数据操作(可能还有其他的业务逻辑)。如果执行失败,捕获唯一索引冲突异常,直接返回成功。需要特别注意的是:防重表和业务表必须在同一个数据库中,并且操作要在同一个事务中。

4.4. 加悲观锁

在支付场景中,用户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;具体流程如下:

具体步骤:多个请求同时根据id查询用户信息。判断余额是否不足100,如果余额不足,则直接返回余额不足。如果余额充足,则通过for update再次查询用户信息,并且尝试获取锁。只有第一个请求能获取到行锁,其余没有获取锁的请求,则等待下一次获取锁的机会。第一个请求获取到锁之后,判断余额是否不足100,如果余额足够,则进行update操作。如果余额不足,说明是重复请求,则直接返回成功。需要特别注意的是:如果使用的是mysql数据库,存储引擎必须用innodb,因为它才支持事务。此外,这里id字段一定要是主键或者唯一索引,不然会锁住整张表。悲观锁需要在同一个事务操作过程中锁住一行数据,如果事务耗时比较长,会造成大量的请求等待,影响接口性能。此外,每次请求接口很难保证都有相同的返回值,所以不适合幂等性设计场景,但是在防重场景中是可以的使用的。

4.5. 加乐观锁

既然悲观锁有性能问题,为了提升接口性能,我们可以使用乐观锁,需要在表中增加一个timestamp或者version字段,这里以version字段为例。在更新数据之前先查询一下数据:`select id,amount,version from user id=123;如果数据存在,假设查到的version等于1,再使用id和version字段作为查询条件更新数据: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值已经修改了,那么前面必定已经成功过一次,后面都是重复的请求。具体流程如下:

具体步骤:先根据id查询用户信息,包含version字段根据id和version字段值作为where条件的参数,更新用户信息,同时version+1判断操作影响行数,如果影响1行,则说明是一次请求,可以做其他数据操作。如果影响0行,说明是重复请求,则直接返回成功。

4.6. 根据状态机

很多时候业务表是有状态的,比如订单表中有:1-下单、2-已支付、3-完成、4-撤销等状态。如果这些状态的值是有规律的,按照业务节点正好是从小到大,我们就能通过它来保证接口的幂等性。该方法适用分布式环境下各个服务相互调用场景。

  • 支付系统例子
    比如支付系统先要扣款,然后更新订单,这个地方就涉及到了订单服务以及支付服务了。用户调用支付,扣款成功后,更新对应订单状态,然后再保存流水。给订单分配一个全局id,只要消费过该消息,将以K-V形式写入redis。那消费者开始消费前,先去redis中查询有没消费记录即可。
    假如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. 如果已经支付,直接返回结果;
    3. 如果未支付,则支付扣款并且保存流水;
    4. 返回支付结果;如通信失败,用户再次发起请求,那么最终结果还是一样的;
  • 数据操作流程

    用户通过浏览器发起请求,服务端收集数据。 根据id和当前状态作为条件,更新成下一个状态判断操作影响行数,如果影响了1行,说明当前操作成功,可以进行其他数据操作。如果影响了0行,说明是重复请求,直接返回成功。主要特别注意的是,该方案仅限于要更新的表有状态字段,并且刚好要更新状态字段的这种特殊情况,并非所有场景都适用。

4.7. 加分布式锁

其实前面介绍过的加唯一索引或者加防重表,本质是使用了数据库的分布式锁,也属于分布式锁的一种。但由于数据库分布式锁的性能不太好,我们可以改用:redis或zookeeper。鉴于现在很多公司分布式配置中心改用apollo或nacos,已经很少用zookeeper了,我们以redis为例介绍分布式锁。目前主要有三种方式实现redis的分布式锁:setNx命令/set命令/Redission框架 每种方案各有利弊,具体实现细节我就不说了,有兴趣的朋友可以加我微信找我私聊。具体流程图如下:

具体步骤:用户通过浏览器发起请求,服务端会收集数据,并且生成订单号code作为唯一业务字段。使用redis的set命令,将该订单code设置到redis中,同时设置超时时间。判断是否设置成功,如果设置成功,说明是第一次请求,则进行数据操作。如果设置失败,说明是重复请求,则直接返回成功。需要特别注意的是:分布式锁一定要设置一个合理的过期时间,如果设置过短,无法有效的防止重复请求。如果设置过长,可能会浪费redis的存储空间,需要根据实际业务情况而定。最近无意间获得一份BAT大厂大佬写的刷题笔记,一下子打通了我的任督二脉,越来越觉得算法没有想象中那么难了。BAT大佬写的刷题笔记,让我offer拿到手软

4.8. 获取token

使用token的方案,需要两次请求才能完成一次业务操作。第一次请求获取token,第二次请求带着这个token,完成业务操作。不需要额外的数据库操作了,发起异步请求创建一个唯一的ticketId,该Id只能使用一次就作废,通过该请求id号来判断是否为同一个请求。

  • 原理描述
    单次支付请求场景步骤为例:

    1. 先异步请求获取门票token;
    2. 调用具体业务,例如支付业务,传入门票;
    3. 根据门票ID查询此次操作是否存在,如果存在则表示该操作已经执行过,直接返回结果;如果不存在,支付扣款,保存结果;
    4. 返回结果到客户端;如果通信失败,用户再次发起请求,那么最终结果还是一样的;
  • 技术实现
    1. 流程图
    2. 具体步骤描述
    3. 服务端提供获取 Token 的接口,该 Token 可以是一个序列号,也可以是一个分布式 ID 或者 UUID 串。
    4. 客户端调用接口获取 Token,这时候服务端会生成一个 Token 串。 然后将该串存入 Redis 数据库中,以该 Token 作为 Redis 的键(注意设置过期时间)。
    5. 将 Token 返回到客户端,客户端拿到后应存到表单隐藏域中。
    6. 客户端在执行提交表单时,把 Token 存入到 Headers 中,执行业务请求带上该 Headers。
    7. 服务端接收到请求后从 Headers 中拿到 Token,然后根据 Token 到 Redis 中查找该 key 是否存在。
    8. 服务端根据 Redis 中是否存该 key 进行判断,如果存在就将删除该 key ,然后正常执行业务逻辑。如果不存在就抛异常,返回重复提交的错误信息。
    9. “ 注意,在并发情况下,执行 Redis 查找数据与删除需要保证原子性,否则很可能在并发下无法保证幂等性。其实现方法可以使用分布式锁或者使用Lua表达式来注销查询与删除操作。

5. 参考文档

赞赏

微信赞赏支付宝赞赏

发表评论

邮箱地址不会被公开。 必填项已用*标注