前面讲 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 总览讲的任务级栅栏是同一个思想的两层:任务之间有栅栏,任务内部有依赖边。
热点账户的三种解法
子账户拆分。 基金户热点是因为它名下挂了几百只产品的头寸。账务建模时把"产品"作为账户的下一级维度,每个产品一个余额行,流水按产品路由——热点账户就被拆成了几百个温账户。前提是业务允许:产品间的头寸确实独立核算(资管产品的合规要求本来就是分产品建账,所以这条路对基金户恰好天然成立)。
单独线程 + 无锁队列。 对无法拆分的真热点(自营户的某些总账科目),给它的分片配专用线程,分片内用无锁单生产者单消费者队列接流水。注意这只解决"接"的问题,处理本身还是串行——它只是把锁竞争换成了内存拷贝,$n_{\max} \cdot c$ 的下限不动。真要压这个下限,只剩降低 $c$:内存化。
内存账本。 前面推导里的 $c$ 如果包含磁盘 IO,那是最该杀的部分。做法是清算期间账户余额不逐条 UPDATE 数据库,而是加载到内存哈希表,流水全部打内存;每个分片周期性(或结束时)把内存账本整体 MERGE 回余额表,一条 UPDATE ... WHERE acct=:k AND version=:v 顶几万条单流水 UPDATE。流水本身仍以追加日志落盘(这是断点续跑和审计的命根子),但"读改写"的热路径全程无锁无磁盘。内存账本 + 批量落库能把 $c$ 降一个数量级,这也是后面"内存撮合 vs 数据库撮合"那篇在交易侧的同构手法。
对账与幂等:并行化不能破坏的底线
并行和内存化都引入了新风险,两条纪律必须守住:
- 落库用乐观锁:MERGE 时带版本号,发现余额被交易时段的实时变更改过(理论上不该发生,日终交易已停),整批回滚重跑,绝不静默覆盖;
- 日志先行:任何一条流水先进追加日志、拿到序号,再进内存账本。进程崩了重启,从最后一条已落日志的序号续跑,保证不丢不重——幂等性来自序号,不来自"重跑无副作用"这种幻想。
最后给一个实测口径的经验值:内存账本 + 哈希分片后,单分片吞吐瓶颈通常在 CPU 的分支预测和哈希表 cache miss,而不是锁;profile 时如果发现时间耗在序列化(把流水对象转二进制日志),换 flat buffer 或手写编码,比加线程有效得多。