从幂等引发的一个思考

记录前段时间做的一个业务中的思考,对技术与业务结合分析做到取舍并最终的需求。

0. 背景

广告服务现在需要支付服务提供一个实时扣费的接口,用于支撑 CPC 的新业务,即属于 CPC(Cost per Click) 的范畴,整个业务流转链路如下:商家用一定的金额买下某个广告位,搜索服务在拉出商品时根据权重排序,其中有专门的广告位用于展示此类商品。抽象到技术,即支持近实时的扣费,用户每有效点击一次该商品,就扣除商家的账户金额,因为要支持近实时,上层服务不希望使用 MQ 来与支付系统交互,而是想直接使用 RPC 接口调用,也就是广告点击 TPS 和支付服务的 TPS 是一致的(风险)。

关于这个近实时直接扣费的点,属于业务上的考虑,虽然也跟产品讨论这个“近实时”的点,但是说初期需要近实时,后期可以接收延迟,并且对大小商家需要有不同的延迟处理,以做到性能和业务上的平衡。

1. 业务分析

业务分析后总结如下:

  1. TPS 广告服务与支付共享,这是不合理的,但是已经和产品以及上下游讨论了这个点,重点在于:可以接受非实时,但是不能都是非实时,即部分用户实时,部分用户非实时。
  2. 业务上支持对于大广告主可以使用保证金这种兜底方案,这种大客户可以接受非实时,也不怕被薅羊毛,因为会扣除保证金。而对于小客户我们初期给他们实时扣费处理,因为这类的用户 TPS 不会很高,因此可以接受这类方案。
  3. 此类扣费需要支持幂等,保证每次点击就算下游服务出现短暂的超时(例如滚动发布时),也需要能通过重试策略来达到数据一致,而不用人工修复数据或强行引入一致性的第三方服务(成本过大)。

2. 实时与非实时

既然业务可接受部分用户实时,部分用户非实时,在于如何区分大客户/小客户,其实从性能上考虑,大客户是很容易造成热点问题,尤其是在广告的热点时间。最开始计划是做纯动态,非人工干扰,即根据对请求的 userId 做收集,同时后台增加一个定时任务按天或按小时做一个统计(这里可以直接用现有大数据的同步库拉数据),来动态计算出大客户/小客户,从而做出不同的业务策略。后来由于时间问题被业务方 pass 了。接着选择了最快且能满足业务方的一个方案,通过业务方后台动态配置 userId,在处理时判断是否属于该配置的 userId 组,来执行大客户或小客户的业务逻辑,这个方案运营人员也能接受,因为大客户也就那么多。这里选用了选择了已有的技术栈 Apollo 动态配置。

大客户处理

大客户是最核心的点,可能 TPS 百分之八十都是来自于大客户,甚至在后期占比会越来越高,而且也会涉及到经典的热点数据问题(MySQL 的行锁竞争),因此是主要优化点。

保证金

对于大客户,业务方接受使用了保证金兜底,就有个问题,保证金设置多少合适?

这里我们可以根据系统可接受的最低阈值来计算:线上的接口 TPS 阈值,乘以当前广告位的值再乘以 timeout 来获得,例如线上 TPS 是 10,当前广告位是 1 元/点击,timeout 为 200ms,那么在极端情况下,当用户金额已为 0 时,此时每秒扣客户的保证金为 10*1/0.2=50 元/秒,同时上游服务会对此类异常响应(余额不足)做一系列的广告位下架处理,假如他们能在 1s 内就能完成,那么保证金最低设置为 50 就可以满足该场景了。

异步优化

我们已经知道了对于大客户来说,他们是有保证金兜底的,因此我们可以使用异步做扣款,达到近实时。异步我使用了 MQ,至于为什么选用 MQ 不选用进程内子线程就不必多说了,可以参见 MQ 的优点。使用了 MQ 处理扣款我们不用担心余额不足,因为如果走到了 MQ 逻辑,那么它肯定是大客户,肯定是有保证金兜底的。同时为了削峰,我们可以使用了 MQ 的延迟消息,至于这里的延迟消息是否开启延迟多久,也可以通过动态配置或直接在 Apollo 上配置,在高峰时 ,手动设置开启延迟时间,设置延迟时间。但是设置了延迟时间,同时也要同步到设置保证金的场景,因为每延迟 1s,保证金就要增加 50 元。消息重复问题使用幂等接口处理。而消息的丢失就使用可靠消息入日志处理。

小客户处理

小客户就不用多说了,小客户的 TPS 与上游服务的 TPS 对齐,不会很高,直接进行数据库的扣除操作。当 TPS 过高时,会晋升为大客户处理逻辑。

3. 幂等支持

系统框架本身没有支持幂等,一是量没到,二是觉得幂等处理会比较复杂,但是这个接口明确是需要做幂等的(大部分开发时间都在做幂等的支持),当前这个业务是一个典型的复杂转账业务,在成功是返回一个值,在失败是返回另一个值,虽然这个返回值在数据库中可以直接查询得到,但是该表的分库分表键设置的不合理,不建议使用查询返回,对 RT 有影响。例如,虽然转账可能是一个非常简单插入更新操作,但是需要返回是否当前余额不够,我们是直接做更新插入(update xx where column > xx 方式)而不是先查。因此最简单支持幂等的方法就是根据请求的唯一参数,来存储第一次的返回值,我们不用考虑返回值从哪来。而我们业务已经约定好的有一个唯一的请求参数,这个唯一的请求参数(主键 ID 或 UUID 或雪花算法)用来判断是否是相同的重复请求。

