2024年1月

FMEA:总监和架构师都在用的高可用架构分析方法

记得之前准备春晚项目的时候,团队成员在一起过架构,老板最常问的问题是“这个组件挂了怎么办?有什么影响?”,我当时还在心里默默嘀咕:这咋都这么容易挂呢?其他组件不做高可用的吗?最近看到
FMEA
,我恍然大悟:哦,这原来不就是 FMEA 吗?原来是我“
有眼无珠,识不得真神啊
”!

本篇来浅谈一下高可用架构、FMEA 以及使用 FMEA 进行架构分析的改进,适合有一定项目经验的工程师阅读。

一、高可用架构

1.1 什么是高可用

当我们谈到高可用时,都会说到可用性。那么,什么是可用性?我们知道,任何东西都有不可用的时候,比如,法拉利也会有抛锚的时候;身体特别健康的人,也难免会头疼感冒;即使是地球,也可能会有毁灭的一天;更何况是服务器/线上应用,硬件故障和软件故障都可能导致不可用。可见,
我们没办法做到东西的 100%可用性,只能做到高可用
(可能无限接近但始终无法到达 100%)。

1.2 高可用的度量

我们如何来量化服务的“高”可用呢?为了清晰描述高可用,于是就有了
SLA
(Service-Level Agreement,服务水平协议)的概念。SLA 是服务提供商与客户之间定义的正式承诺。服务提供商与受服务用户之间具体达成了承诺的服务指标——质量、可用性、责任。

那么,SLA 该如何计算呢?

  • 通俗的定义:SLA =可用时长/(可用时长+不可用时长)。

  • 不通俗的定义:SLA =f(MTBF,MTTR)。

  • MTBF(Mean Time Between Failures):平均故障间隔,通俗一点就是一个东西多长时间坏一次。

  • MTTR(Mean Time To Repair or Mean Time To Recovery):平均修复时间,意思是一旦东西坏了,需要多长时间去修复或者恢复它。

可见,提高 SLA 只有两个方法:一是提高系统的可用时长,二是降低系统的不可用时长。或者说,提高 MTBF,降低 MTTR。

1.3 高可用实现与架构之道

上述的提高高可用的方法只是理论层面的,在工程上其实也有相关的指导思想和架构方法。

当说起高可用实现的时候,浮现在脑海里的是大约在大学时期读过的一本自传。这本自传的书名记不清了,内容讲述的关于李开复的。李开复在做语音识别的时候,需要向别人展示研究成果,但是机器的识别概率只有 80%,达不到目标。那怎么办呢?
没有什么事情是一台机器不能解决的,如果有,那就再加一台
:一台机器识别的概率是 80%,那么不能识别概率是 20%,两台机器同时不能识别的概率就是 20% * 20% = 4%,那么识别的概率就是 96%。这种巧妙的方式让我最早感受到了概率和高可用的神奇。

毕业工作后,我接触到了业内五花八门的高可用方案,逐渐发现高可用实现方式其实是
万变不离其宗
,不管是计算高可用还是存储高可用;不管是异地多活、负载均衡、主备架构、主从架构等,
本质上都是通过“冗余”来实现高可用

用通俗点语言来讲,就是一台机器不够就两台,两台不够就四台;一个机房可能故障断网断电,那就部署两个机房;一条通道可能故障,那就用两条,两条不够那就用三条(移动、电信、联通一起上)。高可用的“冗余”解决方案,其实是
通过增加机器来“冗余”处理单元的手段,来达到服务高可用的目的

二、初识 FMEA

当系统实现了一套高可用架构上线之后,从此就可以“高枕无忧”了吗?其实不然,还需要
择时
进行架构分析与改进(毕竟
好的架构是演进出来的
),FMEA 开始走到舞台中央了。

2.1 什么是 FMEA

失效模式与影响分析
——FMEA(Failure Mode and Effects Analysis)也称为:潜在故障模式和影响分析,故障模式、影响和关键性分析 (FMECA),是由美国军方于 20 世纪 40 年代开始使用的一种过程分析工具,用于识别设计、制造或装配过程、产品或服务中所有可能的故障。

  • 失败模式
    :指的是某事物可能失败的方式或模式。故障是指任何可能发生的错误或缺陷,可以是潜在的,也可以是实际的。
  • 影响分析
    :指的是分析和研究发生失败(或者故障)的造成的影响和后果。

虽然 FMEA 并不是为软件而生,但是同样可以运用于软件领域。通俗的来讲,FMEA 就是一种分析方法,这种方法可以通过假设某组件故障,然后分析影响的途径,从而可以及早发现和识别系统问题、更好地规划后续工作、达到提高系统或者产品的可靠性的目的。

2.2 何时使用 FMEA

上面说的“择时”,究竟是什么时候呢?FMEA 可以在这些时候进行:

  • 当设计一个新系统或者重新设计系统架构的时候
  • 当现有系统或服务以新方式应用的时候
  • 当为现有系统或服务规划改进目标的时候
  • 在系统建设的整个生命周期中定期进行

三、FMEA 实战

如何做 FMEA?这个问题应该在软件领域应该还没有一个业界认可的标准的流程。我翻阅了资料,找到一些我认为比较靠谱的流程,在这里分享一下,大佬可以评论区交流。

3.1 传统制造流程如何做 FMEA

3.1.1 步骤总览

典型的 FMEA 其实是一个团队活动或者团队会议。在会议上,可以开展以下几个必要的步骤:

  • 定义失效模式——可能出现什么样的错误
  • 定义影响——谁(哪个模块,功能,函数)会遭受牵连
  • 描述目标——出现失效模式会发生什么样的事
  • 寻找根因——为什么会发生这样的事
  • 定义策略动作——如何避免
  • 定义当前预防&检测措施——我们已经(尚未)做的措施
  • 重复开展以上步骤,并输出到 FMEA 的表格中。

3.1.2 案例

下面是一个源自《Using FMEA to Improve Software Reliability》(链接放在文稿末尾,感兴趣可自行阅读)的例子,例子本身要做的事情是使用导热胶带将 PCB 板附着到金属散热片。对于这个生产流程来说我们应该不用过多了解,只需要通过这个例子熟悉下 FMEA 的步骤。

FMDA 表格如下:

翻译为中文是:

步骤编号 步骤名称 失效模式 影响 目标 根因 S O D RPN 应对动作 当前方案
1 用酒精清理金属片
2 把导热胶铺在边缘
3 清理底座
4 用力按压散热片,使之贴到导热胶带上
5 用酒精清洗导热槽
6 把 LED 灯带放置在散热片上,施重压在灯带外的区域
  1. 定义失效模式;

    可能的失效:金属片可能没有清理干净。

  2. 定义影响;

    直接结果:铺上的导热胶没有完全黏着,导致热胶路径开裂,从而导致 PCB 过热或直接整块脱落。

  3. 描述目标;

    影响: 导热胶没有黏着,导热路径脱离,PCB 过热
    谁会受到影响:过热通常需要期间承受一定的压力才会出现,生产测试中可能发现不了;很有可能将该缺陷带给客户。

  4. 寻找根因;

    根因: 存在酒精无法溶解的污染物。 根因: 操作错误

  5. 列出风险的优先级;

    就是把失效模式和风险的优先级联系起来。每个失效被赋值为 1-10 之间的三个指标。

  • 严重程度(S):1(无关紧要)到 10(灾难性)
  • 出现的可能性(O): 1 ( 不可能 ) 到 10(不可避免)
  • 可检测性(D):1(肯定能被检测) 到 10(不可检测)

