从段错误到 2300万OPS:我如何为KV存储重构内存池

张开发
2026/4/9 8:37:22 15 分钟阅读

分享文章

从段错误到 2300万OPS:我如何为KV存储重构内存池
从段错误到 2300万OPS我如何为KV存储重构内存池项目Kedis - 一个高性能KV存储系统作者bitborne在维护一个C语言KV存储项目时我发现旧内存池存在严重性能问题。经过架构重构实现了多尺寸类TLS缓存的新内存池单线程性能提升2.8倍多线程提升6倍内存碎片率从65%降至18%。一、导火索一个诡异的段错误那是一个普通的周二下午。我正在给Kedis我们组的KV存储项目跑压力测试突然终端跳出几个刺眼的红色字符Segmentation fault (core dumped)翻看旧代码我发现内存池的实现是这样的// 旧内存池单尺寸、单Chunk、单锁typedefstructmemory_pool{void*chunk;// 只有一个256MB的chunkmem_block_t*free_list;// 全局空闲链表pthread_mutex_tlock;// 一把大锁size_tblock_size;// 固定256B}memory_pool_t;四个致命问题浮出水面问题影响单尺寸类24B的hash节点也分配256B90%内存被浪费容量封顶256MB用完就只能回退malloc失去内存池意义全局锁8线程并发时锁竞争严重CPU空转无NUMA感知跨CPU访问内存Cache Line频繁失效当时我面临两个选择方案A修修补补加个边界检查继续用方案B重构整个内存池架构我选择了方案B。好的工程师不是修bug的人而是让bug无法产生的人。二、旧架构剖析为什么慢2.1 内存碎片隐形杀手Kedis支持多种数据结构Hash表、红黑树、跳表。它们分配的内存大小差异巨大对象类型 实际大小 旧池分配 碎片率 ───────────────────────────────────────────── hashnode_t 24B 256B 90.6% 短key 16B 256B 93.8% 长value 512B 256B×2 50% conn结构体 ~200B 256B 21.9%平均碎片率高达65%这意味着你申请了100MB内存实际只用了35MB。2.2 锁竞争并发杀手压力测试时的火焰图显示34% pthread_mutex_lock ← 所有线程都在等这把锁 │ ├─12% mem_pool_alloc └─22% mem_pool_free阿姆达尔定律告诉我们即使锁内操作只占10%如果有8个线程竞争整体加速比也会被限制在5倍以内。2.3 扩展性瓶颈// 旧代码chunk耗尽后的处理if(!pool-free_list){returnmalloc(pool-block_size);// 回退到malloc}一旦超过256MB新分配就不受内存池管理了碎片化更加严重。三、新架构设计 kmem 诞生3.1 核心思想分层解耦参考了jemalloc和tcmalloc的设计我提出了三层架构┌─────────────────────────────────────────┐ │ Thread Local Cache Layer (无锁) │ │ 每线程64块缓存99%操作无锁 │ └─────────────────┬───────────────────────┘ │ ┌─────────────────▼───────────────────────┐ │ Size Class Layer (细粒度锁) │ │ 6种尺寸64B/128B/256B/512B/1KB/2KB │ │ 每尺寸独立锁锁竞争降低6倍 │ └─────────────────┬───────────────────────┘ │ ┌─────────────────▼───────────────────────┐ │ Chunk Management Layer (mmap/malloc) │ │ 动态扩展按需分配无容量上限 │ └─────────────────────────────────────────┘3.2 关键设计决策决策1为什么选6种尺寸经过对Kedis内存分配的统计尺寸范围 分配占比 对应尺寸类 ────────────────────────────────── 1-64B 23% 64B 65-128B 31% 128B 129-256B 28% 256B 257-512B 12% 512B 513-1024B 4% 1KB 1025-2048B 2% 2KB 2048B 少量 直接malloc这6档可以覆盖98%的分配请求内部碎片控制在20%以内。决策2TLS批量大小为什么是32不是拍脑袋定的。假设单次锁操作耗时1μs批量32块可以摊销到0.031μs/块。但批量太大如256会导致线程退出时归还延迟高单个线程占用过多资源32是经过测试的帕累托最优。决策3mmap vs malloc// 自适应策略if(chunk_size4*1024*1024){memorymmap(...);// 大块用mmap避免内存碎片}else{memorymalloc(...);// 小块用malloc减少TLB miss}四、核心代码实现4.1 尺寸类路由分支预测优化// 不是用if-else链而是用查找表staticconstsize_tclass_sizes[6]{64,128,256,512,1024,2048};intkmem_size_class(size_tsize){// 快速路径常见大小if(size64)return0;if(size128)return1;if(size256)return2;if(size512)return3;if(size1024)return4;if(size2048)return5;return-1;// 大块}为什么这样写CPU分支预测对有序比较更友好避免了除法和位运算虽然clz指令更快但可移植性差4.2 TLS批量获取锁优化精髓void*kmem_alloc_fast(size_tsize){intclskmem_size_class(size);// 1. 先查TLS缓存无锁if(tls.cache_count[cls]0){returnpop_from_tls(cls);}// 2. 缓存空批量从全局池获取仅一次加锁void*batch[32];intgotkmem_slab_alloc_batch(cls,batch,32);// 3. 一个给当前请求其余放TLStls.cache[cls]batch[1];// 剩余31个tls.cache_count[cls]got-1;returnbatch[0];}关键点一次加锁获取32块后续31次分配都是无锁的4.3 Chunk动态切分内存对齐staticintkmem_slab_grow(kmem_slab_t*slab){// 分配对齐的内存通常是4KB页对齐void*memorymmap(NULL,slab-chunk_size,PROT_READ|PROT_WRITE,MAP_PRIVATE|MAP_ANONYMOUS,-1,0);// 切分策略每个块前放元数据头char*ptrmemory;for(inti0;iblocks_per_chunk;i){kmem_block_hdr_t*hdr(kmem_block_hdr_t*)ptr;hdr-size_classslab-class_idx;hdr-magicKMEM_MAGIC_FREED;// 用户可用内存从hdr后开始kmem_free_node_t*node(kmem_free_node_t*)(hdr1);push_to_freelist(node);ptrsizeof(kmem_block_hdr_t)slab-block_size;}}为什么元数据要内嵌避免额外哈希表查询O(1)直接定位缓存友好hdr和data在同一Cache Line五、性能验证数据说话5.1 测试环境CPU: Intel i7-12700 (8P4E cores) RAM: 32GB DDR4-3200 GCC: 11.3.0 with -O2 OS: Ubuntu 22.04 LTS5.2 基准测试结果单线程测试100万次alloc/free方案OPSvs 旧池碎片率malloc/free200M25x-旧内存池8M1x65%kmem_alloc23M2.8x18%kmem_fast23M2.8x18%注malloc在单线程下极快有线程缓存但多线程会暴跌。多线程测试8线程各100万次方案OPS锁竞争占比旧内存池3M67%kmem_alloc18M12%kmem_fast35M1%6倍提升这才是新架构的真正优势。5.3 内存碎片对比压力测试运行10分钟后旧内存池 申请内存1.2GB 实际使用420MB 碎片率65% kmem 申请内存520MB 实际使用426MB 碎片率18% 节省内存约680MB (57%)六、工程踩坑实录坑1pthread_exit不调用TLS析构现象压力测试后内存占用不下降。原因线程退出时__thread变量不会自动归还缓存块。解决显式注册线程退出hookvoidworker_thread(void*arg){kmem_tls_init();// 初始化TLS// ... 工作逻辑kmem_tls_destroy();// 必须显式调用pthread_exit(NULL);}坑2魔数校验的性能陷阱最初代码voidkmem_free(void*ptr){kmem_block_hdr_t*hdrptr-sizeof(kmem_block_hdr_t);assert(hdr-magicKMEM_MAGIC_ALLOCATED);// debug模式检查// ...}问题assert在Release模式下消失但生产环境需要轻量校验。解决条件编译 概率采样#ifdefKMEM_DEBUG#defineKMEM_CHECK_MAGIC(h)assert(h-magicKMEM_MAGIC_ALLOCATED)#else#defineKMEM_CHECK_MAGIC(h)((void)0)#endif坑3NUMA陷阱预留接口虽然kmem架构支持NUMA感知每个CPU绑定本地Chunk但项目时间有限未实现。留下了扩展点// TODO: NUMA-aware allocation#ifdefKMEM_NUMAintnodenuma_node_of_cpu(sched_getcpu());returnkmem_numa_alloc(slab,node);#endif参考资料jemalloc: A Scalable Concurrent Memory Allocatortcmalloc: Thread-Caching Malloc《深入理解计算机系统》第9章虚拟内存《性能之巅》第7章内存分析https://github.com/0voice

更多文章