为什么需要订单重试
订单重试是指当用户提交的订单无法成功处理时,系统尝试重新处理该订单,以保证其最终能够被成功处理的操作。如今,大部分互联网应用都需要使用订单重试来保证订单流程的可靠性以及确保每一笔订单能够成功完成。
订单重试对于现代互联网应用系统来说至关重要。一旦订单丢失,客户可能无限期地失去服务,并造成可怕的负面影响,如恶评和负面评价,从而导致公司的形象和业务受到影响。订单重试能够确保顺利完成交易,保护公司的业务利益,并提高客户的满意度,增强用户对公司的信任感。一旦出现掉单,就得从日志或者数据库等数据源获取订单数据,进行重试调用发货。而本文旨在将这一过程做到自动化,尽量保障订单能够最终完成。
面向场景
订单重试并不局限于充值发货这个场景,还有其它涉及到权益发放的都适合,比如活动发放礼包、代金券,玩家充值增加VIP积分等。
怎么做
-
常见的重试方案会有基于数据库如mysql,还有消息队列如Kafka
-
数据库灵活性高,可以通过订单状态,找到要重试的订单来实现重试功能;但是缺点也比较明显涉及扫表,表里会包含要发货的记录和成功的记录,随着时间推移,数据越大,且扫表频率高,性能也会更差;
-
队列虽然灵活性不高,不像数据库可以进行筛选,但是性能好,队列的数据都是要处理的,只管发货就行;而要实现重试的功能,可以使用到延迟队列
那我们如何结合二者的优势,来实现一个可用性、容错性高的订单重试功能,我们以一个最初的订单发货功能进行改造
不管是什么平台,只要涉及到钱,都会有充值发货功能,比如游戏内道具购买、电商购物。而37手游作为一个发行平台,并不会产生实质的游戏内容比如钻石或元宝,平台主要是对接不同的游戏,如小小蚁国、斗罗大陆、云上城之歌等。当玩家充值后,调用游戏服务的发放接口,比如游戏钻石和元宝等。我们以安卓支付的流程为例子,输出一个简易模型来表达这一个流程
- 玩家下单,选择支付方式充值,等待游戏发货。而平台给玩家创建订单,等待支付渠道回调通知充值结果,然后给玩家发货。
- 游戏打开收银台
- 玩家选择支付方式如微信,进行充值后等待到账
- 支付渠道微信收到账后,进行回调给37手游平台
- 平台收到回调后执行发货
队列方案:
一般的,在发货环节我们会有不少检查,等一系列校验通过后才执行真正的发货。对于发货来说,并不需要做到非常实时,业务是可以接受准实时或者更晚,只要能保证最后有发货即可,所以一般发货会采取异步发货,这也能减轻web接口的压力。
那发货的最初始版本就出来了,我们只要将待发货的数据入到队列,由脚本消费处理调用发货服务即可。
理想情况,这完全可以稳定跑很长一段时间,但是现实往往会有很多意想不到的问题,比如某款游戏异常、发货服务异常无法正常响应、设备故障、宕机等等。
当发货服务异常情况下,我们需要对订单进行重试,如果幸运的话,服务没完全挂掉,马上又恢复了,我们失败后再调一次就成功了。但是呢,服务往往是一段时间内都是无法重试成功的,而且一直重试也会给服务带来压力,可能恢复一点点就被流量打挂了,需要给它时间恢复。所以当遇到失败后,我们不要马上重试,而是延迟重试,因为有时候有异常的可能有多款游戏,如果大家都是隔1分钟就重试,就会抢占资源甚至导致自身服务负载高。正确的做法是随着重试失败次数变多,延迟重试的时间也变长。比如前三次失败后,都是隔1分钟后再试,而第四次失败后,隔5分钟后再试。这样对于游戏服务和自身服务来说都可以避免持续的压力,也能保证消息不丢。
当前市面上主流的延迟队列是 rabbitmq,需要装插件才能使用,还有rocketmq。而我们公司使用的mq产品包括 rabbitmq、kafka、redis。我们只能从这三者选,redis不支持ack机制,会有丢消息的可能,所以放弃。虽然rabbitmq有支持,但是需要装插件升级版本,并且rabbitmq的消息是放在内存的,当流量大的情况下可能会触发限流,稳定性不如Kafka。那Kafka又没有提供延迟队列的功能,我们怎么办呢?我们选择了开源的延迟库,使用Kafka作为队列,由延迟库来控制消息什么时候消费,以及消息失败处理自动升级延迟处理,具体可参考github.com/wsqun/go-de…
流程:
-
支付回调消息入Kafka队列
-
实时发货脚本消费到消息后,调用发货服务
-
如果发货失败,则使用延迟处理库将消息投递到Topic1
-
延迟处理脚本从Topic1消费到消息后,隔1分钟后调用发货服务,失败则继续扭转直到重试次数全部用完
使用Kafka作为消息队列注意:
-
无法直接通过增加消费脚本来提高发货速度。因为Kafka是分区管理的,一个分区只能被一个消费者处理,比如发货的消息Topic只有一个分区,即使你开了两个进程消费,也只会有一个进程能够消费到数据,不像redis可以不断增加消费者来提高消费速度。所以如果想提供发货的速度,应当将Kafka对应的topic分区数增加,调整分区记得及时增加消费者,避免没消费新的分区; 如果不希望增加太多分区,也可以考虑进程内做并发,比如编程语言Go,拉到一个分区的消息后分发到N个协程,提前提交offset,拉取下一批数据,要注意监听退出信号保证进程的消息能够处理完,不然重启就会到导致没发货。所以可以看出我们是放弃了ACK,极端情况下有可能导致消息丢失。当然出现这情况也不是无计可施,我们可以通过位移重置来恢复,前提是要做好幂等,不能重复发货,也可以通过其它数据源恢复,下面方案介绍;
-
配置的延迟时间不宜过长,不要超过客户端设置的消息处理时间,如果超过可能会导致Kafka认为消费者有异常,进行重平衡而导致消息重复消费,当然对于重试来说,多重试一次问题不大。建议是要在逻辑里限制重试的次数,不然有可能会重试很多次,比如可以限制在某个时间点(一个小时后),就不再重试;也可以记录下重试次数,不过需要额外去记录,下面方案介绍。
队列+Mysql方案:
上面重试解决了外部服务异常带来的影响,那相对的,内部异常是否会影响当前架构的稳定性。答案是肯定的,当前架构很明显的一个问题就是强依赖Kafka,如果Kafka本身出现异常会直接影响后续的发货,相当于所有鸡蛋放在一个篮子里。
那解决方案也很容易想到,做数据冗余,与队列相辅相成。多一个mysql表记录队列的数据,同时也有状态流转(待发货、发货失败、发货成功)。发货的消息不仅会入队列,也会写mysql记录状态为待发货状态,脚本实时处理队列消息,将发货结果更新到mysql记录状态,
另外的脚本定时批量获取待发货、发货失败的记录进行补发处理,这样即使Kafka出现异常,也能通过扫表的方式补偿,有点类似于事务的最大努力通知机制。而且多了mysql记录我们可以更直观的看到消息发货的健康状态,能够知道哪一类的消息发货失败。
流程:
-
支付回调消息入mysql 和 Kafka队列,注意彼此独立,入mysql失败不影响入kafka队列,mysql写入的状态是待发货
-
获取待发货消息
-
队列的脚本流程与上面一致,区别是执行完发货后,会把发货结果记录到mysql,如果发货失败照样会入到延迟队列
-
mysql作为辅助,会检查记录的状态。会有两个定时脚本进行检查
-
找出待发货的记录,执行发货流程,将发货结果更新为成功或者失败状态,失败次数+1
-
找出失败记录,执行发货流程,将发货结果更新为成功或者失败状态,失败次数+1
-
-
下面为表记录的状态信息例子,
- 待发货+成功的订单截图:
- 失败后在进行重试的订单截图(记录里面还包含了失败的具体原因还有traceid,traceid可以方便我们快速定位当时整个发货周期内发生的相关日志,可以快速定位问题):
- 重试后成功的订单截图:
那么结合mysql后,当Kafka队列出现异常后,我们依然可以从mysql获取记录进行发货,避免因队列故障而导致掉单。
那么对于上面队列方案提到注意点1,当极端情况导致消息丢失,我们能够知道mysql里没发货的记录,捞出来执行发货保障最终不丢即可;而注意点2限制最大重试次数,就是通过记录的失败次数,因为每次失败都会+1,这样当发现达到某个阈值,那就不再入延迟队列。
使用细节:
关于检查未发货状态的定时脚本,并不是直接拉全部的记录,而是拉一段时间的,比如过去一天到过去1分钟的时间段,那为什么不是当前时间呢?因为会跟实时的发货脚本功能重合,如果拉当前时间的话,那么当一个消息同时入到队列和数据库,会被两边同时消费到处理,其实就浪费资源了,我们数据库是做为一个辅助的手段,不是来跟队列争夺的,不需要两个一样的角色;那我们控制了是过去1分钟时间段来拉消息了,当我们拉到消息后对其进行发货处理后,还不能直接就结束了。因为脚本能拉到消息,就说明你的发货已经是出现延迟了,这时候如果退出了,那下次检查就是1分钟后了,那又有一批玩家支付延迟到账了,这体验是很不好的。应该继续检查是否还有未发货的记录存在,这时候就不是拉过去1分钟前的了,而是自动调整为比如5秒前的。另外还要考虑这批消息要不要并发处理,对于我们平台来说,发货是依赖不同的游戏服务,如果是顺序执行的话,只要出现响应慢了就会导致延迟。所以我们是使用Go做并发处理,同个进程拉到一批消息后就并发处理,马上就去拉下一批消息,这时候就要注意不能拉到上一批消息,因为上一批消息还在处理中,所以我们在select时候,会把上一批消息的最大ID作为查询条件,要大于这个ID的消息。
关于检查发货失败状态的定时脚本,最重要的就是要设置重试最大次数和时间范围,不能一直重试下去。然后不是拉出所有符合条件的,而是限制行数,因为失败的记录可能很多,而且实时性要求不会太高,可以拉一批处理,再根据ID范围拉下一批。另外就是重试的最大次数是可以配的,比如作为脚本启动时候的参数,因为有时候服务会故障很长时间,就容易到达重试次数上限,当服务恢复后就会需要再重试一次,这时候只要改下最大重试次数就可以了。
前面我们也提到了mysql有个不好的地方就是扫表耗性能,那我们这个表作为一个消息事件记录,我们可以定期对表进行删除归档避免变成一个大表,比如只保留最近3个月的记录。对于表的结构也要做好设计,比如日期索引就是必须的,也可以考虑案日期作分区,甚至换一个性能更优的分布式数据库。另外还可以把查询变成有状态的,以检查未发货状态的定时脚本为例,在扫表前把最大的ID获取到缓存起来,下次启动就select条件增加id>?的条件,速度就可以提升很大;检查发货失败状态的脚本也是可以用一样的思路去提升性能。
前面讲的都是发货异常导致的发货减少,但也别忘了发货本身要做好幂等和并发锁,不能因为重试或者多个脚本在调用发货就出现了多发。
监控
那我们已经做了这么多了来保证发货不会掉单和延迟,那是不是就可以高枕无忧了呢?答案显然不是!总会出现一些问题不被上面方案覆盖到,那可能又是一个故障。那故障的等级来自于故障的范围和时间,那范围我们很难控制,因为故障就是发生了,我们很难阻止一个未知的故障发生,能知道也不会有故障一说了。我们能做的就是提前发现它,缩短故障的时间。
对于我们的订单重试功能,我们要能及时知道它当前的状态,不然你只能知道你的脚本在运行着,而不知道它跑的正不正常(不知道延迟正在发生、不知道补单虽然有在补但一直失败)。只有知道了这些,我们才能采取下一步的动作来避免故障扩大。如果我们能知道延迟了,我们就可以考虑增加进程数,提高并发能力;知道补单一直失败了,我们就能反馈给下游服务进行检查。
- 延迟消费监控
监控Kafka消费延迟数
监控脚本并行处理是否出现延迟(右侧每一行代表一个进程,监控值代表正在处理的数量)
- 发货失败,补单监控
- 异常日志监控
除了技术工具来及时发现还是有可能没覆盖到的,只能一直去完善。我们还要借助舆情的力量,要关注是否有玩家反馈异常,因为玩家是最直接感受到故障的,因此有收到反馈,要及时反应出来,可以建立一个专门反馈故障的群,客服有收到相关的消息就及时发布出来。
综上所述,37手游采取的方案是
Kafka消息队列 + Mysql消息状态 + (Grafana+Prometheus)/日志监控
一共有四个脚本:
- 实时处理业务产生的订单数据,成功/失败就更新Mysql消息状态,失败则入延迟队列
- 延迟处理失败的消息,进行重试
- 1分钟定时Select出Mysql消息为待发货的记录,如果无消息就退出,有则处理发货并且循环检查是否还有记录,直到无记录才退出,保障不会出现延迟到账
- 1分钟定时Select出Mysql消息为失败的记录,进行重试
脚本做到配置化/参数化,可灵活调整重试次数、并发限制、时间范围;
监控要全不能遗漏,内部(自己开发的脚本,其运行状态+消费速度;依赖的数据源Kafka延迟情况+Mysql表性能情况)+ 外部(舆情反馈)