一、问题背景最简单的:DB 事务。如创建订单时,同时往订单表、订单商品表插数据,这些 Insert 须在同一事务执行。 Order 服务调用 Pay 服务,刚好网络超时,然后 Order 服务开始重试机制,于是 Pay 服务对同一支付请求,就接收到了两次,而且因为轮询负载均衡算法,落在了不同业务节点!所以一个分布式系统接口,须保证幂等性。 二、如何避免重复下单前端页面也可直接防止用户重复提交表单,但网络错误会导致重传,很多RPC框架、网关都有自动重试机制,所以重复请求在前端侧无法完全避免!问题最后还是如何保证服务接口的幂等性。 2.1 如何判断请求是重复的
所以保证幂等性要做到: 2.1.1 每个请求须有唯一标识比如订单支付请求,得包含订单 id,一个订单 id 最多只能成功支付一次。 2.1.2 每次处理完请求后,须有记录标识该请求已被处理在 MySQL 中记录一个状态字段。如支付之前记录一条这个订单的支付流水。 2.1.3 每次接收请求时,判断之前是否处理过若有一个订单已支付,就肯定已有一条支付流水。若重复发送这个请求,则此时先插入/支付流水,发现 orderId 已存在,唯一约束生效,报错重复 Key。就不会再重复扣款。 在往 DB 插记录时,一般不提供主键,而由 DB 在插入时自动生成。这样重复的请求就会导致插入重复的数据。MySQL 的主键自带唯一性约束,若在一条 INSERT 语句提供主键,且该主键值在表中已存在,则该条 INSERT 会执行失败。因此可利用 DB 的“主键唯一约束”,在插数据时带上主键,以此实现创建订单接口的幂等性。 给 Order 服务添加一个“orderId 生成”的接口,无参,返回值就是一个【全局唯一】订单号。在用户进入创建订单页面时,前端页面先调用该 orderId 生成接口得到一个订单号,在用户提交订单时,在创建订单的请求中携带该订单号。 该订单号其实就是订单表的主键,于是,重复请求中带的都是同一订单号。订单服务在订单表中插入数据的时候,执行的这些重复 INSERT 语句中的主键,也都是同一个订单号。而 DB 唯一约束保证,只有一次 INSERT 执行成功。 实际要结合业务,如使用 Redis,用 orderId 作为唯一K。只有成功插入这个支付流水,才可执行扣款。 要求是支付一个订单,须插入一条支付流水,order_id 建立一个唯一键。你在支付一个订单前,先插入一条支付流水,order_id 就已经传过去了。就能写一个标识到 Redis 中,set order_id payed,当重复请求过来时,先查 Redis 的 order_id 对应的 value,若为 payed 说明已支付,就别再重复支付! 然后再重复支付订单时,写尝试插入一条支付流水,DB 会报唯一键冲突,整个事务回滚。保存一个是否处理过的标识也可以,服务的不同实例可以一起操作 Redis。 若因重复订单导致插入 t_order 失败,则 Order 服务不要把该错误返给前端页面。否则,就可能出现用户点击创建订单按钮后,页面提示创建订单失败,而实际上订单创建成功了。 正确做法:这种 case,订单服务直接返回订单创建成功。 三、解决 ABA3.1 什么是 ABA如订单支付后,seller 要发货,发货完成后要填个快递单号。假设 seller 填个 666,刚填完,发现填错了,赶紧再修改成 888。对订单服务,这就是 2 个更新订单的请求。系统异常时 666 请求到了,单号更成 666,接着 888 请求到了,单号又更新成 888,但是 666 更新成功的响应丢了,调用方没收到成功响应,自动重试,再次发起 666 请求,单号又被更新成 666了,这数据显然就错了! 3.2 解决方案订单主表增加 version 列。每次查询订单时,版本号要随着订单数据返回给页面。页面在更新数据的请求中,把这个版本号作为更新请求的参数,带回给订单更新接口。 订单服务在更新数据的时候,需要比较订单的版本号是否和消息中的一致:
UPDATE orders set tracking_number = 666, version = version + 1 WHERE version = 8; 在这条 SQL 的 WHERE 条件中,version 值需要页面在更新的时候通过请求传进来。 通过该版本号,就能保证,从我打开这条订单记录开始,一直到我更新这条订单记录成功,期间没有其他人修改过该订单数据。若有,则 DB 中的 version 就会改变,那我的更新操作就会执行失败。我就只能重新查询新版本的订单数据,再尝试更新。 有了这个版本号,前文的 ABA 即有两个 case:
无论哪种情况,DB 中的数据与页面上给用户的反馈都是一致的。这就实现了幂等更新且避免 ABA。 防止订单重复支付订单支付流程我们来看看,电商订单支付的简要流程: 从下单/计算开始:
我们再从支付流水的角度看一下支付状态的变化:
为什么要花这么多篇幅来讲支付的业务流程、交互过程呢?因为我认为,防止订单的重复支付,不止是技术上的问题,也是业务和产品上的问题。 为什么订单会重复支付未防重导致的重复支付我们可以看到PC端支付,是扫描二维码,这些二维码,就是对应相应的支付流水,假如用户重复点击支付,如果不做防重的的话,会生成两笔支付流水,也就是两个不同的二维码,要是用户分别扫了两个不同的支付码,那么毫无疑问,就会产生重复支付。 掉单导致的重复支付“我明明付款了,为什么我的订单还没支付呢?” 这就是所谓的“掉单”:
用户一看,自己付了款,结果商城里订单还未付款,但是又特别想要,可能就会再下一单,这样就重复支付了。 多渠道导致的重复支付我们国内支付的体验还是非常快捷的,大家可能没有感觉,如果了解过海外支付的可能了解,很多支付的渠道,消耗的时间非常长。 比如用户保罗选择了一种支付方式Boleto,结果支付的网点离保罗他们村太远了,保罗又选择了Paypal支付,保罗去赶集的时候,又顺手去网点把Boleto的这一笔支付了,结果就重复支付了。 这种情况大家可能很少遇到,我们可以用美团下一个单,先打开微信支付,不要支付啊,接着回到美团,打开支付宝,用支付宝支付完成后,用微信接着支付,大家猜猜,两笔支付是不是都能成功?答案是可以。 如何防止订单重复支付加锁不管是3.申请支付、还是5.支付回调,都应该以订单维度加锁,防止并发下的重复操作。 加锁,毫无疑问,也是分布式锁,通常我们会选择Redis分布式锁。 缓存结果申请支付成功,支付回调成功,都应该缓存结果。 再申请支付,收到成功回调的时候,都应该先去检查支付的状态。 支付中流水取消假如说,用户重复支付了,再次申请支付的时候,如果已经申请支付成功了,那么这笔支付肯定是要拒绝的。 但是,要是已经存在的这笔流水还在支付中呢?——我们不确定它是成功还是失败,肯定是不能拒绝支付的,因为可能用户支付失败了,但是状态还没同步,这样肯定是不行的。 所以,我们可以取消掉正在支付中的流水,再进行支付。 已支付流水退款现在又有新的问题了,假如发起支付的时候,有流水正在支付中,如果第三方支付平台不支持取消支付,或者用户新的支付是通过不同的渠道,我们希望尽可能提高用户的支付成功率,怎么办呢? 我们可以在发起支付的时候,订单还在支付中的情况下,允许用户发起多笔支付,在支付回调的时候,检查用户是否已经有成功流水,对后来的流水进行退款处理。 当然,退款是个很危险的操作,毕竟钱退了,可就很难追回来,一定要做好风险的控制。 主动轮询&重试防止掉单主动轮询防止外部掉单如果因为故障没有收到回调,或者没有及时收到回调,就可能会发生所谓的外部掉单。 防止外部掉单的关键,就在于,不能傻傻地只等三方的回调通知,而要主动去查询,用户发起支付的3s之后,就可以发起轮询了,直到拿到支付流水的最终状态,主动轮询,一般可以这么实现:
同步+异步防止内部掉单支付服务在收到异步通知回调、或者主动轮询到流水的最终状态后,要通知订单服务支付流水的变化,订单服务同步更新订单的状态,这个过程要尽可能保证通知成功,可以采用同步+异步的方式。
这里还有一个问题,客户端如何同步这个状态?因为可能服务端更新了订单状态,但是客户端的界面上还是未支付,得用户主动刷新一下,才能拿到最新的状态,这样明显是不太合适的。 服务端、客户端的状态同步,无非就拉和推:
客户端支付尽可能不外跳不管从产品的角度,还是技术的角度,客户端发起支付这一步,其实应该尽可能地不要外跳,PC端使用支付服务生成的支付码,而不是跳转;移动端网页、APP在应用内展示支付页,当然这个是由第三方支付平台决定的。 不知道大家留意到了没有,现在的支付宝,已经做到了不用拉起钱包,在应用内就可以完成支付,这个对于商家的意义还是比较大的,对用户体验、支付成功率,都有正面的作用,相信以国内的内卷程度,其它支付供应商,一定会“跟进” 四、总结
两种幂等的实现方法,就可以保证,无论请求是不是重复,订单表中的数据都是正确的。 实现订单幂等的方法,完全可以套用在其他需要实现幂等的服务中,只需要这个服务操作的数据保存在数据库中,并且有一张带有主键的数据表即可。 |
|