对第一次的请求参数 + 第一次的请求响应做(key,value)即可,以后的 N+1 次请求直接 get(key)返回。因此重点在于判断出是否第一次请求。这里我们可以使用数据库的 insert 或 redis 的 setnx 方式。

弊端

多了一层存储(key,value)的开销,key 是在本业务中上游服务是支持的,而 value 是下游服务响应的结果,如果 value 响应体过大则存储量会变大的开销。且每次独立的请求都会有此额外开销,因此需要定时/定期的删除,防止膨胀,删除时间需要业务和性能考虑。

因为存储介质大概率会考虑 Redis/MySQL,这两种在量大的时候都会对性能有一定的影响,而每次每个接口不同的请求都要存储。因此我们需要提现在线上查看该接口可能的峰值,以及业务上可能正常重复请求的情况,这里说的正常重复请求的情况是指, RT 过高或网络抖动导致的 RPC 重复请求。

异常重复请求是指这种情况,例如该接口正常 100ms 处理完成,一般来说我们 timeout 会设置 3 倍(一般性能敏感业务的 timeout 会设置为 99% 的 RT 时间),即 timeout=300ms,但是如果上游出现异常,导致在 100ms 内,接口不断的请求,那么我们只会对第一次请求返回成功,在这 100ms 内的 n+1 次请求都会返回请求频率过快的报错。

其实说到这,幂等框架已经初步形成了。在实际开发中,我们直接使用注解来避免对现有业务做侵入,使用切面来做一系列的判断逻辑。具体代码可以参考:https://github.com/liuyukuai/dis.git。不过这个项目是在框架做完后发现的,但是核心思想相同,细节做了不同的处理,例如对请求参数的键设置,我使用了设置字段反射获取值,而不是再使用另一个字段注解,优点在于接口上就能直接看到唯一请求参数是什么,而不用进入到具体请求参数体中,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class scratch {
// 接口一眼就能看出,唯一键为三个字段的值。
@IdempotentReq({RequestBean.Fields.name, RequestBean.Fields.age, RequestBean.Fields.gender})
public Object req(RequestBean requestBean) {
return new Object();
}
}

@Data
@FieldNameConstants
class RequestBean {
private String name;
private Long age;
private List<String> habits;
private Gender gender;
}

enum Gender {
MAN,
WOMAN,
;
}

期望的优点

  1. 这里所说的期望优点,是希望能从代码层面对业务透明化,即,正常的一个业务服务提供者,它只需要增加一个标识,即可以自动变成幂等接口,这是我们想到达到的
  2. 隐含的期望优点,是指对于某些业务方,能支持存储方式的模块选择,例如我们可以自由选择 Redis/MySQL 作为存储(key,value)的介质,但是对于 MySQL 有个问题:一是表的存储在哪,二是定时删除数据的定时任务。
  3. 其实达到以上两点,那么它就能做到:成为一个幂等通用处理框架。

4. 总结

最后是抽出了一套通用的幂等方案,支持 redis 和 mysql 存储请求以及响应。和这个框架类似:https://github.com/liuyukuai/dis

关于这个业务场景,主要是为平台增收,同时也满足大小客户不同的流量需求。由于需求的特殊和时间问题,在技术设计上做了一定的取舍。(从技术上看,我觉得使用 MQ 交互是最好的,但是他们更加信任 RPC 而不是 MQ~)。当广告的 TPS 到了极高时(当平台流量高后,广告点击也会随之上升),底层服务 TPS 与上游 TPS 对齐,这是不合理的,二是耦合重。最好的方式是通过 MQ 交互,异步回调返回结果,这是一个非常好的优化点,不过目前的方案上线了几个月,目前没出现什么问题。

5. 反馈

这个方案后面又做了优化,起因是在上线大概一周后,周末在家无聊修复一个微信退款数据时想到的。

微信对超过一个月的订单退款会有频率限制,即对 TPS 限流,但是由于之前业务量没到这个阈值体系,因此编写代码没有考虑线上也一直也没有触发,而且微信官网也没有这个说明(坑),因此代码上没有对这个频率错误做重试处理,而是直接返回了错误,导致数据有问题,业务大概是:读写数据库 A-> 微信退款-> 读写数据库 B。

导致正常重试是不行的,对于 A 操作不能直接重试,需要重试查询退款进度然后做 B 操作。就是这个查询退款进度启发了我。

同步请求 + 提供查询接口。因为同步请求已经是幂等,但是不可避免 RT 会有抖动,导致重试,而我们超时时间短,极有可能触发上个请求还未完成,下个请求就来了,这时候接口只能返回请求正在处理中,但是对于上游服务是不友好的。最终优化方式为:

  1. 同步请求幂等(我们内部异步)
  2. 去掉重试,减少超时时间
  3. 提供反查接口。

第二步是去掉了重试,并将超时时间减少,减少异常时不必要的等待时间,因为当 RT 过高极有可能不是程序慢的问题,这时候让上游请求我们的反查接口,来最终得到结果值。