从零开始的热点账户建设

type
status
date
slug
summary
tags
category
icon
password
我们在开发需要应对高并发场景的系统时,经常会遇到热点问题,即大部分流量落到一个单点上,这个单点可能是网卡、某个服务实例、某个数据库,甚至是数据库上的一行记录。这些单点成为了制约系统性能提升的瓶颈,是在设计系统时必须要考虑的情况。
对于账户型系统来说(区别于流水型系统),这类单点经常会在某个数据行上出现,比如一个销售非常火爆的商家,它的账户就需要频繁的更新余额,造成锁竞争激烈,继而影响动账的性能。这篇文章将会从零开始,探讨随着流量的增长,各类热点账户问题如何解决。
自有支付账户系统的热点账户功能模块主要经历了以下三个发展阶段:
  1. 基础能力建设,为高 QPS 的出入账提供了基本的功能支持。
  1. 优化功能阶段,为热点账户提供了一系列可配置项,通过回放生成动账明细,自动平衡余额等功能,使热点账户模块能够稳定合规地运行。
  1. 性能提升阶段,为了支持不断增长的业务,通过汇总扣款、跨分片记账等功能提升性能和容量。

基础能力建设

建设账户系统

假设我们现在为了支持字节的自有支付,开发了一个新的账户系统。这个账户系统完全是为了能够跑通各种业务流程而设计的,几乎没有考虑性能问题。在系统上线初期,QPS 只有个位数时,运行还是十分稳定的,可随着业务的发展,我们逐渐发现,系统的性能不够用了。
你可能会想,QPS 也就是几十的水平,怎么性能就不够用了呢,这系统也太拉垮了吧。实际上,为了保证数据的完整和资金安全,账务系统一次动账的操作是很多的,包括创建流水、进行账户和余额校验、更新账户余额、更新账户日余额(即每天动账金额和笔数等信息),更新累计金额(主要用于校验账户等级和限额)。因此一次动账平均需要执行 10 条 SQL,并且为了保证数据一致性,除了创建流水之外,其他的 SQL 都需要放在一个事务中执行,并且还需要锁定账户。
因此,即使单条 SQL 只需要 5 毫秒,那一次动账总共也要花费 50 毫秒的时间,1 秒钟只能最多只能做 20 次。这也是账户系统的特点之一,虽然对性能要求很高,但是比性能更重要的是资金安全性。这也是很多银行对外提供的接口只能支持个位数 QPS 的原因。

突破单行性能

动账性能的限制归根到底是锁账户性能的限制。因为一旦所有的请求都需要锁定账户,那么最终这些请求在数据库层面都是要被串行执行的。因此要想解决限制,就得从这一点上解决。
这里有种最直接的思路就是,既然单个账户性能不够,那么就把资金分散到多个账户中,动账时随机进行选择。基于这种思路,我们可以得到拆分子账户的方案。
如果只是要实现拆分子账户的功能,其实相对比较简单,只需要将余额拆分到多个子账户中,并且在动账请求到来时,自动路由到某个子账户上。
notion image
另一种解决问题的思路是,既然动账时需要锁定账户更新余额,影响性能上限,那干脆就先不更新了。基于这种思路,可以得到汇总记账的方案。
汇总记账简单来说,就是动账时先不实时更新余额,而是先落下流水信息,每隔一段时间,将这些流水进行汇总,再一把更新余额。通过这种方式,就可以避免数据库行锁竞争带来的性能瓶颈。
notion image

优化功能阶段

性能提升的副作用

通过以上两种方案,我们的账务系统可以成功支持上百,甚至上千 QPS 的记账了,尤其是汇总记账,动账性能几乎就等于数据库写入数据的性能了。当我们真的就能高枕无忧了吗?当然不是,这两种性能提升的方式是伴随一定代价的。
对热点账户来说,由于我们把余额拆分到了多个账户中,当我们要一次出一笔大额的资金,单个账户不够用的时候,事情就麻烦了。拒绝动账请求肯定是不可行的,那就需要对子账户进行合并。如果短时间内进行了大量的子账户合并,那么性能也会急剧下降。极端情况下甚至可能退化为单账户动账的情况。
对汇总账户来说,由于动账不是实时的,因此在动账时也无法判断账户中的余额能否支持这笔动账。因此存在将余额扣成负数的可能性,对于一个追求严格资金安全的账务系统来说,这是不可接受的。对于扣款来说,还是需要实时记账。因此,只有绝大部分动账是入账的账户,才适合用汇总记账的方式提升性能。
然而,以上几个副作用只是性能上的副作用,更加严重的其实是对业务上的影响。在建设自有支付系统的过程中,我们还遇到了比这两个更加严重的问题。

