幂等性(消费、接口)

幂等性是什么?

  简单的来说就是一个操作多次执行产生的结果与一次执行产生的结果一致。幂等函数,或幂等方法,是指可以使用相同参数重复执行,并能获得相同结果的函数。这些函数不会影响系统状态,也不用担心重复执行会对系统造成改变。

接口为什么要实现幂等?

不做接口的幂等性会产生什么影响

​ 支付场景:用户购买商品后,发起支付操作,支付系统处理支付成功后,由于网络原因没有及时返回操作成功的信息给用户,其实这个时候订单已经扣过款,相应的支付流水也都已经生成。这个时候,用户又点击支付操作,此时会进行第二次扣款,扣款成功后把操作成功的信息返回给了用户。用户去查看支付订单和流水时 会发现自己支付了两次,完蛋了,该系统要被用户投诉了。这就是没有保证接口的幂等性而造成的不良后果。

什么情况下需要保证接口的幂等性

在【增删改查】4个SQL操作中,尤为需要注意的就是增加和修改操作。

1、select:查询操作

查询操作不会对数据产生副作用。查询一次或者查询多次,在数据不变的情况下,查询结果都是一样的,所以,select 操作是天然的幂等操作。

2、insert:新增操作

新增操作在重复提交的场景下会出现幂等性问题,比如以上的支付场景。

insert into product (id,price)

上述 insert SQL,ID是自增主键,执行多次就会新增多条记录,对结果集产生了副作用,所以,insert 操作天然不具有幂等性。

3、delete:删除操作

删除操作可以分为两种:绝对删除和相对删除。其中,绝对删除不会对数据产生副作用,具有幂等性;相对删除会对数据产生副作用,不具有幂等性。

绝对删除 具有幂等性

delete from order where id = 3;

无论该SQL执行多少次,对结果集产生的效果都是一样的,只删除了一条数据,不会对数据产生副作用,所以,它具有幂等性。

相对删除 不具有幂等性

delete from order where id > 23;

该SQL每执行一次,对结果集产生的结果可能都不一样,同一操作执行多次对数据产生了副作用,所以,它不具有幂等性。

4、update:更新操作

更新操作可以分为两种:绝对更新和相对更新。其中,绝对更新不会对数据产生副作用,具有幂等性;相对更新会对数据产生副作用,不具有幂等性。

绝对更新 具有幂等性

udpate Goods set stock = 386 where goodId = 10;

无论该SQL执行多少次,对结果集产生的效果都是一样的,只更新了一条数据,不会对数据产生副作用,所以,它具有幂等性。

相对更新 不具有幂等性

update Goods set stock = stock + 1 where goodId = 10;

该SQL每执行一次,对结果集产生的结果都不一样,库存数量都会增加1,同一操作执行多次对数据产生了副作用,所以,它不具有幂等性。

幂等的业务场景

1、前端重复提交

用户注册、用户创建商品订单等操作,前端都会提交一些数据给后台服务,后台需要根据用户提交的数据在数据库中创建记录。如果用户不小心多点了几次,后端就收到了好几次提交,这时,就会在数据库中重复创建了多条记录。这就是接口没有幂等性带来的 bug。

2、接口超时重试

对于给第三方调用的接口,有可能会因为网络原因而调用超时失败,这时,一般在设计的时候会对接口调用加上超时/失败重试机制。如果第一次调用已经执行了一半业务逻辑时,发生了网络异常,这时,再次调用时就会因为脏数据的存在而出现调用异常。

3、MQ消息重复消费
在使用消息中间件来处理消息队列,且手动 ACK 确认消息被正常消费时,如果消费者突然断开连接,那么已经执行了一半的消息就会被重新放回队列。当消息被其他消费者重新消费时,如果没有幂等性,就会导致消息重复消费时结果异常,如数据库重复数据、数据库数据冲突、资源重复等。

幂等常用思路

token机制
  当客户端请求页面时,服务器会生成一个随机数token,并且将token放置到session/redis当中,然后将token发给客户端(一般通过构造hidden表单)。下次客户端提交请求时,token会随着表单一起提交到服务器端。服务器端第一次验证相同过后,会将session/redis中的token值更新下,若用户重复提交,第二次的验证判断将失败,因为用户提交的表单中的token没变,但服务器端session/redis中token已经改变了。

token方案的特点就是:需要两次请求才能完成一次业务的操作。

一般包括两个请求阶段:

1)客户端请求申请获取token,服务端生成token返回。

