Redis面试题

2022/1/21

# Redis面试题

redis常见面试题:吃透这份Redis学习笔记,直接把阿里面试官按在地上摩擦! (opens new window)

# 1. 什么是redis?

Redis (Remote Dictionary Server)就是一个使用 C 语言开发的数据库,与传统数据库不同的是 Redis 的数据是存在内存中的 ,也就是它是内存数据库,所以读写速度非常快,因此 Redis 被广泛应用于缓存方向。

Redis 除了做缓存之外,Redis 也经常用来做分布式锁,甚至是消息队列。

Redis 提供了多种数据类型来支持不同的业务场景。Redis 还支持事务 、持久化、Lua 脚本、多种集群方案。

# 2. 分布式缓存常见的技术选型

# 2.1 缓存类型

本地缓存是内存访问,没有远程交互开销,性能最好,但是受限于单机容量,一般缓存较小且无法扩展。

分布式缓存一般都具有良好的水平扩展能力,对较大数据量的场景也能应付自如。缺点就是需要进行远程请求,性能不如本地缓存。

分布式缓存主要解决的是单机缓存的容量受服务器限制并且无法保存通用的信息。因为,本地缓存只在当前服务里有效,比如如果你部署了两个相同的服务,他们两者之间的缓存数据是无法共同的。

Memcached 是分布式缓存最开始兴起的那会,比较常用的。后来,随着 Redis 的发展,大家慢慢都转而使用更加强大的 Redis 了。

为了平衡这种情况,实际业务中一般采用多级缓存,本地缓存只保存访问频率最高的部分热点数据,其他的热点数据放在分布式缓存中。

# 2.1 Redis 和 Memcached 的区别和共同点

共同点

  1. 都是基于内存的数据库,一般都用来当做缓存使用。
  2. 都有过期策略。
  3. 两者的性能都非常高。

区别

  1. Redis 支持更丰富的数据类型(支持更复杂的应用场景)。Redis 不仅仅支持简单的 k/v 类型的数据,同时还提供 list,set,zset,hash 等数据结构的存储。Memcached 只支持最简单的 k/v 数据类型。
  2. Redis 支持数据的持久化,可以将内存中的数据保持在磁盘中,重启的时候可以再次加载进行使用,而 Memecache 把数据全部存在内存之中。
  3. Redis 有灾难恢复机制。 因为可以把缓存中的数据持久化到磁盘上。
  4. Redis 在服务器内存使用完之后,可以将不用的数据放到磁盘上。但是,Memcached 在服务器内存使用完之后,就会直接报异常。
  5. Memcached 没有原生的集群模式,需要依靠客户端来实现往集群中分片写入数据;但是 Redis 目前是原生支持 cluster 模式的.
  6. Memcached 是多线程,非阻塞 IO 复用的网络模型;Redis 使用单线程的多路 IO 复用模型。 (Redis 6.0 引入了多线程 IO )
  7. Redis 支持发布订阅模型、Lua 脚本、事务等功能,而 Memcached 不支持。并且,Redis 支持更多的编程语言。
  8. Memcached 过期数据的删除策略只用了惰性删除,而 Redis 同时使用了惰性删除与定期删除。

相信看了上面的对比之后,我们已经没有什么理由可以选择使用 Memcached 来作为自己项目的分布式缓存了。

# 3. Redis有哪些优缺点?

优点:

  • 性能极高 – Redis 能读的速度是 110000 次/s,写的速度是 81000 次/s 。
  • 丰富的数据类型
  • 原子 – Redis 的所有操作都是原子性的,意思就是要么成功执行要么失败完全不执行。(多操作使用事务保证原子性)
  • 丰富的特性 – Redis 还支持 publish/subscribe, 通知, key 过期等等特性。

缺点:

  • 容量受到物理内存的限制,不能用作海量数据的高性能读写。因此Redis适合的场景主要局限在较小数据量的高性能操作和运算上。

# 4. 为什么要用 Redis /为什么要用缓存?

从高性能和高并发的角度去思考。

高性能

用户第一次访问数据库中的高频数据比较慢(硬盘读取),如果将用户高频访问而且不经常改变的数据放到redis,用户下次访问直接从缓存中读取(读取内存),大大提高访问速度。

注:但是要注意数据库与缓存的数据一致性。

高并发

一般像 MySQL 这类的数据库的 QPS 大概都在 1w 左右(4 核 8g) ,但是使用 Redis 缓存之后很容易达到 10w+,甚至最高能达到 30w+(就单机 redis 的情况,redis 集群的话会更高)。

QPS(Query Per Second):服务器每秒可以执行的查询次数

高频访问数据转移到缓存可以大大提升系统的并发能力。

# 5. 如何保障mysql和redis之间的数据一致性?

mysql和redis的数据一致性

缓存与数据库双写时数据不一致的问题。不管是先写数据库,再删除缓存;还是先删除缓存,再写库,都有可能出现数据不一致的情况。具体情况如下:

  1. 如果删除了缓存Redis,还没有来得及写库MySQL,另一个线程就来读取,发现缓存为空,则去数据库中读取数据写入缓存,此时缓存中为脏数据。
  2. 如果先写了库,在删除缓存前,写库的线程宕机了,没有删除掉缓存,则也会出现数据不一致情况。

因为写和读是并发的,没法保证顺序,就会出现缓存和数据库的数据不一致的问题。如何解决?

# 方案一:延时双删

在写库前后都进行redis.del(key)操作,并且设定合理的超时时间。

伪代码如下:

public void write(String key,Object data){
    redis.delKey(key);
    db.updateData(data);
    Thread.sleep(500);
    redis.delKey(key);
}
1
2
3
4
5
6

具体的步骤就是:

1)先删除缓存

2)再写数据库

3)休眠500毫秒

4)再次删除缓存

那么,这个500毫秒怎么确定的,具体该休眠多久呢?

需要评估自己的项目的读数据业务逻辑的耗时。这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。

当然这种策略还要考虑redis和数据库主从同步的耗时。最后的的写数据的休眠时间:则在读数据业务逻辑的耗时基础上,加几百ms即可。比如:休眠1秒。

设置缓存过期时间

从理论上来说,给缓存设置过期时间,是保证最终一致性的解决方案。所有的写操作以数据库为准,只要到达缓存过期时间,则后面的读请求自然会从数据库中读取新值然后回填缓存。

该方案的弊端

结合双删策略+缓存超时设置,这样最差的情况就是在超时时间内数据存在不一致,而且又增加了写请求的耗时。

# 方案二:异步更新缓存(基于订阅binlog的同步机制)

整体思路:

MySQL binlog增量订阅消费+消息队列+增量数据更新到redis

1)读Redis:热数据基本都在Redis

2)写MySQL:增删改都是操作MySQL

3)更新Redis数据:MySQ的数据操作binlog,来更新到Redis

Redis更新

1)数据操作主要分为两大块:

  • 一个是全量(将全部数据一次写入到redis)
  • 一个是增量(实时更新)

这里说的是增量,指的是mysql的update、insert、delate变更数据。

2)读取binlog后分析 ,利用消息队列,推送更新各台的redis缓存数据。

这样一旦MySQL中产生了新的写入、更新、删除等操作,就可以把binlog相关的消息推送至Redis,Redis再根据binlog中的记录,对Redis进行更新。

其实这种机制,很类似MySQL的主从备份机制,因为MySQL的主备也是通过binlog来实现的数据一致性。

这里可以结合使用canal(阿里的一款开源框架),通过该框架可以对MySQL的binlog进行订阅,而canal正是模仿了mysql的slave数据库的备份请求,使得Redis的数据更新达到了相同的效果。

当然,这里的消息推送工具你也可以采用别的第三方:kafka、rabbitMQ等来实现推送更新Redis。

参看:高并发架构系列:Redis缓存和MySQL数据一致性方案详解 (opens new window)

# 6.Redis有哪些数据类型及使用场景

# String

K-V结构。

value如果是数字,可以进行计数操作。

常用操作:

# 返回 key 所储存的字符串值的长度
strlen key 
# 批量设置 key-value 类型的值
mset key1 value1 key2 value2
# 批量获取多个 key 对应的 value
mget key1 key2 
 # 将 key 中储存的数字值增一
incr number
 # 将 key 中储存的数字值减一
decr number
# 数据在 60s 后过期
expire key  60 
# 数据在 60s 后过期 (setex:[set] + [ex]pire)
setex key 60 value 
# 查看数据还有多久过期
ttl key
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

使用场景:常用在需要计数的场景,比如用户的访问次数、热点文章的点赞转发数量等等。

# List

Redis 的 list 的实现为一个 双向链表,即可以支持反向查找和遍历。特点是易于数据元素的插入和删除并且且可以灵活调整链表长度,但是链表的随机访问困难。

应用场景: 发布与订阅或者说消息队列、慢查询。

常用操作:

实现队列和栈

redis实现栈和队列

查看对应下标范围的列表元素(实现分页)

# 查看对应下标的list列表, 0 为 start,1为 end
lrange myList 0 1 
# 查看列表中的所有元素,-1表示倒数第一
lrange myList 0 -1 
查看链表长度
llen myList
1
2
3
4
5
6

