当前位置: 首页 > 科技观察

说说幂等设计

时间:2023-03-18 22:13:27 科技观察

前言大家好,我是田洛,一名程序员。今天我们来谈谈幂等设计。什么是幂等性为什么需要幂等性接口超时,如何处理?如何设计幂等性?实现幂等性的8种解决方案HTTP幂等性1.什么是幂等性?幂等性是数学和计算机科学中的一个概念。在数学中,幂等性的函数表达式是:f(x)=f(f(x))。例如求绝对值的函数是幂等的,abs(x)=abs(abs(x))。在计算机科学中,幂等是指一次和多次请求资源应该具有相同的副作用,或者说多次请求的影响与一次请求执行的影响相同。2.为什么需要幂等性?举个例子:我们开发一个传递函数,假设调用下游接口超时。一般情况下,超时可能是网络传输丢包造成的,也可能是请求时没有发送,也可能是请求到了,但是返回的结果丢失了。这个时候可以重试吗?如果我们重试,我们会转一笔钱吗?现在的互联网系统在传输超时后几乎是解耦和隔离的,不同系统之间会相互远程调用。调用远程服务有三种状态:成功、失败或超时。前两个是确定状态,而超时是未知状态。当我们的转账超时的时候,如果下游的转账系统做幂等控制,我们发起重试,那么我们就可以保证转账正常进行,也可以保证不会有多余的转账。其实除了transfer这个例子,日常开发中还有很多例子需要考虑幂等性。例如:MQ(消息中间件)消费者在读取消息时,可能会读取到重复的消息。(重复消费)比如提交表单时,如果快速点击提交按钮,可能会生成两份相同的数据(前端重复提交)3、接口超时了,怎么办?加工呢?有两种方案:方案一:下游系统提供相应的查询接口。如果接口超时,先查看相应的记录。如果成功,它将进入成功过程。如果失败,将被视为失败。以我们的转账例子为例,转账系统提供了查询转账记录的接口。如果通道系统超时调用转账系统,通道系统首先检查记录,看转账记录是成功还是失败。如果成功,会走成功的流程,如果失败,会再次尝试发起转账。方案二:下游接口支持幂等。如果上游系统调用超时,就发起重试。两种方案都挺好的,但是如果是MQ重复消费的场景,第一种方案不太合适,所以我们还是需要下游系统的对外接口支持幂等性。4、如何设计幂等性既然这么多场景需要考虑幂等性,那么我们如何设计幂等性呢?幂等性意味着请求的唯一性。无论你使用哪种方案来设计幂等性,都需要一个全局唯一的ID来标记这个请求是唯一的。如果使用唯一索引来控制幂等性,那么唯一索引是唯一的如果使用数据库主键来控制幂等性,那么主键是唯一的如果使用悲观锁,则底层标签仍然是全局唯一的ID4.1GlobalUniqueID全局唯一ID,我们怎么生成呢?大家还记得数据库主键Id是怎么生成的吗?是的,我们可以使用UUID,但是UUID的缺点很明显,它的字符串占用空间比较大,生成的ID太随机,可读性差,而且不自增。我们还可以使用雪花算法(Snowflake)来生成唯一的ID。Snowflake算法是一种生成分布式全局唯一ID的算法,生成的ID称为SnowflakeID。该算法由Twitter创建,用于识别推文。SnowflakeID有64位。Bit1:Java中long的最高位是符号位,代表正数或负数。正数为0,负数为1。一般生成的ID都是正数,所以默认为0。接下来的前41位是时间戳,表示从选中的epoch开始经过的毫秒数。接下来的10位代表计算机ID,以防止冲突。剩下的12位代表生成的ID在每台机器上的序号,这允许在同一毫秒内创建多个SnowflakeID。雪花算法当然,全球唯一ID也可以用百度的Uidgenerator,或者美团的Leaf。4.2幂等设计的基本过程幂等处理的过程实际上就是对接收到的请求进行过滤。当然,请求必须有一个全球唯一的ID标签。那么,如何判断之前是否收到过请求呢?存储请求,收到请求时先查看存储记录。如果记录存在,则返回上次的结果,如果不存在,则处理请求。一般的幂等处理是这样的,如下:5.幂等设计的基本过程类似于实现幂等的8种方案。下面简单介绍一下幂等实现的8种方案。5.1select+insert+主键/唯一索引冲突在日常开发中,为了实现事务接口的幂等性,我是这样实现的:当事务请求来的时候,我会先根据选择数据库的流表请求的唯一序列号的bizSeq字段。如果数据已经存在,如果拦截的是重复请求,直接返回成功;如果数据不存在,执行插入,如果插入成功,直接返回成功,如果插入产生主键冲突异常,捕获异常,然后直接返回成功。流程图如下,伪代码如下:/***幂等处理*/Rspidempotent(Requestreq){ObjectrequestRecord=selectByBizSeq(bizSeq);if(requestRecord!=null){//拦截的是重复的请求日志.info("Repeatedrequest,Directreturnsuccess,serialnumber:{}",bizSeq);returnrsp;}try{insert(req);}catch(DuplicateKeyExceptione){//拦截为重复请求,直接返回成功log。info("主键冲突,是重复请求,直接返回成功,序号:{}",bizSeq);returnrsp;}//请求正常处理dealRequest(req);returnrsp;}为什么已经选择了query之前,还需要try...catch...capture重复异常呢?因为在高并发场景下,两个请求去select的时候,可能找不到,然后都去insert的地方。当然也可以用唯一索引代替数据库的主键,也就是全局唯一的ID。5.2.直接插入+主键/唯一索引冲突在5.1方案中,会先检查流表中的事务请求,判断是否存在,如果不存在,再插入请求记录。如果重复请求的概率比较低,我们可以直接插入请求,通过主键/唯一索引冲突来判断是否是重复请求。流程图如下:伪代码如下:/***idempotentprocessing*/Rspidempotent(Requestreq){try{insert(req);}catch(DuplicateKeyExceptione){//拦截是重复请求,并且成功直接返回log.info("主键冲突是重复请求,直接返回成功,序号:{}",bizSeq);returnrsp;}//正常处理请求dealRequest(req);returnrsp;}提醒:大家不要混淆,防止heavy和power在设计上其实是有区别的。防重主要是为了避免重复的数据,只是拦截重复的请求。幂等设计除了拦截处理过的请求外,还要求每次相同的请求返回相同的效果。然而,在许多情况下,它们的处理流程可能是相似的。5.3状态机幂等许多业务表是有状态的。比如转流表会有0-pending、1-processing、2-success、3-failure状态。传输流更新时,涉及到流状态更新,即涉及到状态机(即状态变化图)。我们可以使用状态机来实现幂等性,我们来看看它是如何实现的。例如转账成功后更新transferflowinprocess为successful状态,SQL写成:updatetransfr_flowsetstatus=2wherebiz_seq='666'andstatus=1;简要流程图如下:伪代码实现如下:RspidempotentTransfer(Requestreq){StringbizSeq=req.getBizSeq();introws="updatetransfr_flowsetstatus=2wherebiz_seq=#{bizSeq}andstatus=1;"if(rows==1){log.info("更新成功,可以处理请求");//其他业务逻辑处理returnrsp;}elseif(rows==0){log.info("更新不成功,请求未处理");//不处理,直接返回returnrsp;}log.warn("数据异常")returnrsp:}状态机如何实现幂等性?当第一个请求来的时候,bizSeq序号为666,串水状态为processing,值为1,需要更新为2-successful状态,所以update语句可以正常更新数据,SQL执行结果影响的行数为1,pipelinestatus最终变为2。第二个请求也来了,如果它的序号还是666,因为序号已经是2-成功了,所以更新结果为0,不处理业务逻辑,直接返回接口。5.45.1和5.2的防重表提取方案是基于bizSeq在业务流表上的唯一性。很多时候,我们业务表的唯一序号需要后台系统生成,或者我们希望防重功能与业务表分离。这时候我们可以单独创建一个防重表。当然,防重表也是利用了主键/索引的唯一性。如果插入到防重表冲突,则直接返回成功。如果插入成功,请求将被处理。5.5TokenToken令牌方案一般包括两个请求阶段:客户端请求申请令牌,服务器生成令牌并返回给客户端请求令牌,服务器验证令牌。流程图如下:客户端发起请求,申请获取token。服务端生成一个全局唯一的token,保存在redis中(一般会设置一个过期时间),然后返回给客户端。客户端使用令牌发起请求。服务器去redis确认token是否存在。一般使用redis.del(token)。如果存在则删除成功,即处理业务逻辑。如果删除失败,则不处理业务逻辑,直接返回结果。5.6悲观锁(如selectforupdate)什么是悲观锁?总体来说是非常悲观的。每次操作数据,感觉别人都会修改一半,所以每次获取数据都会被锁住。官方的说法是,共享资源一次只被一个线程使用,其他线程阻塞,使用完再转移给其他线程使用。悲观锁是如何控制幂等性的?就是加锁,一般和事务一起实现。举一个更新订单的业务场景:假设先找到订单,如果发现是processing状态,就结束业务,然后更新订单状态完成。如果找到订单,是否处于处理状态,则直接返回整体的伪代码如下:#1。启动事务select*fromorderwhereorder_id='666'#查询订单并判断状态if(status!=processing){//非处理状态,直接返回;return;}##处理业务逻辑updateordersetstatus='completed'whereorder_id='666'#更新完成commit;#5。提交事务的场景是非原子操作,在高并发环境下,可能会出现一个业务被执行两次的问题:一个请求A正在执行的时候,另一个请求B也开始了status的操作判断。因为请求A还没来得及改变状态,请求B也能执行成功,导致一个业务被执行了两次。可以使用数据库悲观锁(select...forupdate)来解决这个问题。begin;#1。启动事务select*fromorderwhereorder_id='666'forupdate#查询订单,判断状态,锁定这条记录if(status!=processingMiddle){//非处理状态,直接返回;return;}##处理业务逻辑updateordersetstatus='complete'whereorder_id='666'#更新完成commit;#5。事务提交中的order_id需要是索引或者主键,就把这条记录锁住,如果不是索引或者主键,就会锁表!悲观锁是在同一个事务操作中锁定一行数据。其他请求只能等待。如果当前事务耗时较长,会极大地影响接口的性能。所以一般不推荐使用悲观锁来做这件事。5.7乐观锁悲观锁有性能问题,可以试试乐观锁。什么是乐观锁?乐观锁在操作数据的时候很乐观,认为其他人不会同时修改数据,所以乐观锁不会上锁。只要判断什么时候进行更新,这段时间别人有没有修改过数据。如何实现乐观锁?就是给表增加一个版本号,每次更新时更新记录版本(version=version+1)。具体过程是先找出当前的版本号version,然后在更新和修改数据时,确认是否是刚查到的版本号,如果是,则进行更新。比如我们更新之前先查资料,查出版本号是version=1selectorder_id,versionfromorderwhereorder_id='666';然后将version=1和orderId一起作为条件,然后更新updateordersetversion=version+1,status='P'whereorder_id='666'andversion=1最后更新成功,才能处理业务逻辑。如果更新失败,默认是重复请求,直接返回。流程图如下:为什么建议版本号自增?因为乐观锁有ABA的问题,如果version版本一直自增,就不会出现ABA的情况。5.8分布式锁分布式锁实现幂等性的逻辑是,当有请求进来时,首先尝试获取分布式锁。如果成功,将执行业务逻辑。否则获取失败则丢弃请求,直接返回请求。执行过程如下图所示:分布式锁可以用Redis,也可以用ZooKeeper,但Redis更好,因为它更轻量。Redis分布式锁可以使用命令SETEXPXNX+唯一序列号来实现。分布式锁的key必须是业务的唯一标识。Redis在执行设置key的动作时,必须设置过期时间。过期时间不能太短,太短不能拦截重复请求,也不能设置太长,会占用存储空间。6、HTTP的幂等性我们的接口一般都是基于http的,那么再来说说Http的幂等性。HTTP请求方式主要有以下几种。让我们看看每个接口是否是幂等的。GET方法HEAD方法OPTIONS方法DELETE方法POST方法PUT方法6.1GET方法HTTP的GET方法用于获取资源,类比数据库的select查询。它不应该有副作用,所以它是幂等的。它不会改变资源的状态,不管你调用一次还是多次,效果都是一样的,没有副作用。如果你的GET方法是为了获取最新消息,在不同的时间点调用,虽然返回的资源内容不同,但最终对资源本质没有影响,所以还是幂等的。6.2HEAD方法HTTPHEAD有点像GET,主要区别在于HEAD不包含表示数据,而只包含HTTP头信息,因此也是幂等的。如果要判断一个资源是否存在,很多人会用GET,但是HEAD更合适。即通常使用HEAD方法进行探测。6.3OPTIONS方法HTTPOPTIONS主要是用来获取当前URL支持的方法,也有点像query,所以也是幂等的。6.4DELETE方法HTTPDELETE方法用于删除资源,它是幂等的。比如我们要删除id=666的帖子,执行一次和多次执行的效果是一样的。6.5POST方法HTTPPOST方法用于创建资源,可以类比为提交信息。很明显,一次和多次提交都有副作用,执行效果不一样,不满足幂等性。例如:POSThttp://www.tianluo.com/articles的语义是在http://www.tianluo.com/articles下创建一篇文章,HTTP响应应该包括文章的创建状态和文章的URI。两个相同的POST请求会在服务器端创建两个URI不同的资源;因此,POST方法不是幂等的。6.6PUT方法HTTPPUT方法用于创建或更新操作,对应的URI为要创建或更新的资源,有副作用,应满足幂等性。例如:PUThttp://www.tianluo.com/articles/666的语义是创建或更新ID为666的帖子。多个PUT对同一个URI的副作用与一个PUT相同;因此,PUT方法是幂等的。参考[1]ElasticDesign中的《幂等设计》:https://time.geekbang.org/column/article/4050