委托在柜台里的旅程:接入 → 风控检查 → 报交易所。中间这一段事前风控是交易时延的固定成本,也是资损防线——检查漏一项,可能就是客户透支或违规交易。它的设计约束很拧巴:既要全(监管和风控要求的检查项只增不减),又要快(检查耗时直接加在每一笔委托的延迟上,量化客户为此付费)。这篇讲检查链路本身,以及"全"和"快"怎么同时成立。
检查项全景:按否决成本排序
事前检查的项目大致五类,每类挂在链路的不同位置:
| 检查 | 内容 | 典型成本 |
|---|---|---|
| 静态权限 | 账户是否正常、有无该市场/品种交易权限、品种是否停牌 | 微秒级,查配置 |
| 合规规则 | 价格笼子、涨跌停价、最小变动价位、整数手 | 微秒级,纯计算 |
| 资金/持仓 | 买入冻结资金够不够、卖出冻结持仓够不够、保证金比例(两融) | 十微秒~百微秒,查余额 |
| 名单类 | 黑名单(操纵市场处罚名单)、限制账户、实控人关联检查 | 微秒级,查集合 |
| 限额类 | 客户级/产品级单日委托金额上限、自成交限额 | 微秒级,原子计数 |
排布顺序有个朴素原则:便宜的在前,贵的在后。静态权限和合规规则是纯计算或读热数据,放最前,早拒早解脱——委托在第一条就不合格时,后面的检查白做还白占锁。资金持仓校验最贵(后面细说),放在中间;名单和限额最慢也要做,放最后兜底。
为什么资金校验是最贵的一道
静态检查只读配置,资金持仓检查要读可变状态,而且这状态正在被全系统的并发委托改着。最直觉的实现:校验时 SELECT balance FROM ... FOR UPDATE 加行锁检查可用余额——慢且把并发度锁死了。生产实现都是内存缓存 + 预留记账:
def check_buy(self, acct, amount):
avail = self.cache.available(acct) # 内存读,纳秒级
if avail < amount:
return REJECT
self.cache.reserve(acct, amount) # 原子扣减可用,转入冻结
委托报出前先原子扣减内存里的可用余额,成交后按实际金额多退少补,废单/撤单全额返还。关键点:内存余额是权威数据的镜像 + 本柜台的预记账,权威余额仍在数据库,日终对账抹平差异。风控检查允许的瞬时"超卖"窗口(内存已扣、数据库未落)由"预留"语义保证——扣减发生在委托受理时,不是成交时,透支只会发生在极端并发下两个通道同时受理——所以跨通道(双柜台)场景下,内存账本要么全局唯一(一个风控实例),要么按账户分片路由到同一实例,这是事前风控系统并发设计的头号约束。
预算超了怎么办:分级降级
单条检查链路的全量预算通常是几百微秒到 1 毫秒。预算超支时的武器不是优化到底,而是分级:
- 同步必检项:合规规则(价格笼子、涨跌停)和资金持仓,毫秒预算内的硬检查,不过直接拒单。合规项一项都不能异步化——这是监管红线;
- 准同步检查:名单类的部分维度(如大额可疑名单),同步查本地镜像,本地镜像由异步任务分钟级刷新。接受"名单更新延迟一分钟"的风险换取微秒级响应;
- 异步复核:限额类的统计维度(如客户单日累计买入金额的趋势预警),委托放行后异步算,异常时事后处置(限制后续委托 + 人工介入)。
分级的本质是把"检查"拆成"否决"和"记录"两个动作:能毫秒级内确定性回答的才配否决,其余一律先放行后追。这个原则和清算篇"先落日志再处理"一脉相承——系统的每个决策点都要想清楚"我此刻依据的信息有多新"。
链路本身的工程细节
补偿语义。 检查链是责任链模式,但比教科书多一层:第 N 道检查失败时,前 N-1 道已经做了的副作用(内存预留、计数器递增)要按逆序回滚。漏回滚的经典 bug:资金扣了但静态权限检查失败,预留没释放——客户可用余额越用越少,直到日终批量才对平。把"检查"和"副作用"分开登记,失败时统一走回滚清单。
检查耗时可观测。 每道检查的耗时打点(histogram),线上任何一道检查 P99 上涨都能立刻定位。事前风控是延迟预算的消耗大头,没有分项度量,"委托变慢了"这种投诉永远查不到根因。
规则热更新。 涨跌停价每日更新、价格笼子参数按交易所通知调整,名单分钟级刷新——链路里的所有规则数据都必须支持无重启热加载,且加载用双缓冲(新版本就绪后一次性切换),避免检查读到一半规则换了。风控参数的热更新篇会展开这套下发机制。
最后说一个容易忽视的校验精度问题:资金比较不能用浮点。价格×数量在二进制浮点下会产生 0.1+0.2 式误差,"可用 10000.00 元买 10000.00 元的单"可能被拒。余额和金额全程用定点数(分或厘为单位的整数)或 decimal 类型,比较逻辑才有确定性——这个坑在浮点世界里不大,但在"每分钱都要找到出处"的证券账务里,踩一次就是一次资损事件。