2)第二次请求带着这个token,服务端验证token,完成业务操作。

注意:在验证token是否存在,不要用redis.get(token)之后,再用redis.del(token),这样不是原子操作在高并发情况下依然会存在幂等问题。
可以直接用redis.del(token)的方式,我们看返回是否大于0,就知道是否有数据了,而且因为redis命令操作是单线程的,所以不会出现同时返回1,所以是能够保证幂等的。

乐观锁

这里的乐观锁指的是用乐观锁的原理去实现:为表增加一个 version 字段,当数据需要更新 update 时,先去表中获取此时的 version 版本号。

select version from tableName where Id=1;

更新数据时,首先和最新的版本号作比较,如果不相等,则说明已经有其他的请求去更新数据了,则本次提示更新会失败,让用户重试即可。

update tableName set count=count+1,version=version+1 where version=#{version}

悲观锁

乐观锁可以实现的,往往使用悲观锁也能实现:即在获取被操作数据的时候进行加锁。当同时有多个重复请求过来时,其他请求都会因无法获得被操作数据的锁而阻塞住,因此,其他请求都无法对被操作数据进行操作。

去重表
  利用数据库表单的特性来实现幂等,常用的一个思路是在表上构建唯一性索引。eg需求:博客点赞问题,要想防止一个人重复点赞,可以设计一张表,将博客id与用户id绑定建立唯一索引,每当用户点赞时就往表中写入一条数据,这样重复点赞的数据就无法写入。

我们可以借鉴数据库的乐观锁机制来举个例子

  1、首先为表添加一个版本字段version

  2、在执行更新操作前呢,会先去数据库查询这个version

  3、然后执行更新语句,以version作为条件,例如:
UPDATE T_REPS SET COUNT = COUNT -1,VERSION = VERSION + 1 WHERE VERSION = 1

  4、如果执行更新时有其他人先更新了这张表的数据,那么这个条件就不生效了,也就不会执行操作了,通过这种乐观锁的机制来保障幂等性。

CAS思想保证接口幂等性

状态机制来实现接口幂等性(一个事务的状态是不可逆的)。

针对更新操作,例如 电商订单的支付状态:0=待支付,1=支付中,2=支付成功,3=支付失败。

update Orders set status = 1 where status = 0 and orderId = "123456789";
update Orders set status = 2 where status = 1 and orderId = "123456789";
update Orders set status = 3 where status = 1 and orderId = "123456789";

该SQL语句利用【订单状态的CAS】来保证该操作的幂等性。比如,要进行订单支付,先用CAS思想做更新订单状态的操作,然后再去做实际支付的操作:

(1)返回影响行数=1,则代表订单状态修改成功,可以继续执行后面的支付业务代码。

(2)返回影响行数=0,则代表订单状态修改失败,该订单已经不是待支付订单了,不可以继续执行后面的支付业务代码。(其实这里的解释有待商榷)。

注释:实际这里是利用CAS原理。

消费端-幂等性保障

什么情况下会出现重复消费?

  当消费者消费完消息时,在给生产端返回ack时由于网络中断,导致生产端未收到确认信息,该条消息会重新发送并被消费者消费,但实际上该消费者已成功消费了该条消息,这就是重复消费问题。

如何避免消息的重复消费问题?

  消费端实现幂等性,就意味着,我们的消息永远不会消费多次,即使我们收到了多条一样的消息

  业界主流的幂等性操作:

  · 唯一ID + 指纹码机制,利用数据库主键去重

  · 利用Redis的原子性去实现

唯一ID+指纹码机制

  · 唯一ID + 指纹码机制,利用数据库主键去重

  · SELECT COUNT(1) FROM T_ORDER WHERE ID = 唯一ID +指纹码

  · 好处:实现简单

  · 坏处:高并发下有数据库写入的性能瓶颈

  · 解决方案:跟进ID进行分库分表进行算法路由

  整个思路就是首先我们需要根据消息生成一个全局唯一的ID,然后还需要加上一个指纹码。这个指纹码它并不一定是系统去生成的,而是一些外部的规则或者内部的业务规则去拼接,它的目的就是为了保障这次操作是绝对唯一的。

  将ID + 指纹码拼接好的值作为数据库主键,就可以进行去重了。即在消费消息前呢,先去数据库查询这条消息的指纹码标识是否存在,没有就执行insert操作,如果有就代表已经被消费了,就不需要管了。

  对于高并发下的数据库性能瓶颈,可以跟进ID进行分库分表策略,采用一些路由算法去进行分压分流。应该保证ID通过这种算法,消息即使投递多次都落到同一个数据库分片上,这样就由单台数据库幂等变成多库的幂等。

