云计算、AI、云原生、大数据等一站式技术学习平台

网站首页 > 教程文章 正文

十年之重修Redis原理

jxf315 2025-05-15 18:41:48 教程文章 3 ℃

弱小和无知并不是生存的障碍,傲慢才是。

---- ---- 面试者

总结

Redis可能都用过,但是从来没有理解过,就像一个熟悉的陌生人,本文主要讲述了Redis基本类型的使用、数据结构、持久化、单线程模型和常规缓存、分布式锁等使用,来进一步学习与了解为什么Redis能如此的吊。

数据类型

各类型的操作命令可以参考官方文档:
https://redis.io/docs/latest/commands/

  1. String

最基本的key-value结构,key是唯一标识,value是具体值,value类型可以是字符、数字和二进制。最多可以容纳的数据长度是512M

//最简单的set与get一个key为“username:root:pwd”的值
> set username:root:pwd 123456
> OK
> get username:root:pwd
> "123456"

// decr/incr 递减/递增 1, decrby/incrby num 递减/递增 num,如果key不存在则默认值0
> decrby username:admin:pwd 4
> (integer) -4

// 针对常用的setNX(key不存在则存入返回1,否则返回0) 和 setEX(设置key过期时间)
// 目前已经不推荐使用,因为setNX 和 setEX是两条命令,无法保障原子性
// SET key value [NX | XX] [GET] [EX seconds | PX milliseconds]
> set username:root:pwd NX EX 10  // 如果不存在key为“ username:root:pwd”则设置,并10秒过期
  1. List

List是一个有序字符串数据列表,可以头操作也可以尾操作,数据序号从左到右从小到大,在获取数据时可以实时,也可以阻塞。

// lpush 、rpush、lpop、 rpop 其中push为存入列表,pop表示从列表移除并返回
> rpush username:list root0 root1 root2
> (integer) 3 // 可批量操作,添加3个元素
> lindex username:list 0   // 返回第一个元素 lrange key start stop 可以返回指定范围数据
> "root0"
> lpop username:list    // 移除并返回第一
> "root0"
// blpop、brpop 表示阻塞式获取元素 ,可设置过期时间,0:则表示一直阻塞知道获取值,其中key可以是多个
> blpop username:list password:list 0
> "username:list"
> "root"
  1. Hash

Hash通过key可以存储value是一个键值对的值,适合存储对象信息。由于value又是一个key-value结构,故value的每一个field都可以当做一个String类型进行使用。(例如设置过期时间、数字递增等)

// 一次添加多个field
> hset root:info:hash col1 1 col2 2 col3 3 col4 4 col5 5 col6 6 col7 7
> (integer) 7
> hget root:info:hash col1 // 获取key中的col1的值
> "1"
// hkeys 获取所有key,hvals 获取所有值 ,hgetall 获取key的所有字段和值,hscan可以扫描字段
> hscan root:info:hash 0 match "col*" // 遍历key中字段是以col开头的字段与值
  1. Set

Set是一个无序的不重复数据集合,同时可以进行交集、并集、差集运算。

// 添加元素 sadd
> sadd username:set root1 root2 root1
> (integer) 2 // 这里只存入了两个,因为有root1重复了
// spop 随机返回一个或者多个元素,sismember 判定当前set是否包含元素
> sismember username:set root2
> (integer) 1
// sinter 取交集,sunion取并集,sdiff取差集
> sinter username:set username2:set
> "root2"
  1. ZSet

Zset类型是一个有序集合,相比Set多了一个可以进行排序的score字段,同样数据不可以重复,但是score分数可以重复。

// 添加元素并制定分数
> zadd username:score 1 root1 2 root2 3 root3
> (integer) 3
// 将指定元素的分数增加
> zincrby username:score 100 root1
> "101"
// ZREVRANGE/ZREVRANGE key start stop [WITHSCORES]    正序/倒序 获取下标从start到stop结束
// ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT offset count] 返回有序集合中指定分数区间内的成员,分数由低到高排序
> zrevrangebyscore username:score 200 0 withscores limit 0 2
1) "root1"
2) "101"
3) "root3"
4) "3"
// ZRANGEBYLEX key min max [LIMIT offset count]   
// 返回指定成员区间内的成员,min/max : - 表示负无穷 +表示正无穷,也可以是字符串值,表示成员的边界
 > zrangebylex username:score [roo [root2
1) "room"
2) "room1"
3) "room2"
4) "room3"
5) "room4"
6) "root2"
  1. BitMap

