9.1 kv存储内存池模拟面试问题

一、总体架构

面试官:

先说一下你这个项目里的内存池整体架构是什么?别只说"有个池子",要讲清楚层次和设计目标。

应聘者:

这个项目里的内存池是一个"全局分桶 + 空闲链表 + 批量扩容 "的设计,核心目标是减少频繁 malloc/free 的系统开销,尤其是 key/value、节点、响应缓冲区这些小对象的频繁申请释放。

整体上有三层:

cpp 复制代码
kvs_malloc / kvs_free
        ↓
mem_pool_alloc / mem_pool_free
        ↓
free_lists[256] + chunks + 统计信息

其中:

  • kvs_malloc() 是统一入口
  • kvs_free() 是统一回收入口
  • 真正的池子逻辑在 src/mempool.c
  • 对外接口在 include/mempool.h
  • src/kvstore.c 里通过 #if MEM 切换到内存池或系统 malloc

我这个实现不是自己造一个完整的通用 allocator,而是针对项目里的典型对象做了一个简化版内存池,优先服务小对象和高频对象。

二、内存块怎么设计的

面试官:

内存池里的"块"是怎么设计的?用户拿到的内存长什么样?

应聘者:

每个分配出来的小块,前面都带一个头部 mem_block_t,然后后面才是用户真正可用的内存。

结构是这样的:

cpp 复制代码
[mem_block_t 头部][用户可用内存]

mem_block_t 里主要记录:

  • next:空闲链表指针
  • size:块大小
  • magic:校验标记,防止误释放
  • kind:小块还是大块
  • class_idx:属于哪个桶
  • reserved:预留字段

这样设计的好处是:

  1. free 时能从用户指针倒推出块头
  2. 能区分这块内存是池子分配的还是系统大块
  3. 能快速知道应该挂回哪个空闲链表

三、为什么要分桶

面试官:

为什么要搞 16 字节对齐和 256 个桶?

应聘者:

因为项目里的对象大小分布比较离散,但常见请求大多还是小对象。

我这里采用的是:

  • 16 字节对齐
  • 16 ~ 4096 共 256 个规格

比如:

  • 16B -> bucket 0
  • 32B -> bucket 1
  • 48B -> bucket 2
  • ...
  • 4096B -> bucket 255

这样做是为了:

  • 减少外部碎片
  • 简化分配逻辑
  • 提高复用率
  • 避免每次都找"最合适的块"造成复杂度上升

这更像一个"固定粒度 slab"思路,而不是精细化的 buddy allocator。


四、实际怎么运行

面试官:

具体说说运行流程。比如申请一个 32 字节的小块,整个过程是怎样的?

应聘者:

以 32 字节为例,流程是:

cpp 复制代码
kvs_malloc(24)
  ↓
mem_pool_alloc(24)
  ↓
向上对齐到 32
  ↓
算出 idx = 1
  ↓
看 free_lists[1] 有没有空闲块
  ↓
如果没有,mem_pool_grow(1, 32)
  ↓
一次申请 64 个 32B 块
  ↓
切块后头插进 free_lists[1]
  ↓
从链表头取一个块返回给用户

所以用户最后拿到的是:

cpp 复制代码
[block头部后面的那一段用户空间]

不是整个块头。

释放的时候则反过来:

cpp 复制代码
用户指针 ptr
  ↓
倒推一个 mem_block_t
  ↓
检查 magic
  ↓
如果是小块,头插回对应 free_list

五、mem_pool_grow() 在干什么

面试官:

批量扩容函数 mem_pool_grow() 的逻辑你讲一下,尤其是头插法为什么这么做。

应聘者:

mem_pool_grow() 的职责是:当某个桶空了,就一次性向系统申请一大块连续内存,然后切成多个小块,挂到对应的空闲链表里。

它的关键步骤是:

  1. 计算单块大小:

    cpp 复制代码
    one_block_size = sizeof(mem_block_t) + block_size;
  2. 申请一批:

    cpp 复制代码
    total_size = one_block_size * MEMPOOL_BATCH_COUNT;
  3. 申请一个 mem_chunk_t 记录这次大申请

  4. 申请真正的大内存 memory

  5. memory 切成 64 个小块

  6. 每个小块都头插进 g_pool.free_lists[idx]

