Redis学习(一):单机的秘密

60 阅读11分钟

参考资料

《redis设计与实现》

mp.weixin.qq.com/s/b6OpG6nc9…

pdai.tech/md/db/nosql…

基本概念

来源

Redis (Remote Dictionary Server) 大家都知道,是开源的可以存储多种数据格式的内存缓存系统,由意大利程序员 Salvatore Sanfilippo(通常被称为 "antirez")于2009年首次开发。

我们都知道,太多请求打到数据库会把数据库打崩,我们都知道这个时候要用缓存。那如果用本地缓存呢?

一台16G内存的服务器,用10G来存储业务数据,一来也存不了多少,二来如果有100台服务器,每台服务器都要拿出10G内存来,就是1000G内存,这么大一笔钱就为了存10G的数据,傻子吧。

image.png

这时候就用到redis了,它给mysql挡流量,它给服务器省内存,它就是人们的英雄redis。

image.png

远程

由上图可知,redis是一个单独的被抽出来的内存服务。那么首先意味着,我们要访问到这个服务!访问到这台机器!

网络协议

Redis网络通信协议RESP也是一种应用层协议,传输层则是基于TCP。服务端从TCP socket缓冲区读取数据,然后通过RESP协议解码得到命令,返回的数据则是一个反过程。

-- 命令:set foo bar
*3
$3
SET
$3
foo
$3
bar

当然我们写的不是这种协议,我们只写命令,由redis-cli客户端把命令转化为协议。我们当然可以通过命令行运行使用redis-cli,但开发中会使用封装好的依赖包,jedis与lettuce是其中的佼佼者。

当然使用过程中,jedis与lettuce不同,甚至不同的版本,在redis集群发生故障时,效果也不同。

并发

同时,我们可以看到,会有很多台业务服务器都去访问redis这个内存服务,一个服务处理多个请求,就有了并发。那redis处理并发请求,尤其是大量读写的时候,会不会有并发造成的数据问题呢?

不会,因为大家都背过,redis是单线程的,单线程地处理读写命令自然不会有并发问题(比如线程切换带来的问题)。

id.jpg

数据类型

redis有5种常见数据类型。string当然是使用最多的,hash和zset也是我平时接触比较多的两种。当然也有用过bitmap实现的布隆过滤器。同时redis5.0还有一个新的数据结构stream,好像是可以做消息队列。

hash是一个很好用的功能,存放一组相关的键值对。假设有这样一种场景,需要一个中间值关联起各个id,用rendis怎样做呢?

id.jpg

zset也用的比较多,保存一对多关系,而且比set要多一个信息,就是score。这个score如果放timestamp,就知道了数据存入的时间。真很重要,尤其是在数据有问题的时候。

zrange zsetKey 0 -1 withscores

redis中数据,无论是键还是值,都是以redisObject这种对象来存储的,其中ptr指针指向具体的数据结构实例。

typedef struct redisObject {
    // 类型
    unsigned type:4;
    // 编码方式
    unsigned encoding:4;
    // LRU - 24位, 记录最末一次访问时间(相对于lru_clock); 或者 LFU(最少使用的数据:8位频率,16位访问时间)
    unsigned lru:LRU_BITS; 
    // 引用计数
    int refcount;
    // 指向底层数据结构实例
    void *ptr;
} robj;

过期与淘汰

上面有提到redis中的数据都是以redisObject存储的,key也是这样。redis可以给每个key设置一个过期时间:

expire key seconds
expire key miliseconds
TTL key
PERSIST key -- 移除过期

redis我们正常set一个key-value进去是不会过期的,那当它过期的时候会发生什么呢?

惰性删除:redis在请求某个键时,都会判断它是否过期,如果过期则会被删除然后返回nil。

定期删除:redis有一个定时器,设置每100ms运行一次,这个周期内抽取默认20个键检查过期状态,发现就删除,防止内存占用。

也就是说,不是所有键,一过期就会被删除。只有过期后再次被查询,或者在定期扫描中被扫到,过期的键才会被删除,因此即使存储相同数据的集群,容量大小也可能不同,因为过期键的数量不同。

