深入理解 Redis:特性、应用场景与实践指南
在当今数字化时代,高并发、高性能的应用需求层出不穷。Redis,作为一款基于内存的高性能键值对存储系统,犹如一颗璀璨的明星,在各类应用场景中发挥着举足轻重的作用。无论是提升系统响应速度,还是实现复杂的业务逻辑,Redis 都展现出了强大的实力。接下来,让我们一同深入探索 Redis 的奥秘。
一、何为Redis?
Redis,即 Remote Dictionary Server,是一个开源的、基于内存的数据结构存储系统。它支持多种数据结构,如字符串(String)、列表(List)、哈希(Hash)、集合(Set)、有序集合(Sorted Set)等,并且能够将数据持久化到磁盘。Redis 的设计目标是提供高性能、低延迟的数据访问,因此它在内存中存储数据,这使得它的读写速度极快,能够轻松应对高并发的场景
二、Redis的特性
2.1 高性能
Redis 将所有数据存储在内存中,内存的读写速度远高于磁盘,这使得 Redis 具有极高的性能。据测试,Redis 的读速度可达 110000 次 / 秒,写速度可达 81000 次 / 秒,如此惊人的速度让它成为处理高并发业务的首选。例如,在电商大促活动中,大量的商品查询请求可以通过 Redis 快速响应,保证用户的购物体验。
2.2 丰富的数据结构
Redis 支持多种数据结构,每种数据结构都有其独特的应用场景。
2.2.1 常见的数据类型
Redis中常见的五种数据类型:String(字符串)、Hash(哈希)、List(列表)、Set(集合)、Zset(有序集合)
结构类型 | 结构存储的值 | 结构的读写能力 |
---|---|---|
String(字符串) | 可以是字符串、整数或浮点数 | 对整个字符串或字符串的一部分进行操作,对整数或浮点数进行自增或自减 |
List(列表) | 链表,每一个节点都包含一个字符串 | 可以对链表的两端进行push和pop操作,读取单个或多个元素,根据值查找或删除 |
Set(集合) | 包含字符串的无序集合 | 字符串集合,除去基础的增、删、查,还有计算交、并、差集 |
Hash(散列) | 包含键值对的无序散列表 | 添加、获取、删除单个元素 |
Zset(有序集合) | 和散列一样用于存储键值对 | 字符串成员与浮点数分数之间的有序映射 ;元素的排列顺序由分数的大小决定 ;包含方去有添加、获取、删除单个元素以及根据分值范围或成员来获取元素 |
2.2.2 应用场景
Redis 五 种 数 据 类 型 的 应 用 场 景 :
String 类 型 的 应 用 场 景 : 缓存对象 、常规计数 、分布式锁 、共享session信息等 。
List类型的应用场景:消息队列(但是有两个问题:1.生产者需要自行实现全局唯一ID;2.不能以消费组形式消费数据)等。
Hash类型:缓存对象、购物车等。
Set类型:聚合计算(并集、交集、差集)场景,比如点赞、共同关注、抽奖活动等。
Zset类型:排序场景,比如排行榜、电话和姓名排序等。
2.2.3 内部数据结构
五种数据类型的内部结构:
String类型内部实现
String类型的底层数据结构是SDS(简单动态字符串)
- SDS不仅可以保存文本,还可以保存二进制数据。
- SDS获取字符串长度的时间复杂度为O(1)。因为SDS中存在一个len字段记录了自身的长度,所以复杂度为O(1)。
- Redis的SDS API是安全的,拼接字符串不会造成缓冲区溢出。SDS在拼接字符串前会检查自身空间是否足够,如果空间不足会自动扩容。
List类型内部实现
List类型的底层数据结构是由双向链表或压缩列表实现的
- 如果列表的元素个数小于512个(默认值,可由list-max-ziplist-entries 配 置 ),列表每个元素的值都小于64字节(默认值,可由list-max-ziplist-value配置),Redis会使用压缩列表作为List的底层数据结构。
- 如果不满足上述条件,Redis会使用双向链表作为List的底层数据结构。
Hash类型内部实现
Hash类型的底层数据结构是由压缩列表或哈希表实现的
- 如果散列中的元素个数小于512个,每个元素的值都小于64字节,Redis会使用压缩列表作为Hash的底层数据结构。
- 如果不满足上述条件,Redis会使用哈希表作为Hash的底层数据结构。
Set类型内部实现
Set类型的底层数据结构是由整数集合或哈希表实现的
- 如果集合中的元素都是整数并且个数小于512个,Redis会使用整数集合作为Set的底层数据结构。
- 如果不满足上述条件,Redis会使用哈希表作为Set的底层数据结构。
ZSet类型内部实现
ZSet类型的底层数据结构是压缩列表或跳表实现的
- 如果有序集合中的元素个数小于128个,并且每个元素的值都小于64字节,Redis会使用压缩列表作为ZSet的底层数据结构。
- 如果不满足上述条件,Redis会使用跳表作为ZSet的底层数据结构。
2.3 Redis的线程模型
Redis是单线程的。但并不是指Redis这个程序是单线程的,Redis单线程指的是【接收客户端请求->解析请求->进行数据读写操作等->发送数据给客户端】这个过程是由一个线程(主线程)来执行的。
2.3.1 Redis执行各种操作都是单线程的为什么速度这么快呢?
Redis 虽然采用单线程模型(核心网络 IO 和数据操作由单个线程处理),但性能却非常出色,这看似矛盾的现象背后,是多个设计层面的优化共同作用的结果。以下从核心原因展开详细说明:
2.3.1.1 基于内存操作,避免磁盘 IO 瓶颈
Redis 的所有数据都存储在内存中,而内存的读写速度(微秒级)远快于磁盘(毫秒级,差距可达 10 万倍以上)。单线程操作内存时,无需等待磁盘 IO,自然能高效处理请求。
- 对比:传统数据库(如 MySQL)需要频繁读写磁盘,即使有缓存,也难免因磁盘操作阻塞线程,而 Redis 完全规避了这一核心瓶颈。
2.3.1.2 单线程避免了多线程的开销
多线程模型看似能并行处理任务,但实际会引入额外开销:
- 线程切换成本:操作系统切换线程时需要保存 / 恢复上下文(寄存器、栈等),频繁切换会浪费 CPU 资源。
- 锁竞争问题:多线程操作共享数据时,需通过锁(如互斥锁)保证线程安全,锁的获取 / 释放会导致阻塞,降低效率。
Redis 的单线程模型从根本上避免了这些问题,所有命令在一个线程内串行执行,无需考虑锁和线程切换,减少了资源消耗。
2.3.1.3 高效的 IO 多路复用机制
单线程并非只能处理一个请求,Redis 通过 IO 多路复用技术(如 Linux 的 epoll
、BSD 的 kqueue
)实现了单线程处理多个网络连接的并发请求。
- 原理:Redis 用一个线程监听多个客户端的网络连接,当某个连接有数据可读 / 可写时,IO 多路复用器会通知线程处理该连接,而无需阻塞等待其他连接。
- 效果:单线程能高效处理上万甚至几十万的并发连接,避免了为每个连接创建线程的开销。
2.3.1.4 精简的代码与命令处理流程
Redis 的核心代码(基于 C 语言)非常精简,命令处理逻辑直接、高效:
- 命令解析和执行均在内存中完成,无复杂的磁盘操作或事务日志(除非开启持久化,但持久化由专门的子进程处理,不阻塞主线程)。
- 数据结构设计优化:Redis 为每种数据类型(如 String、Hash、Sorted Set)实现了高效的底层结构(如 String 用动态字符串
SDS
,Hash 用压缩列表或哈希表),操作复杂度低(多数命令为 O (1) 或 O (logN))。
2.3.1.5 避免了不必要的功能开销
Redis 专注于高性能的键值存储,不支持复杂的 SQL 查询、表关联等功能,减少了解析复杂语法和处理多表关系的开销。其命令设计简洁(如 GET
、SET
、HSET
),执行路径短,进一步提升了速度。
总结
Redis 的 “单线程快” 本质是 扬长避短:
- 扬长:利用内存的高速特性、精简的代码和高效数据结构,最大化单线程的处理能力。
- 避短:通过 IO 多路复用解决单线程的并发连接问题,通过规避多线程的切换和锁开销减少资源浪费。
这使得 Redis 在处理纯内存操作、短命令、高并发场景时,性能远超许多多线程磁盘数据库。不过需注意:单线程模型也意味着 Redis 不适合处理耗时过长的命令(如 KEYS *
),否则会阻塞整个线程,影响其他请求。
2.4 持久化
Redis 支持两种持久化方式:RDB(Redis Database)和 AOF(Append Only File)。
2.4.1 RDB
RDB:在指定的时间间隔内将内存中的数据集快照写入磁盘,它生成的文件是一个紧凑的二进制文件。这种方式适合大规模数据的恢复,但可能会丢失最近一次快照后的部分数据。
命令:save,作用:手动执行一次保存操作(不推荐,若是当前数据过多,会导致阻塞)
命令:bgsave,作用:手动执行一次保存操作,但不是立即执行,而是生成一个子进程在后台进行保存操作
bgsave工作原理
Fork的作用是复制一个与当前进程一样的进程。新进程的所有数据(变量、环境变量、程序计数器等) 数值都和原进程一致,但是是一个全新的进程,并作为原进程的子进程
2.4.2 AOF
AOF:以日志的形式记录服务器所处理的每一个写操作,在服务器启动时,通过重新执行这些命令来重建数据集。AOF 的优点是数据完整性更高,基本可以做到不丢失数据,但文件体积可能会较大。
AOF执行过程
- 客户端的请求写命令会被append追加到AOF缓冲区内;
- AOF缓冲区根据AOF持久化策略[always,everysec,no]将操作sync同步到磁盘的AOF文件中;
- AOF文件大小超过重写策略或手动重写时,会对AOF文件rewrite重写,压缩AOF文件容量;
- Redis服务重启时,会重新load加载AOF文件中的写操作达到数据恢复的目的;
AOF写数据三种策略(appendfsync)
- always(每次) ->每次写入操作均同步到AOF文件中,数据零误差,性能较低
- everysec(每秒) ->每秒将缓冲区中的指令同步到AOF文件中,数据准确性较高,性能较高 在系统突然宕机的情况下丢失1秒内的数据
- no(系统控制)->由操作系统控制每次同步到AOF文件的周期,整体过程不可控
两种持久化方式对比:
持久化方式 | RDB | AOF |
占用存储空间 | 小(数据级:压缩) | 大(指令级:重写) |
存储速度 | 慢 | 块 |
恢复速度 | 块 | 慢 |
数据安全性 | 会丢失数据 | 依据策略决定 |
资源消耗 | 高/重量级 | 低/轻量级 |
启动优先级 | 低 | 高 |
2.5 删除策略
Redis的删除策略有两种一种是基于过期时间的删除策略,一种是基于内存淘汰的删除策略。
2.5.1 基于过期时间的删除策略
Redis是一种内存级数据库,所有数据均存放在内存中,内存中的数据可以通过TTL指令获取其状态
- XX :具有时效性的数据
- -1 :永久有效的数据
- -2 :已经过期的数据或被删除的数据或未定义的数据
过期数据的删除策略分为三种:定时删除、惰性删除、定期删除。
定时删除
- 创建一个定时器,当key设置有过期时间,且过期时间到达时,由定时器任务立即执行对键的删除 操作
- 优点:节约内存,到时就删除,快速释放掉不必要的内存占用
- 缺点:CPU压力很大,无论CPU此时负载量多高,均占用CPU,会影响redis服务器响应时间和指 令吞吐量
- 总结:用处理器性能换取存储空间(拿时间换空间)
惰性删除
- 数据到达过期时间,不做处理。等下次访问该数据时
- 如果未过期,返回数据
- 发现已过期,删除,返回不存在
- 优点:节约CPU性能,发现必须删除的时候才删除
- 缺点:内存压力很大,出现长期占用内存的数据
- 总结:用存储空间换取处理器性能(拿空间换时间)
定期删除
- Redis启动服务器初始化时,读取配置server.hz的值,默认为10
- 每秒钟执行server.hz次serverCron()中的方法---databasesCron()---activeExpireCycle() activeExpireCycle()对每个expires[*]逐一进行检测,每次执行250ms/server.hz
- 对某个expires[*]检测时,随机挑选W个key检测
- 如果key超时,删除key
- 如果一轮中删除的key的数量>W * 25%,循环该过程
- *如果一轮中删除的key的数量≤W * 25%,检查下一个expires[*],0-15循环
- W取值=ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP属性值
- 参数current_db用于记录activeExpireCycle() 进入哪个expires[*] 执行
- 如果activeExpireCycle()执行时间到期,下次从current_db继续向下执行
2.4.2 基于内存淘汰的删除策略
Redis使用内存存储数据,在执行每一个命令前,会调用freeMemoryIfNeeded()检测内存是否充 足。如果内存不满足新加入数据的最低存储要求,redis要临时删除一些数据为当前指令清理存储 空间。清理数据的策略称为逐出算法。
maxmemory-policy删除策略
- 检测易失数据(可能会过期的数据集server.db[i].expires )
① volatile-lru:挑选最近最少使用的数据淘汰
② volatile-lfu:挑选最近使用次数最少的数据淘汰
③ volatile-ttl:挑选将要过期的数据淘汰
④ volatile-random:任意选择数据淘汰
- 检测全库数据(所有数据集server.db[i].dict )
⑤ allkeys-lru:挑选最近最少使用的数据淘汰
⑥ allkeys-lfu:挑选最近使用次数最少的数据淘汰
⑦ allkeys-random:任意选择数据淘汰
- 放弃数据驱逐
⑧ no-enviction(驱逐):禁止驱逐数据(redis4.0中默认策略),会引发错误OOM(Out Of Memory)达到最大内存后的,对被挑选出来的数据进行删除的策略
三、Redis的应用场景
3.1 缓存
缓存是 Redis 最常见的应用场景之一。通过将热点数据存储在 Redis 中,可以大大减少对后端数据库的访问次数,降低数据库的压力,提高系统的响应速度。例如,在电商平台中,商品的详情信息、用户的登录信息等经常被访问的数据可以缓存到 Redis 中。当用户请求这些数据时,首先从 Redis 中获取,如果 Redis 中不存在,则从数据库中查询,并将查询结果存入 Redis 中,以便后续使用。
3.2 分布式锁
在分布式系统中,多个进程或线程可能会同时访问共享资源,为了保证数据的一致性和完整性,需要使用分布式锁。Redis 可以通过SETNX(Set if Not eXists)命令来实现分布式锁。SETNX命令只有在键不存在时才会设置成功,返回 1;如果键已存在,则设置失败,返回 0。例如,在电商的秒杀场景中,多个用户同时抢购商品,为了防止超卖,需要使用分布式锁来保证同一时刻只有一个用户能够成功下单。在使用分布式锁时,还需要设置锁的过期时间,以防止死锁的发生。
一个可靠的分布式锁必须满足的 4 个核心条件:
- 互斥性:同一时刻只能有一个进程持有锁,防止并发操作冲突。
- 安全性:锁只能被持有它的进程释放,防止其他进程误删锁。
- 避免死锁:即使持有锁的进程崩溃,锁也能在一定时间后自动释放,避免资源永久阻塞。
- 高可用:锁服务需具备高可用能力,避免单点故障导致整个系统不可用。
Redis 分布式锁的设计与优化,都是围绕这 4 个条件展开的。
3.2.1 用“键值对”表示锁的状态
Redis分布式锁的本质是通过“键的存在性”表示锁的状态:
- 当进程需要获取锁时,尝试在 Redis 中创建一个特定的键(如lock:order:100,表示 “订单 100 的锁”),若键不存在则创建成功(获取锁),若键已存在则创建失败(获取锁失败)。
- 当进程完成操作后,删除该键释放锁,其他进程即可重新竞争。
3.2.2 核心命令:SET key value NX PX timeout
- NX(Not Exists):仅当键不存在时才创建,保证互斥性(同一时刻只有一个进程能创建成功)。
- PX timeout:为键设置毫秒级过期时间(如PX 30000表示 30 秒后自动删除),避免死锁(即使进程崩溃,锁也会自动释放)。
- value:通常设置为一个唯一标识(如 UUID),用于后续验证 “锁的持有者”,确保安全性(防止其他进程误删锁)。
例如:某进程尝试获取“订单100的锁”,使用UUID(abc123)作为唯一标识,设置三十秒过期时间
SET lock:order:100 abc123 NX PX 30000
- 若返回OK,表示成功获取锁,可执行临界区操作。
- 若返回nil,表示锁已经被其他线程持有,获取失败。
3.2.3 释放锁的正确方法,避免“误删”与“提前释放”
释放锁时间,不能简单执行DEL命令(可能误删其他线程持有的锁),而需通过“判断锁的持有者是否为当前线程”+“删除锁”的原子操作实现。
直接DEL命令的隐患
假设进程 A 持有锁(value 为abc123),但因操作耗时超过锁的过期时间(30 秒),锁已自动释放并被进程 B 获取(value 为def456)。此时若进程 A 执行DEL lock:order:100,会误删进程 B 持有的锁,导致互斥性失效。
解决方案:Lua 脚本实现原子验证与删除
Redis支持通过Lua脚本执行多个命令,确保操作的原子性。释放锁的Lua脚本逻辑为:
- 检查Redis中锁的value是否等于当前进程的唯一标识(如abc123)。
- 若相等:执行DEL命令释放锁。
- 若不等:不执行任何操作(避免误删其他进程的锁)。
四、Redis的企业级解决方案
4.1 缓存预热
一个新的服务器,启动后迅速宕机
原因:
- 请求数量较高
- 主从之间数据吞吐量较大,数据同步操作频度较高,因为刚刚启动时,缓存中没有任何数据
解决方案:
- 日常例行统计数据访问记录,统计访问频度较高的热点数据
- 将统计结果中的数据分类,根据级别,redis优先加载级别较高的热点数据
4.2 缓存雪崩
缓存雪崩指的是在同一段时间内大量的缓存key同时失效或者Redis服务器宕机,导致大量请求到达数据库上,带来巨大压力。
解决方案:
- 给不同的key的TTL(剩余存活时间)添加随机值
- 给业务添加多级缓存
- 利用Redis集群增加服务的可用性
- 给缓存业务增加降级限流策略
4.3 缓存击穿
缓存击穿问题也叫热点key问题,它指的是一个被高并发访问并且缓存业务难以重建的key突然失效,无数的请求会在瞬间给数据库带来巨大的冲击。
解决方案:
- 互斥锁
- 逻辑过期
4.3.1 互斥锁
因为锁能实现互斥性。假设线程过来,只能一个人一个人的来访问数据库,从而避免对于数据库访问压 力过大,但这也会影响查询的性能,因为此时会让查询的性能从并行变成了串行,我们可以采用 tryLock方法 + double check来解决这样的问题。
假设现在线程1过来访问,他查询缓存没有命中,但是此时他获得到了锁的资源,那么线程1就会一个人 去执行逻辑,假设现在线程2过来,线程2在执行过程中,并没有获得到锁,那么线程2就可以进行到休 眠,直到线程1把锁释放后,线程2获得到锁,然后再来执行逻辑,此时就能够从缓存中拿到数据了。
4.3.2 逻辑过期
我们之所以会出现这个缓存击穿问题,主要原因是在于我们对key设置了过期时间,假设我 们不设置过期时间,其实就不会有缓存击穿的问题,但是不设置过期时间,这样数据不就一直占用我们 内存了吗,我们可以采用逻辑过期方案。
我们把过期时间设置在 redis的value中,注意:这个过期时间并不会直接作用于redis,而是我们后续 通过逻辑去处理。假设线程1去查询缓存,然后从value中判断出来当前的数据已经过期了,此时线程1 去获得互斥锁,那么其他线程会进行阻塞,获得了锁的线程他会开启一个 线程去进行 以前的重构数据 的逻辑,直到新开的线程完成这个逻辑后,才释放锁, 而线程1直接进行返回,假设现在线程3过来访 问,由于线程线程2持有着锁,所以线程3无法获得锁,线程3也直接返回数据,只有等到新开的线程2把 重建数据构建完后,其他线程才能走返回正确的数据。
这种方案巧妙在于,异步的构建缓存,缺点在于在构建完缓存之前,返回的都是脏数据。
比较:
- 互斥锁方案:由于保证了互斥性,所以数据一致,且实现简单,因为仅仅只需要加一把锁而已,也没其 他的事情需要操心,所以没有额外的内存消耗,缺点在于有锁就有死锁问题的发生,且只能串行执行性 能肯定受到影响
- 逻辑过期方案: 线程读取过程中不需要等待,性能好,有一个额外的线程持有锁去进行重构数据,但是 在重构数据完成前,其他的线程只能返回之前的数据,且实现起来麻烦
4.4 缓存穿透
缓存穿透 :缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这 些请求都会打到数据库。
解决方案:
- 缓存空对象:
- 优点:实现简单,维护方便
- 缺点:额外的内存开销,可能造成短期的不一致
- 布隆过滤:
- 优点:内存占用少,没有多余的key
- 缺点:实现复杂,存在误判的可能
- 缓存空对象:当我们客户端访问不存在的数据时,先请求redis,但是此时redis中没有数据, 此时会访问到数据库,但是数据库中也没有数据,这个数据穿透了缓存,直击数据库,我们都知道数据 库能够承载的并发不如redis这么高,如果大量的请求同时过来访问这种不存在的数据,这些请求就都会 访问到数据库,简单的解决方案就是哪怕这个数据在数据库中也不存在,我们也把这个数据存入到redis 中去,这样,下次用户过来访问这个不存在的数据,那么在redis中也能找到这个数据就不会进入到缓存了。
- 布隆过滤:布隆过滤器其实采用的是哈希思想来解决这个问题,通过一个庞大的二进制数组,走哈希思 想去判断当前这个要查询的这个数据是否存在,如果布隆过滤器判断存在,则放行,这个请求会去访问 redis,哪怕此时redis中的数据过期了,但是数据库中一定存在这个数据,在数据库中查询出来这个数 据后,再将其放入到redis中, 假设布隆过滤器判断这个数据不存在,则直接返回
五、总结
Redis 作为一款功能强大的内存数据存储系统,以其高性能、丰富的数据结构、持久化支持、高可用和分布式等特性,在众多应用场景中发挥着关键作用。从缓存加速到消息队列,从分布式锁到实时排行榜,Redis 为开发者提供了高效、便捷的解决方案。在实际项目中,合理地使用 Redis,并根据业务需求进行优化和配置,能够显著提升系统的性能和稳定性。希望通过本文的介绍,读者能够对 Redis 有更深入的理解,并在自己的项目中充分发挥 Redis 的优势。