• 概述
  • 数据结构
  • 持久化
  • 集群
  • 事务
  • 缓存
  • 分布式锁

概述

Redis 的优缺点

Redis 的优点?为什么快?

  1. 基于内存操作:绝大部分操作和数据都在内存中,相比传统磁盘文件操作减少了IO。
  2. 高效的数据结构:优化的 String、Hash、List、Set、Zset 等数据结构
  3. 采用单线程:省去上下文切换和CPU的开销,同时不存在资源竞争,避免死锁。

    • 单线程网络请求模块使用单线程进行处理,其他模块仍用多个线程。因为 Redis 的瓶颈不是 CPU,最有可能是机器内存或网络带宽,并且单线程易于实现。
    • 多线程:Redis 6.0 以后,多线程用于处理网络数据的读写和协议解析,充分利用 CPU 资源,减少网络 I/O 阻塞带来的性能损耗。
  4. I/O多路复用:一个服务端进程(复用)同时处理多个套接字描述符(多路),根据 Socket 上的事件来选择对应的事件处理器进行处理。

Why is Redis so fast?

Redis 的缺点?为什么不做主数据库只做缓存?

  1. 内存限制:数据库容量受到物理内存的限制,不能用作海量数据的高性能读写。
  2. 数据持久化:尽管采用了数据持久化机制,如果服务器崩溃或断电,内存中数据仍可能丢失。
  3. 数据安全:不具备像主数据库一样复杂的认证和审计机制。
  4. 结构化查询:作为键值(Key-Value)数据库,对结构化查询支持较差。
  5. 事务处理:对复杂的事务无能为力,比如跨多个键的事务处理。
  6. 在线扩容:在集群容量达到上限时在线扩容会变得很复杂。

 
为什么用 Redis 而不用 Map 做缓存?

  • Map 实现的是本地缓存,其生命周期随着 JVM 的销毁而结束,且在多实例的情况下,每个实例都需要各自保存一份缓存,缓存不具有一致性;而 Redis 的分布式缓存具有一致性,各实例共用一份缓存数据。
  • Redis 可单独部署,在多个项目间共享
  • Redis 的缓存可以持久化,Map 是内存对象,程序一重启数据就没了。
  • Redis 可以用几十G内存来做缓存,Map 不行。
  • Redis 可以处理每秒百万级的并发。
  • Redis 缓存有过期机制和丰富的 API。

Redis 应用场景

  1. 缓存热点数据:缓解数据库的压力。用户在访问业务数据时,先到 Redis 中拿;如果不存在,再到 MySQL 中拿,接着把访问过的数据写入 Redis。
  2. 社交网络:Redis 的哈希、集合等数据结构能很方便的的实现排行榜、共同好友等功能;利用 Redis 原子性的自增操作,可以实现计数器的功能,比如统计用户点赞数等;也可作限速器,如秒杀场景中防止用户快速点击带来不必要的压力。
  3. 消息队列:Redis 提供了发布/订阅模式及阻塞队列功能,能够实现简单的消息队列,实现异步操作。
  4. 分布式锁:分布式场景下,无法使用单机环境下的锁对多个节点上的进程同步。可以使用 Redis 自带的 SETNXSET if Not eXists)命令或 RedLock 分布式锁实现。

数据过期策略

Redis 采用了惰性删除和定期删除相结合的过期策略:

  • 惰性删除:不主动删除过期键,访问 key 时再检测是否过期,如果过期则删除。这种方式对 CPU 友好,但如果 key 过期后一直没有使用,则在内存中永远不会释放。
  • 定期删除:每隔一段时间取出一些 key 进行检查并删除过期 key。分两种模式,SLOW 模式是定时任务,FAST 模式执行频率不固定,但两次间隔不低于 2ms。

数据淘汰策略

Redis 的内存不够用时,有 8 种策略来选择要删除的 key:

  • noeviction:默认,不淘汰任何 key,内存满时拒绝写入。
  • volatile-ttl:优先淘汰更早过期的 key。
  • volatile-random:随机淘汰设置了过期时间的 key。
  • volatile-lru:淘汰所有设置了过期时间中最近最久未使用的 key。
  • volatile-lfu:淘汰设置了过期时间中最少频率使用的 key。
  • allkeys-random:随机淘汰任意 key。
  • allkeys-lru:淘汰最近最久未使用的 key。
  • allkeys-lfu:淘汰最少频率使用的 key。

 
