1. 缓存三兄弟
1.1 缓存穿透
缓存穿透是指,当服务收到大量对不存在的数据的请求时,由于数据不存在,每次都不会命中缓存,都会去数据库中查询,导致数据库压力过大。
解决方案:
- 缓存空对象,将不存在的数据也加到缓存中,存为 null 值。该方案的缺点在于,如果每次请求的数据都不一样就没有意义了。
- 布隆过滤器,在读取数据前先通过布隆过滤器判断数据是否存在,如果不存在则直接返回空值,如果存在再访问数据。
1.2 缓存击穿
缓存击穿是指,当某个热点数据在缓存中过期时,大量请求会同时访问数据库,导致数据库压力过大。
解决方案:
- 如果已知哪些数据是热点数据,可以直接将其设置为永不过期。
- 如果追求强一致性,可以使用互斥锁来保证只有一个线程去访问数据库,访问期间阻塞线程,性能可能会比较差。
- 如果只追求最终一致性,可以采用逻辑过期的方式,即不使用 redis 的数据过期功能,而是自行在数据中维护一个过期时间,每次访问时进行检查和更新。如果发现数据过期了,则加锁从数据库中取数据,但不阻塞线程,而是返回过期的数据。等读取到数据库的数据后,再更新到缓存中,并重新设置过期时间。
1.3 缓存雪崩
缓存雪崩是指,大量的缓存数据在同一时间过期,大量请求无法命中缓存,直接访问数据库,导致数据库压力过大。原因可能是同一批数据被同时放入缓存中,也可能是某台缓存服务宕机了。
解决方案:
- 在设置过期时间时,添加一个随机波动,防止同一批数据同时过期。
- 如果缓存采用的是 Redis 集群,可以想办法提高集群的可用性,避免缓存大量失效的情况出现。
- 采用多级缓存,尽量让缓存失效时,请求不会同时集中到数据库上。
2. 双写一致性
双写一致性指的是写入数据时,如何保证 Redis 和 MySQL 中的数据一致性。
不管是先写入 Redis,还是先写入 MySQL,都有可能出现数据不一致,这个问题和并行编程中的竟态条件是一样的,如果要保证强一致性的话,只能使用加锁的方式。先写入 Redis,然后在访问 MySQL 前,在 Redis 中用 setnx 尝试获取一个锁,如果获取成功,则说明没有其他线程在写入 MySQL,可以继续执行,否则等待一段时间后重试。
但如果允许短暂的数据不一致,则有多种可用的方案,目标都是确保数据最终状态是最新的,但不保证期间数据是最新的。
比较原始的方案就是延时双删,即先删除 Redis 中的数据再写入 MySQL,一段后再删除 Redis 中的数据。如果第二次删除失败,则按照预先设定的回避策略,一段时间后重试。
之所以要双删,是因为如果只进行一次删除可能会出现刷新后读取到的数据还是老数据的情况,因此要进行延迟,确保数据已写入 MySQL 中,然后再删除一次缓存,确保读取到的数据是最新的。
延时双删的缺点在与延迟的时间不好控制,如果 SQL 执行时间过长,超出了延迟的时间,依然会导致出现最终数据的不一致。但如果简单加长延迟时间,又会导致期间数据不一致的时间过长。不过如果服务对数据不一致的时间的容忍度很高,使用延时双删还是可行的。
因此更好的方案是借助消息队列服务,在 MySQL 更新后发出一个消息,让该消息触发 Redis 的删除操作。可以采用日志增量订阅和消费服务,例如 Canal,来发送这个消息。
3. 持久化
有两种方案,RDB 和 AOF。
3.1 RDB (Redis Database) 对整个 Redis 的内存数据做快照,以二进制数据的形式储存,默认会进行压缩。 采用写时复制策略,只储存开始快照时的内存数据,在快照期间对内存数据的修改不会被记录到 RDB 文件中。 默认开启,可以定时触发,也可以由客户端手动触发。
优点:
- 文件体积小
- 恢复速度快
缺点:
- 生成快照期间对系统资源的占用高
- 数据丢失多
3.2 AOF (Append Only File) 记录 Redis 执行的每一条写命令,以文本的形式记录到文件中。 通常采用每秒写入,虽然也有实时写入,但缓存数据不需要这么高的完整性。 会定期进行重写,以清除对同一个值的重复写命令。
优点:
- 丢失数据少,甚至可以做到不丢失数据
- 对系统资源的占用低
缺点:
- 文件体积大
- 恢复速度慢
实际使用时,通常采用混合模式,通常优先采用 AOF 进行恢复,如果 AOF 文件损坏,则使用 RDB 进行恢复。如果重启速度很重要,也可能优先使用 RDB 进行复原。
4. 数据过期策略
数据过期策略是指如何查找已过期的数据。
惰性删除 只在数据被访问时才判断是否过期,过期则删除
定期删除 定期抽取一定数量的 key,检查是否过期,过期则删除,有两种抽取频率。
SLOW 模式 定期执行,默认为 1 秒十次,一次抽取的 key 较多
FAST 模式 每次事件循环执行一次,两次间的间隔基本不超过 2ms,一次抽取的 key 较少
有两个可配置的参数,timelimit
用于限制一次删除操作的最长执行时间,config_cycle_acceptable_stale
表示目标过期 key 占比,当过期 key 占比低于目标则停止删除操作.
5. 数据淘汰策略
数据淘汰策略是指当缓存占用超过上限一定比例时,将部分缓存数据删除以腾出空间。
共八种淘汰策略,后六种策略其实是 random、LRU、LFU 和 allkeys、volatile 两种目标集合的组合。
策略 | 说明 |
---|---|
noeviction | 拒绝淘汰,内存不足直接报错 |
volatile-ttl | 淘汰离过期时间最近的 key |
allkeys-random | 随机淘汰 |
allkeys-lru | 淘汰最后读取时间最久的 key |
allkeys-lfu | 淘汰最近最少使用的 key |
volatile-random | 从配置了过期时间的键中随机淘汰 |
volatile-lru | 从配置了过期时间的键中淘汰最后读取时间最久的 key |
volatile-lfu | 从配置了过期时间的键中淘汰最近最少使用的 key |
所有淘汰策略均采用采样近似的方法,都是抽取部分 key,按 TTL、LRU、LFU 从其中淘汰一个。
LRU 会在每个 key 中记录一个时间戳,表示最后访问的时间。
LFU 则基于计数器实现,计数器并不记录准确次数,而是一个热度值,基于衰减因子 lfu_decay_time
和增长因子 lfu_log_factor
进行概率衰减/增长。两个因子的工作原理完全不一样,实际使用时要注意。
热度值上限为 255,在不断增长,增长因子为 100 的情况下,约要 10M 次命中才会达到上限。
LFU 在计算衰减概率时,最后访问时间同样会参与到计算中。因此 LFU 其实是综合考虑了 LRU 的。
LRU 和 LFU 均基于 hash 表 + 链表实现,不过 LFU 的更新逻辑会更复杂一些。
当业务的访问较为均匀时,采用 LRU 一般即可满足需求。
当业务有很强的时效性,某些数据会突然成为热点数据时,LRU 可能会导致热点数据被淘汰,应当采用 LFU。
但是当数据量特别大,淘汰速度跟不上新增速度时,最终 redis 还是会采用 allkeys-random 进行淘汰,此时不管用什么策略都没用了。
6. 分布式锁
当服务以分布式方式部署时,单机的锁无法满足需求,需要基于外部的 Redis 服务实现分布式锁,通常使用 setnx 来获取锁。
为了防止某个服务挂掉后,锁无法释放,需要给锁设置过期时间。但如果一个服务执行时间过长,可能会出现在执行完前锁就被释放,因此需要一个看门狗线程来定时给锁续期。
如果要实现可重入的锁,可以在值中利用 hash 结构记录线程 id 和重入次数。
如果基于 Redis 集群使用分布式锁,为了防止主节点挂了后,主从不一致的情况,可以采用 Redlock 算法。
不过一般来说都是使用 Redisson 提供好的分布式锁,已经提供了常用的各种锁的实现。
7. 主从数据同步
全量复制 通过 bgsave 命令生成 RDB 文件,发送给从节点进行同步,在缓冲区记录期间发生的操作,生成一个命令日志。在从节点同步 RDB 完成后,主节点将命令日志发送给从节点执行。
增量复制 根据从节点的 offset 值,主节点发送命令日志 offset 之后的部分给从节点执行。
8. 哨兵模式
哨兵负责监控集群中各个节点的状态,当主节点挂掉后,哨兵会自动选举出新的主节点,并通知客户端。
判断主节点是否挂掉,通常采用定期发送 ping 的方式,如果某个哨兵超过一定时间未收到某个节点的响应,则认为该节点主观下线,若超过一定数量的哨兵都认为某个节点下线,则该节点客观下线。
选举新主节点时,首先会判断从节点的网络稳定性,如果累计断开时间过长直接排除。然后判断配置的 slave-priority,越小优先级越高。再判读 offset 值,越新的优先级越高。如果上面的判断都相同,则选 id 最小的。
8.1 集群脑裂
如果 master 实际没挂,哨兵集群却认为它客观下线了,选出了新的 master。但是原先的 master 还在工作,出现了多个 master,此时就出现了数据的不一致。
如果选举配置没问题,脑裂通常是因为主节点的网络分区出现问题,此时客户端可以访问主节点,但是哨兵无法访问主节点,且主节点通常也无法访问从节点。
解决方案有:
- 设置 min-replicas-to-write,当主节点发现从节点数量少于该值时,就拒绝提供服务。
- 设置 min-replicas-max-lag,单位为秒,当有从节点超过该时间未成功同步时,主节点拒绝提供服务。
目的都是为了当集群不同部分被隔开时,避免数据丢失。
9. 分片集群
集群中有多个主节点,每个主节点负责一部分数据,管理几个从节点。
主节点间互相 ping 来确保监测彼此的健康状态。
客户端可以访问任意一个节点来访问集群,被访问的节点会根据 hash slot 转发给正确的主节点。
通过给主节点设置 hash slot,将数据分片,每个主节点负责一部分 hash slot,当客户端访问时,根据 key 计算出对应的 hash slot,然后访问对应的主节点。
10. Redis 是单线程的,为什么还这么快?
C 语言编写,所有数据操作都在内存中进行。
Redis 作为内存数据库,计算任务少,处理速度的瓶颈不在于 CPU,而在于网络 I/O 速度,因此单线程的事件循环不影响效率。如果采用多线程,还会增加线程切换的开销,同时还要考虑线程安全问题。
为了让单线程支持处理多个客户端连接,Redis 采用 I/O 多路复用,默认方案为 epoll,可以同时监听多个 socket,当某个 socket 可读时,再进行实际的读写操作。