那么,如果内存满了,已经过期的键,还会挡着新进来的键怎么办?得淘汰,内存满了得淘汰。

noeviction:不淘汰任何键,如果达到内存限制,插入新数据则返回错误。
allkeys-lru:在所有键中使用 LRU(最近最少使用)算法选择一个键进行删除。
volatile-lru:只在设置了过期时间的键中使用 LRU 淘汰策略。
allkeys-random:在所有键中随机选择一个键进行删除。
volatile-random:在设置了过期时间的键中随机选择一个键进行删除。
volatile-ttl:只在设置过期时间的键中,优先删除即将过期的键。

redis默认使用noeviction,就是说,老是背什么redis的淘汰策略,实际默认情况下是没用到的。内存快满了就告警,就不让内存用完就完事了,扩容就完事了(扩容也没那么简单)。

在数据不能被淘汰,数据不能遗失的场景下,只能用noeviction。其他的策略还没用过,也没权限在生产环境设置。

持久化

上面的过期与淘汰,使得内存不会无限的上涨。但是如果服务出故障了,服务自己突然重启了,那缓存一下子就都没有了。

难道说因为意外丢失的内存数据,要重启之后,再慢慢地补回来?原来查缓存的数据,一下子又都打到mysql里面,那就又是竹篮打水一场空。

那怎么办呢?要最大程度上去持久化reids内存的数据,redis服务重启后,能从持久化的磁盘数据中最大程度地恢复。

大家都知道,redis持久化有两种:RDB和AOF。

RDB:Redis Database Backup,从redis服务的单线程里面弄一个异步子线程,定期将全量内存数据生成一个快照文件持久化到磁盘里。

但是是每隔几分钟才会生成一个快照,这几分钟内挂了,这几分钟里的数据自然是没了。对于有追求的巨佬来说,当然是不能接受的。

AOF:Append Only File,先把数据写进了内存,再把redis的命令记录进日志。(有的数据库,如mysql,都是先写了什么undo日志,再才写数据)。服务重启时,把AOF文件重新执行一遍也能恢复大部分数据。(AOF文件也会更新,不会无限膨胀)。

混用:RDB快照文件按一定的频率生成和更新,两次RDB快照之间,用AOF记录两次快照之间的操作。兼具RDB恢复数据快和间隔短的优势。

为什么快

  1. 内存存储(核心原因)

重要性:最核心优势,直接决定了数据访问速度。

原理:Redis 数据完全存储在内存中,内存的读写速度(纳秒级)比传统磁盘(毫秒级)快 10^6 倍以上。

优势: 消除磁盘 I/O 瓶颈,随机访问无延迟。 适合存储高频访问的热数据(如缓存、会话数据)。

代价:内存成本较高,需通过持久化(RDB/AOF)保障数据安全。

  1. 高效数据结构优化(性能倍增器)

重要性:直接优化数据操作复杂度。

核心数据结构:

SDS(简单动态字符串):预分配内存、二进制安全,减少内存重分配。

HashTable:O(1) 时间复杂度的键值查询。

跳跃表(SkipList):有序集合(ZSET)的底层实现,查询效率 O(logN)。

压缩列表(Ziplist):小数据量时节省内存,线性遍历但缓存友好。

快速列表(Quicklist):链表与压缩列表结合,平衡内存与访问效率。

优势:针对不同场景选择最优结构,降低操作时间复杂度。

  1. 单线程模型(避免竞争损耗)

重要性:简化设计,避免多线程锁竞争。

原理: 单线程处理所有命令(网络 I/O + 数据操作)。 通过 I/O 多路复用(如 epoll)管理并发连接。

优势: 无上下文切换、锁竞争,CPU 利用率高。 天然原子性操作,无需额外同步。

适用场景:CPU 非密集型操作(如内存访问、简单计算)。

  1. I/O 多路复用(高并发支撑)

重要性:单线程下仍能支撑高并发连接。

原理: 使用 epoll/kqueue 等系统调用,监控多个 socket 事件。 事件驱动模式,非阻塞处理请求。