头插法就是:

cpp 复制代码
block->next = g_pool.free_lists[idx];
g_pool.free_lists[idx] = block;

它的特点是:

  • O(1) 插入
  • 不用找尾巴
  • 代码简单
  • 分配时也是从头取,天然匹配

六、kvs_malloc / kvs_free 的意义

面试官:

你为什么还要包一层 kvs_mallockvs_free?直接用 mem_pool_alloc 不行吗?

应聘者:

包这一层的价值是统一入口和可切换性。

现在在 src/kvstore.c 里:

cpp 复制代码
void *kvs_malloc(size_t size) {
#if MEM
    return mem_pool_alloc(size);
#else
    return malloc(size);
#endif
}
cpp 复制代码
void kvs_free(void *ptr) {
#if MEM
    mem_pool_free(ptr);
#else
    free(ptr);
#endif
}

这样做的好处:

  1. 业务代码不用关心底层是不是内存池
  2. 可以通过宏一键切换
  3. 方便调试和对比性能
  4. 持久化、网络、数据结构都能统一使用

也就是说,内存池不是独立给某一个模块服务的,而是整个服务端统一的内存入口。

七、哪些地方在用它

面试官:

这个池子具体服务了哪些地方?只是 key/value 吗?

应聘者:

不是。它服务的是整个项目里所有通过 kvs_malloc/kvs_free 走的路径。

典型包括:

  • array 里的 key/value
  • hash 的节点、key/value
  • rbtree 的节点、key/value
  • skiplist 的节点、key/value/forward
  • 网络响应缓冲区
  • 持久化临时 buffer
  • 读写命令时的请求副本

也就是说,池子不是只管数据存储本体,而是覆盖了整个服务运行时。


八、为什么要有 magic

面试官:

magic 字段是干什么的?这不是多此一举吗?

应聘者:

不是多此一举,它是一个轻量级的安全校验。

mem_pool_free() 里会先检查:

cpp 复制代码
if (block->magic != MEMPOOL_MAGIC) return;

作用是:

  1. 防止错误指针被误释放
  2. 防止普通 malloc 的地址被当成池子块处理
  3. 在调试阶段更容易定位问题

尤其是这种项目里,数据结构经常嵌套分配,magic 能帮你早点发现"对象生命周期乱了"的 bug。


九、它有哪些局限

面试官:

这个内存池现在有什么不足?

应聘者:

有几个明显局限:

  1. 不是线程安全的

    现在是全局池,没有加锁,多线程下需要保护 free_list 和统计信息。

  2. 大对象直接走系统 malloc

    这不是 bug,是设计选择,但如果超大正文很多,池子收益会下降。

  3. 没有真正的内存回收策略

    chunks 只负责生命周期结束时统一释放,不会在运行中主动缩回去。

  4. 没有按业务对象定制尺寸

    现在是通用 16B 粒度,比较朴素,后面可以做热点 size class 优化。

  5. 没有页级整理/碎片压缩

    还没有 Redis 那种 active defrag 思路。

十、和 Redis 的差别

面试官:

你这个和 Redis 比,有什么差别?

应聘者:

Redis 不主要靠自己写的"通用内存池",而是更依赖底层 allocator,比如 jemalloc,同时在对象层做压缩:

  • 小字符串可能走 embstr
  • 整数直接走 int
  • 紧凑集合编码减少分配
  • 还会做主动碎片整理

我这个项目更像是:

  • 自己实现一个小型通用内存池
  • 统一承接服务里大部分小对象
  • 对象层还没做到 Redis 那么激进的压缩

所以我这版更适合学习 allocator 原理,Redis 更适合学习工程级内存管理策略。


十一、一个具体例子

面试官:

你举个完整例子,说清楚一个 key/value 从申请到释放,再到保存,是怎么走的。

应聘者:

比如执行:

cpp 复制代码
SET name Tom

写入时

cpp 复制代码
协议解析
  ↓
kvs_array_set / kvs_hash_set / kvs_rbtree_set / kvs_skiplist_set
  ↓
kvs_malloc(key)
  ↓
kvs_malloc(value)
  ↓
对象进入容器

删除时

cpp 复制代码
DEL name
  ↓
查找对应对象
  ↓
kvs_free(key)
  ↓
kvs_free(value)
  ↓
