根治顽疾:Keep客户端 In-App Purchase 掉单踩坑指南
简介
In-App Purchase(以下简称IAP)是苹果为开发者提供的应用内购服务。Keep于17年初接入In-App Purchase,功能上线后暴漏出严重的丢单问题,丢单概率大概在百分之一。丢单问题在多人多次优化后仍未能解决,成为Keep客户端的顽疾。直至最近的两次优化彻底根治了丢单问题。本文中笔者将循着Keep客户端解决IAP掉单问题的两次优化之旅跟大家分享排查问题的思路以及最终的方案。
历史问题
由于IAP本身设计问题及开发者不恰当使用API导致IAP掉单是一个较为普遍的内购问题。同时,网上存在各种没有数据支撑的所谓的”解决方案”及各种”一站式解决掉单”的标题党,会对开发者产生一定的误导。盲目的引入这些方案在没有解决问题的同时平白增加了代码的复杂度。甚至有一些开发者表示IAP漏单是无法避免的,只能通过客服的介入来进行补单。
在之前的几次排查过程中,由于网上信息的误导,盲目接入了几种网上流传的解决方案。
- 本地化存储 :在收到IAP支付成功回调后,将业务订单号、receipt信息持久化,在app启动时遍历本地存储列表触发补单逻辑。
- 网络异常重试:app校验receipt信息http请求失败时,会触发重试逻辑,连续重试10次。
盲目主要指的是,方案本身没有可靠的数据支撑,RD对于接入方案的效果没有预期,缺乏适当的技术埋点追踪接入后的效果。
事实上,这两种方案并不能解决掉单问题,且都存在很大的问题:
- 本地化存储:完全是无效的冗余逻辑,平白增加代码复杂度。
- 网络异常重试:由于缺乏恰当的实现,并不能对补单提供有力的保障。
我们将通过与第一次优化方案的对比来详细阐述这两种方案所存在的问题。
第一次优化
在翻阅IAP相关文档及明确了Keep客户端中存在的历史问题后,明确了从两个角度进行优化:
- 程序健壮性:提升程序健壮性避免由于网络、crash等导致的掉单。
- 补单实时性:在异常发生时,保障大部分用户实时快速完成补单。
同时,增加IAP支付流程各个阶段的埋点。为上线后的问题排查及优化效果统计打下基础。
程序健壮性
我们将IAP流程简化为如下:
从流程上来看,由于客户端导致的掉单有两种可能:
- 步骤一:用户支付成功,Apple回调客户端通信失败。
- 步骤二:Apple回调客户端后,客户端与server通信失败。
所以提升程序健壮性要从这两种掉单case入手。
事务机制
事实上,对于以上两种掉单case,Apple已经为我们提供了合理的解决方案。
IAP中每一次支付行为都被抽象成一个事务(SKPaymentTransaction),只有事务被正常完结(调用finishTransaction:)本次支付行为才算完成。在每一次app启动时,通过调用addTransactionObserver:就会触发之前所有未完结的事务。详见:支付队列观察者。所以,由于事务机制的存在,我们只需做到以下两点就可以避免掉单:
- 对于每一个支付事务,在确保服务端处理完后再结束(finishTransaction:)该事务。
- App启动时,注册支付队列观察者(addTransactionObserver:)并添加相应补单逻辑。
依赖于Apple提供的事务机制显然比本地化存储方案更加可靠,主要体现在以下几点:
- 本地化存储只能解决”客户端与server通信失败”的掉单场景。
- 本地化存储的数据会随着用户设备更换、app删除重装而丢失而Apple的事务机制不会。
可以看到,本地化存储解决的掉单场景是Apple所提供支付队列观察者能解决场景的子集。
所以第一个优化点在于:依靠Apple的事务机制,同时删除冗余的本地化存储方案。
结果
我们追踪了最近一个月内所有用户支付成功的订单中,通过Apple提供的事务机制恢复的订单。得到如下结论:通过事务机制恢复的订单占总支付成功订单的4.78‰。即,通过第一次优化我们将掉单率降低了4.78‰。
补单实时性
事务机制有一个明显的弊端:补单逻辑只有在app重启时才能触发。 重启app对于用户来说是一个很重的操作。我们希望添加某种机制更实时的为用户进行补单,于是我们引入了”网络异常重试逻辑”以提升补单效率,做为事务机制的一个补充。
网络异常重试
网络异常重试,主要是为了避免在付款成功后,用户网络状况发生变化(如,乘坐地铁进入隧道)导致与server通信失败的及时重试逻辑。
该方案本身没有太大问题,在较低的接入成本与影响面下可以很大程度的提升补单的实时性,是app启动时事务机制补单逻辑的一个很好的补充。不过,需要恰当的实现才能达到最优的效果。
之前Keep的实现方案是:初始化一个计数器,在网络请求失败的回调内累加计数器并触发重试逻辑,直至重试10次后放弃重试。
在实际测试过程中在断网的状况下,发出去的网络请求会立刻拿到失败回调,10次重试请求会在1s内发完。所以该方案能达到的效果被大打折扣。
解决方案当然是拉长重试间隔,另外,由于用户网络恢复的可能是随着时间逐渐递减的,为了避免频繁的重试我们不妨依次延长每一次重试的间隔。Keep目前的方案是以斐波那契数列来做为每一次重试的间隔,即10次重试的间隔分别是:
- 1,1,2,3,5,8,13,21,34,55
结果
下表是包含首次校验receipt失败(checkOrder_Failed)在内的,触发网络异常重试逻辑的埋点:
PS:以下数据没有考虑在重试过程中app异常关闭或用户手动关闭app
事件 | 次数 |
---|---|
checkOrder_Failed | 332 |
checkOrder_Failed_Retry_1 | 166 |
checkOrder_Failed_Retry_2 | 127 |
checkOrder_Failed_Retry_3 | 122 |
checkOrder_Failed_Retry_4 | 103 |
checkOrder_Failed_Retry_5 | 85 |
checkOrder_Failed_Retry_6 | 69 |
checkOrder_Failed_Retry_7 | 52 |
checkOrder_Failed_Retry_8 | 34 |
checkOrder_Failed_Retry_9 | 23 |
checkOrder_Failed_Retry_10 | 15 |
在所有首次校验(checkOrder_Failed)失败的332笔订单下,经过10次重试(checkOrder_Failed_Retry_10)后只有15笔订单需要用户重启app来触发补单逻辑。 这样在大概两分半的时间内,通过10次重试,我们追回了95.5%的失败用户为补单的实时性提供了有力的保障。
本地化存储 & 补单实时性?
通过本地化存储我们可以在更多的时机来触发补单逻辑以提升补单实时性。如:网络切换、app前后台切换。这里需要权衡的点是:
- 本地化存储、网络切换,app前后台切换逻辑会影响到百分之百的用户(包括非付费用户),同时会有一定的开发维护成本。
- 在加入恰当的网络异常重试逻辑后,网络切换、app前后台切换的补单逻辑能帮助到的用户只有IAP付费用户的万分之几。
当我们把影响范围的基数放在所有日活用户上后,这种方案的收益比可能只有几十/几百万分之一。所以,Keep目前并没有接入这种方案,补单逻辑只是依赖Apple在app启动时的事务机制。
第二次优化
第一次优化上线不久:客服再次反馈IAP支付掉单问题。而且由于业务膨胀式的发展,虽然优化掉了约千分之五的掉单case每天掉单的数量反而在上升。
信息收集
收拾了一下心情,继续整理了接下来的工作思路:
- 通过埋点及用户的反馈信息分析用户掉单原因。
- 收集信息撰写TSI联系Apple寻求技术支持。
- 与同行进行沟通,如何解决掉单问题。
得到了如下的信息反馈:
- 对于反馈用户的订单号,通过埋点查看走到了支付失败的回调中。
- TSI得到的反馈是:在收到用户支付成功请求后一定会对客户端下发支付成功的回调,且在我们没有调用(finishTransaction:)结束该事务的情况下,会持续在每一次app启动时调用支付成功回调。
- 在与国内两个直播平台的员工进行沟通后:得到的反馈是,的确有丢单的状况。多次排查无果后,主要措施是由人工客服介入补单来处理。
不难看出,对于1、2两条信息是矛盾的。不过,在后续的排查过程中还是选择了相信用户的诚信以及Apple的技术能力。站在另一个角度去审视自己的代码。
抽丝剥茧
在选择相信用户和苹果的基础上,以Apple的事务机制来套有两点是可以肯定的:
- 在用户支付成功后,Apple回调了支付成功的逻辑。
- 在处理该笔订单的过程中,客户端一定调用了(finishTransaction:)结束该事务。
所以客户端的排查点就在于:在除了服务端处理完后,还有哪些地方调用了finishTransaction:?
终于,在转换思路后一个隐藏的bug浮出水面————在收到Apple支付成功回调后,客户端会首先校验业务OrderNo的合法性,如果orderNo为空,会直接调用(finishTransaction:)结束该事务,从而导致掉单!
业务OrderNo
Keep的业务实现逻辑是,在用户发起购买时会生成对应的OrderNo,OrderNo将在整个购买流程中进行透传,直至用户支付成功后的receipt校验。且整个支付流程中在与Apple交互的过程中,通过Apple”提供”(注意这里的引号)的SKPayment的applicationUsername来传递。
Iap支付的订单流转逻辑:
所以问题的症结在于,我们使用applicationUsername透传OrderNo合理么?
那么Apple对于applicationUsername定义是什么?
Use this property to help the store detect irregular activity. For example, in a game, it would be unusual for dozens of different iTunes Store accounts to make purchases on behalf of the same in-game character.
The recommended implementation is to use a one-way hash of the user’s account name to calculate the value for this property.
Apple提供applicationUsername,是为了防止用户作弊而不是用于透传业务信息的。所以,归根结底产生bug的原因还是我们开发者滥用API(目前网上依然有很多IAP相关的讨论、博客都是使用applicationUsername来透传业务信息)。
复现及分析
在上述猜想的基础上,在线上环境下测试了一些边界情况成功复现了掉单case(必须为线上正式包,沙盒环境无法复现,testflight无法复现)。
复现步骤:
- itunes store 登入的appleId未绑定支付方式
- 发起支付
- 绑定支付方式并杀死keep app
- 在appStore完成完成支付
- 重启app
分析:
在这种case下,在应用内我们收到的回调状态是这样的:
- Purchasing (Keep app 发起,携带OrderNo)
- Failed(Keep app 发起,携带OrderNo)
- 用户绑定支付方式
- Purchasing(AppStore 发起,不携带OrderNo)
- Purchased(AppStore 发起,不携带OrderNo)
用户分别在Keep、AppStore各发起了一次支付。
- Keep内发起的支付:创建了OrderNo,也完成了对于applicationUsername的赋值,但是由于用户没有绑定支付方式该笔订单以失败结束,所以我们会收到相应失败的回调。
- AppStore内发起的支付:用户支付成功了,但是并没有创建OrderNo,也没有完成对于applicationUsername的赋值,所以在Apple回调支付成功后,没有解析到OrderNo。调用(finishTransaction:)结束该事务后产生掉单。
订单创建后置
Keep目前采用的解决方案是,对于未生成OrderNo的订单,采用订单创建后置来处理:
KeepClient
- –客户端去掉OrderNo校验逻辑
KeepServer
- –校验接口的OrderNo改为非必传参数
- –支付网关层校验成功后, 发送mq消息给业务方
- –业务方收到消息后进行模拟提单
- –交易中心完结订单
- –交易中心回调业务方接口
- –业务方发放权益
KeepServer订单流转如下图(TradeCenter=交易中心 PayGateway=支付网关):
结果
同样我们以记一个月所有用户支付成功的订单为样本。通过订单创建后置恢复的订单占总支付成功订单的5.25‰。即,通过此次优化IAP掉单率降低了5.25‰,完美解决了客户端的掉单问题。