这三个数乘起来就是 风险优先级参数(RPN)。RPN 越大,改善的必要性就越高。
S x O x D = RPN

影响: 导热胶带没有完全黏住,导热路径脱离,PCB 过热。

严重程度 6 缩短 PCB 组件寿命
可能性 2 经过恰当的训练,这不太可能发生
可检测性 3 在生产过程中很容易被检测

RPN: 6 x 2 x 3 = 36

  1. 定义解决方案;

    失效模式:金属薄片没有清除干净。
    解决动作:操作员及时赋能培训清理技巧。

  2. 描述当前预防和检测方法;

    失效模式:金属薄片没有清除干净。
    当前方法:在产线上岗之前对操作员进行培训、在生产中展示工作指南。

  3. 重复重复再重复;

    重复步骤 1-7,得到一个完整的表格。我们筛选出最有价值的措施来做系统设计和改造工作。

3.2 软件如何做 FMEA

3.2.1 简化的 FMEA

按照 FMEA 理论,FMEA 分析需要通过一次或多次会议完成,参与人应该包括:系统 Owner、项目 Leader、领域专家(架构师)参加、相关开发、测试等人员。当然这样效果肯定是最好的,实际情况可能无法在同一时间召集大家都来参加会议。特别是在“
降本增笑,开猿节流
”的大背景下,我们日常的工作肯定是更卷了,时间变得更宝贵了。我根据日常工作经验,结合 FMEA 核心思想和分析流程,总结出一个
简化版
的流程和表格,供大家参考。自己在架构 Review 或者设计过程中可以先问自己几个问题:

  1. 系统中的组件可能发生故障吗?
  2. 如果发生了故障,有什么影响?
  3. 故障发生的可能性有多大?影响是否严重?(可以使用 RPN 进行量化)
  4. 当前的解决方案或者预案是什么?是立即优化还是排期后续优化?

3.2.2 案例

下面是一个简单的例子来模拟一次 FMEA 分析。假设这是一个博客管理系统,具有最基础的登录注册功能、发布和查看博客等功能。其系统架构如图:

下表是我分析的样例,仅供参考。

序号 功能模块 失效模式 故障影响 根本原因 风险等级评估(可使用 RPN 方式进行评估) 当前已有解决方案或预案 短期方案 长期方案
1 用户登录 MySQL 数据库无法访问 用户无法登录,页面提示“系统异常” 数据库服务器宕机 完善监控,若发现宕机联系 DBA 进行处理;补充备库 故障时自动进行主备切换
2 用户登录 MySQL 数据库响应时间达到 3s 用户登录体验受影响 数据库慢查询 定期扫描慢查询,进行治理 分库分表等数据治理方案
3

四、总结

本文讲述了高可用架构和一个能够发现架构中隐藏高可用问题的方法:失效模式与影响分析(FMEA),通过硬件和软件领域的两个案例实战了一波 FMEA,给出我心中的简版的 FMEA,这其实就是我理解的 FMEA 的核心思想,希望对大家有帮助,之后工作和项目中也可以尝试使用一下。

参考文献

Using FMEA to Improve Software Reliability

一起学习

这里是
James Shangguan
,欢迎私信或者微信交流,在 2024 年里面,我们一起提高认知。也欢迎大家关注我的公众号或博客园,点赞、留言、转发。你的支持,将是我更文的最大动力!

开心一刻

昨晚,小妹跟我妈聊天

小妹:妈,跟你商量个事,我想换车,资助我点呀

妈:哎呀,你那分扣的攒一堆都够考清华的,还换车资助点,有车开就不错了

小妹:你要是这么逼我,别说哪天我去学人家傍大款啊

妈:哎呀妈,你脸上那褶子比你人生规划都清晰,咋地,大款缺地图呀,找你?

小妹:让我回到我18岁,大个、水灵、白,你再看看

妈:你18长的像黑鱼棒似的,还水灵白,消防栓水灵,也没见谁娶它呀,女人呐,你得有内涵

前情回顾


记一次线上问题 → 偶尔的热情真的难顶呀!

我们知道了女神偶尔的消息可能是借钱

那你到底是借还是不借?

不好意思,貌似抓错重点了

重点应该是:
把消息发送从事务中拎出来就好了,也就是等事务提交后,再发消息

什么,没看
记一次线上问题 → 偶尔的热情真的难顶呀!
,不知道重点,那还不赶紧去看?

我光提了重点,但是没给你们具体实现,就问你们气不气?

本着认真负责的态度,我还是提供几种实现,谁让我太宠你们了

事务拎出来

说起来很简单,做起来其实也很简单

犯病拎

为了更接近真实案例,我把

调整一下

User更新

插入操作日志
在一个事务中,
发消息
需要拎出去

拎出去还不简单,看我表演

相信大家都能看懂如上代码,上游调用
update
的地方也不用改,简直完美!

大家看仔细了,
update
上的
@Transactional(rollbackFor = Exception.
class
)

被拿掉了,不是漏写了!

如果
update
上继续保留
@Transactional(rollbackFor = Exception.
class
)

是什么情况?

那不是和没拎出来一样了吗?特么的还多写了几行代码!

回到刚拎出来的情况,
update

updateUser
在同一个类中,非事务方法
update
调用了事务方法
updateUser
,事务会怎么样?

如果你还没反应过来,八股文需要再背一背了:
在同一个类中,一个非事务方法调用另一个事务方法,事务不会生效

恭喜你,解决一个
bug
的同时,成功引入了另一个
bug

你懵的同时,你老大也懵

你们肯定会问:非事务方法
update
调用事务方法
updateUser
,事务为什么会失效了?

巧了,正好我有答案:
记一次线上问题 → 事务去哪了

别扭拎

同一个类中,非事务方法调用事务方法,事务不生效的解决方案中,是不是有这样一种解决方案:
自己注册自己

我们
debug
一下,看下堆栈情况

我们先看
update

调用链中没有事务相关内容

我们再看
updateUser

调用链中有事务相关内容

从结果来看,确实能够满足要求,上游调用
update
的地方也不用调整,并且还自给自足,感觉是个好方案呀


自己注册自己
这种情况,你们见得多吗,甚至见过吗

反正我看着好别扭,不知道你们有这种感觉没有?

要不将就着这么用?

常规拎

自己注册自己
是非常不推荐的!

为什么不推荐? 来来来,把脸伸过来

怎么这么多问题,非要把我榨干?

那我就说几点

1、违反了单一职责原则,一个类应该只负责一件事情,如果它开始依赖自己,那么它的职责就不够清晰,这可能会导致代码难以维护和扩展

2、循环依赖,自己依赖自己就是最简单版的循环依赖,虽说
Spring
能解决部分循环依赖,但
Spring
是不推荐循环依赖写法的

3、导致一些莫名其妙的问题,还非常难以排查,大家可以
Google
一下,关键字类似:
Spring 自己注入自己 有什么问题

推荐的做法是新建一个
UserManager
,类似如下

此时,上游调用的地方也需要调整,改调用
com.qsl.manager.UserManager#update
,如下所示:

同样
debug
下,来看看堆栈信息

com.qsl.manager.UserManager#update
调用栈情况如下

非常简单,没有任何的代理

我们再看下
com.qsl.service.impl.UserServiceImpl#updateUser

此时,调用链中是有事务相关内容的