kvs_free(node or table slot)

保存时

cpp 复制代码
SAVE
  ↓
persist_save_all
  ↓
遍历所有引擎
  ↓
把 key/value 序列化进临时 buffer
  ↓
io_uring 写盘
  ↓
rename 成正式文件

注意:

SAVE 不会释放业务对象,它只是把内存里的内容"拍成照片"写到文件里。

十二、怎么总结这个项目的内存池

面试官:

最后一句话总结一下你的内存池。

应聘者:

这是一个面向 KV 服务的轻量级分桶内存池,采用 16B 粒度、256 个大小类、批量扩容、头插空闲链表和块头标记的设计,统一通过 kvs_malloc/kvs_free 接入业务层,主要优化频繁小对象申请释放的场景,同时为持久化、网络和容器对象提供统一的内存管理入口。

追问式面试

1. 面试官:为什么 mem_pool_free() 不能直接 free(ptr)

应聘者:

因为 ptr 不是块头地址,而是用户拿到的地址。

在 9.1-kvstore\\src\\mempool.c里,真正释放时要先:

cpp 复制代码
mem_block_t *block = ((mem_block_t *)ptr) - 1;

也就是说,要先倒推回头部,再判断它是不是池子分配出来的。

如果直接 free(ptr)

  • 对小块来说,ptr 根本不是系统 malloc 返回的原始地址
  • 会导致非法释放
  • 也破坏了池子的空闲链表管理

2. 面试官:为什么要有 magic 字段?

应聘者:

magic 是防误判的保护。

mem_pool_free() 里会检查:

cpp 复制代码
if (block->magic != MEMPOOL_MAGIC) return;

作用是:

  • 防止把非池子内存误当成池子块
  • 防止双重释放或脏指针误挂回链表
  • 调试时能更快定位问题

这个字段不增加太多成本,但能显著提高健壮性。


3. 面试官:为什么要有 cached_count,还要遍历链表统计一次?

应聘者:

cached_count 是运行时维护的计数,遍历链表是校验手段。

在 9.1-kvstore\\src\\mempool.c的 mem_pool_dump() 里,实际上做了两套统计:

  • 平时增减维护的 g_pool.cached_count
  • 现场遍历 free_lists[i] 算出来的 total_cached

这样可以检查:

  • 计数逻辑有没有漏加、漏减
  • 链表有没有被破坏
  • 某个地方是不是误释放了

换句话说,cached_count 是"平时记账",遍历是"审计"。


4. 面试官:为什么 mem_pool_grow() 一次申请 64 个小块?这个数字怎么来的?

应聘者:

64 是一个经验值,不是标准答案。

在 9.1-kvstore\\src\\mempool.c 里:

cpp 复制代码
#define MEMPOOL_BATCH_COUNT 64u

它的目的就是平衡两件事:

  • 批量太小:系统 malloc 调用太频繁
  • 批量太大:一次性占太多内存,浪费会更明显

64 是一个比较保守、好理解、适合学习阶段的值。

如果后面压测发现某些桶特别热,可以把它改成"按桶自适应"。


5. 面试官:为什么要对齐到 16 字节?

应聘者:

因为 16 字节对齐能兼顾三件事:

  • 简化桶划分
  • 保持结构体和指针访问友好
  • 减少一些低效的奇怪尺寸

在 9.1-kvstore\\src\\mempool.c 里:

cpp 复制代码
return (size + MEMPOOL_ALIGN - 1u) & ~(MEMPOOL_ALIGN - 1u);

这会把任意 size 向上补到 16 的倍数。

比如:

  • 1 -> 16
  • 17 -> 32
  • 33 -> 48

这样桶的编号也很好算。


6. 面试官:为什么小块释放后不还给系统?

应聘者:

因为这个池子的目标就是"复用"。

小块释放后只是挂回对应 free_list,以后再申请时直接拿来用。

如果每次都还给系统,就又变成 malloc/free 了,池子的意义就没了。

这个策略特别适合:

  • 频繁创建/销毁的 key/value
  • 网络缓冲区
  • 持久化临时缓冲区

也就是典型的"短命、小尺寸、重复出现"的对象。


7. 面试官:那大对象为什么还是走系统 malloc?是不是内存池失效了?

应聘者:

不是失效,而是分工不同。

当前池子只覆盖 <=4096 的小对象。

大对象走系统 malloc,原因有三个:

  1. 大对象大小变化太大,不适合固定桶
  2. 如果硬塞进池子,容易造成严重浪费
  3. 学习阶段先把高频小对象优化好,收益更直接

所以正确理解是:

  • 小对象靠池子
  • 大对象靠系统分配器

这和 Redis 的思路也类似,不是所有东西都强行池化。


8. 面试官:chunks 链表到底是干嘛的?

应聘者:

chunks 是"记录池子向系统申请过的整块大内存"的管理链表。

在 9.1-kvstore\\src\\mempool.c 里:

cpp 复制代码
mem_chunk_t *chunks;

它的意义是:

  • 方便程序退出时统一释放整批内存
  • 不需要单独记住每个小块
  • 避免遍历所有 free_list 逐个 free

free_list 管的是"小块复用",chunks 管的是"来源回收"。


9. 面试官:如果一个指针不是你池子分配出来的,kvs_free 会怎样?

应聘者:

mem_pool_free() 会先检查 magic

如果不是池子内存,它直接返回,不做危险动作。

这是一种保守策略。

它不会帮你兜底所有错误,但能避免把乱指针继续挂进链表导致更严重的破坏。


10. 面试官:这个内存池在并发下安全吗?

应聘者:

当前版本不是线程安全的。

因为全局共享:

  • g_pool.free_lists
  • g_pool.chunks
  • cached_count / active_count

这些字段在并发 alloc/free 时都需要保护。

如果以后扩展到多线程,至少要加:

  • 全局锁
  • 或每个桶一把锁
  • 更进一步可以做 thread-local cache

现在这个版本更适合单线程或者外层已经串行化的场景。


11. 面试官:为什么 SAVE 要先写 .tmprename

应聘者:

为了避免半写文件污染正式快照。

在 9.1-kvstore\\src\\persist.c 里:

cpp 复制代码
tmp_path = "%s.tmp"
write tmp
rename(tmp, path)

12. 面试官:你这个池子最核心的价值到底是什么?

应聘者:

是把项目里大量"小而频繁"的分配统一收口,减少系统分配次数,并且让释放路径更可控。

它真正服务的是:

  • 节点对象
  • key/value 缓冲
  • 网络请求/响应缓冲
  • 持久化临时 buffer

所以它不是"万能池",而是"针对 KV 服务工作负载的高频对象池"。


13. 面试官:如果让我继续优化,你最想改什么?

应聘者:

我会优先改两点:

  1. hash/rbtree/skiplist 的节点再压实

    • 减少多次 kvs_malloc
    • 尽量把 node/key/value 放进一块连续内存
  2. 给热点桶做统计和自适应

    • 看哪些 size class 最热
    • 再决定 batch size 和桶策略

这两项比盲目扩大池子更有意义。

补充:

1. 面试官:为什么你的 free list 用单链表,不用双链表?

应聘者:

因为这个场景只需要两件事:头插和头删,单链表就够了。

在内存池里:

  • 分配时:从头部取一个块
  • 释放时:把块头插回链表头

这两个操作都只需要 O(1),不需要访问前驱节点,所以没必要用双链表。

单链表更省空间,mem_block_t 头部也更轻。


2. 面试官:为什么 magic 字段比单纯看 size 更可靠?

应聘者:

因为 size 只能说明"像不像某种块",不能证明"是不是这块池子里的块"。

magic 的作用是做身份校验:

cpp 复制代码
if (block->magic != MEMPOOL_MAGIC) return;

它能防止:

  • 非池子内存误 free
  • 脏指针被当成合法块
  • 头部被破坏后继续挂回链表

所以 magic 是一种低成本的防御机制。


3. 面试官:mem_pool_align() 那句位运算为什么能对齐?

应聘者:

它的作用是把任意 size 向上补到 16 的倍数:

cpp 复制代码
(size + 15) & ~15

例如:

  • 1 -> 16
  • 17 -> 32
  • 33 -> 48

原理是:

  • 先加上 align - 1
  • 再把低位清零

这样可以把 size 映射到固定桶里。


4. 面试官:mem_pool_grow() 为什么要先申请 mem_chunk_t,再申请真正的大内存?

