Redis 面试之应用篇
Redis 面试之应用篇
缓存
【中级】如何避免缓存雪崩、缓存击穿、缓存穿透?
::: details 要点
- 缓存击穿:指某个热点数据在缓存中失效,导致大量请求直接访问数据库。此时,由于瞬间的高并发,可能导致数据库崩溃。
- 缓存穿透:指查询一个不存在的数据,缓存中没有相应的记录,每次请求都会去数据库查询,造成数据库负担加重。
- 缓存雪崩:指多个缓存数据在同一时间过期,导致大量请求同时访问数据库,从而造成数据库瞬间负载激增。
解决方案
缓存击穿:
- 使用互斥锁,确保同一时间只有一个请求可以去数据库查询并更新缓存。
- 热点数据永不过期。
缓存穿透:
- 使用布隆过滤器,过滤掉不存在的请求,避免直接访问数据库。
- 对查询结果进行缓存,即使是不存在的数据,也可以缓存一个标识,以减少对数据库的请求。
缓存雪崩:
- 采用随机过期时间策略,避免多个数据同时过期。
- 使用双缓存策略,将数据同时存储在两层缓存中,减少数据库直接请求。
:::
【中级】如何保证缓存与数据库的数据一致性?
::: details 要点
一般来说,系统如果不是严格要求缓存和数据库保持一致性的话,尽量不要将读请求和写请求串行化。串行化可以保证一定不会出现数据不一致的情况,但是它会导致系统的吞吐量大幅度下降。
一般来说缓存的更新有两种情况:
- 先删除缓存,再更新数据库。
- 先更新数据库,再删除缓存。
为什么是删除缓存,而不是更新缓存呢?
你可以想想当有多个并发的请求更新数据,你并不能保证更新数据库的顺序和更新缓存的顺序一致,那就会出现数据库中和缓存中数据不一致的情况。所以一般来说考虑删除缓存。
:::
【中级】有哪些常见的内存淘汰算法?
::: details 要点
缓存淘汰的类型:
- 基于空间 - 设置缓存空间大小。
- 基于容量 - 设置缓存存储记录数。
- 基于时间
- TTL(Time To Live,即存活期)缓存数据从创建到过期的时间。
- TTI(Time To Idle,即空闲期)缓存数据多久没被访问的时间。
缓存淘汰算法:
- FIFO - 先进先出。在这种淘汰算法中,先进入缓存的会先被淘汰。这种可谓是最简单的了,但是会导致我们命中率很低。试想一下我们如果有个访问频率很高的数据是所有数据第一个访问的,而那些不是很高的是后面再访问的,那这样就会把我们的首个数据但是他的访问频率很高给挤出。
- LRU - 最近最少使用算法。在这种算法中避免了上面的问题,每次访问数据都会将其放在我们的队尾,如果需要淘汰数据,就只需要淘汰队首即可。但是这个依然有个问题,如果有个数据在 1 个小时的前 59 分钟访问了 1 万次(可见这是个热点数据), 再后一分钟没有访问这个数据,但是有其他的数据访问,就导致了我们这个热点数据被淘汰。
- LFU - 最近最少频率使用。在这种算法中又对上面进行了优化,利用额外的空间记录每个数据的使用频率,然后选出频率最低进行淘汰。这样就避免了 LRU 不能处理时间段的问题。
这三种缓存淘汰算法,实现复杂度一个比一个高,同样的命中率也是一个比一个好。而我们一般来说选择的方案居中即可,即实现成本不是太高,而命中率也还行的 LRU。
:::
分布式锁
【中级】实现分布式锁需要解决哪些问题?
::: details 要点
分布式锁的解决方案大致有以下几种:
- 基于数据库实现
- 基于缓存(Redis,Memcached 等)实现
- 基于 Zookeeper 实现
分布式锁的实现要点大同小异,仅在实现细节上有所不同。
实现分布式锁需要解决以下目标:
- 互斥 - 分布式锁必须是独一无二的,表现形式为:向数据存储插入一个唯一的 key,一旦有一个线程插入这个 key,其他线程就不能再插入了。
- 避免死锁 - 在分布式锁的场景中,部分失败和异步网络这两个问题是同时存在的。如果一个进程获得了锁,但是这个进程与锁服务之间的网络出现了问题,导致无法通信,那么这个情况下,如果锁服务让它一直持有锁,就会导致死锁的发生。
- 常见的解决思路是引入超时机制,即成功申请锁后,超过一定时间,锁失效(删除 key)。
- 超时机制解锁了死锁问题,但又引入了一个新问题:如果应用加锁时,对于操作共享资源的时长估计不足,可能会出现:操作尚未执行完,但是锁没了的尴尬情况。为了解决这个问题,需要引入锁续期机制:当持有锁的线程尚未执行完操作前,不断周期性检测锁的超时时间,一旦发现快要过期,就自动为锁续期。
- ZooKeeper 分布式锁避免死锁采用了另外一种思路—— Watch 机制。
- 可重入 - 可重入指的是:同一个线程在没有释放锁之前,能否再次获得该锁。其实现方案是:只需在加锁的时候,记录好当前获取锁的节点 + 线程组合的唯一标识,然后在后续的加锁请求时,如果当前请求的节点 + 线程的唯一标识和当前持有锁的相同,那么就直接返回加锁成功;如果不相同,则按正常加锁流程处理。
- 公平性 - 当多个线程请求同一锁时,它们必须按照请求的顺序来获取锁,即先来先得的原则。锁的公平性的实现也非常简单,对于被阻塞的加锁请求,我们只要先记录好它们的顺序,在锁被释放后,按顺序颁发就可以了。
- 重试 - 有时候,加锁失败可能只是由于网络波动、请求超时等原因,稍候就可以成功获取锁。为了应对这种情况,加锁操作需要支持重试机制。常见的做法是,设置一个加锁超时时间,在该时间范围内,不断自旋重试加锁操作,超时后再判定加锁失败。
- 容错 - 分布式锁若存储在单一节点,一旦该节点宕机或失联,就会导致锁失效。将分布式锁存储在多数据库实例中,加锁时并发写入
N
个节点,只要N / 2 + 1
个节点写入成功即视为加锁成功。代表有:Redis 官方提供的 RedLock。
:::
【中级】Redis 中如何实现分布式锁?
::: details 要点
基础实现
- 加锁:
SET key val NX PX 30000
(NX
:仅当 key 不存在时写入,PX
:超时时间,毫秒)。 - 解锁:
DEL key
(直接删除 key 释放锁)。
避免死锁
- 问题:节点宕机或异常导致锁无法释放。
- 方案:加锁时设置超时时间(
PX/EX
),到期自动删除。 - 原子性:使用
SET key val NX PX
替代setnx + expire
,避免组合命令非原子性问题。
超时续期(WatchDog)
- 问题:业务未执行完,锁已过期。
- 方案:启动定时任务检测锁剩余时间,自动续期(如 Redisson 的 WatchDog 机制)。
安全解锁
- 问题:其他节点误删锁。
- 方案:
- 加锁时写入唯一标识(如 UUID)。
- 解锁时校验标识,匹配才删除(使用 Lua 脚本保证原子性):
1
2
3
4
5if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
自旋重试
- 问题:网络波动导致加锁失败。
- 方案:在超时时间内循环重试加锁(伪代码示例见原内容)。
其他问题
- 不可重入:同一线程无法重复获取同一把锁(可通过 Redisson 等客户端支持)。
- 单点问题:主从切换可能导致锁失效(可用 RedLock 算法,但存在争议)。
:::
【中级】Red Lock 分布式锁的原理是什么?
::: details 要点
核心机制:
- 多节点写入:同时向多个 Redis 主节点(≥5 个)加锁
- 多数派原则:半数以上节点加锁成功才算成功
- 耗时控制:总加锁时间必须小于锁的过期时间
- 强制清理:解锁时向所有节点发起请求
注意事项
- 网络延迟:多节点操作会增加耗时风险
- 性能代价:相比单节点锁性能更低
- 实现复杂度:需要精确控制时间和节点状态
:::
【中级】Redisson 分布式锁的原理是什么?
::: details 要点
Redisson 是一个流行的 Redis Java 客户端,它基于 Netty 开发,并提供了丰富的扩展功能,如:分布式计数器、分布式集合、分布式锁 等。
Redisson 支持的分布式锁有多种:Lock, FairLock, MultiLock, RedLock, ReadWriteLock, Semaphore, PermitExpirableSemaphore, CountDownLatch,可以根据场景需要去选择,非常方便。一般而言,使用 Redis 分布式锁,推荐直接使用 Redisson 提供的 API,功能全面且较为可靠。
Redisson 分布式锁的实现要点:
- 锁的获取:Redisson 使用 Lua 脚本,利用
exists + hexists + hincrby
命令来保证只有一个线程能成功设置键(表示获得锁)。同时,Redisson 会通过pexpire
命令为锁设置过期时间,防止因宕机等原因导致锁无法释放(即死锁问题)。 - 锁的续期:为了防止锁在持有过程中过期导致其他线程抢占锁,Redisson 实现了一种叫做 Watch Dog(看门狗) 的锁自动续期的功能。持有锁的线程会定期续期,即更新锁的过期时间,确保任务没有完成时锁不会失效。
- 锁的释放:锁释放时,Redisson 也是通过 Lua 脚本保证释放操作的原子性。利用
hexists + del
确保只有持有锁的线程才能释放锁,防止误释放锁的情况。Lua 脚本同时利用 publish 命令,广播唤醒其它等待的线程。 - 可重入锁:Redisson 支持可重入锁,持有锁的线程可以多次获取同一把锁而不会被阻塞。具体是利用 Redis 中的哈希结构,哈希中的 key 为线程 ID,如果重入则 value +1,如果释放则 value -1,减到 0 说明锁被释放了,则 del 锁。
:::
消息队列
【中级】Redis 如何实现消息队列?
::: details 要点
Redis 可以做消息队列吗?
Redis 有哪些实现消息队列的方式?
先说结论:可以是可以,但不建议使用 Redis 来做消息队列。和专业的消息队列相比,还是有很多欠缺的地方。
基于 List 实现消息队列
Redis 2.0 之前,如果想要使用 Redis 来做消息队列的话,只能通过 List 来实现。
通过 RPUSH/LPOP
或者 LPUSH/RPOP
即可实现简易版消息队列:
1 | 生产者生产消息 |
不过,通过 RPUSH/LPOP
或者 LPUSH/RPOP
这样的方式存在性能问题,我们需要不断轮询去调用 RPOP
或 LPOP
来消费消息。当 List 为空时,大部分的轮询的请求都是无效请求,这种方式大量浪费了系统资源。
因此,Redis 还提供了 BLPOP
、BRPOP
这种阻塞式读取的命令(带 B-Blocking 的都是阻塞式),并且还支持一个超时参数。如果 List 为空,Redis 服务端不会立刻返回结果,它会等待 List 中有新数据后再返回或者是等待最多一个超时时间后返回空。如果将超时时间设置为 0 时,即可无限等待,直到弹出消息
1 | 超时时间为 10s |
List 实现消息队列功能太简单,像消息确认机制等功能还需要我们自己实现,最要命的是没有广播机制,消息也只能被消费一次。
基于发布订阅功能实现消息队列
Redis 2.0 引入了发布订阅 (pub/sub) 功能,解决了 List 实现消息队列没有广播机制的问题。
Redis 发布订阅 (pub/sub) 功能
pub/sub 中引入了一个概念叫 channel(频道),发布订阅机制的实现就是基于这个 channel 来做的。
pub/sub 涉及发布者(Publisher)和订阅者(Subscriber,也叫消费者)两个角色:
- 发布者通过
PUBLISH
投递消息给指定 channel。 - 订阅者通过
SUBSCRIBE
订阅它关心的 channel。并且,订阅者可以订阅一个或者多个 channel。
pub/sub 既能单播又能广播,还支持 channel 的简单正则匹配。不过,消息丢失(客户端断开连接或者 Redis 宕机都会导致消息丢失)、消息堆积(发布者发布消息的时候不会管消费者的具体消费能力如何)等问题依然没有一个比较好的解决办法。
基于 Stream 实现消息队列
为此,Redis 5.0 新增加的一个数据结构 Stream
来做消息队列。Stream
支持:
- 发布 / 订阅模式
- 按照消费者组进行消费(借鉴了 Kafka 消费者组的概念)
- 消息持久化( RDB 和 AOF)
- ACK 机制(通过确认机制来告知已经成功处理了消息)
- 阻塞式获取消息
Stream
的结构如下:
这是一个有序的消息链表,每个消息都有一个唯一的 ID 和对应的内容。ID 是一个时间戳和序列号的组合,用来保证消息的唯一性和递增性。内容是一个或多个键值对(类似 Hash 基本数据类型),用来存储消息的数据。
这里再对图中涉及到的一些概念,进行简单解释:
Consumer Group
:消费者组用于组织和管理多个消费者。消费者组本身不处理消息,而是再将消息分发给消费者,由消费者进行真正的消费last_delivered_id
:标识消费者组当前消费位置的游标,消费者组中任意一个消费者读取了消息都会使 last_delivered_id 往前移动。pending_ids
:记录已经被客户端消费但没有 ack 的消息的 ID。
Stream
使用起来相对要麻烦一些,这里就不演示了。
总的来说,Stream
已经可以满足一个消息队列的基本要求了。不过,Stream
在实际使用中依然会有一些小问题不太好解决比如在 Redis 发生故障恢复后不能保证消息至少被消费一次。
综上,和专业的消息队列相比,使用 Redis 来实现消息队列还是有很多欠缺的地方比如消息丢失和堆积问题不好解决。因此,我们通常建议不要使用 Redis 来做消息队列,你完全可以选择市面上比较成熟的一些消息队列比如 RocketMQ、Kafka。不过,如果你就是想要用 Redis 来做消息队列的话,那我建议你优先考虑 Stream
,这是目前相对最优的 Redis 消息队列实现。
相关阅读:Redis 消息队列发展历程 - 阿里开发者 - 2022
:::
延时任务
【中级】如何基于 Redis 实现延时任务?
::: details 要点
基于 Redis 实现延时任务的功能无非就下面两种方案:
- Redis 过期事件监听
- Redisson 内置的延时队列
Redis 过期事件监听的存在时效性较差、丢消息、多服务实例下消息重复消费等问题,不被推荐使用。
Redisson 内置的延时队列具备下面这些优势:
- 减少了丢消息的可能:DelayedQueue 中的消息会被持久化,即使 Redis 宕机了,根据持久化机制,也只可能丢失一点消息,影响不大。当然了,你也可以使用扫描数据库的方法作为补偿机制。
- 消息不存在重复消费问题:每个客户端都是从同一个目标队列中获取任务的,不存在重复消费的问题。
:::