Redis 如何做内存优化?
尽可能的将数据模型抽象到一个哈希表里面。比如一个用户对象,不要为这个用户的名称,邮箱等设置单独的 Key,而是将这个用户的所有信息存储到一张哈希表里。

数据结构

String

底层由 SDS(简单动态字符串)实现:具有 Len 属性;拼接前会自动扩容。用于:

  • 缓存对象:JSON 等。
  • 共享Session信息:解决了分布式系统下多服务器 Session 不一致的问题。
  • 分布式锁:利用 SETNX 命令。
  • 计数器:支持原子性数值操作,用于访问次数、点赞、库存等。


Redis 3.2前,HashList 默认在元素数量小于 512 且所有值都小于 64 字节时使用压缩列表,否则分别使用哈希表双向链表Set 默认在元素数量小于 512 且都是整数时使用 整数集合,否则使用哈希表

Redis 3.2后,List 底层数据结构只由 QuickList 实现,替代了双向链表和压缩列表。Redis 7.0 中,压缩列表被废弃,由 ListPack 来实现。

Hash

O(1) 的复杂度快速查询数据,应用场景如:

  • 缓存对象:对象一般为 String + Json ,变化频繁的属性抽出来用 Hash 存储。
  • 购物车:以用户 id 为 key ,商品 id 为 field ,商品数量为 value

 
拉链法/链地址法(Chaining):当哈希冲突发生时,多个键值对被存储在同一个哈希桶Bucket)中,通过链表链接起来。

渐进式Rehash(‌惰性Rehash‌):由于 Rehash 是一个较耗时的操作,一次性重新计算所有链表在桶中的位置可能会引起客户端长时间阻塞,因此 Redis 通过渐进式方式优化:维护两个哈希表,一个是当前正在使用的表 ht[0] ,另一个是新分配的空表 ht[1] 。除了通过定时任务逐步处理部分哈希桶以外(主动Rehash),期间对哈希表操作(如查询、插入、删除)时,也会顺序将 ht[0] 中索引位置上的所有数据迁移到 ht[1] 上,最终在某个时间点所有键值对都迁移完成。触发条件如下:

负载因子 = 已存储键值对数量 / 哈希桶数量。
负载因子 ≥ 1 且没有执行 RDB 快照或 AOF 重写时,就会进行 Rehash
负载因子 ≥ 5 时,无论是否执行 RDB 快照或 AOF 重写,都会强制 Rehash

List

有序可重复集合。可以通过 LPUSHBRPOP 来实现左进右出的消息队列,数据消费者阻塞地获取列表尾部的数据。

Set

无序去重集合。可用于:

  • 共同好友、爱好等:支持交集、并集等方法。
  • 抽奖活动Set 类型有去重功能,可以保证同一个用户不会中奖两次。
Set的差集、并集和交集计算复杂度较高,数据量较大时会导致Redis实例阻塞。在主从集群中,为了避免主库被阻塞,可以选择一个从库完成聚合统计;或者把数据返回给客户端,由客户端来完成聚合统计。

Zset (Sorted Set)

带权有序去重集合。基于哈希表跳表实现,访问中间元素时间复杂度是 O(log N) 。每个元素有一个score参数。相较于List更耗内存。典型应用场景:排行榜延时队列(由 score 记录时间戳)。

Bitmap

位图。用于数据量大且二值统计的场景,如签到用户登录态资源占用

Hyperloglog

用于基数统计(如UV(Unique Visitor)统计),仅需12KB就能统计2^64个数据(正常每个数据需要8 Bytes)。基于概率实现,标准误差是0.81%。

Geospatial

存储地理空间信息,支持距离计算、范围查询等操作,可用于定位附近的人打车距离计算等。使用 GeoHash 编码方法实现经纬度到Zset中元素权重分数的转换。

Stream

有序存储序列数据,专门为消息队列设计。相较于List的特性:可自动生成全局唯一消息ID(时间戳-序列号),支持消费者组


持久化

AOF(Append-Only File)

每执行一条写操作命令,就将该命令以追加的方式写入到 AOF 文件;在恢复时,以逐一执行命令的方式来进行数据恢复。可以通过 bgrewriteaof 命令重写 AOF 文件,合并同一个 key 的多次写操作。