应聘者:

因为 mem_chunk_t 是管理节点,不是用户数据。

它的职责是记住这一整块大内存的来源,方便程序退出时统一释放:

cpp 复制代码
chunk->memory = memory;
chunk->next = g_pool.chunks;
g_pool.chunks = chunk;

这样销毁池子时,只要顺着 chunks 走,就能把系统申请过的所有大块内存统一回收掉。


5. 面试官:为什么 free_lists[idx] 用头插法,不用尾插法?

应聘者:

因为头插最简单,复杂度最低。

头插只需要:

cpp 复制代码
block->next = g_pool.free_lists[idx];
g_pool.free_lists[idx] = block;

尾插则需要遍历到尾部,复杂度是 O(n)

在内存池里,释放动作很频繁,所以释放路径必须尽量短。


6. 面试官:你的池子为什么适合小对象,不适合大对象?

应聘者:

因为池子最擅长处理的是:

  • 大小固定
  • 频繁申请释放
  • 分配模式重复

而大对象通常:

  • 尺寸变化大
  • 单次占用高
  • 复用概率低

如果硬把大对象也纳入固定桶,会导致:

  • 空间浪费
  • 桶设计复杂化
  • 复用收益不高

所以我这里把 4096 以上的大对象直接交给系统 allocator,这是更合理的分层。


7. 面试官:你这个池子和 Redis 的思路到底差在哪?

应聘者:

Redis 更偏向"对象层压缩 + 底层 allocator 优化 ",我这个项目更偏向"自己实现一个通用小对象池"。

Redis 会尽量减少分配次数,比如:

  • 小字符串用 embstr
  • 整数直接用整数编码
  • 紧凑结构用 listpack 之类

我的实现则是:

  • 所有业务对象统一走 kvs_malloc
  • 小对象走我自己的池子
  • 大对象直接走系统分配

Redis 更工程化,我这个更适合学习 allocator 原理。


8. 面试官:你的内存池现在最大的风险是什么?

应聘者:

两个:

  1. 不是线程安全的

    全局 g_pool 没有锁,多线程下会出问题。

  2. 对象内部指针的生命周期要手动管理

    kvs_free(node) 不会自动释放 node->keynode->value,所以业务层必须自己维护好层次释放顺序。


9. 面试官:cached_count 和实际链表长度为什么要双重维护?

应聘者:

这是为了做一致性校验。

  • cached_count 是运行时记账,快
  • 遍历链表是现场审计,准

如果两者不一致,说明:

  • 某个地方漏减或漏加
  • 链表被破坏
  • 释放/分配逻辑有 bug

这个设计在调试阶段很有价值。


11. 面试官:你怎么证明这个池子真的有用?

应聘者:

看三个指标:

  • system_alloc_count 是否明显下降
  • reuse_count 是否明显上升
  • 实际运行中的延迟是否更稳定

如果业务里大量小对象反复申请释放,那么池子能明显减少系统调用和堆碎片。


12. 面试官:一句话总结你的内存池设计。

应聘者:

这是一个面向 KV 服务的小对象分桶内存池,采用 16 字节对齐、256 个规格、批量扩容和头插空闲链表的方式,统一服务业务对象、网络缓冲和持久化临时对象,重点优化高频小内存申请释放场景。

继续压力测试

1. 面试官:为什么你这个内存池要做成全局的,而不是每个模块一个池子?

应聘者:

因为这个项目的对象类型很多,但生命周期模式很相似,都是频繁申请、频繁释放的小对象。做成全局池可以统一管理,减少重复实现,也能让统计更完整,像 cached_countreuse_count 这些信息都能集中观察。

如果每个模块单独一个池,虽然隔离性更强,但会增加复杂度,还会导致不同池之间出现"有的很空、有的很忙"的资源不均衡问题。


2. 面试官:为什么池子用 16 字节对齐,而不是 8 或 32?

应聘者:

16 字节是一个比较平衡的粒度。

如果太小,比如 8 字节,会导致桶更多,管理更碎;如果太大,比如 32 字节,小对象浪费会明显变大。

16 字节对齐对结构体、指针和常见字符串长度都比较友好,也方便用位运算快速计算桶编号。


3. 面试官:mem_pool_class_index() 为什么是 (size / 16) - 1,不会有问题吗?

