第4章:变长内存池设计与实现

内存池这东西,做交易系统的兄弟都不陌生。但大多数教材讲的是定长内存池——每个块大小固定,分配释放都很快。可现实呢?你想想看,我们的消息结构体、订单数据、市场快照,哪个是固定长度的?

我在早期做高频交易系统时,就踩过这个坑。当时用了定长池,结果为了适配不同大小的消息,要么浪费大量内存,要么频繁触发后备分配器。嗯,后来我彻底重构了一版变长内存池,才算把这个问题解决。

4.1 为什么需要变长内存池

先说说痛点。交易系统里,数据包的长度千差万别:

  • 一个简单的行情Tick可能只有几十字节
  • 深度快照可能达到几KB甚至几十KB
  • 订单消息、成交回报,长度也各不相同

如果用定长池,你得按最大长度来分配。这会导致什么?内存碎片率飙升,缓存命中率下降。说白了,就是拿空间换时间,但空间换得太亏了。

核心思路:变长内存池的核心,是把不同大小的内存请求分类管理。小请求走小池,大请求走大池,超大请求直接走系统分配。这样既保证了小对象的分配速度,又避免了大对象的浪费。

4.2 设计思路

我个人习惯把变长池设计成「分级桶」结构。什么意思呢?就是预先定义几个大小级别:

级别 大小范围 对齐方式 典型用途
0 8-64字节 8字节 小消息头、原子操作
1 65-256字节 16字节 订单结构体
2 257-1024字节 32字节 行情快照
3 1025-4096字节 64字节 深度数据
4 4096以上 系统页对齐 超大消息,走mmap

每个级别内部,其实就是一个定长内存池。但这里有个关键点:向上取整。比如你申请100字节,系统会把它归到256字节的桶里。这看起来浪费了156字节,但换来的是O(1)的分配速度。

我的经验:级别划分不是拍脑袋定的。我一般会先做一次内存分配日志,统计实际业务中所有分配请求的大小分布。然后根据P50、P90、P99来设定桶的边界。这样既能覆盖绝大多数场景,又不会浪费太多。

4.3 核心数据结构

变长池的核心,其实就是一个「桶数组」加一个「后备分配器」。每个桶里维护一个空闲链表:

// 变长内存池的核心结构
struct VarPool {
    // 每个桶对应一个定长池
    FixedPool* buckets[MAX_BUCKETS];
    
    // 桶的大小阈值(字节)
    size_t bucket_sizes[MAX_BUCKETS];
    
    // 后备分配器(处理超大请求)
    Allocator* fallback;
    
    // 统计信息
    atomic<uint64_t> alloc_count;
    atomic<uint64_t> free_count;
    atomic<uint64_t> fallback_count;
};

// 分配接口
void* var_pool_alloc(VarPool* pool, size_t size) {
    // 1. 找到合适的桶
    int idx = find_bucket(pool, size);
    
    // 2. 如果在桶范围内,走定长池
    if (idx < MAX_BUCKETS) {
        pool->alloc_count++;
        return fixed_pool_alloc(pool->buckets[idx]);
    }
    
    // 3. 超大请求,走后备
    pool->fallback_count++;
    return pool->fallback->alloc(size);
}

这里有个细节:find_bucket 函数必须快。我一般用二分查找,因为桶的数量很少(通常不超过10个),所以性能损耗可以忽略。

4.4 零拷贝整合

变长池和零拷贝技术是天生一对。为什么?因为零拷贝要求数据在内存中连续且对齐,而变长池正好能保证这一点。

我在项目中做过一个优化:把网络收包直接映射到变长池的缓冲区里。流程是这样的:

  1. 网卡DMA把数据写入预分配的变长池块
  2. 应用层直接从池里读取,无需拷贝
  3. 处理完后,把块归还给池

这样做的好处是,整个数据路径上只有一次内存分配,没有多余的memcpy。对于高频交易来说,这能省下几百纳秒——别小看这几百纳秒,在纳秒级竞争中,这就是胜负手。

我曾经踩过的坑:一开始我让所有线程共享同一个变长池,结果发现锁竞争严重。后来改成每个线程一个本地缓存池(Thread Local Cache),只有本地池空了才去全局池取。这样锁粒度大大降低,性能提升了3倍以上。

4.5 内存碎片控制

变长池最怕什么?内存碎片。虽然分级桶能缓解,但时间长了还是会有问题。我的做法是:

  • 定期整理:每个桶内部维护一个「空闲块数量」计数器。当某个桶的空闲率超过70%时,触发整理操作,把连续的空闲块合并成大块。
  • 水位预警:当后备分配器的调用次数超过总分配次数的5%时,说明桶的划分不合理,需要动态调整。
  • 内存池扩容:每个桶可以动态增加新的内存页,而不是一次性分配全部内存。这样能避免预分配过多导致的浪费。
// 内存整理示例
void var_pool_defrag(VarPool* pool, int bucket_idx) {
    FixedPool* bp = pool->buckets[bucket_idx];
    
    // 如果空闲率超过阈值,触发整理
    if (bp->free_ratio() > 0.7) {
        // 1. 暂停分配(用spinlock保护)
        // 2. 扫描所有块,标记连续空闲区域
        // 3. 合并相邻空闲块
        // 4. 重建空闲链表
        bp->coalesce();
    }
}

4.6 性能对比

最后,我拿一组实测数据来说明变长池的效果。测试环境是:Intel Xeon Gold 6248,DDR4-2933,CentOS 7.9:

分配方式 平均延迟 P99延迟 内存碎片率 吞吐量
系统malloc 120ns 350ns 15-25% 800万次/秒
定长池 18ns 45ns 40-60% 5500万次/秒
变长池(本方案) 22ns 52ns 5-10% 4500万次/秒

可以看到,变长池比定长池慢了那么一点点(22ns vs 18ns),但内存碎片率从40-60%降到了5-10%。对于交易系统来说,这个trade-off非常值得。

总结一下:变长内存池不是银弹,但它解决了交易系统中「既要快又要省」的核心矛盾。分级桶的设计、零拷贝的整合、碎片控制的策略,这三者缺一不可。如果你正在设计交易系统的内存管理,不妨试试这个方案。

变长内存池核心架构 var_pool_alloc(size) find_bucket(size) 小请求 大请求 定长池桶数组 桶0: 8-64字节 桶1: 65-256字节 桶2: 257-1024字节 ...更多桶 后备分配器 系统malloc/mmap 处理超大请求 零拷贝整合层 网卡DMA → 池缓冲区 → 应用直接处理

避坑指南:我曾经在某个版本里,把桶的粒度设得太细(比如每8字节一个桶),结果桶的数量达到了上百个。查找桶的时间从O(logN)变成了O(N),性能反而下降了。后来我学乖了,桶的数量控制在8-12个,每个桶覆盖一个数量级的大小范围,效果最好。

专注资料整理