# Hash

hash 类似于 JDK1.8 前的 HashMap,内部实现也差不多(数组 + 链表)。

hash 是一个 string 类型的 field 和 value 的映射表,特别适合用于存储对象,后续操作的时候,你可以直接仅仅修改这个对象中的某个字段的值。 比如我们可以 hash 数据结构来存储用户信息,商品信息等等。

应用场景: 系统中对象数据的存储。

常用操作:

# 存储
hmset userInfoKey name "guide" description "dev" age "24"
# 查看 key 对应的 value中指定的字段是否存在
hexists userInfoKey name 
# 获取存储在哈希表中指定字段的值
hget userInfoKey name 
# 获取在哈希表中指定 key 的所有字段和值
hgetall userInfoKey 
 # 获取 key 列表
hkeys userInfoKey
# 获取 value 列表
hvals userInfoKey 
# 修改某个字段对应的值
hset userInfoKey name "GuideGeGe" 
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# Set

set 类似于 Java 中的 HashSet 。Redis 中的 set 类型是一种无序集合,集合中的元素没有先后顺序。当你需要存储一个列表数据,又不希望出现重复数据时,set 是一个很好的选择,并且 set 提供了判断某个成员是否在一个 set 集合内的重要接口,这个也是 list 所不能提供的。

可以基于 set 轻易实现交集、并集、差集的操作。比如:你可以将一个用户所有的关注人存在一个集合中,将其所有粉丝存在一个集合。Redis 可以非常方便的实现如共同关注、共同粉丝、共同喜好等功能。这个过程也就是求交集的过程。

常用操作:

# 添加元素进去
sadd mySet value1 value2 
# 查看 set 中所有的元素
smembers mySet 
# 查看 set 的长度
scard mySet
# 检查某个元素是否存在set 中,只能接收单个元素
sismember mySet value1 
# 获取 mySet 和 mySet2 的交集并存放在 mySet3 中
sinterstore mySet3 mySet mySet2
1
2
3
4
5
6
7
8
9
10

# Sorted Set

和 set 相比,sorted set 增加了一个权重参数 score,使得集合中的元素能够按 score 进行有序排列,还可以通过 score 的范围来获取元素的列表。

应用场景: 需要对数据根据某个权重进行排序的场景。比如在直播系统中,实时排行信息包含直播间在线用户列表,各种礼物排行榜,弹幕消息(可以理解为按消息维度的消息排行榜)等信息。

常用操作:

# 添加元素到 sorted set 中 3.0 为权重
zadd myZset 3.0 value1 
 # 一次添加多个元素
zadd myZset 2.0 value2 1.0 value3
# 查看 sorted set 中的元素数量
zcard myZset 
# 查看某个 value 的权重
zscore myZset value1 
# 顺序输出某个范围区间的元素,0 -1 表示输出所有元素
zrange  myZset 0 -1 
# 逆序输出某个范围区间的元素,0 为 start  1 为 stop
zrevrange  myZset 0 1 
1
2
3
4
5
6
7
8
9
10
11
12

# bitmap

bitmap 存储的是连续的二进制数字(0 和 1),通过 bitmap, 只需要一个 bit 位来表示某个元素对应的值或者状态,key 就是对应元素本身 。我们知道 8 个 bit 可以组成一个 byte,所以 bitmap 本身会极大的节省储存空间。

应用场景: 适合需要保存状态信息(比如是否签到、是否登录...)并需要进一步对这些信息进行分析的场景。比如用户签到情况、活跃用户情况、用户行为统计(比如是否点赞过某个视频)。

# SETBIT 会返回之前位的值(默认是 0)这里会生成 7 个位
127.0.0.1:6379> setbit mykey 7 1
(integer) 0
127.0.0.1:6379> setbit mykey 7 0
(integer) 1
127.0.0.1:6379> getbit mykey 7
(integer) 0
127.0.0.1:6379> setbit mykey 6 1
(integer) 0
127.0.0.1:6379> setbit mykey 8 1
(integer) 0
# 通过 bitcount 统计被被设置为 1 的位的数量。
127.0.0.1:6379> bitcount mykey
(integer) 2
1
2
3
4
5
6
7
8
9
10
11
12
13
14

针对上面提到的一些场景,这里进行进一步说明。

使用场景一:用户行为分析 很多网站为了分析你的喜好,需要研究你点赞过的内容。

# 记录你喜欢过 001 号小姐姐
127.0.0.1:6379> setbit beauty_girl_001 uid 1Copy to clipboardErrorCopied
1
2

使用场景二:统计活跃用户

使用时间作为 key,然后用户 ID 为 offset,如果当日活跃过就设置为 1

那么我该如果计算某几天/月/年的活跃用户呢(暂且约定,统计时间内只有有一天在线就称为活跃),有请下一个 redis 的命令

# 对一个或多个保存二进制位的字符串 key 进行位元操作,并将结果保存到 destkey 上。
# BITOP 命令支持 AND 、 OR 、 NOT 、 XOR 这四种操作中的任意一种参数
BITOP operation destkey key [key ...]Copy to clipboardErrorCopied
1
2
3

初始化数据:

127.0.0.1:6379> setbit 20210308 1 1
(integer) 0
127.0.0.1:6379> setbit 20210308 2 1
(integer) 0
127.0.0.1:6379> setbit 20210309 1 1
(integer) 0Copy to clipboardErrorCopied
1
2
3
4
5
6

统计 20210308~20210309 总活跃用户数: 1

127.0.0.1:6379> bitop and desk1 20210308 20210309
(integer) 1
127.0.0.1:6379> bitcount desk1
(integer) 1Copy to clipboardErrorCopied
1
2
3
4

统计 20210308~20210309 在线活跃用户数: 2

127.0.0.1:6379> bitop or desk2 20210308 20210309
(integer) 1
127.0.0.1:6379> bitcount desk2
(integer) 2Copy to clipboardErrorCopied
1
2
3
4

使用场景三:用户在线状态

对于获取或者统计用户在线状态,使用 bitmap 是一个节约空间效率又高的一种方法。

只需要一个 key,然后用户 ID 为 offset,如果在线就设置为 1,不在线就设置为 0。

# 7. Redis持久化机制

# 7.1 为什么要持久化?

很多时候我们需要持久化数据也就是将内存中的数据写入到硬盘里面,大部分原因是为了之后重用数据(比如重启机器、机器故障之后恢复数据),或者是为了防止系统故障而将数据备份到一个远程位置。

# 7.2 快照持久化(snapshotting,RDB)

Redis 可以通过创建快照来获得存储在内存里面的数据在某个时间点上的副本/快照(RDB文件)。

Redis 创建快照之后,可以对快照进行备份,可以将快照复制到其他服务器从而创建具有相同数据的服务器副本(Redis 主从结构,主要用来提高 Redis 性能),还可以将快照留在原地以便重启服务器的时候使用。

创建快照(RDB文件)

在redis.conf中配置文件名称,默认为dump.rdb

快照持久化是 Redis 默认采用的持久化方式,在 Redis.conf 配置文件中默认有此下配置:

save 900 1           #在900秒(15分钟)之后,如果至少有1个key发生变化,Redis就会自动触发BGSAVE命令创建快照。
save 300 10          #在300秒(5分钟)之后,如果至少有10个key发生变化,Redis就会自动触发BGSAVE命令创建快照。
save 60 10000        #在60秒(1分钟)之后,如果至少有10000个key发生变化,Redis就会自动触发BGSAVE命令创建快照。
1
2
3

也可采用手动形式创建快照:

  • SAVE命令:阻塞Redis服务器进程,直到RDB文件创建完毕为止,在服务器进程阻塞期间,服务器不能处理任何命令请求。
  • BGSAVE命令:派生出一个子进程,然后由子进程负责创建RDB文件,服务器进程(父进程)继续处理命令请求。

载入快照(RDB文件)

Redis载入RDB文件并没有专门的命令,而是在Redis服务器启动时自动执行的。而且,Redis服务器启动时是否会载入RDB文件还取决于服务器是否启用了AOF持久化功能,具体判断逻辑为:

  • 只有在AOF持久化功能处于关闭状态时,服务器才会使用RDB文件来还原数据。
  • 如果服务器开启了AOF持久化功能,那么服务器会优先使用AOF文件来还原数据。

redis启动载入快照流程

默认情况下,Redis服务器的AOF持久化功能是关闭的。

如何进行备份?

Redis会单独创建(fork)一个子进程来进行持久化,会先将数据写入到 一个临时文件中,待持久化过程都结束了,再用这个临时文件替换上次持久化好的文件。 整个过程中,主进程是不进行任何IO操作的,这就确保了极高的性能 如果需要进行大规模数据的恢复,且对于数据恢复的完整性不是非常敏感,那RDB方式要比AOF方式更加的高效。RDB的缺点是最后一次持久化后的数据可能丢失

# 7.3 只追加文件持久化(append-only file, AOF)

与快照持久化相比,AOF 持久化 的实时性更好,因此已成为主流的持久化方案。

默认情况下 Redis 没有开启 AOF方式的持久化,可以通过 appendonly 参数开启:

appendonly yes
1

开启 AOF 持久化后每执行一条会更改 Redis 中的数据的命令,Redis 就会将该命令写入硬盘中的 AOF 文件。AOF 文件的保存位置和 RDB 文件的位置相同,都是通过 dir 参数设置的,默认的文件名是 appendonly.aof。

在 Redis 的配置文件中存在三种不同的 AOF 持久化方式,它们分别是:

appendfsync always    #每次有数据修改发生时都会写入AOF文件,这样会严重降低Redis的速度
appendfsync everysec  #每秒钟同步一次,显示地将多个写命令同步到硬盘
appendfsync no        #让操作系统决定何时进行同步
1
2
3

appendfsync选项的默认值是everysec,也推荐使用这个值,因为既保证了效率又保证了安全性。

# Redis 4.0 对于持久化机制的优化

Redis 4.0 开始支持 RDB 和 AOF 的混合持久化(默认关闭,可以通过配置项 aof-use-rdb-preamble 开启)。

如果把混合持久化打开,AOF 重写的时候就直接把 RDB 的内容写到 AOF 文件开头。这样做的好处是可以结合 RDB 和 AOF 的优点, 快速加载同时避免丢失过多的数据。当然缺点也是有的, AOF 里面的 RDB 部分是压缩格式不再是 AOF 格式,可读性较差。

# AOF 重写

因为AOF持久化是通过保存被执行的写命令来记录数据库数据的,所以随着Redis服务器运行时间的增加,AOF文件中的内容会越来越多,文件的体积会越来越大,如果不做控制,会有以下2点坏处:

  1. 过多的占用服务器磁盘空间,可能会对Redis服务器甚至整个宿主计算机造成影响。
  2. AOF文件的体积越大,使用AOF文件来进行数据库还原所需的时间就越多。

为了解决AOF文件体积越来越大的问题,Redis提供了AOF文件重写功能,即Redis服务器会创建一个新的AOF文件来替代现有的AOF文件,新旧两个AOF文件所保存的数据库数据相同,但新AOF文件不会包含任何浪费空间的冗余命令,所以新AOF文件的体积通常会比旧AOF文件的体积要小很多。

原理

AOF文件重写并不需要对现有的AOF文件进行任何读取、分析或者写入操作,而是通过读取服务器当前的数据库数据来实现的。

举例:

AOF 重写举例

为了记录这个list键的状态,AOF文件需要保存上面执行的6条命令。

但list键的结果是“C” "D" "E" "F" "G"这样的数据,所以AOF文件重写时,可以用一条RPUSH list “C” "D" "E" "F" "G"命令来代替之前的六条命令,这样就可以将保存list键所需的命令从六条减少为一条了。

按照上面的原理,如果Redis服务器存储的键值对足够多,AOF文件重写生成的新AOF文件就会减少很多很多的冗余命令,进而大大减小了AOF文件的体积。

综上所述,AOF文件重写功能的实现原理为:

首先从数据库中读取键现在的值,然后用一条命令去记录键值对,代替之前记录这个键值对的多条命令。

# AOF后台重写

因为AOF文件重写会进行大量的文件写入操作,所以执行这个操作的线程将被长时间阻塞。

因为Redis服务器使用单个线程来处理命令请求,所以如果由服务器进程直接执行这个操作,那么在重写AOF文件期间,服务器将无法处理客户端发送过来的命令请求。

为了避免上述问题,Redis将AOF文件重写功能放到子进程里执行,这样做有以下2个好处:

  1. 子进程进行AOF文件重写期间,服务器进程(父进程)可以继续处理命令请求。
  2. 子进程带有服务器进程的数据副本,使用子进程而不是线程,可以在避免使用锁的情况下,保证数据的安全性。

AOF后台重写的步骤如下所示:

  1. 服务器进程创建子进程,子进程开始AOF文件重写
  2. 从创建子进程开始,服务器进程执行的所有写命令不仅要写入AOF缓冲区,还要写入AOF重写缓冲区
    1. 写入AOF缓冲区的目的是为了同步到原有的AOF文件。
    2. 写入AOF重写缓冲区的目的是因为子进程在进行AOF文件重写期间,服务器进程还在继续处理命令请求,而新的命令可能会对现有的数据库进行修改,从而使得服务器当前的数据库数据和重写后的AOF文件所保存的数据库数据不一致。
  3. 子进程完成AOF重写工作,向父进程发送一个信号,父进程在接收到该信号后,会执行以下操作: 1.将AOF重写缓冲区中的所有内容写入到新AOF文件中,这样就保证了新AOF文件所保存的数据库数据和服务器当前的数据库数据是一致的。 2.对新的AOF文件进行改名,原子地覆盖现有的AOF文件,完成新旧两个AOF文件的替换。

Redis提供了BGREWRITEAOF命令来执行以上步骤。

除了手动执行BGREWRITEAOF命令外,Redis还提供了2个配置项用来自动执行BGREWRITEAOF命令:

auto-aof-rewrite-percentage 100 auto-aof-rewrite-min-size 64mb

该配置表示,当AOF文件的体积大于64MB,并且AOF文件的体积比上一次重写之后的体积大了至少一倍(100%),Redis将自动执行BGREWRITEAOF命令。

# 7.4 RDB持久化、AOF持久化的区别

实现方式

RDB持久化是通过将某个时间点Redis服务器存储的数据保存到RDB文件中来实现持久化的。

AOF持久化是通过将Redis服务器执行的所有写命令保存到AOF文件中来实现持久化的。

文件体积

由上述实现方式可知,RDB持久化记录的是结果,AOF持久化记录的是过程,所以AOF持久化生成的AOF文件会有体积越来越大的问题,Redis提供了AOF重写功能来减小AOF文件体积。

安全性

AOF持久化的安全性要比RDB持久化的安全性高,即如果发生机器故障,AOF持久化要比RDB持久化丢失的数据要少。

因为RDB持久化会丢失上次RDB持久化后写入的数据,而AOF持久化最多丢失1s之内写入的数据(使用默认everysec配置的话)。

优先级

由于上述的安全性问题,如果Redis服务器开启了AOF持久化功能,Redis服务器在启动时会使用AOF文件来还原数据,如果Redis服务器没有开启AOF持久化功能,Redis服务器在启动时会使用RDB文件来还原数据,所以AOF文件的优先级比RDB文件的优先级高。

# 7.5 Redis持久化数据和缓存怎么做扩容?

1、如果Redis被当做缓存使用,使用一致性哈希实现动态扩容缩容。

2、如果Redis被当做一个持久化存储使用,必须使用固定的keys-to-nodes映射关系,节点的数量一旦确定不能变化。否则的话(即Redis节点需要动态变化的情况),必须使用可以在运行时进行数据再平衡的一套系统,而当前只有Redis集群可以做到这样。

# 7.6 RDB和AOF如何选择?

  • RDB持久化方式能够在指定的时间间隔能对你的数据进行快照存储

  • AOF持久化方式记录每次对服务器写的操作,当服务器重启的时候会重新执行这些命令来恢复原始的数据,AOF命令以redis协议追加保存每次写的操作到文件末尾. Redis还能对AOF文件进行后台重写,使得AOF文件的体积不至于过大。

  • 只做缓存:如果你只希望你的数据在服务器运行的时候存在,你也可以不使用任何持久化方式.

  • 同时开启两种持久化方式

    • 在这种情况下,当redis重启的时候会优先载入AOF文件来恢复原始的数据, 因为在通常情况下AOF文件保存的数据集要比RDB文件保存的数据集要完整.
    • RDB的数据不实时,同时使用两者时服务器重启也只会找AOF文件。那要不要只使用AOF呢? 建议不要,因为RDB更适合用于备份数据库(AOF在不断变化不好备份), 快速重启,而且不会有AOF可能潜在的bug,留着作为一个万一的手段。
  • 性能建议

    • 因为RDB文件只用作后备用途,建议只在Slave上持久化RDB文件,而且只要15分钟备份一次就够了,只保留save 900 1这条规则。

    • 如果使用AOF,好处是在最恶劣情况下也只会丢失不超过两秒数据,启动脚本较简单只load自己的AOF文件就可以了。

      代价,一是带来了持续的IO,二是AOF rewrite的最后将rewrite过程中产生的新数据写到新文件造成的阻塞几乎是不可避免的。

    • 只要硬盘许可,应该尽量减少AOF rewrite的频率,AOF重写的基础大小默认值64M太小了,可以设到5G以上。

    • AOF默认超过原大小100%大小时重写可以改到适当的数值。

# 8. Redis过期键的删除策略

如果假设你设置了一批 key 只能存活 1 分钟,那么 1 分钟后,Redis 是怎么对这批 key 进行删除的呢?

常用的过期数据的删除策略就两个(重要!自己造缓存轮子的时候需要格外考虑的东西):

  1. 惰性删除 :只会在取出 key 的时候才对数据进行过期检查。这样对 CPU 最友好,但是可能会造成太多过期 key 没有被删除。
  2. 定期删除 : 每隔一段时间抽取一批 key 执行删除过期 key 操作。并且,Redis 底层会通过限制删除操作执行的时长和频率来减少删除操作对 CPU 时间的影响。

