幂等性
几个概念
- 幂等:是一个数学概念,表示N次变换和1次变换的结果相同。
- 幂等操作:其特点是任意多次执行所产生的影响均与一次执行的影响相同(不会改变资源状态,对数据没有副作用)。
- 幂等性:一系列操作都是幂等操作
- 幂等接口:幂等接口认为,外部调用者会存在多次调用的场景,为了防止重试对数据状态的改变,需要将接口的设计为幂等的
HTTP方法的幂等性是指一次和多次请求某一个资源应该具有同样的副作用。说白了就是,同一个请求,发送一次和发送N次效果是一样的!幂等性是分布式系统设计中十分重要的概念,而HTTP的分布式本质也决定了它在HTTP中具有重要地位。下面将以HTTP中的幂等性做例子加以介绍。
假设有一个从账户取钱的远程API(可以是HTTP的,也可以不是),我们暂时用类函数的方式记为:
//扣除成功返回true,失败返回false.
bool withdraw(account_id, amount)
所以问题来了,一种典型的情况是withdraw请求已经被服务器端正确处理,但服务器端的返回结果由于网络等原因被掉丢了,导致客户端无法得知处理结果。如果是在网页上,一些不恰当的设计可能会使用户认为上一次操作失败了,然后刷新页面,这就导致了withdraw被调用两次,账户也被多扣了一次钱。
- 解决方案一:采用分布式事务:
通过引入支持分布式事务的中间件来保证withdraw功能的事务性。分布式事务的优点是对于调用者很简单,复杂性都交给了中间件来管理。
缺点则是一方面架构太重量级,容易被绑在特定的中间件上,不利于异构系统的集成
另一方面分布式事务虽然能保证事务的ACID性质,而但却无法提供性能和可用性的保证 - 解决方案二:幂等设计
我们可以通过一些技巧把withdraw变成幂等的,比如:
//create_ticket的语义是获取一个服务器端生成的唯一的处理号ticket_id,用于标识后续的操作。
int create_ticket()
//idempotent_withdraw关联了一个ticket_id,一个ticket_id表示的操作至多只会被处理一次,每次调用都将返回第一次调用时的处理结果。
bool idempotent_withdraw(ticket_id, account_id, amount)
我们还可以进行改进:
在web中,请求的方式可以分为GET、DELETE、PUT、POST:
- GET请求可能会每次得到不同的结果,但它本身并没有产生任何副作用,因而是满足幂等性的。
- DELETE有副作用,但它应该满足幂等性。你重复对同一条数据进行删除,后面只是删除失败。对资源本身没有影响。
- POST方法用于创建资源,所对应的URI并非创建的资源本身,而是去执行创建动作的操作者,有副作用,不满足幂等性。
- PUT方法用于创建或更新操作,所对应的URI是要创建或更新的资源本身,有副作用,它应该满足幂等性。
所以我们可以采用POST /tickets来实现create_ticket,然后用PUT /accounts/account_id/ticket_id&amount=xxx来实现idempotent_withdraw。这样无论put多少次结果都是一样的。
相信通过刚才的例子我们应该初步了解到了幂等性的重要性。
幂等性有几个比较重要的点:
- 幂等不仅仅只是一次(或多次)请求对资源没有副作用(比如查询数据库操作,没有增删改,因此没有对数据库有任何影响)。
- 幂等还包括第一次请求的时候对资源产生了副作用,但是以后的多次请求都不会再对资源产生副作用。
- 幂等关注的是以后的多次请求是否对资源产生的副作用,而不关注结果。
- 网络超时等问题,不是幂等的讨论范围。
所以我们主要通过唯一唯一的业务单号来确保,第一步先查询业务单号是否存在,然后对其进行业务处理。但是又引出了新的问题,在高并发的情况下并不能保证执行顺序。这里我们就需要把查询和变更状态操作加锁,将并行操作改为串行操作。
- 乐观锁
如果只是更新已有的数据,没有必要对业务进行加锁,设计表结构时使用乐观锁,一般通过version来做乐观锁,这样既能保证执行效率,又能保证幂等。
UPDATE tab1 SET col1=1,version=version+1 WHERE version=#version#
2、防重表
使用订单号orderNo做为去重表的唯一索引,每次请求都根据订单号向去重表中插入一条数据。第一次请求查询订单支付状态,当然订单没有支付,进行支付操作,无论成功与否,执行完后更新订单状态为成功或失败,删除去重表中的数据。后续的订单因为表中唯一索引而插入失败,则返回操作失败,直到第一次的请求完成(成功或失败)。可以看出防重表作用是加锁的功能。
3、分布式锁
这里使用的防重表可以使用分布式锁代替,比如Redis。订单发起支付请求,支付系统会去Redis缓存中查询是否存在该订单号的Key,如果不存在,则向Redis增加Key为订单号。查询订单支付已经支付,如果没有则进行支付,支付完成后删除该订单号的Key。通过Redis做到了分布式锁,只有这次订单订单支付请求完成,下次请求才能进来。相比去重表,将放并发做到了缓存中,较为高效。思路相同,同一时间只能完成一次支付请求。
4、token令牌
这种方式分成两个阶段:申请token阶段和支付阶段。
第一阶段,在进入到提交订单页面之前,需要订单系统根据用户信息向支付系统发起一次申请token的请求,支付系统将token保存到Redis缓存中,为第二阶段支付使用。
第二阶段,订单系统拿着申请到的token发起支付请求,支付系统会检查Redis中是否存在该token,如果存在,表示第一次发起支付请求,删除缓存中token后开始支付逻辑处理;如果缓存中不存在,表示非法请求。
5、支付缓冲区
把订单的支付请求都快速地接下来,一个快速接单的缓冲管道。后续使用异步任务处理管道中的数据,过滤掉重复的待支付订单。优点是同步转异步,高吞吐。不足是不能及时地返回支付结果,需要后续监听支付结果的异步返回。