通过幂等设计和原子锁避免重复退款


幂等性设计

我们回顾下上篇教程创建的 RefundAttendee 任务类,它会做两件事情 —— 退款和发送邮件:

如果这个队列任务执行失败,由于配置了 tries 值为 3,所以会重试,这里就存在一个隐患:如果退款成功,但邮件发送异常导致任务失败重试,就存在重复退款的问题。

我们在设计队列任务的执行时,由于存在重试机制,所以要考虑多次执行的幂等性,对于当前这个退款场景而言,就是多次执行 RefundAttendee,也不会出现重复退款。要实现这个幂等性,可以在 handle 方法中先判断该用户是否已经退款,只有未退款的用户才会发起退款操作:

双重检查

需要注意的是,调用第三方支付服务 API 进行退款会发起网络请求,如果退款成功,但是由于网络问题导致返回响应失败,那么永远也不会将订单标记为已退款。同样的问题也肯能会出现在退款后更新订单退款状态时。

这种情况下,只有第三方支付服务提供商才是唯一可信的数据源,只有他们知道是否完成了退款操作。因此,我们需要基于支付服务商提供的 API 来判断是否退款成功,而不是本地数据库字段:

这一点在开发与第三方服务 API 交互的功能时非常重要,也是最容易出问题,但又不方便排查的地方。

refund 方法一样,wasRefunded 方法也会通过 HTTP 请求获取服务商那里的退款状态:

三重检查与原子锁

虽然我们主观上不会将同一个 RefundAttendee 多次推送到队列,但是意外总是难免发生。

如果两个队列处理器进程同时获取到这两个执行同一笔退款的任务,可能会存在同时发送 HTTP 请求并进行退款的操作,进而出现并发安全问题:

要避免这个问题,可以使用原子锁来保证每笔退款操作串行执行:

在执行任何操作前,先基于缓存键 refund.{id} 获取一个原子锁,只有获取到这个锁,才能执行退款操作,而由于分配锁的操作是原子操作,其他并发执行的处理器进程执行到这里的时候由于锁已被其他进程获取就直接跳过了。

任务执行完成后,会自动释放这个锁。

这里我们使用了基于缓存的原子锁,这是 Laravel 底层提供的功能,除此之外,还有基于 Redis、数据库的实现版本,具体可以参考学院君之前发布的基于 Redis 实现分布式锁及其在 Laravel 底层的实现源码 这篇教程。

通过锁管理重试

如果队列处理器进程在获取到锁之后执行任务期间崩溃,而锁又还没有来得及释放,RefundAttendee 会被认为执行成功,即使还没有执行退款操作 —— 因为在后续重试的时候,这把锁还在。

要解决这个问题,需要设置原子锁的过期时间并确保下次重试的时候锁已经释放:

我们通过 Cache::lock 的第二个参数告知锁的过期时间是 10s。

由于处理器进程崩溃后,下次重试是在 90s 之后(参考上篇教程避免队列任务重复执行中的介绍),此时锁已过期,就会重新获取到一把新锁来执行这个任务。

配置任务延迟

如果是任务执行期间出现异常,锁会自动释放,但是如果释放锁的时候出问题咋整?

我们已经知道锁会在 10s 后过期,但是在此期间,重试任务时由于锁还在,就不会执行这个任务。要解决这个问题,可以在这个任务类中设置一个合理的延迟时间:

如果任务执行失败,会在 11s 之后执行,这样,就可以确保即便锁释放失败,也能正常执行这个任务了。

注意:如果队列任务执行失败,11s 后重试,如果是队列处理器进程崩溃,则是 90s 后重试,这两个重试时间是不一样的。


点赞 取消点赞 收藏 取消收藏

<< 上一篇: 取消会议和自动退款处理

>> 下一篇: 通过队列批处理退款订单