Bitmap,即位图,是一串连续的二进制数组(0和1),可以通过偏移量(offset)定位元素。BitMap通过最小的单位bit来进行0|1的设置,表示某个元素的值或者状态,比如签到场景。

// 设置值,其中value只能是 0 和 1
SETBIT key offset value
// 获取值
GETBIT key offset
// 获取指定范围内值为 1 的个数,start 和 end 以字节为单位
BITCOUNT key start end
// BitMap间的运算 operations 位移操作符,枚举值
// AND 与运算 &
// OR 或运算 |
// XOR 异或 ^
// NOT 取反 ~
// result 计算的结果,会存储在该key中
// key1 … keyn 参与运算的key,可以有多个,空格分割,not运算只能一个key
// 当 BITOP 处理不同长度的字符串时,较短的那个字符串所缺少的部分会被看作 0。返回值是保存到 destkey 的字符串的长度(以字节byte为单位),和输入 key 中最长的字符串长度相等。
BITOP [operations] [result] [key1] [keyn…]
// 返回指定key中第一次出现指定value(0/1)的位置
BITPOS [key] [value]
  1. HyperLogLog

HyperLogLog用于统计一个集合中不重复的元素个数,主要是针对大数据量,其使用的内存会很小,但是其统计是基于概率完成,故不是很准确,标准误算率0.81%。如果需要准确计算则还是使用set,但是set在数据量很大时占用的内存也会很高。HyperLogLog典型的使用场景,用于统计网页用户的访问计数。

// 添加元素 PFADD key user1 user2 user3 user4 user5
> pfadd username:hyper root1 root2 root3 root1 root2
(integer) 1
> pfcount username:hyper	// pfcount key [key ...]  统计
(integer) 3
  1. GEO

主要用于存储地理位置,涉及经纬度,可以将地址信息存储之后,搜索附近的场景。

// 添加元素 GEOADD key [NX | XX] [CH] longitude latitude member [longitude latitude member ...]
// 获取元素经纬度 GEOPOS key member [member ...]
> geoadd username:geo 1 1 lyj 1 2 lc 1 3 yy 2 3 rx 3 3 hh
(integer) 5
// GEODIST key member1 member2 [m|km|ft|mi]  返回两个给定位置之间的距离,根据地球模型的经纬度计算实际距离
> geodist username:geo lyj lc m
"111226.3808"

// 根据用户给定的经纬度坐标来获取指定范围内的地理位置集合。
// GEORADIUS key longitude latitude radius m|km|ft|mi [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count] [ASC|DESC] [STORE key] [STOREDIST key]
> georadius username:geo 2 2 150000 m withdist asc
1) 1) "lc"
   2) "111158.5488"
2) 1) "rx"
   2) "111226.4015"
  1. Stream

专门为消息队列设计的数据类型,消息有序,支持按照消费组读取消息。不适合做传统的消息处理,适合小量消息的任务队列,或是事件队列。

// * 表示让 Redis 为插入的数据自动生成一个全局唯一的 ID
> XADD userinfo:stream * name root
"1746675468456-0"
// Block 阻塞式读取消息,命令最后的“$”符号表示读取最新的消息
> XREAD BLOCK 10000 STREAMS userinfo:stream $
(nil)
(10.00s)

数据结构

Redis数据在存储时,dict内存放了两个hash表,一个用于存储数据,一个则用于扩容逻辑。在Hash表里,key和value存放的都是指针,key指向String对象,value则可以指向支持的所有类型。在进行扩容先将hash表2扩容到hash表1内存的两倍,然后进行渐进式rehash,在此期间,当请求访问hash表1进行访问修改操作时,会同时将数据迁移到hash表2,新增的数据直接存入hash表2,故此时数据会存储在两个hash表中,查询时会先查询hash表1没有则查询表2,这样当所有key都访问到之后,便完成了迁移。

  1. SDS

