4. 内存缓冲数据结构选型
逐笔数据缓冲的核心矛盾只有一个:生产速率与消费速率不匹配。行情推送在开盘、涨停板打开、指数调仓等时刻会出现毫秒级突发,单路逐笔委托峰值可达每秒数万条;而落盘消费端受磁盘 IOPS、批量提交事务、序列化开销限制,稳定吞吐往往只有生产峰值的一半甚至更低。缓冲结构的作用不是"存数据",而是在速率差之间提供一段可控的弹性空间,并决定这段空间被填满时的行为。选型本质上是在吞吐、延迟、内存占用、实现复杂度、溢出策略五个维度上做取舍。
四类结构的本质差异
环形队列用固定长度数组加头尾指针实现,写入即覆盖或写入即拒绝,内存一次分配、无动态扩容、无 GC 压力。它的优势是确定性极强:容量固定、访问 O(1)、缓存友好;劣势是容量必须提前估算,溢出策略需要显式设计(丢弃最旧、丢弃最新或阻塞)。适合单生产者单消费者、速率相对平稳、可容忍少量丢弃的场景。
阻塞队列在队列满时让生产者等待、队列空时让消费者等待,天然实现背压。Java 的 ArrayBlockingQueue、C++ 的 BlockingQueue 都属于此类。它保证不丢数据,但代价是把压力反向传导给行情接收线程——一旦落盘变慢,接收线程被阻塞,TCP 接收窗口收缩,严重时触发交易所断线。因此阻塞队列适合消费能力可预期且必须零丢失的场景,且必须配合超时等待与降级策略。
无锁队列基于 CAS 或原子指针实现多生产者多消费者并发入队出队,典型如 Disruptor 的 RingBuffer、Michael-Scott 队列。它避免了锁竞争带来的线程切换,在核数较多的机器上吞吐显著优于加锁队列。但无锁队列对内存序、伪共享、ABA 问题敏感,实现和调试成本高,且"无锁"不等于"无等待",高竞争下 CAS 自旋仍会消耗 CPU。适合多路行情汇聚、多线程消费的高吞吐场景。
分段缓冲把大缓冲切分为多个固定大小的段(Segment),每段独立管理生命周期,写入填满一段后切换到下一段,消费端按段批量读取、整段释放。它把"逐条同步"变成"按段批量",显著降低锁粒度与落盘次数,同时便于实现内存池复用。适合批量落盘、需要控制单次写盘大小的场景,但段大小与段数量的配置直接影响延迟与内存占用。
用速率与消费能力做定量判断
选型不能凭感觉,需要先把速率量化。设生产峰值速率为 Rp(条/秒),消费稳定速率为 Rc(条/秒),突发持续时间为 T(秒),则缓冲至少需要容纳的条数为:
最小缓冲容量 N_min = (Rp - Rc) × T × 安全系数 k
示例(假设参数):
Rp = 50000 条/秒,Rc = 20000 条/秒,T = 2 秒,k = 1.5
N_min = (50000 - 20000) × 2 × 1.5 = 90000 条
单条逐笔约 120 字节 → 约 10.8 MB
这个计算给出的是"不丢数据所需的最小容量"。如果实际可用内存远大于此值,环形队列即可胜任;如果内存受限或突发时间不可预测,就必须考虑溢出策略。反过来,若 Rc 长期低于 Rp,任何有限缓冲最终都会满,此时问题不在缓冲结构,而在消费端扩容或降级采样。
组合使用才是工程常态
真实系统很少只用一种结构。一个常见的逐笔落盘链路是:多路行情接入线程通过无锁队列汇聚到统一缓冲,避免多线程锁竞争;落盘线程从无锁队列批量取出,写入分段缓冲,每段积累到 4096 条或 50 毫秒触发一次批量写盘,写完整段释放回内存池。这样既获得了无锁队列的高并发写入能力,又通过分段把随机小写变成顺序大写,磁盘利用率明显提升。
需要特别注意的是阻塞队列的背压风险。若在行情接收线程直接使用阻塞队列且不设超时,落盘一旦抖动,接收线程被挂起,socket 缓冲区填满后交易所侧会判定客户端不活跃。正确做法是使用 offer(timeout),超时后记录丢弃计数并降级为采样写入,同时上报告警,把"静默卡死"变成"可观测的降级"。
选型对照表
| 结构 | 吞吐 | 延迟确定性 | 内存占用 | 溢出行为 | 典型适用条件 |
|---|---|---|---|---|---|
| 环形队列 | 高 | 高 | 固定、可控 | 覆盖或拒绝 | 单产单消、可容忍少量丢弃 |
| 阻塞队列 | 中 | 低(受消费端影响) | 固定 | 背压阻塞 | 零丢失、消费能力可预期 |
| 无锁队列 | 很高 | 中 | 固定 | 需自行设计 | 多路汇聚、多核环境 |
| 分段缓冲 | 高(批量) | 中(段级延迟) | 段数×段大小 | 段满切换/等待 | 批量落盘、内存池复用 |
最终判断可以归结为三个问题:数据能不能丢?生产端是否多线程?落盘是否批量?三个答案基本就能锁定结构或结构组合。把容量计算、溢出策略和监控指标(队列深度、丢弃计数、单次写盘耗时)一并设计进去,缓冲才真正成为速率差的稳定器,而不是故障的隐藏点。