跳至正文
Starone

金融科技

构建高性能金融科技系统

在金融系统里,只有当每个结果都正确、有序且事后可解释时,速度才有意义。我们首先为可预测的尾延迟和完整的审计轨迹做设计,原始吞吐量只是随之而来的结果。

· 阅读约 5 分钟 · Starone 工程团队

在大多数软件里,性能优化是与正确性讨价还价:这里容忍一点陈旧数据,那里给一个近似计数。金融系统没有这个选项。一个短暂出错的余额、一笔被执行两次的订单,或者一条六个月后无法解释的账本分录,无论产生得多快,都是缺陷。

我们的观点是,这个领域里的高性能意味着负载之下行为可预测,而不是一个亮眼的吞吐量数字。工程投入集中在延迟分布的尾部,集中在消除资金状态变更点上的争用,以及维护一条在热路径上几乎零成本的审计轨迹。

延迟预算,按每一跳设定

端到端目标只有被拆分之后才可执行。假设一次支付授权在第 99 百分位上有 300 ms 的预算,那么每一跳都分得一份:边缘与认证、风控检查、账本写入、对外部支付通道的调用。哪个组件超出了自己的份额,靠度量就能确定,而不必靠争论。

预算以高百分位表述,是因为平均值会掩盖真正要紧的那些请求。一个面向用户、需要扇出到多个服务的操作,感受到的是其中最慢的那一个,因此尾延迟会随每一个依赖而叠加。负载测试必须是开环的,即以固定速率发送请求,不管响应如何。闭环测试会在系统变慢时跟着放慢,恰好掩盖了它本该暴露的排队现象。

争用才是真正的瓶颈

金融类工作负载的写入集中在少数几行上。商户结算账户、金库钱包或某个热门交易品种,会被很大比例的交易触及;无论有多少应用实例,这些行上的行级锁都会把整个系统串行化。当约束只是一把锁时,加硬件无济于事。

我们倾向的设计,一是减少持锁期间做的事,二是减少需要加锁的次数。

  • 保持事务简短:数据库事务内不做网络调用,不做风险评分,也不序列化大体积的 payload。
  • 按一致的顺序获取锁,例如按账户标识升序,这样两笔方向相反的转账就不会死锁。
  • 追加账本分录,而不是原地更新余额行;余额由分录推导,或由定期物化的快照得出。
  • 按账户对工作分区,让单一写入者按顺序处理每个账户的命令,从而在这条路径上完全不需要加锁。

乐观还是悲观,取决于冲突率

乐观并发控制,即在写入时校验版本列,在冲突很少时表现出色,因为读写双方都不需要等待。但在热点账户上,它会退化为反复重试,既浪费工作量又拉长尾延迟。使用 SELECT FOR UPDATE 的悲观锁让写入者依次排队,在高冲突下延迟更平稳,代价是等待期间一直占用连接。

选择应当依据每个实体实测的冲突率,一个系统同时使用两者也完全合理:客户账户用乐观方式,而那少数几个所有交易都会触及的账户,则走串行化的单写入者路径。

序列、顺序与空洞

在这里,顺序是一项正确性属性。每一次状态变更都应当在其作用域内带有单调递增的序列号,这样消费者就能通过空洞发现漏掉的消息并请求重放,而不是悄无声息地与源头产生偏差。墙上时钟的时间戳无法替代它:不同主机之间时钟会漂移,两个事件也可能落在同一毫秒。

数据库 sequence 是一个常见陷阱。它被有意设计成非事务性的,所以回滚的事务会消耗掉一个值并留下空洞,提交的先后顺序也可能与数值顺序不一致。对代理键来说这可以接受。但在编号必须连续无缺的场景,例如某些发票或对账单的编号序列,或者读取方按已见过的最大标识轮询、因而会跳过一条晚提交的记录时,这就不可接受。连续无缺的编号需要在同一事务内使用串行化的分配器,这在设计上就更慢,应当只限于确有需要的地方。

用背压代替崩溃

没有上限的系统会败得很难看:队列增长,延迟上升,客户端超时后重试,而重试又给一个已经落后的系统继续加压。每个队列和连接池都应当有界,到达上限时的行为也应当是一项明确的决策。对某些操作,正确的做法是以可重试的错误快速拒绝;对另一些,则是丢弃报表生成之类的低优先级工作,以保护授权请求。

截止时间(deadline)应当随请求一路传递,这样下游服务就不会为调用方早已放弃的工作白费力气。重试需要带抖动、有上限的指数退避以及重试预算,并且必须携带幂等键,否则从一次故障中恢复的过程本身就会造成重复的资金划转。

热路径上的可审计性

审计往往是以“再往日志表写一次”的方式加上去的,这让工作量翻倍,而且仍然可能与主记录不一致。我们更倾向于让审计轨迹本身就是主记录:一个仅追加的复式记账账本,其中每条分录不可变,引用了产生它的命令,纠错靠冲正分录而不是更新。金额以最小货币单位的整数或定点小数存储,绝不使用二进制浮点数。

仅追加的日志同时也很快。顺序写是存储引擎能提供的最廉价的操作,同一份日志还能支撑重放、对账和问题调查。代价是读模型必须另行推导并保持最新,而且除非事先规划好快照与归档,否则存储会无限增长。

取舍落在哪里

单写入者分区加内存状态,能给出最好的延迟,也带来最难的运维模型:故障切换、重放耗时和分区再均衡都需要专门的工程投入。对于交易量适中的系统,一个索引得当、事务简短、加锁顺序严谨的 PostgreSQL 数据库更简单,更容易推理,通常也已足够。只有当度量表明关系型设计已经没有余地时,我们才会采用专门的架构,而不是提前。

  • 账本选择严格可串行化(strict serialisability),报表、搜索和通知则接受最终一致性。
  • 在不能接受丢失已确认写入的地方选择同步复制,并为它增加的延迟留出预算。
  • 交易量允许时选择简单;一个值班工程师讲得清楚的架构,在故障期间本身就是一项性能特性。

正在做类似的事情

我们很乐意交流心得。欢迎与撰写本文的工程师聊聊。