i007.cc

i007.cc

优先队列-降维打击

05.价值资料

Redis 网络模型和基本原理

Redis 的网络模型经历了从”单线程 Reactor”到”多线程 IO + 单线程执行”的演进,核心思路都是用 IO 多路复用 + 事件驱动 来避免线程切换和锁竞争的开销。

核心:单线程 + IO 多路复用(Redis 6.0 之前)

Redis 的命令执行一直是单线程的(一个主线程按顺序处理所有客户端命令),但它能支撑很高的并发,关键在于网络 IO 部分用了多路复用技术,而不是每个连接开一个线程/进程。

Redis 内部实现了一个事件驱动库(源码里的 ae.c,即 AE,A simple Event-driven library),它会根据操作系统自动选择底层多路复用机制:Linux 用 epoll,macOS/BSD 用 kqueue,都没有的话退化到 select。这一层把不同系统的差异封装成统一接口。

事件循环里主要处理两类事件:

文件事件(file event)负责处理网络 IO,比如客户端发起连接、发送命令、接收回复;时间事件(time event)负责处理定时任务,比如 serverCron 里的过期键清理、AOF 刷盘、集群心跳等。事件循环会不断轮询:调用多路复用接口拿到就绪的文件描述符,依次分发给对应的处理器(acceptor 处理新连接、command reader 读命令、command writer 写回复),处理完再检查是否有到期的时间事件要执行。

整个流程可以理解为一个经典的 Reactor 模式:一个线程、一个事件循环、把 accept、read、命令解析、命令执行、write 全部串行化处理。这样做的好处是没有多线程的上下文切换和锁开销,代码模型简单,不会有并发修改数据结构的问题;配合 Redis 数据结构基本是内存操作,速度足够快,所以单线程也能跑到很高的 QPS(通常十万级别)。

Redis 6.0 引入的多线程 IO

单线程的瓶颈慢慢出现在网络 IO 上:当连接数多、请求体大(比如大 value、pipeline 批量命令)时,socket 的读写和协议解析(RESP 序列化/反序列化)会占用主线程相当一部分时间,成为瓶颈,而这部分其实是可以并行化的。

所以从 6.0 开始,Redis 加入了 多 IO 线程io-threads 配置项),但注意它只把”读取请求 / 解析协议 / 写回结果”这几步做了多线程化,命令的实际执行(比如 SETLPUSH 这些操作数据结构的逻辑)仍然是单线程完成的。流程大致是:主线程负责 accept 连接;多个 IO 线程并行地从各自负责的连接上读取数据、解析成命令;主线程再单线程地依次执行这些命令;写回复时同样可以用多个 IO 线程并行发送。这样保留了”命令执行无需加锁”的简单模型,又缓解了网络 IO 这一层的瓶颈。默认这个特性是关闭的(io-threads 1),需要手动开启并设置线程数才生效,官方建议在 4 核以上、且网络 IO 确实是瓶颈时再开启。

一句话总结

Redis 用单线程处理命令逻辑保证了无锁、简单、可预测的执行模型,用 IO 多路复用(epoll 等)实现了单线程也能同时管理成千上万个连接,6.0 之后又把网络 IO 的读写/编解码部分拆给多个线程做,进一步榨取多核性能,但”数据操作本身单线程执行”这个核心设计一直没变。

如果你是在准备面试或者想深入某一块(比如 epoll 的 LT/ET 模式、ae.c 的具体实现、pipeline 对网络模型的影响、集群模式下的 gossip 协议网络处理等),可以告诉我具体想深入哪个方向。


Redis 基本原理

Redis(Remote Dictionary Server)本质是一个基于内存的高性能 key-value 存储系统,可以当数据库、缓存、消息中间件用。它的核心设计目标是”快”,实现路径主要靠这几个支柱:数据全部在内存里操作、单线程避免并发开销、加上前面聊过的 IO 多路复用网络模型。下面按几个维度展开。

数据结构与底层编码

Redis 对外暴露 5 种基础数据类型:String、Hash、List、Set、Zset(有序集合),此外还有 Bitmap、HyperLogLog、Geo、Stream 等基于这些基础类型封装的高级结构。

关键在于每种类型底层会根据数据量大小和内容自动选择不同的编码方式,来平衡内存占用和性能。比如 String 小整数用 int 编码,短字符串用 embstr(一次内存分配),长字符串用 raw;Hash、List、Zset 在元素少、值小的时候用压缩列表 ziplist / listpack(紧凑连续内存,省空间),数据量变大后自动转成哈希表、双向链表(Redis 3.2 后 List 改为 quicklist)、跳表(skiplist)等结构,保证操作复杂度。这种”小对象用省内存的编码,大对象用高性能的结构”的自适应策略,是 Redis 能同时兼顾内存效率和速度的关键之一。

