线程模型
redis 内部使用文件事件处理器 file event handler,这个文件事件处理器是单线程的,所以 redis 才叫做单线程的模型。它采用 IO 多路复用机制同时监听多个 socket,根据 socket 上的事件来选择对应的事件处理器进行处理。
文件事件处理器的结构包含 4 个部分:
- 多个 socket
- IO 多路复用程序
- 文件事件分派器
- 事件处理器(连接应答处理器、命令请求处理器、命令回复处理器)
多个 socket 可能会并发产生不同的操作,每个操作对应不同的文件事件,但是 IO 多路复用程序会监听多个 socket,会将 socket 产生的事件放入队列中排队,事件分派器每次从队列中取出一个事件,把该事件交给对应的事件处理器进行处理。
数据类型
Redis可以存储键与5种不同数据结构类型之间的映射,这5种数据结构类型分别为STRING(字符串)、LIST(列表)、SET(集合)、HASH(散列)和ZSET(有序集合)。
| 结构类型 | 结构存储的值 | 结构的读写能力 |
|---|---|---|
| STRING | 可以是字符串、整数或者浮点数 | 对整个字符串或者字符串的其中一部分执行操作;对整数和浮点数执行自增或者自减操作 |
| LIST | 一个链表,链表上的每个节点都包含了一个字符串 | 从链表的两端推入或者弹出元素;根据偏移量对链表进行修剪;读取单个或者多个元素;根据值查找或者移除元素 |
| SET | 包含字符串的无序收集器,并且被包含的每个字符串都是独一无二的、各不相同的 | 添加、获取、移除的单个元素;检查一个元素是否存在于集合中;计算交集、并集、差集;从集合里面随机获取元素 |
| HASH | 包含键值对的无序散列表 | 添加、获取、移除单个键值对;获取所有键值对 |
| ZSET(有序集合) | 字符串成员(member)与浮点数权值(分值,score)之间的有序映射,元素的排列顺序由分值的大小决定 | 添加、获取、删除单个元素;根据分值范围或者成员来获取成员 |
字符串STRING

字符串可以存储三种类型的值:字节串、整数、浮点数。下面是对Redis字符串执行自增和自减操作的命令:
如果用户对一个不存在的键或者一个保存了空串的键执行自增或者自减操作,那么Redis在执行操作时会将这个键的值当作是0来处理。如果用户尝试对一个值无法被解释为整数或者浮点数的字符串执行自增或者自减操作,那么Redis将向用户返回一个错误。
除了自增操作和自减操作之外,Redis还拥有对字节串的其中一部分进行读取或者写入的操作:
列表LIST

Redis的列表允许用户从序列的两端推入或者弹出元素,获取列表元素,以及执行各种常见的列表操作。除此之外,列表还可以用来存储任务信息、最近浏览过的文章或者常用联系人信息。下面是一部分常用的列表命令:
其次下面还有一些可以将元素从一个列表移动到另一个列表,或者阻塞执行命令的客户端直到有其他客户端给列表添加元素为止的命令:
集合SET

Redis的集合以无序的方式来存储多个各不相同的元素,用户可以快速地对集合执行添加元素操作、移除元素操作以及检查一个元素是否存在于集合里:
除此之外,集合真正厉害的地方在于组合和关联多个集合:
散列HASH

Redis的散列可以让用户将多个键值对存储到一个Redis键里面。下面是最常用的散列命令,其中包括添加和删除键值对的命令、获取所有键值对的命令,以及对键值对的值进行自增或者自建操作的命令:
下面是散列的其它几个批量操作命令,以及一些和字符串操作类似的散列命令:
有序集合ZSET

