技术博客

账务性能:热点账户与并行清算——为什么清算要按账户哈希分片

第 24 / 141 篇 · 2026-08-10 · secfin · 清算性能并发分片

前面讲 EOD 总览时说过清算任务有严格的依赖顺序,这篇往下钻一层:单个任务内部怎么跑快。日终要处理的委托和流水以百万计,如果老老实实逐条 UPDATE 账户余额表,跑完要几个小时——这在 15:00 收市、第二天 9:00 要开门的行业里不可接受。加速的方向很自然:并行。但账务并行有一个其他业务系统没有的约束:同一账户的流水必须严格串行。

为什么同一账户必须串行

余额更新不是幂等操作,是读改写:balance = balance + delta。两条对同一账户的流水如果并发执行,经典竞态:

流水 A: 读 balance=100 → +50 → 写 150
流水 B: 读 balance=100 → -30 → 写  70   (A 的更新被覆盖,丢了)

数据库的行锁能防住这个(后写者等待前写者提交再重读),但代价是同一账户的流水在数据库层面被串行化,且每拿一次锁都是一次磁盘往返。热点账户一天几十万条流水(基金申赎户、自营户、做市账户),全部挤在一行上,锁等待会把并行度直接打回串行甚至更差(锁竞争的开销比计算本身大)。

所以账务并行的基本单元不是"一条流水",而是"一个账户的流水序列"。这个约束决定了分片方案的形状。

按账户哈希分片:推导加速比的上限

把账户按哈希分到 $P$ 个分片,每个分片一个工作线程,分片内串行、分片间并行。设总流水 $N$ 条、账户数 $M$ 个、单条流水处理成本 $c$(扣掉锁竞争后主要是计算 + 内存写)。理想加速比受制于两件事:

分片不均衡。 账户的流水数极度倾斜——绝大多数账户一天几条流水,少数账户几十万条。按账户哈希分片能保证同一账户进同一分片,但不能保证各分片流水总量均衡。设分片 $i$ 分到的总流水是 $N_i$,整个批处理耗时是 $\max_i (N_i \cdot c)$。假如流水最多的账户独占一个分片,该分片的流水数是 $n_{\max}$,那么无论加多少线程:

$$T_{parallel} \geq n_{\max} \cdot c$$

加速比 $S = \dfrac{N \cdot c}{T_{parallel}} \leq \dfrac{N}{n_{\max}}$。也就是说,单个热点账户的流水数就是加速比的天花板。实测里 $N/n_{\max}$ 常常只有几十,这解释了为什么"加机器没用"是清算优化的常见死胡同——瓶颈不在总量,在分布。

依赖方向。 有些账务操作跨账户:头寸划拨、券商和客户的二级清算。这类操作要求两个账户的序列有全局顺序(A 减必须在 B 加之前可见,或者至少同时提交)。处理办法是把跨账户操作从"流水处理主循环"里摘出来,单独一个阶段跑——主循环只处理单账户自闭的流水,跨账户的进依赖图按序执行。这和 EOD 总览讲的任务级栅栏是同一个思想的两层:任务之间有栅栏,任务内部有依赖边。

按账户哈希分片的并行清算结构当日流水池按账户号 hash 路由分片 1(线程)分片 2分片 3分片 P(长尾)内存余额账本每分片一块流水日志(追加)供断点续跑批量落库UPDATE 变 MERGE跨账户操作依赖图按序执行长尾分片 P 装的是哈希撞上的热点账户,其流水量决定整个批次的收尾时间对策:热点账户拆子账户或单独线程

热点账户的三种解法

子账户拆分。 基金户热点是因为它名下挂了几百只产品的头寸。账务建模时把"产品"作为账户的下一级维度,每个产品一个余额行,流水按产品路由——热点账户就被拆成了几百个温账户。前提是业务允许:产品间的头寸确实独立核算(资管产品的合规要求本来就是分产品建账,所以这条路对基金户恰好天然成立)。

单独线程 + 无锁队列。 对无法拆分的真热点(自营户的某些总账科目),给它的分片配专用线程,分片内用无锁单生产者单消费者队列接流水。注意这只解决"接"的问题,处理本身还是串行——它只是把锁竞争换成了内存拷贝,$n_{\max} \cdot c$ 的下限不动。真要压这个下限,只剩降低 $c$:内存化。

内存账本。 前面推导里的 $c$ 如果包含磁盘 IO,那是最该杀的部分。做法是清算期间账户余额不逐条 UPDATE 数据库,而是加载到内存哈希表,流水全部打内存;每个分片周期性(或结束时)把内存账本整体 MERGE 回余额表,一条 UPDATE ... WHERE acct=:k AND version=:v 顶几万条单流水 UPDATE。流水本身仍以追加日志落盘(这是断点续跑和审计的命根子),但"读改写"的热路径全程无锁无磁盘。内存账本 + 批量落库能把 $c$ 降一个数量级,这也是后面"内存撮合 vs 数据库撮合"那篇在交易侧的同构手法。

对账与幂等:并行化不能破坏的底线

并行和内存化都引入了新风险,两条纪律必须守住:

  1. 落库用乐观锁:MERGE 时带版本号,发现余额被交易时段的实时变更改过(理论上不该发生,日终交易已停),整批回滚重跑,绝不静默覆盖;
  2. 日志先行:任何一条流水先进追加日志、拿到序号,再进内存账本。进程崩了重启,从最后一条已落日志的序号续跑,保证不丢不重——幂等性来自序号,不来自"重跑无副作用"这种幻想。

最后给一个实测口径的经验值:内存账本 + 哈希分片后,单分片吞吐瓶颈通常在 CPU 的分支预测和哈希表 cache miss,而不是锁;profile 时如果发现时间耗在序列化(把流水对象转二进制日志),换 flat buffer 或手写编码,比加线程有效得多。