摘要
RateHub 是一个统一行情基础服务,负责接入 Twelvedata、Finnhub、Binance、Whitezzzs 等上游,为业务系统提供实时价格与多周期 K 线数据。
这个项目经历了两次明显的架构变化:第一版采用 Connect / Share 分离的分布式架构,以 MongoDB 作为共享事实来源;第二版收敛为基于 JDK 21、Netty、内存状态和 MySQL 的单体实时服务。
这次收敛并不是从“先进架构”退回“简单架构”,而是重新匹配数据生命周期、实时链路和当前业务规模。
1. 背景与约束
子公司成立于 2023 年 6 月。我在 8 月到岗时,研发以项目制推进,几乎每个项目都维护一套独立代码。行情接入也随项目复制,每套系统分别连接 Twelvedata、Finnhub 等上游平台。
问题集中在三个约束上:
- 上游连接成本:Twelvedata、Finnhub 的 API Key 对 WebSocket 连接数有限制,一个 Key 通常只能承载 1 到 3 条连接。重复接入导致 Key 成本接近每月 2,000 美元。
- 行情数据的时效性:最新价属于高频变化的短生命周期状态,消费方更关心当前值和推送延迟,而不是每一次变化都被持久化。
- 历史数据的可查询性:K 线需要回溯、补偿和按周期查询,必须具备稳定的持久化模型。
因此,RateHub 的目标不是再建设一套交易业务系统,而是将上游行情接入、实时状态管理和历史 K 线存储收敛为统一的基础能力。
2. 第一版:Connect / Share 分离
2.1 组件与职责
第一版技术栈为 JDK 17、Spring Boot、MyBatis Plus、MongoDB、Kafka、XXL-Job、Redis、Redisson 和 Vue 3。
架构拆分为两个角色:
| 组件 | 主要职责 | 运行方式 |
|---|---|---|
| Connect | 作为 WebSocket 客户端连接上游,接收实时行情并写入 MongoDB | 集群部署 |
| Share | 作为 WebSocket 服务端向下游推送实时行情,通过 HTTP 提供 K 线查询 | 集群部署 |
| XXL-Job | 调度分钟、小时、天、周、月、年等周期的 K 线任务 | 定时任务 |
| Redis / Redisson | 任务协调、分布式锁和短期共享状态 | 共享基础设施 |
当时的核心数据路径是:
上游 WebSocket
│
▼
Connect 集群
│ 每次行情变化写入
▼
MongoDB(共享事实来源)
│
├── Share 读取最新行情
│ └── WebSocket 推送给下游
│
└── K 线聚合任务读取
└── HTTP 查询 / 周期性补偿
实时行情以 WIF-USDC 一类的交易对作为主键。K 线按分钟、小时、天、周、月、年等周期聚合,以时间作为唯一维度,记录开、高、低、收等字段。
2.2 这个设计解决了什么
第一版的设计逻辑是成立的:
- Connect 与 Share 可以独立扩容;
- MongoDB 作为共享数据源,避免实例只依赖本地内存而产生状态差异;
- K 线任务可以通过 Redisson 锁协调,避免集群重复执行;
- 上游连接、下游推送和历史查询之间有明确的职责边界。
问题不在于这些组件“不能用”,而在于数据路径没有区分实时状态和历史事实。
2.3 Kafka 的旁路定位
第一版中的 Kafka 主要用于异步传递非实时核心路径的消息,使用范围比分发链路更分散,但没有成为实时行情报价的广播总线。
典型场景是 WebSocket 连接监控。上游接入服务将连接状态、断线和监控事件写入 Kafka,管理服务异步消费后写入 MongoDB,并向管理端 WebSocket 推送:
上游接入服务
│
▼
Kafka
│
▼
管理服务消费者
├── MongoDB
└── 管理端 WebSocket
这种方式将行情接入服务与管理服务解耦。管理服务暂时不可用时,接入服务仍可以继续接收上游行情,不需要同步调用管理服务,也不会因为管理端的处理延迟直接阻塞上游连接逻辑。
各个接入模块还保留了 Kafka 的消费者配置,用于服务间事件传递、监控状态同步以及部分任务异步化。但实时报价仍然沿着下面的路径处理:
上游 WebSocket
│
▼
接入服务
│
▼
MongoDB
│
▼
管理服务定时查询
│
▼
下游 WebSocket
因此,Kafka 在第一版中承担的是监控事件和服务间通知的旁路职责,并没有改变 MongoDB 作为实时行情共享状态的事实来源。
3. 第一版线上问题:持久化与阻塞调用进入热路径
3.1 行情变化造成读写放大
行情的基本特征是高频、小粒度、持续更新。第一版把每一次上游变化都写入 MongoDB,再由 Share 端持续读取并推送:
一次上游变化
→ 一次数据库写入
→ 一次或多次数据库读取
→ 一个或多个下游连接推送
这条链路把数据库放进了实时热路径。即使某个标的当前没有有效订阅,轮询、聚合和任务扫描仍然可能继续制造 I/O。随着连接和标的数量增加,存储层承担了超过其必要职责的工作。
这里的关键判断是:**最新行情是可恢复的运行时状态,不是必须逐条保存的业务事实。**只要能够重新连接上游,系统就可以重新建立最新状态。
3.2 阻塞型 HTTP 调用放大资源竞争
K 线补偿和查询依赖大量并发 HTTP 请求。当上游响应变慢时,传统线程模型中的平台线程会被阻塞。请求堆积后,连接池、线程池和任务队列开始相互挤压:
上游延迟升高
→ HTTP 请求占用平台线程
→ 线程池排队
→ 任务执行时间拉长
→ 连接池与任务队列继续堆积
→ 平台整体响应变慢
这一版的问题并不是 MongoDB 本身“不适合行情”,也不是同步 HTTP 调用本身不可用,而是实时数据访问模式和 K 线请求并发模型都与当时的资源约束不匹配:前者使数据库成为实时链路的瓶颈,后者使应用线程资源成为并发瓶颈。
4. 第二版:收敛为单体实时服务
第二版选用 JDK 21、Spring Boot、MyBatis Plus、Netty、MySQL、Redis、Kafka 和 Vue 3,并将 Connect 与 Share 的职责收敛到同一个可独立部署的服务中。Kafka 主要用于异步调度和削峰,不进入实时行情报价的主链路。
4.1 按数据生命周期重新分层
第二版首先把数据按生命周期分为三类:
| 数据类型 | 生命周期 | 存储策略 | 原因 |
|---|---|---|---|
| 最新行情 | 短生命周期、可从上游恢复 | JVM 内存 | 减少实时路径上的持久化与读取 |
| K 线历史 | 长生命周期、需要回溯和查询 | MySQL | 支持周期聚合、补偿和稳定查询 |
| 协调状态 | 短期共享、跨实例可见 | Redis | 支持必要的锁、协调和共享状态 |
新的主路径变为:
上游 WebSocket
│
▼
统一连接层
│
├── JVM 内存中的最新行情
│ └── 按交易对聚合订阅并由 Netty 推送
│
└── 周期聚合任务
└── 批量写入 MySQL 作为历史数据
4.2 关键实现取舍
- 统一连接层:上游接入不再随着业务项目复制,连接管理、重连和订阅关系集中处理。
- 内存保存最新行情:实时读写不再经过数据库,避免高频变化造成持久化和查询放大。
- 批量写入 K 线:K 线按周期聚合后批量写入 MySQL,将历史数据从实时链路中分离出来。
- Netty 承担长连接:对外 WebSocket 服务采用事件驱动模型,适合大量长连接和异步推送。
- 按交易对聚合订阅:多个客户端订阅同一标的时,只维护一份上游订阅和一份内存状态,避免重复触发上游请求。
- Kafka 异步调度:K 线请求和周期任务通过 Kafka 排队,再由消费者执行上游查询、缓存、校验和结果推送,避免慢 HTTP 调用直接占用 WebSocket 事件处理链路。
- 虚拟线程承接阻塞调用:JDK 21 的虚拟线程用于处理 Kafka 消费者中的阻塞型 HTTP 调用,降低等待 I/O 时对平台线程的占用。
虚拟线程并不会让慢接口变快。上游调用仍然需要设置超时、限流、熔断、重试退避和连接池隔离。它解决的是线程资源利用率,不是上游服务质量问题。
4.3 Kafka:异步调度与队列治理
Kafka 在第二版中承担的是任务编排和削峰职责,而不是实时行情报价的传输层。实时行情仍然沿着“上游 WebSocket → JVM 内存 → Netty 推送”的路径处理;Kafka 只承接可能阻塞、可以排队或需要并发控制的工作。
主要有三类场景:
- K 线 WebSocket 请求:客户端通过 WebSocket 请求 K 线时,服务端先将请求写入
socket-candlestick-topic,由 Kafka 消费者调用上游接口,完成查询、缓存、连续性校验和结果推送。上游响应变慢时,等待发生在消费者侧,不会直接阻塞 Netty 的事件处理链路。 - 周期性 K 线任务:每分钟 K 线、迷你 K 线和涨幅数据任务由
@Scheduled发布 Kafka 消息,再由对应的@KafkaListener消费。消费者使用 Redis 分布式锁避免重复执行,使用虚拟线程并发请求多个标的,随后将结果写入 Redis 并推送给订阅客户端。 - 队列规模治理:系统定期检查指定主题的消息规模,超过阈值时触发治理策略,避免 K 线请求持续积压、队列无限增长。对于可以安全重试的 K 线请求,必要时可以清理或重建对应主题,再由客户端重新发起请求。这个机制是容量保护措施,不代表可以替代消费者限流、超时和失败重试策略。
因此,Kafka 的引入并没有改变第二版的实时报价主路径,而是把 K 线这类具有等待时间和突发并发特征的工作从长连接处理链路中隔离出来。
5. 为什么选择单体,而不是继续拆分
第二版的单体不是把所有逻辑揉成一个模块,而是把具有强时序关系的实时路径放到同一个部署单元中:
接收行情 → 更新内存状态 → 匹配订阅 → 推送下游
这条链路在当前阶段具备几个特征:
- 状态更新和推送之间存在高频交互;
- 实时状态可以从上游恢复,不要求跨节点共享完整历史;
- 当前容量尚未证明需要独立扩容每一个组件;
- 减少网络跳转和共享存储读写,比预先拆分服务更直接地降低复杂度。
因此,单体是对当前约束的优化,而不是对未来扩展的否定。代码内部仍然按连接管理、行情状态、订阅路由、K 线聚合和历史存储划分边界,为后续拆分保留迁移路径。
6. 架构结论与后续拆分条件
RateHub 的演进可以概括为:
分布式组件复用
→ 共享数据库承载实时热路径
→ 识别短生命周期状态与历史数据的差异
→ 收敛实时链路
→ 保留可独立演进的内部边界
第一版更强调扩展性和共享状态一致性,第二版更强调实时路径上的低延迟、低 I/O 和可控复杂度。两套方案都不是普遍正确的答案,差异来自业务规模、数据生命周期和可用性目标。
两版虽然都使用 Kafka,但它在系统中的位置并不相同:
| 版本 | Kafka 的主要用途 |
|---|---|
| 第一版 | 服务间传递 WebSocket 监控事件、异步同步状态 |
| 第二版 | K 线请求、K 线定时补数和相关任务调度 |
因此,第二版实时链路的缩短,并不是因为把第一版的 Kafka 换成了另一种消息队列,而是因为最新报价从 MongoDB 共享状态改成了 JVM 内存状态,并由上游回调直接触发下游推送。
未来只有在以下条件被真实负载证明之后,才值得拆分:
- 单实例连接数或 JVM 内存成为明确瓶颈;
- 下游推送吞吐需要独立扩容;
- 上游连接管理与业务发布节奏产生隔离需求;
- 可用性目标要求实时状态跨节点冗余;
- 监控数据能够说明拆分后的边界会降低而不是增加系统复杂度。
架构不是提前写完的蓝图,而是一组随着约束变化不断被验证的假设。真正重要的不是“用了多少服务”,而是每一次拆分或收敛,都能回答它解决了哪个瓶颈,以及为什么现在值得付出这份复杂度。