单线程执行模型

命令执行本身是单线程的(这个在上一条网络模型里细讲过),所有命令天然线性化,不存在并发修改数据结构的竞态问题,也就不需要像多线程程序那样加锁,减少了上下文切换和锁竞争的开销。这也是为什么单条命令是原子的,但要注意”原子性”仅限单条命令,多条命令组合起来(即便用了 MULTI/EXEC 事务)如果中间夹了外部逻辑判断,依然可能有竞态窗口。

持久化:RDB 与 AOF

Redis 数据在内存里,但要防止进程重启/宕机丢数据,提供两种持久化机制,可以单独用也可以混合用。

RDB(快照)是在某个时间点把整个数据集 fork 一个子进程写成二进制文件,优点是文件紧凑、恢复速度快,缺点是两次快照之间的数据可能丢失。fork 子进程时利用了操作系统的 写时复制(copy-on-write),父进程继续处理请求,子进程共享内存页,只有发生写操作时才复制被修改的页,所以 fork 本身很快,不会长时间阻塞主线程。

AOF(追加日志)是把每条写命令追加写入日志文件,重启时重放日志恢复数据,优点是数据更完整(可配置每秒刷盘或每条命令刷盘),缺点是文件更大、恢复更慢。为了防止 AOF 文件无限增长,Redis 会做 AOF 重写(bgrewriteaof),原理和 RDB 类似,fork 子进程根据当前内存数据生成一份最小化的等效命令集合,替换旧文件。

4.0 之后还支持混合持久化:AOF 重写时,文件前半部分用 RDB 格式的全量快照,后面追加重写过程中新产生的增量命令,兼顾恢复速度和数据完整性。

过期删除与内存淘汰

Redis 支持给 key 设置 TTL。删除过期 key 用的是惰性删除 + 定期删除结合的策略:惰性删除是每次访问 key 时才检查是否过期,过期就删除,好处是不浪费 CPU 主动轮询,坏处是如果一个过期 key 一直没被访问,会一直占着内存;定期删除是后台定时任务(serverCron 里)随机抽取一批设了过期时间的 key 检查,过期的删掉,循环该过程直到过期 key 比例降到阈值以下,弥补惰性删除的空档。

当内存达到 maxmemory 限制时,还会触发内存淘汰策略,比如 LRU(淘汰最近最少使用)、LFU(淘汰最近使用频率最低)、random、ttl 优先等,具体策略可配置,默认不淘汰(写入直接报错)。

复制与高可用

主从复制:从节点连上主节点后先做一次全量同步(主节点生成 RDB 快照发给从节点,再把快照生成期间的增量命令通过 replication buffer 发过去),之后主节点每执行一条写命令就异步转发给所有从节点,实现增量同步。这是异步复制,所以主从之间存在短暂的数据延迟,不保证强一致。

在此基础上,Redis 提供了两套高可用方案:Sentinel(哨兵)负责监控主从节点健康状态,主节点故障时自动选举一个从节点升级为新主节点,并通知客户端切换,解决的是故障自动转移问题;Cluster(集群)则是数据分片方案,把 16384 个哈希槽(hash slot)分布到多个节点上,每个 key 通过 CRC16 计算落到某个槽,从而实现数据的水平扩展,同时集群内每个分片也可以配从节点做高可用。

其他值得了解的机制

事务(MULTI/EXEC)把多条命令放进队列,一次性顺序执行,中间不会被其他客户端命令插入,但不支持像关系型数据库那样的回滚;发布订阅(Pub/Sub)提供简单的消息广播能力,但消息不持久化,订阅者离线就丢失,如果需要持久化消息队列语义,通常用 Stream 类型(5.0 引入,类似 Kafka 的日志结构,支持消费组、ACK、消息回溯)。

一句话总结

Redis 快是”内存操作 + 高效数据结构编码 + 单线程无锁执行 + IO 多路复用”这几件事叠加的结果;它的可靠性靠 RDB/AOF 持久化兜底,可用性和扩展性靠主从复制、哨兵、集群这一整套机制补齐。

如果你想深入某一块,比如 RDB/AOF 的具体触发时机和配置项、集群的槽位迁移和请求重定向(MOVED/ASK)、缓存穿透/击穿/雪崩这类工程问题,或者跳表为什么用在 Zset 上而不用红黑树,可以告诉我方向,我可以再展开。

发表回复