# 开启 AOF,默认关闭
appendonly yes
# AOF 文件名称
appendfilename "xx.aof"
# 写回策略(每条命令/每秒/操作系统控制)
appendfsync (always/everysec/no) 
# 自动重写
auto-aof-rewrite-percentage 100  # AOF 文件比上次增长超过 100% 触发重写
auto-aof-rewrite-min-size 64mb  # AOF 文件最小多大才出发重写

RDB(Redis Database Backup)

RDB 快照将某一瞬间的内存数据记录到磁盘中。bgsave 时,子进程通过拷贝主进程的页表,实现共享内存(read-only);主进程写数据时,采用 copy-on-write,拷贝一份数据进行写操作,防止出现脏数据。

# 主动触发
127.0.0.1:6379> save  # 主进程执行 RDB,阻塞所有命令
ok
127.0.0.1:6379> bgsave  # 子进程执行 RDB
Background saving started

# 被动触发(redis.conf 文件)
save 900 1  # 若 900 秒内至少有 1 个 key 被修改,则执行 bgsave

两者对比 / 结合

AOF vs RDB

  • AOF 记录执行的命令,RDB 记录内存数据。
  • AOF 文件相较于 RDB 更大,需要定期重写。
  • AOF 恢复数据很慢,因为 Redis 执行命令是单线程的。
  • RDB 两次备份之间会丢失数据,可靠性低。

 
两者结合:在 AOF 重写日志时,fork 出来的重写子进程会先将共享内存数据以 RDB 方式写入到 AOF 文件,主线程的操作命令会被记录在重写缓冲区里,重写缓冲区里的增量命令会以 AOF 方式追加到 AOF 文件,写入完成后通知主进程将新的含有 RDB 和 AOF 格式的 AOF 文件替换旧 AOF 文件。文件的前半部分是 RDB 格式的全量数据,后半部分是 AOF 格式的增量数据。


集群

主从复制

采用一主多从的模式,主从之间读写分离,无法保证强一致性和高可用性。主服务器可读可写,写操作同步给从服务器;从服务器只读,接受主服务器同步来的写操作命令。

  • 全量同步:第一次同步时,主节点先与从节点同步版本信息(replication id 和 offset),主节点执行 bgsave,生成 RDB 文件,发送给从节点执行;RDB 生成期间的命令会记录到缓冲区,再将该日志文件发送给从节点同步。
  • 增量同步:主节点将日志中 offset 值之后的数据发送给从节点同步。

哨兵模式

哨兵(Sentinel)机制用于实现主从集群的自动故障恢复。

  • 监控:通过心跳机制不断检查 master 和 slave 是否按预期工作。若规定时间内未响应,则该实例主观下线;若超过指定数量的哨兵都认为该实例主观下线,则该实例客观下线
  • 自动故障恢复:如果 master 故障,会将一个 slave 提升为 master。选择新主节点时,排除断开时间超过指定值的节点后,选择 slave-priority 最小、offset 值最大(即最完整)、id 最小的节点。
  • 通知:当集群发生故障转移时,会将最新信息推送给 Redis 客户端。

 
Redis 集群(哨兵模式)脑裂
如果主节点与所有的从节点都失联了,但此时的主节点和客户端的网络是正常的,客户端还在向这个失联的主节点写数据。哨兵发现主节点失联后,会在从节点中选举出一个新的主节点。网络恢复后哨兵会把旧主节点降级,然后旧主节点会向新主节点请求数据同步,因为第一次同步是全量同步,旧主节点会清空掉自己本地的数据。导致客户端写入的数据丢失。

# 解决脑裂问题:主节点满足以下要求才能接收客户端数据
min-replicas-to-write N  # 至少有 N 个从节点
min-replicas-max-lag T  # 数据复制和同步的延迟不能超过 T 秒

分片集群

主从和哨兵解决了高可用、高并发读的问题,分片集群在其基础上解决了海量数据存储和高并发写的问题。

  • 集群中有多个 master,每个 master 保存不同数据。
  • 每个 master 可以有多个 slave 节点。
  • master 之间彼此监测健康状态。
  • 客户端请求可以被路由到集群中正确的节点。

 
Redis 集群有 16384哈希槽,分配到多个集群的主节点中。每个 key 通过 CRC16 校验后对 16384 取模来决定放置到哪个槽。

# 指定哈希槽,根据 xxx 的 hash 值
127.0.0.1:6379> set {xxx} key value