SDS即simple dynamic string 简单动态字符串,由于redis是C语言开发的,其中C语言的char类型由于是数组存储并且以“\0”作为结束符,计算字符串长度得遍历统计时间复杂度为O(n),同时因为“\0”作为结束符,故存储的内容如果一旦存在“\0”则字符串会被截断,其次char类型在一初始化时固定了长度,后续的字符串操作,不会自动扩容,容易导致缓冲区溢出。

SDS为了解决以上问题,会有单独的一个字段记录长度,数据存储是一个字节数组,支持自动扩容,这样既能存储字符,也可以存储文件、图片等二进制数据。

  1. 链表

C语言并没有链表,Redis自己定义链表结构,其数据结构和Java的链表结构类似,维护了头指针、尾指针、长度以及每一个节点要维护前后指针。在Redis中List类型在数据量小时,使用压缩列表,数据量大时使用链表,在3.2版本使用quicklist,在5.0版本之后,改为listpack。

优点:可以使用不连续的内存空间,可以直接访问头和尾数据。

缺点:由于内存空间不连续,导致对CPU缓存(预取机制)利用率低,其次每一个节点都需要维护节点头(保存前后指针等),内存开销比较大。

  1. 压缩列表

压缩列表是由连续的内存块存储的数据列表,类似数组,但是每一个节点都会维护自身内存的大小和前一个元素的内存大小(已知当前节点指针,通过前一个元素内存大小,通过偏移量可以访问前一个元素),由于每个节点保存了前一个节点的长度,故当前一个节点的长度变长时,会导致当前节点用于存储该长度的内存需要扩大,极端场景下,就会导致下一个节点也需要更新,进而引起连锁更新。

例如:压缩列表每个元素长度都是254,当前元素只需要使用一个字节就可以存下前一个节点的长度,但是第一个元素长度变长到258了,Redis就会扩容到用5个字节存储前一个节点的长度,这样就会导致每一个元素都需要扩容,导致连锁更新。

虽然压缩列表内存连续CPU缓存使用率高,但是数据访问时间复杂度为O(n),又存在连锁更新问题,故压缩列表只适合存储少量的数据,虽然连锁更新也可能存在,但是数据量小可以容忍。Redis 对象(List 对象、Hash 对象、Zset 对象)包含的元素数量较少时,使用压缩列表。

这里为什么压缩列表不像数组那样可以随机访问,而是需要遍历整个列表?

因为Java中的数组一般需要指定数据类型,简单数据类型则每一个元素内存都是固定大小的,随机访问即通过:下标 * 固定内存 = 偏移量,但是redis的压缩列表中的数据是不定长的,故无法从起始指针点通过偏移量随机访问,只可以遍历每个元素,在通过元素的长度计算偏移量访问下一个元素。

  1. 哈希表

哈希表和Java中的Map的数据结构相似,即哈希桶 + 链表 实现,即当hash冲突之后,桶所在的数据变成链表,但是redis的哈希表不涉及红黑树。当达到负载因子时触发扩容,负载因子 = 哈希表已存储的节点数量 / 哈希表大小 。

  • 当负载因子大于等于 1 ,并且 Redis 没有在执行 bgsave 命令或者 bgrewiteaof 命令,也就是没有执行 RDB 快照或没有进行 AOF 重写的时候,就会进行 rehash 操作。
  • 当负载因子大于等于 5 时,此时说明哈希冲突非常严重了,不管有没有有在执行 RDB 快照或 AOF 重写,都会强制进行 rehash 操作。

Redis 的 Hash 对象的底层实现之一是压缩列表(最新 Redis 代码已将压缩列表替换成 listpack),Hash 对象的另外一个底层实现就是哈希表。

  1. 整数集合

整数集合是Set对象在元素数据量不大的时候,用于存储整数的集合,内存连续空间,好比Java中的数组,由于整数的长度可预测,故一开始每个元素使用固定的8位进行存储(如果可以存储的下),如果后续存入的数字8位存储不下,则需要进行扩容,比如16位,这样就需要先把已存储的整数,全部扩容到16位,然后再存入新的数据。

