SpringBoot第19讲:SpringBoot 如何保证接口幂等
在以SpringBoot开发Restful接口时,如何防止接口的重复提交呢? 本文是SpringBoot第19讲,主要介绍接口幂等相关的知识点,并实践常见基于Token实现接口幂等。
文章目录
- SpringBoot第19讲:SpringBoot 如何保证接口幂等
- 1、准备知识点
- 1.1、什么是幂等?
- 1.2、什么是接口幂等?
- 2、常见的保证幂等的方式?
- 2.1、数据库层面
- 1、悲观锁
- 2、唯一ID/索引
- 3、乐观锁(基于版本号或者时间戳)
- 2.2、分布式锁
- 2.3、防重 Token 令牌
- 3、示例源码
- 4、参考文章
1、准备知识点
从幂等和防止重复提交,接口幂等和常见的保证幂等的方式等知识点构筑知识体系。
1.1、什么是幂等?
幂等原先是数学中的一个概念,表示进行1次变换和进行N次变换产生的效果相同。
当我们讨论接口的幂等性时一般是在说:以相同的请求调用这个接口一次和调用这个接口多次,对系统产生的影响是相同的。如果一个接口满足这个特性,那么我们就说这个接口是一个幂等接口。
- 接口幂等和防止重复提交是一回事吗?
- 严格来说,并不是。
- 幂等: 更多的是在重复请求已经发生,或是无法避免的情况下,采取一定的技术手段让这些重复请求不给系统带来副作用。
- 防止重复: 提交更多的是不让用户发起多次一样的请求。比如说用户在线购物下单时点了提交订单按钮,但是由于网络原因响应很慢,此时用户比较心急多次点击了订单提交按钮。 这种情况下就可能会造成多次下单。一般防止重复提交的方案有:将订单按钮置灰,跳转到结果页等。主要还是从客户端的角度来解决这个问题。
- 防重提交可以参考这篇文章:项目实战第四十二讲:分布式环境下,使用ResubmitCheck注解进行防重校验
-
哪些情况下客户端是防止不了重复提交的?
-
虽然我们可在客户端做一些防止接口重复提交的事(比如将订单按钮置灰,跳转到结果页等), 但是如下情况依然客户端是很难控制接口重复提交到后台的,这也进一步表明了接口幂等和防止重复提交不是一回事以及后端接口保证接口幂等的必要性所在。
1、接口超时重试:接口可能会因为某些原因而调用失败,出于容错性考虑会加上失败重试的机制。如果接口调用一半,再次调用就会因为脏数据的存在而出现异常。
2、消息重复消费:在使用消息中间件来处理消息队列,且手动ack确认消息被正常消费时。如果消费者突然断开连接,那么已经执行了一半的消息会重新放回队列。被其他消费者重新消费时就会导致结果异常,如数据库重复数据,数据库数据冲突,资源重复等。
3、请求重发:网络抖动引发的nginx重发请求,造成重复调用;
-
1.2、什么是接口幂等?
在HTTP/1.1中,对幂等性进行了定义。它描述了一次和多次请求某一个资源对于资源本身应该具有同样的结果(网络超时等问题除外),即第一次请求的时候对资源产生了副作用,但是以后的多次请求都不会再对资源产生副作用。
这里的副作用是不会对结果产生破坏或者产生不可预料的结果。也就是说,其任意多次执行对资源本身所产生的影响均与一次执行的影响相同。
- 对哪些类型的接口需要保证接口幂等?
我们看下标准的restful请求,幂等情况是怎么样的:
- SELECT查询操作
- GET:只是获取资源,对资源本身没有任何副作用,天然的幂等性。
- HEAD:本质上和GET一样,获取头信息,主要是探活的作用,具有幂等性。
- OPTIONS:获取当前URL所支持的方法,因此也是具有幂等性的。
- DELETE删除操作
- 删除的操作,如果从删除的一次和删除多次的角度看,数据并不会变化,这个角度看它是幂等的
- 但是如果,从另外一个角度,删除数据一般是返回受影响的行数,删除一次和多次删除返回的受影响行数是不一样的,所以从这个角度它需要保证幂等。(折中而言DELETE操作通常也会被纳入保证接口幂等的要求)
- 项目开发中一般不返回删除数据受影响的行数
- ADD/EDIT操作
- PUT:用于更新资源,有副作用,但是它应该满足幂等性,比如根据id更新数据,调用多次和N次的作用是相同的(根据业务需求而变)。
- POST:用于添加资源,多次提交很可能产生副作用,比如订单提交,多次提交很可能产生多笔订单。
2、常见的保证幂等的方式?
我们来看下常见的保证幂等的方式。
2.1、数据库层面
1、悲观锁
典型的数据库悲观锁:
for update
select * from t_order where order_id = trade_no for update;
为什么加for update就可以?
- 当线程A执行for update,数据会对当前记录加锁,其他线程执行到此行代码的时候,会等待线程A释放锁之后,才可以获取锁,继续后续操作。
- 事物提交时,for update获取的锁会自动释放。
PS:这种方式很少被使用,因为如果业务处理比较耗时,并发情况下,后面线程会长期处于等待状态,占用了很多线程,让这些线程处于无效等待状态,我们的web服务中的线程数量一般都是有限的,如果大量线程由于获取for update锁处于等待状态,不利于系统并发操作。
- 项目中没有采用该方案
可以参考这篇文章:MySQL第八讲:数据库事务及MVCC机制/分布式事务实战
2、唯一ID/索引
针对的是插入操作。
数据库唯一主键的实现主要是利用数据库中主键唯一约束的特性,一般来说唯一主键比较适用于“插入”时的幂等性,其能保证一张表中只能存在一条带该唯一主键的记录。
使用数据库唯一主键完成幂等性时需要注意的是,该主键一般来说并不是使用数据库中自增主键,而是使用分布式 ID 充当主键,这样才能能保证在分布式环境下 ID 的全局唯一性。
-
使用snowflake算法,也可以机采用数据库号段模式,或者redis自增等方式
-
去重表
去重表本质上也是一种唯一索引方案。
这种方法适用于在业务中有唯一标的插入场景中,比如在以上的支付场景中,如果一个订单只会支付一次,所以订单ID可以作为唯一标识。这时,我们就可以建一张去重表,并且把唯一标识作为唯一索引,在我们实现时,把创建支付单据和写入去去重表,放在一个事务中,如果重复创建,数据库会抛出唯一约束异常,操作就会回滚。
-
SPU创建编辑采用的这种方案,使用类目id+关键属性作为唯一标
-
# 表 spu审核锁定表 create table zcy_spu_audit_lock( category_id bigint not null comment '冗余类目id', key_property_text varchar(255) not null comment '关键属性', org_id bigint null comment '提交人机构id,映射orgId', org_name varchar(50) null comment '来源,一般是提交人机构名,映射orgName', user_id bigint null comment '创建人id,映射userId', operator varchar(50) null comment '创建人,映射operator', created_at datetime null comment '创建时间', primary key (category_id, key_property_text) ) comment 'spu审核锁定表,主用于判重';
3、乐观锁(基于版本号或者时间戳)
针对更新操作。
- 使用版本号或者时间戳
这种方法适合在更新的场景中,比如我们要更新商品的名字,这时我们就可以在更新的接口中增加一个版本号,来做幂等
boolean updateGoodsName(int id, String newName, int version);
在实现时可以如下
update goods set name=#{newName},version=#{version}+1 where id=#{id} and version=#{version}
采用该方案的业务
<!-- 库存采用了这样的方案 -->
<update id="updateAvailableAndlockStockQuantity" parameterType="map">
UPDATE
<include refid="tb"/>
<set>
<if test="availableQuantity != null">available_quantity = #{availableQuantity},</if>
<if test="lockStockQuantity != null">lock_stock_quantity = #{lockStockQuantity},</if>
update_at = now(), version = version + 1
</set>
where id = #{id} and sku_id = #{skuId} and warehouse_code = #{warehouseCode} and version = #{version}
</update>
<update id="batchUpdateStockAvailableQuantity" parameterType="java.util.List">
<foreach collection="list" item="stock" index="index" open="" close="" separator=";">
UPDATE <include refid="tb"/>
<set>
available_quantity = #{stock.availableQuantity},update_at = now(),version = version + 1
</set>
WHERE id = #{stock.id} and version = #{stock.version} and sku_id = #{stock.skuId}
</foreach>
</update>
<delete id="deleteBySkuIdAndWarehouseCode" parameterType="java.util.Map">
delete from <include refid="tb"/>
where sku_id = #{skuId} and warehouse_code = #{warehouseCode} and version = #{version}
</delete>
<!-- 商品拓展信息表 -->
<update id="update" parameterType="cn.gov.zcy.service.item.domain.ItemExtInfo">
<!--@mbg.generated-->
update zcy_item_ext_info
<set>
<if test="extJson != null">
ext_json = #{extJson,jdbcType=VARCHAR},
</if>
<if test="status != null">
`status` = #{status,jdbcType=INTEGER},
</if>
<if test="updateId != null">
update_id = #{updateId,jdbcType=BIGINT},
</if>
updated_at = now(),
version = version+1
</set>
where id = #{id,jdbcType=BIGINT}
and version = #{version}
<if test="itemId != null">
and item_id = #{itemId}
</if>
</update>
<!-- 商品价格表 -->
<update id="update" parameterType="cn.gov.zcy.service.item.domain.ZcyItemPrice">
update
<include refid="tb"/>
<set>
<if test="createdAt != null">created_at = #{createdAt},</if>
<if test="updatedAt != null">updated_at = #{updatedAt},</if>
<if test="version != null">version = #{version} + 1,</if>
<if test="remark != null">remark = #{remark},</if>
<if test="itemId != null">item_id = #{itemId},</if>
<if test="skuId != null">sku_id = #{skuId},</if>
<if test="historySalePrice != null">history_sale_price = #{historySalePrice},</if>
<if test="gpiPrice != null">gpi_price = #{gpiPrice}</if>
<if test="marketPrice != null">market_price = #{marketPrice},</if>
<if test="crawlerPrice != null">crawler_price = #{crawlerPrice},</if>
<if test="minDealPrice != null">min_deal_price = #{minDealPrice},</if>
<if test="avgDealPrice != null">avg_deal_price = #{avgDealPrice},</if>
<if test="avgSalePrice != null">avg_sale_price = #{avgSalePrice},</if>
<if test="minSalePrice != null">min_sale_price = #{minSalePrice},</if>
<if test="lastDealTime != null">last_deal_time = #{lastDealTime}</if>
where ID = #{id}
</set>
</update>
- 状态机
本质上也是乐观锁,这种方法适合在有状态机流转的情况下,比如订单的创建和付款,订单的付款肯定是在之前,这时我们可以通过在设计状态字段时,使用int类型,并且通过值类型的大小来做幂等,比如订单的创建为0,付款成功为100。付款失败为99
在做状态机更新时,我们就这可以这样控制
update `order` set status=#{status} where id=#{id} and status<#{status}
- 订单采用的这种方案
2.2、分布式锁
分布式锁实现幂等性的逻辑是,在每次执行方法之前判断,是否可以获取到分布式锁,如果可以,则表示为第一次执行方法,否则直接舍弃请求即可。
需要注意的是分布式锁的key必须为业务的唯一标识,通常用redis分布式锁或者zookeeper来实现分布式锁。
分布式锁的实现方法具体请参考:分布式系统第四讲:分布式锁及实现方案
- 创建商品、创建店铺采用的这种方案
2.3、防重 Token 令牌
简单的说就是调用方在调用接口的时候先向后端请求一个全局 ID(Token),请求的时候携带这个全局 ID 一起请求(Token 最好将其放到 Headers 中),后端需要对这个 Token 作为 Key,用户信息作为 Value 到 Redis 中进行键值内容校验,如果 Key 存在且 Value 匹配就执行删除命令,然后正常执行后面的业务逻辑。如果不存在对应的 Key 或 Value 不匹配就返回重复执行的错误信息,这样来保证幂等操作。
- 使用场景:针对客户端连续点击或者调用方的超时重试等情况,例如提交订单
使用限制:
- 需要生成全局唯一 Token 串;
- 需要使用第三方组件 Redis 进行数据效验;
主要流程
注意,在并发情况下,执行 Redis 查找数据与删除需要保证原子性,否则很可能在并发下无法保证幂等性。其实现方法可以使用分布式锁或者使用 Lua 表达式来注销查询与删除操作。
3、示例源码
todo
4、参考文章
SpringBoot接口幂等性实现的4种方案