为什么哈希槽大小为 16384?

  • Redis 集群中,每个槽位的状态信息用位图表示,通过心跳包进行传输。尽管 CRC16 能得到 65535 个值,但 65535 的消息需要 8k,而 16384 只占用了 2k
  • 集群设计最多支持 1000 个分片,16384 能够保证在最大集群规模、哈希槽均匀分布的场景下,每个分片平均分到的哈希槽不至于太小。

事务


缓存

缓存雪崩

大量缓存数据在同一时间过期或 Redis 宕机时,全部请求都直接访问数据库。

1. 大量缓存数据同时过期

  • 过期时间:为不同的数据均匀设置不同的过期时间。
  • 互斥锁 / 逻辑过期:同缓存击穿。

 
2. Redis 宕机

  • 集群:哨兵模式、集群模式提高可用性。
  • 服务熔断 / 请求限流:Nginx、Spring Cloud Gateway 等。
  • 多级缓存:Guava、Caffeine 等。

缓存击穿

缓存中的某个热点数据过期,大量的并发请求直接访问数据库,将数据库冲垮。

  • 互斥锁(强一致):保证同一时间只有一个业务线程更新缓存。一个线程查询缓存未命中时,获取互斥锁,并查询数据库重建缓存数据;未能获取互斥锁的请求,要么自旋,要么返回空值或默认值。
  • 逻辑过期(高可用):发现逻辑时间过期时,获取互斥锁,开启后台线程异步更新缓存,返回过期数据;后台线程更新完成后释放锁。
  • 双 key 策略:一致性更低,设置一个主 key 和一个永不过期的备 key。当主 key 过期时,就直接返回备 key 的缓存数据。更新缓存的时候,同时更新主 key 和备 key 的数据。

缓存穿透

数据既不在缓存中,也不在数据库中,导致请求发现缓存缺失再去访问数据库后,仍然无法构建缓存数据。导致每次这样的请求都会直接访问数据库,数据库的压力骤增。

  • 非法请求限制:API 入口处进行参数校验。
  • 缓存空值/默认值:若查询返回的数据为空,仍把这个空结果缓存。这种方式实现简单,但消耗内存,且可能发生数据不一致的问题。
  • 布隆过滤器:缓存预热时,预热布隆过滤器;此后查询 Redis前,先通过布隆过滤器判断是否存在,不存在则直接返回。

双写一致性

  • 延时双删:当要进行写操作时,先删除缓存,再修改数据库,之后延时一段时间后(防止并发的读操作将脏数据写入),再删除缓存。
  • 分布式锁(强一致性):例如读写锁。
  • 消息队列(最终一致性):数据更新时发布消息,异步更新缓存。
  • Canal:类似消息队列,监听 MySQL binlog,业务零侵入。

分布式锁

Redis 分布式锁

Redis 实现分布式锁主要是利用 SETNXSET if Not eXists)命令。

# NX 是互斥,EX 是超时时间,放到一条命令中保证原子性
127.0.0.1:6379> SET xxlock abc NX EX 60
OK
127.0.0.1:6379> SET xxlock xyz NX EX 60
(nil)
127.0.0.1:6379> GET xxlock 
"abc"
127.0.0.1:6379> TTL xxlock  # 生存时间
(integer) 47
127.0.0.1:6379> DEL xxlock
(integer) 1

# 这样不是原子操作
SETNX xxlock abc
EXPIRE xxlock 60

Redisson 分布式锁

看门狗(Watch Dog)
加锁成功后,Redisson 通过看门狗监听持有锁的线程,每隔 releaseTime / 3 就为其续期一次。若手动释放锁,则通知看门狗无需继续监听。

重试机制
Redisson 还为未持有锁的线程添加了重试机制,使其在获取锁失败后再次尝试,直到达到重试阈值。

可重入锁
Redisson 实现的分布式锁是可重入的(同一线程下),它利用 hash 结构在 field 和 value 中分别存入了线程 ID 和重入次数。

主从一致性
Redisson 提供了 RedLock(红锁),在 n 个 Redis 实例的情况下,必须在超过 n / 2 + 1 个 Redis 实例上创建锁后,才算加锁成功。

RLock lock = redissonClient.getLock("xxLock");
// lock.tryLock(获取锁的最大等待时间, 自动释放时间, 时间单位)
// 如果没有设置自动释放时间,则启用看门狗机制
if (lock.tryLock(10, TimeUnit.SECONDS)) {
    try {
        doSomething(); // 可重入,这个方法里也可以获取到锁
    } finally {
        lock.unlock();
    }
}

2024-11-20 八股文·none