账单怎么办?

对于一个简单的内部账务系统来说,做到以上的热点和汇总记账方案可能就已经够用了。但是别忘了我们现在建设的是三方支付的账务系统。对于持牌三方支付机构来说,有一个非常重要的能力是提供准确可靠的账单。一方面如果无法提供账单,是肯定无法通过审查的;另一方面,想象一下如果抖音电商无法给商户提供账单,商户怎么可能愿意入驻呢?因此,无论从合规还是从业务发展的角度,账单都是必须的能力。
但是,当我们将一个热点账户拆分成多个子账户之后,问题就出现了,如何提供余额连续变动的账单?每个子账户上的动账明细是余额连续的,然而当合并到一起之后,就无法保证顺序了。
假设现在有三个子账户,起始余额分别为 100、50 和 70,发生了若干笔动账,并且存在不同子账户上同时发生动账的情况。
notion image
account
amount
pre_balance
end_balance
time
sub_account1
-1
100
99
00:00
sub_account1
1
99
100
00:02
但是对于整个账户,无法准确的知道此时账户总的余额。我们使用的方式是增加 shadow_balance 概念,用于表示进行账户拆分时的余额快照。然后将所有子账户的动账明细按时间排列,如果发生在同一时刻,就再根据 id 排序,因为是在同一个数据库分片上,所以一定能保证没有重复id,依据这些明细逐条生成主账户的动账明细,期初余额就是拆分时的 shadow_balance,根据每次的动账金额就能计算得到期末余额。我们将以上这个过程称为回放。通过异步的回放任务,就能生成完整连续的动账明细,也就能够得到账单。
notion image

热点账户大额出款

解决了账单问题,看起来我们的账务系统是可以稳定合规地提供高性能的服务了。但是随着业务场景的丰富,出现了各式各样的热点账户和动账场景,现有的方案可能无法满足需求了。比如为了提升我们支付系统的渗透率,营销活动是不可或缺的,下面就以营销活动为例来说明这一情况。
notion image
运营同学在配置活动时,会将一大笔资金从营销商户的现金户划转到优惠券户中,用于在用户使用优惠券时扣减。而如果到活动结束时预算没有花完,剩余的这部分资金就需要回收到现金户中。这里就出现了热点账户遇到的第一个困境 —— 大额出款。因为资金是分散在多个子账户中的,虽然总余额足够,但是单个子账户不足以支持,需要进行余额合并之后再出款。
我们可以用一种较为简单的实现方式来解决这个问题,当发现子账户余额不足时,就把这个子账户里的余额转入到其他子账户当中。通过依赖上游的重试就能起到归拢资金的作用,最终能将这笔大额资金出款成功。

热点账户的退化

大额资金出款成功之后,问题又来了,在经过若干次余额之后,可能只存在几个有余额的子账户了,这就导致了性能的急剧下降。严重情况下甚至可能退化成单个账户的性能。实际上这种情况在生产环境就出现过,比如运营同学在高峰期进行了一笔大额的划账。因此,我们需要尽可能保证余额的均匀
为了实现这个目的,可以定时运行余额 Rebalance 任务,一旦发现有热点账户余额不够平均,比如大于或小于平均值的一定倍数,就将余额重新打散。保证子账户的余额始终都处于较为平均的状态。
另外,对于一些重要商户,或者是重要的活动场景,子账户余额合并功能需要被关闭掉,避免因为误操作而影响线上服务性能。

性能提升阶段

汇总出款

通过以上几项优化,热点和汇总记账功能已经较为完善了,能够支持绝大部分业务场景。但大家不妨想办法 “刁难” 一下目前的账户系统,什么情况才能让热点和汇总方案同时失效,造成严重的性能问题。
我们不妨梳理下目前已有的能力和缺陷:
  • 对于出款或入款 QPS 大的场景,可以使用热点账户,但大额出款会导致性能下降
  • 对于只有入款 QPS 大的场景,可以使用汇总记账,但无法支持高 QPS 的出款