和散列存储着键与值之间的映射类似,有序集合也存储着成员与分值之间的映射,并且提供了分值处理命令,以及根据分值大小有序的获取或者扫描成员和分值的命令。
除此之外,下面是另外一些非常有用的有序集合命令:
数据结构
字典(渐进式rehash)
在Redis种,键值对存储方式是由字典(Dict)保存的,而字典底层是通过哈希表来是西安的,通过哈希表中的节点保存字典中的键值对。我们知道当HashMap中由于hash冲突超过某个阈值时,处于链表性能的考虑,会进行Resize操作,Redis也一样。
在Redis的具体实现中,使用了一种叫做渐进式rehash的方式来提高字典的缩放效率,避免rehash对服务器性能的影响,渐进式rehash的好处在于它采取分而治之的方式,将rehash键值对所需的计算工作均摊到对字典的每个添加、删除、查找和更新操作上,从而避免了集中式rehash而带来的庞大计算量。
字典结构
字典的结构如下:
1 | typedef struct dict { |
字典中存放的哈希表结构如下:
1 | typedef struct dictht { |
节点的结构如下:
1 | typedef struct dictEntry { |
从哈希表节点结构中,可以看出,在redis中解决hash冲突的方式为采用链地址法。key和v分别用于保存键值对的键和值。
渐进式rehash的步骤
在Redis中,扩展或收缩哈希表需要将ht[0]中的所有键值对rehash到ht[1]里面,但是这个rehash动作并不是一次性、集中式的完成,而是分多次、渐进式的完成的。为了避免rehash对服务器性能造成影响,服务器不是一次性将ht[0]里面的所有键值对全部rehash到ht[1]中,而是分多次、渐进式的将ht[0]里面的键值对慢慢的rehash到ht[1]。
以下是哈希表渐进式rehash的详细步骤:
- 为ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表。
- 在字典中维持一个索引计数器变量rehashidx,并将它的值设置为0,表示rehash工作正式开始。
- 在rehash进行期间,每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定的操作以外,还会顺带将ht[0]哈希表在rehashidx索引上的所有键值对rehash到ht[1]中,当rehash工作完成之后,程序将rehashidx属性的值加1。
- 随着字典操作的不断执行,最终在某个时间节点上,ht[0]的所有键值对都会被rehash至ht[1],这时程序将rehashidx属性的值设为-1,表示rehash操作已完成。
渐进式rehash的好处在于它采取分而治之的方式,将rehash键值对所需要的计算工作均摊到对字典的每个添加、删除、查找和更新操作上,从而避免了集中式rehash而带来的庞大计算量。
渐进式rehash执行期间的哈希表
因为在渐进式rehash的过程中,字典会同时使用ht[0]和ht[1]两个哈希表,所以在渐进式rehash进行期间,字典的删除、查找和更新等操作都会在两个哈希表上进行,例如要在字典里面查找一个键的话,程序会先在ht[0]里面进行查找,如果没找到就会继续在ht[1]里面进行查找。
另外,在渐进式rehash执行期间,新添加到字典的键值对一律会被保存到ht[1]中,而ht[0]则不再进行任何添加操作。这一措施保证了ht[0]包含的键值对数量只减不增,并随着rehash操作的执行而最终变成空表。
渐进式rehash的问题
渐进式rehash避免了Redis阻塞,可以说非常完美,但是由于在rehash时,需要分配一个新的hash表,在rehash期间,同时有两个hash表使用,会使得Redis内存使用量瞬间突增,在Redis满容状态下,由于rehash会导致大量Key驱逐。
跳表skiplist
跳表是有序集合的底层实现之一。跳表(skiplist)是一种随机化的数据,它是基于多指针有序链表实现,可以看成多个有序链表。
在查找时,从上层指针开始查找,找到对应的区间再进行到下一层去查找,下面是查找22的过程:
跳表的特点如下:
- 跳跃表是一种随机化数据结构,查找、添加、删除操作都可以在对数期望时间下完成。
- 跳跃表目前在 Redis 的唯一作用,就是作为有序集类型的底层数据结构(之一,另一个构成有序集的结构是字典)。
- 为了满足自身的需求,Redis 对跳表进行了修改,包括:
- score 值可重复。
- 对比一个元素需要同时检查它的 score 和 memeber 。
- 每个节点带有高度为 1 层的后退指针,用于从表尾方向向表头方向迭代。
与红黑树等平衡树相比,跳跃表具有以下优点:
- 插入速度非常快速,因为不需要进行旋转等操作来维护平衡性。
- 更容易实现。
- 支持无锁操作。
键的过期时间
Redis中有个设置时间过期的功能,即对存储在 Redis 数据库中的值可以设置一个过期时间。作为一个缓存数据库,这是非常实用的。如我们一般项目中的 Token 或者一些登录信息,尤其是短信验证码都是有时间限制的,按照传统的数据库处理方式,一般都是自己判断过期,这样无疑会严重影响项目性能。
我们 set key 的时候,都可以给一个 expire time,就是过期时间,通过过期时间我们可以指定这个 key 可以存活的时间。
如果假设你设置了一批 key 只能存活1个小时,那么接下来1小时后,redis是怎么对这批key进行删除的?Redis有两种删除策略:
- 定期删除:Redis默认是每隔 100ms 就随机抽取一些设置了过期时间的key,检查其是否过期,如果过期就删除。注意这里是随机抽取的。为什么要随机呢?你想一想假如 redis 存了几十万个 key ,每隔100ms就遍历所有的设置过期时间的 key 的话,就会给 CPU 带来很大的负载!
- 惰性删除 :定期删除可能会导致很多过期 key 到了时间并没有被删除掉。所以就有了惰性删除。假如你的过期 key,靠定期删除没有被删除掉,还停留在内存里,除非你的系统去查一下那个 key,才会被redis给删除掉。
但是仅仅通过设置过期时间还是有问题的。我们想一下:如果定期删除漏掉了很多过期 key,然后你也没及时去查,也就没走惰性删除,此时会怎么样?如果大量过期key堆积在内存里,导致Redis内存块耗尽了。怎么解决这个问题呢? Redis 内存淘汰机制。
Redis内存淘汰机制
Redis可以设置内存最大使用量,当内存使用量超出时,会施行数据淘汰策略:
| 策略 | 描述 |
|---|---|
volatile-lru |
从已设置过期时间的数据集中挑选最近最少使用的数据淘汰 |
allkeys-lru |
当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的Key(这个是最常用的) |
volatile-random |
从已设置过期时间的数据集中任意选择数据淘汰 |
allkeys-random |
从数据集中任意选择数据淘汰 |
volatile-ttl |
从已设置过期时间的数据集中挑选将要过期的数据淘汰 |
no-eviction |
禁止驱逐数据,也就是说当内存不足以容纳新写入数据时,新写入操作会报错。 |
Redis4.0版本后增加以下两种:
| 策略 | 描述 |
|---|---|
volatile-lfu |
从已设置过期时间的数据集中挑选最不经常使用的数据淘汰 |
allkeys-lfu |
当内存不足以容纳新写入数据时,在键空间中,移除最不经常使用的Key |
持久化
Redis是内存型数据库,为了保证数据在断电后不会丢失,需要将内存中的数据持久化到硬盘上,Redis通过了两种不同的持久化方法来将数据存储到硬盘里面:快照(RDB)和只追加文件(AOF)。
- 快照(RDB):它可以将存在于某一时刻的所有数据都写入硬盘里面。
- 只追加文件(AOF):它会在执行写命令时,将被执行的写命令复制到硬盘里面。
这两种方法既可以同时使用,也可以单独使用,在某些情况下甚至可以两种方法都不使用。
快照持久化(RDB)
Redis可以通过创建快照来获得存储在内存里面的数据在某个时间点上的副本。在创建快照之后,用户可以对快照进行备份,可以将快照复制到其他服务器从而创建具有相同数据的服务器副本,还可以将快照留在原地以便重启服务器时使用。
根据配置,快照将被写入 dbfilename 选项指定的文件里面,并存储在dir选项指定的路径上面。如果在新的快照文件创建完毕之前,Redis、系统或者硬件这三者之中的任意一个崩溃了,那么Redis将会丢失最近一次创建快照之后写入的所有数据。
创建快照的办法有以下几种:
- 客户端可以通过向Redis发送
BGSAVE命令来创建以恶搞快照。对于支持BGSAVE命令的平台来说(基本所有平台都支持,除了Windows平台),Redis会调用fork来创建一个子进程,然后子进程负责将快照写入硬盘,而父进程则继续处理命令请求。 - 客户端还可以通过向Redis发送
SAVE命令来创建一个快照,接到SAVE命令的Redis服务器在创建完毕之前将不再响应任何其它命令。SAVE命令并不常用,我们通常只会在没有足够内存去执行BGSAVE命令的情况下,又或者即使等待持久化操作执行完毕也无所谓的情况下,才会使用这个命令。 - 如果用户设置了save配置选项,比如
save 60 10000,那么从Redis最近一次创建快照之后开始算起,当“60秒之内有10000次写入”这个条件被满足时,Redis就会自动触发BGSAVE命令。如果用户设置了多个save配置选项,那么当任意一个save配置选项所设置的条件被满足时,Redis就会触发一次BGSAVE命令。 - 当Redis通过SHUTDOWN命令接收到关闭服务器的请求时,或者接收到标准的TERM信号时,会执行一个SAVE命令,阻塞所有客户端,不在执行客户端发送的任何命令,并在SAVE命令执行完毕之后关闭服务器。
- 当一个Redis服务器连接另一个Redis服务器,并向对方发送SYNC命令来开始一次复制操作时,如果主服务器目前没有在执行BGSAVE操作,或者主服务器并非刚刚执行完BGSAVE操作,那么主服务器就会执行BGSAVE命令。
在使用快照持久化来保存数据时,一定要记住:如果系统真的发生崩溃,用户将丢失最近一次生成快照之后更改的所有数据。因此,快照持久化只适用于那些即使丢失一部分数据也不会造成问题的应用程序,而不能接受这种数据损失的应用程序,则可以考虑下面的AOF持久化方式。
只追加文件持久化(AOF)
AOF持久化会将被执行的写命令写到AOF文件的末尾,以此来记录数据发生的变化。因此,Redis只要从头到尾执行一次AOF文件包含的所有写命令,就可以恢复AOF文件所记录的数据集。AOF文件可以通过设置appendonly yes配置选项来打开。
使用AOF持久化需要设置同步选项,从而确保写命令同步到磁盘文件上的时机:
| 选项 | 同步频率 | 性能 |
|---|---|---|
| always | 每个Redis写命令都要同步写入硬盘 | 这样做会严重降低Redis的速度和服务器的性能 |
| everysec | 每秒执行一次同步,显式的将多个写命令同步到硬盘 | 比较合适,可以保证系统崩溃时只会丢失一秒左右的数据,并且Redis每秒执行一次同步对服务器性能几乎没有任何影响 |
| no | 让操作系统来决定应该何时进行同步 | 并不能给服务器性能带来多大的提升,而且也会增加系统崩溃时数据丢失的数量 |
重写/压缩AOF文件
因为Redis会不断的将被执行的写命令记录到AOF文件里面,所以随着Redis不断运行,AOF文件的体积也会不断的增长,在极端的情况下,体积不断增大的AOF文件甚至可能会用完硬盘的所有可用空间。
还有另外一个问题就是,因为Redis再重启之后需要通过重新执行AOF文件记录的所有写命令来还原数据,所以如果AOF文件的体积非常大,那么还原操作执行的时间就可能会非常长。
为了解决AOF文件体积不断增大的问题,用户可以向Redis发送BGREWRITENAOF命令,这个命令会通过移除AOF文件中的冗余命令来重写AOF文件,使AOF文件的体积变得尽可能小。
BGWRITENAOF的工作原理和BGSAVE创建快照的工作原理非常相似:Redis会创建一个子进程,然后由子进程负责对AOF文件进行重写。因为AOF文件重写也需要用到子进程,所以快照持久化因为创建子进程而导致的性能问题和内存占用问题,在AOF持久化中也同样存在。
复制
对于一个正在运行的服务器,用户可以通过SLAVEOF host port命令来让服务器开始复制一个新的主服务器,也可以通过发送SLAVEOF no one命令来让服务器终止复制操作,不再接受主服务器的数据更新。
复制的启动过程

Redis再复制进行期间也会尽可能的处理接收到的命令请求,但是,如果主从服务器之间的网络带宽不足,或者主服务器没有足够的内存来创建子进程和创建记录写命令的缓冲区,那么Redis处理命令请求的效率就会收到影响。因此,尽管这并不是必须的,但是在实际中最好还是让主服务器只使用50%~65%的内存,留下30%~45%的内存用于执行BGSAVE命令和创建记录写命令的缓冲区。
当多个从服务器尝试连接同一个主服务器时,就会出现下表中的其中一种情况:
主从链
随着负载不断上升,主服务器可能无法很快地更新所有从服务器,或者重新连接和重新同步从服务器将导致系统超载。为了解决这个问题,可以创建一个中间层来分担主服务器的复制工作。中间层的服务器是最上层服务器的从服务器,又是最下层服务器的主服务器。
检验硬盘写入
为了验证主服务器是否已经将写数据发送至从服务器,用户需要在向主服务器写入真正的数据之后,再向主服务器写入一个唯一的虚构值,然后通过检查虚构值是否存在于从服务器来判断写数据是否已经到达从服务器。
更节约时间的做法是,检查INFO命令的输出结果中aof_pending_bio_fsync属性的值是否为0,如果是的话,那么就表示服务器已经将一致的所有数据都保存到硬盘里面了。
事务
在Redis中实现事务主要依靠以下几个命令来实现:
Redis事务从开始到结束通常会通过三个阶段:
- 事务开始
- 命令入队
- 事务执行
事务的特点:
- 事务提供了一种将多个命令打包,然后一次性、有序地执行的机制。
- 事务在执行过程中不会被中断,所有事务命令执行完之后,事务才能结束。
- 多个命令会被入队到事务队列中,然后按先进先出(FIFO)的顺序执行。
- 带 WATCH 命令的事务会将客户端和被监视的键在数据库的 watched_keys 字典中进行关联,当键被修改时,程序会将所有监视被修改键的客户端的 REDIS_DIRTY_CAS 选项打开。
- 只有在客户端的 REDIS_DIRTY_CAS 选项未被打开时,才能执行事务,否则事务直接返回失败。
- Redis 的事务保证了 ACID 中的一致性(C)和隔离性(I),但并不保证原子性(A)和持久性(D)。
- Redis同一个事务中如果有一条命令执行失败,其后的命令仍然会被执行,没有回滚。
缓存雪崩、缓存穿透和缓存击穿
缓存雪崩
简介:缓存同一时间大面积的失效,所以,后面的请求都会落到数据库上,造成数据库短时间内承受大量请求而崩掉。
解决办法:
- 事前:尽量保证整个 Redis 集群的高可用性,发现机器宕机尽快补上。选择合适的内存淘汰策略。
- 事中:本地ehcache缓存 + hystrix限流&降级,避免MySQL崩掉。限流组件,可以设置每秒的请求,有多少能通过组件,剩余的未通过的请求,怎么办?走降级!可以返回一些默认的值,或者友情提示,或者空白的值。
- 事后:利用 redis 持久化机制保存的数据尽快恢复缓存。
缓存穿透
简介:一般是黑客故意去请求缓存中不存在的数据,导致所有的请求都落到数据库上,造成数据库短时间内承受大量请求而崩掉。
解决方式很简单,每次系统 A 从数据库中只要没查到,就写一个空值到缓存里去,比如 set -999 UNKNOWN。然后设置一个过期时间,这样的话,下次有相同的 key 来访问的时候,在缓存失效之前,都可以直接从缓存中取数据。
缓存击穿
缓存击穿,就是说某个 key 非常热点,访问非常频繁,处于集中式高并发访问的情况,当这个 key 在失效的瞬间,大量的请求就击穿了缓存,直接请求数据库,就像是在一道屏障上凿开了一个洞。
不同场景下的解决方式可如下:
- 若缓存的数据是基本不会发生更新的,则可尝试将该热点数据设置为永不过期。
- 若缓存的数据更新不频繁,且缓存刷新的整个流程耗时较少的情况下,则可以采用基于 redis、zookeeper 等分布式中间件的分布式互斥锁,或者本地互斥锁以保证仅少量的请求能请求数据库并重新构建缓存,其余线程则在锁释放后能访问到新缓存。
- 若缓存的数据更新频繁或者缓存刷新的流程耗时较长的情况下,可以利用定时线程在缓存过期前主动的重新构建缓存或者延后缓存的过期时间,以保证所有的请求能一直访问到对应的缓存。
Redis的并发竞争Key
在并发情况下,写请求可能发⽣生竞争,例例如将age的数值加1,⼀一般是取出age,加1,然后set回去。因为是两步操作,存在并发问题,例例如A线程还没set回去时候,其他线程完成了了+1的操作。一般可以通过如下⽅方式解决:
- *使用自带的INCR原子命令。 *
- 客户端对两步操作加锁,Java原生的或者redis的setnx 。
- 使用redis的事务功能。
Redis通过multi,exec,watch完成对事务的⽀支持。事务能将多个命令打包执⾏,Redis⼀次性、按顺序执⾏,然后结果⼀起返回,执⾏事务过程中不会中断去处理其他请求。
Redis将事务中的命令放进一个队列,当exec请求时,依次执行队列中的命名,将结果一次放进一个结果队列,然后返回结果队列,需要注意的是,Redis不会因为任意命令的失败而进⾏回滚操作,不论成功失败,都会全部执行完,然后返回结果。
*Redis通过watch实现事务的乐观锁,每个redis实例都维护着一个watch_keys字典,记录着key被监视的客户端,当有修改请求时,就会将监视这个key的所有客户端的REDIS_DIRTY_CAS标识打开。当请求exec时,服务器器发现请求客户端的标示打开,说明有数据发⽣生了了修改,则会拒绝执⾏提交的事务。 *
缓存与数据库双写时的数据一致性
一般情况下我们都是这样使用缓存的:先读缓存,缓存没有的话,就读数据库,然后取出数据后放入缓存,同时返回响应。这种方式很明显会存在缓存和数据库的数据不一致的情况。
一般来说,如果允许缓存可以稍微的跟数据库偶尔有不一致的情况,也就是说如果你的系统不是严格要求 “缓存+数据库” 必须保持一致性的话,最好不要做这个方案,即:读请求和写请求串行化,串到一个内存队列里去。
串行化可以保证一定不会出现不一致的情况,但是它也会导致系统的吞吐量大幅度降低,用比正常情况下多几倍的机器去支撑线上的一个请求。
读的时候,先读缓存,缓存没有的话,就读数据库,然后取出数据后放入缓存,同时返回响应。更新的时候,先更新数据库,然后再删除缓存。
如果删除缓存失败了,那么会导致数据库中是新数据,缓存中是旧数据,数据就出现了不一致。
解决思路:先删除缓存,再更新数据库。如果数据库更新失败了,那么数据库中是旧数据,缓存中是空的,那么数据不会不一致。因为读的时候缓存没有,所以去读了数据库中的旧数据,然后更新到缓存中。
数据发生了变更,先删除了缓存,然后要去修改数据库,此时还没修改。一个请求过来,去读缓存,发现缓存空了,去查询数据库,查到了修改前的旧数据,放到了缓存中。随后数据变更的程序完成了数据库的修改。完了,数据库和缓存中的数据不一样了…
解决思路:
- 延迟双删
- 更新数据的时候,根据数据的唯一标识,将操作路由之后,发送到一个 jvm 内部队列中。读取数据的时候,如果发现数据不在缓存中,那么将重新执行“读取数据+更新缓存”的操作,根据唯一标识路由之后,也发送到同一个 jvm 内部队列中。
一个队列对应一个工作线程,每个工作线程串行拿到对应的操作,然后一条一条的执行。这样的话,一个数据变更的操作,先删除缓存,然后再去更新数据库,但是还没完成更新。此时如果一个读请求过来,没有读到缓存,那么可以先将缓存更新的请求发送到队列中,此时会在队列中积压,然后同步等待缓存更新完成。
这里有一个优化点,一个队列中,其实多个更新缓存请求串在一起是没意义的,因此可以做过滤,如果发现队列中已经有一个更新缓存的请求了,那么就不用再放个更新请求操作进去了,直接等待前面的更新操作请求完成即可。
Redis与Memcache的区别