定期删除对内存更加友好,惰性删除对 CPU 更加友好。两者各有千秋,所以 Redis 采用的是 定期删除+惰性/懒汉式删除

为什么不单独用定时删除策略?

定时删除:用一个定时器来负责监视key,过期则自动删除,十分消耗CPU资源。在大量并发请求下,CPU要将时间应用在处理请求,而不是删除key。

定期删除+惰性删除是如何工作的呢?

定期删除,redis默认每个100ms检查,是否有过期的key,有过期key则删除。需要说明的是,redis不是每个100ms将所有的key检查一次,而是随机抽取进行检查(如果每隔100ms,全部key进行检查,redis岂不是卡死)。因此,如果只采用定期删除策略,会导致很多key到时间没有删除。于是,惰性删除派上用场。也就是说在你获取某个key的时候,redis会检查一下,这个key如果设置了过期时间那么是否过期了?如果过期了此时就会删除。

但是,仅仅通过给 key 设置过期时间还是有问题的。因为还是可能存在定期删除和惰性删除漏掉了很多过期 key 的情况。这样就导致大量过期 key 堆积在内存里,然后就 Out of memory 了。

怎么解决这个问题呢?答案就是: Redis 内存淘汰机制。

# 9. Redis 内存淘汰机制了解么?

查看设置:在redis.conf中有一行配置maxmemory-policy volatile-lru

如何设置:config set maxmemory-policy allkeys-lru

Redis 提供 6 种数据淘汰策略:

  1. volatile-lru(least recently used):从已设置过期时间的数据集(server.db[i].expires)中挑选最近最少使用的数据淘汰
  2. volatile-ttl:从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰
  3. volatile-random:从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰
  4. allkeys-lru(least recently used):当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的 key(这个是最常用的)
  5. allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰
  6. no-eviction:禁止驱逐数据,也就是说当内存不足以容纳新写入数据时,新写入操作会报错。这个应该没人使用吧!

4.0 版本后增加以下两种:

  1. volatile-lfu(least frequently used):从已设置过期时间的数据集(server.db[i].expires)中挑选最不经常使用的数据淘汰
  2. allkeys-lfu(least frequently used):当内存不足以容纳新写入数据时,在键空间中,移除最不经常使用的 key

相关问题:MySQL 里有 2000w 数据,Redis 中只存 20w 的数据,如何保证 Redis 中的数据都是热点数据?

Redis 内存数据集大小上升到一定大小的时候,就会施行数据淘汰策略。

内存相关

查看redis占用最大内存:maxmemory <bytes>

如何修改redis内存大小:config set maxmemory

redis默认内存大小:如果不设置最大内存大小或者设置最大内存大小为0,在64位操作系统下不限制内存大小,在32位操作系统下最多使用3GB内存。

生产上redis内存如何配置?一般推荐设置为最大物理内存的3/4

redis内存使用情况命令:info memory

# 10. Redis key的过期时间和永久有效分别怎么设置?

EXPIREPERSIST 命令

# 11. 如何进行Redis内存优化?

对于Redis中的所有对象在内部都是由一个种叫RedisObject的数据结构来实现。

typedef struct redisObject{
    //类型
	unsigned type:4;
	
	//编码
	unsigned encoding:4;
	
	//指向底层实现的数据结构的指针
	void *ptr;
}
1
2
3
4
5
6
7
8
9
10

从RedisObject的结构得知Redis存储一个对象的内存成本是:type + encoding + *ptr指向的数据(数据本身)。

# (1)key与value的优化

  • 过滤不必要的数据。比如我们缓存一个用户的信息,我们通常只要缓存基本信息比如:呢称,性别,角色类型,生日即可。其它的一些不必要信息,我们可以过滤掉。
  • 精简数据。比如角色类型:"VIP"。我们可以指定1代表"VIP",不直接存储VIP字符串。
  • 数据压缩。存进redis之前我们还可以对内容进行压缩,常用的工具有GZIP、Snappy。
  • 缩短键值对的存储长度
    • 在 key 不变的情况下,value 值越大操作效率越慢,因为 Redis 对于同一种数据类型会使用不同的内部编码进行存储,然而数据量越大使用的内部编码就越复杂,而越是复杂的内部编码存储的性能就越低。
    • 因此在保证完整语义的同时,我们要尽量的缩短键值对的存储长度,必要时要对数据进行序列化和压缩再存储。

# (2)字符串拼接的优化

set redis "hello" 
append redis " redis"
-----------------------
set redis "hello redis"
1
2
3
4

分割线上下的命令效果是等价的,最终get redis 得到的结果都是"hello redis"。但这两者占用的内存大小却是不一样的。

为什么会这样呢?因为Redis的字符串有一个预分配的设定。当执行set redis "hello"命令时,此时内存中存储的是"hello\0",默认加一个'\0'表示结束符。当执行append redis " redis"命令之后内存里的存储就变成了"hello redis\0",额外还多申请一倍的空间,这个空间的上限是1M,所以字段串的append操作是会额外申请一份内存空间!这样做的原因是Redis作者认为如果字符串经常append,与其每一次append重新分配一次内存空间,不如预先分配好,下次append时如果空间够直接存储就行了。所以如果有大量的append字符串操作,并且字符串的长度不大的话,还不如用set 命令重新覆盖 这样比 append追加会更节约内存。

# (3)编码优化

对于同一个数据类型比如hash map,根据键的数量和值的大小也有不同的底层实现方式。举hash map来说底层的编码方式有两种:ziplist、hash table。从内存和效率来说ziplis与hash table的关系是

内存:ziplist < hash table 效率:ziplist < hash table。

ziplist的效率是O(n),hash table的效率是O(1) 也就是说Redis这里采用的是以时间换空间的套路。

截至redis 4.0 只要hash map的key的数量小于等于 512,value的大小小于等于64字节,hash map的底层数据结构就是ziplist,反之则是hash table

这样当hash map里面的数据不是很多时,用ziplist来实现即节省了内存,效率也不用下降很多,因为数据不多。但生产中如果我们有这样的一个hash map,它的key有512个,value有只有一个的大小超过了64字节,刚好是65。这个时候Redis 就默认把ziplist换成了hash table,这是不是很坑!这时可以自己优化转换规则:config set hash-max-ziplist-entries 65。这样value大于65字节才转换成hash table。

所以大家可以了解一下Redis底层的编码实现和转换规则,根据实际业务特点修改配置。

# (4)使用 lazy free 特性

lazy free 特性是 Redis 4.0 新增的一个非常使用的功能,它可以理解为惰性删除或延迟删除。意思是在删除的时候提供异步延时释放键值的功能,把键值释放操作放在 BIO(Background I/O) 单独的子线程处理中,以减少删除删除对 Redis 主线程的阻塞,可以有效地避免删除 big key 时带来的性能和可用性问题。

lazy free 对应了 4 种场景,默认都是关闭的:

  • lazyfree-lazy-eviction:表示当 Redis 运行内存超过 maxmeory 时,是否开启 lazy free 机制删除;
  • lazyfree-lazy-expire:表示设置了过期时间的键值,当过期之后是否开启 lazy free 机制删除;
  • lazyfree-lazy-server-del:有些指令在处理已存在的键时,会带有一个隐式的 del 键的操作,比如 rename 命令,当目标键已存在,Redis 会先删除目标键,如果这些目标键是一个 big key,就会造成阻塞删除的问题,此配置表示在这种场景中是否开启 lazy free 机制删除;
  • slave-lazy-flush:针对 slave(从节点) 进行全量数据同步,slave 在加载 master 的 RDB 文件前,会运行 flushall 来清理自己的数据,它表示此时是否开启 lazy free 机制删除。

# (5)设置合理的键值过期时间

我们应该根据实际的业务情况,对键值设置合理的过期时间,这样 Redis 会帮你自动清除过期的键值对,以节约对内存的占用,以避免键值过多的堆积,频繁的触发内存淘汰策略。

# (6)禁用长耗时的查询命令

Redis 绝大多数读写命令的时间复杂度都在 O(1) 到 O(N) 之间,在官方文档对每命令都有时间复杂度说明。

其中 O(1) 表示可以安全使用的,而 O(N) 就应该当心了,N 表示不确定,数据越大查询的速度可能会越慢。因为 Redis 只用一个线程来做数据查询,如果这些指令耗时很长,就会阻塞 Redis,造成大量延时。

要避免 O(N) 命令对 Redis 造成的影响,可以从以下几个方面入手改造:

  • 禁止使用 keys 命令;
  • 避免一次查询所有的成员,要使用 scan 命令进行分批的,游标式的遍历;
  • 通过机制严格控制 Hash、Set、Sorted Set 等结构的数据大小;
  • 将排序、并集、交集等操作放在客户端执行,以减少 Redis 服务器运行压力;
  • 删除 (del) 一个大数据的时候,可能会需要很长时间,所以建议用异步删除的方式 unlink,它会启动一个新的线程来删除目标数据,而不阻塞 Redis 的主线程。