因此,如果有一种情况,出款和入款 QPS 同时都很高,并且还伴随着大额出款,现有的能力就无法支持了。对于这种场景,其实一般情况下都是能通过业务上的优化来避免的,比如将收入和支出进行分离,或者把一个商户拆分成多个商户等。这其实相当于用业务上的便利性换取更高的性能。
但随着业务量的增加,这种场景其实还是有很大可能出现的。比如一个大商户的待结算户,每天的交易量都很大,同时会有很多的收单和退款交易,这就满足了出入款 QPS 都很高;另外,每天还需要结算户货款收入到它的余额账户或者银行卡,这就满足了大额出款。因此必须要有性能更高的解决方案。
对于这种场景,热点账户方案天然的就无法支持,因为当大额出款的金额占总金额的绝大多数时,合并子账户势必会导致性能的急剧下降,并且大量请求还可能遇到子账户余额不足的错误。因此只能采用汇总记账的思路,也就是把出款也异步化。
一旦出款异步化了,我们就会面临将余额打穿成负数的风险,因为账户的当前余额减去还未实际动账的订单金额有可能为负数,也就是说本来应该返回余额不足的请求,在这种情况下返回了成功。为了避免这种情况,一种思路就是用 redis 记录未实际动账的订单总金额。这样在每次动账时可以计算余额是否会被更新为负,以保证安全,大致逻辑如下:
notion image

突破单库性能

支持汇总扣款之后,我们的热点账户模块机会可以支持几乎所有场景的热点情况了,而且基于异步记账的特性,性能上限取决于数据库的写入性能(只要写入 SumLog 就可以返回成功结果)。为了动账时的数据一致,我们此前所有的操作都是在同一个数据库分片上用事务保证的。但是随着业务单量的进一步提高,我们会遇到以下两个问题:
  1. 单库性能不够,因为除了写入 SumLog 之外,数据库上还要执行很多其他操作,根据压测经验,单库上汇总记账的性能上限在 2000 左右。
  1. 部分数据库容量不足,因为同一个商户的所有订单都在同一个分片上,大商户所在分片数据容量增长会非常快,并且无法通过数据库的水平扩容来解决。
  1. 因此,如果要突破单个数据库的性能和容量限制,就需要支持将订单落到不同的分片上。
notion image
汇总跨分片记账的大致流程如上图所示,账户位于 5 分片,在接收动账请求时,将用于控制幂等的 Order 和记账凭证 SumLog 落在其他分片上(可以根据业务订单号 hash 出分片号)。生成这两笔订单是可以通过事务保证一致性的。在回放时,会从各个分片上收集待回放的 SumLog,生成账户动账明细,并更新账户余额。因为订单落到不同分片上后,幂等和查询原单等相关逻辑会更加复杂,为了控制风险,可以考虑基于商户号控制,只对单量非常大的业务场景做汇总跨分片。
通过跨分片的方案,单个数据库的性能不再是汇总记账的瓶颈,同时原来在账户分片上的 Order 和 SumLog 分散到了数据库的所有分片上,写入性能和存储空间都大大提高。我们生产环境使用的是 16 个数据库实例,理论上最大可以获得约 16 倍的性能和存储空间,即使未来随着业务发展不够用了,还是能够通过扩容数据库来解决的。

未来展望

经过上述一系列的建设和优化,单账户上 2000 QPS 的动账对于我们的账户系统来说应该是比较轻松了,开启跨分片之后理论上能达到上万 QPS。但接下来呢,如果 QPS 进一步提高,还有继续优化和提升的空间吗?
如上一节所说,当实现了跨分片的汇总记账之后,动账性能就不再受单个数据库限制,而是能通过增加数据库水平扩展了。但当我们的账务系统支持几万 QPS 还不够的时候,我们就要思考一个问题,当单量大到这种程度的时候,存储成本会非常高,并且业务上还需要有复杂的归档和查询逻辑来支持,再做水平扩容收益就不是很可观了。
一种思路是可以简化汇总记账,原先回放时会逐条生成动账明细,可以改为一个批次只生成一条,这样如果动账QPS是 2000 的话,相当于动账明细从 2000 条合并到了 1 条,能够大大提升并发性能和节约存储空间(这里其实已经做完了,可以期待一下后续的文章)。将这种高性能和存储占用的汇总记账作为一种选项提供给业务方选择。
Loading...

© NotionNext 2021-2025