行情系统的分发问题之前是按"所有人看同样的市场"解的——组播天然做广播。自选股和预警把问题反过来:每个用户各看一小撮证券,服务端要在行情到达的瞬间算出"这路行情触发了谁的什么条件",还要扛住开盘瞬间全部预警集中触发的洪峰。这篇讲这个反向分发怎么建。
问题规模与朴素解法的死法
规模感:活跃用户千万级,人均自选 20~50 只(有上限,防止有人导全市场),预警规则人均几条。行情洪峰时每秒几十万条证券变动。朴素做法是每来一条行情遍历所有用户的所有预警——$O(\text{行情数} \times \text{规则数})$,乘出来是天数,直接出局。
倒排一下就行:不按用户找证券,按证券找用户。索引结构是 证券ID → 订阅了它的规则列表,每条规则挂阈值和比较方向。行情到达时,一条证券的变动只查一次索引,遍历的是"关心这只票的规则"——假设某证券平均被 5% 用户关注,匹配成本砍掉两个数量级。
$$ C_{\text{match}} = \sum_{\text{行情}} |\text{rules}(id)| \quad \text{而非} \quad N_{\text{rule}} \times \sum_{\text{行情}} 1 $$
倒排表是内存结构,和行情快照共享同一台服务器的地址空间(行情服务器直接在自己进程里跑预警匹配,读共享内存零拷贝)。规则变更(用户改阈值、加删预警)实时改倒排表——增删都是 O(1) 链表操作,和用户请求路径同步完成,不存在"加载生效延迟"。
触发语义:穿越,而不是满足
"股价高于 10 元就提醒我"——什么叫"高于"?如果开盘就在 10.5 元,要不要提醒?定义不清会出两种事故:不提醒,用户错过(报警失效感);反复提醒,用户被轰炸(通知垃圾化)。正确语义是穿越触发:
$$ \text{trigger} = (p_{\text{prev}} < \tau \land p_{\text{new}} \ge \tau) \;\lor\; (p_{\text{prev}} > \tau \land p_{\text{new}} \le \tau) $$
只在从一侧穿到另一侧的那一笔触发。实现上每条规则要记"当前在哪一侧"(一个比特),行情来时对比迁移方向。这个状态在行情乱序时的处理要小心:快照恢复或乱序重排会改写"上一笔"的参照,触发判定可能重复或漏掉——工程上的简化是接受微小失真(预警本来就是辅助功能),但必须在状态重置(用户改阈值、切数据源)时清空穿越状态,否则旧状态配新阈值会产生幽灵触发。
涨跌幅预警多一步:阈值是相对基准(昨收)的百分比,除权日昨收跳变,所有挂在该证券上的百分比规则基准全变。公司行动事件要主动广播给预警引擎,触发规则重锚定——这又是《K 线生成》里那张公司行动事件表的第二个消费者,事件驱动架构在这里省掉一堆对账麻烦。
触发之后的推送链路
匹配只是第一步,触发后的通知链路按延迟要求分级:
在线推送。APP 在线时长连接(WebSocket/厂商通道),服务端直接推,延迟亚秒级。这是主路径。
离线通知。APP 不在线走 APNs/厂商推送通道。注意行情触发洪峰会在开盘瞬间打爆推送通道的 QPS 限额(涨停潮那天,几万条预警同一秒触发)——必须做聚合:同一用户同一秒的多条触发合并成一条汇总通知("您的 3 只自选股触发预警"),聚合窗口 1~3 秒。用户感知不到 2 秒的合并延迟,推送通道的限额可是实打实的钱。
触达与重复抑制。规则触发后要记"已触发未复位"状态,避免价格围绕阈值震荡时每笔都通知。常见做法是触发后进入冷却期(如当日不再触发同方向),或要求反向穿越复位后才可再触发。冷却期的长短是产品决策,但系统要提供两种语义供配置。
放在链路哪里:不挡主路
最后一个架构问题:预警引擎在行情链路的什么位置。答案是挂在行情服务器的快照区旁边,而不是塞进分发主路径。理由:匹配需要读全量快照(涨跌幅预警要昨收、换手率预警要累计量),天然和快照同址;匹配失败或预警引擎过载,绝不能拖慢行情分发——主路(组播、代理)不依赖预警,预警进程崩了重启扫一遍快照和规则表就能恢复(穿越状态可以从规则表重建,代价是重启瞬间的少量重复触发,可接受)。
这条"旁路挂接、失败隔离"的原则和交易链路的事前风控放异步类似:辅助功能永远不能把主功能拖死。