一个计费系统的研究

需求

一个ToB的SaaS系统,企业先充值,然后用户在使用服务的时候从企业账户上扣钱。
服务分为三类:免费服务,按次计费,按时长计费。当一个服务同时服务多个人时服务费用可能按人数增长。用户的一个操作可能触发多个服务。
按时长计费的服务使用先计划再服务的模式,即用户先制定服务的开始时间,结束时间,服务人数等计划,然后到时手工/自动开启服务。
对于计费有明细和汇总供用户查看。

外部约束

对接了基础服务供应商的系统,计费时长以供应商提供的为准,但是供应商一般隔天提供。

思考

计费时机

这里主要是针对按时长计费的服务,因为按次计费的服务只需要在业务触发同时扣款即可。

服务前

若采用服务前扣款,则此时数据还不够完善(例如实际服务人数不确定),可能触发的折扣规则不确定,只能按最大值扣款,这样会导致用户同时享受的服务数下降,体验不好。

服务后

若采用服务后扣款,则可能会遇到羊毛党,若服务开始后一直不结束则则可能会使服务费用远超余额导致自身利益损失。

服务中

还有一种服务中计费的方式,即服务开始后定时从账户上扣款,直至余额不足终止服务。这样对调度任务本身要求比较高,另外和服务前扣款遇到的问题一样,服务没有终止有些计费参数不明确。

预扣费

还有预扣费的方式即在进行服务计划时扣费,这样就把按时长计费转化为了按次计费,它遇到的问题和服务前扣费是类似的。并且这样用户体验更加糟糕。不过这里可以做一个对用户的充值提醒。

计费与业务的分离与一致

职责分离

这里把计费逻辑与业务本身的逻辑进行分离,即使考虑软件设计的一些原则也对于今后服务的私有化提供了可能。加上专门进行权限检查的BFF模块,这三者的关系有以下三种可能

monolith
BFF ServiceN ServiceM 请求服务 进行业务操作 进行计费 成功 另一个业务操作 请求服务 进行业务操作 进行计费 成功 BFF ServiceN ServiceM

这种情况下计费服务和对应的计费模块在同一个Artifact里面,每一个业务模块里都需要集成自身服务对应的计费功能。至于业务操作和计费哪个先进行都可以。

Choreography

我们把上面的计费功能抽取出来作为一个模块就得到下面的结构

BFF BusinessN Account BusinessM MQ 请求服务 进行业务操作 进行计费 成功 成功 另一个业务操作 请求服务 进行业务操作 发送消息 进行计费 成功 成功 BFF BusinessN Account BusinessM MQ

这种情况的部署模型和上面一个类似,也是有多个业务模块和一个计费模块,只不过计费模块的调用是通过业务模块来驱动,这种调用可以是同步(上)也可以是异步(下)。

Orchestration

如果我们把协调工作从业务模块抽取出来交给BFF统一处理则得到下面的架构

BFF BusinessN Account BusinessM 进行业务操作 成功 进行计费 成功 另一个业务操作 进行业务操作 成功 进行计费 成功 BFF BusinessN Account BusinessM

这种情况计费服务和业务模块是单独的微服务,在多个业务服务的情况下只需要一个计费服务。至于业务操作和计费则进入分布式事务的范畴。

总结

其实三种情况对于计费服务的调用都可以是异步的(monolith通过EventBus,而Orchestration同样让BFF调用MQ),只是只有Choreography的情况下MQ的价值比较大,并且另外两种情况使用MQ会使得事务比较复杂。

事务

严格来说只有monolith可以通过共用一个数据库的方式来实现ACID,而Choreography和Orchestration是无法实现的。如果我们细致的把失败的原因分为业务逻辑(例如余额不足)和技术原因(例如与DB的网络中断)两种原因,那么Choreography可以解决业务逻辑的问题,而Orchestration则需要业务操作本身支持撤销(虽然这里的业务场景是支持的)。
上面的讨论是基于同步模式的情况下,如果在异步模式的情况下都不是天然支持事务的。

设计

balance

为了同时达到良好的用户体验和不亏两个相互矛盾的目标,最终设计通过两类账号来实现,一个账号给用户良好体验,一类账号确保不亏损(至少不能亏太多)。前者只用于给用户展示balanceForDisplay,后者用于判断是否提供服务balanceForService,另外我们考虑用户可以适当的赊账,于是有minBalanceForService用于控制balanceForService的最小值,显然minBalanceForService不应该是正数。
那么这两类账号分别用于服务开始前和服务结束后,在服务开始前由advanceCharge判断能否提供服务并预扣balanceForService,显然这个是同步的。在服务结束后由finalCharge扣除balanceForDisplay并对balanceForService进行修正,这个可以是异步的。对于按次计费而言,在advanceCharge时已经可以进行扣除。对于一个BusinessEvent而言可能既包括按次付费也有按时长付费,甚至更复杂的情况要等到BusinessEvent结束后才能知道是否有按时长付费(会有这种情况么?)而这些规则应该是由计费系统管理,所以对finalCharge的调用是无法避免的