利用Redis的原子性去实现

setnx key value:当且仅当 key 不存在时,将 key 的值设为 value,并返回 1。若给定的 key 已经存在,则 setnx 不做任何动作,并返回 0。注意:该命令在设置成功时返回 1,设置失败时返回 0。

​ 我们都知道redis是单线程的,并且性能也非常好,提供了很多原子性的命令。比如可以使用 setnx 命令。

在接收到消息后将消息ID作为key执行 setnx 命令,如果执行成功就表示没有处理过这条消息,可以进行消费了,执行失败表示消息已经被消费了。

使用 redis 的原子性去实现主要需要考虑两个点

  · 第一:我们是否要进行数据落库,如果落库的话,关键解决的问题是数据库和缓存如何做到原子性?

  · 第二:如果不进行落库,那么都存储到缓存中,如何设置定时同步的策略(同步到关系型数据库)?缓存又如何做到数据可靠性保障呢

  关于不落库,定时同步的策略,目前主流方案有两种,第一种为双缓存模式,异步写入到缓存中,也可以异步写到数据库,但是最终会有一个回调函数检查,这样能保障最终一致性,不能保证100%的实时性。第二种是定时同步,比如databus同步。

  1.使用redis的setnx命令的情况下,如果消费者端setnx成功后,进行消息消费,但是此时突然宕机。那么对于接下来一段时间内(指锁的有效时长),就无法保证消息的及时消费?

  答:首先宕机问题要尽量避免,通过一些高可用的方案降低宕机的风险,如果确实宕机了,对于已发送但未被消费的消息,可以自己去做补偿或者投递到延迟队列里处理,宕机会造成生产端消息堆积,如果对消息实时处理要求比较高,需要提前预备一些应急方案另起服务去处理这些消息。

  2.redis的setnx怎么做幂等性的? 锁的有效时长设为多少呢

  redis实现幂等很简单,我以redis实现接口的幂等性为例说明。你可以自定义一个幂等注解,然后配合AOP进行方法拦截,对拦截的请求信息(包括方法名+参数名+参数值)根据固定的规则去生成一个key,然后调用redis的setnx方法,如果返回ok,则正常调用方法,否则就是重复调用了。这样可以保证重复请求接口在一定时间内只会被成功处理一次。至于锁的有效时长要根据业务情况而定的。

mq如何保证消息的幂等性

一、出现非幂等性的情况

  1、生产者已把消息发送到mq,在mq给生产者返回ack的时候网络中断,故生产者未收到确定信息,生产者认为消息未发送成功,但实际情况是,mq已成功接收到了消息,在网络重连后,生产者会重新发送刚才的消息,造成mq接收了重复的消息

  2、消费者在消费mq中的消息时,mq已把消息发送给消费者,消费者在给mq返回ack时网络中断,故mq未收到确认信息,该条消息会重新发给其他的消费者,或者在网络重连后再次发送给该消费者,但实际上该消费者已成功消费了该条消息,造成消费者消费了重复的消息;

二、解决办法

  1、mq接收生产者传来的消息:

mq内部会为每条消息生成一个全局唯一、与业务无关的消息id,当mq接收到消息时,会先根据该id判断消息是否重复发送,mq再决定是否接收该消息。

  2、消费者消费mq中的消息:

也可利用mq的该id来判断,或者可按自己的规则生成一个全局唯一id,每次消费消息时用该id先判断该消息是否已消费过

总结

幂等意味着一条请求的唯一性。不管是你哪个方案去设计幂等,都需要一个全局唯一的ID,去标记这个请求是独一无二的。

  • 如果你是利用唯一索引控制幂等,那唯一索引是唯一的
  • 如果你是利用数据库主键控制幂等,那主键是唯一的
  • 如果你是悲观锁的方式,底层标记还是全局唯一的ID

全局的唯一性ID:可以使用UUID,但是UUID的缺点比较明显,它字符串占用的空间比较大,生成的ID过于随机,可读性差,而且没有递增。我们还可以使用雪花算法(Snowflake)或者百度的Uidgenerator或者美团的Leaf 生成唯一性ID,

posted @ 2023-01-05 16:36  caibaotimes  阅读(429)  评论(0编辑  收藏  举报