应聘者:

它只在 size 已经向上对齐到 16 的前提下使用,所以 16 会映射到 0,32 会映射到 1,依次类推。

如果不先对齐,确实会错,比如 17 直接算就会得到 0,但实际应该进入 32B 桶。所以正确流程一定是先 mem_pool_align() 再算桶号。


4. 面试官:mem_block_t 为什么要把 magickindclass_idx 都放进去?

应聘者:

这几个字段分别解决不同问题:

  • magic:判断这是不是池子分配的块
  • kind:判断是小块还是大块
  • class_idx:释放时知道挂回哪个 free list

它们让 free 路径能自描述,不需要额外查表。


5. 面试官:为什么 mem_pool_grow() 是先申请管理节点,再申请大内存?

应聘者:

因为 mem_chunk_t 的职责只是记录"大内存来源",方便最后销毁。

如果先申请大内存,再申请管理节点,万一管理节点申请失败,会多出一块无法登记的内存,回收就麻烦。现在这个顺序更稳妥。


6. 面试官:你这里的 chunksfree_lists 分别负责什么?

应聘者:

free_lists 负责"可复用的小块池",按规格分桶。

chunks 负责"所有从系统申请来的大块来源记录",用于销毁时统一释放。

一个管复用,一个管回收来源,不是同一个职责。


7. 面试官:为什么 mem_pool_free() 对小块不直接 free,而是挂回链表?

应聘者:

因为内存池的核心目标就是复用。

如果每次释放小块都还给系统,那就退化成普通 malloc/free 了,池子的价值就没了。

挂回链表后,下次申请同规格对象时可以直接复用,减少系统调用和碎片。


8. 面试官:你怎么保证 mem_pool_free() 不会把非法指针挂回链表?

应聘者:

magic 校验。

释放时先从用户指针倒推回块头,再检查 block->magic 是否等于 MEMPOOL_MAGIC

如果不是,就直接返回,不参与链表操作。这样能防止误释放破坏池子结构。


9. 面试官:为什么 mem_pool_dump() 要做链表遍历校验?

应聘者:

因为运行时维护的计数可能出错,而链表遍历是现场真实状态。

双重检查可以帮助发现:

  • 计数有没有漏加漏减
  • 链表有没有断
  • 释放逻辑有没有问题

这个对调试特别有价值。


10. 面试官:你的大对象为什么直接走系统 malloc?这和池子的目标冲突吗?

应聘者:

不冲突,这是分层设计。

池子最适合固定规格、频繁复用的小对象;大对象通常变化大、复用少、尺寸不稳定,硬纳入池子反而会浪费严重。

所以小对象池化,大对象交给系统 allocator,这是合理的职责划分。


12. 面试官:如果项目以后变成多线程,你这套池子会出什么问题?

应聘者:

现在这套不是线程安全的。

共享的 g_pool.free_listsg_pool.chunks 和统计字段在并发下都会有竞争条件,可能导致链表损坏。

如果要支持多线程,至少要加全局锁,或者按桶加锁;更进一步可以做 thread-local cache,减少竞争。


13. 面试官:为什么 mem_pool_destory() 是在 dest_kvengine() 里最后调用?

应聘者:

因为前面的容器销毁会先释放自己持有的节点、key、value 等对象,最后再统一销毁内存池。

如果先销毁池子,后面容器再尝试 kvs_free,就会访问已经失效的池子状态。

所以顺序必须是:先容器,后池子。


15. 面试官:如果让你总结这个内存池设计的核心价值,你会怎么说?

应聘者:

它的核心价值是把项目里高频出现的小对象分配统一收口,减少系统 malloc/free 的频率,降低碎片,提高复用率,并且通过 magickindclass_idx 和统计信息,让分配和释放路径更可控、更容易调试。


3. 面试官:那小块到底什么时候真正 free 掉?

应聘者:

mem_pool_destory() 里统一释放。

流程是:

复制代码
小块释放 -> 回到 free_list
池子销毁 -> 遍历 chunks
          -> free(chunk->memory)
          -> free(chunk)

也就是说,小块平时只是"缓存",最后由 chunks 统一还给系统。


7. 面试官:你这个内存池本质上更像什么?

应聘者:

更像一个简化版 slab / freelist allocator。