是不是很完美的将消息发送从事务中抽出来了?

这确实也是我们最常用的方式,没有之一!

惊喜拎

既不想新增
UserManager
,又想把消息发送从事务中抽离出来,还要保证事务生效,并且不能用
自己注册自己
,有什么办法吗

好处全都要,坏处往外撂,求求你,做个人吧


但是,注意转折来了!

最近我还真学了一个新知识:
TransactionSynchronizationManager
,发现它完美契合上述的
既要、又要、还要、并且要

我们先回到最初的版本

接下来看我表演,稍微调整下代码

什么,调整了哪些,看的不够直观?

我真是服了你们这群老六,那我就再爱你们一次,让你们看的更直观,直接
beyond compare

就调整这么一点,上游调用
update
的地方也不用调整,你们的
既要、又要、还要、并且要
就满足了!

是不是很简单?

为了严谨,我们来验证一下

如何验证了?

最简单的办法就是在发送消息的地方打个断点,如下所示


debug
执行到此的时候,消息是未发送的,这个没问题吧?

那么我们只需要验证:此时事务是否已经提交

问题又来了,如何验证事务已经提交了呢?

很简单,我们直接去数据库查对应的记录,是不是修改之后的数据,如果是,那就说明事务已经提交,否则说明事务没提交,能理解吧?

我们以修改 张三 的密码为例,
bebug
未开始,此时 张三 的密码是
zhangsan1

我们把 张三 的密码改成
zhangsan2

开始
bebug

此时,消息还未发送,我们去数据库查下 张三 的密码

此时 张三 的密码已经是
zhangsan2
了,是修改之后的数据,说明了什么?

说明事务已经提交了,而此时消息还未发送!
是不是很优雅的实现了最初的重点:
把消息发送从事务中拎出来就好了,也就是等事务提交后,再发消息

TransactionSynchronizationManager

从字面意思来看,就是一个事务同步管理器

概况

TransactionSynchronizationManager

Spring
框架中提供的一个工具类,主要用于
管理事务的同步操作

通过
TransactionSynchronizationManager
,开发者可以自定义实现
TransactionSynchronization
接口或继承
TransactionSynchronizationAdapter

从而在事务的不同阶段(如提交前、提交后、回滚后等)执行特定的操作(如发送消息)

TransactionSynchronizationManager
提供了很多静态方法,
registerSynchronization
就是其中之一(其他的大家自行去学习)

入参类型是
TransactionSynchronization
,该接口定义了几个事务同步方法(命名很好,见名知意)

分别代表着在事务的不同阶段,会被执行的操作,比如
afterCommit
会在事务提交后执行

底层原理

为什么事务提交后一定会执行
org.springframework.transaction.support.TransactionSynchronization#afterCommit

幕后一定有操盘手,我们来揪一揪它

怎么揪?

正所谓:
源码之下无密码
,我们直捣黄龙干源码

问题又来了,
Spring
源码那么多,我们怎么知道哪一部分跟
TransactionSynchronization
有关?

很简单,去
bebug
的堆栈中找,很容易就能找到切入点

切入点是不是很明显了:
org.springframework.transaction.support.AbstractPlatformTransactionManager#commit


/*** This implementation of commit handles participating in existing
* transactions and programmatic rollback requests.
* Delegates to {
@codeisRollbackOnly}, {@codedoCommit}
* and {
@coderollback}.
*
@seeorg.springframework.transaction.TransactionStatus#isRollbackOnly()
*
@see#doCommit
*
@see#rollback*/@Overridepublic final void commit(TransactionStatus status) throwsTransactionException {if(status.isCompleted()) {throw newIllegalTransactionStateException("Transaction is already completed - do not call commit or rollback more than once per transaction");
}

DefaultTransactionStatus defStatus
=(DefaultTransactionStatus) status;if(defStatus.isLocalRollbackOnly()) {if(defStatus.isDebug()) {
logger.debug(
"Transactional code has requested rollback");
}
processRollback(defStatus,
false);return;
}
if (!shouldCommitOnGlobalRollbackOnly() &&defStatus.isGlobalRollbackOnly()) {if(defStatus.isDebug()) {
logger.debug(
"Global transaction is marked as rollback-only but transactional code requested commit");
}
processRollback(defStatus,
true);return;
}

processCommit(defStatus);
}

View Code

通过
commit
的源码,或者上图的调用链,我们会继续来到
org.springframework.transaction.support.AbstractPlatformTransactionManager#processCommit


/*** Process an actual commit.
* Rollback-only flags have already been checked and applied.
*
@paramstatus object representing the transaction
*
@throwsTransactionException in case of commit failure*/ private void processCommit(DefaultTransactionStatus status) throwsTransactionException {try{boolean beforeCompletionInvoked = false;try{boolean unexpectedRollback = false;
prepareForCommit(status);
triggerBeforeCommit(status);
triggerBeforeCompletion(status);
beforeCompletionInvoked
= true;if(status.hasSavepoint()) {if(status.isDebug()) {
logger.debug(
"Releasing transaction savepoint");
}
unexpectedRollback
=status.isGlobalRollbackOnly();
status.releaseHeldSavepoint();
}
else if(status.isNewTransaction()) {if(status.isDebug()) {
logger.debug(
"Initiating transaction commit");
}
unexpectedRollback
=status.isGlobalRollbackOnly();
doCommit(status);
}
else if(isFailEarlyOnGlobalRollbackOnly()) {
unexpectedRollback
=status.isGlobalRollbackOnly();
}
//Throw UnexpectedRollbackException if we have a global rollback-only//marker but still didn't get a corresponding exception from commit. if(unexpectedRollback) {throw newUnexpectedRollbackException("Transaction silently rolled back because it has been marked as rollback-only");
}
}
catch(UnexpectedRollbackException ex) {//can only be caused by doCommit triggerAfterCompletion(status, TransactionSynchronization.STATUS_ROLLED_BACK);throwex;
}
catch(TransactionException ex) {//can only be caused by doCommit if(isRollbackOnCommitFailure()) {
doRollbackOnCommitException(status, ex);
}
else{
triggerAfterCompletion(status, TransactionSynchronization.STATUS_UNKNOWN);
}
throwex;
}
catch (RuntimeException |Error ex) {if (!beforeCompletionInvoked) {
triggerBeforeCompletion(status);
}
doRollbackOnCommitException(status, ex);
throwex;
}
//Trigger afterCommit callbacks, with an exception thrown there//propagated to callers but the transaction still considered as committed. try{
triggerAfterCommit(status);
}
finally{
triggerAfterCompletion(status, TransactionSynchronization.STATUS_COMMITTED);
}

}
finally{
cleanupAfterCompletion(status);
}
}

View Code

大家仔细看这个方法,在
doCommit(status)
之前有
triggerBeforeCommit(status)

triggerBeforeCompletion(status)

doCommit(status)
之后有
triggerAfterCommit(status)

triggerAfterCompletion(status, TransactionSynchronization.STATUS_COMMITTED)

这几个方法的作用很明显了吧(
trigger
是触发的意思)

接下来我们跟哪个方法?

很明显,我们要跟
triggerAfterCommit(status)
,因为我们要找的是
afterCommit
的操盘手

内容很简单,下一步跟的对象也很明确

这里要分两步说明下

1、
TransactionSynchronizationManager.getSynchronizations()

先获取所有的事务同步器,然后进行排序

排序先撇开,我们先看看获取到了哪些事务同步器

第一个不眼熟,我们先不管

