核心数据结构设计:内存中的Tick存储结构

做Tick级存储,说白了就是跟时间赛跑。

我刚开始做这个项目时,第一反应是:直接用个数组存Tick数据不就行了?后来发现,嗯,太天真了。金融数据的Tick,每秒可能产生几千条,每条都包含时间戳、价格、成交量。如果每条都new一个对象,GC能把你的应用卡成PPT。

所以,核心问题就三个:存得快、查得快、内存省

Ring Buffer:无锁的Tick存储结构

Ring Buffer,也叫环形缓冲区。它本质上就是一个固定大小的数组,加上两个指针——读指针和写指针。

为什么选它?因为Tick数据是典型的流式数据,只追加,不修改。Ring Buffer天然支持这种模式。

核心设计要点:

  • 预分配固定大小的数组,避免动态扩容
  • 写指针永远向前移动,覆盖旧数据
  • 读指针追赶写指针,实现无锁读取

我在项目中遇到过一个问题:当Ring Buffer写满时,是阻塞等待还是直接覆盖?对于Tick数据,我建议直接覆盖。因为历史Tick数据如果没被消费,说明消费者已经跟不上了,这时候保留最新数据比保留旧数据更有意义。

// 伪代码示例:Ring Buffer核心结构
struct RingBuffer {
    TickData* buffer;    // 预分配数组
    uint64_t writeIndex; // 写指针(原子变量)
    uint64_t readIndex;  // 读指针(原子变量)
    uint64_t capacity;   // 容量,必须是2的幂
};

// 写入操作
void push(TickData* tick) {
    uint64_t pos = writeIndex & (capacity - 1);
    buffer[pos] = *tick;  // 直接覆盖
    writeIndex++;         // 原子递增
}

// 读取操作
bool pop(TickData* out) {
    if (readIndex == writeIndex) return false;
    uint64_t pos = readIndex & (capacity - 1);
    *out = buffer[pos];
    readIndex++;
    return true;
}

你想想看,这里用capacity - 1做位运算取模,比直接用%快了一个数量级。这就是为什么我要求容量必须是2的幂。

避坑指南:我曾经在Ring Buffer里直接存了指针,结果消费者读的时候数据已经被覆盖了。后来改成存值拷贝,虽然多了一次内存拷贝,但保证了数据一致性。记住:共享内存里别存指针

时间序列索引设计

Ring Buffer解决了存储问题,但查询呢?

比如你要查某个时间点的Tick数据,总不能遍历整个Ring Buffer吧?所以我们需要一个索引。

我设计的索引结构很简单:跳表(Skip List)。为什么不用B+树?因为Tick数据是内存中的,跳表在内存场景下比B+树更友好——实现简单、无锁化容易、范围查询也不差。

// 时间序列索引结构
struct TimeIndex {
    // 跳表节点
    struct Node {
        int64_t timestamp;  // 时间戳,纳秒精度
        uint64_t offset;    // Ring Buffer中的位置
        Node* next[16];     // 层级指针
    };
    
    Node* head;            // 头节点
    int maxLevel;          // 当前最大层级
};

每个Tick写入时,我们把它的时间戳和Ring Buffer中的位置插入跳表。查询时,通过跳表快速定位到目标时间戳附近,然后线性扫描。

这里有个细节:时间戳用纳秒精度。为什么?因为Tick数据在毫秒内可能有多个,纳秒才能区分先后顺序。我见过有人用毫秒做索引,结果同一毫秒内的Tick顺序全乱了。

注意:跳表的层级不要设太高。16层对于百万级Tick数据已经足够。层级太高反而浪费内存,而且查询性能提升有限。

数据压缩策略:Delta-of-Delta

说到压缩,很多人第一反应是:用Snappy或者LZ4。但那是通用压缩,对于时间序列数据,我们有更优雅的方案——Delta-of-Delta

这个思路来自Facebook的Gorilla论文。核心思想是:时间戳之间的差值,其差值往往很小

举个例子:

原始时间戳(纳秒):
1000000000, 1000000100, 1000000200, 1000000300

Delta(差值):
100, 100, 100

Delta-of-Delta:
0, 0  // 因为差值都是100,所以二次差值为0

你看,原始数据是4个64位整数,共256位。经过Delta-of-Delta后,只需要存储第一个时间戳(64位)、第一个Delta(64位),然后后面全是0。0可以用1个bit表示。压缩比惊人。