特点是:

  • 固定粒度对齐
  • 按规格分桶
  • 空闲链表复用
  • 批量扩容
  • 退出时统一销毁

它不是一个完整通用 allocator,但足够支撑这个 KV 项目的高频小对象场景。


8. 面试官:你在项目里为什么还要保留系统 malloc/free

应聘者:

因为池子只适合小且固定的对象。

大对象,比如超长 value 或者很大的临时 buffer,直接走系统分配更合理。

所以这里是分层设计:

  • 小对象:池子
  • 大对象:系统 allocator

这不是"池子失效",而是"池子只做它最擅长的部分"。


9. 面试官:你的持久化层为什么也会用 kvs_malloc

应聘者:

因为持久化层也有大量临时对象,比如:

  • persist_buf_t 扩容
  • tmp_path
  • load 时的 key_bufvalue_buf
  • response 缓冲

这些都是短命对象,走 kvs_malloc 也能受益于池子,减少系统分配次数。


10. 面试官:persist_save_all() 为什么要先统计 record 数量?

应聘者:

因为文件头里要写 record_count

保存时先统计总记录数,后面加载时就能准确知道要读多少条。

它的流程是:

复制代码
统计各引擎条数
  ↓
写文件头
  ↓
遍历所有记录
  ↓
写到临时 buffer
  ↓
落盘

这样文件格式自描述更强,恢复时也更稳。


11. 面试官:为什么 SAVE 要写 .tmprename

应聘者:

这是为了防止半写文件污染正式快照。

如果直接写 kvstore.data,写到一半崩了,正式文件就坏了。

先写 .tmp,成功后 rename 成正式文件,更接近原子替换,安全性高很多。


12. 面试官:为什么启动恢复顺序一定是先 kvstore.datakvstore.aof

应聘者:

因为 kvstore.data 是全量基线,kvstore.aof 是基线之后的增量。

如果顺序反了,后读的快照会把前面回放的增量覆盖掉,恢复就错了。

所以正确顺序是:

复制代码
先快照
再增量

13. 面试官:你这个内存池和 Redis 的差距在哪里?

应聘者:

Redis 更偏向工程化:

  • 底层依赖 jemalloc 这类 allocator
  • 小字符串做 embstr
  • 整数直接内嵌
  • 紧凑结构减少分配
  • 还有主动碎片整理

我这个更像教学版的通用小对象池,适合理解 allocator 原理,但工程细节没 Redis 那么深。


14. 面试官:这个池子最明显的局限是什么?

应聘者:

三个:

  1. 不是线程安全的
  2. 大对象还是靠系统 malloc
  3. 还没做更细的热点优化,比如按业务对象定制块布局

15. 面试官:如果让你下一步优化,你会先改哪里?

应聘者:

我会先改数据结构对象的分配方式,把 hash / rbtree / skiplist 里多次 kvs_malloc 的地方尽量压成一块连续内存。

这样能进一步减少碎片,也更像 Redis 的对象布局思路。

相关推荐
AI吃大瓜1 小时前
人脸检测和行人检测3:C/C++实现YOLOv8 YOLO11 YOLO26人脸检测和人体检测(含源码,可实时检测)
c++·yolov8·人脸检测·人体检测·yolo11·yolo26
Persistent的粽子!2 小时前
C++的内存管理
c++·经验分享·笔记
hetao17338372 小时前
2026-09-13 hetao1733837 的刷题记录
c++·算法
hansang_IR2 小时前
【题解】[JSOI2016] 扭动的回文串
c++·算法
有点。2 小时前
C++03阶段练习(一)
数据结构·c++
刃神太酷啦2 小时前
Redis 核心进阶:哨兵、集群、缓存问题与分布式锁详解----《Hello Redis!》(6)
linux·c语言·数据库·c++·redis·分布式·缓存
郝学胜-神的一滴2 小时前
C++20模板元编程 01:从零吃透模板核心底层逻辑
开发语言·c++·vscode·程序人生·开源
6Hzlia3 小时前
【Classic 150 刷题计划】 LeetCode 123. 买卖股票的最佳时机 III | C++ 状态机动态规划与通用交易拓展
c++·算法·leetcode
luj_17683 小时前
生物体如何应对功能击穿?
c语言·开发语言·c++·经验分享·算法