# (7)使用 slowlog 优化耗时命令

使用 slowlog 功能找出最耗时的 Redis 命令进行相关的优化,以提升 Redis 的运行速度,慢查询有两个重要的配置项:

  • slowlog-log-slower-than :用于设置慢查询的评定时间,也就是说超过此配置项的命令,将会被当成慢操作记录在慢查询日志中,它执行单位是微秒 (1 秒等于 1000000 微秒);
  • slowlog-max-len :用来配置慢查询日志的最大记录数。

我们可以根据实际的业务情况进行相应的配置,其中慢日志是按照插入的顺序倒序存入慢查 询日志中,我们可以使用 slowlog get n 来获取相关的慢查询日志,再找到这些慢查询对应的业务进行相关的优化。

# (8)使用 Pipeline 批量操作数据

Pipeline (管道技术) 是客户端提供的一种批处理技术,用于一次处理多个 Redis 命令,从而提高整个交互的性能。

# (9)避免大量数据同时失效

Redis 过期键值删除使用的是贪心策略,它每秒会进行 10 次过期扫描,此配置可在 redis.conf 进行配置,默认值是 hz 10,Redis 会随机抽取 20 个值,删除这 20 个键中过期的键,如果过期 key 的比例超过 25% ,重复执行此流程,如下图所示:

过期删除键流程

如果在大型系统中有大量缓存在同一时间同时过期,那么会导致 Redis 循环多次持续扫描删除过期字典,直到过期字典中过期键值被删除的比较稀疏为止,而在整个执行过程会导致 Redis 的读写出现明显的卡顿,卡顿的另一种原因是内存管理器需要频繁回收内存页,因此也会消耗一定的 CPU。

为了避免这种卡顿现象的产生,需要预防大量的缓存在同一时刻一起过期,就简单的解决方案就是在过期时间的基础上添加一个指定范围的随机数。

# (10)Redis 连接池

在客户端的使用上我们除了要尽量使用 Pipeline 的技术外,还需要注意要尽量使用 Redis 连接池,而不是频繁创建销毁 Redis 连接,这样就可以减少网络传输次数和减少了非必要调用指令。

# (11)限制 Redis 内存大小,使用合理的内存淘汰策略

参看内存淘汰策略

# (12)使用物理机而非虚拟机

对性能要求较高可考虑在物理机直接部署Redis而不是使用虚拟机。

在虚拟机中运行 Redis 服务器,因为和物理机共享一个物理网口,并且一台物理机可能有多个虚拟机在运行,因此在内存占用上和网络延迟方面都会有很糟糕的表现,我们可以通过 ./redis-cli --intrinsic-latency 100 命令查看延迟时间,如果对 Redis 的性能有较高要求的话,应尽可能在物理机上直接部署 Redis 服务器。

# (13)使用合理的持久化策略

参看持久化策略

# (14)使用分布式架构来增加读写速度

Redis 分布式架构有三个重要的手段:

  • 主从同步
  • 哨兵模式
  • Redis Cluster 集群

使用主从同步功能我们可以把写入放到主库上执行,把读功能转移到从服务上,因此就可以在单位时间内处理更多的请求,从而提升的 Redis 整体的运行速度。

而哨兵模式是对于主从功能的升级,但当主节点奔溃之后,无需人工干预就能自动恢复 Redis 的正常使用。

Redis Cluster 是 Redis 3.0 正式推出的,Redis 集群是通过将数据库分散存储到多个节点上来平衡各个节点的负载压力。

Redis Cluster 采用虚拟哈希槽分区,所有的键根据哈希函数映射到 0 ~ 16383 整数槽内,计算公式:slot = CRC16(key) & 16383,每一个节点负责维护一部分槽以及槽所映射的键值数据。这样 Redis 就可以把读写压力从一台服务器,分散给多台服务器了,因此性能会有很大的提升。

在这三个功能中,我们只需要使用一个就行了,毫无疑问 Redis Cluster 应该是首选的实现方案,它可以把读写压力自动的分担给更多的服务器,并且拥有自动容灾的能力。

# 12. Redis线程模型

# 12.1 线程模型

Redis 基于 Reactor 模式来设计开发了自己的一套高效的事件处理模型 (Netty 的线程模型也基于 Reactor 模式,Reactor 模式不愧是高性能 IO 的基石)。

Redis内部使用文件事件处理器file event handler,这个文件事件处理器是单线程的所以redis才叫做单线程的模型。它采用IO多路复用机制同时监听多个socket,将产生事件的socket压入到内存队列中,事件分派器根据socket上的事件类型来选择对应的事件处理器来进行处理。

文件事件处理器包含4个部分:

  • 多个socket
  • IO多路复用程序
  • 文件事件分派器
  • 事件处理器(连接应答处理器、命令请求处理器、命令回复处理器)

Redis 通过IO 多路复用程序 来监听来自客户端的大量连接(或者说是监听多个 socket),它会将感兴趣的事件及类型(读、写)注册到内核中并监听每个事件是否发生。

这样的好处非常明显: I/O 多路复用技术的使用让 Redis 不需要额外创建多余的线程来监听客户端的大量连接,降低了资源的消耗(和 NIO 中的 Selector 组件很像)。

客户端与redis的一次通信过程:

Redis线程模型

  1. 客户端发起建立连接的请求。
    1. 服务端会产生一个AE_READABLE事件,IO多路复用程序接收到server socket事件后,将该socket压入队列中。
    2. 文件事件分派器从队列中获取socket,交给连接应答处理器,创建一个可以和客户端交流的socket01。
    3. 将socket01的AE_READABLE事件与命令请求处理器关联。
  2. 客户端发起set key value请求。
    1. socket01产生AE_READABLE事件,socket01压入队列。
    2. 将获取到的socket01与命令请求处理器关联。
    3. 命令请求处理器读取socket01中的key value,并在内存中完成对应的设置。
    4. 将socket01的AE_WRITABLE事件与命令回复处理器关联。
  3. 服务端返回结果。
    1. redis中的socket01会产生一个AE_WRITABLE事件,压入到队列中。
    2. 将获取到的socket01与命令回复处理器关联。
    3. 命令回复处理器对socket01输入操作结果,比如ok。之后解除socket01的AE_WRITABLE事件与命令回复处理器的关联。

# 12.2 Redis为什么这么快?

  1. 纯内存操作,避免大量访问数据库,减少直接读取磁盘数据,redis将数据储存在内存里面,读写数据的时候都不会受到硬盘 I/O 速度的限制,所以速度快
  2. 单线程操作,避免了不必要的上下文切换和竞争条件,也不存在多进程或者多线程导致的切换而消耗CPU,不用去考虑各种锁的问题,不存在加锁释放锁操作,没有因为可能出现死锁而导致的性能消耗。(Redis6.0引入了多线程)
  3. 采用了非阻塞I/O多路复用机制
  4. C语言实现,距离操作系统更近,执行速度会更快。

# 12.3 Redis的非阻塞IO多路复用模型

文件事件处理器:组成结构:多个套接字、IO多路复用程序、文件事件分派器、事件处理器

详解Redis非阻塞io多路复用线程模型

Redis客户端对服务端的调用分为发送命令,执行命令,返回结果三个过程。

  1. I/O多路复用(multiplexing)程序来同时监听多个套接字,并关联相应的事件处理器。

  2. 当被监听的套接字准备好执行连接应答(accept)、读取(read)、写入(write)、关闭(close)等操作时, 产生于操作相对应的文件事件。

  3. 多个文件事件可能会并发地出现,将产生时间的套接字放入一个队列中,有序、同步、每次一个套接字的方式将文件事件发送给文件事件分派器。

  4. 文件事件分派器将事件分派给对应处理器处理。

  5. 文件事件处理完毕后,通过套接字返回。

Redis为文件事件编写了多个处理器,分别为socket关联连接、命令请求、命令回复处理器等。

# 12.4 Redis6.0 之前 为什么不使用多线程?

Redis中只有网络请求模块和数据操作模块是单线程的。而其他的如持久化存储模块、集群支撑模块等是多线程的。

Redis中并不是没有多线程模型的,早在Redis 4.0的时候就已经针对部分命令做了多线程化。

  1. 单线程编程容易并且更容易维护;
  2. Redis 的性能瓶颈不在 CPU ,主要在内存和网络;
  3. 多线程就会存在死锁、线程上下文切换等问题,甚至会影响性能。

参看:为什么 Redis 选择单线程模型 (opens new window)

# 12.5 Redis6.0 之后为何引入了多线程?

Redis6.0 引入多线程主要是为了提高网络 IO 读写性能,因为这个算是 Redis 中的一个性能瓶颈(Redis 的瓶颈主要受限于内存和网络)。

虽然,Redis6.0 引入了多线程,但是 Redis 的多线程只是在网络数据的读写这类耗时操作上使用了, 执行命令仍然是单线程顺序执行。因此,你也不需要担心线程安全问题。

Redis6.0 的多线程默认是禁用的,只使用主线程。如需开启需要修改 redis 配置文件 redis.conf

io-threads-do-reads yesCopy to clipboardErrorCopied
1