我在项目中实际测试过:原始Tick数据约100MB,用Delta-of-Delta压缩后不到10MB。而且压缩和解压速度极快,因为只涉及整数加减法,没有复杂的哈希表或字典。

// Delta-of-Delta编码示例
void encodeTimestamps(int64_t* timestamps, int count, BitWriter* writer) {
    // 写入第一个时间戳
    writer->write(timestamps[0], 64);
    
    // 写入第一个Delta
    int64_t prevDelta = timestamps[1] - timestamps[0];
    writer->write(prevDelta, 64);
    
    // 后续使用Delta-of-Delta
    for (int i = 2; i < count; i++) {
        int64_t delta = timestamps[i] - timestamps[i-1];
        int64_t deltaOfDelta = delta - prevDelta;
        
        if (deltaOfDelta == 0) {
            writer->write(0, 1);  // 1个bit表示0
        } else if (deltaOfDelta >= -63 && deltaOfDelta <= 64) {
            writer->write(1, 1);  // 标志位
            writer->write(deltaOfDelta + 63, 7);  // 7个bit存值
        } else {
            writer->write(1, 1);  // 标志位
            writer->write(1, 1);  // 表示使用完整64位
            writer->write(deltaOfDelta, 64);
        }
        
        prevDelta = delta;
    }
}

经验之谈:Delta-of-Delta对乱序数据不友好。如果Tick数据的时间戳不是严格递增的,压缩率会下降。我建议在写入前先做一次排序,或者使用更鲁棒的压缩方案(比如差值+游程编码)。

内存池与对象复用

最后一个问题:对象创建和销毁的开销。

每条Tick数据都是一个对象,如果每条都new/delete,内存分配器会疯掉。而且频繁的GC停顿对于Tick级系统是致命的。

解决方案:内存池

我设计的内存池很简单:预分配一大块连续内存,切成固定大小的块。每次需要Tick对象时,从池里取一个空闲块;用完了还回去。

// 内存池核心结构
struct MemoryPool {
    char* memory;           // 预分配的内存块
    uint32_t blockSize;     // 每个块的大小
    uint32_t blockCount;    // 块的数量
    uint32_t freeList;      // 空闲链表头(存储空闲块的索引)
    
    void* allocate() {
        if (freeList == INVALID_INDEX) return nullptr;
        uint32_t index = freeList;
        freeList = *(uint32_t*)(memory + index * blockSize);
        return memory + index * blockSize;
    }
    
    void deallocate(void* ptr) {
        uint32_t index = ((char*)ptr - memory) / blockSize;
        *(uint32_t*)ptr = freeList;
        freeList = index;
    }
};

这里有个巧妙的设计:空闲链表直接利用内存块本身的空间。每个空闲块的前4个字节存放下一个空闲块的索引。这样不需要额外的链表节点,零内存开销。

注意:内存池的块大小要统一。如果Tick对象大小不一,可以用多个不同大小的内存池。我曾经图省事用了一个大小,结果小对象浪费空间,大对象放不下。后来改成按对象大小分池,问题解决。

整体架构图

下面这张图展示了本章的核心数据结构之间的关系:

Tick级存储核心数据结构 Tick数据流 Ring Buffer(环形缓冲区) 固定大小数组 + 读写指针 跳表索引 时间戳→位置 Delta-of-Delta压缩 时间戳差值二次差分编码 内存池(对象复用) 预分配 + 空闲链表 核心设计原则 1. 无锁写入 2. 预分配内存 3. 零GC停顿 4. 高压缩比 5. 对象复用 6. 位运算优化 7. 原子操作 8. 内存对齐

从图中可以看到,Tick数据流先进入Ring Buffer,同时建立跳表索引。数据在持久化前经过Delta-of-Delta压缩,而所有对象的生命周期都由内存池管理。这四个组件环环相扣,缺一不可。

总结一下本章的核心要点:

  • Ring Buffer解决写入性能问题,无锁设计是关键
  • 跳表索引解决查询性能问题,时间戳精度要到纳秒
  • Delta-of-Delta解决存储空间问题,压缩比可达10:1
  • 内存池解决内存碎片问题,零GC是Tick系统的生命线

嗯,这些就是Tick级存储中间件的核心数据结构。下一章我们会深入实现细节,看看这些结构如何协同工作。记住:设计越简单,运行越可靠


公众号:蓝海资料掘金营,微信deep3321