交易资金查询的请求频率控制:先计数,再按触发源分层
项目 trade-app
1. 背景与问题
这是一款港股、美股与期货的一站式交易 App。交易首页同时展示证券和期货账户的总资产、可用购买力、净现金和浮动盈亏。资金类数据不走 HTTP,而是走交易长连接:客户端把查询打包成 Protobuf 发给交易网关,网关再去问后面的柜台,应答回来后写进资金数据中心,由数据中心通知当前页面。
证券资金由两个查询拼起来:
- 资金信息:可取资金、追缴保证金、待买入等。
- 资金明细:可用购买力、净现金结余、新股冻结资金等,也是总资产的一部分。
问题在于「谁来触发这两个查询」。交易模块做着做着,触发点越来越多:
- 交易首页有一个定时器,隔几秒查一次;
- 证券主页里,持仓、委托、成交每变动一次就查一次;
- 出入金主推到达时查一次;
- 港股、美股子页、下单页各自也会查;
- 页面出现、下拉刷新、刷新按钮、交易长连接重连成功、切换交易账户,都会查。
这些触发源彼此不知道对方的存在。用户只是进了一次交易首页,同一个查询却可能在一两秒内被发出好几次。每一次都要经过交易网关和柜台;网关对交易请求本身也有频率限制(交易登录时会收到「请求过于频繁」的提示)。
这篇不讲事故,讲的是一次主动的治理:先把调用次数量出来,再按触发源分层处理。
2. 先计数
第一步不是改逻辑,而是在调试包里给「资金明细查询」加一个调用计数,每发一次就打一行日志。这样在真机上进出交易首页、切 Tab、下拉、等推送,就能直接看到每个动作带来了几次查询、来自哪里。
调试包里的调用计数只在调试编译条件下生效,正式包不受影响。仓库里没有留下优化前后的具体次数,这里也不写数字。
计数之后,触发源可以分成三类:
三类的诉求完全不同:
| 触发源 | 用户在等吗 | 能不能晚一点 | 能不能合并 |
|---|---|---|---|
| 定时器 | 不在等 | 能 | 能,甚至可以不要 |
| 推送驱动 | 不在等 | 能晚一两秒 | 能,一串推送只需查一次 |
| 下拉、刷新按钮 | 在等,有转圈 | 不能 | 不能被合并掉 |
| 页面出现、重连、切账户 | 半在等 | 不宜 | 要保证一定查到 |
所以方案不是一个统一的节流,而是按来源走不同的通道。
3. 整体设计
整套做法由四层组成:
- 轮询:先降频,再从交易首页拿掉,改为事件驱动。
- 请求层:两个资金查询各自一个合并窗口,另开强制通道。
- 场景层:交易数据请求管理按页面场景决定查什么、走哪条通道。
- 展示层:盈亏推送引起的通知和用户刷新分开处理;港美股盈亏等齐再画,等不到就用现有数据。
4. 关键实现
4.1 轮询:从 3 秒到 10 秒,再到不轮询
交易首页最早用一个 3 秒的定时器查资金,先改成 10 秒。之后查询时机收进请求管理,用过一版 15 秒的定时器,其中期货的定时器在交易长连接断开时暂停、重连后恢复。再往后,交易首页的定时器整个拿掉了:资金变化本来就有推送,首页只在下面几个时刻主动查:
- 下拉刷新或点刷新按钮;
- 页面出现,且距上次刷新已经超过 10 秒;
- 交易长连接重新建立通讯;
- 切换交易账户。
手动刷新本身也有间隔限制:页面基类记录距上次手动刷新的秒数,不足 10 秒的下拉直接收起,不发请求。期货主页仍保留一个 15 秒的定时刷新。
4.2 合并窗口与强制通道
被动触发的查询,用一个很小的「待发标记」合并:窗口里第一次调用把标记置上,1 秒后真正发出,并把标记清掉;窗口里其余的调用看到标记已经置上,直接返回。应答回来写进数据中心,所有页面共享同一份结果。
强制通道则完全绕开标记,立即发出。
// 示意,非原始代码
enum FundQuery { case balance, detail }
final class SecuritiesRequest {
private var pending: [FundQuery: Bool] = [:] // 只在主线程读写
func query(_ kind: FundQuery, force: Bool = false) {
if force { send(kind); return } // 强制通道:立即发
if pending[kind] == true { return } // 窗口内已有一次待发
pending[kind] = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
self.send(kind) // 发的时候才读当前账户
self.pending[kind] = false
}
}
}
窗口和强制通道是配合着用的:给两个资金查询都加了强制通道,把同样的窗口用到资金信息查询上,并让两个查询各用一个待发标记。强制调用既不看也不改待发标记,所以窗口里已经有一次待发时,下拉刷新仍会立即发出;窗口到期的那一次照常发出,两次的应答都写进同一个数据中心。代价是窗口内偶尔会多一次查询,换来的是用户动作永远不被吞。各用一个标记这一点踩过坑,见第 5 节。
待发标记本身只有两个状态:
4.3 谁走哪条通道
交易数据请求管理把「什么场景查什么」集中到一处,页面只说自己是谁:
| 场景 | 查询内容 | 通道 |
|---|---|---|
| 交易首页(下拉、出现、重连、切账户) | 证券资金信息 + 资金明细,期货免密资金 | 强制 |
| 证券主页的持仓、委托、成交变动 | 证券资金信息 + 资金明细 | 合并窗口 |
| 出入金主推 | 证券资金信息 + 资金明细 | 合并窗口 |
| 证券主页手动刷新 | 证券资金信息 + 资金明细 | 强制 |
| 港股 / 美股子页、下单页 | 资金明细(子页也查资金信息) | 合并窗口 |
| 期货主页、期货资金详情 | 登录后的期货资金 | 直接发 |
规则很简单:**用户在等的、或者必须保证查到的,走强制;推送带来的,走窗口。**一串推送只换来一次查询,而下拉刷新不会因为「窗口里已经有一次待发」被吞掉。
4.4 盈亏推送与用户刷新分流
浮动盈亏不是柜台给的,而是客户端用行情推送 × 持仓 × 汇率现算的。港股或美股的总盈亏每变一次,数据中心都会通知代理「该刷新资金了」。资金应答到达时,数据中心也会发同一个通知。
问题出在这两种通知走的是同一个回调。盈亏推送引起的通知走进了同一个刷新回调,和主动刷新的限制逻辑相互干扰,导致紧跟在自动刷新后面的那次下拉看起来没反应。修复是在数据中心发通知前标明来源,回调里拦截由盈亏推送引起的那一次。
后来又做了一次调整:只在等待超时之后的分支里拦截盈亏推送引起的通知。还在等盈亏到齐的阶段,盈亏推送正是「齐了」的信号,不能拦。
4.5 等齐再画,但不无限等
证券总资产里有一部分是持仓市值,必须等每个持仓都收到行情快照、算出盈亏才准确。如果资金一到就画,用户会先看到一个偏小的总资产,过一会儿再跳一下。
交易首页在数据中心的队列上跑一个每秒累加的等待计时:港股和美股的盈亏都到齐就立即刷新;没到齐就先不刷新;超过上限就用现有数据刷新,并暂停计时。切换交易账户时清零重来。上限最初是 15 秒,后来收紧到 10 秒。数据中心在每次更新港美股持仓时,判断两边的盈亏是否都已收到。
4.6 服务端的限流提示
频率控制的另一半在服务端。证券交易登录的应答里有「请求过于频繁」一类的返回,客户端把它单独认出来,给出「稍候重试」的提示,而不是当成普通的登录失败。这一处是登录请求,不是资金查询,但说明交易网关对请求频率是有约束的,也是客户端要自己收敛请求的原因之一。
5. 踩过的坑
两个查询共用一个标记。 给资金明细加强制通道的那一版里,资金明细的窗口用的是资金信息的标记。结果是:资金信息有一次待发时,资金明细的非强制调用也会被当成「重复」丢掉,反过来也一样。同一天就拆成了两个标记,每种查询各管各的窗口。
一个通知,两种来源。 第 4.4 节的问题本质上是「同一个回调,不知道是谁调的」。修复用的是数据中心上的一个来源标记:发通知前先写标记,回调里读。这能用,是因为写和读都在同一条串行队列上。
提前返回没有清标记。 窗口到期发请求前要先取当前证券账户,取不到就直接返回,这时待发标记还没清掉,之后这个查询的非强制调用都会被丢弃;强制调用不受影响,但也不会清掉这个标记。正常使用里账户总是在的,这条路径很难走到,但它和「单飞标志泄漏」是同一类问题,也写进了第 7 节。
注释和常量不一致。 等待上限从 15 秒改到 10 秒时,注释里还留着「5 秒」「15 秒」的说法。行为以常量为准,注释需要跟着统一。
首次调用要多等 1 秒。 合并窗口是「第一次调用后延迟 1 秒再发」,所以凡是用户在等的场景都必须走强制通道,否则进页面就要多等 1 秒。第 4.3 节的分流表就是为了这件事。
6. 对比与取舍
和项目里其他「合并」放在一起看:
| 做法 | 在哪里 | 解决什么 | 合并的单位 |
|---|---|---|---|
| 合并窗口 + 强制通道 | 证券资金查询 | 一串推送只查一次,用户动作不被吞 | 查询类型 |
| 主推后 1 秒合并再拉取 | 交易侧的行情到持仓中间层 | 成交主推不带持仓与委托,需要再拉一次 | 主推类型 |
| 同一接口单飞 | HTTP 客户端 | 凭证续期、用户信息等不重复请求 | 接口地址 |
| 手动刷新间隔 | 页面基类 | 防止连续下拉 | 页面 |
| 登录防重入闸门 | 交易登录 | 1 秒内不重复登录 | 登录入口 |
| 问题 | 另一种做法 | 采用的做法 | 原因 |
|---|---|---|---|
| 被动触发太多 | 在长连接层统一去重 | 在请求层按查询类型合并 | 长连接没有地址,按查询类型做键更直接 |
| 用户刷新不能被吞 | 窗口改成首次立即发、之后冷却 | 保留延迟发,另开强制通道 | 推送往往成串到达,延迟发能把一串收成一次 |
| 首页要不要轮询 | 保留定时器兜底 | 拿掉首页定时器,靠推送和生命周期事件 | 资金变化有主推,定时器只会制造重复查询 |
| 盈亏没到齐 | 先画再跳 | 等齐再画,设上限 | 总资产先小后大很容易被误读 |
7. 可以改进的地方
下面每一条都是方向,不是已经做完的改动。
| 现在的做法 | 问题 | 改进方向 |
|---|---|---|
| 每个查询各写一份「标记 + 延迟」 | 逻辑散落,容易出现共用标记、漏清标记 | 抽通用合并器:键、窗口、首发或尾发,用 defer 清标记 |
| 来源靠数据中心上的共享标记 | 依赖串行队列,后写覆盖先写 | 把来源作为通知参数传下去,回调按参数分流 |
| 标记只在主线程读写 | 调用方必须先切线程 | 用串行队列或 actor 包住合并器 |
| 等待上限写死在页面里 | 不同网络下体验不同 | 上限可配置,超时后在界面上标注「部分数据」 |
| 没有留下优化前后的次数 | 效果只能定性描述 | 计数从日志改成打点,按页面汇总上报 |