关于幂等问题

幂等的作用

无论是单机或者分布式系统都会遇到并发的问题,除了使用类似流量控制等的方法之外,当请求真正到达后端服务器之后,幂等设计就可以作为一种解决方案了。

基于消息队列的分布式架构的系统中,由于各种原因,比如网络波动等,队列里面的消息就会重发,从本质上来说,系统自动产生了一个并发请求。简单一点也可以理解为一个webapi接口被完全“相同”的参数调用了多次,这时候幂等就派上用处了。

参考文章:
什么是幂等?分布式锁如何实现业务幂等?
如何保证接口的幂等性?

前端层面实现

从前端考虑解决幂等问题主要有三种方式:前端防重、PRG模式、Token机制

  1. 前端防重
    通过前端防重保证幂等是最简单的实现方式,前端相关属性和JS代码即可完成设置。可靠性并不好,有经验的人员可以通过工具跳过页面仍能重复提交。主要适用于表单重复提交或按钮重复点击。
  2. PRG模式
    即POST-REDIRECT-GET,是一种比较常见的前端防重策略。当用户进行表单提交时,会重定向到另外一个提交成功页面,而不是停留在原先的表单页面。这样就避免了用户刷新导致重复提交。同时防止了通过浏览器按钮前进/后退导致表单重复提交。
  3. 使用token
    通过token机制来保证幂等是一种非常常见的解决方案,同时也适合绝大部分场景。该方案需要前后端进行一定程度的交互来完成。
    在这里插入图片描述

但是这种先执行业务再删除token的方案会有问题。在高并发情况下,很有可能出现第一次访问时token存在,完成具体业务操作。但在还没有删除token时,客户端又携带token发起请求,此时,因为token还存在,第二次请求也会验证通过,执行具体业务操作。

解决这个并发问题的核心思想就是并行变串行,会造成一定性能损耗与吞吐量降低。

第一种方案:对于业务代码执行和删除token整体加线程锁。当后续线程再来访问时,则阻塞排队。
第二种方案:借助redis单线程和incr是原子性的特点。当第一次获取token时,以token作为key,对其进行自增。
然后将token进行返回,当客户端携带token访问执行业务代码时,对于判断token是否存在不用删除,而是对其继续incr。如果incr后的返回值为2。则是一个合法请求允许执行,如果是其他值,则代表是非法请求,直接返回。

那如果先删除token再执行业务呢?其实也会存在问题,假设具体业务代码执行超时或失败,没有向客户端返回明确结果,那客户端就很有可能会进行重试,但此时之前的token已经被删除了,则会被认为是重复请求,不再进行业务处理。

这种方案无需进行额外处理,一个token只能代表一次请求。一旦业务执行出现异常,则让客户端重新获取令牌,重新发起一次访问即可。推荐使用先删除token方案。
但是无论先删token还是后删token,都会有一个相同的问题。每次业务请求都回产生一个额外的请求去获取token。但是,业务失败或超时,在生产环境下,一万个里最多也就十个左右会失败,那为了这十来个请求,让其他九千九百多个请求都产生额外请求,就有一些得不偿失了。虽然redis性能好,但是这也是一种资源的浪费。

数据库层面实现

数据库实现幂等性的方案有两个:

  1. 乐观锁

    mysql乐观锁更适用于一些需要计数的表上,而且在竞争不激烈,出现并发冲突几率较小时,推荐使用乐观锁。虽然通过MySQL乐观锁可以完成并发控制,但锁的操作是直接作用于数据库上,这样就会在一定程度上对数据库性能产生影响。并且mysql的连接数量是有限的,如果出现大量锁操作占用连接时,也会造成MySQL的性能瓶颈。

  1. 防重表(唯一索引)

    对于防止数据重复提交,有一种解决方案就是通过防重表实现。防重表的实现思路也非常简单。首先创建一张表作为防重表,同时在该表中建立一个或多个字段的唯一索引作为防重字段,用于保证并发情况下,数据只有一条。在向业务表中插入数据之前先向防重表插入,如果插入失败则表示是重复数据。

    对于防重表的解决方案,可能有人会说为什么不使用悲观锁。悲观锁在使用的过程中也是会发生死锁的。悲观锁是通过锁表的方式实现的。 假设现在一个用户A访问表A(锁住了表A),然后试图访问表B; 另一个用户B访问表B(锁住了表B),然后试图访问表A。 这时对于用户A来说,由于表B已经被用户B锁住了,所以用户A必须等到用户B释放表B才能访问。 同时对于用户B来说,由于表A已经被用户A锁住了,所以用户B必须等到用户A释放表A才能访问。此时死锁就已经产生了。

JVM锁实现

JVM 锁实现是指通过 JVM 提供的内置锁如 Lock 或者是 synchronized 来实现幂等性。使用 JVM 锁来实现幂等性的一般流程为:首先通过 Lock 对代码段进行加锁操作,然后再判断此订单是否已经被处理过,如果未处理则开启事务执行订单处理,处理完成之后提交事务并释放锁,执行流程如下图所示:
在这里插入图片描述
JVM 锁存在的最大问题在于,它只能应用于单机环境,因为 Lock 本身为单机锁,所以它就不适应于分布式多机环境。

分布式锁实现

分布式锁实现幂等性的逻辑是,在每次执行方法之前先判断是否可以获取到分布式锁,如果可以,则表示为第一次执行方法,否则直接舍弃请求即可,执行流程如下图所示:
在这里插入图片描述
分布式锁存在的问题是

  1. 锁续期问题

    当对业务进行加锁时,锁的过期时间,绝对不能想当然的设置一个值。假设线程A在执行某个业务时加锁成功并设置锁过期时间。但该业务执行时间过长,业务的执行时间超过了锁过期时间,那么在业务还没执行完时,锁就自动释放了。接着后续线程就可以获取到锁,又来执行该业务。就会造成线程A还没执行完,后续线程又来执行,导致同一个业务逻辑被重复执行。

  2. 服务单点&集群问题

    在单点redis虽然可以完成锁操作,可一旦redis服务节点挂掉了,则无法提供锁操作。
    在生产环境下,为了保证redis高可用,会采用异步复制方法进行主从部署。当主节点写入数据成功,会异步的将数据复制给从节点,并且当主节点宕机,从节点会被提升为主节点继续工作。假设主节点写入数据成功,在没有将数据复制给从节点时,主节点宕机。则会造成提升为主节点的从节点中是没有锁信息的,其他线程则又可以继续加锁,导致互斥失效。