Redis5.0中的Stream是什么?
其实就是新增的一个用来做消息队列的数据结构
每个消息都有唯一的消息ID,并且会按照添加的顺序进行排序,那开发人员可以添加消息、拉取消息、删除消息、订阅消息,此外就是Stream还支持消费者,可以实现多个消费者并发处理消息
在Stream之前,一般是通过pub/sub来实现消息队列的功能,但是有一个缺点就是这个消息时无法持久化的,并且是直接推送给在线的订阅者,假设订阅者下线或是宕机,消息就丢失了
Redis Stream 提供了消息的持久化和主备复制功能,可以让任何客户端访问任何时刻的数据,并且能记住每一个客户端的访问位置,还能保证消息不丢失。
所以Stream的特点就是:
- 有序性,消息可以按照发布时间排序,消费者可以按照发布顺序进行消费
- 多消费者支持,多个消费者可以订阅同一个stream,并且是独立消费,并且支持竞争式消费(一条消息只能给一个消费者消费)和共享式消费(一条消息给所有消费者消费)两种
- 持久性,stream会把消息记录到内存中,我们可以开通消费持久化,就会持久化到磁盘,所以能做到重启不丢失
- 消费分组,可以将消息分配给不同的消费组,从而实现灵活消费
Stream的底层是通过类似于日志的数据结构,每个stream都是由一个或是多个日志实现的,每个日志包含多个消息,每个消息包含一个唯一的ID和一些附加的字段,如消息体、时间戳等。
Stream和市面上的MQ产品对比有什么不足吗?
首先就是Redis的消息主要是存储在内存中的,那么消息一多就会OOM,并且还会卡顿,比较适合少量消息,且对实时性要求比较高的场景
此外就是功能比较简单,只有stream这一个队列,一个stream就是一个队列,不像rocketmq那样,支持topic,和事务的概念
最后就是redis是单线程对外服务,所以并发能力也是会有上限
RedisCluster中使用事务和lua有什么限制?
在 Redis Cluster 中,事务不能跨多个节点执行。事务中涉及的所有键必须位于同一节点上。如果尝试在一个事务中包含多个分片的键,事务将失败。另外,对 WATCH 命令也用同样的限制,要求他只能监视位于同一分片上的键。
和事务相同,执行 Lua 脚本时,脚本中访问的所有键也必须位于同一节点。Redis 不会在节点之间迁移数据来支持跨节点的脚本执行。Lua 脚本执行为原子操作,但是如果脚本因为某些键不在同一节点而失败,整个脚本将终止执行,可能会影响数据的一致性。
通过HashTag就能避免了
Redisson的lock和tryLock有什么区别?
lock:阻塞锁
- 调用lock() 之后会一直阻塞等,能拿到锁就会往下执行,拿不到就一直死等
- 特点:无返回值,死等,容易让请求超时
tryLock:非阻塞锁
- 嗲用 tryLock(waitTime, leaseTime, unit) 时会等待超时时间,如果超时了还是没拿到的话就会直接false
- 特点:会返回布尔值表示是否抢到,最多只会等待waitTime 时间
Redisson的watchdog机制是怎么样的?
为什么需要watchdog?
假设你给锁设置了30秒的过期时间,但是你的业务执行了40秒才能结束,那不是执行到一半,锁就释放了,这个时候会出现并发安全问题
boolean locked = lock.tryLock(5, 30, TimeUnit.SECONDS);
// 尝试获取锁,最多等待5秒,抢到之后加锁30秒,时间一过就释放
不过这段代码的看门狗机制并没有生效,只有我们不设置锁时间的时候才生效,并且默认30秒,然后不断续期
lock.lock();
lock.tryLock(5, TimeUnit.SECONDS);
Watchdog 到底做什么?
只有当我们不指定锁过期时间,看门狗才生效
默认情况下直接把锁过期时间设置为30秒,并且通过一个定时任务,每10秒检查一下是否执行完,如果执行完会移除锁,否则继续续期为30秒
当执行了lock.unlock();之后,会将锁移除掉,并停止看门狗定时任务(前提是锁属于你当前线程)
看门狗定时任务什么时候会停止?
- 调用了 unlock()
- 客户端宕机、重启或是业务线程挂了,定时任务检测不到本地状态,直接把停止定时任务
底层原理?
- 内部用一个 Map 记录哪些锁需要续期
- 用 Netty 时间轮 做定时任务
- 用 Lua 脚本 保证续期原子性
- 解锁时从 Map 移除 → 停止续期
客户端挂了,看门狗会过期吗?
会,因为看门狗定时任务其实是JVM的一个线程,客户端挂了,这个定时任务也会停止
那如果业务线程挂了呢?无限续期?
不会,因为你业务线程一旦挂了,会回调到redission,然后他会把Map记录的这个线程信息移除掉,这样,你的挂就不会被定时任务续期了
Redisson的watchdog什么情况下可能会失效?
- 我们主动设置了过期时间
- redis服务器宕机或是服务失效
- 本地cpu过载,不能及时的进行续期操作
Redisson和Jedis有啥区别?如何选择?
jedis是redis提供的Java命令转换工具,说白了就是全部都是原生的功能,只是负责把Java代码转化成对应的redis命令去执行
而Redission处理这些基础的功能之外,主打的是分布式场景,比如分布式锁(可重入、公平锁、红锁、联锁、分布式信号量、CountDownLatch、分布式限流、布隆过滤器、延迟队列、分布式 Map、Queue、自动连接池、主从、哨兵、集群全封装好、看门狗自动续期
如何选择?
Redission相当于一个完善的中间件工具,基本什么场景都能直接使用,而jedis比较适合只是简单实现缓存功能,想要轻量级集成的场景
Redisson如何保证解锁的线程一定是加锁的线程?
加锁的时候存储了什么?
lock.lock();
执行上面这段代码的时候
Redisson 会在 Redis 里生成一个唯一标识:
锁的value = UUID(客户端唯一ID) + ":" + 线程ID
最终的锁的value就是:
"f8ee4851-1234-4f56:10086"
这个UUID 是以客户端为维度生成的,而线程id就是区分同一个客户端里面的不同线程
解锁时做什么?
lock.unlock();
Redisson 会用 Lua 脚本原子执行:
- 判断锁是否存在
- 取出锁里存的 UUID + 线程 ID
- 和当前线程的标识对比
- 不一致 → 直接返回,不解锁
- 一致 → 才允许删除锁
底层的lua脚本
-- 1. 判断当前线程是不是加锁的线程
if (redis.call('hexists', KEYS[1], ARGV[2]) == 0) then
return nil; -- 不是!直接返回,不解锁
end;
-- 2. 是自己的锁,才能删除
redis.call('hdel', KEYS[1], ARGV[2]);
return 1;
当发现不是自己的锁时会直接抛异常
这里用lua脚本就是保证判断+删除是原子性的
Redisson中为什么要废弃RedLock,该用啥?
RedLock 是什么?
废弃的原因:
- 锁过期依赖redis的机器时钟,不同节点时钟可能不一致,不适合,完全做不到一致性
- 没有经过彻底的检查,并且在一些极端情况下,比如网络分区和节点故障时,这个算法不完全可靠
- 部署麻烦,需要同时部署多个节点
总结就是,没办法做到完全一致性,并且极端情况下不适合,存在一定的风险
替代方案:
- 单实例部署,然后单实例加锁,这样能避免重复加锁或是锁丢失问题,但是存在单点故障
- 如果一定是集群部署的话,那还是采用原来的redission或是setnx,但是业务端做好幂等处理,不要出现重复执行的情况
- 使用一些强一致性组件实现分布式锁,因为redis是AP,可以考虑ZK这种CP
Redis的Key和Value的设计原则有哪些?
key设计原则:
- 可读性
- 简洁性
- 避免特殊字符
- 命名空间,这个比较实用,一般就是以服务为维度进行划分
- 长度限制
value设计原则:
- 选择合适的数据类型
- 避免大key
- 设置合适的过期时间
Redis的持久化机制是怎样的?
有两种,RDB和AOF
RDB:
- RDB 说白了就是定时拍快照
- 定时把内存数据拍成一个完整快照 dump.rdb
- 把全量二进制文件数据写进一个紧凑文件
- 数据恢复的时候秒级恢复
优点:体积小,恢复快,适合做容灾处理
缺点:定时执行,会丢失最后一段数据
save 30 1
30秒内改了1条就触发刷盘
AOF:
- AOF就是把所有的指令都追加到日志末尾
- 每执行一条就记录一次,重启的时候会重放这个文件
优点:数据更安全,丢失更少、可打开看明文(没开混合持久化时)
缺点:文件特别大、恢复慢(要一条条执行)、对磁盘 IO 压力大
AOF的三种刷盘策略:
- Always(同步刷盘)每条命令都立即同步落盘→ 最安全,但性能最差
- Everysec(每秒刷盘)【默认】先写缓冲区,每秒刷一次→ 最多丢 1 秒数据,性价比最高
- No(操作系统控制)完全交给操作系统决定何时落盘→ 性能最好,但最容易丢数据
混合持久化:
这两种持久化方案各有优点,可以把他们结合起来,RDB-AOF 混合持久化
AOF重写的时候,把现有的数据进行一次RDB快照,记录起来,然后接下来的新的指令以追加的形式追加到AOF文件末尾
也就是旧数据以RDB形式,新指令以AOF形式,一个文件两种数据,可读性比较差
AOF重写就是把旧指令进行精简,因为可能你对同一个key的改动命令有很多条,但是只有最后一条是生效的
redis能保证不丢失数据吗?
不能。redis是基于内存的数据,即使通过持久化机制也不是绝对可靠,哪怕 AOF + Always 也不行
每次持久化的时候只是写入到页缓存,不是真正的写入到磁盘,有极低的概率发生数据丢失
Redis的过期策略是怎么样的?
redis的过期策略是定期删除+惰性删除相结合
定期删除
- 每隔 100ms 随机抽查一批带过期时间的 key
- 发现过期就直接删除
- 优点:主动清理,节省内存
- 缺点:耗 CPU,不能保证所有过期 key 都立刻删掉
惰性删除
- 不主动删,等到 key 被访问时才检查是否过期
- 过期了就删除并返回不存在
- 优点:省 CPU
- 缺点:过期 key 没人访问就一直占内存
两种策略相结合能比较好的平衡cpu和性能
Redis的内存淘汰策略是怎么样的?
内存淘汰策略就是当内存满了之后,要通过什么规则删除key来腾出空间
两个前缀
- llkeys :对所有 key 生效
- volatile :只对设置了过期时间的 key 生效
一共有8种:
1)noeviction
- 不淘汰任何 key,内存满了直接报错,不让写
2)allkeys-lru(最常用)
- 所有 key 里,删掉最近最少使用的
3)volatile-lru
- 只在带过期时间的 key 里,删最近最少使用的
4)allkeys-random
- 所有 key 里随机删
5)volatile-random
- 带过期时间的 key 里随机删
6)volatile-ttl
- 删马上要过期的 key(TTL 最短)
7)allkeys-lfu
- 所有 key 里,删访问频率最低的
8)volatile-lfu
- 带过期时间的 key 里,删访问频率最低的
lru和lfu有什么区别?
lru是最近最少使用,看的是最近没怎么用的
lfu是使用频率最低,看的是总的使用次数
怎么选择?
只是当缓存用的话,选择 allkeys-lru
追求极致命中率,选择 allkeys-lfu
绝对不丢数据,选择 noeviction (但是内存满了会OOM)
生产环境一般推荐使用 allkeys-lru,默认是noeviction
Redis的事务和Lua之间有哪些区别?
Redis 事务 = 批量打包命令,不能用结果、不能判断、不能循环。
Lua 脚本 = 全能原子脚本,能用结果、能判断、能循环、网络更少。企业里 99% 都用 Lua,不用事务。
原子性区别:
- 事务执行的时候,一个命令出错,后续的命令继续跑
- lua脚本执行的时候出错,后续的命令不会继续跑,更符合我们的认知
- lua脚本用的比较多,事务基本没怎么用
网络交互次数:
-
事务本质上是通过开始和结束标识来告知哪些命令属于事务,也就是说依然需要多次网络交互
MULTI
set k1 v1 → 1次网络
set k2 v2 → 1次网络
get k3 → 1次网络
EXEC → 1次网络 -
lua脚本是一次性打包,只需要一次网络IO
能否使用前面命令的结果?
- 事务完全不能,事务本质上只是将命令进行排队,各个命令之间还是独立的
- lua脚本可以
能否进行逻辑控制,比如if/else/循环等?
- 事务不行,lua可以
总结就是事务本质上只是将多条命令按照一定的顺序执行,但是无论是性能还是功能都远不如lua,真实场景基本没什么人用事务,都是使用lua
Redis的事务机制是怎样的?
事务其实就是用来实现多个指令按照一定的顺序执行的
通过 MULTI 开启事务,中间就是要顺序执行的指令,然后通过 EXEC 来标识事务结束
事务有一个比较坑的点就是,指令不是一次性打包过去的,而是通过一条条发送的形式,也就是说存在多次IO
那这么一看好像还不如管道技术,不过管道技术不保证指令执行的顺序
此外,事务执行的指令之间是独立执行的
总结下来就是,事务其实就是早期实现批量顺序执行指令的方法,但是后面lua出来之后,这个技术就没什么人用了
Redis的虚拟内存机制是什么?
虚拟内存主要是在早期内存比较贵的时候使用的,就是把不常访问的冷数据换到磁盘,让内存中存储的永远是热数据
后面放弃的原因:
- 性能比较差,磁盘IO比内存慢太多了,频繁的换入换出会拖慢redis
- 设计复杂,换入换出需要很复杂的维护逻辑
- 性价比低,内存开始便宜,没必要使用这个技术
- 现在内存不够是直接使用 Redis Cluster 做横向扩容
Redis如何高效安全的遍历所有key
遍历有两个指令,keys和scan
- KEYS 会阻塞 Redis,绝对不能在生产用
- SCAN 是游标分批遍历,不阻塞,生产环境唯一推荐
keys:
keys *
这个指令是一次性把所有的key都遍历出来,到那时因为redis是单线程处理,所以这个会导致阻塞,是非常危险的一个行为,生产环境一定不能用
scan:
SCAN 游标 MATCH 匹配模式 COUNT 数量
对应Java代码:
Cursor<Object> cursor = redisTemplate.opsForSet()
.scan(cacheKey, ScanOptions.scanOptions().match("*").count(100).build());
这种是基于游标的扫描方式,每次扫描一小批,不会造成卡顿
生产环境唯一推荐
Redis如何实现发布_订阅?
发布订阅其实就是发布者往一个频道写入消息,订阅者监听这个频道,一旦有消息就能马上收到
- 发布者(Publisher):往一个 "频道" 里发消息
- 频道(Channel):消息的中转站
- 订阅者(Subscriber) :监听频道,一有消息就立刻收到
模式:一对多广播,也就是发一条消息,所有订阅者都能收到
两条指令:
SUBSCRIBE 频道名 // 订阅
PUBLISH 频道名 消息 // 发布
优点:
- 极快:内存转发,实时性极高
- 极轻:简单几行命令就能用
- 一对多广播:适合通知、推送、消息同步
- 支持模式匹配订阅(
PSUBSCRIBE topic*)
缺点:Pub/Sub 最大问题:不存消息,会丢数据!
- 订阅者不在线 → 消息直接丢失
- 客户端断开 → 历史消息不会补发
- Redis 重启 → 所有消息清空
- 没有 ACK,没有重试,没有堆积
总结就是发了就发了,不会存储,没人接收到就丢失
stream就是用来解决pub/sub不可靠的问题
Redis如何实现延迟消息?
Redis key 过期监听:
- 开通 notify-keyspace-events Ex
- key一过期,redis就会发送消息,业务监听过期事件
- 但是不推荐,因为这个过期回调不可靠,并且不精准
Zet 实现:
- 把到期时间记录到Zset的score中,然后开后台线程去定期扫描
- 优点:可靠,持久化,高可用
- 缺点:要手写代码,并且高并发场景需要加锁来确保不会重复消费
使用Redission实现的延迟队列:
- 底层其实还是 ZSet 做延时排序 + 双List 存顺序与消费 + Pub/Sub 唤醒 + Lua 原子转移 + Netty 时间轮调度
- 加入消息的时候就是存储到ZSet,到期会自动取出,推送到目标队列,业务直接监听队列就行
- 优点:开箱即用,不用自己写轮询代码,并且高吞吐高可用,分布式环境下比较适合
Redis实现分布锁的时候,哪些问题需要考虑?
- 互斥性和可重入性
-
- 互斥性就是同一把锁只能一个线程拿,不能出现同时两个线程拥有同一把锁的现象
- 可重入性是,同一线程可以多次获取同一把锁,而不会被阻塞,所以就要求你的这个锁需要有线程Id标识以及计数器
- 误解锁问题
-
- 这个简单,就是把线程Id也带上,解锁的时候先对比一下你这个锁是不是属于当前线程
- 可以使用lua脚本来实现判断加解锁原子性操作
- 锁过期时间
-
- 锁一定要设置过期时间,否则就变成死锁了
- 但是时间设置太短会出现不能覆盖业务操作,太长会导致并发差
- 最好就是使用redission来实现,内置的watchdog机制能自动续期
- 锁丢失问题
-
- 如果是单机节点部署,那么redis一旦宕机,拿你的业务服务也会停摆
- 所以为了避免单点故障,一般会多节点部署,但是多节点部署的时候,假设我们往主节点加锁,数据还没来得及同步,主节点宕机,新的从节点上来没锁数据,就会出现锁丢失问题
- 解决方法,依旧往主节点加锁,但是尽量确保业务正常执行的过程中是幂等性的
- 但是其实最有效的方法还是单点部署
- 网络分区
-
- 集群被网络切成两半,两边都拿到部分锁,导致两个客户端都加锁成功
- 这种问题redis同样无法避免,只能业务端做幂等处理
Redis使用什么协议进行通信?
使用的是RESP,是redis自定义的通信协议,底层是基于TCP的
协议里有五种类型,分别通过不同前缀来标识:
-
- 简单字符串(如 +OK)
-
- 错误(如 -ERR)
- : 整数(如 :1)
- $ 批量字符串(带长度)
- * 数组
之所以要自己设计协议就是因为自定义协议比HTTP快多了,并且它的协议比较简单,各种语言都能很好的适配
Redis是AP的还是CP的?
属于AP,P就是分区容错,所以AP和CP需要在集群部署的情况下讨论
单机redis就不适用
redis属于AP,因为它追求的是可用性,但是允许数据出现短暂的不一致
具体原因:redis的主从复制默认是异步的,也就是往主节点写入之后,立即返回成功,当然我们可以设置成同步复制,但是一般不会这么做,此外就是即使同步复制也不代表就是CP,因为数据仍然可能丢失
使用wait命令能变成CP吗?
不能,因为wait只是提高可靠性,并没有保证数据的强一致性,如果想要强一致性需要使用ZK等使用了共识算法的中间件
Redis为什么被设计成是单线程的?
其实redis不是只有一个线程,而是只用一个线程处理网络请求+键值对读写命令执行
而RDB/AOF持久化,集群同步,异步删除等操作早就是通过后台线程去执行了
为什么处理网络请求+键值对读写只用一个线程?
因为redis的数据主要是存储在内存中,所以主要的性能瓶颈是在内存与网络,而不是cpu,所有操作都是在内存,cpu原本就能极快的处理完,无需多线程来提速
此外,单线程处理意味着没有锁竞争开销,且没有线程切换开销,这反而实现了线程安全且性能高的天然优势
接着就是代码简单,好维护,不用担心复杂的并发问题或是死锁风险
最后就是redis使用了单线程+IO多路复用,让线程一直都是有事情做,而不是阻塞等待IO事件
单线程为什么还能这么快?
纯内存操作
单线程无锁竞争
IO多路复用能实现高并发
命令简单高效
总结就是Redis的性能瓶颈确实是在IO上,但是不是通过多线程来解决,而是通过IO多路复用技术
Redis为什么要自己定义SDS?
c语言原生的字符串的弊端:
- 获取长度慢,每次都要从头遍历到 \0 才知道长度,每次获取都是 O(n)
- 存不了二进制数据(图片/视频/序列后的数据),因为二进制数据可能会有 \0 ,但是 \0 是c语言字符串结尾标识,容易被直接误判截断
- C语言字符串不会自动扩容,进行字符串拼接的时候容易越界
- 频繁修改字符串会导致频繁分配和释放内存,造成内存碎片
SDS 其实就是redis自己实现的简单字符串
struct sdshdr {
int len; // 已使用长度
int alloc; // 总分配空间
char buf[]; // 字节数组
};
优点:
- O (1) 获取长度,直接读 len,不用遍历
- 二进制安全,不依赖 \0 判断结束,靠 len能存图片、序列化数据、任何字节
- 杜绝缓冲区溢出,拼接前先检查空间,不够就自动扩容,绝对不会越界
- 预分配 + 惰性释放,减少内存分配,扩容时多分配一点空间,缩短时不立即回收空间,大幅提升修改性能
Redis为什么这么快?
主要原因:
- 基于内存,这个我认为是最主要的原因,它的读写都是基于内存,这意味着它的性能瓶颈在IO,不在cpu,内存的访问速度是非常快的
- 单线程模型,网络请求处理+key读写都是一个线程实现,这就不会有锁竞争问题,也不会有上下文切换问题
- IO多路复用,redis的主要瓶颈是在网络IO,解决网络IO有两种方法,一是多线程,而是多路复用,但是多线程会导致锁竞争和并发安全问题,所以采用了多路复用解决,避免线程卡死在等待网络IO上面
- 多种高效的数据结构,比如SDS/哈希表/有序集合/列表等,能实现快速的处理数据请求
- 多线程引入,redis6.0开始引入多线程,并行处理网络请求,更大程度减少IO等待影响
Redis与Memcached有什么区别?
Memcached 是早期的KV缓存数据库,但是只能做缓存,脚本其它功能也没有,没有持久化功能,只支持KV结构,多线程+细粒度锁,组件本身没有集群能力,需要客户端手动分片
Redis支持哪几种数据类型?
比较常见的就是5种:
- 字符串(String)
- 哈希(Hash)
- 列表(List)
- 集合(Set)
- 有序集合(Sorted Set)
此外还有比较高级的GEO或是Stream以及比较常用的BitMap 等
Redis中key过期了一定会立即删除吗
不会,redis的key删除是定期+惰性的策略
具体就是:
- 定期删除:Redis 后台每秒跑 10 次(由
hz 10控制):
-
- 从带过期时间的 key里随机抽 20 个
- 删掉其中已过期的
- 如果超过 25% 都过期了,就继续再抽一轮
- 单次最多跑 25ms 就停,避免阻塞主线程
特点:批量清理,但只抽样本,不会全量扫,所以一定删不干净。
- 惰性删除:
-
- 你去访问这个 key 时
- Redis 先检查:过期了吗?
- 过期 → 当场删除,返回不存在
- 没过期 → 正常返回
特点:不访问就永远不删,极度省 CPU,但浪费内存。
为什么不一到时间立马删除?
如果同时有大量的key过期,你采用立马删除的策略,会让服务的压力瞬间增大
Redis中的setnx命令为什么是原子性的
因为redis是单线程模型,命令都是主线程来执行的,意味着每个命令执行的时候不会有其它命令来打断
当执行setnx的时候,如果key存在会返回0,并且不做任何操作,key不存在则是将值设置进去
总结就是,setnx本身是原子性执行,把判断key是否存在+设置值做成一步操作,此外就是redis是单线程模型,执行这条指令的时候不会有其它指令打断
Redis中的Zset是怎么实现的?
ZSet 底层其实有两套机制,数据少用listpack(紧凑列表),数据多时用 skiplist(跳表)+ dict(哈希表),自动转换,兼顾内存和性能
小数据量的时候: listpack(紧凑列表,替代旧 ziplist)
适用条件(默认):
- 元素个数 <
zset-max-ziplist-entries(默认 128) - 每个 member + score 总大小 <
zset-max-ziplist-value(默认 64 字节)
特点:
-
块连续内存紧凑存储,没有指针,省内存
-
按照score从小到大进行排序存储
-
插入和查找都是O(n),但是因为个数小,所以很快(每次插入都会遍历到这个数据应该插入的位置)
[score1][member1][score2][member2][score3][member3]...
大数据量: skiplist + dict(跳表 + 哈希表)
当数据比较大或是比较多的时候会自动从listpack转化成 skiplist + dict
dict(哈希表)
- key:member
- value:score
作用:O (1) 查 member 对应的 score(如 ZSCORE)
skiplist(跳表)
- 按 score 排序(同 score 按 member 字典序)
- 作用:O (log n) 范围查询、排名、插入、删除(如 ZRANGE、ZRANK)
记录两份就是为了能高效的知道每一个member的score,也能快速范围查询特定score的member
skiplist 是什么?结构怎么样?
其实就是跳表,跳表也是一种特殊的链表,不过因为会拔高层,来实现范围查询时的快速定位
比如:

特点就是越上层,节点越稀少,并且比较适合内存这种来定位,磁盘因为每次往下跳都是一次IO,不太适合
总结就是带了多级索引的链表
listpack 和 ziplist的区别?
其实几乎没什么区别,主要的区别是在内部的entry存储有所改变
外层:
[zlbytes][zltail][zllen][entry1][entry2][entry3]...[zlend]
4字节 4字节 2字节 变长 变长 变长 1字节
[lpbytes][lptail][lplen][entry1][entry2][entry3]...[lpend]
4字节 4字节 2字节 变长 变长 变长 1字节
可以发现是一样的
内层entry
【Ziplist Entry】
┌─────────┬──────────┬─────────┐
│ prevlen │ encoding │ content │
│ 记前一个长度 │ 数据类型 │ 实际数据 │
└─────────┴──────────┴─────────┘
↓
连锁更新根源
【Listpack Entry】
┌──────────┬─────────┬─────┐
│ encoding │ content │ len │
│ 数据类型 │ 实际数据 │ 记当前长度 │
└──────────┴─────────┴─────┘
↓
无连锁更新
但是ziplist 的每一个entry会记录前一个的长度,用来支持双向遍历,但是前一个一旦内容发生改变,对应的长度需要更新,那么当前节点同样需要更新,极端情况下,一个节点的更新会引发后续大量节点更新,这个就叫连锁更新
Listpack 不记别人的长度,只记自己的,那么要实现反向遍历的时候,需要先解析到前一个节点的长度,这样同样可以实现,并且避免连锁更新的情况
Redis中有一批key瞬间过期,为什么其它key的读写效率会降低?
因为redis的主线程同一时间只能做一件事,如果大量的key过期,那么主线程就会一直在删除key,造成明显卡顿
虽然说单次删除最多只会占用25ms,并且每秒执行10次删除,但是假设你是很多key过期的情况下,相当于每一秒都要卡住250ms,并且是分散在1秒的各个时间段,并起来就会造成明显卡顿
总结就是,主线程都被用来删除过期key了,没有空去处理其它key的读写
此外可能还会出现一个缓存雪崩的问题,因为key过期意味着你需要回表,大量压力打到数据库
解决方法:key的过期时间最好加上一个偏移随机数,避免都是同时过期
watchdog解锁失败,会不会导致一直续期下去?
不会。不管是解锁成功、失败还是报错都会停止看门狗
核心代码:
handle((opStatus, e) -> {
// 不管成功失败,第一行就取消续期!
cancelExpirationRenewal(threadId);
});
protected void cancelExpirationRenewal(Long threadId) {
ExpirationEntry task = (ExpirationEntry)EXPIRATION_RENEWAL_MAP.get(this.getEntryName());
if (task != null) {
if (threadId != null) {
task.removeThreadId(threadId);
}
if (threadId == null || task.hasNoThreads()) {
Timeout timeout = task.getTimeout();
if (timeout != null) {
timeout.cancel();
}
EXPIRATION_RENEWAL_MAP.remove(this.getEntryName());
}
}
}
不管后续执行成功还是失败,第一行就是把看门狗定时任务取消
续期靠什么?
// key 是锁的唯一标识 value 是一个实体,里面记录的是持有锁的线程id以及重入次数
ConcurrentMap<String, ExpirationEntry> EXPIRATION_RENEWAL_MAP = new ConcurrentHashMap();
public static class ExpirationEntry {
private final Map<Long, Integer> threadIds = new LinkedHashMap();
private volatile Timeout timeout;
public ExpirationEntry() {
}
public synchronized void addThreadId(long threadId) {
Integer counter = (Integer)this.threadIds.get(threadId);
if (counter == null) {
counter = 1;
} else {
counter = counter + 1;
}
this.threadIds.put(threadId, counter);
}
public synchronized boolean hasNoThreads() {
return this.threadIds.isEmpty();
}
public synchronized Long getFirstThreadId() {
return this.threadIds.isEmpty() ? null : (Long)this.threadIds.keySet().iterator().next();
}
public synchronized void removeThreadId(long threadId) {
Integer counter = (Integer)this.threadIds.get(threadId);
if (counter != null) {
counter = counter - 1;
if (counter == 0) {
this.threadIds.remove(threadId);
} else {
this.threadIds.put(threadId, counter);
}
}
}
public void setTimeout(Timeout timeout) {
this.timeout = timeout;
}
public Timeout getTimeout() {
return this.timeout;
}
}
其实watchdog里面维护了一个map,记录每一个锁和对应的线程,如果线程id在里面,就会被续期
但是只要被移除就会停止续期,cancelExpirationRenewal() 就是把这个id进行移除
极端情况下,redis挂了,没法接触redis的锁,没关系,看门狗续期停了,锁自然过期,因为续期是本地操作,无论什么情况下都能解锁成功,哪怕你本地服务直接宕机了
那假设业务线程挂了,watchdog怎么停止续期呢?
停止不了,真的出现业务线程挂掉,而进程还在执行,那就直接死锁,这也是redisson的一个弊端
有什么解决方法?直接使用带过期时间的锁就不会走watchdog逻辑
boolean locked = lock.tryLock(5, 30, TimeUnit.SECONDS);
源码:
private void renewExpiration() {
ExpirationEntry ee = (ExpirationEntry)EXPIRATION_RENEWAL_MAP.get(this.getEntryName());
if (ee != null) {
Timeout task = this.commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
public void run(Timeout timeout) throws Exception {
ExpirationEntry ent = (ExpirationEntry)RedissonBaseLock.EXPIRATION_RENEWAL_MAP.get(RedissonBaseLock.this.getEntryName());
if (ent != null) {
Long threadId = ent.getFirstThreadId();
if (threadId != null) {
RFuture<Boolean> future = RedissonBaseLock.this.renewExpirationAsync(threadId);
future.onComplete((res, e) -> {
if (e != null) {
RedissonBaseLock.log.error("Can't update lock " + RedissonBaseLock.this.getRawName() + " expiration", e);
RedissonBaseLock.EXPIRATION_RENEWAL_MAP.remove(RedissonBaseLock.this.getEntryName());
} else {
if (res) {
RedissonBaseLock.this.renewExpiration();
}
}
});
}
}
}
}, this.internalLockLeaseTime / 3L, TimeUnit.MILLISECONDS);
ee.setTimeout(task);
}
}
protected RFuture<Boolean> renewExpirationAsync(long threadId) {
return this.evalWriteAsync(this.getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN, "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then redis.call('pexpire', KEYS[1], ARGV[1]); return 1; end; return 0;", Collections.singletonList(this.getRawName()), this.internalLockLeaseTime, this.getLockName(threadId));
}
watchdog一直续期,那客户端挂了怎么办?
这里的客户端挂了得分情况,是客户端整个jvm挂了还是只是执行业务的线程没来得及释放锁就挂了
如果是整个jvm挂了,不用紧张,因为redisson是基于jvm本地线程来执行的,你jvm挂了,watchdog自然也执行不了,锁到期就释放了
如果是业务线程挂了,但是jvm没挂,就会出现一直续期的情况,这个是设计上的弊端,但是出现概率极低
真正的生产环境一般都会给锁设置超时时间,不会走watchdog逻辑,因为这个东西本身也会耗费一定的性能
除了做缓存,Redis还能用来干什么?
- 分布式锁
- 分布式限流
- 全局计数器/点赞/访问量
- 分布式Id生成器
- 分布式session
- 实时排行榜(ZSet)
- 共同好友/共同关注(Set交集)
- 签到/状态统计(BitMap)
- 布隆过滤器
- 地理位置GEO
- 简单的消息队列(一般用Stream可以实现,但是不推荐)
介绍下Redis集群的脑裂问题?
脑裂就是同时出现两个Matser
产生原因:网络分区+哨兵切换
- 原来的 Master 因为网络抖动 / 临时故障,和哨兵、从库断开
- 哨兵等了一段时间没收到心跳,认为 Master 挂了
- 哨兵重新选举一个 Slave 成为新 Master
- 结果旧 Master 网络又恢复了
- 此时:
-
- 旧 Master 还在提供写服务
- 新 Master 也在提供写服务→ 脑裂出现
危害:
- 两边同时写,数据不一致
- 旧 Master 上的新写入,不会同步到新 Master
- 等旧 Master 重新加入集群变成 Slave 时
-
- 会被清空数据,重新全量同步
- 旧 Master 上的新写入全部丢失
解决方法:
min-slaves-to-write 1 至少要有一个slave才能写入
min-slaves-max-lag 10 slave给master的ack信号不能超过10秒
一旦不满足上面的条件,master会直接拒绝服务,能大大的避免脑裂危害
但是不是100%解决,主从切换以及心跳判定等都是需要时间的,肯定会丢失一部分数据
介绍一下Redis的集群模式?
主从模式:
最简单的集群模式,采用一主多从的配置
分工:
- 主节点:读+写
- 从节点:只读+同步主库数据,主要是做一个备份的操作
优点:读能力可以水平扩展,数据有副本,安全一点
缺点:没有自动故障转移,主节点挂了,比如人工切换
哨兵模式:
就是在主从的基础上加上哨兵节点,这些哨兵节点同样是redis节点,但是他们不提供读写,只用来选举和管理各个节点的存活情况
一般就是主从+一组哨兵(至少三个)
哨兵干什么:
- 监控主节点是否存活
- 主节点挂了,投票选新主
- 通知客户端切换新主
关键机制:
- 主观下线:单个哨兵觉得节点挂了
- 客观下线:多数哨兵认为主节点挂了 → 才切主
优点:
- 主节点挂了自动切换,不用人工
- 客户端通过哨兵自动发现新主
缺点:
- 只是高可用,不能扩容存储
- 所有数据都在一个主节点,压力大了扛不住
Redis Cluster 集群模式:
这种模式采取的是分片存储+高可用+水平扩容
具体就是将数据分配到16384个槽位,然后将这些槽位分配给这些节点,每个节点都是主节点,都负责一部分的读写
然后每个主节点可以单独配从节点
特点:
- 自动分片,数据分散在不同节点
- 单个主节点压力小,可加节点扩容
- 自带故障转移,不需要哨兵
优点:
- 海量数据存储
- 高并发读写
- 高可用 + 水平扩展
缺点:
- 架构更复杂
- 跨节点事务、Lua、多键操作受限
如何基于Redisson实现一个延迟队列
主要是使用到ZSet 这个底层数据结构
@Component
public class RedissonOrderDelayQueue {
@Autowired
RedissonClient redisson;
public void addTaskToDelayQueue(String orderId) {
RBlockingDeque<String> blockingDeque = redisson.getBlockingDeque("orderQueue");
RDelayedQueue<String> delayedQueue = redisson.getDelayedQueue(blockingDeque);
System.out.println(LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) + "添加任务到延时队列里面");
delayedQueue.offer(orderId, 3, TimeUnit.SECONDS);
delayedQueue.offer(orderId, 6, TimeUnit.SECONDS);
delayedQueue.offer(orderId, 9, TimeUnit.SECONDS);
}
public String getOrderFromDelayQueue() {
RBlockingDeque<String> blockingDeque = redisson.getBlockingDeque("orderQueue");
RDelayedQueue<String> delayedQueue = redisson.getDelayedQueue(blockingDeque);
String orderId = blockingDeque.take();
return orderId;
}
}
当元素的延迟时间到达时,Redisson 会将元素从 RDelayedQueue 转移到关联的 RBlockingDeque 中。
使用 RBlockingDeque 的 take 方法从关联的 RBlockingDeque 中获取元素。这是一个阻塞操作,如果没有元素可用,它会等待直到有元素可用。
如何基于Redis实现滑动窗口限流?
固定窗口就是从你第一条消息开始往后60秒作为一个窗口,但是存在问题就是有可能出现流量突刺
因为假设第一分钟的最后一段时间和第二分钟的最开始一段时间来了大量的流量,就会造成瞬时压力
滑动窗口则不会,它是以没一个时刻为临界点,往前推60秒作为时间窗口,计算这段时间窗口的出现次数
滑动窗口一直在动,任何时刻都只看最近 60s的请求数,更平滑、更精准
比如:
- 窗口 60s,最多 100 次请求
- 任何时间点进来,都只统计最近 60s 内的量
为什么用ZSet实现?
ZSet 完美匹配滑动窗口需求:
- score = 时间戳
- member = 唯一标识(请求 ID / 时间戳)
- 可以按分数范围删除(删掉 60s 前的数据)
- 可以快速统计数量
实现步骤:
- 计算窗口起始时间:当前时间 - 60s
- 删除 窗口外的旧数据(
ZREMRANGEBYSCORE) - 统计 当前窗口内请求数(
ZCARD) - 没超量就添加当前请求 (
ZADD)
需要使用lua脚本来实现,因为必需把删除-统计-添加这三步原子性实现
String luaScript = "local window_start = ARGV[1] - 60000\n" +
"redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', window_start)\n" +
"local current_requests = redis.call('ZCARD', KEYS[1])\n" +
"if current_requests < tonumber(ARGV[2]) then\n" +
" redis.call('ZADD', KEYS[1], ARGV[1], ARGV[1])\n" +
" return 1\n" +
"else\n" +
" return 0\n" +
"end";
也可以使用redisson提供的限流器来实现,但是底层是令牌桶,不是滑动窗口
如何解决Redis和数据库的一致性问题?
都先就是采用删除缓存,不要更新缓存,因为删缓存的代价比较低,更新缓存的话,不一定用得到
问题:先删缓存还是先改数据库?
一般采用先更新数据库,再删除缓存
因为redis的读写效率远远高于数据库,先删缓存在写数据库,中间数据不一致的时间间隔比较长,容易放大出现数据不一致的概率
延迟双删:
就是先删除缓存,再更新数据库,延迟几百毫秒只会再删除缓存,最大限度的避免出现数据不一致问题
订阅binlog:
基于binlog订阅,一般就是使用canal来实现,当监听到数据库数据发生修改再去删除缓存,这种方案同样可以,这种就是最大限度的减少服务的耦合
如何用Redisson实现分布式锁?
为什么要用redsion?用setnx不行吗?
因为原生的setnx存在的弊端:
- 不能自动续期
- 不支持可重入
- 解锁的时候没有防误删
- 没有公平锁,读写锁等高级功能
redisson最厉害的功能,看门狗:
能实现自动给锁续期,每10秒检查一次,如果业务还在执行就续期为30秒,能有效避免锁范围不能覆盖业务流程的情况
可重入锁:
同一个线程可以多次获取同一把锁,不会锁死自己
Redisson底层是使用Hash结构来实现的
- key:锁名
- field:线程Id
- value:重入次数
加锁的时候重入次数+1,结果的时候重入次数-1,直到0才释放
生产环境使用:
RLock lock = redisson.getLock("order-lock");
try {
lock.lock(); // 看门狗自动续期
// 业务代码
} finally {
lock.unlock(); // 必须释放
}
几种锁的区别:
普通可重入锁(默认情况下)
RLock lock = redisson.getLock("lock");
谁都能强,抢不到会阻塞,最公平
公平锁:
RLock lock = redisson.getFairLock("lock");
先到先得,不会出现线程饥饿现象,到那时性能会比普通锁差一点
联锁:
RedissonMultiLock lock = new RedissonMultiLock(lock1, lock2, lock3);
必需同时抢到多个锁才算成功,适合同时需要锁住多个资源的情况
红锁:解决redis主从切换锁丢失问题,主要思想就是必需向 N/2 个节点加锁成功才算成功
但是已经抛弃
读写锁:
RReadWriteLock rwLock = redisson.getReadWriteLock("lock");
rwLock.readLock().lock(); // 读共享
rwLock.writeLock().lock(); // 写独占
核心原理就是通过Hash结构实现
key: 锁名称
field: 模式标识 + 线程ID
value: 重入次数
如何用Redis实现乐观锁?
乐观锁就是不加锁,不上锁,不阻塞,只是在最后提交的时候检查一下数据是否发生变动,来决定是否提交,本质上就是CAS
**实现原理:**通过watch key,这个命令就是监控这个key,一旦事务执行前这个key被修改,整个事务直接作废
**事务:**它其实是把指令都先收集起来,但是不执行,等到收到exec的时候在阻塞一次性执行
redis实现乐观锁:
流程:
- WATCH key开始监视这个 key
- GET key拿到当前值
- MULTI开启事务
- SET key 新值把修改放入事务队列
- EXEC执行事务
-
-
如果 key 没变 → 执行成功
-
如果 key 被别人改了 → EXEC 返回 null,事务失败
线程A:WATCH mykey → 开始监控
线程A:GET mykey=10 → 拿到旧值
线程B:SET mykey=20 → 中途修改!
线程A:MULTI
线程A:SET mykey=11
线程A:EXEC → 返回 null,事务失败
-
只要中途有人改过了就会失败
代码:
// 1. 监控 key
jedis.watch(key);
// 2. 获取当前值
int intValue = Integer.parseInt(jedis.get(key));
// 3. 开启事务
Transaction t = jedis.multi();
// 4. 执行修改
t.set(key, String.valueOf(intValue + 1));
// 5. 提交事务
// 如果返回 null → 说明被别人改了 → 乐观锁冲突
if (t.exec() == null) {
System.out.println("冲突,事务失败");
}
总结就是先通过watch key 锁定一下key,底层会通过版本号机制来确保,然后你后续的操作需要通过事务来提交,也就是把一系列指令打包上去执行
当执行exec的时候,redis会判断key是否被修改过来实现乐观锁
因为内部是基于版本号实现的,所以不存在ABA问题
如何用SETNX实现分布式锁?
setnx 就是 当这个key不存在的时候才能设置成功,如果存在的话就会失败,由于redis天然的单线程优势,所以可以保证只有一个线程能加锁成功
redisTemplate.opsForValue().setIfAbsent(cacheKey, 1, 1, TimeUnit.SECONDS);
如果想要解锁的话,就得用lua脚本了
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
因为需要
- 判断一下是否是自己的锁
- 删除锁
- lua保证原子性
这三步得一起执行,否则不能保证原子性,可能会出现安全问题
特点:实现简单,性能高,适合大部分简单的业务场景
缺点:无法自动续期,不可重入,主从切换可能会丢锁(这个是都会的)
如何用setnx实现一个可重入锁?
可重入锁就是同一个线程可以多次获取同一把锁而不会出现把自己锁死的现象
那么value需要如何设计?
线程ID : 重入次数
例如:
"thread_123:1"
"thread_123:2"
"thread_123:3"
当然维护的时候逻辑也会有所变化:
加锁逻辑
- 锁不存在 → SETNX 加锁,值 = 线程ID:1
- 锁存在
-
- 是自己线程 → 重入次数 +1
- 不是自己线程 → 获取锁失败
解锁逻辑
- 只能解自己的锁
- 重入次数 -1
- 减到 0 → 真正删除锁
这里需要用lua脚本来实现加锁和解锁才是正确的
如何在RedisCluster中执行lua脚本?
为什么Cluster集群模式下无法执行lua脚本?
因为 Redis Cluster 是分片集群:
- 16384 个 slot 分散在不同主节点
- 不同 key 会被 hash 到不同节点
- Lua 脚本必须在一个节点上原子执行
假设你的lua脚本操作了2个不同节点的key,会直接报错
redis.call('get', 'key1')
redis.call('set', 'key2')
解决方法:使用HashTag
HashTag 是个什么东西?
HashTag其实不是什么新技术,只是一个命名规则
正常情况下,Redis Cluster 放key的规则:
slot = CRC16(key) % 16384
比如:
order:100 → 算哈希 → 落到 slot 567
user:100 → 算哈希 → 落到 slot 1024
item:100 → 算哈希 → 落到 slot 8888
他们的槽位不一样,节点不一样,所以当你使用lua的时候,一旦出现计算出来key存储在不同的节点就会直接报错
HashTag 做了什么?
它的规则:如果 key 里面出现 { 和 },Redis 不再对整个 key 做哈希,只对 { } 中间的那串字符串做哈希。
slot = CRC16( {中间的内容} ) % 16384
那么有了这个,我们就可以开始操作:
order:{100}:info
user:{100}:info
item:{100}:data
这样子就能把对应的key存储到同一个节点
可以发现这个技术的局限性还是很大的,在生产环境一般就是和用户Id做绑定,这样子只要是和同一个用户相关的key就都会存储在同一个节点
总结:HashTag 不是可选项,而是Redis Cluster 的唯一选择
什么情况下会出现数据库和缓存不一致的问题?
其实因为缓存和数据库是两个不同的组件,所以他们的数据操作都不会是原子性的,只要出现并发情况下就有可能出现数据不一致
比如:先删缓存再操作数据库,或是先操作数据库再删缓存
无论是哪一种都会因为你写的同时有线程在读,导致读取到旧值,然后补偿缓存的时候导致数据不一致
什么是GEO,有什么用?
GEO是专门用来存储地理位置的数据结构,其实就是一个地图左表存储+搜索引擎
底层实现:基于ZSet,把经纬度通过GeoHash算法转化成一个score,再按照score排序,就能快速的搜索附近了
基本命令:
GEOADD 集合名 经度 纬度 名称
eg: GEOADD user:location 116.481028 39.922893 "张三"
GEODIST user:location 张三 李四 km
GEORADIUS user:location 116.48 39.92 5 km
GEOPOS user:location 张三
GEO优点就是能方便地实现查找附近地人地功能,但是缺点就是只适合小规模,低并发地场景,因为redis的数据主要存储在内存,并且是单线程查询
什么是Redis的Pipeline,和事务有什么区别?
Pipeline 就是命令打包工具,可以把多条指令一次性打包发送,节省掉中途多次网络IO
对应的代码:
redisTemplate.executePipelined(new SessionCallback<Object>() {
@Override
public <K, V> Object execute(RedisOperations<K, V> operations) throws DataAccessException {
for (String key : cacheKeyMap.keySet()) {
redisTemplate.expire(key, 1, TimeUnit.DAYS);
}
return null;
}
});
和事务的区别:
事务:
- 保证 原子性
- 中间被修改则 全部不执行
- 一次性执行
- 目的:保证数据安全
Pipeline:
- 不保证原子性
- 中间可以被其他命令插入
- 命令是顺序执行,但不是原子的
- 目的:提速、减少网络开销
最主要的区别就是事务保证原子性,但是Pipeline不保证,并且Pipeline直接打包发送,只有一次IO,事务会有多次IO
相同点:无论是Pipeline还是事务,一个命令执行失败,其它命令继续执行,不会回滚
什么是Redis的渐进式rehash
rehash 就是重新计算哈希值,把数据搬到新的数组里
redis的底层和HashMap差不多,都是数组+链表(key的存储)
为什么需要渐进式?
如果redis的哈希表很大,一次性rehash会导致卡死主线程,导致业务卡顿,所以redis不能像HashMap那样一次性搬完
所以解决方法就是一次挪一点,分批迁移
具体流程:
Redis 用 两个 hash 表:
- ht[0]:旧表
- ht[1]:新表(扩容时才用)
还有一个下标:rehashidx,初始 = -1(不在 rehash)
开始 rehash 后从 0 往后走,表示 "搬到第几个桶了"。
当触发扩容条件时:元素个数 ≥ 哈希表长度(或者超过负载因子)
ht [1] 容量 = ht [0] × 2
rehashidx = 0,开始渐进式搬迁
迁移过程:
每次访问的时候( 增 / 删 / 查 / 改 )
顺带 把 ht [0] 里 rehashidx 位置的桶,全部搬到 ht [1],然后 rehashidx++
一次只搬一个桶,非常快,不会阻塞。慢慢搬、慢慢搬...... 直到 ht [0] 搬空。
查询数据的时候怎么办?
rehash 期间:
- 先查 ht[0]
- 找不到再查 ht[1]
保证数据不会丢。
新增数据就统一往ht[1]写
搬迁完成后
- ht [0] 释放
- ht [1] 变成新的 ht [0]
- ht [1] 置空
- rehashidx = -1结束。
什么是Redis的数据分片?
数据分片就是当数据比较多,单个节点存储不下的时候,就会把数据切分成很多个小块,分散在不同的redis节点上
Redis Cluster 怎么分片?
用 哈希槽(slot):
-
总共 16384 个槽(0~16383)
-
集群里每个主节点负责一部分槽
-
一个 key 属于哪个槽,计算公式固定:
slot = CRC16(key) % 16384
客户端找到槽 → 找到节点 → 访问。
Redis Cluster将整个数据集划分为16384个槽,为什么是16384呢,这个数字有什么特别的呢?
16384 是 2^14 ,选择这个是从几个方面考量的:
- 心跳包大小合适
- 集群节点不会超 1000 个
- 位运算快,便于计算、迁移、路由
CRC16 是什么?
一个快速哈希算法,能把 key 转成一个 16 位数字
Redis 使用 CRC16 对 key 做哈希,再对 16384 取模得到槽位。
分片的好处:
- 水平扩展:加机器就能扩容
- 高并发:多节点并行扛流量
- 高可用:一个节点挂了不影响全局
- 负载均衡:key 均匀分散
什么是RedLock,他解决了什么问题?
就是用来解决redis单节点故障问题的
- 在使用单节点实现分布式锁时,如果这个redis挂掉了,所有客户端都会出现无法获取锁的现象
- 那假设是集群模式下,我们先给master节点加锁,这个时候还没来得及给其它节点同步就宕机了,那么新的主节点选举出来后,依然可以加锁,这个时候就出现并发安全问题
RedLock 的思想就是,需要多个redis节点,每次加锁的时候必需同时在这么多台节点上加锁,如果能做到 2N+1/2 个节点加锁成功,那么就认为本次加锁是成功的
主要解决了,单节点宕机造成服务瘫痪问题以及主从切换后锁丢失问题
什么是大Key问题,如何解决?
什么是大key?
满足下面任意一个就是 Big Key:
- String :value 体积太大(一般 > 5MB 就算)
- List/Hash/Set/ZSet:元素个数太多(比如 > 10000 个)
也就是:占空间大、元素多、个头大的 key,就是 Big Key。
BigKey 的危害:
- 阻塞主线程读写 / 删除大 Key 非常耗时,Redis 单线程会被卡住,导致请求延迟飙升。
- 内存不均集群里某个节点特别大,数据倾斜,容易 OOM。
- 迁移困难集群扩容、槽迁移时大 Key 搬运极慢,甚至失败。
- 过期删除卡顿 大 Key 过期删除时会造成 长时间阻塞。
- 备份恢复慢RDB/AOF 更大,加载更慢。
发现 Big Key
redis-cli --bigkeys
只保留最大值:全程只记录每种类型最大的那 1 个 Key,不保存所有 Key 数据,内存开销极低
怎么解决 Big Key?
- 拆分
- 把一个大 Key 拆成很多小 Key
- 例如:
-
- user:1:info → 拆成 user:1:base、user:1:order、user:1:score
- Hash 结构可以按 field 分段存储
- 分批删除,不要一次性 DEL
- Big Key 直接 DEL 会阻塞 Redis
- 要用 渐进式删除:
-
- Hash:HSCAN + HDEL
- List:LTRIM
- Set:SSCAN + SREM
- 业务优化
- 不要存大文本、大图片到 Redis(放对象存储)
- 不要一次性存超长列表
- 合理设置 TTL,自动清理冷数据
- 单独实例隔离
- 把大 Key 扔到单独的 Redis 实例,不影响核心业务。
什么是缓存击穿、缓存穿透、缓存雪崩?
缓存击穿:
热点key过期,导致大量请求打到数据库,数据库压力剧增(key存在,只是缓存过期)
解决方法:
- 当出现缓存击穿的时候,通过互斥锁确保只让一个线程去构建缓存,其它线程阻塞或快速失败
- 热点key永不过期,这个可以使用一些第三方云redis,一些有内置检测热点key并自动续期的功能
缓存穿透:
查询一个根本不存在的key,导致每次都去查询数据库
解决方法:
- 当查询到不存在的key的时候就缓存null值,或是对象
- 布隆过滤器做前置判断,但是布隆过滤器不一定准确,它说存在不等于真的存在,依然需要搭配缓存null值一起使用
缓存雪崩:
大量key同一时间过期,或是redis宕机,说白了就是大量缓存瞬间起不了作用了,导致大量请求落库
解决方法:
- 给key过期时间加随机偏移量,避免相同TTL
- 部署redis集群模式,避免单点故障
- 多级缓存,可以加一个本地缓存之类的,能抗一会
- 限流、降级,当redis不管用的时候,避免大量请求进到数据库
什么是热Key问题,如何解决热key问题
热key:某个key热点很高,导致把单个redis节点打满,大量读写集中在单个redis,会导致流量倾斜,并且该节点的其它key会被阻塞
典型场景:明星热搜、爆款商品、热点新闻、秒杀活动。
热 Key 有什么危害?
- 流量倾斜:集群负载不均,某个节点被打满
- 网卡打满:大 key + 热 key 尤其危险
- Redis 阻塞 / 超时
- 雪崩风险:热 key 所在节点挂掉,流量直接砸向 DB
怎么发现热 Key?
- 凭业务经验提前预判(秒杀、活动)
- redis-cli --hotkeys(Redis 4.0.3+ 支持)
- 客户端 / 代理层统计访问次数
- 监控 Redis 节点 CPU、流量突增
怎么解决热key?
- 多级缓存(最常用、最有效)
- 在应用服务器加 本地缓存(Caffeine/Cache)热 Key 直接在本地返回,根本不请求 Redis。
- 结构:本地缓存 → Redis → DB
- 优点:
-
- 极快
- 极大减轻 Redis 压力
- 热 Key 拆分(分片)
- 把一个热 Key 拆成多个:hot_key:0、hot_key:1、hot_key:2...
- 请求时随机访问一个,把压力分散到不同节点。
- 适合:热点列表、热点内容、不需要强一致的数据。
- 热 Key 多副本备份
- 把同一个热 Key 写到多个 Redis 实例 / 节点,让请求分散到不同机器。
- 类似 "一主多备读热点"。
- 限流 + 降级
- 对热 Key 接口做限流,防止瞬间流量打垮服务。
为什么Lua脚本可以保证原子性?
在数据库中,事务的ACID中原子性指的是"要么都执行要么都回滚"。在并发编程中,原子性指的是"操作不可拆分、不被中断"。
redis是一个数据库,同时也是一个支持并发编程的系统,但是它支持的是并发编程的原子性,不支持数据库回滚的原子性
原子性:
Lua脚本可以保证原子性,因为Redis会将Lua脚本封装成一个单独的 事务,而这个单独的事务会在Redis客户端运行时,由Redis服务器自行处理并完成整个事务,如果在这个进程中有其他客户端请求的时候,Redis将会把它暂存起来,等到 Lua 脚本处理完毕后,才会再把被暂存的请求恢复。
所以原理就是把Lua脚本当成事务来执行,事务执行的时候会阻塞其它客户端的命令请求
Lua脚本语言的优点:
- 高效性:Lua脚本语言的解释器能够高效的执行脚本,它运行快速,可以实现复杂的程序功能。
- 简单性:Lua脚本语言是一种小巧的编程语言,它只有一小部分的内部机制,易于学习,一直用代码可以轻松实现复杂程序功能。
- 可移植性:Lua脚本语言可以在多种操作系统上运行,写好的脚本只要移植到其它系统上就可以运行,可以在不同操作系统上使用,可以节省时间和成本。
- 灵活性:Lua脚本语言具有高度的灵活性,可以实现高效灵活的软件架构,帮助开发者更加精准的实现复杂的需求。
- 安全性:Lua脚本语言的安全性良好,可以保护开发者的代码不受恶意的攻击和恶意的破坏。
为什么Redis6.0引入了多线程?
为什么单线程Redis还不够快?
redis的性能瓶颈不是再cpu计算,而是再网络IO读写
Redis6.0多线程多了什么?
- 读 socket 数据、解析命令(多线程并行)
- 写响应回客户端(多线程并行)
但是执行命令的时候依然是单线程执行
为什么命令执行还是使用单线程?
为了线程安全
- 不用改复杂的锁机制
- 不用考虑并发安全
- 原有逻辑几乎不用动
- 避免死锁、数据竞争
其实是在原来的基础上引入多线程,原本是IO多路复用,然后监听到数据之后用一个线程来解析数据,但是有一个问题就是,还是只有一个线程在进行读数据、解析协议、发送响应,这些都是CPU 操作,单线程再快也只能串行。
所以多线程就是把这部分并行化
为什么Redis不支持回滚?
具体原因:
- Redis定位是缓存,不需要强一致性
- 回滚操作太复杂,会拖慢Redis的性能
- Redis提供的事务是并发编程层面的事务,也就是保证执行的过程中不会被打断,而不是可回滚的这种事务
- 大多数命令都是原子性的,就算复杂需求也可以使用lua脚本保证,无需回滚
为什么Redis设计成单线程也能这么快?
- 基于内存操作,速度极快;
- 数据结构简单高效;
- 单线程避免了线程切换和锁竞争,减少 CPU 开销;
- 使用 IO 多路复用(epoll),一个线程可以同时处理大量网络连接,非阻塞、高并发。
为什么ZSet既能支持高效的范围查询,还能以O(1)复杂度获取元素权重值?
typedef struct zset {
dict *dict; // 哈希表
zskiplist *zsl; // 跳表
} zset;
因为一个数据会存储两份
哈希表:
- key:member
- value:score
哈希表用来快速查询每一个元素的score
跳表:按照score从小到大进行排序, 支持 高效排序与范围查询,时间复杂度 O(logN)
为什么需要延迟双删,两次删除的原因是什么?
延迟双删就是 先删缓存------更新DB------再次删除缓存
先删除缓存相较于先更新DB有一个优势:
先删除缓存,后续如果DB更新失败不会导致什么后果,只是缓存被删除了,没有数据不一致出现
如果先更新DB再删除缓存会存在一个问题,假设DB更新成功但是缓存删除失败,不就出现数据不一致了嘛,而且可能会出现很长时间的数据不一致
所以我们会更倾向于先删除缓存再更新DB,然后通过一个延迟任务再次删除缓存,从而实现延迟双删,最大限度避免数据不一致性
Redission如何实现的布隆过滤器?原理是什么?
BitMap 是什么?
底层其实就是string,也就是SDS,它其实就是一个超大的位数组,每一位都只能是0/1
常用场景:
-
用户签到、日活统计(BitMap)
-
布隆过滤器
-
在线状态、黑名单
SETBIT key offset 1 # 设置某一位为1
GETBIT key offset # 获取某一位
BITCOUNT key # 统计有多少个1
BITOP AND/OR/XOR/NOT dest key1 key2 # 位运算
Redisson底层的布隆过滤器就是基于BitMap 来实现的。
就是把我们的数据通过Hash运算之后,判断要把哪些位设置为1
java
private RBloomFilter<String> userPhoneBloomFilter;
/**
* 布隆过滤器初始化
*/
@PostConstruct
public void initBloomFilter() {
// 初始化Redisson布隆过滤器
userPhoneBloomFilter = redissonClient.getBloomFilter("user:phone:bloom:filter");
userPhoneBloomFilter.tryInit(1_000_000L, 0.0001);
// 初始化数据(从索引表把所有的数据加载到布隆过滤器里面)
List<String> allUserPhones = userPhoneMapper.selectAllUserPhonesFromIndex();
allUserPhones.forEach(userPhoneBloomFilter::add);
}