这里为什么需要把已经存储的数据同步扩容到16位,使用8位不是更节省内存吗?

可能是因为要保证元素支持随机访问,如果元素有的8位有的16位,这样就无法通过偏移量进行随机访问了。

  1. 跳表

Redis 只有 Zset 对象的底层实现用到了跳表,跳表的优势是能支持平均 O(logN) 复杂度的节点查找。跳表的重点是设计相邻两层的节点数量为2:1(即第4层只有1个节点,第3层有2个节点,第2层有4个节点),这样才能将复杂度降为O(logN)。

跳表数据是有序的,在查询或添加数据时,都从头结点的最上层开始,找到指向的下一个节点,先进行比较,如果小于则直接下一层继续比较,直到大于指向的节点,然后走到指向节点,再与该节点指向下一个节点进行比较,以此类推。

Redis在创建节点时,会从[0,1]之间获取随机数,当小于0.25时,该节点就加一层,直到大于0.25结束,即加一层的概率为25%,层数越高概率越低,层高最大64层。

  1. quicklist

在Redis3.2时,将List的实现改成了quicklist,即双向链表 + 压缩列表,每一个链表的节点对应一个压缩列表,数据存储在压缩列表中,当压缩列表内存不足时,便新增一个压缩列表用于存储新的数据。

  1. listpack

listpack类型是压缩列表的升级版,是一个连续的空间存储,每一个元素存储当前节点的类型编码、数据、长度,(这里不再存储前一个节点的长度,避免连锁更新)可以通过节点的长度基于偏移量进行访问。

问题:当前节点不再存储前一个节点的长度,那么listpack是如何实现节点往前遍历的呢?

由于listpack的每一个元素,类型编码+数据+长度是按顺序存储的,同时长度存储使用几个字节也是不确定的,但是存储长度的每一个字节,最高位会是一个状态位,1:表示继续,0:表示结束,那么如果当前节点的指针在index位置,则先向前读取一个字节,如果最高位是1,则剩余的7位转为10进制的长度大小,然后继续往前读一个字节,直到最高位为0截止,将读到的所有字节存储的数字相加即为前一个元素的长度,在通过偏移量读取数据。

持久化

AOF持久化

一般数据库或是中间件的持久化,都会以操作日志的形式进行存储,Redis也不例外,AOF日志就是一种以操作日志的持久化方式,但是只针对写操作命令进行持久化。

Redis进行写命令操作时,会将操作日志写入server.aof_buf缓冲区,server.aof_buf是一个内存里的动态字符串缓冲区即SDS类型。在一个执行事件结束之后,会调用write()将server.aof_buf里的内容写入磁盘的page cache,这里page cache合适刷新到磁盘文件及真正的写入aof文件里,基于appendfsync配置有三种情况:

  • always:每次操作命令执行完之后,立刻调用write()和fsync()进行写文件与刷盘。
  • everysec:在执行操作命令和write()之后,由后台线程每秒调用一次fsync()进行刷盘。
  • no:即将命令写入page cache之后,完全依赖系统内核自主刷盘(通常30s作用)。

问题:AOF文件不断写入后,文件越来越大必然会导致启动加载数据时非常慢,redis是怎么解决的?

Redis为了避免AOF文件越来越大,引入了AOF重写机制,即当文件大小达到阈值之后,会重新读取数据库的所有数据生成命令,写入一个新的AOF文件,然后用新的文件替代旧的文件。

问题:当AOF重写时,如果有新的写操作请求,这样重写的AOF日志不就存在脏数据了吗?

Redis的主进程会阻塞基于fork创建子进程,同时复制物理内存的虚拟内存页共享给子进程(写时复制COW),然后子进程就会根据内存页读取数据生成命令写一个新的AOF文件,而主进程创建完子进程之后,重新开始接收请求,针对写操作,主进程会继续先将操作命令写入旧的AOF文件,同时会将新增的写命令写入一个AOF重写缓冲区,待子进程完成AOF文件重写之后重置主进程,此时主进程阻塞,将AOF重写缓冲区的命令追加写入新的AOF文件,然后替换旧的AOF文件,这样保证数据不丢无脏数据。