开启多线程后,还需要设置线程数,否则是不生效的。同样需要修改 redis 配置文件 redis.conf :

io-threads 4 #官网建议4核的机器建议设置为2或3个线程,8核的建议设置为6个线程
1

参看:

# 13. Redis事务

事务是一个单独的隔离操作:事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。

事务是一个原子操作:事务中的命令要么全部被执行,要么全部都不执行。

# 13.1 实现原理

Redis事务是一个单独的隔离操作:事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。

Redis事务的主要作用就是 串联多个命令防止别的命令插队

Redis 的通过 MULTIEXECDISCARDWATCH 这四个命令来实现事务的,Redis 的单个命令都是原子性的。

三个阶段(事务开始、命令入队、事务执行)

  1. 使用 MULTI命令后可以输入多个命令。Redis 不会立即执行这些命令,而是将它们放到队列,当调用了EXEC (opens new window)命令将执行所有命令。

    这个过程是这样的:

    1. 开始事务(MULTI)。
    2. 命令入队(批量操作 Redis 的命令,先进先出(FIFO)的顺序执行)。
    3. 执行事务(EXEC)。
  2. 也可以通过DISCARD命令取消一个事务,它会清空事务队列中保存的所有命令。

  3. WATCH 命令用于监听指定的键,当调用 EXEC 命令执行事务时,如果一个被 WATCH 命令监视的键被修改的话,整个事务都不会执行,直接返回失败。

  4. 调用了EXEC (opens new window)命令将执行所有命令。

可以将 Redis 中的事务就理解为 :Redis 事务提供了一种将多个命令请求打包的功能。然后,再按顺序执行打包的所有命令,并且不会被中途打断。

原理分析可参考:高频Redis面试题解析:Redis 事务是否具备原子性? (opens new window)

# 13.2 事务的四大特性

  1. 原子性(Atomicity): 事务是最小的执行单位,不允许分割。事务的原子性确保动作要么全部完成,要么完全不起作用;
  2. 隔离性(Isolation): 并发访问数据库时,一个用户的事务不被其他事务所干扰,各并发事务之间数据库是独立的;
  3. 持久性(Durability): 一个事务被提交之后。它对数据库中数据的改变是持久的,即使数据库发生故障也不应该对其有任何影响。
  4. 一致性(Consistency): 执行事务前后,数据保持一致,多个事务对同一个数据读取的结果是相同的;

# 14.3 Redis 是不支持 roll back 的原因

  1. 大多数事务失败是因为语法错误或者类型错误,这两种错误,在开发阶段都是可以预见的。
  2. Redis 为了性能方面就忽略了事务回滚。

# 14.4 事务失败处理(是否保证原子性)

  1. Redis 语法错误(可以理解为编译期错误

在一个事务中,当命令出现错误时,后续命令正确依旧是可以添加到命令队列中去得,但是使用 EXEC 命令执行命令队列的时候,就会报错,并且队列中正确的命令也不会被执行(支持原子性)。

  1. Redis 类型错误(可以理解为运行期错误

这种错误不是命令错误,而是因为对命令理解不透彻出现的使用错误,在执行过程中会报错。但队列中正确的命令会被执行(不支持原子性)。

Redis不保证原子性,也没有隔离级别。

# 15. Redis缓存异常

# 15.1 缓存热点key

突然有几十万的请求去访问redis上的某个特定key,那么这样会造成流量过于集中,达到物理网卡上限,从而导致这台redis的服务器宕机引发雪崩。

缓存热点key问题

针对热key的解决方案:

  1. 提前把热key打散到不同的服务器,降低压力
  2. 加入二级缓存,提前加载热key数据到内存中,如果redis宕机,走内存查询

# 15.2 缓存雪崩

# 什么是缓存雪崩?

缓存雪崩:缓存在同一时间大面积的失效,后面的请求都直接落到了数据库上,造成数据库短时间内承受大量请求。 这就好比雪崩一样,摧枯拉朽之势,数据库的压力可想而知,可能直接就被这么多请求弄宕机了。

有一些被大量访问数据(热点缓存)在某一时刻大面积失效或者系统的缓存模块出了问题比如宕机导致不可用,导致对应的请求直接落到了数据库上。

例子:秒杀开始 12 个小时之前,统一存放了一批商品到 Redis 中,设置的缓存过期时间也是 12 个小时,那么秒杀开始的时候,这些秒杀的商品的访问直接就失效了。导致的情况就是,相应的请求直接就落到了数据库上,就像雪崩一样可怕。

缓存雪崩

# 如何解决缓存雪崩

针对 Redis 服务不可用的情况:

  1. 采用 Redis 集群,避免单机出现问题整个缓存服务都没办法使用。
  2. 限流,避免同时处理大量的请求。

针对热点缓存失效的情况:

  1. 设置不同的失效时间比如随机设置缓存的失效时间。
  2. 缓存永不失效。
  3. 构建多级缓存架构:nginx缓存 + redis缓存 +其他缓存(ehcache等)
  4. **设置过期标志更新缓存:**记录缓存数据是否过期(设置提前量),如果过期会触发通知另外的线程在后台去更新实际key的缓存。Redis有一个过期删除键的监听事件。

# 15.3 缓存穿透

# 什么是缓存穿透?

缓存穿透:大量请求的 key 根本不存在于缓存中,导致请求直接到了数据库上,根本没有经过缓存这一层。

例子:某个黑客故意制造我们缓存中不存在的 key 发起大量请求,导致大量请求落到数据库,导致数据库压力过大,严重会击垮数据库。

缓存穿透

# 如何解决缓存穿透?

1)缓存无效 key(对空值缓存)

如果缓存和数据库都查不到某个 key 的数据就写一个到 Redis 中去并设置过期时间,具体命令如下: SET key value EX 10086

这种方式可以解决请求的 key 变化不频繁的情况,如果黑客恶意攻击,每次构建不同的请求 key,会导致 Redis 中缓存大量无效的 key 。很明显,这种方案并不能从根本上解决此问题。如果非要用这种方式来解决穿透问题的话,尽量将无效的 key 的过期时间设置短一点比如 1 分钟,最长不超过五分钟。

另外,这里多说一嘴,一般情况下我们是这样设计 key 的: 表名:列名:主键名:主键值

2)设置可访问的名单(白名单)

使用bitmaps类型定义一个可以访问的名单,名单id作为bitmaps的偏移量,每次访问和bitmap里面的id进行比较,如果访问id不在bitmaps里面,进行拦截,不允许访问。

3)布隆过滤器

布隆过滤器(Bloom Filter)是1970年由布隆提出的。它实际上是一个很长的二进制向量(位图)和一系列随机映射函数(哈希函数)

布隆过滤器可以用于检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都远远超过一般的算法,缺点是有一定的误识别率和删除困难。

具体是这样做的:把所有可能存在的请求的值都存放在布隆过滤器中,当用户请求过来,先判断用户发来的请求的值是否存在于布隆过滤器中。不存在的话,直接返回请求参数错误信息给客户端,存在的话才会走下面的流程。

布隆过滤器流程

但是,需要注意的是布隆过滤器可能会存在误判的情况。总结来说就是: 布隆过滤器说某个元素存在,小概率会误判。布隆过滤器说某个元素不在,那么这个元素一定不在。

我们先来看一下,当一个元素加入布隆过滤器中的时候,会进行哪些操作:

  1. 使用布隆过滤器中的哈希函数对元素值进行计算,得到哈希值(有几个哈希函数得到几个哈希值)。
  2. 根据得到的哈希值,在位数组中把对应下标的值置为 1。

我们再来看一下,当我们需要判断一个元素是否存在于布隆过滤器的时候,会进行哪些操作:

  1. 对给定元素再次进行相同的哈希计算;
  2. 得到值之后判断位数组中的每个元素是否都为 1,如果值都为 1,那么说明这个值在布隆过滤器中,如果存在一个值不为 1,说明该元素不在布隆过滤器中。

然后,一定会出现这样一种情况:不同的字符串可能哈希出来的位置相同。 (可以适当增加位数组大小或者调整我们的哈希函数来降低概率)

布隆过滤器可参看:详解布隆过滤器的原理,使用场景和注意事项 (opens new window)

4)进行实时监控

当发现Redis的命中率开始急速降低,需要排查访问对象和访问的数据,和运维人员配合,可以设置黑名单限制服务

# 15.4 缓存击穿

缓存击穿是指一个Key非常热点,在不停的扛着大并发,大并发集中对这一个点进行访问,当这个Key在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库,就像在一个完好无损的桶上凿开了一个洞.

  1. **预先设置热门数据:**在redis高峰访问之前,把一些热门数据提前存入到redis里面,加大这些热门数据key的时长。
  2. **实时调整:**现场监控哪些数据热门,实时调整key的过期时长
  3. 加锁更新,比如请求查询A,发现缓存中没有,对A这个key加锁,同时去数据库查询数据,写入缓存,再返回给用户,这样后面的请求就可以从缓存中拿到数据了。
  4. 将过期时间组合写在value中,通过异步的方式不断的刷新过期时间,防止此类现象。当并发量降低时,可以停止进行刷新。

缓存击穿

