幂等性的常见实现方法
1 为什么分布式系统要求业务操作的幂等性
幂等(idempotent)的概念源于数学,幂等的特点是任意多次执行所产生的影响均与一次执行产生的影响相同,不会因为多次的操作产生别的副作用。分布式系统由多个系统与节点组成,系统与节点在进行通信时由于网络、系统或节点的不稳定性导致一次系统与系统之间接口调用结果的三态,成功、失败、未知(请求已发出,但出现超时返回异常,此时下游系统可能已处理成功也可能处理失败)。系统在处理未知状态的调用结果时,通常会通过定时任务进行重试,这也就导致了位于下游的系统其接口可能会被相同的请求重复调用多次的情况。如果分布式系统没有保证各业务操作的幂等,必然导致由于多次相同操作带来的业务副作用(用户同一笔订单支付了多次,用户收到多条相同的发货通知、商品下面展示了多条用户同一时间的相同评论记录)。
2 实现业务操作幂等性的常用方法
2.1 业务Token
业务Token通常的作法是在请求实际的业务操作前先去获取对应的业务Token,下游业务根据请求参数与时间生成Token,然后将Token写入缓存并设置一定的过期时间。真正发起业务请求时,请求发起方会将业务Token加入到请求参数中。下游业务收到业务请求后会先判段缓存中Token是否存在,如果存在则删除缓存中的业务Token并执行业务逻辑,如果不存则直接返回错误。这种做法实际是防重而非真正意思上的幂等。
方案优点、缺点、合适业务场景:业务Token方案整体实现简单,但实际为防重策略,Token过期时间要根据业务情况去确定。对于新增数据的业务场景比较适合,比如用户在支付订单前,在进入支付订单页面时交易系统可以先根据用户与订单的信息从支付系统获取业务Token,然后在支付时再将支付信息、订单信息、业务Token一起传给支付系统,支付系统再去校验业务Token,存在则调用支付渠道执行支付逻辑,不存在直接返回支付失败。
2.2 乐观锁 + 状态机或版本号
乐观锁+状态机或版本号,通常的作法是在执行业务逻辑时将对应的SQL加上乐观锁,SQL的条件一般是有现在状态或版本号。
update order set order_status = ${next_status} where order_status = ${current_status};
有些场景也会直接通过版本号使用乐观锁。
update post_detail set title = ${title}, content = ${content}, version = ${current_version} + 1 where version = ${current_version};
方案优点、缺点、合适业务场景:实现简单,不依赖外部系统,效率高,但不适合新增数据的业务场景只适合数据更新场景。这种方式不仅适合处理并发请求量很大的场景同时,同时适时一些请求量很小的场景。比如一些运营后台。
多运营人员同时操作一内容导致,最后运营人员A以为最终内容是title为t2,content为c1,运营人员B以为最终内容是title为t1,content为c2。而实际数据库中的数据为运营人员A与运营人员B操作的之和,还有一种可能是运营人员B直接把运营人员A的操作内容给覆盖了,再运营人员B与A完全不知情时。这种场景更好的处理方式是通过乐观锁+版本号。直接让后继的操作运营人员感知,之前的内容以被修改。
2.3 悲观锁
悲观锁实现分为单进程与多进程。单进程要么是在应用层加锁要么是在BD层面加锁。应用层加锁Java语言中可以通过synchronize关键字或是并发包下面的Lock子类实现。BD层面加锁可以采用select for update实现,但要避免锁表的情况。多进程场景的话要通过分布式锁来实现,主流的实现思路有Redis, Zookeeper。Redis实现上可以直接使用Redisson实现的RedLock。Zookeeper可以通过其watch机制实现分布式锁。
方案优点、缺点、合适业务场景:悲观锁应该是在没有其他合适方案时才会采取的兜底方案。悲观锁会降低系统整体的并发量。采用select for update 能保证BD层面数据的一致性,但存在锁表的风险。
2.4 去重表(唯一索引)
去重表的实现思路是请求自身带唯一的ID,或是根据请求参数与时间等特性生成唯一的ID,下游服务在处理请求时,先将唯一ID插入去重表中(唯一ID是去重表的唯一索引),成功则执行业务逻辑,DB层面抛出DuplicateKeyException时则直接返回失败或是检索历史处理结果返回。去重表(唯一索引)非常适合处理MQ消费消息场景时的幂等性问题。通常MQ都不会保证消息投递的唯一性,下游在消费消息时一定要根据业务场景的不同考虑是否要保证消费消息的幂等性。
3 业务场景分析
实际的业务场景中通常会结合多种方式来保证业务操作的幂等性。下面简要的介绍一下支付业务场景的幂等实现。
3.1 支付业务场景
上面是用户支付订单场景时业务系统、支付中心、支付通道之间整体交互的时序图(同业务应用APP与业务服系统交互部分统一划到业务系统/应用,同支付通道与支付通道APP交互部分统一划到支付通道)。 整个流程可分为三大步 一、用户发起订单支付进入收银台 ,二、用户选定支付产品,确认支付 ,三、应用通过SDK调用支付通道,展示支付结果。这三大步中业务系统与支付中心交互的接口共有三个分别是统一下单接口(createPayOrder 1.2步)、支付接口(pay 2.3步)、支付结果查询接口(queryPayResult 3.3 步)。在整体流程中业务系统与支付中心比较重要的数据模型有业务订单(业务系统)、支付订单(支付中心)、支付流水单(支付中心),比较重要的状态有业务订单支付状态、支付订单支付状态、支付流水支付状态。业务订单支付状态:待支付、支付中、支付成功、支付失败,支付订单支付状态:待支付、支付中、支付成功、支付失败,支付流水支付状态:待支付、支付中、支付成功、支付失败。
一、统一下单接口(createPayOrder)幂等
用户从业务APP订单页面,点击去支付会进入收银台页面。在实现每次进入收银台页面前或是重新切换到收银台页面,业务APP实际会先根据业务订单号调用支付结果查询接口,如果已支付成功则提示不能重复支付,后跳会之前的页面。 支付中心的统一下单接口(createPayOrder)会根据业务标识与业务订单号去创建支付订单并返回支付单号。实际上业务系统在获取收银支付产品列表时支付系统内会调统一下单接口(createPayOrder)。如果在这个过程中发面支付中心已存在对应订单号的支付订单且支付状态为支付成功,则不会返回支付产品列表,而且提示该订单已支付成功。统一下单接口(createPayOrder)通过支付订单表中业务标识与业务订单组成的唯一锁引防重与处理幂等。
二、支付接口(pay )幂等
用户进入收银台页面后,选中某一支付产品后,点击确认支付时。业务APP实际会通过业务系统调用支付中心的支付接口(pay)。支付中心内部会根据支付单号、支付产品编码去创建支付流水,并根据支付产吕编码调用支付通道统一下单接口返回预支付标识,支付中心再根据不同的支付通道组装并加密支付参数,然后返回给业务APP,APP根据返回的支付参数通过SDK调对应支付通道的支付方法。
上面整体流程上有几点要注意:
1、为了降低用户重复支付的概率,支付中心应该对支付订单进行锁单,业务系统第一次调pay支付接口后用支付中心会用支付单号对支付订单进行锁单,同时会设置一个较短的释放时间如2s,当用户频繁操作时如果支付订单处理锁单状态直接提示用户操作过于频繁请误重复支付。
2、在创建支付流水前要检查支付流水记录中否存在同一支付单号的其他支付中的支付流水,如果存在要支付中心应主动调用对应支付通道支付结果查询接口进行处理。如果支付通道侧返回支付成功,则提用户请误重复支付。
3、按上面的流程处理还是可以出现重复支付的情况,在支付单未被锁单或之前的锁单已释放后,可能出现用户多次支付的情况。当同一支付单号有多笔支付状态为支付成功的支付流水时,应做相应的原路退款处理。
4、在用户支付的过程中,如果业务订单刚好超时自动关闭,最终导致业务系统无法正常处理支付中心支付成功的消息,对这种特殊情况一定要做好处理。
三、支付结果查询接口(queryPayResult )幂等
支付结果查询接口,主要用于业务则APP在SDK调用支付通道支付接口后,并同步收到支付结果,后进行主动查询当前业务订单是否支付成,并展示对应的支付结果给用户。
总结
本文简要地总结了常见的处理幂等手段与其适用的场景,这些处理手段的本质共性可归纳为让请求串行或唯一化,某一时间只处一请求。悲欢锁实际是在让请求串行化,业务Token、乐观锁 + 状态机或版本号、去重表(唯一索引)实际是在让请求唯一化。