Memcached 与 Redis 核心机制深度解析
内存缓存系统的设计初衷
数据库磁盘 I/O 的固有限制在高并发场景下会成为显著瓶颈。将热点数据迁移至内存并以键值对形式组织,能够大幅削减查询延迟。Memcached 与 Redis 正是基于这一思路诞生的内存级缓存中间件,通过前置缓存层降低后端数据库压力,提升整体系统吞吐量。
网络服务架构
两者均以独立守护进程形态运行,支持跨网络访问。通信层面基于 TCP 协议实现请求响应,同时兼容 UDP 以降低部分场景下的协议开销。当客户端与服务端位于同一主机时,还可通过 Unix Domain Socket 规避网络协议栈开销,获得更低延迟。
I/O 事件处理模型
现代网络服务普遍采用 epoll 替代传统的 select/poll,Redis 与 Memcached 亦不例外。Redis 在 epoll 之外保留了 select 与 poll 的可选支持,并在 BSD 平台集成 kqueue。Memcached 则依托 libevent 封装底层事件通知,其内部同样基于 epoll 实现。
Redis 采用单线程事件循环(Event Loop)架构,仅通过后台线程执行持久化等辅助任务。其事件分发为典型的 Reactor 模式, noteworthy 的设计在于客户端连接管理:由于 Redis 可配置最大连接数上限,且文件描述符(fd)在生命周期内唯一,Redis 直接使用 fd 作为数组索引,以 O(1) 复杂度定位客户端上下文,避免了红黑树等复杂结构的维护成本。此方案适用于连接规模可控的场景,Nginx 等需要处理海量动态连接的 HTTP 服务器则仍需依赖红黑树。
Memcached 采用 Master-Worker 多线程模型。主线程负责端口监听与连接建立,随后通过轮询或负载策略将连接分发给工作线程。线程间通过管道(Pipe)进行指令传递:主线程将新连接置入对应工作线程的就绪队列,并向其管道写入通知;工作线程在事件循环中监听管道读端,接收到通知后从队列取出连接处理。该模型能够充分利用多核并行能力,但引入了锁、条件变量等同步机制,实现复杂度显著高于单线程方案。
内存管理策略
Memcached 实现了私有内存池机制,预先向系统申请大块内存,后续分配均从池中获取,减少系统调用频率。内存池按 Slab 分级管理,不同 Slab 对应不同尺寸的内存块(Chunk),相同尺寸的 Chunk 归属同一 Slab Class。新数据写入时,根据数据大小选择"能容纳该数据的最小 Chunk",由此产生的内部碎片是空间换时间的权衡。
Redis 则采用即时分配策略,需要时直接向操作系统申请,释放时归还系统,内存管理完全委托给内核。为优化分配效率,Redis 支持链接 tcmalloc 替代 glibc 的默认 malloc,利用更高效的内存分配算法降低碎片率。这种设计简化了 Redis 的核心逻辑,使其能将重心置于数据结构实现与持久化机制。
数据存储引擎对比
Memcached 的存储机制
Memcached 仅支持简单的键值映射,值类型为二进制安全字符串。其存储单元称为 Item,包含元信息、键与值。为加速查找,Memcached 维护一张 Hash 表,采用链地址法处理冲突,每个桶存储指向 Item 的指针链表。
Hash 表支持渐进式扩容:当 Item 数量超过桶数的 1.5 倍时触发,由独立后台线程将旧表数据迁移至新表(桶数翻倍)。扩容期间查询可能涉及新旧两张表,需根据桶位置与迁移进度判断数据归属。
Item 的分配源自 Slab 体系。每个 Slab Class 管理若干 Slab,Slab 由等尺寸 Chunk 组成。空闲 Chunk 通过指针串联成空闲列表,分配时直接取用。为淘汰冷数据,每个 Slab Class 维护一条 LRU 链表,按访问时间排序,空间不足时从尾部开始回收。
过期检查采用惰性策略:访问时比对时间戳,过期即失效,不主动扫描。此举节省 CPU,但可能导致过期数据长期驻留内存。并发更新通过 CAS(Compare-And-Swap)协议保证:每个 Item 携带 64 位版本号,更新前校验客户端提交的版本与服务器当前版本是否一致,防止写覆盖。
Redis 的数据结构实现
Redis 支持 String、List、Set、Sorted Set、Hash 五种数据结构,通过 redisObject 统一抽象:
typedef struct redisObject {
unsigned type:4; // 对象类型
unsigned encoding:4; // 底层编码
unsigned lru:LRU_BITS; // 最近访问时间
int refcount; // 引用计数
void *ptr; // 指向实际数据
} robj;每种类型对应多种底层实现,运行时根据数据特征自动选择最优编码。例如 String 可采用 RAW、INT 或 EMBSTR;List 可在双向链表与压缩列表间切换;Sorted Set 由跳表(Skip List)或压缩列表实现,同时辅以 Hash 表加速按成员查询。
Redis 的字符串实现为 SDS(Simple Dynamic String):
struct sdshdr {
int len; // 已用长度
int free; // 剩余容量
char buf[]; // 柔性数组,存储实际字符
};键值映射通过自研字典(Dict)实现,采用双 Hash 表设计支持平滑扩容:
typedef struct dict {
dictType *type;
void *privdata;
dictht ht[2]; // 主表与副表
int rehashidx; // -1 表示未在扩容
int iterators;
} dict;扩容(或缩容)时,新表申请完毕,后续每次字典操作附带迁移一个桶的数据,将一次性开销均摊至多次请求,避免服务卡顿。迁移完成后交换主副表指针。
Redis 默认启用 16 个独立数据库,客户端可切换使用。过期时间通过独立的 Expire Dict 维护,键映射至 64 位时间戳。删除策略结合惰性删除与定时随机采样,在内存占用与回收及时性间取得平衡。
持久化机制
RDB 快照
RDB 以二进制形式保存某一时刻的完整数据库状态。触发方式包括手动执行 SAVE(阻塞主线程)或 BGSAVE(Fork 子进程后台执行),以及配置条件自动触发(如 "900 秒内 1 次变更")。
子进程写入临时文件,完成后通知父进程原子替换。由于 Copy-On-Write 机制,Fork 后的内存页共享,仅修改时复制,但期间的数据变更不会反映至快照,故 RDB 为时间点一致性而非实时一致性。
AOF 日志
AOF 记录每条修改命令的文本序列,重启时重放以重建状态。同步策略可选:每次写入后 fsync、每秒后台 fsync、或交由操作系统决定。
AOF 重写(Rewrite)机制解决日志膨胀问题:Fork 子进程遍历当前数据库生成等价命令集写入新文件,期间父进程将新命令追加至重写缓冲区,子进程结束后合并入新文件。此设计避免了进程间复杂通信,以简单缓冲区实现增量同步。
事务与并发控制
Redis 提供 MULTI/EXEC 事务块,命令入队后批量执行,不支持回滚。执行期间不会被其他客户端打断,但无法防止隔离性问题。引入 WATCH 命令实现乐观锁:监视指定键,若事务提交前被其他客户端修改,则事务执行失败。实现上通过客户端标记 REDIS_DIRTY_CAS 与数据库的 Watcher 列表联动完成。
发布订阅功能
Redis 支持频道(Channel)与模式(Pattern)订阅。服务端维护 pubsub_channels 字典映射频道至客户端链表,以及 pubsub_patterns 列表存储模式订阅关系。消息发布时,先向精确匹配频道广播,再遍历模式列表进行正则匹配广播,同一客户端可能多次接收。
分布式路由:一致性 Hash
传统取模 Hash 在节点扩容时导致大规模缓存失效。一致性 Hash 将节点映射至 2^32 的环形空间,数据 Key 同样映射至环上,顺时针寻找首个节点即为路由目标。新增节点仅影响环上相邻区间,大幅缩小学动范围。节点规模越大,扩容影响面越小,配合虚拟节点技术可进一步优化负载均衡。