redis
一 概述
redis是一种key-value数据库,支持五种数据类型:string(字符串),hash(哈希),list(列表),set(集合)及zset(sorted set:有序集合)。
1 String(字符串)
string是键值对形式。一个键最大能存储512MB。string类型是二进制安全的(基于SDS(Simple Dynamic String))。意思是redis的string可以包含任何数据。比如jpg图片或者序列化的对象
为什么要使用这样的一个数据结构来存储字符串呢?
- ①:二进制安全的数据结构
- 比如是操作命令是
get aaa\0
:获取aaa\0
的值。如果是c语言的字符数组就会把\0
吞掉,变为get aaa
,而使用SDS
就会完整的操作aaa\0
,SDS
把所有接受到的数据都转成字符串,即使是一些特殊字符! - ②:SDS提供了内存预分配机制,避免频繁的内存分配
- 如果是c语言,在修改一个key时,会分配一个新的字符数组,然后进行内存赋值,而
SDS
则采用预先分配机制,直接把字符串容量扩大两倍,key的长度变化时,直接在已分配的内存中修改即可,如果不够继续扩大2倍 - ③:兼容c语言的函数库
string的使用场景
单值缓存
对象缓存
分布式锁(SETNX, 使用分布式锁时最好设置key超时时间防止死锁 SET product:10001 true ex 10 nx )
计数器(INCR)
分布式主键id(INCR, redis批次自增1000作为发号器)
2 Hash(哈希)
Redis hash是一个string类型的键值对集合。常用命令 hget hset hgetall等
redis> HMSET myhash field1 "Hello" field2 "World" OK redis> HGET myhash field1 "Hello" redis> HGET myhash field2 "World"
hash的使用场景
电商购物车
优点
1)同类数据归类整合储存,方便数据管理
2)相比string操作消耗内存与cpu更小
string类型通过kv方式存储数据,通过对key进行hash运算决定存储在数组哪个位置。如果把hash类型的数据变成string类型来存储,则需要更多的key,同时在存放时也需要更多的hash运算,消耗更多的cpu资源
3)相比string储存更节省空间
如果把hash类型的数据变成string类型来存储,将需要存储更多key,需要更多的内存空间!
缺点
1)过期功能不能使用在field上,只能用在key上
redis的过期时间只能用在key上,而hash的key是一个大的概念,里面的map型结构才是重要数据,hash结构相比于string不能实现精准过期!
2)hash结构在Redis集群架构下不适合大规模使用
因为如果一个hash的key中的属性很多的话,只能存在一个redis节点上,那么这个节点压力会比其他节点压力大很多,造成redis集群下压力分配不均衡!
3 List(列表)
Redis 列表是简单的字符串列表,按照插入顺序排序。你可以添加一个元素到列表的头部(左边)或者尾部(右边)。
Redis 中list的数据结构实现是双向链表,所以可以非常便捷的应用于消息队列(生产者/消费者模型)。消息的生产者只需要通过lpush将消息放入list,消费者便可以通过rpop取出该消息,并且可以保证消息的有序性。由于 Redis拥有持久化功能,也不需要担心由于服务器故障导致消息丢失。
list的使用场景
①:Stack(栈) = LPUSH(左边放) + LPOP(左边取)
②:Queue(队列)= LPUSH(左边放) + RPOP(右边取)
③:Blocking MQ(阻塞队列)= LPUSH(左边放) + BRPOP(右边阻塞取:没有数据就阻塞!)
问:那么redis实现的数据结构和jdk中提供的数据结构有什么区别呢?
答:jdk提供的数据结构仅在本服务中有用,如果在分布式环境下,则需要借助redis等中间件,模拟数据结构来统一管理数据。
4 Set(集合)
Redis的Set是string类型的无序集合。
集合是通过哈希表实现的,所以添加,删除,查找的复杂度都是O(1)。
set的使用场景
去重
交集/差集/补集
我和某人共同关注的人,朋友圈点赞等功能
问: 如何快速找到微博场景与A互相关注的好友列表?
答: 用户 A,将它的关注和粉丝的用户 id 都存放在两个 set 中:
A-follow: 存放 A 所有关注的用户 id
A-followed: 存放 A 所有粉丝的用户 id
根据A-follow和A-followed的交集得到与 A 互相关注的用户
5 Zset (有序集合)
zset 和 set 一样也是string类型元素的集合, 且不允许重复的成员。
不同的是每个元素都会关联一个double类型的分数。redis正是通过分数来为集合中的成员进行从小到大的排序。 zset的成员是唯一的,但分数(score)却可以重复。
zset的使用场景
排行榜
二 持久化之RDB
1 概述
每隔某段时间保存全量数据的快照到磁盘。生成一个二进制文件,配置文件中的格式是 save N M。表示在N秒内,redis至少发生M次修改则抓快照写到磁盘。当然也可以手动执行save或bgsave
save 300 10 #在300秒(5分钟)之后,如果至少有10个key发生变化,Redis就会自动触发bgsave命令创建快照。
RDB 创建快照时会阻塞主线程吗?
Redis 提供了两个命令来生成 RDB 快照文件:
save
: 同步保存操作,会阻塞 Redis 主线程;bgsave
: fork 出一个子进程,子进程执行,不会阻塞 Redis 主线程,默认选项。
2 原理
Redis是单线程的,所以在快照持久化的时候fork一个子进程将内存中数据序列化。为使父进程不受子进程快照操作阻塞影响,采用COW技术:将数据段分为N个数据页,当主进程操作一个数据页中数据时,把页面从共享内存中复制一份,父进程指向新的页进行数据修改,此时子进程还是指向老的页面。
3 优缺点
缺点:全量同步数据量大,性能下降、会丢失宕机到最近一次快照的数据
优点:全量快照,文件小,恢复快
三 持久化之AOF(append only file)
1 概述
每一次数据变化都以增量形式保存在AOF文件中
AOF可以做到数据不丢失,相应地性能就会差一些。在配置文件中开启“appendonly yes”,redis每执行一个修改数据的命令,都会把它添加到AOF文件中,当redis重启时,将会读取AOF文件进行"回放"以恢复到redis关闭前的最后时刻
随着修改数据的执行AOF文件会越来越大,其中很多内容反复记录某一个key的变化情况,因此redis有一个特性对此进行了优化: 不影响client端操作的同时,后台重建AOF文件,在任何时候执行BGREWRITEAOF命令,都会把当前内存中最短序列的命令写到磁盘,这些命令完全可以构建当前的数据而没有多余的变化情况,缩小了AOF文件的大小。
AOF相对可靠,它和mysql的bin.log异曲同工。AOF文件内容是字符串,非常容易阅读和解析,且在没有被rewrite前,可以删除其中的某些命令
AOF文件刷新的方式,有三种,需要配置参数appendfsync:
a) appendfsync always每提交一个修改命令都调用fsync刷新到AOF文件,非常非常慢,但也非常安全;
b) appendfsync everysec每秒钟都调用fsync刷新到AOF文件,很快,但可能会丢失一秒以内的数据;
c) appendfsync no依靠OS进行刷新,redis不主动刷新AOF,这样最快,但安全性就差。默认并推荐每秒刷新,这样在速度和安全上都做到了兼顾。
2 原理
同样用到了COW,首先redis会fork一个子进程;子进程将最新的AOF写入一个临时文件;父进程增量的把内存中的最新执行的修改写入(这时仍写入旧的AOF,rewrite如果失败也是安全的);当子进程完成rewrite临时文件后,父进程会收到一个信号,并把之前内存中增量的修改写入临时文件末尾;这时redis将旧AOF文件重命名,临时文件重命名,开始向新的AOF中写入。
3 工作流程
AOF 持久化功能的实现可以简单分为 5 步:
- 命令追加(append):所有的写命令会追加到 AOF 缓冲区中。
- 文件写入(write):将 AOF 缓冲区的数据写入到 AOF 文件中。这一步需要调用
write
函数(系统调用),write
将数据写入到了系统内核缓冲区之后直接返回了(延迟写)。注意!!!此时并没有同步到磁盘。 - 文件同步(fsync):AOF 缓冲区根据对应的持久化方式(
fsync
策略)向硬盘做同步操作。这一步需要调用fsync
函数(系统调用),fsync
针对单个文件操作,对其进行强制硬盘同步,fsync
将阻塞直到写入磁盘完成后返回,保证了数据持久化。 - 文件重写(rewrite):随着 AOF 文件越来越大,需要定期对 AOF 文件进行重写,达到压缩的目的。
- 重启加载(load):当 Redis 重启时,可以加载 AOF 文件进行数据恢复。
Linux 系统直接提供了一些函数用于对文件和设备进行访问和控制,这些函数被称为 系统调用(syscall)。
这里对上面提到的一些 Linux 系统调用再做一遍解释:
write
:写入系统内核缓冲区之后直接返回(仅仅是写到缓冲区),不会立即同步到硬盘。虽然提高了效率,但也带来了数据丢失的风险。同步硬盘操作通常依赖于系统调度机制,Linux 内核通常为 30s 同步一次,具体值取决于写出的数据量和 I/O 缓冲区的状态。fsync
:fsync
用于强制刷新系统内核缓冲区(同步到到磁盘),确保写磁盘操作结束才会返回。
AOF 工作流程图如下:
4 优缺点
缺点:随着写不断增加,AOF文件越来越大。例如递增100次一个值,AOF保存100个记录,而RDB只保存一个,即最终结果100
优点:可读性高,适合保存增量数据,数据不易丢失
5 AOF 为什么是在执行完命令之后记录日志?
关系型数据库(如 MySQL)通常都是执行命令之前记录日志(方便故障恢复),而 Redis AOF 持久化机制是在执行完命令之后再记录日志。
为什么是在执行完命令之后记录日志呢?
- 避免额外的检查开销,AOF 记录日志不会对命令进行语法检查;
- 在命令执行完之后再记录,不会阻塞当前的命令执行。
这样也带来了风险(我在前面介绍 AOF 持久化的时候也提到过):
- 如果刚执行完命令 Redis 就宕机会导致对应的修改丢失;
- 可能会阻塞后续其他命令的执行(AOF 记录日志是在 Redis 主线程中进行的)。
6 AOF 重写了解吗?
当 AOF 变得太大时,Redis 能够在后台自动重写 AOF 产生一个新的 AOF 文件,这个新的 AOF 文件和原有的 AOF 文件所保存的数据库状态一样,但体积更小。
AOF 重写(rewrite) 是一个有歧义的名字,该功能是通过读取数据库中的键值对来实现的,程序无须对现有 AOF 文件进行任何读入、分析或者写入操作。
由于 AOF 重写会进行大量的写入操作,为了避免对 Redis 正常处理命令请求造成影响,Redis 将 AOF 重写程序放到子进程里执行。
AOF 文件重写期间,Redis 还会维护一个 AOF 重写缓冲区,该缓冲区会在子进程创建新 AOF 文件期间,记录服务器执行的所有写命令。当子进程完成创建新 AOF 文件的工作之后,服务器会将重写缓冲区中的所有内容追加到新 AOF 文件的末尾,使得新的 AOF 文件保存的数据库状态与现有的数据库状态一致。最后,服务器用新的 AOF 文件替换旧的 AOF 文件,以此来完成 AOF 文件重写操作。
四 redis压力问
1 redis为什么是单线程的
因为cpu不是redis的瓶颈。redis的瓶颈最有可能是机器内存或者网络带宽。既然单线程容易实现,而且cpu不会成为瓶颈,那就顺理成章地采用单线程的方案了。关于redis的性能,官方网站也有,普通笔记本轻松处理每秒几十万的请求。
redis操作的是内存中的数据结构,如果在多线程中就需要为这些对象加锁。使用多线程虽然可以提高性能,但是每个线程的效率严重下降了,而且程序的逻辑复杂化。redis的数据结构并不全是简单的key-value,还有list,hash等复杂的结构,这些结构有可能会进行很细粒度的操作,比如在一个很长的列表后面添加一个元素,在hash中添加或者删除一个对象,这些操作还可以合成MULTI/EXEC的组。这样操作中可能就需要加非常多的锁,导致的结果是同步开销大大增加。
redis在权衡之后的选择是用单线程,突出自己功能的灵活性。在单线程基础上任何原子操作都可以几乎无代价地实现,多么复杂的数据结构都可以轻松运用。
并不是所有的kv数据库或者内存数据库都应该用单线程,比如zk用的就是多线程,最终还是看源码作者的意愿和取舍。
2 redis总体快速的原因
(1)内存 - 请求是纯粹的内存操作,非常快速
(2)单线程 - 避免了不必要的上下文切换和竞态条件
(3)IO - 非阻塞IO,多路复用。内部采用epoll,将读,写,关闭,连接都转化成了事件,利用epoll的多路复用特性,在io上没有浪费
这3个条件不是相互独立的,特别是第一条,如果请求都是耗时的,采用单线程吞吐量及性能可想而知了。应该说redis为特殊的场景选择了合适的技术方案。
3 万一cpu成为redis的瓶颈了,或者不想让服务器其他核闲置,怎么办
多起几个redis进程。redis是kv数据库不是关系数据库,数据之间没有约束。只要客户端分清哪些key放在哪个redis进程上就可以了。redis-cluster可以帮你做的更好
4 redis相比memcached有哪些优势
(1) 数据类型 - memcached所有的值均是简单的字符串,redis作为其替代者,支持更为丰富的数据类型
(2) 速度 - redis的速度比memcached快很多
(3) 持久化 - redis可以持久化其数据
(4) 备份 - redis支持数据的备份,即master-slave模式的数据备份
(5) 网络io模型 - memcached是多线程,分为监听线程,worker线程,引入全局锁,也带来了性能损耗。redis使用单线程的io复用模型,将速度优势发挥到最大,各有千秋
(6) 数据一致性 - memcached提供了cas命令来保证,而redis提供了事务功能,可以保证一串命令的原子性,中间不会被任何操作打断
(思路: 数据结构/速度/持久化/单线程/事务)
5 redis最佳实现
实际使用 Redis 的过程中,我们尽量要准守一些常见的规范,比如:
- 使用连接池:避免频繁创建关闭客户端连接。
- 尽量不使用 O(n)指令,使用 O(n) 命令时要关注 n 的数量:像
KEYS *
、HGETALL
、LRANGE
、SMEMBERS
、SINTER
/SUNION
/SDIFF
等 O(n) 命令并非不能使用,但是需要明确 n 的值。另外,有遍历的需求可以使用HSCAN
、SSCAN
、ZSCAN
代替。 - 使用批量操作减少网络传输:原生批量操作命令(比如
MGET
、MSET
等等)、pipeline、Lua 脚本。 - 尽量不适用 Redis 事务:Redis 事务实现的功能比较鸡肋,可以使用 Lua 脚本代替。
- 禁止长时间开启 monitor:对性能影响比较大。
- 控制 key 的生命周期:避免 Redis 中存放了太多不经常被访问的数据。
- Master最好不要做任何持久化工作,如RDB内存快照和AOF日志文件。如果数据比较重要,某个Slave开启AOF备份数据,策略设置为每秒同步一次
- 为了主从复制的速度和连接的稳定性,Master和Slave最好在同一个局域网内
- 主从复制不要用图状结构,用单向链表结构更为稳定,即:Master <- Slave1 <- Slave2 <- Slave3...
6 穿透/雪崩/击穿什么情况发生,怎么解决
1 缓存穿透
查询一个一定不存在的数据。db查不到数据则不写入缓存,那么下次请求这个不存在的数据同样会到db层查询,失去了缓存的意义。流量大或人为恶意攻击可能会使db宕掉。
解决方案
(1) 布隆过滤器(彻底解决)。将全量可能存在的数据哈希到一个足够大的bitmap中,布隆可能误报,但绝不会漏报,那么一定不存在的数据会被拦截掉,从而缓解了对db的压力
(2) 空结果也进入缓存。如果查询返回的结果为空 (数据不存在 | 服务不可用), 仍将数据-空结果进行缓存,注意将其过期时间设置非常短(不超过5min)
如果不是nil,缓存无效key的方式会导致 Redis 中缓存大量无效的 key 。很明显,这种方案并不能从根本上解决此问题。如果非要用这种方式来解决穿透问题的话,尽量将无效的 key 的过期时间设置短一点比如 1 分钟。
3)接口限流 根据用户或者 IP 对接口进行限流,对于异常频繁的访问行为,还可以采取黑名单机制,例如将异常 IP 列入黑名单。
2 缓存雪崩
设置缓存时采用了相同的过期时间,导致缓存在某一时刻同时实效,请求全部打到db,db瞬间压力过重雪崩。
解决方案
(1) 加锁或采用队列保证缓存的单线程,避免失效时大量请求落到db存储系统
(2) 缓存时间离散化。在原缓存的失效时间基础上增加一个随机值,降低同一时间集体失效概率
(3) 多级缓存,例如本地缓存+Redis 缓存的组合,当 Redis 缓存出现问题时,还可以从本地缓存中获取到部分数据。
(4) 缓存预热,也就是在程序启动后或运行过程中,主动将热点数据加载到缓存中。
缓存预热如何实现?
常见的缓存预热方式有两种:
- 使用定时任务,比如 xxl-job,来定时触发缓存预热的逻辑,将数据库中的热点数据查询出来并存入缓存中。
- 使用消息队列,比如 Kafka,来异步地进行缓存预热,将数据库中的热点数据的主键或者 ID 发送到消息队列中,然后由缓存服务消费消息队列中的数据,根据主键或者 ID 查询数据库并更新缓存。
3 缓存击穿
对于设置了过期时间的某些key,在过期的时间点,恰好对这个key有大量的并发请求过来,这些请求发现缓存过期同时请求db加载数据并回设到缓存,这个高并发的请求可能瞬间把后端db压垮。
解决方案
(1) 永不过期
(2) 使用互斥锁。缓存失效的时候,不直接load db,而是使用缓存工具中带有成功返回标识的方法(比如redis的setnx,memcache的add)去set一个mutex key,当操作返回成功时,再进行load db的操作并回设缓存。否则重试整个get缓存的方法。
(3) 针对热点数据提前预热,将其存入缓存中并设置合理的过期时间比如秒杀场景下的数据在秒杀结束之前不过期。
在redis2.6.1之前版本未实现setnx的过期时间,所以给出两种版本代码参考
a) setnx无过期时间版本:
1 String get(String key) {
2 String value = redis.get(key);
3 if (value == null) {
4 if (redis.setnx(key_mutex, "1")) {
5 // 3 min timeout to avoid mutex holder crash
6 redis.expire(key_mutex, 3 * 60)
7 value = db.get(key);
8 redis.set(key, value);
9 redis.delete(key_mutex);
10 } else {
11 //其他线程休息50毫秒后重试
12 Thread.sleep(50);
13 get(key);
14 }
15 }
16 }
b) redis2.6.1后, setnx有过期时间版本:
1 public String get(key) {
2 String value = redis.get(key);
3 if (value == null) { //代表缓存值过期
4 //设置3min的超时,防止del操作失败的时候,下次缓存过期一直不能load db
5 if (redis.setnx(key_mutex, 1, 3 * 60) == 1) { //代表设置成功
6 value = db.get(key);
7 redis.set(key, value, expire_secs);
8 redis.del(key_mutex);
9 } else { //这个时候代表同时候的其他线程已经load db并回设到缓存了,这时候重试获取缓存值即可
10 sleep(50);
11 get(key); //重试
12 }
13 } else {
14 return value;
15 }
16 }
7 Redis 的有序集合底层为什么要用跳表,而不用平衡树、红黑树或者 B+树?
这道面试题很多大厂比较喜欢问,难度还是有点大的。
- 平衡树 vs 跳表:平衡树的插入、删除和查询的时间复杂度和跳表一样都是 O(log n)。对于范围查询来说,平衡树也可以通过中序遍历的方式达到和跳表一样的效果。但是它的每一次插入或者删除操作都需要保证整颗树左右节点的绝对平衡,只要不平衡就要通过旋转操作来保持平衡,这个过程是比较耗时的。跳表诞生的初衷就是为了克服平衡树的一些缺点。跳表使用概率平衡而不是严格强制的平衡,因此,跳表中的插入和删除算法比平衡树的等效算法简单得多,速度也快得多。
- 红黑树 vs 跳表:相比较于红黑树来说,跳表的实现也更简单一些,不需要通过旋转和染色(红黑变换)来保证黑平衡。并且,按照区间来查找数据这个操作,红黑树的效率没有跳表高。
- B+树 vs 跳表:B+树更适合作为数据库和文件系统中常用的索引结构之一,它的核心思想是通过可能少的 IO 定位到尽可能多的索引来获得查询数据。对于 Redis 这种内存数据库来说,它对这些并不感冒,因为 Redis 作为内存数据库它不可能存储大量的数据,所以对于索引不需要通过 B+树这种方式进行维护,只需按照概率进行随机维护即可,节约内存。而且使用跳表实现 zset 时相较前者来说更简单一些,在进行插入时只需通过索引将数据插入到链表中合适的位置再随机维护一定高度的索引即可,也不需要像 B+树那样插入时发现失衡时还需要对节点分裂与合并。
8 过期的数据的删除策略了解么?
如果假设你设置了一批 key 只能存活 1 分钟,那么 1 分钟后,Redis 是怎么对这批 key 进行删除的呢?
常用的过期数据的删除策略就两个:
- 惰性删除:只会在取出 key 的时候才对数据进行过期检查。这样对 CPU 最友好,但是可能会造成太多过期 key 没有被删除。
- 定期删除:每隔一段时间抽取一批 key 执行删除过期 key 操作。并且,Redis 底层会通过限制删除操作执行的时长和频率来减少删除操作对 CPU 时间的影响。
当某个key被设置了过期时间之后,客户端每次对该key的访问(读写)都会事先检测该key是否过期,如果过期就直接删除;但有一些键只访问一次,因此需要主动删除,默认情况下redis每秒检测10次,检测的对象是所有设置了过期时间的键集合,每次从这个集合中随机检测20个键查看他们是否过期,如果过期就直接删除,如果删除后还有超过25%的集合中的键已经过期,那么继续检测过期集合中的20个随机键进行删除。这样可以保证过期键最大只占所有设置了过期时间键的25%。
定期删除对内存更加友好,惰性删除对 CPU 更加友好。两者各有千秋,所以 Redis 采用的是 定期删除+惰性/懒汉式删除 。
但是,仅仅通过给 key 设置过期时间还是有问题的。因为还是可能存在定期删除和惰性删除漏掉了很多过期 key 的情况。这样就导致大量过期 key 堆积在内存里,然后就 Out of memory 了。
怎么解决这个问题呢?答案就是:Redis 内存淘汰机制。
9 Redis 内存淘汰机制了解么?
当redis内存超出物理内存限制时,内存的数据会开始和磁盘产生频繁的交换。这种机制涉及到IO操作,会让redis的性能急剧下降. 所以一般会限制最大的使用内存,redis提供了配置参数maxmemory
来规定最大的使用内存。
maxmemory 1GB
maxmemory 0 # 表示不做限制,一般不会用
Redis 提供 6 种数据淘汰策略:
- volatile-lru:从已设置过期时间的数据集中挑选最近最少使用的数据淘汰。
- volatile-ttl:从已设置过期时间的数据集(
server.db[i].expires
)中挑选将要过期的数据淘汰。 - volatile-random:从已设置过期时间的数据集中任意选择数据淘汰。
- allkeys-lru:当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的 key(这个是最常用的)。
- allkeys-random:从数据集(
server.db[i].dict
)中任意选择数据淘汰。 - no-eviction:禁止驱逐数据,也就是说当内存不足以容纳新写入数据时,新写入操作会报错。这个应该没人使用吧!
4.0 版本后增加以下两种:
- volatile-lfu(least frequently used):从已设置过期时间的数据集(
server.db[i].expires
)中挑选最不经常使用的数据淘汰。 - allkeys-lfu(least frequently used):当内存不足以容纳新写入数据时,在键空间中,移除最不经常使用的 key。
10 Redis事务
Redis 事务提供了一种将多个命令请求打包的功能。然后,再按顺序执行打包的所有命令,并且不会被中途打断。
Redis 事务实际开发中使用的非常少,功能比较鸡肋,不要将其和我们平时理解的关系型数据库的事务混淆了。
Redis 可以通过 MULTI
,EXEC
,DISCARD
等命令来实现事务(Transaction)功能。
MULTI
OK
SET PROJECT "JavaGuide"
QUEUED
GET PROJECT
QUEUED
EXEC
1) OK
2) "JavaGuide"
MULTI
命令后可以输入多个命令,Redis 不会立即执行这些命令,而是将它们放到队列,当调用了 EXEC
命令后,再执行所有的命令。
这个过程是这样的:
- 开始事务(
MULTI
); - 命令入队(批量操作 Redis 的命令,先进先出(FIFO)的顺序执行);
- 执行事务(
EXEC
)。
11 Redis事务支持原子性吗
Redis 的事务和我们平时理解的关系型数据库的事务不同。我们知道事务具有四大特性:1. 原子性,2. 隔离性,3. 持久性,4. 一致性。
- 原子性(Atomicity): 事务是最小的执行单位,不允许分割。事务的原子性确保动作要么全部完成,要么完全不起作用;
- 隔离性(Isolation): 并发访问数据库时,一个用户的事务不被其他事务所干扰,各并发事务之间数据库是独立的;
- 持久性(Durability): 一个事务被提交之后。它对数据库中数据的改变是持久的,即使数据库发生故障也不应该对其有任何影响。
- 一致性(Consistency): 执行事务前后,数据保持一致,多个事务对同一个数据读取的结果是相同的;
Redis 事务在运行错误的情况下,除了执行过程中出现错误的命令外,其他命令都能正常执行。并且,Redis 事务是不支持回滚(roll back)操作的。因此,Redis 事务其实是不满足原子性的。
Redis 官网也解释了自己为啥不支持回滚。简单来说就是 Redis 开发者们觉得没必要支持回滚,这样更简单便捷并且性能更好。Redis 开发者觉得即使命令执行错误也应该在开发过程中就被发现而不是生产过程中。
Mysql当中针对于并发事务会存在脏读、不可重复读、幻读等情况,那么Redis会有这种情况吗?
对于Redis而言根本不需要考虑这个。因为Redis是单线程的,根本不具备并发事务,并且Redis的事务虽然给人的感觉是将所有Redis命令放到了一个事务,本质上执行事务,就是把这个事务当成了一行命令来处理,然后对事务内的命令也是一行一行执行。
事务一般都是为原子性而生,既然Redis事务没有原子性,那他存在的意义是什么?
redis事务的主要作用就是串联多个命令防止 别的命令插队。
12 Redis事务支持持久性吗
Redis 不同于 Memcached 的很重要一点就是,Redis 支持持久化,而且支持 3 种持久化方式:
- 快照(snapshotting,RDB)
- 只追加文件(append-only file, AOF)
- RDB 和 AOF 的混合持久化(Redis 4.0 新增)
与 RDB 持久化相比,AOF 持久化的实时性更好。在 Redis 的配置文件中存在三种不同的 AOF 持久化方式( fsync
策略),它们分别是:
appendfsync always #每次有数据修改发生时都会调用fsync函数同步AOF文件,fsync完成后线程返回,这样会严重降低Redis的速度
appendfsync everysec #每秒钟调用fsync函数同步一次AOF文件
appendfsync no #让操作系统决定何时进行同步,一般为30秒一次
AOF 持久化的fsync
策略为 no、everysec 时都会存在数据丢失的情况 。always 下可以基本是可以满足持久性要求的,但性能太差,实际开发过程中不会使用。
因此,Redis 事务的持久性也是没办法保证的。
13 如何解决Redis事务的缺陷
Redis 从 2.6 版本开始支持执行 Lua 脚本,它的功能和事务非常类似。我们可以利用 Lua 脚本来批量执行多条 Redis 命令,这些 Redis 命令会被提交到 Redis 服务器一次性执行完成,大幅减小了网络开销。
一段 Lua 脚本可以视作一条命令执行,一段 Lua 脚本执行过程中不会有其他脚本或 Redis 命令同时执行,保证了操作不会被其他指令插入或打扰。
不过,如果 Lua 脚本运行时出错并中途结束,出错之后的命令是不会被执行的。并且,出错之前执行的命令是无法被撤销的,无法实现类似关系型数据库执行失败可以回滚的那种原子性效果。因此, 严格来说的话,通过 Lua 脚本来批量执行 Redis 命令实际也是不完全满足原子性的。
如果想要让 Lua 脚本中的命令全部执行,必须保证语句语法和命令都是对的。
14 Redis性能优化 - 批量操作减少网络传输
一个 Redis 命令的执行可以简化为以下 4 步:
- 发送命令
- 命令排队
- 命令执行
- 返回结果
其中,第 1 步和第 4 步耗费时间之和称为 Round Trip Time (RTT,往返时间) ,也就是数据在网络上传输的时间。
使用批量操作可以减少网络传输次数,进而有效减小网络开销,大幅减少 RTT。
另外,除了能减少 RTT 之外,发送一次命令的 socket I/O 成本也比较高(涉及上下文切换,存在read()
和write()
系统调用),批量操作还可以减少 socket I/O 成本
原生批量操作命令
MGET(获取一个或多个指定 key 的值) HMGET(获取指定哈希表中一个或者多个指定字段的值) SAA(向指定集合添加一个或多个元素)
不过,在 Redis 官方提供的分片集群解决方案 Redis Cluster 下,使用这些原生批量操作命令可能会存在一些小问题需要解决。就比如说 MGET
无法保证所有的 key 都在同一个 hash slot(哈希槽)上,MGET
可能还是需要多次网络传输,原子操作也无法保证了。不过,相较于非批量操作,还是可以节省不少网络传输次数。
整个步骤的简化版如下(通常由 Redis 客户端实现,无需我们自己再手动实现):
- 找到 key 对应的所有 hash slot;
- 分别向对应的 Redis 节点发起
MGET
请求获取数据; - 等待所有请求执行结束,重新组装结果数据,保持跟入参 key 的顺序一致,然后返回结果。
如果想要解决这个多次网络传输的问题,比较常用的办法是自己维护 key 与 slot 的关系。不过这样不太灵活,虽然带来了性能提升,但同样让系统复杂性提升。
pipeline
对于不支持批量操作的命令,我们可以利用 pipeline(流水线) 将一批 Redis 命令封装成一组,这些 Redis 命令会被一次性提交到 Redis 服务器,只需要一次网络传输。不过,需要注意控制一次批量操作的 元素个数(例如 500 以内,实际也和元素字节数有关),避免网络传输的数据量过大。
与MGET
、MSET
等原生批量操作命令一样,pipeline 同样在 Redis Cluster 上使用会存在一些小问题。原因类似,无法保证所有的 key 都在同一个 hash slot(哈希槽)上。如果想要使用的话,客户端需要自己维护 key 与 slot 的关系。
原生批量操作命令和 pipeline 的是有区别的,使用的时候需要注意:
- 原生批量操作命令是原子操作,pipeline 是非原子操作。
- pipeline 可以打包不同的命令,原生批量操作命令不可以。
- 原生批量操作命令是 Redis 服务端支持实现的,而 pipeline 需要服务端和客户端的共同实现。
顺带补充一下 pipeline 和 Redis 事务的对比:
- 事务是原子操作,pipeline 是非原子操作。两个不同的事务不会同时运行,而 pipeline 可以同时以交错方式执行。
- Redis 事务中每个命令都需要发送到服务端,而 Pipeline 只需要发送一次,请求次数更少。
事务可以看作是一个原子操作,但其实并不满足原子性。当我们提到 Redis 中的原子操作时,主要指的是这个操作(比如事务、Lua 脚本)不会被其他操作(比如其他事务、Lua 脚本)打扰,并不能完全保证这个操作中的所有写命令要么都执行要么都不执行。这主要也是因为 Redis 是不支持回滚操作。
另外,pipeline 不适用于执行顺序有依赖关系的一批命令。就比如说,你需要将前一个命令的结果给后续的命令使用,pipeline 就没办法满足你的需求了。对于这种需求,我们可以使用 Lua 脚本 。
Lua 脚本
Lua 脚本同样支持批量操作多条命令。一段 Lua 脚本可以视作一条命令执行,可以看作是 原子操作 。也就是说,一段 Lua 脚本执行过程中不会有其他脚本或 Redis 命令同时执行,保证了操作不会被其他指令插入或打扰,这是 pipeline 所不具备的。
并且,Lua 脚本中支持一些简单的逻辑处理比如使用命令读取值并在 Lua 脚本中进行处理,这同样是 pipeline 所不具备的。
不过, Lua 脚本依然存在下面这些缺陷:
- 如果 Lua 脚本运行时出错并中途结束,之后的操作不会进行,但是之前已经发生的写操作不会撤销,所以即使使用了 Lua 脚本,也不能实现类似数据库回滚的原子性。
- Redis Cluster 下 Lua 脚本的原子操作也无法保证了,原因同样是无法保证所有的 key 都在同一个 hash slot(哈希槽)上。
15 Redis性能优化 - 大量key集中过期问题
前面提到过:对于过期 key,Redis 采用的是 定期删除+惰性/懒汉式删除 策略。
定期删除执行过程中,如果突然遇到大量过期 key 的话,客户端请求必须等待定期清理过期 key 任务线程执行完成,因为这个这个定期任务线程是在 Redis 主线程中执行的。这就导致客户端请求没办法被及时处理,响应速度会比较慢。
如何解决呢? 下面是两种常见的方法:
- 给 key 设置随机过期时间。
- 开启 lazy-free(惰性删除/延迟释放) 。lazy-free 特性是 Redis 4.0 开始引入的,指的是让 Redis 采用异步方式延迟释放 key 使用的内存,将该操作交给单独的子线程处理,避免阻塞主线程。
个人建议不管是否开启 lazy-free,我们都尽量给 key 设置随机过期时间。
16 Redis性能优化 - 避免bigKey(大value)
简单来说,如果一个 key 对应的 value 所占用的内存比较大,那这个 key 就可以看作是 bigkey。
- String 类型的 value 超过 1MB
- 复合类型(List、Hash、Set、Sorted Set 等)的 value 包含的元素超过 5000 个(不过,对于复合类型的 value 来说,不一定包含的元素越多,占用的内存就越多)。
bigkey 是怎么产生的?有什么危害?
bigkey 通常是由于下面这些原因产生的:
- 程序设计不当,比如直接使用 String 类型存储较大的文件对应的二进制数据。
- 对于业务的数据规模考虑不周到,比如使用集合类型的时候没有考虑到数据量的快速增长。
- 未及时清理垃圾数据,比如哈希中冗余了大量的无用键值对。
bigkey 除了会消耗更多的内存空间和带宽,还会对性能造成比较大的影响。
大 key 还会造成阻塞问题。具体来说,主要体现在下面三个方面:
- 客户端超时阻塞:由于 Redis 执行命令是单线程处理,然后在操作大 key 时会比较耗时,那么就会阻塞 Redis,从客户端这一视角看,就是很久很久都没有响应。
- 网络阻塞:每次获取大 key 产生的网络流量较大,如果一个 key 的大小是 1 MB,每秒访问量为 1000,那么每秒会产生 1000MB 的流量,这对于普通千兆网卡的服务器来说是灾难性的。
- 工作线程阻塞:如果使用 del 删除大 key 时,会阻塞工作线程,这样就没办法处理后续的命令。
大 key 造成的阻塞问题还会进一步影响到主从同步和集群扩容。
综上,大 key 带来的潜在问题是非常多的,我们应该尽量避免 Redis 中存在 bigkey。
如何处理 bigkey?
bigkey 的常见处理以及优化办法如下(这些方法可以配合起来使用):
- 分割 bigkey:将一个 bigkey 分割为多个小 key。例如,将一个含有上万字段数量的 Hash 按照一定策略(比如二次哈希)拆分为多个 Hash。
- 手动清理:Redis 4.0+ 可以使用
UNLINK
命令来异步删除一个或多个指定的 key。Redis 4.0 以下可以考虑使用SCAN
命令结合DEL
命令来分批次删除。 - 采用合适的数据结构:例如,文件二进制数据不使用 String 保存、使用 HyperLogLog 统计页面 UV、Bitmap 保存状态信息(0/1)。
- 开启 lazy-free(惰性删除/延迟释放) :lazy-free 特性是 Redis 4.0 开始引入的,指的是让 Redis 采用异步方式延迟释放 key 使用的内存,将该操作交给单独的子线程处理,避免阻塞主线程。
17 Redis性能优化 - hotKey
如果一个 key 的访问次数比较多且明显多于其他 key 的话,那这个 key 就可以看作是 hotkey(热 Key)。例如在 Redis 实例的每秒处理请求达到 5000 次,而其中某个 key 的每秒访问量就高达 2000 次,那这个 key 就可以看作是 hotkey。
hotkey 出现的原因主要是某个热点数据访问量暴增,如重大的热搜事件、参与秒杀的商品。
hotkey 有什么危害?
处理 hotkey 会占用大量的 CPU 和带宽,可能会影响 Redis 实例对其他请求的正常处理。此外,如果突然访问 hotkey 的请求超出了 Redis 的处理能力,Redis 就会直接宕机。这种情况下,大量请求将落到后面的数据库上,可能会导致数据库崩溃。
因此,hotkey 很可能成为系统性能的瓶颈点,需要单独对其进行优化,以确保系统的高可用性和稳定性。
如何解决 hotkey?
hotkey 的常见处理以及优化办法如下(这些方法可以配合起来使用):
- 读写分离:主节点处理写请求,从节点处理读请求。
- 使用 Redis Cluster:将热点数据分散存储在多个 Redis 节点上。
- 二级缓存:hotkey 采用二级缓存的方式进行处理,将 hotkey 存放一份到 JVM 本地内存中(可以用 Caffeine)。
18 Redis内存碎片
你可以将内存碎片简单地理解为那些不可用的空闲内存。
举个例子:操作系统为你分配了 32 字节的连续内存空间,而你存储数据实际只需要使用 24 字节内存空间,那这多余出来的 8 字节内存空间如果后续没办法再被分配存储其他数据的话,就可以被称为内存碎片。
Redis 内存碎片虽然不会影响 Redis 性能,但是会增加内存消耗。
Redis 内存碎片产生比较常见的 2 个原因:
1、Redis 存储存储数据的时候向操作系统申请的内存空间可能会大于数据实际需要的存储空间。
Redis 使用 zmalloc
方法(Redis 自己实现的内存分配方法)进行内存分配的时候,除了要分配 size
大小的内存之外,还会多分配 PREFIX_SIZE
大小的内存。
19 Redis常见阻塞原因总结
O(n) 命令
Redis 中的大部分命令都是 O(1)时间复杂度,但也有少部分 O(n) 时间复杂度的命令.由于这些命令时间复杂度是 O(n),有时候也会全表扫描,随着 n 的增大,执行耗时也会越长,从而导致客户端阻塞。不过, 这些命令并不是一定不能使用,但是需要明确 N 的值。另外,有遍历的需求可以使用 HSCAN
、SSCAN
、ZSCAN
代替。除了这些 O(n)时间复杂度的命令可能会导致阻塞之外, 还有一些时间复杂度可能在 O(N) 以上的命令
SAVE 创建 RDB 快照
Redis 提供了两个命令来生成 RDB 快照文件:
save
: 同步保存操作,会阻塞 Redis 主线程;bgsave
: fork 出一个子进程,子进程执行,不会阻塞 Redis 主线程,默认选项。
默认情况下,Redis 默认配置会使用 bgsave
命令。如果手动使用 save
命令生成 RDB 快照文件的话,就会阻塞主线程。
AOF 日志记录阻塞
AOF 刷盘阻塞
AOF 重写阻塞
大 Key
如果一个 key 对应的 value 所占用的内存比较大,那这个 key 就可以看作是 bigkey。具体多大才算大呢?有一个不是特别精确的参考标准:
- string 类型的 value 超过 1MB
- 复合类型(列表、哈希、集合、有序集合等)的 value 包含的元素超过 5000 个(对于复合类型的 value 来说,不一定包含的元素越多,占用的内存就越多)。
大 key 造成的阻塞问题如下:
- 客户端超时阻塞:由于 Redis 执行命令是单线程处理,然后在操作大 key 时会比较耗时,那么就会阻塞 Redis,从客户端这一视角看,就是很久很久都没有响应。
- 引发网络阻塞:每次获取大 key 产生的网络流量较大,如果一个 key 的大小是 1 MB,每秒访问量为 1000,那么每秒会产生 1000MB 的流量,这对于普通千兆网卡的服务器来说是灾难性的。
- 阻塞工作线程:如果使用 del 删除大 key 时,会阻塞工作线程,这样就没办法处理后续的命令。
清空数据库
清空数据库和上面 bigkey 删除也是同样道理,flushdb
、flushall
也涉及到删除和释放所有的键值对,也是 Redis 的阻塞点。
集群扩容
Redis 集群可以进行节点的动态扩容缩容,这一过程目前还处于半自动状态,需要人工介入。
在扩缩容的时候,需要进行数据迁移。而 Redis 为了保证迁移的一致性,迁移所有操作都是同步操作。
执行迁移时,两端的 Redis 均会进入时长不等的阻塞状态,对于小 Key,该时间可以忽略不计,但如果一旦 Key 的内存使用过大,严重的时候会触发集群内的故障转移,造成不必要的切换。
Swap(内存交换)
什么是 Swap? Swap 直译过来是交换的意思,Linux 中的 Swap 常被称为内存交换或者交换分区。类似于 Windows 中的虚拟内存,就是当内存不足的时候,把一部分硬盘空间虚拟成内存使用,从而解决内存容量不足的情况。因此,Swap 分区的作用就是牺牲硬盘,增加内存,解决 VPS 内存不够用或者爆满的问题。
Swap 对于 Redis 来说是非常致命的,Redis 保证高性能的一个重要前提是所有的数据在内存中。如果操作系统把 Redis 使用的部分内存换出硬盘,由于内存与硬盘的读写速度差几个数量级,会导致发生交换后的 Redis 性能急剧下降。
预防内存交换的方法:
- 保证机器充足的可用内存
- 确保所有 Redis 实例设置最大可用内存(maxmemory),防止极端情况 Redis 内存不可控的增长
- 降低系统使用 swap 优先级,如
echo 10 > /proc/sys/vm/swappiness
CPU 竞争
Redis 是典型的 CPU 密集型应用,不建议和其他多核 CPU 密集型服务部署在一起。当其他进程过度消耗 CPU 时,将严重影响 Redis 的吞吐量。
可以通过reids-cli --stat
获取当前 Redis 使用情况。通过top
命令获取进程对 CPU 的利用率等信息 通过info commandstats
统计信息分析出命令不合理开销时间,查看是否是因为高算法复杂度或者过度的内存优化问题。
20 Redis主从同步原理
总体来说redis主从复制的策略就是:
当主从服务器刚建立连接的时候,进行全量同步;全量复制结束后,进行增量复制。当然,如果有需要,slave 在任何时候都可以发起全量同步
全量复制:顾名思义也就是一次性把主节点数据全部发送给从节点,所以这种情况下,当数据量比较大时,会对主节点和网络造成很大的开销。
部分复制:用于处理主从复制时因网络中断等原因造成数据丢失的场景。当从节点再次和主节点连接时,主节点会补发丢失的数据。因为是补发,所以在发送的数据量一定是小于全量的数据。
主从全量复制的流程:
Redis全量复制一般发生在Slave初始化阶段,这时Slave需要将Master上的所有数据都复制一份,具体步骤如下:
(7)如果slave node开启了AOF,那么会立即执行BGREWRITEAOF,重写AOF
主从复制的特点:
(1)Redis使用异步复制,每次接收到写命令之后,先在内部写入数据,然后异步发送给slave服务器。但从Redis 2.8开始,从服务器会周期性的应答从复制流中处理的数据量。
(2)主从复制不阻塞master服务器。也就是说当若干个从服务器在进行初始同步时,主服务器仍然可以处理外界请求。
(3)主从复制不阻塞slave服务器。当master服务器进行初始同步时,slave服务器返回的是以前旧版本的数据,如果你不想这样,那么在启动redis配置文件中进行设置,那么在同步过程中来自外界的查询请求都会返回错误给客户端;
(4)主从复制提高了redis服务的扩展性,避免单个redis服务器的读写访问压力过大的问题,同时也可以给为数据备份及冗余提供一种解决方案;
(5)使用主从复制可以为master服务器免除把数据写入磁盘的消耗,可以配置让master服务器不再将数据持久化到磁盘,而是通过连接让一个配置的slave类型的Redis服务器及时将相关数据持久化到磁盘。不过这种做法存在master类型的Redis服务器一旦重启,因为此时master服务器不进行持久化,所以数据为空,这时候通过主从同步可能导致slave类型的Redis服务器上的数据也被清空,所以这个配置要确保主服务器不会自动重启(详见第2点的“master开启持久化对主从架构的安全意义”)
21 Redis哨兵机制
Redis Sentinel,即Redis哨兵,在Redis 2.8版本开始引入。哨兵的核心功能是主节点的自动故障转移。
哨兵机制(sentinel)是Redis解决高可用的一种解决方案:它是由一个或者多个sentinel 实例组成的一个sentinel 系统。
哨兵实现了什么功能呢?下面是Redis官方文档的描述:
监控(Monitoring):哨兵会不断地检查主节点和从节点是否运作正常。
自动故障转移(Automatic failover):当主节点不能正常工作时,哨兵会开始自动故障转移操作,它会将失效主节点的其中一个从节点升级为新的主节点,并让其他从节点改为复制新的主节点。
配置提供者(Configuration provider):客户端在初始化时,通过连接哨兵来获得当前Redis服务的主节点地址。
通知(Notification):哨兵可以将故障转移的结果发送给客户端。
其中,监控和自动故障转移功能,使得哨兵可以及时发现主节点故障并完成转移;而配置提供者和通知功能,则需要在与客户端的交互中才能体现。
哨兵机制基本流程
sentinel(哨兵机制)其实就是一个运行在特殊模式下的Redis服务器。
在服务器初始化时,普通Redis服务器初始化时会通过载入RDB文件或者AOF文件来恢复数据库状态,而sentinel服务器由于不使用数据库,所以它在初始化时无需载入RDB文件或者AOF文件。
哨兵进程运行时,它会周期性地心跳检测,检测所有主从服务器是否正常运行。心跳检测方式为周期性向主从服务器发送PING命令,若主从服务器在规定时间内响应哨兵进程,则判断该服务器处于存活状态;若主从服务器在规定时间内没有响应哨兵进程,则哨兵进程会判定其下线,这时哨兵进程会进行故障转移,也就是重新选主。选主就是会从其所属的多个从服务器中选举一个服务器作为新的主服务器,来提供服务。选举成功后,哨兵进程让已下线主服务器属下的所有从服务器去复制新的主服务器。
多个哨兵进行通信
在哨兵集群下,哨兵实例进行通信,是基于Redis提供的pub/sub机制的,也就是发布/订阅模式。
在主从集群中,哨兵节点不会直接与其他哨兵节点建立连接,而是首先会和主库建立起连接,然后向一个名为"_sentinel_:hello"频道发送自己的信息(IP+port),其他订阅了该频道的哨兵节点就会获取到该哨兵节点信息,从而哨兵节点之间互知。
通俗讲,Redis哨兵模式中,哨兵节点的互通是通过订阅指定的频道来进行的,而不是直接与其他sentinel节点建立起连接
主观下线和客观下线
- 主观下线:任何一个哨兵都是可以监控探测,并作出Redis节点下线的判断;
- 客观下线:由哨兵集群共同决定Redis节点是否下线;
哨兵进程会使用PING命令的方式来检测各个主库和从库的网络连接情况,用来判断实例状态。如果哨兵发现主库或者从库响应超时,那么哨兵会判定其为"主观下线"。
如果哨兵检测从库,发现从库在规定时间内未响应,那么哨兵就会把它标记为"主观下线",因为从库的下线影响一般不太大,集群的对外服务不会间断。但是,如果检测主库,哨兵不会简单把它标记为"主观下线",开启主从切换。
因为很有可能会有一种特殊情况:哨兵误判。也就是说主库本身没有故障,但由于哨兵的误判,判断它为下线状态。一旦启动主从切换,后续的选举和通知操作都会带来额外的计算和通信开销。因此,为了不必要开销,我们要严格注意误判的情况。
在哨兵集群中,判定主库是否处于下线状态,不是由一个哨兵来决定的,而是只有大多数哨兵认为主库已经"主观下线",主库才会标记为"客观下线"。这种判断机制为:少数服从多数。同时会触发主从切换模式。
简单的来说,"客观下线"的标准为,当有N个实例,最好要有N/2+1个哨兵实例认为其"主观下线",那么主库才是"客观下线"。这样的好处减少了误判的概率,避免了不必要的开销
哨兵集群的选举
基于pub/sub机制的客户端事件通知
从本质上说,哨兵就是一个运行在特定模式的Redis,只不过它并不服务于请求操作,只是完成监控、故障转移、通知的任务。每个哨兵提供pub/sub机制,客户端可以从哨兵订阅消息。
客户端可以从哨兵订阅所有事件,这样客户端不仅可以在主从切换后得到新主库的连接信息,还可以监控主从库切换过程中发生的各个重要事件。
有了pub/sub机制,哨兵和哨兵之间、哨兵与从库之间、哨兵与客户端之间就能连接起来了,再加上上述将的主库判断依据和选举依据,哨兵集群的监控、选举、通知三个任务就可以正常运行了。