作为一个后端开发人员,不只是要求开发人员需要掌握 Redis,也要求运维人员也要懂 Redis。由于 Redis 的运用广泛,我们也知道它的重要性,至此面试中经常被问到。在这 80道 Redis 面试题中,考察知识点包括基础、数据结构指令、高并发处理、持久化、集群、复制、Redis 应用等。如果你最近在准备面试或者在学习 Redis,我相信本次小编可以满足你的面试需要。也希望我花了半个月整理的这些题目,解答对你有所帮助。
1、什么是 Redis?
Redis 是一个使用 C 语言写成的,开源的高性能key-value非关系缓存数据库。它支持存储的value 类型相对更多,包括string(字符串)、list(链表)、set(集合)、zset(sorted set --有序集合)和 hash(哈希类型)。Redis的数据都基于缓存的,所以很快,每秒可以处理超过 10万次读写操作,是已知性能最快的Key-Value DB。Redis也可以实现数据写入磁盘中,保证了数据的安全不丢 失,而且Redis的操作是原子性的。
2、Redis 的数据类型?
Redis主要有5种数据类型,包括String,List,Set,Zset,Hash,满足大部分的使用要求
3、使用 Redis 有哪些好处?
(1) 速度快,因为数据存在内存中,类似于HashMap,HashMap的优势就是查找和操作的时间复
杂度都很低
(2)支持丰富数据类型,支持string,list,set,sorted set,hash
(3) 支持事务,操作都是原子性,所谓的原子性就是对数据的更改要么全部执行,要么全部不执行
(4) 丰富的特性:可用于缓存,消息,按key设置过期时间,过期后将会自动删除
4、Redis 相比 Memcached 有哪些优势?
1、Memcached 所有的值均是简单的字符串, redis 作为其替代者, 支持更为丰富的数据类
2、Redis 的速度比 Memcached 快很
3、Redis 可以持久化其数据
5、Memcache 与 Redis 的区别都有哪些?
1、存储方式 Memecache 把数据全部存在内存之中, 断电后会挂掉, 数据不能超过内存大小。 Redis有部份存在硬盘上, 这样能保证数据的持久性
2、数据支持类型 Memcache 对数据类型支持相对简单。 Redis 有复杂的数据类型。
3、使用底层模型不同 它们之间底层实现方式 以及与客户端之间通信的应用协议不一样。 Redis 直接自己构建了 VM 机制 ,因为一般的系统调用系统函数的话, 会浪费一定的时间去移动和请求。
6、Redis 是单进程单线程的?
答: Redis 是单进程单线程的, redis 利用队列技术将并发访问变为串行访问, 消除了传统数据库串行控制的开销。
7、一个字符串类型的值能存储最大容量是多少?
答: 512M
8、Redis 的持久化机制是什么?各自的优缺点?
Redis 提供两种持久化机制 RDB(默认) 和 AOF 机制:
RDB:是Redis DataBase缩写快照
- RDB是Redis默认的持久化方式。按照一定的时间将内存的数据以快照的形式保存到硬盘中,对应产生的数据文件为dump.rdb。通过配置文件中的save参数来定义快照的周期。
优点:
- 只有一个文件 dump.rdb,方便持久化。
- 容灾性好,一个文件可以保存到安全的磁盘。
- 性能最大化,fork 子进程来完成写操作,让主进程继续处理命令,所以是 IO 最大化。使用单 独子进程来进行持久化,主进程不会进行任何
IO 操作,保证了 redis 的高性能 - 相对于数据集大时,比 AOF 的启动效率更高。
缺点:
- 数据安全性低。RDB 是间隔一段时间进行持久化,如果持久化之间 redis 发生故障,会发生数
据丢失。所以这种方式更适合数据要求不严谨的时候) - AOF(Append-only fifile)持久化方式: 是指所有的命令行记录以 redis 命令请 求协议的格式
完全持久化存储)保存为 aof 文件。
AOF:持久化:
AOF持久化(即Append Only File持久化),则是将Redis执行的每次写命令记录到单独的日志文件
中,当重启Redis会重新将持久化的日志中文件恢复数据。当两种方式同时开启时,数据恢复Redis会优先选择AOF恢复
优点:
- 数据安全,aof 持久化可以配置 appendfsync 属性,有 always,每进行一次 命令操作就记录 到 aof 文件中一次。
- 通过 append 模式写文件,即使中途服务器宕机,可以通过 redis-check-aof 工具解决数据一 致性问题。
- AOF 机制的 rewrite 模式。AOF 文件没被 rewrite 之前(文件过大时会对命令
进行合并重写),可以删除其中的某些命令(比如误操作的 flflushall))
缺点:
-
AOF 文件比 RDB 文件大,且恢复速度慢。
-
数据集大的时候,比 rdb 启动效率低。
俩种持久化的优缺点是什么?
- AOF文件比RDB更新频率高,优先使用AOF还原数据。
- AOF比RDB更安全也更大
- RDB性能比AOF好
- 如果两个都配了优先加载AOF
9、Redis 常见性能问题和解决方案:
10、Redis 过期键的删除策略?
11、Redis 的回收策略(淘汰策略)?
12、为什么 Redis 需要把所有数据放到内存中?
答:Redis 为了达到最快的读写速度将数据都读到内存中,并通过异步的方式将数据写入磁盘。所以redis 具有快速和数据持久化的特征。如果数据放在内存中, 磁盘 I/O 速度为严重影响 redis 的性能。在内存越来越便宜的今天, redis 将会越来越受欢迎。如果设置了最大使用的内存, 则数据已有记录数达到内存限值后不能继续插入新值。
13、Redis 的同步机制了解么?
答:Redis 可以使用主从同步,从从同步。第一次同步时,主节点做一次 bgsave, 并同时将后续修改操作记录到内存 buffffer, 待完成后将 rdb 文件全量同步到复制节点, 复制节点接受完成后将 rdb 镜像加载到内存。加载完成后, 再通知主节点将期间修改的操作记录同步到复制节点进行重放就完成了同步过程。
14、Pipeline 有什么好处,为什么要用 pipeline?
答:可以将多次 IO 往返的时间缩减为一次,前提是 pipeline 执行的指令之间没有因果相关性。使用redis-benchmark 进行压测的时候可以发现影响 redis 的 QPS 峰值的一个重要因素是 pipeline 批次指令的数目。
15、是否使用过 Redis 集群,集群的原理是什么?
1、Redis Sentinal 着眼于高可用, 在 master 宕机时会自动将 slave 提升为master, 继续提供服务。
2、Redis Cluster 着眼于扩展性, 在单个 redis 内存不足时, 使用 Cluster 进行分片存储。
16、Redis 集群方案什么情况下会导致整个集群不可用?
答: 有 A, B, C 三个节点的集群,在没有复制模型的情况下,如果节点 B 失败了, 那么整个集群就会以为缺少 5501-11000 这个范围的槽而不可用。
17、Redis 支持的 Java 客户端都有哪些?官方推荐用哪个?
答: Redisson、Jedis、lettuce 等等, 官方推荐使用 Redisson。
18、Jedis 与 Redisson 对比有什么优缺点?
答: Jedis 是 Redis 的 Java 实现的客户端, 其 API 提供了比较全面的 Redis 命令的支持;Redisson 实现了分布式和可扩展的 Java 数据结构,和 Jedis 相比,功能较为简单, 不支持字符串操作, 不支持排序、事务、管道、分区等 Redis 特性。Redisson 的宗旨是促进使用者对 Redis 的关注分离,从而让使用者能够将精力更集中地放在处理业务逻辑上。
19、Redis 如何设置密码及验证密码?
设置密码: confifig set requirepass 123456 授权密码: auth 123456
20、说说 Redis 哈希槽的概念?
答: Redis 集群没有使用一致性 hash,而是引入了哈希槽的概念, Redis 集群有16384 个哈希槽,每个key 通过 CRC16 校验后对 16384 取模来决定放置哪个槽, 集群的每个节点负责一部分 hash 槽。
21、Redis 集群的主从复制模型是怎样的?
答: 为了使在部分节点失败或者大部分节点无法通信的情况下集群仍然可用, 所以集群使用了主从复制模型,每个节点都会有 N-1 个复制品.
22、Redis 集群会有写操作丢失吗?为什么?
答:Redis 并不能保证数据的强一致性,这意味这在实际中集群在特定的条件下可能会丢失写操作。
23、Redis 集群之间是如何复制的?
答: 异步复制
24、Redis 集群最大节点个数是多少?
答: 16384 个。
25、Redis 集群如何选择数据库?
答: Redis 集群目前无法做数据库选择, 默认在 0 数据库。
26、怎么测试 Redis 的连通性?
答: 使用 ping 命令。
27、怎么理解 Redis 事务?
答:
1) 事务是一个单独的隔离操作: 事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中, 不会被其他客户端发送来的命令请求所打断。
2) 事务是一个原子操作: 事务中的命令要么全部被执行, 要么全部都不执行。
28、Redis 事务相关的命令有哪几个?
答: MULTI、EXEC、DISCARD、WATCH
29、Redis key 的过期时间和永久有效分别怎么设置?
答: EXPIRE 和 PERSIST 命令。
30、Redis 如何做内存优化?
- 1、缩减键值对象
缩减键(key)和值(value)的长度,
key长度:如在设计键时,在完整描述业务情况下,键值越短越好。
value长度:值对象缩减比较复杂,常见需求是把业务对象序列化成二进制数组放入Redis。首先应该在业务上精简业务对象,去掉不必要的属性避免存储无效数据。其次在序列化工具选择上,应该选择更高效的序列化工具来降低字节数组大小。以JAVA为例,内置的序列化方式无论从速度还是压缩比都不尽如人意,这时可以选择更高效的序列化工具,如: protostuff,kryo等,下图是JAVA常见序列化工具空间压缩对比。
- 2、共享对象池
对象共享池指Redis内部维护[0-9999]的整数对象池。创建大量的整数类型redisObject存在内存开销,每个redisObject内部结构至少占16字节,甚至超过了整数自身空间消耗。所以Redis内存维护一个[0-9999]的整数对象池,用于节约内存。 除了整数值对象,其他类型如list,hash,set,zset内部元素也可以使用整数对象池。因此开发中在满足需求的前提下,尽量使用整数对象以节省内存。
-
3、字符串优化
-
4、编码优化
-
5、控制key的数量
参考链接
- https://cachecloud.github.io/2017/02/16/Redis%E5%86%85%E5%AD%98%E4%BC%98%E5%8C%96/?utm_source=tuicool&utm_medium=referral
31、Redis 回收进程如何工作的?
一个客户端运行了新的命令,添加了新的数据。Redi 检查内存使用情况, 如
果大于 maxmemory 的限制, 则根据设定好的策略进行回收。一个新的命令被 执
行,等等。所以我们不断地穿越内存限制的边界,通过不断达到边界然后不 断
地回收回到边界以下。如果一个命令的结果导致大量内存被使用(例如很大 的
集合的交集保存到一个新的键),不用多久内存限制就会被这个内存使用量超
越
32、都有哪些办法可以降低 Redis 的内存使用情况呢?
如果使用的是32位的Redis实例,可以好好利用Hash,list,sorted set,set等集合类型数据,因为通常情况下很多小的key- value可以用更紧凑的方式存放
1. 降低redis内存占用的优点
-
有助于减少创建快照和加载快照所用的时间
-
提升载入AOF文件和重写AOF文件时的效率
-
缩短从服务器进行同步所需的时间
-
无需添加额外的硬件就可以让redis存贮更多的数据
2、短结构
Redis为列表、集合、散列、有序集合提供了一组配置选项,这些选项可以让redis以更节约的方式存储较短的结构。
2.1 ziplist压缩列表(列表、散列、有续集和)
通常情况下使用的存储方式
-
当列表、散列、有序集合的长度较短或者体积较小的时候,redis将会采用一种名为ziplist的紧凑存储方式来存储这些结构。
-
ziplist是列表、散列、有序集合这三种不同类型的对象的一种非结构化表示,它会以序列化的方式存储数据,这些序列化的数据每次被读取的时候都需要进行解码,每次写入的时候也要进行编码。
双向列表与压缩列表的区别:
为了了解压缩列表比其他数据结构更加节约内存,我们以列表结构为例进行深入研究。
典型的双向列表
1、在典型双向列表里面,每个值都都会有一个节点表示。每个节点都会带有指向链表前一个节点和后一个节点的指针,以及一个指向节点包含的字符串值的指针。
2、每个节点包含的字符串值都会分为三部分进行存储。包括字符串长度、字符串值中剩余可用字节数量、以空字符结尾的字符串本身。
例子:
假若一个某个节点存储了’abc’字符串,在32位的平台下保守估计需要21个字节的额外开销(三个指针+两个int+空字符即:34+24+1=21)
由例子可知存储一个3字节字符串就需要付出至少21个字节的额外开销。
ziplist
压缩列表是由节点组成的序列,每个节点包含两个长度和一个字符串。第一个长度记录前一个节点的长度(用于对压缩列表从后向前遍历);第二个长度是记录本当前点的长度;被存储的字符串。
例子:
存储字符串’abc’,两个长度都可以用1字节来存储,因此所带来的额外开销为2字节(两个长度即1+1=2)
结论:
压缩列表是通过避免存储额外的指针和元数据,从而达到降低额外的开销。
配置:
#list2 list-max-ziplist-entries 51
#表示允许包含的最大元素数量
list-max-ziplist-value 64 #表示压缩节点允许存储的最大体积
#hash #当超过任一限制后,将不会使用ziplist方式进行存储
hash-max-ziplist-entries 512
hash-max-ziplist-value 64
#zset
zset-max-ziplist-entries 128
zset-max-ziplist-value 64
测试list:
1、建立test.php文件
#test.php
<?php
$redis=new Redis();
$redis->connect('192.168.95.11','6379');
for ($i=0; $i<512 ; $i++)
{
$redis->lpush('test-list',$i.'-test-list'); #往test-list推入512条数据
}
?>
此时的test-list中含有512条数据,没有超除配置文件中的限制
2、往test-list中再推入一条数据
此时test-list含有513条数据,大于配置文件中限制的512条,索引将放弃ziplist存储方式,采用其原来的linkedlist存储方式,散列与有序集合同理。
2.2、intset整数集合(集合)
前提条件,集合中包含的所有member都可以被解析为十进制整数。
以有序数组的方式存储集合不仅可以降低内存消耗,还可以提升集合操作的执行速度。
配置:
set-max-intset-entries 512 #限制集合中member个数,超出则不采取i那个tset存储
测试:
建立test.php文件
#test.php
<?php
$redis=new Redis();
$redis->connect('192.168.95.11','6379');
for ($i=0; $i<512 ; $i++)
{
$redis->sadd('test-set',$i); #给集合test-set插入512个member
}
?>
2.3、性能问题
不管列表、散列、有序集合、集合,当超出限制的条件后,就会转换为更为典型的底层结构类型。因为随着紧凑结构的体积不断变大,操作这些结构的速度将会变得越来越慢。
测试:
将采用list进行代表性测试
测试思路:
-
1、在默认配置下往test-list推入50000条数据,查看所需时间;接着在使用rpoplpush将test-list数据全部推入到新列表list-new中,查看所需时间
-
2、修改配置,list-max-ziplist-entries 100000,再执行上面的同样操作
-
3、对比时间,得出结论
默认配置下测试:
1、插入数据,查看时间
#test1.php
<?php
header("content-type: text/html;charset=utf8;");
$redis=new Redis();
$redis->connect('192.168.95.11','6379');
$start=time();
for ($i=0; $i<50000 ; $i++)
{
$redis->lpush('test-list',$i.'-aaaassssssddddddkkk');
}
$end=time();
echo "插入耗时为:".($end-$start).'s';
?>
结果耗时4秒
2、执行相应命令,查看耗时
#test2.php
<?php
header("content-type: text/html;charset=utf8;");
$redis=new Redis();
$redis->connect('192.168.95.11','6379');
$start=time();
$num=0;
while($redis->rpoplpush('test-list','test-new'))
{
$num+=1;
}
echo '执行次数为:'.$num."<br/>";
$end=time();
echo "耗时为:".($end-$start).'s';
?>
更改配置文件下测试
1、先修改配置文件
list-max-ziplist-entries 100000 #将这个值修改大一点,可以更好的凸显对性能的影响
list-max-ziplist-value 64 #此值可不做修改
2、插入数据
执行test1.php
结果为:耗时12s
3、执行相应命令,查看耗时
执行test2.php
结果为:执行次数:50000,耗时12s
结论:
在本机中执行测试50000条数据就相差8s,若在高并发下,长压缩列表和大整数集合将起不到任何的优化,反而使得性能降低。
3、片结构
分片的本质就是基于简单的规则将数据划分为更小的部分,然后根据数据所属的部分来决定将数据发送到哪个位置上。很多数据库使用这种技术来扩展存储空间,并提高自己所能处理的负载量。
结合前面讲到的,我们不难发现分片结构对于redis的重要意义。因此我们需要在配置文件中关于ziplist以及intset的相关配置做出适当的调整。
3.1、分片式散列
#ShardHash.class.php
散列分片主要是根据基础键以及散列包含的键计算出分片键ID,然后再与基础键拼接成一个完整的分片键。在执行hset与hget以及大部分hash命令时,都需要先将key(field)通过shardKey方法处理,得到分片键才能够进行下一步操作。
回到顶部
3.2、分片式集合
如何构造分片式集合才能够让它更节省内存,性能更加强大呢?主要的思路就是,将集合里面的存储的数据尽量在不改变其原有功能的情况下转换成可以被解析为十进制的数据。根据前面所讲到的,当集合中的所有成员都能够被解析为十进制数据时,将会采用intset存储方式,这不仅能够节省内存,而且还可以提高响应的性能。
4、将信息打包转换成存储字节
结合前面所讲的分片技术,采用string分片结构为大量连续的ID用户存储信息。
使用定长字符串,为每一个ID分配n个字节进行存储相应的信息。
33、Redis 的内存用完了会发生什么?
内存回收
使用 Redis 服务时,很多情况下某些键值对只会在特定的时间内有效,为了防止这种类型的数据一直占有内存,我们可以给键值对设置有效期。
Redis 中可以通过 4 个独立的命令来给一个键设置过期时间。
- expire key ttl:将 key 值的过期时间设置为 ttl 秒
- pexpire key ttl:将 key 值的过期时间设置为 ttl 毫秒
- expireat key timestamp:将 key 值的过期时间设置为指定的 timestamp 秒数
- pexpireat key timestamp:将 key 值的过期时间设置为指定的 timestamp 毫秒数
不管是使用哪一个命令,最终 Redis 底层都是使用 pexpireat 命令来实现的。另外,set 等命令也可以在设置 key 的同时加上过期时间,这样可以保证设值和设过期时间的原子性。
设置了有效期后,可以通过 ttl 和 pttl 两个命令来查询剩余的过期时间(如果未设置过期时间则下面两个命令都返回 -1,如果设置了一个非法的过期时间,则都返回 -2)。
- ttl key:返回 key 剩余的过期秒数
- pttl key:返回 key 剩余的过期毫秒数
过期策略
将一个过期的键删除,我们一般都会有三种策略。
- 定时删除:为每个键都设置一个定时器,一旦过期时间到了,则将键删除。这种策略对内存很友好,但是对 CPU 不友好,因为每个定时器都会占用一定的 CPU 资源
- 惰性删除:不管键有没有过期,都不主动删除,等到每次去获取键时再判断是否过期,如果过期就删除该键,否则返回键对应的值。这种策略对内存不够友好,可能会浪费很多内存
- 定期扫描:系统每隔一段时间就定期扫描一次,发现过期的键就进行删除。这种策略是上面两种策略的折中方案,需要注意的是这个定期的频率要结合实际情况掌控好,使用这种方案有一个缺陷就是可能会出现已经过期的键也被返回
Redis 选择的是策略 2 和策略 3 的综合使用。不过 Redis 的定期扫描只会扫描设置了过期时间的键,因为设置了过期时间的键在 Redis 中会单独存储,所以不会出现扫描所有键的情况。
typedef struct redisDb {
dict *dict; // 所有的键值对
dict *expires; // 设置了过期时间的键值对
dict *blocking_keys; // 被阻塞的key,如客户端执行BLPOP等阻塞指令时
dict *watched_keys; // WATCHED keys
int id; // Database ID
// 其他属性...
} redisDb;
8 种淘汰策略
假如 Redis 中所有的键都没有过期,并且此时内存满了,那么客户端继续执行 set 等命令时 Redis 会怎么处理呢?
Redis 提供了不同的淘汰策略来处理这种场景。
首先 Redis 提供了一个参数 maxmemory 来配置 Redis 的最大使用内存:maxmemory 。
或者也可以通过命令 config set maxmemory 1GB 来动态修改。
如果没有设置该参数,那么在 32 位的操作系统中 Redis 最多使用 3GB 内存,而在 64 位的操作系统中则不作限制。
Redis 中提供了 8 种淘汰策略,可以通过参数 maxmemory-policy 进行配置。
淘汰策略也可以直接使用命令 config set maxmemory-policy <策略> 来进行动态配置。
LRU 算法
LRU 全称为 Least Recently Used,即最近最长时间未被使用,主要针对的是使用时间。
Redis 改进后的 LRU 算法
Redis 并没有采用传统的 LRU 算法,因为传统的 LRU 算法存在 2 个问题。
- 需要额外的存储空间
- 可能存在某些 key 值使用很频繁,但是最近没被使用,从而被 LRU 算法删除
为了避免以上 2 个问题,Redis 对传统的 LRU 算法进行了改造,通过抽样的方式进行删除。
配置文件中提供了一个属性 maxmemory_samples 5,默认值是 5,表示随机抽取 5 个 key 值,然后对这 5 个 key 值按照 LRU 算法进行删除。很明显,key 值越大,删除的准确度就越高。
对于抽样 LRU 算法和传统的 LRU 算法,Redis 官网有一个对比图。
- 浅灰色带是被删除的对象
- 灰色带是未被删除的对象
- 绿色带是添加的对象
左上角第一幅图代表的是传统的 LRU 算法,可以看到,当抽样数达到 10 个(右上角),已经和传统的 LRU 算法非常接近了。
Redis 如何管理热度数据
redisObject 对象中存在一个 lru 属性。
typedef struct redisObject {
unsigned type:4; // 对象类型(4位 = 0.5字节)
unsigned encoding:4; // 编码(4位 = 0.5字节)
unsigned lru:LRU_BITS; // 记录对象最后一次被应用程序访问的时间(24位 = 3字节)
int refcount; // 引用计数,等于0时表示可以被垃圾回收(32位 = 4字节)
void *ptr; // 指向底层实际的数据存储结构,如: SDS等(8字节)
} robj;
lru 属性是在创建对象的时候写入的,对象被访问到时也会进行更新。
正常的思路是,最后决定要不要删除某一个键肯定是用当前时间戳减去 lru,差值最大的就优先被删除。
但是 Redis 并不是这样做的,Redis 中维护了一个全局属性 lru_clock,这个属性是通过一个全局函数 serverCron 每隔 100 毫秒执行一次来进行更新的,记录的是当前的 unix 时间戳。最后决定删除的数据是通过 lru_clock 减去对象的 lru 属性得出的。
为什么 Redis 要这样做呢?直接取全局时间不是更准确吗?
因为这样做可以在每次更新对象的 lru 属性的时候直接取全局属性,而不需要去调用系统函数来获取系统时间,从而提升效率(Redis 中有很多这种细节考虑来提升性能,可以说是对性能尽可能地优化到极致)。
不过这里还有一个问题,redisObject 对象中的 lru 属性只有 24 位,24 位只能存储 194 天的时间戳大小,一旦超过了 194 天,就会重新从 0 开始计算,所以这时候就可能会出现 redisObject 对象中的 lru 属性大于全局的 lru_clock 属性的情况。
正因为如此,所以计算的时候也需要分为 2 种情况。
- 当全局 lruclock > lru 时,则使用 lruclock - lru 得到空闲时间
- 当全局 lruclock < lru时,则使用 lruclock_max(即 194 天) - lru + lruclock 得到空闲时间
需要注意的是,这种计算方式并不能保证抽样的数据中一定能删除空闲时间最长的。首先因为超过 194 天还不被使用的情况很少,再者只有 lruclock 第 2 轮继续超过 lru 属性时,计算才会出问题。
比如对象 A 记录的 lru 是 1 天,而 lruclock 第二轮都到 10 天了,这时候就会导致计算结果只有 10 - 1 = 9 天,而实际上应该是 194 + 10 - 1 = 203 天。
但是这种情况更少发生,这种处理方式是可能存在删除不准确的情况,但是本身这种算法就是一种近似的算法,所以并不会有太大影响。
LFU 算法
LFU 全称为 Least Frequently Used,即最近最少频率使用,主要针对的是使用频率。这个属性也是记录在 redisObject 中的 lru 属性内。
当我们采用 LFU 回收策略时,lru 属性的高 16 位用来记录访问时间(last decrement time:ldt,单位为分钟),低 8 位用来记录访问频率(logistic counter:logc),简称 counter。
访问频次递增
LFU 计数器每个键只有 8 位,它能表示的最大值是 255,所以 Redis 使用的是一种基于概率的对数器来实现 counter 的递增。
给定一个旧的访问频次,当一个键被访问时,counter 按以下方式递增。
- 提取 0 和 1 之间的随机数 R
- counter - 初始值(默认为 5),得到一个基础差值,如果这个差值小于 0,则直接取 0(为了方便计算,把这个差值记为
baseval) - 概率 P 计算公式为:1 / (baseval * lfu_log_factor + 1)
- 如果 R < P 时,频次进行递增(counter++)
公式中的 lfu_log_factor 称之为对数因子,默认是 10 ,可以通过参数来进行控制:lfu_log_factor 10。
下图是对数因子 lfu_log_factor 和频次 counter 增长的关系图。
可以看到,当对数因子 lfu_log_factor 为 100 时,大概是 10M(1000万) 次访问才会将访问 counter 增长到 255,而默认的 10 也能支持到 1M(100万) 次访问 counter 才能达到 255 上限,这在大部分场景都是足够满足需求的。
访问频次递减
如果访问频次 counter 只是一直在递增,那么迟早会到 255,也就是说 counter 一直递增不能完全反应一个 key 的热度,所以当某一个 key 在一段时间内不被访问,counter 也需要相应地减少。
counter 的减少速度由参数 lfu-decay-time 进行控制(默认是 1,单位是分钟):lfu-decay-time 1。
数值 N 表示:N 分钟内没有被访问,counter 就要减 N。
- 获取当前时间戳,转换为分钟后取低 16 位(为了方便后续计算,这个值记为 now)
- 取出对象内的 lru 属性中的高 16 位(为了方便后续计算,这个值记为 ldt)
- 当 lru > now 时,默认为过了一个周期(16 位,最大 65535),则取差值 65535 - ldt + now;当 lru
<= now 时,取差值 now - ldt(为了方便后续计算,这个差值记为 idle_time) - 取出配置文件中的 lfu_decay_time 值,然后计算:idle_time /
lfu_decay_time(为了方便后续计算,这个值记为num_periods) - 最后将 counter 减少:counter - num_periods
简单来说,取出当前的时间戳和对象中的 lru 属性进行对比,计算出当前多久没有被访问到,比如计算得到的结果是 100 分钟没有被访问,然后再除以配置参数 lfu_decay_time,如果这个配置默认为 1,则是 100 / 1 = 100,代表 100 分钟没访问,所以 counter 就减少 100。
34、一个 Redis 实例最多能存放多少的 keys?List、Set、Sorted Set 他们最多能存放多少元素?
理论上 Redis 可以处理多达 232 的 keys,并且在实际中进行了测试,每个实
例至少存放了 2 亿 5 千万的 keys。我们正在测试一些较大的值。任何 list、set、
和 sorted set 都可以放 232 个元素。换句话说,Redis 的存储极限是系统中的可
用内存值。
35、MySQL 里有 2000w 数据,redis 中只存 20w 的数据,如何保证 redis 中的数据都是热点数据?
这个问题可以从内存淘汰策略,Redis内存大小,设置key过期时间出发
为每一个key设置过期时间,设置redis占用内存大小(一般为百分之50%),一旦达到maxmemory阀值,触发Redis内存淘汰策略
Redis内存淘汰策略
默认8种:
(1)volatile-lru:从已设置过期时间的数据集中挑选最近最少使用的数据淘汰。
(2)volatile-ttl:从已设置过期时间的数据集中挑选将要过期的数据淘汰。
(3)volatile-random:从已设置过期时间的数据集中任意选择数据淘汰。
(4)volatile-lfu:从已设置过期时间的数据集挑选使用频率最低的数据淘汰。
(5)allkeys-lru:从数据集中挑选最近最少使用的数据淘汰
(6)allkeys-lfu:从数据集中挑选使用频率最低的数据淘汰。
(7)allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰
(8) no-enviction(驱逐):禁止驱逐数据,这也是默认策略。意思是当内存不足以容纳新入数据时,新写入操作就会报错。
建议使用allkeys-lru 淘汰策略
redis内存大小
默认没有配置
36、Redis 最适合的场景?
答: 缓存、数据共享分布式、分布式锁、全局ID、计数器、限流、位统计、时间轴、消息队列、抽奖、点赞、签到、打卡、商品标签、好友关系、用户关注、推荐模型、排行榜、倒排索引、显示最新的项目列表等
1.缓存
作为Key-Value形态的内存数据库,Redis 最先会被想到的应用场景便是作为数据缓存。而使用 Redis 缓存数据非常简单,只需要通过string类型将序列化后的对象存起来即可,不过也有一些需要注意的地方:
必须保证不同对象的 key 不会重复,并且使 key 尽量短,一般使用类名(表名)加主键拼接而成。
选择一个优秀的序列化方式也很重要,目的是提高序列化的效率和减少内存占用。
缓存内容与数据库的一致性,这里一般有两种做法:
只在数据库查询后将对象放入缓存,如果对象发生了修改或删除操作,直接清除对应缓存(或设为过期)。
在数据库新增和查询后将对象放入缓存,修改后更新缓存,删除后清除对应缓存(或设为过期)。
2. 数据共享分布式
String 类型,因为 Redis 是分布式的独立服务,可以在多个应用之间共享
例如:分布式Session
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
3、分布式锁
如今都是分布式的环境下java自带的单体锁已经不适用的。在 Redis 2.6.12 版本开始,string的set命令增加了一些参数:
-
EX:设置键的过期时间(单位为秒)
-
PX:设置键的过期时间(单位为毫秒)
-
NX :只在键不存在时,才对键进行设置操作。 SET key value NX 效果等同于 SETNX key value 。
-
XX :只在键已经存在时,才对键进行设置操作。
由于这个操作是原子性的,可以简单地以此实现一个分布式的锁,例如:
set lock_key locked NX EX 1
如果这个操作返回false,说明 key 的添加不成功,也就是当前有人在占用这把锁。而如果返回true,则说明得了锁,便可以继续进行操作,并且在操作后通过del命令释放掉锁。并且即使程序因为某些原因并没有释放锁,由于设置了过期时间,该锁也会在 1 秒后自动释放,不会影响到其他程序的运行。
推荐使用 redisson 第三方库实现分布式锁。
参考 java分布式锁终极解决方案之 redisson
4、全局ID
int类型,incrby,利用原子性
incrby userid 1000
分库分表的场景,一次性拿一段
5、计数器
int类型,incr方法
例如:文章的阅读量、微博点赞数、允许一定的延迟,先写入Redis再定时同步到数据库
计数功能应该是最适合 Redis 的使用场景之一了,因为它高频率读写的特征可以完全发挥 Redis 作为内存数据库的高效。在 Redis 的数据结构中,string、hash和sorted set都提供了incr方法用于原子性的自增操作,下面举例说明一下它们各自的使用场景:
如果应用需要显示每天的注册用户数,便可以使用string作为计数器,设定一个名为REGISTERED_COUNT_TODAY的 key,并在初始化时给它设置一个到凌晨 0 点的过期时间,每当用户注册成功后便使用incr命令使该 key 增长 1,同时当每天凌晨 0 点后,这个计数器都会因为 key 过期使值清零。
每条微博都有点赞数、评论数、转发数和浏览数四条属性,这时用hash进行计数会更好,将该计数器的 key 设为weibo:weibo_id,hash的 field 为like_number、comment_number、forward_number和view_number,在对应操作后通过hincrby使hash 中的 field 自增。
如果应用有一个发帖排行榜的功能,便选择sorted set吧,将集合的 key 设为POST_RANK。当用户发帖后,使用zincrby将该用户 id 的 score 增长 1。sorted set会重新进行排序,用户所在排行榜的位置也就会得到实时的更新。
6、限流
int类型,incr方法
以访问者的ip和其他信息作为key,访问一次增加一次计数,超过次数则返回false
7、位统计
String类型的bitcount(1.6.6的bitmap数据结构介绍)
字符是以8位二进制存储的
set k1 a
setbit k1 6 1
setbit k1 7 0
get k1
/* 6 7 代表的a的二进制位的修改
a 对应的ASCII码是97,转换为二进制数据是01100001
b 对应的ASCII码是98,转换为二进制数据是01100010
因为bit非常节省空间(1 MB=8388608 bit),可以用来做大数据量的统计。
参考 使用Redis的bitmaps统计用户留存率、活跃用户
用户日活月活怎么统计 - Redis HyperLogLog 详解
8. 时间轴(Timeline)
list作为双向链表,不光可以作为队列使用。如果将它用作栈便可以成为一个公用的时间轴。当用户发完微博后,都通过lpush将它存放在一个 key 为LATEST_WEIBO的list中,之后便可以通过lrange取出当前最新的微博。
9. 消息队列
Redis 中list的数据结构实现是双向链表,所以可以非常便捷的应用于消息队列(生产者 / 消费者模型)。消息的生产者只需要通过lpush将消息放入 list,消费者便可以通过rpop取出该消息,并且可以保证消息的有序性。如果需要实现带有优先级的消息队列也可以选择sorted set。而pub/sub功能也可以用作发布者 / 订阅者模型的消息。无论使用何种方式,由于 Redis 拥有持久化功能,也不需要担心由于服务器故障导致消息丢失的情况。
List提供了两个阻塞的弹出操作:blpop/brpop,可以设置超时时间
- blpop:blpop key1 timeout 移除并获取列表的第一个元素,如果列表没有元素会阻塞列表直到等待超时或发现可弹出元素为止。
- brpop:brpop key1 timeout 移除并获取列表的最后一个元素,如果列表没有元素会阻塞列表直到等待超时或发现可弹出元素为止。
上面的操作。其实就是java的阻塞队列。学习的东西越多。学习成本越低
- 队列:先进先除:rpush blpop,左头右尾,右边进入队列,左边出队列
- 栈:先进后出:rpush brpop
10、抽奖
利用set结构的无序性,通过 Spop( Redis Spop 命令用于移除集合中的指定 key 的一个或多个随机元素,移除后会返回移除的元素。 ) 随机获得值
redis> SADD myset "one"
(integer) 1
redis> SADD myset "two"
(integer) 1
redis> SADD myset "three"
(integer) 1
redis> SPOP myset
"one"
redis> SMEMBERS myset
1) "three"
2) "two"
redis> SADD myset "four"
(integer) 1
redis> SADD myset "five"
(integer) 1
redis> SPOP myset 3
1) "five"
2) "four"
3) "two"
redis> SMEMBERS myset
1) "three"
redis>
11、点赞、签到、打卡
假如上面的微博ID是t1001,用户ID是u3001
用 like:t1001 来维护 t1001 这条微博的所有点赞用户
- 点赞了这条微博:sadd like:t1001 u3001
- 取消点赞:srem like:t1001 u3001
- 是否点赞:sismember like:t1001 u3001
- 点赞的所有用户:smembers like:t1001
- 点赞数:scard like:t1001
是不是比数据库简单多了。
12 商品标签
老规矩,用 tags:i5001 来维护商品所有的标签。
- sadd tags:i5001 画面清晰细腻
- sadd tags:i5001 真彩清晰显示屏
- sadd tags:i5001 流程至极
13、好友关系、用户关注、推荐模型
这个场景最开始是是一篇介绍微博 Redis 应用的 PPT 中看到的,其中提到微博的 Redis 主要是用在在计数和好友关系两方面上,当时对好友关系方面的用法不太了解,后来看到《Redis 设计与实现》中介绍到作者最开始去使用 Redis 便是希望能通过set解决传统数据库无法快速计算集合中交集这个功能。后来联想到微博当前的业务场景,确实能够以这种方式实现,所以姑且猜测一下:
对于一个用户 A,将它的关注和粉丝的用户 id 都存放在两个 set 中:
-
A:follow:存放 A 所有关注的用户 id
-
A:follower:存放 A 所有粉丝的用户 id
那么通过sinter命令便可以根据A:follow和A:follower的交集得到与 A 互相关注的用户。当 A 进入另一个用户 B 的主页后,A:follow和B:follow的交集便是 A 和 B 的共同专注,A:follow和B:follower的交集便是 A 关注的人也关注了 B。
举例
follow 关注 fans 粉丝
相互关注:
-
sadd 1:follow 2
-
sadd 2:fans 1
-
sadd 1:fans 2
-
sadd 2:follow 1
我关注的人也关注了他(取交集): -
sinter 1:follow 2:fans
可能认识的人:
- 用户1可能认识的人(差集):sdiff 2:follow 1:follow
- 用户2可能认识的人:sdiff 1:follow 2:follow
14 .排行榜
使用sorted set(有序set)和一个计算热度的算法便可以轻松打造一个热度排行榜,zrevrangebyscore可以得到以分数倒序排列的序列,zrank可以得到一个成员在该排行榜的位置(是分数正序排列时的位置,如果要获取倒序排列时的位置需要用zcard-zrank)。
id 为6001 的新闻点击数加1:
zincrby hotNews:20190926 1 n6001
获取今天点击最多的15条:
- zrevrange hotNews:20190926 0 15 withscores
15 .倒排索引
倒排索引是构造搜索功能的最常见方式,在 Redis 中也可以通过set进行建立倒排索引,这里以简单的拼音 + 前缀搜索城市功能举例:
假设一个城市北京,通过拼音词库将北京转为beijing,再通过前缀分词将这两个词分为若干个前缀索引,有:北、北京、b、be…beijin和beijing。将这些索引分别作为set的 key(例如:index:北)并存储北京的 id,倒排索引便建立好了。接下来只需要在搜索时通过关键词取出对应的set并得到其中的 id 即可。
16 .显示最新的项目列表
比如说,我们的一个Web应用想要列出用户贴出的最新20条评论。在最新的评论边上我们有一个“显示全部”的链接,点击后就可以获得更多的评论。
每次新评论发表时,我们会将它的ID添加到一个Redis列表。可以限定列表的长度为5000
LPUSH latest.comments
在Redis中我们的最新ID使用了常驻缓存,这是一直更新的。但是我们做了限制不能超过5000个ID,因此我们的获取ID函数会一直询问Redis。只有在超出了这个范围的时候,才需要去访问数据库。
37、假如 Redis 里面有 1 亿个 key,其中有 10w 个 key 是以某个固定的已知的前缀开头的,如果将它们全部找出来?
答:使用keys指令可以扫出指定模式的key列表。
如果这个redis正在给线上的业务提供服务,那使用keys指令会有什么问题?
这个时候你要回答redis关键的一个特性:redis的单线程的。keys指令会导致线程阻塞一段时间,线上服务会停顿,直到指令执行完毕,服务才能恢复。这个时候可以使用scan指令,scan指令可以无阻塞的提取出指定模式的key列表,但是会有一定的重复概率,在客户端做一次去重就可以了,但是整体所花费的时间会比直接用keys指令长。
38、如果有大量的 key 需要设置同一时间过期,一般需要注意什么?
如果大量的 key 过期时间设置的过于集中,到过期的那个时间点,redis 可能
会出现短暂的卡顿现象。一般需要在时间上加一个随机值,使得过期时间分散一
些。
39、使用过 Redis 做异步队列么,你是怎么用的?
使用List做队列,RPUSH生产消息,LPOP消费消息
-
缺点:没有等待队列里有值就直接去消费
-
弥补:可以通过在应用层引入Sleep机制去调用LPOP重试
BLPOP key [key …] timeout:阻塞直到队列有消息或者超时 -
缺点:只能供一个消费者消费
pub/sub:主题订阅者模式
- 发送者(pub)发送消息。订阅者(sub)接收消息
- 订阅者可以订阅任意数量的频道(topic)
- 消息的发布是无状态的,无法保证可达(可使用专业的消息队列rocketMQ等)
40、使用过 Redis 分布式锁么,它是什么回事?
在并发编程中,我们通过锁,来避免由于竞争而造成的数据不一致问题。通常,我们以synchronized 、Lock来使用它。
但是Java中的锁,只能保证在同一个JVM进程内中执行。如果在分布式集群环境下呢?
一、分布式锁
分布式锁,是一种思想,它的实现方式有很多。比如,我们将沙滩当做分布式锁的组件,那么它看起来应该是这样的:
-
加锁
在沙滩上踩一脚,留下自己的脚印,就对应了加锁操作。其他进程或者线程,看到沙滩上已经有脚印,证明锁已被别人持有,则等待。 -
解锁
把脚印从沙滩上抹去,就是解锁的过程。 -
锁超时
为了避免死锁,我们可以设置一阵风,在单位时间后刮起,将脚印自动抹去。
分布式锁的实现有很多,比如基于数据库、memcached、Redis、系统文件、zookeeper等。它们的核心的理念跟上面的过程大致相同。
二、redis
我们先来看如何通过单节点Redis实现一个简单的分布式锁。
1、加锁
加锁实际上就是在redis中,给Key键设置一个值,为避免死锁,并给定一个过期时间。
SET lock_key random_value NX PX 5000
值得注意的是:
random_value 是客户端生成的唯一的字符串。
NX 代表只在键不存在时,才对键进行设置操作。
PX 5000 设置键的过期时间为5000毫秒。
这样,如果上面的命令执行成功,则证明客户端获取到了锁。
2、解锁
解锁的过程就是将Key键删除。但也不能乱删,不能说客户端1的请求将客户端2的锁给删除掉。这时候random_value的作用就体现出来。
为了保证解锁操作的原子性,我们用LUA脚本完成这一操作。先判断当前锁的字符串是否与传入的值相等,是的话就删除Key,解锁成功。
if redis.call('get',KEYS[1]) == ARGV[1] then
return redis.call('del',KEYS[1])
else
return 0
end
3、实现
首先,我们在pom文件中,引入Jedis。在这里,笔者用的是最新版本,注意由于版本的不同,API可能有所差异。
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>3.0.1</version>
</dependency>
加锁的过程很简单,就是通过SET指令来设置值,成功则返回;否则就循环等待,在timeout时间内仍未获取到锁,则获取失败。
@Service
public class RedisLock {
Logger logger = LoggerFactory.getLogger(this.getClass());
private String lock_key = "redis_lock"; //锁键
protected long internalLockLeaseTime = 30000;//锁过期时间
private long timeout = 999999; //获取锁的超时时间
//SET命令的参数
SetParams params = SetParams.setParams().nx().px(internalLockLeaseTime);
@Autowired
JedisPool jedisPool;
/**
* 加锁
* @param id
* @return
*/
public boolean lock(String id){
Jedis jedis = jedisPool.getResource();
Long start = System.currentTimeMillis();
try{
for(;;){
//SET命令返回OK ,则证明获取锁成功
String lock = jedis.set(lock_key, id, params);
if("OK".equals(lock)){
return true;
}
//否则循环等待,在timeout时间内仍未获取到锁,则获取失败
long l = System.currentTimeMillis() - start;
if (l>=timeout) {
return false;
}
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}finally {
jedis.close();
}
}
}
解锁我们通过jedis.eval来执行一段LUA就可以。将锁的Key键和生成的字符串当做参数传进来。
/**
- 解锁
- @param id
- @return
*/
public boolean unlock(String id){
Jedis jedis = jedisPool.getResource();
String script =
“if redis.call(‘get’,KEYS[1]) == ARGV[1] then” +
" return redis.call(‘del’,KEYS[1]) " +
“else” +
" return 0 " +
“end”;
try {
Object result = jedis.eval(script, Collections.singletonList(lock_key),
Collections.singletonList(id));
if(“1”.equals(result.toString())){
return true;
}
return false;
}finally {
jedis.close();
}
}
最后,我们可以在多线程环境下测试一下。我们开启1000个线程,对count进行累加。调用的时候,关键是唯一字符串的生成。这里,笔者使用的是Snowflake算法。
@Controller
public class IndexController {
@Autowired
RedisLock redisLock;
int count = 0;
@RequestMapping("/index")
@ResponseBody
public String index() throws InterruptedException {
int clientcount =1000;
CountDownLatch countDownLatch = new CountDownLatch(clientcount);
ExecutorService executorService = Executors.newFixedThreadPool(clientcount);
long start = System.currentTimeMillis();
for (int i = 0;i<clientcount;i++){
executorService.execute(() -> {
//通过Snowflake算法获取唯一的ID字符串
String id = IdUtil.getId();
try {
redisLock.lock(id);
count++;
}finally {
redisLock.unlock(id);
}
countDownLatch.countDown();
});
}
countDownLatch.await();
long end = System.currentTimeMillis();
logger.info("执行线程数:{},总耗时:{},count数为:{}",clientcount,end-start,count);
return "Hello";
}
}
至此,单节点Redis的分布式锁的实现就已经完成了。比较简单,但是问题也比较大,最重要的一点是,锁不具有可重入性。
三、redisson
Redisson是架设在Redis基础上的一个Java驻内存数据网格(In-Memory Data Grid)。
充分的利用了Redis键值数据库提供的一系列优势,基于Java实用工具包中常用接口,
为使用者提供了一系列具有分布式特性的常用工具类。使得原本作为协调单机多线程
并发程序的工具包获得了协调分布式多机多线程并发系统的能力,大大降低了设计
和研发大规模分布式系统的难度。同时结合各富特色的分布式服务,
更进一步简化了分布式环境中程序相互之间的协作。
相对于Jedis而言,Redisson强大的一批。当然了,随之而来的就是它的复杂性。它里面也实现了分布式锁,而且包含多种类型的锁,更多请参阅分布式锁和同步器
1、可重入锁
上面我们自己实现的Redis分布式锁,其实不具有可重入性。那么下面我们先来看看Redisson中如何调用可重入锁。
在这里,笔者使用的是它的最新版本,3.10.1。
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.10.1</version>
</dependency>
首先,通过配置获取RedissonClient客户端的实例,然后getLock获取锁的实例,进行操作即可。
public static void main(String[] args) {
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
config.useSingleServer().setPassword("redis1234");
final RedissonClient client = Redisson.create(config);
RLock lock = client.getLock("lock1");
try{
lock.lock();
}finally{
lock.unlock();
}
}
2、获取锁实例
我们先来看RLock lock = client.getLock(“lock1”); 这句代码就是为了获取锁的实例,然后我们可以看到它返回的是一个RedissonLock对象。
public RLock getLock(String name) {
return new RedissonLock(connectionManager.getCommandExecutor(), name);
}
在RedissonLock构造方法中,主要初始化一些属性。
public RedissonLock(CommandAsyncExecutor commandExecutor, String name) {
super(commandExecutor, name);
//命令执行器
this.commandExecutor = commandExecutor;
//UUID字符串
this.id = commandExecutor.getConnectionManager().getId();
//内部锁过期时间
this.internalLockLeaseTime = commandExecutor.
getConnectionManager().getCfg().getLockWatchdogTimeout();
this.entryName = id + ":" + name;
}
3、加锁
当我们调用lock方法,定位到lockInterruptibly。在这里,完成了加锁的逻辑。
public void lockInterruptibly(long leaseTime, TimeUnit unit) throws InterruptedException {
//当前线程ID
long threadId = Thread.currentThread().getId();
//尝试获取锁
Long ttl = tryAcquire(leaseTime, unit, threadId);
// 如果ttl为空,则证明获取锁成功
if (ttl == null) {
return;
}
//如果获取锁失败,则订阅到对应这个锁的channel
RFuture<RedissonLockEntry> future = subscribe(threadId);
commandExecutor.syncSubscription(future);
try {
while (true) {
//再次尝试获取锁
ttl = tryAcquire(leaseTime, unit, threadId);
//ttl为空,说明成功获取锁,返回
if (ttl == null) {
break;
}
//ttl大于0 则等待ttl时间后继续尝试获取
if (ttl >= 0) {
getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} else {
getEntry(threadId).getLatch().acquire();
}
}
} finally {
//取消对channel的订阅
unsubscribe(future, threadId);
}
//get(lockAsync(leaseTime, unit));
}
如上代码,就是加锁的全过程。先调用tryAcquire来获取锁,如果返回值ttl为空,则证明加锁成功,返回;如果不为空,则证明加锁失败。这时候,它会订阅这个锁的Channel,等待锁释放的消息,然后重新尝试获取锁。流程如下:
获取锁
获取锁的过程是怎样的呢?接下来就要看tryAcquire方法。在这里,它有两种处理方式,一种是带有过期时间的锁,一种是不带过期时间的锁。
private <T> RFuture<Long> tryAcquireAsync(long leaseTime, TimeUnit unit, final long threadId) {
//如果带有过期时间,则按照普通方式获取锁
if (leaseTime != -1) {
return tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
}
//先按照30秒的过期时间来执行获取锁的方法
RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(
commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(),
TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
//如果还持有这个锁,则开启定时任务不断刷新该锁的过期时间
ttlRemainingFuture.addListener(new FutureListener<Long>() {
@Override
public void operationComplete(Future<Long> future) throws Exception {
if (!future.isSuccess()) {
return;
}
Long ttlRemaining = future.getNow();
// lock acquired
if (ttlRemaining == null) {
scheduleExpirationRenewal(threadId);
}
}
});
return ttlRemainingFuture;
}
接着往下看,tryLockInnerAsync方法是真正执行获取锁的逻辑,它是一段LUA脚本代码。在这里,它使用的是hash数据结构。
<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit,
long threadId, RedisStrictCommand<T> command) {
//过期时间
internalLockLeaseTime = unit.toMillis(leaseTime);
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
//如果锁不存在,则通过hset设置它的值,并设置过期时间
"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; " +
//如果锁已存在,并且锁的是当前线程,则通过hincrby给数值递增1
"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; " +
//如果锁已存在,但并非本线程,则返回过期时间ttl
"return redis.call('pttl', KEYS[1]);",
Collections.<Object>singletonList(getName()),
internalLockLeaseTime, getLockName(threadId));
}
这段LUA代码看起来并不复杂,有三个判断:
- 通过exists判断,如果锁不存在,则设置值和过期时间,加锁成功
- 通过hexists判断,如果锁已存在,并且锁的是当前线程,则证明是重入锁,加锁成功
- 如果锁已存在,但锁的不是当前线程,则证明有其他线程持有锁。返回当前锁的过期时间,加锁失败
加锁成功后,在redis的内存数据中,就有一条hash结构的数据。Key为锁的名称;field为随机字符串+线程ID;值为1。如果同一线程多次调用lock方法,值递增1。
127.0.0.1:6379> hgetall lock1
1) "b5ae0be4-5623-45a5-8faa-ab7eb167ce87:1"
2) "1"
4、解锁
我们通过调用unlock方法来解锁。
public RFuture<Void> unlockAsync(final long threadId) {
final RPromise<Void> result = new RedissonPromise<Void>();
//解锁方法
RFuture<Boolean> future = unlockInnerAsync(threadId);
future.addListener(new FutureListener<Boolean>() {
@Override
public void operationComplete(Future<Boolean> future) throws Exception {
if (!future.isSuccess()) {
cancelExpirationRenewal(threadId);
result.tryFailure(future.cause());
return;
}
//获取返回值
Boolean opStatus = future.getNow();
//如果返回空,则证明解锁的线程和当前锁不是同一个线程,抛出异常
if (opStatus == null) {
IllegalMonitorStateException cause =
new IllegalMonitorStateException("
attempt to unlock lock, not locked by current thread by node id: "
+ id + " thread-id: " + threadId);
result.tryFailure(cause);
return;
}
//解锁成功,取消刷新过期时间的那个定时任务
if (opStatus) {
cancelExpirationRenewal(null);
}
result.trySuccess(null);
}
});
return result;
}
然后我们再看unlockInnerAsync方法。这里也是一段LUA脚本代码。
protected RFuture<Boolean> unlockInnerAsync(long threadId) {
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, EVAL,
//如果锁已经不存在, 发布锁释放的消息
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; " +
"end;" +
//如果释放锁的线程和已存在锁的线程不是同一个线程,返回null
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
"return nil;" +
"end; " +
//通过hincrby递减1的方式,释放一次锁
//若剩余次数大于0 ,则刷新过期时间
"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
"if (counter > 0) then " +
"redis.call('pexpire', KEYS[1], ARGV[2]); " +
"return 0; " +
//否则证明锁已经释放,删除key并发布锁释放的消息
"else " +
"redis.call('del', KEYS[1]); " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; "+
"end; " +
"return nil;",
Arrays.<Object>asList(getName(), getChannelName()),
LockPubSub.unlockMessage, internalLockLeaseTime, getLockName(threadId));
}
如上代码,就是释放锁的逻辑。同样的,它也是有三个判断:
-
如果锁已经不存在,通过publish发布锁释放的消息,解锁成功
-
如果解锁的线程和当前锁的线程不是同一个,解锁失败,抛出异常
-
通过hincrby递减1,先释放一次锁。若剩余次数还大于0,则证明当前锁是重入锁,刷新过期时间;若剩余次数小于0,删除key并发布锁释放的消息,解锁成功
至此,Redisson中的可重入锁的逻辑,就分析完了。但值得注意的是,上面的两种实现方式都是针对单机Redis实例而进行的。如果我们有多个Redis实例,请参阅Redlock算法。该算法的具体内容,请参考REDIS distlock – Redis中国用户组(CRUG)
41、如何实现集群中的 session 共享存储?
在互联网公司为了可以支撑更大的流量,后端往往需要多台服务器共同来支撑前端用户请求,那如果用户在 A 服务器登录了,第二次请求跑到服务 B 就会出现登录失效问题。
分布式 Session 一般会有以下几种解决方案:
- 1.客户端存储:直接将信息存储在cookie中,cookie是存储在客户端上的一小段数据,客户端通过http协议和服务器进行cookie交互,通常用来存储一些不敏感信息
- 2.Nginx ip_hash 策略:服务端使用 Nginx 代理,每个请求按访问 IP 的 hash 分配,这样来自同一 IP 固定访问一个后台服务器,避免了在服务器 A 创建 Session,第二次分发到服务器 B 的现象。
- 3.Session 复制:任何一个服务器上的 Session 发生改变(增删改),该节点会把这个 Session 的所有内容序列化,然后广播给所有其它节点。
共享 Session:服务端无状态话,将用户的 Session 等信息使用缓存中间件(如Redis)来统一管理,保障分发到每一个服务器的响应结果都一致。
建议采用共享 Session的方案。
前端代码:
42、memcached 与redis 的区别?
43、查看 Redis 使用情况及状态信息用什么命令?
44、Redis 的内存用完了会发生什么?
45、Redis 是单线程的,如何提高多核 CPU 的利用率?
46、一个 Redis 实例最多能存放多少的 keys?List、Set、Sorted Set 他们最多能存放多少元素?
47、Redis 常见性能问题和解决方案?
48、Redis 提供了哪几种持久化方式?
49、如何选择合适的持久化方式?
50、修改配置不重启 Redis 会实时生效吗?
————————————————