RDB快照持久化

通过AOF持久化,由于主线程既需要处理业务数据,又需要写日志,必然会影响整体的性能,同时服务重启重读AOF文件重放数据时,性能也会随命令条数启动的越来越慢。这样Redis提供了一种快照形式的持久化方式,即在每各一个阶段(比如60秒内,至少100次修改,支持设置多种策略),会将数据做一次全量快照,把内存中的数据存入磁盘。

问题:在RDB持久化过程中,有新的写入数据怎么处理?

这里同AOF情况一样,在触发RDB持久化时,Redis会通过写时复制(copy-on-write),将内存页共享给子进程,由子进程将内存的数据持久化到磁盘,但是这里如果有新增的写操作,虽然会新复制一个内存用于写操作,但是写入的数据只能等到下一次的持久化时才能写文件,即服务重启时,这部分数据会丢失。

RDB&AOF结合

由于AOF和RDB两种场景都存在一定的问题,所以Redis支持两种模式结合的方式,即子进程先通过RDB模式对数据进行快照持久化,然后再把AOF重写缓冲区的写操作命令追加到持久化文件中,这样这种混合的AOF文件前半部分是RDB全量数据格式,后半部分是AOF增量命令格式。

Redis数据过期&内存淘汰策略

Key过期删除

在Redis使用过程中,我们可以对key设置过期时间,其中过期删除策略有定时删除、惰性删除、定期删除。Redis默认使用惰性删除 + 定期删除,针对设置了过期时间的key,redis会将这些“key-过期时间”存入一个哈希表。

  1. 定时删除

在设置key的过期时间时,会同时创建一个定时事件,由定时事件处理器自动执行key的过期删除,这样可以尽快的释放内存,但是当key比较多时,定时时间处理器会占用CPU降低性能

  1. 惰性删除

每次访问key时,才会检测key是否过期,如果过期则删除该key。这样针对删除key不会占用额外的CPU资源,但是可能会导致内存浪费。

  1. 定期删除

默认每秒10次,从数据库中随机抽取一定数据量设置了过期时间的key进行检查,并删除其中的过期key,如果本轮删除的key占比超过了25%,则继续直到未达到25%或超时(避免阻塞主线程)。这样相比定时删除虽然减少了CPU的占用,但是也存在部分过期数据会在一定的时间窗口占用内存。

内存淘汰策略

key的过期删除是针对设置了过期时间key的删除策略,但是当redis存储的数据达到设置maxmemory的最大运行内存时,就会触发内存淘汰策略,redis默认不淘汰,避免数据无故丢失。

  1. noeviction:不进行淘汰,当达到内存阈值之后,拒绝写入新数据,只允许删除和读操作。
  2. volatile-lru:从设置过期时间的键中,淘汰最近最少使用(LRU)的键。
  3. volatile-lfu:从设置过期时间的键中,淘汰最不经常使用(LFU)的键(Redis 4.0+)。
  4. volatile-ttl:从设置过期时间的键中,淘汰剩余生存时间(TTL)最短的键。
  5. volatile-random:从设置过期时间的键中,随机淘汰一个键。
  6. allkeys-lru:从所有键中,淘汰最近最少使用(LRU)的键。
  7. allkeys-lfu:从所有键中,淘汰最不经常使用(LFU)的键(Redis 4.0+)。
  8. allkeys-random:从所有键中,随机淘汰一个键。

LRU & LFU

LRU最近最少使用,通过一个链表维护,数据每次用到时就会把数据移动到链表头,删除数据时只需要删除尾部元素即可,但是这样的话,就需要额外维护一个链表以及每次访问都需要移动元素到链表头的额外开销。

Redis的LRU是对元素添加了一个额外的字段记录最后一次访问时间,在进行内存淘汰时,随机抽样淘汰最久没有使用的那个。