第二个眼不眼熟?是不是就是
com.qsl.service.impl.UserServiceImpl#update
中的匿名内部类?(如果想看的更明显,就不要用匿名内部类)

是不是就对应上了:先注册,再获取,最后被调用

被调用就是下面的第 2 步

2、
invokeAfterCommit

逻辑很简单,遍历所有事务同步器,逐个调用事务同步器的
afterCommit
方法

我们案例中的
发消息
就是在此处被执行了

至此,相信大家都没疑惑了吧

总结

1、关于
Spring
循环依赖,大家可以翻阅下我之前的博客

Spring 的循环依赖,源码详细分析 → 真的非要三级缓存吗

再探循环依赖 → Spring 是如何判定原型循环依赖和构造方法循环依赖的?

三探循环依赖 → 记一次线上偶现的循环依赖问题

四探循环依赖 → 当循环依赖遇上 BeanPostProcessor,爱情可能就产生了!

总之一句话:一定要杜绝循环依赖!

2、事务提交之后再执行某些操作的实现方式

事务失效的方式,大家一定要警惕,这坑很容易掉进去

自己注册自己的方式,直接杜绝,就当没有这种方式

Manager
方式很常规,可以使用

TransactionSynchronizationManager
方式很优雅,推荐使用

看了这篇博客后,该用哪种方式,大家心里有数了吧

3、TransactionSynchronizationManager 使用有限制条件

具体看其注释说明,就当给你们留的家庭作业了

一定要去看,不然使用出了问题可别怪我没提醒你们

问题背景

项目是 SpringBoot 单体式,在项目中,为了实现调用 controller 请求的日志记录功能。因此做了以下配置:

  1. 创建自定义拦截器 LogInterceptor;
  2. 因为需要使用到流获取请求参数,解决流只能读一次问题,所以需要自定义 HttpServletRequestWrapper;
  3. 需要使得自定义 HttpServletRequestWrapper 生效,因此还需要自定义 Filter (HttpServletRequestFilter);

目前在项目中可以正常使用。现在需要把项目的这些配置复制到另一个项目,其中Springboot版本一致,其他三方依赖什么都一样。但是新项目导入文件时,报参数缺失。

排查思路

  1. 查询新老项目配置的 filter是否一样?

    发现是一样的。并且没有配置多余的filter。

  2. 查询新老项目导入文件接口的 controller 请求入参方式是否一样?

    发现导入文件时,都是form 表单,content-type 都是 multipart/form-data。(导入文件仅支持form表单,因此第一次排查属于多余!)

  3. 怀疑拦截器 LogInterceptor 使用了 request,导致 request数据丢失。

​ 通过debug发现,其实走到 logInterceptor 时,request 数据已经丢失。

  1. 既然还没走到拦截器,那可能是自定义 HttpServletRequestWrapper 或者 自定义 HttpServletRequestFilter 问题,接着 debug,发现配置都没问题,可以正常执行。

  2. 依次排查了 filter 的加载顺序,发现也没啥用。

  3. 排除了 自定义 requestWrapper 和 filter,又发现可以正常获取数据了。然后试着将 requestWrapper 中构造方式的流转换为字节的操作放在每次获取 getInputStream() 方法中,发现也可以。因此怀疑是底层加载出现问题了。

  4. 试着对比新老项目所使用的 filter,我通过debug打断点的方式,可以在ApplicationFilterChain的internalDoFilter()方法上打断点。

发现两边不太一样。查询 hiddenHttpMethodFilter过滤器,发现是在 WebMvcAutoConfiguration 自动装配的,但是必须设置 spring.mvc.hiddenmethod.filter.enabled=ture。然后查询项目中 application.yml 配置,发现新老项目这块缺失不一样。然后需要新项目配置,发现问题解决。

根因分析

思考:

  • 为啥调整 requestWrapper 中流转换为字节操作的位置,从构造方式移到 getInputStream() 方法。就可以。

  • 为啥非要使用 hiddenHttpMethodFilter 过滤器,也就能解决问题

分析:

通过百度和查看 hiddenHttpMethodFilter 源码发现,org.apache.catalina.connector.Request.parseParameters() 方法中,判断事先调用过了getInputStream或者getReader,再调用getParameter就不会进行解析了。
也就是在一个请求链中,请求对象被前面对象方法中调用request.getInputStream()或request.getReader()获取过内容后,后面的对象方法里再调用这两个方法也无法获取到客户端请求的内容,但是调用request.getParameter()方法获取过内容后,后面的对象方法里依然可以调用它获取到参数的内容。
这也就验证了以上两个问题

  • 问题1,移动位置后,在 自定义 filter中, 将 httpServletRequest 对象转换成对象时,不执行 getInputStream() 方法,也就解决了问题。
  • 问题2,使用 hiddenHttpMethodFilter 过滤器,里边执行了一行代码 String paramValue = request.getParameter(this.methodParam);,这行代码保证了parseParameters()方法优先执行,也能解决问题。

根因:

Tomcat的ServletRequest中, getParameter()方法与getInputStream()/getReader()不兼容, 只能选择一方.调用了一方, 另一方就会是空的(前提:表单的POST请求)。

解决方案

原理已经清楚了,按时会产生出各种不同的问题,因此解决方案需要根据问题场景来说。常见的有:

  1. 调整自定义 RequestWrapper 中流转换为字节操作的位置,使其放在 getInputStream() 方法中,这样就不影响底层框架使用。

  2. 在 自定义 filter 中,继承 OncePerRequestFilter, 并重写 shouldNotFilter(HttpServletRequest request) 方法,然后只对 Json 请求内容做流只能读取一次的处理(按需引入 Wrapper)。

扩展知识

application/x- www-form-urlencoded 是 Post 请求默认的请求体内容类型,该请求方式是通过调用 request.getParameter() 方法来获取请求参数值。

当请求体内容(注意:get请求没有请求体)类型是 application/x- www-form-urlencoded 时也可以直接调用request.getInputStream()或request.getReader()方法获取到请求内容再解析出具体都参数,但前提是还没调用request.getParameter()方法。此时当request.getInputStream()或request.getReader()获取到请求内容后,无法再调request.getParameter()获取请求内容。即对该类型的请求,三个方法互斥,只能调其中一个。

application/json 是 Post 请求 body 体传参方式,该请求方式是通过调用 request.getInputStream()或request.getReader() 方法来获取请求内容值。

multipart/form-data 是Post 请求文件上传的传参方式,这种比较特殊,经测试,可以使用 request.getParameter() 获取到除文件外的其他参数,获取文件参数,需要使用 request.getInputStream() 。

参考链接:

记一次getParameter()获取不到参数问题的排查

自定义httpservletrequestwrapper导致form表单提交数据丢失_添加requestwapper后无法获取提交参数

苹果内购

前言

接触公司的充值业务很久了,在处理苹果充值的时候也踩了很多的坑,这里就花时间来总结下。

苹果内购

IAP 全称:
In-App Purchase
,是指苹果
App Store
的应用内购买,是苹果为 App 内购买虚拟商品或服务提供的一套交易系统。

为什么这里着重来介绍 IAP 呢,因为 IAP 和微信支付和支付宝支付的实现逻辑不太一样,因为 IAP 支付依赖 IOS 客户端调用异步支付回调接口,进行用户和订单的绑定,会有丢单的情况产生,这里
假定大家对苹果支付有一定了解了,重点主要是来聊下苹果支付的技术实现方案。