Account String id int balanceForDisplay int balanceForService int minBalanceForService void advanceCharge void finalCharge

ChargeEvent

这也意味着每个服务也有两类价格,一个是用于预扣的费用advanceCharge,一个是实际服务后才能确定的费用finalCharge。虽然看上去按次计费可以跳过预扣费这一步而进行直接的实际扣费,但是为了让balanceForService准确,还是要进行修改,只不过可以随同balanceForDisplay一起修改从而减少DB操作次数。

ChargeEvent String businessId ChargeType type int getAdvanceCharge Integer getFinalCharge BusinessEventM BusinessEventN 1 * 1 *

一个业务事件BusinessEvent可能会触发多个计费事件ChargeEvent(也可能是0次,例如免费事件),通过ChargeType这一枚举类来区分。为简单起见,对于同一个BusinessEvent里面多个ChargeEvent采用同样的时长。注意对于未完成的事件而言getFinalCharge可能返回null

Flowchart

before

综合上面的考虑采用Choreography模式,并加上锁相关的动作,则开始业务事件流程如下所示

N
Y
N
Y
Start
业务锁
从业务库读取数据
判断业务逻辑
符合业务逻辑
业务锁
预扣款
扣款成功
写入成功记录
返回

预扣款子流程如下

Y
N
Y
N
Start
分解计费
预扣款是否为0
返回
账目锁
读取余额
预扣
余额是否低于最小值
解锁
写入收费记录&更新balanceForService

上面增加了预扣款是否为0的判断是为了减少锁的可能性,也可以不判断直接认为大于0.另外如果有费用为0的收费项存在时,即使不更新账户余额也要写入收费记录

after

触发实际扣费的情况可以分为两类,一类是用户的服务终止,这个时候就可以知道时长了,但是这个时长和我们的服务供应商提供的时长可能不一致。那么也可以等供应商的账单来了之后再计算费用,但是这样用户的账号余额就不能及时更新了。

N
Y
Start
业务锁
从业务库读取数据
判断业务逻辑
符合业务逻辑
业务锁
扣除余额
写入成功记录
返回

扣款子流程如下

Y
N
Start
分解计费
扣款是否为0
返回
账目锁
读取余额
扣除
更新收费记录&更新balanceForDisplay
unlock

总的来说事后扣款和事前预扣的流程很相似,主要区别是事后扣款不用管账户上的钱是否充足。

再论分布式事务

CAP

另外上面没有考虑服务本身需要调用供应商的服务的情况,如果考虑这一点,开启服务时需要在业务库写入完毕后调用第三方服务(必要时再更新自己的服务)。如果调用第三方失败则需要考虑回滚计费系统,如果是异常则需要人工干预。CAP已经明确了在这一套操作中如果出现网络分区,那么是无法保证事务一致性的.这里除了写日志和发邮件来进行人工干预外还可以通过T+1的查询来对数据进行修复(不能在异常时立刻查询,因为写操作可能在读操作之后执行).如果有类似对账系统对于过期服务拒绝的话,那么就可以在有限的时间内实现最终一致性.

2PC的视角

如果我们以2PC的视角来看待计费服务,那么理想情况下整个过程应该分为两步,第一步是业务操作Ready,计费Ready,供应商服务Ready,然后第二步再提交.这里遇到的问题是供应商提供的服务没有Ready,Commit的支持,那么就需要将供应商Ready融入业务操作Ready的判断内.也就是说在通过业务操作的Ready后就必然能满足供应商服务的Ready,推而广之,如果系统的一个业务操作涉及到外部服务,那么就必须依赖自身系统而非外部服务来判断这个操作是否合法的.当然,外部服务还是会判断一次,这里有一些浪费.

Saga视角

Saga把参与到一个分布式事务中的多次调用分为三类:可撤销的服务(有对于撤销接口可以完美的回滚),关键服务(无法回滚),必须成功的服务(通过无限重试确保成功).
结合我们现在的场景,那么这里的计费服务显然是可撤销的服务(因为撤销本身是增加资源),而供应商服务是不可撤销服务(对于按时间计费的服务可以通过立刻终止来变相回滚),如果有积分模块的话,那么积分模块就是必须成功的服务.所以按照这样的顺序来看,也是先调用计费服务,再调用供应商服务,至于积分和显示的余额的扣减(注意这个时候页面显示的余额已经不是最重要的了)可以通过反复重试而成功.何况积分可能面临无法回滚的情况,就不能放到关键服务的前面了.

补充

一个保险措施,就是添加一个调度任务,每天半夜终止所有未结束的服务服务。
另外可以考虑一个额外的审计系统来审查用户的操作,这里既包括用户对系统的恶意操作,也包括对企业恶意用户的操作和善意用户的误操作

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值