LRU算法存在一个问题,就是有一些数据可能只被读取一次,但是这些数据会缓存很长一段时间不被回收。

LFU最近最不常用,会有一个频次哈希表和相同频次的链表维护(链表根据最近访问时间排序),当一个数据被访问时频次会+1,并将数据添加到对应频次的链表中,这样淘汰数据时直接淘汰最小频次的链表数据。其中频次的计算是需要加上时间的衰减的,这里实现标准需要用户自己实现。

Redis的LFU是在元素中有字段记录了最近访问时间和频次,其中频次不是简单的+1,而是有相应的计算公式,同样Redis的淘汰也是随机抽样数据进行淘汰。

Redis单线程模型

Redis为什么性能这么高,其一因为完全是内存操作之外,还有一部分原因是其使用的I/O多路复用+事件驱动模型。

  1. Redis启动时会初始化Socket Server绑定到监听端口默认6379,并触发监听。
  2. 当客户端发起连接请求时,IO多路复用器检测到socket server可读,通知事件循环并分发到连接处理器处理;
  3. 连接处理器创建连接之后,将客户端对应的Socket的AE_READABLE事件注册到事件循环。
  4. 客户端发送命令数据,IO多路复用检测到该客户端对应的Socket可读,通知事件循环读取客户端数据,并执行命令,将返回的数据写入输出缓冲区;
  5. Redis在返回数据写入输出缓冲区时,直接进行响应返回输出,由于内核缓冲区可能写满了,故未返回的数据继续留在输出缓冲区,然后触发Socket的AE_WRITABLE事件;
  6. 事件循环监听到之后,将缓冲区剩余未相应的数据继续输出。

Redis缓存一致性&缓存击穿与穿透

缓存一致性

平时在使用redis时,主要是用作缓存,这里就涉及到数据库与缓存一致性的问题。保证缓存一致性的一般解决方案就是先更新数据库然后删除缓存,在读取数据时如果缓存没有就从数据库加载,然后添加到缓存。该方案中修改数据库与删除缓存由于是操作两个数据库的两次操作,无法做到原子性,故删除缓存操作需要失败重试。失败重试实现方案可以使用MQ,也可以自己数据库记录之后定时重试。还有一种方案就是监听MySQL的binlog针对数据的操作发送MQ消息,然后重试删除。

缓存击穿与穿透

当大量的热点数据同时过期,就会导致并发请求大量的直接访问到数据库,这样的话数据库可能直接宕机,这种现象叫做缓存击穿。一般可以基于分布式锁限制控制一个线程去加载数据,其他线程阻塞等待,或是数据不过期,采用后台定时任务更新缓存。

针对数据既不在缓存也不在数据库时,这个时候恶意请求就会刷爆数据库,这样的情况就叫做缓存穿透,针对这种场景可以对数据库不存在的结果缓存一个空值,或者使用布隆过滤器。但是布隆过滤器需要对所有数据进行初始化,基于多个hash算法计算的结果,判定如果一个值在多个hash桶里都不存在,则表名一定不存在这个值,如果部分存在,则可能数据库存在(存在hash冲突)。

Redis分布式锁

针对基于redis实现分布式锁,一开始都是基于setnx,但是会存在很多问题,比如设置锁和设置过期时间不是原子操作、过期时间到了锁自动释放但是业务还没执行完、再就是锁重入的问题等等,这里直接推荐使用redisson工具包,完美解决以上问题。

针对锁过期,redisson会在创建锁时,另起一个线程自动为锁持有者进行续期,针对操作原子性redisson是基于lua脚本实现的。

Redis事务

redis针对多条命令的执行,是基于MULTI...命令1、命令2...EXEC命令来实现的,其中redis只保证命令要么都执行(即使中间有命令执行失败),要么都不执行,且不支持失败回滚。其中Watch命令可以监视一个或是多个key,在EXEC命令执行前(执行exec命令时,会一起执行操作命令)如果监视的key值发生了变化,则会取消执行。

lua脚本天然的支持原子性,适合编写多条语句以及条件判断。

学习的多了,知道的就少了

参考

https://xiaolincoding.com/redis/

最近发表
标签列表