聊下,目前 IAP 中充值我们遇到的问题点

1、丢单;

2、订单充值到错误的账号;

3、在苹果设置中直接点击充值,导致不到账。

苹果支付的难点

关于苹果充值的难点,这边总结下来就是下面两点:

1、如何处理订单回执和用户账号的绑定关系;

2、APP 中复杂的网络环境,如何保证订单能够成功回调;

方案设计

下面来介绍下苹果支付中需要特别关注的点。

1、商品设计

苹果对应的充值商品 id 需要进行申请,命名规则没有强制性的要求,需要根据自己的商品类型来进行映射。通常的做法,服务端会定义自己系统的内部的商品,服务端会提供一个提供一个商品列表的接口,会把自己系统内部的商品信息,映射到苹果系统中的商品id。

如何合理的设计苹果 iap 的商品ID呢?一般有两种做法?

1、每个系统内部的商品都对应一个苹果 iap 的商品id;

这种就是需要申请的商品id比较多,同时如果商品信息发生更改,就需要重新申请,当然,如果自己系统内部的商品,比较单一,并且基本上能够保持不变,那这种方式可以商品设计的选择之一。

2、每种类型的商品,一个价格对应一个苹果 iap 的商品id;

苹果的 iap 的商品id和价格做绑定了,如果商品信息有变化,只要价格不变,就不用重新申请对应的 iap 商品id 了。

具体使用哪种可以根据自己系统中的商品特点来进行选择。

2、用户和回执的绑定

苹果支付中,苹果回调中的是没有第三方订单信息的,所以,苹果订单回执信息和用户的绑定需要在 APP 中完成。

苹果充值成功,给到 IOS 客户端充值的回调信息,然后客户端需要把当前的回执信息和当前的用户做好绑定,上报数据到自己的服务端,进行订单校验和充值道具的下发。

处理充值的时候,每一笔订单都会生成唯一的订单号。用户和回执信息的绑定,也就是,订单号和用户回执信息的绑定,因为订单对应数据,里面会关联用户信息。

这样用户和回执的绑定也就回归到用户订单和苹果回执的绑定,那么订单号在何时生成,如何和苹果回执做绑定呢?有两种方案,下面来一一探讨下。

1、生成订单 -> 发起 iap 充值 -> 客户端 绑定订单id和 iap 充值的回执信息,向服务端发起回调 -> 服务端接收到回调信息,校验订单回执,给用户增加道具;

2、发起 iap 充值 -> 接收到回调信息 -> 客户端将商品信息,用户信息,iap 的回执信息,一起回调给服务端 -> 服务端接收到回调信息,校验回执信息,生成对应的订单,同时给用户增加道具;

这两种最主要的差别就是订单的生成时机不同,第一中订单号对客户端是可见的,第二种订单号对客户端是不可见的。

1、订单提前生成

iap

订单提前生成,比较符合我们处理的订单的逻辑,如果自己的充值系统中也同时集成了微信和支付宝的支付,那么选用这种方式,订单的处理就相对统一。

缺点:

1、每次点击充值就会有一个订单的生成,即使用户后面没有实际的充值,会有无效订单数据的产生;

2、因为订单号和苹果回执信息的绑定是需要在客户端中进行绑定操作的,那么这个就会存在绑定错乱的情况,用户可能点击充值了多个商品,这时候处理绑定的时候, A 商品的订单号,绑定到了 B 商品的回执信息上了。结果就是用户订单异常。

2、回调的时候生成订单号

iap

订单是后面生成的,所以可以控制只有回执信息验证通过的时候才生成订单号,避免了无用订单的生成。

不过这两种处理方式个人感觉差别不大,设计的时候考虑下如果有其它的支付方式同时存在,如何做好兼容就行了。

我们用的第一种,提前生成订单,因为我们系统中同时存在了,微信,支付宝和苹果充值,所以各个充值方式的兼容也是我们考虑的一个重要的点。

不过也根据第一种方式做了细微的调整,因为苹果充值存在充值成功但是回调是失败的情况,接收到失败的订单回调,本地记录的订单号就会被清理了,这时候有成功的回调过来,本地就没有对应的订单号了,同时对于订阅商品,下一个扣款日重新发我续订,app 接收到回调通知,本地也是没有存储对应的订单号。没有订单号,也就意味着苹果的充值回执信息,和用户关联不上了。这种情况,我们的处理方式就是,app 接收到成功的回调,本地没有订单号,就重新调用服务端接口重新生成一个。保证用户和苹果的回执信息一定能关联上。

iap

3、回调的重试

因为苹果订单的绑定依赖于客户端,当网络环境不好,接收到回调信息,之后向自己的服务端进行回执信息的验证,出现接口请求不通的情况,这时候就需要执行回调回执的重试。

当然重试分成两部分

1、客户端回调回执信息的重试;

2、服务端验证回执信息的重试。

一般服务端在设计充值的时候,都会使用到分布式事务,消息队列等来保证事务的最终一致性。同时对于一些第三方的请求,也会有对应的重试机制:

1、数据标记法:接收到请求,先落数据库,如果验证成功将数据标记成功,如果一直没有验证处理成功,就定时从数据库将此数据取出来,继续重试验证的逻辑;

2、消息队列的重试机制:一般消息队列都有对应的重试机制,消息验证成功,就将消息从队列中移除,否则重新丢到队列中,借助队列的重试机制,知道本消息处理成功。

客户端部分的重试步骤:

1、接收到苹果的回调,拼接订单和用户信息,向自己的服务端发起回调;

2、如果自己服务端的回调接口,返回成功,在一定时间内定时查询该订单的状态,如果订单返回成功,更新用户的账户信息;

3、如果回调自己的服务端失败,这时候就需要进行重试操作,1分钟内重试5次,直到该回调接口返回成功的状态;

4、如果1分钟内重试了5次还是失败,记录该回执信息,用户打开app,或者切换到充值页面的时候继续重试。

因为对于回调的处理,服务端只要接收到请求,就会记录该回调信息,然后接口返回成功,所以只要客户端的网络正常,这种失败的情况是不会出现的,多次的重试操作一定能规避这种情况。

充值冲遇到的问题点

1、丢单

丢单是苹果充值经常遇到的问题,因为是 app 接收到的苹果的服务回调,相比于服务端的
sever to server
的通知,受限于客户端的当时的网络环境,app 的打开状况,稳定性是偏差的。

如何处理呢?

原则上就是客户端接收到苹果的回调通知,尽可能的拼接用户信息和回执信息发送给自己的服务单进行数据的验证,服务在验证数据的时候做好订单的唯一性处理,避免商品超发的情况。

总结了可能有下面几种情况:

1、接收到异常的回调:充值成功了,但是客户端先接收到的是一个充值失败的回调,然后草草结束掉本次订单,导致后面收到了充值成功的订单,但是订单已经结束就不处理了,最终结果就是丢单了;

2、网络不稳定:充值成功了,客户端成功接收到了苹果的回调,但是给自己服务端回调的时候,因为网络原因导致回调失败了,结果就是用户丢单了;

3、用户频繁切换账户:充值成功了,用户在充值过程中发生了账号的切换,因为使用的苹果账号是同一个,但是登陆 app 的账号可以是多个,切换账号的过程中,充值到其中一个账号中了,但是给到用户的体验就是当前账号没到账,就认为是丢单了;