# 15.5 缓存预热

缓存预热:系统上线后,将相关缓存数据直接加载到缓存系统。

避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题。用户直接查询事先被预热的缓存数据。

思路:

  • 数据量不大时,启动时直接加载
  • 定时刷新缓存
  • 手动刷新缓存

# 15.6 缓存降级

当访问量剧增、服务出现问题(如响应时间慢或不响应)或非核心服务影响到核心流程的性能时,仍然需要保证服务还是可用的,即使是有 损服务。系统可以根据一些关键数据进行自动降级,也可以配置开关实现人工降级。 降级的最终目的是保证核心服务可用,即使是有损的。而且有些服务是无法降级的(如加入购物车、结算)。
以参考日志级别设置预案: (1)一般:比如有些服务偶尔因为网络抖动或者服务正在上线而超时,可以自动降级; (2)警告:有些服务在一段时间内成功率有波动(如在95~100%之间),可以自动降级或人工降级,并发送告警; (3)错误:比如可用率低于90%,或者数据库连接池被打爆了,或者访问量突然猛增到系统能承受的最大阀值,此时可以根据情况自动降 级或者人工降级; (4)严重错误:比如因为特殊原因数据错误了,此时需要紧急人工降级。服务降级的目的,是为了防止Redis服务故障,导致数据库跟着一 起发生雪崩问题。因此,对于不重要的缓存数据,可以采取服务降级策略,例如一个比较常见的做法就是,Redis出现问题,不去数据库查 询,而是直接返回默认值给用户

# 16 分布式问题

# 16.1 Redis实现分布式锁?

# 为什么引入分布式锁?

在一个分布式系统中,我们从数据库中读取一个数据,然后修改保存,这种情况很容易遇到并发问题。因为读取和更新保存不是一个原子操作,在并发时就会导致数据的不正确。

这种场景其实并不少见,比如电商秒杀活动,库存数量的更新就会遇到。

如果是单机应用,直接使用本地锁就可以避免。如果是分布式应用,本地锁派不上用场,这时就需要引入分布式锁来解决。

由此可见分布式锁的目的其实很简单,就是为了保证多台服务器在执行某一段代码时保证只有一台服务器执行

# 分布式锁的实现原则

  • 互斥性。在任何时刻,保证只有一个客户端持有锁。
  • 不能出现死锁。如果在一个客户端持有锁的期间,这个客户端崩溃了,也要保证后续的其他客户端可以上锁。
  • 保证上锁和解锁都是同一个客户端

一般来说,实现分布式锁的方式有以下几种:

  • 使用MySQL,基于唯一索引。
  • 使用ZooKeeper,基于临时有序节点。
  • 使用Redis,基于setnx命令

# Redis实现分布式锁

Redis实现分布式锁主要利用Redis的setnx命令。setnxSET if not exists(如果不存在,则 SET)的简写。

127.0.0.1:6379> setnx lock value1 #在键lock不存在的情况下,将键key的值设置为value1
(integer) 1
127.0.0.1:6379> setnx lock value2 #试图覆盖lock的值,返回0表示失败
(integer) 0
127.0.0.1:6379> get lock #获取lock的值,验证没有被覆盖
"value1"
127.0.0.1:6379> del lock #删除lock的值,删除成功
(integer) 1
127.0.0.1:6379> setnx lock value2 #再使用setnx命令设置,返回0表示成功
(integer) 1
127.0.0.1:6379> get lock #获取lock的值,验证设置成功
"value2"
1
2
3
4
5
6
7
8
9
10
11
12

上面这几个命令就是最基本的用来完成分布式锁的命令。

加锁:使用setnx key value命令,如果key不存在,设置value(加锁成功)。如果已经存在lock(也就是有客户端持有锁了),则设置失败(加锁失败)。

解锁:使用del命令,通过删除键值释放锁。释放锁之后,其他客户端可以通过setnx命令进行加锁。

key的值可以根据业务设置,比如是用户中心使用的,可以命令为USER_REDIS_LOCK,value可以使用uuid保证唯一,用于标识加锁的客户端。保证加锁和解锁都是同一个客户端。

简单的代码实现:

private static Jedis jedis = new Jedis("127.0.0.1");

private static final Long SUCCESS = 1L;

/**
  * 加锁
  */
public boolean tryLock(String key, String requestId) {
    //使用setnx命令。
    //不存在则保存返回1,加锁成功。如果已经存在则返回0,加锁失败。
    return SUCCESS.equals(jedis.setnx(key, requestId));
}

//删除key的lua脚本,先比较requestId是否相等,相等则删除
private static final String DEL_SCRIPT = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";

/**
  * 解锁
  */