优势: 单线程可处理数万级并发连接。 减少线程/进程切换开销。

  1. 优化的协议与序列化(降低开销)

重要性:减少网络传输和解析时间。

RESP 协议: 二进制安全,简单易解析。 客户端无需复杂序列化(如 JSON/Protobuf)。

优势:降低 CPU 和网络带宽消耗,提升吞吐量。

  1. 持久化策略优化(减少主线程干扰)

重要性:保证数据安全的同时不影响查询性能。

策略: RDB:子进程生成快照,主线程无阻塞。 AOF:追加写盘(可配置为每秒同步或每次操作同步)。

优势:持久化操作在后台执行,主线程持续响应查询。

  1. C 语言实现与系统级优化(底层性能保障)

重要性:贴近硬件,最大化执行效率。 优化手段: 内存分配器(jemalloc/tcmalloc)减少碎片。 字节对齐、预读取等 CPU 缓存优化。 避免系统调用(如通过内存映射文件持久化)。

  1. 避免复杂事务与关联查询(场景聚焦)

重要性:专注简单操作,减少额外开销。 设计取舍: 无 SQL 式的 JOIN 操作,数据模型扁平化。 事务支持有限(单命令原子性,无回滚)。

总结

Redis 的单机高性能是 内存存储 + 数据结构优化 + 单线程事件模型 三位一体的结果。

它通过牺牲通用性(如复杂查询、事务)和内存成本,换取极致的读写速度,特别适合缓存、实时计数等高频访问场景。

在分布式系统中,可通过集群扩展容量,但单机性能优化仍是其核心优势

高效数据结构

注意这里说的是数据结构,而不是Redis的数据类型。

javabetter.cn/sidebar/san…

首先要明确Redis有6种数据结构:动态字符串(sds)、链表(list)、字典(ht)、跳跃表(skiplist)、整数集合(intset)、压缩列表(ziplist)。

image.png

hash表的结构比较特别,和java的hashmap实现是不同的:每个字典带有两个 hash 表,平时⽤和 rehash 时使⽤。

hash 表使⽤链地址法来解决键冲突,被分配到同⼀个索引位置的多个键值对会形成⼀个单向链表。

在对 hash 表进⾏扩容或者缩容的时候,为了服务的可⽤性,rehash 的过程不是⼀次性完成的,⽽是渐进式的。

image.png

跳表由 zskiplist 和 zskiplistNode 组成,zskiplist ⽤于保存跳表的基本信息(表头、表尾、⻓度、层高等)。

zskiplistNode ⽤于表示跳表节点,每个跳表节点的层⾼是不固定的,每个节点都有⼀个指向保存了当前节点的分值和成员对象的指针。

需要重点理解这里的层高的意思,层高大致是采用二分的思想,往上建索引。

image.png

单线程模型

xie.infoq.cn/article/d01… www.cnblogs.com/xiaolincodi…

redis读、写的主流程一直是单线程的。大致是下面小林coding的图里蓝色方框的部份:

image.png

但并不是说整个redis就全部是单线程的,一个1核的服务器和一个8核的服务器跑redis服务,还是会不一样的。

比如4.0版本引入了lazy free线程,将很慢的操作异步处理,提升吞吐量。提及更多的就是6.0的网络IO异步化,引入IO线程,IO操作交给IO线程来做,提升吞吐量。

image.png

IO多路复用

www.cnblogs.com/88223100/p/…

上图中也可以看到IO多路复用,用在处理多个客户端连接的地方。IO多路复用本质上高效的事件通知机制,把多个客户端发送过来的请求数据给往下发,不涉及读写数据。

I/O 多路复用其实是使用一个线程来检查多个 Socket 的就绪状态,在单个线程中通过记录跟踪每一个 socket(I/O流)的状态来管理处理多个 I/O 流。

与 I/O 线程的关系: 多路复用负责“通知”:主线程通过 epoll 发现哪些客户端有数据到达; I/O 线程负责“搬运”:将数据从 Socket 读取到缓冲区(或从缓冲区写入 Socket)。

这里后面有机会再详细学一学,写一写。