4、自己服务端校验票据异常:充值成功了,客户端接收到了回调,但是回调回执信息给自己服务端的时候,服务端在验证票据的时候出现了异常,导致该订单验证失败,用户的体验就是该订单丢单了;

下面来分析下上面的几种情况:

对于情况场景 1 和场景 2,客户端尽可能的做好重试,只要接收到苹果 iap 中的回调,就拼接信息回掉给自己的服务端,如果当前的回调接口没有返回成功的标识,就要继续重试。

对于场景 3 ,可以在交互上优化,用户充值之后返回 app ,可以加一个充值中的 loading 页面,避免用户在这个过程中出现切换账号的操作。

对于场景 3 ,一般服务端在设计充值这种业务的时候都会用到分布式事务,所以这种情况是能够避免的。

2、充值成功,下发的物品不对

因为充值商品,订单号和 ipa 充值回执信息的绑定是在 app 中操作的。如果用户在充值的时候有频繁点击充值的行为,那么在绑定充值回执的数据的时候,就有可能出现绑定错乱的情况。

原来商品的 a 的回执信息,绑定时候,被绑定到了 商品 b 上面。

这时候服务端就需要做好数据的检验,如果通过回执信息请求苹果的订单接口是能拿到,充值订单对应的 iap 商品,通过这个商品就能判断回到数据绑定的数据是否正确,如果不正确修改当前订单信息的数据即可。

3、处理退款

根据苹果的策略,用户在购买IAP后90天内,能以各种原因申请退款(扣款后购买失败、买错了、不喜欢等等)。

用户成功申请退款了,系统中对应的道具也要清除掉,不然就是充值漏洞了,里面的商品就会被用户白嫖了。

苹果在
WWDC 2020
苹果全球开发者大会,苹果宣布所有的内购项类型,当用户在应用内退款成功时,
App Store Server
会发送实时的通知给开发者服务器告知有退款,开发者可通过处理该消息来更新用户的账户信息。

退款流程:

1、用户购买内购商品;

2、用户申请退款;

3、苹果发起退款;

4、Apple Store Server 发送退款通知;

5、用户收到退款成功的通知;

6、开发者收到退款订单通知。

最后来看下普通充值的订单的具体信息

{
	"environment": "Production", // 当前的环境,Production表示生产环境,Sandbox表示的是沙盒环境
	"receipt": {
		"receipt_type": "Production",
		"adam_id": 6666666,
		"app_item_id": 8888888,
		"bundle_id": "test.888888",
		"application_version": "4.79.0.1",
		"download_id": 999999999,
		"version_external_identifier": 862386348,
		"receipt_creation_date": "2024-01-07 04:33:30 Etc/GMT",
		"receipt_creation_date_ms": "1704602010000",
		"receipt_creation_date_pst": "2024-01-06 20:33:30 America/Los_Angeles",
		"request_date": "2024-01-10 01:39:43 Etc/GMT",
		"request_date_ms": "1704850783803",
		"request_date_pst": "2024-01-09 17:39:43 America/Los_Angeles",
		"original_purchase_date": "2023-12-30 23:42:26 Etc/GMT",
		"original_purchase_date_ms": "1703979746000",
		"original_purchase_date_pst": "2023-12-30 15:42:26 America/Los_Angeles",
		"original_application_version": "4.79.0.1",
		"in_app": [{
				"quantity": "1", // 商品的数量
				"product_id": "6666661101_2_2_12.00", // iap 的商品id
				"transaction_id": "381201227775036", // 交易号
				"original_transaction_id": "381201227775036", // 原始交易号
				"purchase_date": "2024-01-07 04:33:29 Etc/GMT", // 最新的购买时间
				"purchase_date_ms": "1704602009000", // 最新的购买时间毫秒
				"purchase_date_pst": "2024-01-06 20:33:29 America/Los_Angeles", // 最新的购买时间,太平洋时间
				"original_purchase_date": "2024-01-07 04:33:29 Etc/GMT", // 最初的购买时间
				"original_purchase_date_ms": "1704602009000", // 最初的购买时间,毫秒
				"original_purchase_date_pst": "2024-01-06 20:33:29 America/Los_Angeles", // 最初的购买时间太平洋时间
				"is_trial_period": "false", // 是否是试用期
				"in_app_ownership_type": "PURCHASED"
			}
		]
	},
	"latest_receipt": "xxxxx", // 凭证信息
	"status": 0 //
}

苹果订阅

上面简单聊了下苹果中普通商品的充值流程,下面来聊一下订阅商品的充值。

自动订阅根据名字能看出来相对于普通的商品,自动订阅的商品到了扣款周期,苹果会自动发起重新扣款。

先来看下订阅的充值流程,订阅的首次充值流程和普通的商品的首次充值流程一样,充值成功之后,后面会涉及订阅的下次扣费,所以后面多了原始交易号和回执信息的记录,方便后面自动扣款的续订操作。

iap

苹果订阅商品在下个扣款周期扣费的时候,扣款的动作由苹果自动发起,这和支付宝和微信的订阅扣款逻辑不同。

因为是苹果自动进行的扣款处理,所以会如果苹果扣款的时候的通知不及时或者消息丢失,就很容易造成用户的丢单情况。

处理思路:

1、配置服务端回调通知

配置服务端回调通知,配置服务端通知的动作思可选的,不过建议开启。开启之后就能及时收到苹果的订单的状态,处理用户的订单状态。

App Store Server Notifications V1

服务端的通知类型,有下面几种:

CANCEL:表示苹果支持已经取消了自动续期订阅并且用户在cancellation_date_ms时间收到了退款信息;

触发时机:

CANCEL事件通过AppleCare支持取消订阅并退还购买款项时触发。

DID_CHANGE_RENEWAL_PREF:表示客户对其订阅计划进行了更改,该更改会在下一次续订时生效。当前活动的计划不受影响;

触发时机:

当用户在同一订阅分组中,从一个订阅商品切换到另一个订阅商品时,会触发 DID_CHANGE_RENEWAL_PREF 事件;

DID_CHANGE_RENEWAL_STATUS:表示订阅续订状态发生变化;

触发时机:

用户关闭了订阅,或者非订阅状态重新续订。

通过判断 auto_renew_status 判断当前的订阅状态,auto_renew_status == 0 表示订阅状态已经关闭, auto_renew_status == 1 表示订阅处于开启状态。

DID_FAIL_TO_RENEW:表示由于计费问题而无法续订的订阅,栗如,用户当前卡上没钱了;

DID_RECOVER:表示成功自动续订一个过去续订失败的过期订阅。检查expires_date以确定下一次续费的日期和时间;

DID_RENEW:表示用户当前的订阅周期已经重新续订了;

INITIAL_BUY:用户在首次发生订阅时触发;

INTERACTIVE_RENEWAL:表示客户通过使用应用程序界面或在 App Store 帐户的订阅设置中以交互方式续订订阅;

触发时机:

用户取消了订阅,一段时间后用户通过 AppStore 交互页面重新订阅产品,会触发 INTERACTIVE_RENEWAL 事件。

REFUND:表示 App Store 已成功对消耗性应用内购买、非消耗性应用内购买或非续订订阅的交易进行退款,不同于取消(CANCEL)通知类型,取消通知类型针对的是自动续期订阅类型商品,用户通过 AppleCare 支持取消订阅并退还购买款项时触发;

苹果服务端的返回状态 DID_RENEW 就表示苹果当前的订阅的扣款已经成功了,接收到这个状态的通知,处理用户的订单即可。

2、客户端通知;

苹果每次订阅的扣款也会下发通知到客户端,客户端接收到的扣款成功的通知,回调该信息到自己的服务端,服务端接收到该回调通知,判断当前订阅的道具有没有下发,没有下发,修改本次订阅的状态,下发对应的充值道具给到当前的用户,并记录本次订阅已经完成。

3、服务端定时轮询;

服务端定时轮询快到期的订阅,向苹果发起请求查询当前订阅的状态,判断订阅当前的扣款状态,如果扣款了,就修改订阅扣款到下个周期,下发充值道具给到用户,否则就继续轮询,直到用户取消订阅,或者用户订阅扣款成功。

StoreKit 1 对比 2

StoreKit 1
存在的问题:

1、苹果后台不能查看到退款的订单详情。只能苹果处理退款后发通知给我们的服务器,告知发生了一笔退款;

2、消耗性、非消耗性、非续期订阅、自动续订能不能在沙盒环境测试退款,系统没提供这种测试方式;

3、不能够将用户反馈的苹果付费收据里的 orderID 与具体的业务订单进行关联;

4、研发过程中,无法直接关联苹果交易号 transactionId 与 业务订单号 orderID 之间联系,、在开发过程中,无法直接关联 transaction 与 orderID 之间联系,虽然有一个 applicationUserName 字段,可以存储一个信息。但是这个字段是不是 100%靠谱,在某些情况下会丢失存储的数据;

StoreKit 2

2021 年 WWDC,在 iOS 15 系统上推出了一个新的
StoreKit 2
库,该库采用了完全新的 API 来解决应用内购买问题。
StoreKit 2
主要的更新有这几个:

StoreKit 2 库采用 Swift 5.5 版本最新特性重写,只支持 Swift、iOS 15+,提供了一些新的 API 接口,导致新的支付流程会发生一些变化。

1、提供了获取交易历史记录、可购买的商品列表(自动续期订阅以及非消耗品)信息;

2、提供了获取订阅状态、管理订阅状态接口;

3、支持在 App 内发起退款。

新的 api

1、新的商品接口

新增了一些商品类型,订阅信息,这些字段信息在 StoreKit 1 里是没有的。

方便利用的字段:

1、通过新增的 product type 我们可以轻易的知道当前的商品是消耗品还是订阅商品;

2、针对于自动连续订阅的第一次购买优惠,我们可以直接感知到当前的商品是不是用户的 Apple ID 下的第一次购买;

2、新的购买接口

提供了新的购买商品接口。其中购买商品时增加了一些可选参数 PurchaseOption 结构体,该结构体里有新增的特别重要的字段 appAccountToken, 类似 SKPayment.applicationUsername 字段,但是 appAccountToken 信息会永久保存在 Transaction 信息内。

appAccountToken 字段是由开发者创建的;关联到 App 里的用户账号;使用 UUID 格式;永久存储在 Transaction 信息里。这里的存储的信息,不会像 v1 版本,存在数据丢失的情况。

这里的 appAccountToken 字段苹果的意思是用来存储用户账号信息的,但是应该也可以用来存储 orderID 相关的信息,需要将 orderID 转成 UUID 格式塞到 Transaction 信息内,方便处理补单、退款等操作。

处理验证 Transaction。系统会验证是否是一个合法的 Transaction,此时系统不再提供 base64 的 receip string 信息,只需要上传 transaction.id 和 transaction.originalID,服务器端根据需要选择合适的 ID 进行验证。

3、交易历史查询接口

提供了三个新的交易(Transcation)相关的 API:

1、All transactions:全部的购买交易订单,在 transaction 里面获取;

2、Latest transactions:最新的购买交易订单;

3、Current entitlements:所有当前订阅的交易,以及所有购买(且未退还)的非消耗品。

4、订阅类型项目的状态

订阅类型项目的状态,比如主动获取最新的交易、获取更新订阅的状态,获取更新订阅的信息等。其中获取更新订阅的信息,可以获取更新的状态、品项 id、如果过期的话,可以知道过期的原因。(比如用户取消、扣费失败、订阅正常过期等。)获取的所有数据都是 JWS 格式验证。

5、管理订阅页面

可以直接唤起 App Store 里的管理订阅页面。

6、退款api

提供了新的发起退款 API,允许用户在开发者的 App 中直接进行退款申请。用户进行申请退款后,App 可以收到通知、另外苹果服务器也会通知开发者服务器。

StoreKit 2
支持 iOS 15 以上的系统,所有如果用户有很多这个版本之下的用户,就需要考虑如何合理的接入新的版本了。

对于后端来说,
Apple Server API V1

Apple Server API V2
都能够运用,与客户端是否升级到
StoreKit 2
无关。

总结

上面主要总结了苹果支付的主要逻辑。

1、苹果支付对比微信和支付宝的最大的不同就是,IAP 支付依赖 IOS 客户端调用异步支付回调接口,进行用户和订单的绑定;

2、苹果支付最大的难点就是用户和回执的绑定;

  • 苹果充值成功,给到 IOS 客户端充值的回调信息,然后客户端需要把当前的回执信息和当前的用户做好绑定,上报数据到自己的服务端,进行订单校验和充值道具的下发。

  • 处理充值的时候,每一笔订单都会生成唯一的订单号。用户和回执信息的绑定,也就是,订单号和用户回执信息的绑定,因为订单对应数据,里面会关联用户信息。

3、重试,因为依赖于 app 的回调,所有当网络环境不好,接收到回调信息,之后向自己的服务端进行回执信息的验证,出现接口请求不通的情况,这时候就需要执行回调回执的重试;

  • 客户端部分的重试步骤:

  • 1、接收到苹果的回调,拼接订单和用户信息,向自己的服务端发起回调;

  • 2、如果自己服务端的回调接口,返回成功,在一定时间内定时查询该订单的状态,如果订单返回成功,更新用户的账户信息;

  • 3、如果回调自己的服务端失败,这时候就需要进行重试操作,1分钟内重试5次,直到该回调接口返回成功的状态;

  • 4、如果1分钟内重试了5次还是失败,记录该回执信息,用户打开app,或者切换到充值页面的时候继续重试。

  • 因为对于回调的处理,服务端只要接收到请求,就会记录该回调信息,然后接口返回成功,所以只要客户端的网络正常,这种失败的情况是不会出现的,多次的重试操作一定能规避这种情况。

4、退款的处理,根据苹果的策略,用户在购买IAP后90天内,能以各种原因申请退款(扣款后购买失败、买错了、不喜欢等等);

  • 用户成功申请退款了,系统中对应的道具也要清除掉,不然就是充值漏洞了,里面的商品就会被用户白嫖了。

  • 苹果在 WWDC 2020 苹果全球开发者大会,苹果宣布所有的内购项类型,当用户在应用内退款成功时,App Store Server 会发送实时的通知给开发者服务器告知有退款,开发者可通过处理该消息来更新用户的账户信息。