public boolean unLock(String key, String requestId) {
    //删除成功表示解锁成功
    Long result = (Long) jedis.eval(DEL_SCRIPT, Collections.singletonList(key), Collections.singletonList(requestId));
    return SUCCESS.equals(result);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

流程:

Redis实现分布式锁的流程

# 问题一:持有锁的客户端不能释放锁,引发死锁

这样只能保证互斥性和加锁解锁都是同一个客户端,也保证只有一个客户端持有锁,但可能会出现死锁问题,即如果一个持有锁的客户端突然崩溃,导致无法解锁。

解决——超时机制:

设置超时机制,在设置key的时候,加上有效时间,如果过期,自动失效,不会出现死锁。

代码修改:

public boolean tryLock(String key, String requestId, int expireTime) {
    //使用jedis的api,保证原子性
    //NX 不存在则操作 EX 设置有效期,单位是秒
    String result = jedis.set(key, requestId, "NX", "EX", expireTime);
    //返回OK则表示加锁成功
    return "OK".equals(result);
}
1
2
3
4
5
6
7

解决死锁问题后的流程

虽然,这样不会引发死锁问题,但是有效时间设置多长是一个问题如果业务操作比有效时间长,业务代码还没执行完就解锁了,不能保证数据一致性

这个问题就有点棘手了,在网上也有很多讨论,第一种解决方法就是靠程序员自己去把握,预估一下业务代码需要执行的时间,然后设置有效期时间比执行时间长一些,保证不会因为自动解锁影响到客户端业务代码的执行。

但是这并不是万全之策,比如网络抖动这种情况是无法预测的,也有可能导致业务代码执行的时间变长,所以并不安全。

有一种方法比较靠谱一点,就是给锁续期。思路:当加锁成功后,同时开启守护线程,默认有效期是30秒,每隔10秒就会给锁续期到30秒,只要持有锁的客户端没有宕机,就能保证一直持有锁,直到业务代码执行完毕由客户端自己解锁,如果宕机了自然就在有效期失效后自动解锁

避免死锁+锁续期流程

# 问题二:不可重入锁

上述加的锁只能加一次,不可重入。可重入锁意思是在外层使用锁之后,内层仍然可以使用,那么可重入锁的实现思路又是怎么样的呢?

使用Redis的哈希表存储可重入次数,当加锁成功后,使用hset命令,value(重入次数)则是1

"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('hset', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; "
1
2
3
4
5

如果同一个客户端再次加锁成功,则使用hincrby自增加一。

"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"return redis.call('pttl', KEYS[1]);"
1
2
3
4
5
6

加锁流程:

Redis分布式可重入锁加锁流程

解锁时,先判断可重复次数是否大于0,大于0则减一,否则删除键值,释放锁资源。

protected RFuture<Boolean> unlockInnerAsync(long threadId) {
    return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
"return nil;" +
"end; " +
"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
"if (counter > 0) then " +
"redis.call('pexpire', KEYS[1], ARGV[2]); " +
"return 0; " +
"else " +
"redis.call('del', KEYS[1]); " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; "+
"end; " +
"return nil;",
Arrays.<Object>asList(getName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId));
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

Redis分布式可重入锁解锁流程

为了保证操作原子性,加锁和解锁操作都是使用lua脚本执行。

# 问题三:操作成功后立即返回结果,消耗性能

上面的加锁方法是加锁后立即返回加锁结果,如果加锁失败的情况下,总不可能一直轮询尝试加锁,直到加锁成功为止,这样太过耗费性能。所以需要利用发布订阅的机制进行优化。

步骤如下:

  1. 当加锁失败后,订阅锁释放的消息,自身进入阻塞状态。
  2. 当持有锁的客户端释放锁的时候,发布锁释放的消息。
  3. 当进入阻塞等待的其他客户端收到锁释放的消息后,解除阻塞等待状态,再次尝试加锁。

增加发布订阅流程

参考:

# 总结

以上的实现思路仅仅考虑在单机版Redis上,如果是集群版Redis需要考虑的问题还要再多一点。Redis由于他的高性能读写能力,所以在并发高的场景下使用Redis分布式锁会多一点。

# 16.2 如何解决 Redis 的并发竞争 Key 问题?

# 什么是Redis 的并发竞争 Key 问题?

Redis 的并发竞争 Key 问题:多个redis的client同时set key引起的并发问题。

由于单线程所以Redis本身并没有锁的概念,多个客户端连接并不存在竞争关系,但是利用jedis等客户端对Redis进行并发访问时会出现问题。

比如:同时有多个子系统去set一个key。这个时候要注意什么呢?

# 场景一:库存问题

例如有多个请求一起去对某个商品减库存,通常操作流程是:

  • 取出当前库存值
  • 计算新库存值
  • 写入新库存值

假设当前库存值为 20,现在有2个连接都要减 5,结果库存值应该是 10 才对,但存在下面这种情况:

库存问题

# 场景二:顺序问题

比如有3个请求有序的修改某个key,按正常顺序的话,数据版本应该是 1->2->3,最后应该是 3。

但如果第二个请求由于网络原因迟到了,数据版本就变为了 1->3->2,最后值为 2,出问题了。

# 解决方案一:利用消息队列

在并发量过大的情况下,可以通过消息中间件进行处理,把并行读写进行串行化。

把Redis.set操作放在队列中使其串行化,必须的一个一个执行。

这种方式在一些高并发的场景中算是一种通用的解决方案。

# 解决方案二:分布式锁+时间戳

整体技术方案

准备一个分布式锁,大家去抢锁,抢到锁就做set操作。

加锁的目的实际上就是把并行读写改成串行读写的方式,从而来避免资源竞争。

分布式锁参看上文

时间戳

要求key的操作需要顺序执行,所以需要保存一个时间戳判断set顺序。

系统A key 1 {ValueA 7:00} 系统B key 1 { ValueB 7:05}

假设系统B先抢到锁,将key1设置为{ValueB 7:05}。接下来系统A抢到锁,发现自己的key1的时间戳早于缓存中的时间戳(7:00<7:05),那就不做set操作了。

# 16.3 分布式Redis是前期做还是后期规模上来了再做好?为什么?

Redis是轻量的(单实例只使用1M内存),为防止以后的扩容,最好的办法就是一开始就启动较多实例。即便只有一台服务器,也可以开说个实例,前期比较麻烦,但随着数据增长,牺牲是值得的。需要更多的Redis服务器时,你需要做的就是仅仅将Redis实例从一台服务迁移到另外一台服务器而已,而不用考虑重新分区的问题。

# 16.4 什么是 RedLock?

参看:

# 17 集群方案

# 17.1 Redis主从复制

Redis为了解决单点数据库问题,会把数据复制多个副本部署到其他节点上,通过复制,实现Redis的高可用性,实现对数据的冗余备份,保证数据和服务的高度可靠性。

简介:

  • 主从模式里使用一个redis实例作为主机(master),其余多个实例作为备份机(slave);
  • master用来支持数据的写入和读取操作,而slave支持读取及master的数据同步;
  • 在整个架构里,master和slave实例里的数据完全一致;

流程:

①从数据库向主数据库发送sync(数据同步)命令。

②主数据库接收同步命令后,会保存快照,创建一个RDB文件。

③当主数据库执行完保持快照后,会向从数据库发送RDB文件,而从数据库会接收并载入该文件。

④主数据库将缓冲区的所有写命令发给从服务器执行。

⑤以上处理完之后,之后主数据库每执行一个写命令,都会将被执行的写命令发送给从数据库。

注意:在Redis2.8之后,主从断开重连后会根据断开之前最新的命令偏移量进行增量复制。

Redis主从复制流程

断开重连增量同步

主节点故障处理方式

主从模式中,每个客户端连接redis实例时都指定了ip和端口号。如果所连接的redis实例因为故障下线了,则无法通知客户端连接其他客户端地址,因此只能进行手动操作。

不支持高可用

显而易见,主从模式很好地解 决了数据备份的问题,但是主节点因为故障下线后,需要手动更改客户端配置重新连接,这种模式并不能保证服务的高可用。

于是redis集群迎来了哨兵模式......

# 17.2 哨兵(sentinal)机制

哨兵的出现主要是解决了主从复制出现故障时 需要人为干预 的问题。

简介:

  • 和主从模式不一样的是,哨兵模式中 增加了独立进程(即哨兵)来监控集群中的一举一动 。客户端在连接集群时,首先连接哨兵,通过哨兵查询主节点的地址,然后再去连接主节点进行数据交互。
  • 如果master异常,则会进行master-slave切换,将最优的一个slave切换为主节点。
  • 同时,哨兵持续监控挂掉的主节点,待其恢复后,作为新的从节点加入集群中。

Redis哨兵主要功能

(1)集群监控:负责监控Redis master和slave进程是否正常工作

(2)消息通知:如果某个Redis实例有故障,那么哨兵负责发送消息作为报警通知给管理员

(3)故障转移:如果master node挂掉了,会自动转移到slave node上

(4)配置中心:如果故障转移发生了,通知client客户端新的master地址

Redis哨兵的高可用

原理:当主节点出现故障时,由Redis Sentinel自动完成故障发现和转移,并通知应用方,实现高可用性。

Redis哨兵高可用

主节点故障处理方式/哨兵工作方式

  1. 哨兵机制建立了多个哨兵节点(进程),共同监控数据节点的运行状况。
  2. 同时哨兵节点之间也互相通信,交换对主从节点的监控状况。
  3. 每隔1秒每个哨兵会向整个集群:Master主服务器+Slave从服务器+其他Sentinel(哨兵)进程,发送一次ping命令做一次心跳检测。

主观下线和客观下线

**主观下线:**一个哨兵节点判定主节点down掉是主观下线。

**客观下线:**只有半数哨兵节点都主观判定主节点down掉,此时多个哨兵节点交换主观判定结果,才会判定主节点客观下线。

原理

基本上哪个哨兵节点最先判断出这个主节点客观下线,就会在各个哨兵节点中发起投票机制Raft算法(选举算法),最终被投为领导者的哨兵节点完成主从自动化切换的过程,客户端在master节点发生故障时会重向哨兵要地址,此时会获得最新的master节点地址。。

扩容问题

哨兵模式的出现虽然解决了主从模式中master节点宕机不能自主切换(即高可用)的问题。但是,随着业务的逐渐增长,不可避免需要对当前业务进行扩容。

常见的扩容方式有垂直和水平扩容两种方式:

  • 垂直扩容:通过增加master内存来增加容量;
  • 水平扩容:通过增加节点来进行扩容,即在当前基础上再增加一个master节点。

虽然垂直扩容方式很便捷,不需要添加多余的节点,但是机器的容量是有限的,最终还是需要通过水平扩容方式来解决。而水平扩容涉及到数据的迁移,且迁移过程中又要保证服务的可用性。因此,数据能不迁移就尽量不要迁移。

显然,哨兵模式无法满足这种情形。因此,redis cluster应运而生。

# 17.3 Redis cluster 模式

简介:

  1. redis cluster模式采用了 无中心节点 的方式来实现,每个主节点都会与其它主节点保持连接。节点间通过gossip协议交换彼此的信息,同时每个主节点又有一个或多个从节点;

  2. 客户端连接集群时,直接与redis集群的每个主节点连接,根据hash算法取模将key存储在不同的哈希槽上;

  3. 在集群中采用数据分片的方式,将redis集群分为16384个哈希槽。如下图所示,这些哈希槽分别存储于三个主节点中:

  • Master1负责0~5460号哈希槽
  • Master2负责5461~10922号哈希槽
  • Master3负责10922~16383号哈希槽
  1. 每个节点会保存一份数据分布表,节点会将自己的slot信息发送给其他节点,节点间不停的传递数据分布表;

  2. 客户端连接集群时,通过集群中某个节点地址进行连接。客户端尝试向这个节点执行命令时,比如获取某个key值,如果key所在的slot刚好在该节点上,则能够直接执行成功。如果slot不在该节点,则节点会返回MOVED错误,同时把该slot对应的节点告诉客户端,客户端可以去该节点执行命令。

主节点故障处理方式

redis cluster中主节点故障处理方式与哨兵模式较为相像,当约定时间内某节点无法与集群中的另一个节点顺利完成ping消息通信时,则将该节点标记为主观下线状态,同时将这个信息向整个集群广播。

如果一个节点收到某个节点失联的数量达到了集群的大多数时,那么将该节点标记为客观下线状态,并向集群广播下线节点的fail消息。然后立即对该故障节点进行主从切换。等到原来的主节点恢复后,会自动成为新主节点的从节点。如果主节点没有从节点,那么当它发生故障时,集群就将处于不可用状态。

扩容问题

在哨兵模式中我们在扩容的时候遇到了问题,那么cluster中我们如何动态上线某个节点呢。当集群中加入某个节点时,哈希槽又是如何来进行分配的?当集群中加入新节点时,会与集群中的某个节点进行握手,该节点会把集群内的其它节点信息通过gossip协议发送给新节点,新节点与这些节点完成握手后加入到集群中。

然后集群中的节点会各取一部分哈希槽分配给新节点,如下图:

  • Master1负责1365-5460
  • Master2负责6827-10922
  • Master3负责12288-16383
  • Master4负责0-1364,5461-6826,10923-12287

当集群中要删除节点时,只需要将节点中的所有哈希槽移动到其它节点,然后再移除空白(不包含任何哈希槽)的节点就可以了。

一致性hash解决每个节点都要进行数据变更的问题:Redis分布式算法原理—Hash一致性理解 (opens new window)

# 参考: