幂等性解决方案

2,454 阅读6分钟

1. 幂等性介绍

  • 在计算机中,表示对同一个过程应用相同的参数多次和应用一次产生的效果是一样,这样的过程即被称为满足幂等性。
  1. 幂等:update test_user set user_age = 25 where user_id=2,这中情况无论执行多少次,结果都不受影响,所以是幂等的。
  2. 非幂等:update test_user set user_times = user_times + 1 where user_id = 2, 这样的更新语句每执行一次,结果都会 不一样,所以是非幂等的。

2. 需要幂等的场景

可能会发生重复请求或消费的场景,在微服务架构中是随处可见的。以下几个常见场景:

  • 网络波动:因网络波动,可能会引起重复请求

  • 分布式消息消费:任务发布后,使用分布式消息服务来进行消费,参考【消息总线真的能保证幂等?】

  • 用户重复操作:用户在使用产品时,可能会误操作而触发多笔交易,或者因为长时间没有响应,而有意触发多笔交易。

  • 未关闭的重试机制:技术人员人为的错误,因开发人员、测试人员或运维人员没有检查出来,而开启的重试机制(如Nginx重试、RPC通信重试或业务层重试等)

3. “天然”的幂等和需要“人工”的幂等

  1. CRUD分析 CRUD是指在做计算处理时的[增加](Create)、读取(Read)、更新(Update)和删除(Delete)几个单词的首字母简写。主要被用在描述软件系统中数据库或者持久层的基本操作功能。 所以CRUD角度分析幂等性,是从操作目的层面来看问题的:
操作 幂等性
新增类请求(C) 数据库自增主键,不具备幂等性
查询类动作(R) 重复查询不会产生或变更新的数据,因此查询是天然具备幂等性
基于主键的计算式更新(U) 不具备幂等性,即:UPDATE goods SET number=number-1 WHERE id=1
基于主键的非计算式更新(U) 具备幂等性,即:UPDATE goods SET number=newNumber WHERE id=1
基于条件查询的更新(U) 不一定具有幂等性(需要根据实际情况进行分析判断)
基于主建的删除(D) 具备幂等性
业务层面都是逻辑删除,即Update操作(U) 不具备幂等性
  1. HTTP方法分析 按照restful规范定义的接口,使用http方法,应该严格遵循http方法语义:
方法 幂等性 对应CRUD操作
POST 不安全且不幂等 C
GET 安全且幂等 R
PUT 不安全但幂等 U
DELETE 不安全但幂等 D
  1. “天然”的幂等 GET,PUT,DELETE都是幂等操作,而POST不是,以下进行分析:首先GET请求很好理解,对资源做查询多次,此实现的结果都是一样的。PUT请求的幂等性可以这样理解,将A修改为B,它第一次请求值变为了B,再进行多次此操作,最终的结果还是B,与一次执行的结果是一样的,即属于CURD中所说的基于主键的非计算式更新,所以PUT是幂等操作。同理可以理解DELETE操作,第一次将资源删除后,后面多次进行此删除请求,最终结果是一样的,将资源删除掉了。

  2. 需要“人工”的幂等 POST不是幂等操作,因为一次请求添加一份新资源,二次请求则添加了两份新资源,多次请求会产生不同的结果,因此POST不是幂等操作。如果需要在POST方法的接口实现幂等,需要人为加上幂等的机制。 下面我们来说说,幂等地实现方法。

4.幂等性解决方案

  1. 全局唯一ID 如果使用全局唯一ID,就是根据业务的操作和内容生成一个全局ID,在执行操作前先根据这个全局唯一ID是否存在,来判断这个操作是否已经执行。如果不存在则把全局ID,存储到存储系统中,比如数据库、Redis等。如果存在则表示该方法已经执行。 使用全局唯一ID是一个通用方案,可以支持插入、更新、删除业务操作。但是这个方案看起来很美但是实现起来比较麻烦,下面的方案适用于特定的场景,但是实现起来比较简单。
  2. 去重表 这种方法适用于在业务中有唯一标的插入场景中,比如在以上的支付场景中,如果一个订单只会支付一次,所以订单ID可以作为唯一标识。这时,我们就可以建一张去重表,并且把唯一标识作为唯一索引,用以记录订单支付信息,在我们实现时,把创建支付单据和写入去去重表,放在一个事务中,如果重复创建,数据库会抛出唯一约束异常,操作就会回滚。这个方法其实也是用到唯一ID,与上面全局唯一ID不同的是,他是针对具体单个业务流程的,实现起来相对简单。
  3. 插入或更新 这种方法插入并且有唯一索引的情况,比如我们要关联商品品类,其中商品的ID和品类的ID可以构成唯一索引,并且在数据表中也增加了唯一索引。这时就可以使用InsertOrUpdate操作。在mysql数据库中如下:
insert into goods_category (goods_id,category_id,create_time,update_time) 
values(#{goodsId},#{categoryId},now(),now()) on DUPLICATE KEY UPDATE update_time=now()
  1. 多版本控制 这种方法适合在更新的场景中,比如我们要更新商品的名字,这时我们就可以在更新的接口中增加一个版本号,来做幂等: boolean updateGoodsName(int id,String newName,int version); 在实现时可以如下:
update goods set name=#{newName},version=#{version} where  id=#{id} and version<${version}
  1. 状态机控制 这种方法适合在有状态机流转的情况下,比如就会订单的创建和付款,订单的付款肯定是在之前,这时我们可以通过在设计状态字段时,使用int类型,并且通过值类型的大小来做幂等,比如订单的创建为0,付款成功为100,付款失败为99。在做状态机更新时,我们就这可以这样控制:
update goods_order set status=#{status} where id=#{id} and status<#{status}

5.实战解决方案

  1. 火车票下单,重复订单解决问题:进入订单页面之前,后台生成全局唯一订单给前端,同时将这个订单号加入到BoomFilter过滤器中。

  2. 支付结果通知,通过订单号加锁来处理同一条订单执行操作,修改订单状态,验证订单状态是否已经处理过了。

  3. 防止页面重复提交,获取页面时返回token,提交市带上token,处理之前先删除token,如果token不存在任务已经提交过了。

  4. 减库存的时候带上版本号,版本号相同就减库存。

参考