交易资金查询的请求频率控制:先计数,再按触发源分层

项目 trade-app

  • 交易
  • 长连接
  • 请求合并
  • 节流
  • 推送
  • 状态

1. 背景与问题

这是一款港股、美股与期货的一站式交易 App。交易首页同时展示证券和期货账户的总资产、可用购买力、净现金和浮动盈亏。资金类数据不走 HTTP,而是走交易长连接:客户端把查询打包成 Protobuf 发给交易网关,网关再去问后面的柜台,应答回来后写进资金数据中心,由数据中心通知当前页面。

证券资金由两个查询拼起来:

  1. 资金信息:可取资金、追缴保证金、待买入等。
  2. 资金明细:可用购买力、净现金结余、新股冻结资金等,也是总资产的一部分。

问题在于「谁来触发这两个查询」。交易模块做着做着,触发点越来越多:

  • 交易首页有一个定时器,隔几秒查一次;
  • 证券主页里,持仓、委托、成交每变动一次就查一次;
  • 出入金主推到达时查一次;
  • 港股、美股子页、下单页各自也会查;
  • 页面出现、下拉刷新、刷新按钮、交易长连接重连成功、切换交易账户,都会查。

这些触发源彼此不知道对方的存在。用户只是进了一次交易首页,同一个查询却可能在一两秒内被发出好几次。每一次都要经过交易网关和柜台;网关对交易请求本身也有频率限制(交易登录时会收到「请求过于频繁」的提示)。

这篇不讲事故,讲的是一次主动的治理:先把调用次数量出来,再按触发源分层处理。


2. 先计数

第一步不是改逻辑,而是在调试包里给「资金明细查询」加一个调用计数,每发一次就打一行日志。这样在真机上进出交易首页、切 Tab、下拉、等推送,就能直接看到每个动作带来了几次查询、来自哪里。

调试包里的调用计数只在调试编译条件下生效,正式包不受影响。仓库里没有留下优化前后的具体次数,这里也不写数字。

计数之后,触发源可以分成三类:

用户与生命周期下拉 / 刷新按钮页面出现重连成功 / 切换账户推送驱动持仓 / 委托 / 成交变动出入金主推浮动盈亏变化定时交易首页定时器期货主页定时器资金信息 资金明细交易网关

三类的诉求完全不同:

触发源 用户在等吗 能不能晚一点 能不能合并
定时器 不在等 能 能,甚至可以不要
推送驱动 不在等 能晚一两秒 能,一串推送只需查一次
下拉、刷新按钮 在等,有转圈 不能 不能被合并掉
页面出现、重连、切账户 半在等 不宜 要保证一定查到

所以方案不是一个统一的节流,而是按来源走不同的通道。


3. 整体设计

定时器推送驱动用户动作 / 出现 / 重连
到齐没到齐且未超时超时盈亏推送引起的通知
触发源谁触发的?降频,最终取消首页轮询合并窗口 1 秒内只发一次强制通道 立即发出发给交易网关资金数据中心盈亏到齐了吗?刷新界面继续等分流:不占用户刷新

整套做法由四层组成:

  1. 轮询:先降频,再从交易首页拿掉,改为事件驱动。
  2. 请求层:两个资金查询各自一个合并窗口,另开强制通道。
  3. 场景层:交易数据请求管理按页面场景决定查什么、走哪条通道。
  4. 展示层:盈亏推送引起的通知和用户刷新分开处理;港美股盈亏等齐再画,等不到就用现有数据。
刷新
应答写入通知刷新
交易数据请求管理按场景发起查询交易首页 强制证券主页 合并期货主页 / 期货资金详情证券请求资金信息查询 force 可选资金明细查询 force 可选每种查询一个待发标记资金数据中心资金 / 持仓 / 汇率港股与美股盈亏是否到齐是否由盈亏推送引起当前代理交易首页是否需要刷新已等待秒数上次刷新时间

4. 关键实现

4.1 轮询:从 3 秒到 10 秒,再到不轮询

交易首页最早用一个 3 秒的定时器查资金,先改成 10 秒。之后查询时机收进请求管理,用过一版 15 秒的定时器,其中期货的定时器在交易长连接断开时暂停、重连后恢复。再往后,交易首页的定时器整个拿掉了:资金变化本来就有推送,首页只在下面几个时刻主动查:

  • 下拉刷新或点刷新按钮;
  • 页面出现,且距上次刷新已经超过 10 秒;
  • 交易长连接重新建立通讯;
  • 切换交易账户。
降频查询时机收进请求管理拿掉首页定时器下拉 / 出现超过 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
        }
    }
}
交易网关证券请求出入金主推委托变动持仓变动交易网关证券请求出入金主推委托变动持仓变动查资金明细置待发标记,1 秒后发查资金明细已有待发,直接返回查资金明细已有待发,直接返回窗口结束,只发一次应答写入数据中心

窗口和强制通道是配合着用的:给两个资金查询都加了强制通道,把同样的窗口用到资金信息查询上,并让两个查询各用一个待发标记。强制调用既不看也不改待发标记,所以窗口里已经有一次待发时,下拉刷新仍会立即发出;窗口到期的那一次照常发出,两次的应答都写进同一个数据中心。代价是窗口内偶尔会多一次查询,换来的是用户动作永远不被吞。各用一个标记这一点踩过坑,见第 5 节。

待发标记本身只有两个状态:

非强制调用强制调用另外发出,不影响 窗口1 秒后发出并清标记强制调用直接发出
空闲待发

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 包住合并器
等待上限写死在页面里 不同网络下体验不同 上限可配置,超时后在界面上标注「部分数据」
没有留下优化前后的次数 效果只能定性描述 计数从日志改成打点,按页面汇总上报
现在 手写标记 / 共享来源标记 / 主线程串行通用合并器 键 + 窗口 + 一定清标记来源随通知传递actor 串行化调用次数打点上报可复用、可度量的请求治理