5、苹果订阅:因为订阅是苹果直接发起的,所以我们要合理的处理订阅扣款之后的回调;

  • 1、配置服务端回调通知:配置服务端通知的动作思可选的,不过建议开启。开启之后就能及时收到苹果的订单的状态,处理用户的订单状态;

  • 回调中的 CANCEL:表示苹果支持已经取消了自动续期订阅并且用户在cancellation_date_ms时间收到了退款信息;

  • 2、客户端通知;

  • 苹果每次订阅的扣款也会下发通知到客户端,客户端接收到的扣款成功的通知,回调该信息到自己的服务端,服务端接收到该回调通知,判断当前订阅的道具有没有下发,没有下发,修改本次订阅的状态,下发对应的充值道具给到当前的用户,并记录本次订阅已经完成。

  • 3、服务端定时轮询;

  • 服务端定时轮询快到期的订阅,向苹果发起请求查询当前订阅的状态,判断订阅当前的扣款状态,如果扣款了,就修改订阅扣款到下个周期,下发充值道具给到用户,否则就继续轮询,直到用户取消订阅,或者用户订阅扣款成功。

参考

【AppStore内购】
https://liushoukai.github.io/2020/04/04/appstore-in-app-purchase/
【官方文档】
https://developer.apple.com/cn/in-app-purchase/
【iOS StoreKit 2 新特性解析】
https://juejin.cn/post/7096063372159877150
【苹果支付】
https://boilingfrog.github.io/2024/01/28/苹果iap支付/

使用
pandas
进行数据分析时,第一步就是读取文件。
在平时学习和练习的过程中,用到的数据量不会太大,所以读取文件的步骤往往会被我们忽视。

然而,在实际场景中,面对十万,百万级别的数据量是家常便饭,即使千万,上亿级别的数据,单机处理也问题不大。
不过,当数据量和数据属性多了之后,读取文件的性能瓶颈就开始浮现出来。

当我们第一次拿到数据时,经常会反反复复的读取文件,尝试各种分析数据的方法。
如果每次读取文件都要等一段时间,不仅会影响工作效率,还影响心情。

下面记录了我自己优化
pandas
读取大文件效率的探索过程。

1. 准备部分

首先,准备数据。
下面的测试用的数据是一些虚拟币的交易数据,除了常用的K线数据之外,还包含很多分析因子的值。

import pandas as pd

fp = "all_coin_factor_data_12H.csv"
df = pd.read_csv(fp, encoding="gbk")
df.shape

# 运行结果
(398070, 224)

总数据量接近
40万
,每条数据有
224
个属性。

然后,封装一个简单的
装饰器
来计时函数运行时间。

from time import time

def timeit(func):
    def func_wrapper(*args, **kwargs):
        start = time()
        ret = func(*args, **kwargs)
        end = time()
        spend = end - start
        print("{} cost time: {:.3f} s".format(func.__name__, spend))
        return ret

    return func_wrapper

2. 正常读取

先看看读取这样规模的数据,需要多少时间。
下面的示例中,循环读取
10次
上面准备的数据
all_coin_factor_data_12H.csv

import pandas as pd

@timeit
def read(fp):
    df = pd.read_csv(
        fp,
        encoding="gbk",
        parse_dates=["time"],
    )
    return df

if __name__ == "__main__":
    fp = "./all_coin_factor_data_12H.csv"
    for i in range(10):
        read(fp)

运行结果如下:
image.png
读取一次大概
27秒
左右。

3. 压缩读取

读取的文件
all_coin_factor_data_12H.csv
大概
1.5GB
左右,
pandas
是可以直接读取压缩文件的,尝试压缩之后读取性能是否能够提高。
压缩之后,大约
615MB
左右,压缩前大小的一半不到点。

import pandas as pd

@timeit
def read_zip(fp):
    df = pd.read_csv(
        fp,
        encoding="gbk",
        parse_dates=["time"],
        compression="zip",
    )
    return df

if __name__ == "__main__":
    fp = "./all_coin_factor_data_12H.zip"
    for i in range(10):
        read_zip(fp)

运行结果如下:
image.png
读取一次大概
34秒
左右,还不如直接读取来得快。

4. 分批读取

接下来试试分批读取能不能提高速度,分批读取的方式是针对数据量特别大的情况,
单机处理过亿数据量的时候,经常会用到这个方法,防止内存溢出。

先试试每次读取
1万条

import pandas as pd

@timeit
def read_chunk(fp, chunksize=1000):
    df = pd.DataFrame()
    reader = pd.read_csv(
        fp,
        encoding="gbk",
        parse_dates=["time"],
        chunksize=chunksize,
    )
    for chunk in reader:
        df = pd.concat([df, chunk])

    df = df.reset_index()
    return df

if __name__ == "__main__":
    fp = "./all_coin_factor_data_12H.csv"
    for i in range(10):
        read_chunk(fp, 10000)

运行结果如下:
image.png
和读取压缩文件的性能差不多。

如果调整成每次读取
10万条
,性能会有一些微提高。
image.png

分批读取时,一次读取的越多(只要内存够用),速度越快。
其实我也试了一次读取
1千条
的性能,非常慢,这里就不截图了。

5. 使用polars读取

前面尝试的方法,效果都不太好,下面引入一个和
pandas
兼容的库
Polars

Polars
是一个高性能的
DataFrame
库,它主要用于操作结构化数据。
它是用
Rust
写的,主打就是
高性能

使用
Polars
读取文件之后返回的
Dataframe
虽然和
pandas

DataFrame
不完全一样,
当可以通过一个简单的
to_pandas
方法来完成转换。

下面看看使用
Polars
读取文件的性能:

import polars as pl

@timeit
def read_pl(fp):
    df = pl.read_csv(
        fp,
        encoding="gbk",
        try_parse_dates=True,
    )
    return df.to_pandas()

if __name__ == "__main__":
    fp = "./all_coin_factor_data_12H.csv"
    for i in range(10):
        read_pl(fp)

运行结果如下:
image.png
使用
Polars
后性能提高非常明显,看来,混合使用
Polars

pandas
是一个不错的方案。

6. 序列化后读取

最后这个方法,其实不是直接读取原始数据,而是将原始数据转换为
python
自己的
序列化格式

pickle
)之后,再去读取。

这个方法多了一个转换的步骤:

fp = "./all_coin_factor_data_12H.csv"
df = read(fp)
df.to_pickle("./all_coin_factor_data_12H.pkl")

生成一个 序列化文件:
all_coin_factor_data_12H.pkl

然后,测试下读取这个序列化文件的性能。

@timeit
def read_pkl(fp):
    df = pd.read_pickle(fp)
    return df

if __name__ == "__main__":
    fp = "./all_coin_factor_data_12H.pkl"
    for i in range(10):
        read_pkl(fp)

运行结果如下:
image.png
这个性能出乎意料之外的好,而且
csv文件
序列化成
pkl文件
之后,占用磁盘的大小也只有原来的一半。
csv
文件
1.5GB
左右,
pkl
文件只有
690MB

这个方案虽然性能惊人,但也有一些局限,
首先是原始文件不能是那种实时变化的数据,因为原始
csv文件
转换为
pkl文件
也是要花时间的(上面的测试没有算这个时间)。

其次,序列化之后的
pkl文件

python
专用的,不像
csv文件
那样通用,不利于其他
非python
的系统使用。

7. 总结

本文探讨了一些
pandas
读取大文件的优化方案,最后比较好的就是
Polars方案

pickle序列化
方案。

如果我们的项目是分析固定的数据,比如历史的交易数据,历史天气数据,历史销售数据等等,
那么,就可以考虑
pickle序列化
方案,先花时间讲原始数据序列化,
后续的分析中不担心读取文件浪费时间,可以更高效的尝试各种分析思路。

除此之外的情况,建议使用
Polars方案

最后补充一点,如果读取文件的性能对你影响不大,那就用原来的方式,千万不要画蛇添足的去优化,
把精力花在数据分析的业务上。