Redis

Redis

缓存

缓存穿透、击穿、雪崩

缓存穿透

问题描述:指访问数据库中不存在的数据时,导致了缓存失效,从而出现数据库访问量过大的问题。通常的利用缓存的机制中,会先在 redis 缓存中查找数据,若不存在则进一步到数据库中找,正常情况下找到后会将数据放到缓存中。但是当在数据库中也没有找到的时候,就不会放到缓存中,这样的数据请求就是缓存穿透,大量这样的请求就会导致缓存失效,数据库的压力激增。

解决方法:

  1. 将数据库中也未查询到的数据以 key-null 的形式存储到缓存中,这样就可以避免一些恶意的数据查询导致数据库服务宕机。
    1. 优点:实现简单
    1. 缺点:占用缓存(缓存null的时候设置ttl)、数据不一致问题
  1. 利用布隆过滤器(告诉我们某样东西一定不存在或者可能存在​​),布隆过滤器是一种基于哈希映射的解决方案,会在预热数据的时候对数据库中的 key 值进行映射,例如使用三个不同的哈希函数对 key 进行映射,当发起查询的时候,会优先通过布隆过滤器判断 key 是否存在于数据库,这样就可以一定程度上避免不存在的 key 的请求带来的影响。
    1. 优点:占用小
    1. 缺点:存在一定的误判

Redis布隆过滤器的实现

缓存击穿

问题描述:缓存击穿指的是对数据库热点数据的过期导致的问题,当热点数据过期时,需要重新到数据库中更新缓存中的数据,这一段时间对热点数据的缓存访问就会失效,转而从数据库中获取数据,这就导致数据库的压力激增,容易就被高并发的请求冲垮。

解决方法:

  1. 互斥锁,当缓存出现更新的时候,利用锁机制阻止其他请求对缓存的访问(让它等待或者返回默认值),待更新完毕后,请求就可以访问到最新的数据了。
    1. 优点:一致性高
    1. 缺点:性能差
  1. 逻辑过期缓存中的热点数据(不设置过期的时间),异步更新缓存中的过期数据,更新期间的请求依旧返回过期数据;更新完毕后的请求得到的将是最新的数据。
    1. 优点:高可用,性能高
    1. 缺点:不能保证数据绝对一致(滞后性)

缓存雪崩

问题描述:缓存雪崩指的是缓存中的大量 key 在同一时期过期或者缓存服务宕机了,导致对数据库的并发操作激增,容易造成数据库宕机。

解决方法:

  1. 构建 redis 的高可靠集群(主从模式、哨兵模式、cluster模式等)
  1. 每个 key 设置不同的随机过期时间(不会同时过期)、设置更新锁(效率低)、后台更新缓存(设置线程频繁检测、消息队列)(后两种都类似解决击穿问题的,击穿是热点 key,而对全部的 key 进行这样的操作必定会带来效率问题)
  1. 服务熔断或者限流机制
  1. 增加多种缓存服务

总结

请求限流是解决这三种缓存问题的公用解决办法。

限流如何用redis实现?

算法~利用zset实现滑动窗口限流

通过zset和滑动窗口实现,存入请求作为value,请求的时间作为score。

用当前时间减去限流时间段,例如10s,然后从zset中找出满足这段时间内的数据,判断数据个数是否超过了limit,如果超过了,则限流。

数据一致性问题

mysql 的数据如何与 redis 中的数据进行同步?

双写一致性:当修改了数据库中的数据要同时更新缓存的数据,缓存和数据库要保持一致

一致性要求高---分布式锁

读操作:正常

写操作:延迟双删----删除缓存、修改数据库、(延时)、删除缓存(删除数据库修改期间写入缓存的脏数据)

(1)先删除缓存,再操作数据库:线程1线程2线程2线程1

注意此时缓存中就存在之前被删除的旧数据,即产生了脏数据。

(2)先操作数据库,再删除缓存:线程1线程2线程2线程1

注意此时缓存中依旧是保存了旧数据。

因此需要双删的策略,减少脏数据的出现,而延时是因为数据读写操作是分离的,延时是为了尽量保证数据更新完毕后再执行缓存删除。

延时的时间不好确定,仍有可能出现脏数据,因此做不到绝对的强一致。可以设置数据的过期时间,实现最终的一致性

强一致的解决方案

分布式锁:写数据的时候加锁,在更新操作的时候阻止其他线程进行读写操作。

根据存入缓存中的数据的特性----读多写少,因此可以使用读写锁实现性能提高:

(1)共享锁:读锁 readLock,加锁之后,其他线程可以共享读操作

(2)排它锁:独占锁 writeLock,加锁之后,阻塞其他线程的读和写操作

因此在读数据的时候加共享锁,允许其他线程一起读,但是不允许修改;在写数据的时候加排他锁,阻塞其他读写操作

偏向必须强一致的业务使用。

允许延迟一致---异步通知

异步通知保证数据的一致性:

通过 MQ 实现:修改数据库后,发送修改消息给 MQ,通过 MQ 向缓存发送通知。

订阅 MySQL binlog,先修改数据库,再操作缓存,在数据库更新成功后生成一条binlog记录。如Canal使用的异步通知,不需要修改业务代码,伪装为一个 mysql 的从节点,通过 binlog 读取数据库的更新操作,并同步到缓存中。

此外还需要MQ的实现以及重传机制的设定。

总结

介绍项目中的缓存业务,讲哪些业务用到了读写一致,如何实现的(介绍一下方案)

缓存持久化

redis 作为缓存,数据的持久化是怎么做的?

RDB:数据快照

RDB(Redis Database Backup file),也叫做 Redis 数据快照。将缓存中的所有数据都记录到磁盘中,redis 实例故障重启后,从磁盘读取快照文件,恢复数据。

如何实现备份
  1. 主动备份:save 和 bgsave 命令
  1. 条件触发备份:save 900 1、save 300 10、save 60 10000(60 秒内有 10000 个 key 被修改,执行 bgsave)
RDB 的执行原理

bgsave 开始时会 fork 主进程得到子进程,子进程共享主进程中的数据,主要是页表(记录了虚拟地址和物理地址之间的映射),然后在子进程中读取缓存中的数据并写入 RDB 文件,存入磁盘。

fork 采用 copy-on-write 技术,两个进程共享内存是 read-only 的:

当主进程执行读操作时,访问共享内存;

当主进程执行写操作时,则会拷贝一份数据,执行写操作,之后主进程的页表映射到拷贝数据上,防止脏写。

缺点:执行的效率低。

image

补充:

RDB 快操作的操作可以通过 bgsave 的方式在子进程上执行,但是会丢失在快照期间 Redis 中的写操作,若写操作完全修改了缓存中的数据,此时内存的占用就会变为原来的两倍,相当于旧数据白保存了(共享数据 + 修改后的数据副本)

标准操作:AOF(追加文件)

AOF(Append Only File),Redis 处理的每一个写命令都会记录在 AOF 文件,可以看做是命令的日志文件。
默认关闭,需要修改配置来开启。还可以配置文件名称

此外还可以配置记录的频率(三种写回的策略):

  1. always:同步刷盘,可靠性高,几乎不丢数据,但是性能影响大
  1. everysec:每秒刷盘,性能适中,但是会最多丢失 1 秒数据
  1. no:操作系统控制,性能最好,但是可靠性差,可能会丢失大量数据

缺点:AOF 比 RBD 文件大得多,对同一个 key 的重复读写只有最后一次会生效。

解决方案:使用 bgrewriteaof 或者设置配置的方式减少无效命令

补充:

1.关于可靠性

AOF 的 always 并不是完全可靠的,因为 Redis 是执行命令然后在写入 AOF 文件的,这个过程是在同一个主进程中顺序执行的。

这样的顺序的好处在于,避免 AOF 撤回写入的失败指令,同时可以避免阻塞当前写任务的执行;

但是,若在写入前宕机,就会丢失该操作的记录;此外这样的顺序还会导致下一个命令被阻塞。

2.关于写回策略

了解本质是内核操作,由 fsync()

3.AOF 重写机制

在新文件中进行重写,然后再覆盖旧文件,防止重写失败污染源文件。

这个过程是在后台子 bgrewriteaof 进程中进行的,这个子进程重写的过程和 RDB 快照的过程类似,用 fork 实现。

在后台重写中,两个阶段会阻塞父进程:创建子进程过程中,复制页表;以及创建完后发生写操作,进行的“写时复制”(没想到吧,AOF 也有),拷贝物理内存。

这个过程中修改的是 bigkey,则复制的过程会比较耗时,可能阻塞主进程。

此外,重写过程中,若主进程修改了已经存在的 key,还会出现主子进程数据不一致问题。在 Redis 中设置了“AOF 重写缓冲区”,重写过程中 Redis 会将写入的命令同时写入到「AOF 缓冲区」和 「AOF 重写缓冲区」。当子进程完成了重写操作后,会发送信号通知主进程,主进程阻塞调用信号函数,将AOF 重写缓冲区所有内容追加到新的AOF文件中,同步新旧AOF文件的数据库状态。最后覆盖旧的AOF文件。

所以重写AOF和AOF是两个异步的任务,同时进行。

后台重写中可能发生阻塞的两个场合:进行大Key内存复制和调用信号处理函数。

对比

2024-12-21 16:24:15

RDB

AOF

持久化方式

定时对缓存做快照

保存写操作命令日志的方式

数据完整性

不完整,两次备份之间会丢失

相对完整,取决于刷盘策略

文件大小

会有压缩、文件体积小

记录写命令,文件体积大

宕机恢复速度

很快

数据恢复优先级

低,完整性不如 AOF

高,数据完整性高

系统资源占用

高,大量 CPU 和内存的消耗,需要 fork 进程来进行快照操作

低,主要是磁盘 io 操作,但是重写会占用大量系统资源

使用场景

可以容忍数分钟数据丢失,追求更快的恢复速度

对数据安全性要求高

注意在回答的时候需要结合实际,通常业务中会采用两种方式结合来实现数据的持久化。

Redis 4.0 配置中支持开启混合持久化:AOF 重写过程中用 RDB 的方式写入 AOF 文件,重写期间的主进程写入操作以 AOF 写入缓存,最后合并两个部分,覆盖原先的 AOF 文件。混合持久化的文件中前半部分是 RDB 格式的全量数据,后半部分是 AOF 格式的增量数据。

总结:

回答的逻辑

持久化的方式、数据的完整性、文件的大小、恢复的速度、运行时占用、使用场景

组合使用

大 Key 对持久化的影响

2024-12-24 12:37:10

  1. 对 AOF 写入日志的影响
    1. always(每次写操作后都将日志写回磁盘):在主线程中立即调用 fsycn()主线程被阻塞的时间较久。
    1. everysec(每秒写回磁盘):异步执行 fsycn()
    1. no(不主动写回磁盘):永远不执行 fsycn()
  1. 对 AOF 重写和 RDB 的影响
    1. AOF 的重写机制是基于日志文件的大小的,大 Key 的写入会导致日志文件增长过快,从而导致很容易触发重写操作。
    1. AOF 重写和 RDB 快照(bgsave 命令),都会通过 fork()
      1. 该过程中会将主进程中的页表复制给子进程,而存储多个大 Key 导致 Redis 的占用的内存很大,对应的页表也会很大。复制页表非常耗时,导致 fork()函数发生阻塞,同时也会阻塞 Redis 主线程(fork 函数是由 Redis 主线程调用的)。
      1. 此外在发生写时复制(Copy On Write)时,会先将物理内存中的修改部分进行复制,并重新设置其内存映射关系,最后再执行写操作。对大 Key 进行写后,复制的过程是耗时的,导致主线程阻塞。

缓存更新策略

过期策略

记录方式,怎么知道key过期了?

过期检测的策略?

定期删除的两种模式?

两种策略:

  • 惰性删除:为 key 设置过期时间,只有当用到 key 的时候才去检查 key 是否过期,若过期则删除。
    • 优点:对 cpu 友好
    • 缺点:对内存不友好
  • 定期删除:每隔一段时间「随机」从数据库中取出一定数量的 key 进行检查,并删除其中的过期 key。
    • 两种模式:
      • SLOW:定时任务,默认频率为 10hz,即 1s10 次,每次不超过 25ms,可通过配置修改(执行速度慢,因此限制100ms执行一次,属于低频大量清理
      • FAST:频率不固定,但是执行的间隔不低于 2ms,每次不超过 1ms。(高频少量清理
    • 优点:限制时长和频率减少对 cpu 的影响,定期删除也可以有效释放内存。
    • 缺点:时间难确定

最佳策略:两种结合使用,先FAST

补充

Q1:如何设置过期时间?

设置过期时间的命令:expire <key> <n>pexpire <key> <n>expireat <key> <n>pexpireat <key> <n>set <key> <value> ex <n>set <key> <value> px <n>setex <key> <n> <valule>

查询存活时间:ttl <key>

后悔了?:persist <key>

Q2:如何判定 key 已过期?

查询 Redis 数据结构中的过期字典,其中存储了 key-ttl,若查询的 key 在过期字典中,存在则会将过期时间与当前系统的时间进行比较,判断是否过期。

Q3:过期删除策略有哪些?

定时删除、惰性删除、定期删除

惰性删除的流程:查询请求---判断 key 过期情况---若过期,则删除已过期的 key,返回 null(该删除步骤分为同步和异步两种方式)

定期删除:

  • SLOW模式下,从过期字典中随机抽取 20 个 key、判断是否过期,并删除过期的、若删除的 key 个数超过 25%,则重复抽取和删除。限制了该过程最长不超过 25ms。
  • 而FAST模式下,限制了执行周期为2ms,单次执行1ms,同样也是抽取20个key进行判断,若删除的 key 个数超过 25%,则重复抽取和删除

Q4:Redis 过期策略是什么?

Redis 选择「惰性删除 + 定期删除」这两种策略配和使用。

内存淘汰策略

淘汰的时间,频率是怎么样的?

什么样的key需要淘汰?

淘汰策略是对于内存达到上限(设定的阈值)后对其中的资源进行置换的操作

包含了八种类型的淘汰策略:

不淘汰、快过期的淘汰、随机淘汰、lru、lfu

  1. 不进行数据淘汰:
    1. noeviction:Redis 的默认内存淘汰策略,当内存满后,不允许继续写入,对读和删除操作没有影响。
  1. 进行数据淘汰:
    1. 从设置了过期时间的淘汰策略
      1. volatile-random:从设置了过期时间的 key 中进行随机淘汰 key
      1. volatile-ttl:从设置了过期时间的 key 中淘汰快过期的 key
      1. volatile-lru:从设置了过期时间的 key 中淘汰最久未访问的 key
      1. volatile-lfu:从设置了过期时间的 key 中淘汰最少使用的 key
    1. 从所有数据中进行淘汰
      1. allkeys-random:从所有 key 中进行随机淘汰 key
      1. allkeys-lru:从所有 key 中淘汰最久未访问的 key
      1. allkeys-lfu:从所有 key 中淘汰最少使用的 key

我们需要根据不同的场景使用不同的策略:

面试题实例

Q1:数据库有 1000 万数据,Redis 只能缓存 20 万数据,如何保证 Redis 中的数据都是热点数据?

Q2:Redis 内存用完了会发生什么?

补充

如何查看当前 Redis 的内存淘汰策略? config get maxmemory-policy

如何修改淘汰策略? config set maxmemory-policy <策略>maxmemory-policy <策略>

Redis 中的 LRU双向链表LRU记录最后一次访问的时间,当触发了淘汰策略时,会先对数据进行随机采样,然后淘汰其中最久未访问的 key。该方式避免了链表的存储和移动操作。

但是这样的设计存在问题,无法解决缓存污染的问题,即当一个程序读取了大量数据,这些数据后续不再读取,那么这些数据会在内存中存在较长的时间。

因此提出了 LFU

image

lru为LFU的时候,记录的并不是真实的访问次数,而是逻辑访问次数,它有如下的规则:

image

高可用

集群

设计一个高可用的 Redis 服务,一定要从 Redis 的多服务节点来考虑,可以从主从复制和哨兵集群两个方面考虑,此外还有cluster模式。

什么是主从复制

主从复制

三个模式:全量复制、基于长连接的命令传播、增量复制

链接建立阶段

初始链接

首先从服务器向目标服务器发起请求 psyncFULLRESYNC全量复制】作为响应命令)。

然后主服务器通过 bgsave 开始生成 RDB 文件,期间生成的新数据通过 replication buffer

主服务器持续向从服务器发送新的写命令。

后续链接

当多个从节点向主服务发送建立连接请求的时候,主服务的压力会很大,因此允许从服务器拥有从服务器,依次缓解主服务器压力。

持续同步阶段

主从服务器之间建立的是 TCP 的长连接

根据是否是第一次请求,进行全量同步和增量同步

断线重连阶段

当从服务器的网络断开后,再次恢复网络时,会采用全量同步和增量同步两种方式:

主服务除了将增量的数据保存到 replication bufferrepl_backlog_buffer 中,用于保存着最近传播的写命令。主服务器在上面有一个 master_repl_offset,记录写到哪里了,从服务器有一个 slave_repl_offset,记录读到哪里了,用于实现断线后的恢复数据标识。

若断线时间过长,环形的空间出现了覆盖,那么主从就无法同步,此时需要进行全量同步。

反之则采用增量恢复。

因此如何设置 repl backlog buffer

​replication bufferrepl_backlog_buffer

​replication bufferrepl_backlog_buffer

主从模式实现了数据的冗余备份和分担,但是并不具有高可用性,其中存在很多的问题:

  • 需要人工介入进行主节点切换
  • 主节点写的能力受限于单节点
  • 存储能力受限于主节点的容量

第一点通过哨兵模式实现,而后面两点则是通过集群模式实现。

哨兵模式

哨兵集群、监控、选举、通知

哨兵模式是 Redis 用于实现主从故障转移的方案。帮助我们实现对 Redis 集群的监控,主节点选举以及同步通知。

监控

首先了解如何进行节点监控的,如何判断主节点是否真的故障了?

哨兵会每隔几秒给所有主从节点发送 Ping 命令,主从节点对哨兵进行响应。

若哨兵没有收到节点的响应,就将其标记为主观下线,此外对于主节点还会进一步被标记是否客观下线。

为什么这么设计?

哨兵自身也有网络环境,也会因为故障而产生误判。对应的,主节点也可能因为压力大而未在规定时间内响应哨兵节点。所以其目的是为了保证判断的准确性。

那如何判断主节点是否真的故障了(客观下线)?

这需要哨兵集群来解决上面的问题:哨兵集群由多个哨兵节点构成(大于 3 个),它们会对主节点的状态一起进行判断,就可以减少(避免)单个哨兵因为自身网络状况不好,而误判主节点下线的情况。

当一个哨兵判断主节点为主观下线后,就会向其他哨兵发命令(请求它们的意见),其他哨兵收到这个命令后,就会根据自身和主节点的网络状况,做出赞成投票或者拒绝投票的响应。

当该哨兵节点的赞同票数达到哨兵配置文件中的设定项(quorum)后,就会标记该主节点为客观下线。

主节点被判断为客观下线后,需要哨兵节点来进行新的主节点的选举。

选举

选举有两个部分:哨兵选举和主节点选举。

哨兵选举

指的是从哨兵集群中的候选者中选出一个哨兵作为 Leader,其负责后续的主从故障转移。

如何成为候选者?

做出了主节点客观下线判断的哨兵节点是 Leader 的候选者,即若该节点判断得到 quorum 数量的赞成票,则在标记主节点客观下线的同时,该哨兵节点成为候选者。

如何成为 Leader?

所有候选者会向其他哨兵发送命令,表明希望成为 Leader。所有的哨兵节点拥有一票,非候选者只能投给别人,而候选者还可以投给自己。

当任何一个候选者同时满足:

  • 拿到半数以上的赞成票
  • 票数同时大于等于哨兵配置文件中的 quorum 值

那么该哨兵就升级为 Leader。

当多个候选者竞争时,谁先满足两个条件,谁就优先成为 Leader(非候选者先收到谁的请求就先投给谁)。

如何合理配置哨兵集群?

选举需要成功就必须对人数和规则进行合理设定。哨兵集群中,quorum 的值建议设置为哨兵个数的二分之一加 1,并且哨兵节点的数量应该是奇数

主节点选举(主从故障转移)

该过程由之前选出来的哨兵 Leader 主持,包含了四个步骤:

  1. 选出新的主节点

在从节点中选出一个节点,将其转化为主节点;

为了算出最优的从节点作为新的主节点,需要依据节点的存活情况优先级、复制进度、ID 号来进行比较。

选举出后,对该从节点发送 SLAVEOF no one

这个过程中,哨兵节点还需要对该节点进行频繁的监听,当其成功升级为主节点后,将进行后续的操作。

  1. 从节点重新指向新主节点

哨兵节点向从节点发送 SLAVEOF

  1. 通知客户端主节点的变化

在主从切换完成后需要将新的主节点的信息告知客户端。哨兵节点提供订阅-通知的机制,客户端从哨兵节点中订阅消息。当不同事件发生时,哨兵节点向其频道发送消息,客户端就会收到对应的消息。

  1. 将旧的主节点变为从节点

哨兵节点会继续监视旧节点,在其重新上线后,将其变为新主节点的从节点。

通知

在主从切换完成后需要将新的主节点的信息告知客户端。哨兵节点提供订阅-通知的机制,客户端从哨兵节点中订阅消息。当不同事件发生时,哨兵节点向其频道发送消息,客户端就会收到对应的消息。

客户端不仅可以在主从切换后得到新主节点的连接信息,还可以监控到主从节点切换过程中发生的各个重要事件

哨兵集群实现的关键

通知与订阅

思考哨兵集群是如何发现彼此的,

从节点信息

哨兵节点向主节点发送 INFO 命令,主节点接收到这个命令后就会将其从节点的信息发送给哨兵,然后哨兵节点依据该信息与所有的从节点建立连接。

基于上面两种方式,建立了哨兵与哨兵之间以及哨兵与主从节点之间的联系,前者构成了哨兵集群,后者建立了监控网络。

分片集群

cluster中引入哈希槽的概念,Redis一共有16384个哈希槽,将其划分为多个等份,分给多个主节点,这些主节点以及它们的从节点构成分片集群。

cluster实现了可以不用将所有数据都放在一个节点下,实现了跨redis存储。

分布式锁

redis分布式锁是如何实现的?

setnxlua 脚本(保证命令执行的原子性

基本实现:加锁与释放锁

加锁:set lock_key value nx px time

需要设置过期时间,防止服务宕机无法释放,这个操作一定要和加锁操作具有原子性。

在redis分布式锁中,加锁的操作的是原子操作,但是解锁不是,解锁包括了查询锁是否存在解锁

Redisson实现分布式锁如何合理控制锁的有效时长?

释放锁:看门狗,给分布式锁续期

Redisson可重入吗?

可以

Redisson:

示例代码

image

Redis实现的分布式锁不可重入,但是Redisson实现的分布式锁是可重入的

Redisson锁可以解决主从数据一致的问题吗?

不能,不同节点的锁状态更新存在一定的延时,若主节点中的部分锁未同步到从节点就挂了,那么这部分的锁就失效了。

一般是AP:但是会存在主节点宕机导致的锁丢失问题

CP实现:红锁

集群的情况下分布式锁的可靠性----Redlock(红锁):

如何向集群加锁操作

怎么样算加锁成功

如何释放

强一致性建议使用zookeeper

获取锁与释放锁

其他面试题总结

为什么Redis那么快?

  1. 使用内存,执行速度快
  1. 单线程,避免了多线程的竞争、上下文切换和多线程安全问题
  1. 使用I/O多路复用模型,非阻塞IO

I/O多路复用的原理?

利用单个线程来同时监听多个Socket,并在某个Socket可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。

实际上对于阻塞IO和非阻塞IO,它们都是针对某一个Socket进行处理的。前者是一直等待,直到数据拷贝完成,期间一直阻塞;后者则是会在数据未就绪时不断尝试获取,处于非阻塞,数据就绪后进入阻塞状态,直至数据完成拷贝。

而I/O多路复用则是同时监听多个Socket,准备好了再通知进程进行拷贝,避免了等待一个Socket而导致的无效等待。

I/O多路复用在Linux中的实现方案?

select-poll:通知就绪,需要遍历确认哪一个

epoll:通知就绪,同时通知是哪一个

Redis网络模型?

存储结构

数据格式:

跳表:

数据结构与类型

RedisObject

String

SDS数据结构

image

其中flags是sds头的数据类型,头包括len、alloc、flags

buf[]是char类型的字节数组

数据格式

包含了三种数据类型格式:

  • raw:RedisObject的指针指向一个SDS数据。
  • embstr:当SDS长度小于44字节的时候,可以实现RedisObject和数据连续存储(刚好64字节)
  • int:存储数值类型,数据保存在RedisObject的指针位置

List

LinkedList、ZipList、QuickList

LinkedList:链表,双端访问,节点存储效率低,且内存占用不连续。

ZipList:压缩链表(但不是真正的链表),双端访问,内存利用率高,但是因为是连续的存储空间,导致存储上限低。

QuickList:LinkedLsit+ZipList,可以双端访问,存储效率高,包含多个ZipList,存储上限高。

3.2之后使用的都是QuickList实现List

数据格式

image

Set

两种数据结构

  • HT(Dict)数据结构存储,使用key存储元素,value为null。
  • 若存储的数据都是整数,并且元素的数量不超过set-max-intset-entries时,使用的是IntSet数据结构,节省内存。

数据格式

数据存储的时候默认是升序的。

image

ZSet

数据结构

dict(ht)+ skiplist

ziplist

每一个元素包含了score值和member值,会根据score进行排序。--- 跳表

其中member值必须唯一,重复添加score会被覆盖,通过member查询对应的分数。 --- 哈希

用法

ZADD z1 10 m1 20 m2 30 m3
(integer) 3
ZSCORE z1 m1
"10"

数据格式

menber键查询score值 + 排序:HT+SkipList

image

性能好,但是元素不多的时候耗费内存,因此在元素少的情况下,使用ZipList数据结构

image

Hash

数据结构

ziplist

dict(ht):ht采用拉链法的方式解决哈希冲突问题

用法

hset user:1 name jack age 21
(integer) 2
hget user:1 name
"jack"

渐进式rehash

什么时候rehash?

image

  • 负载因子 >= 1,此时若redis没有在bgsave或者bgrewriteaof,也就是RDB快照和AOF重写的时候就会rehash;
  • 负载因子 >= 5,强制执行rehash;

rehash的过程?

在dict数据结构中,保存了两个哈希表ht,其中ht[0]用于保存数据,ht[1]用于rehash的时候暂存数据。

ht[1]的大小是ht[0]的两倍,rehash的过程中需要将ht[0]中的所有数据重新计算哈希后放入ht[1]中,然后将ht[0]的地址修改为ht[1]的地址,最后为ht[1]新建一个空的哈希表,等待下一次的rehash。

但是当需要rehash的数据量非常大的时候,rehash就会占用大量的时间,导致其他操作被阻塞。

因此提出一种渐进式rehash

在查询、新增、删除、更新操作的时候,按照索引位置逐步进行rehash,渐进地将ht[0]中的数据复制到ht[1]中。最终ht[0]就会变成空表。

压缩列表--连锁更新问题

一个压缩列表的项包含了:

  • prevlen:前一个节点的长度
  • encoding:记录当前节点的实际数据类型
  • data:数据

其中prevlen的长度取决于前一个节点的大小:

  • 如果前一个节点的长度小于 254 字节,那么 prevlen 属性需要用 1 字节的空间来保存这个长度值;
  • 如果前一个节点的长度大于等于 254 字节,那么 prevlen 属性需要用 5 字节的空间来保存这个长度值;

因此当所有节点都小于254字节时,插入一个大于254字节的,就会导致prevlen扩展,也就是节点会增加4个字节,这就可能导致连锁更新。

总结

网络模型

1.Linux中5种IO模型

1.阻塞IO

阻塞IO模型中,用户进程在两个阶段都是阻塞状态。

  • 用户通过系统调用recvfrom
  • 数据就绪后,需要将数据从内核空间拷贝到用户空间;

用户进程在这两个过程中都处于等待状态。

image

缺点:每一个线程请求都需要阻塞等待数据的返回,那么1000个请求就会产生1000个阻塞线程,这样的内存开销是巨大的,会影响系统性能。

而多路复用io则采用一对多的方式实现,可以实现更高的并发性能。

2.非阻塞IO

非阻塞IO模型中,用户进程在第一个阶段中不会阻塞,而在第二个阶段中需要等待。

  • 当用户进行发起系统调用recvfrom
  • 需要等待数据从内核中拷贝到用户空间;

image

3.IO多路复用

文件描述符,FD,是Linux中关联文件的,在Linux中,一切皆文件,包括网络套接字Socket,因此可以使用FD来作为网络套接字Socket标识。

IO多路复用利用单个线程来同时监听多个FD。并在某个FD可读、可写时得到通知监听的单线程,从而避免无效的等待,充分利用CPU资源。

image

监听线程获取到就绪任务列表中也有不同的表现:

  • Redis则采用单线程处理命令任务
  • Nginx多worker进程,每一个worker独立进行监听,接收到就绪的任务后发送给线程池。

具体实现:select、poll、epoll

前两者只会通知用户进程有FD就绪了,但是用户进程并不知道具体是哪个,因此还需要去遍历FD来确认;而epoll会在通知用户进程FD就绪的同时,把已经就绪的FD写入用户空间。

1.select

数据结构上,将不同的事件分为不同的FD集合来存储。

使用bit位来标识每一个FD,在用户态中,首先创建一个fd_set类型的数组rfds(用到的是bit位),将需要监听的FD标识为1,然后执行select(max_pos + 1, refs, null, null, out_time)

然后进行一次用户态到内核态的切换,并将rfds复制到内核区

内核中,会先遍历一遍监听范围内的FD,若没有则进入休眠,同时监听rdfs,等待数据就绪被唤醒或超时;

当有DF就绪了,内核中的监听程序会唤醒对应的程序,遍历rdfs,将未就绪的FD置为0,然后通知用户进程已经有就绪的了,同时将内核中的rfds拷贝到用户空间中;

然后切换回用户空间,此时用户空间只是知道了有FD就绪但不知道具体哪一个,因此用户进程需要遍历一遍rfds,最终找出就绪的FD;

缺点:

  • 存在两次rfds拷贝,两次用户-内核态的切换;
  • 无法知道具体哪一个FD就绪,需要遍历(刚拷贝到内核需要1次遍历、有FD就绪了需要遍历一次,送到用户空间需要遍历一次,因此一次读取需要遍历3次);
  • 监听的FD数量不能超过1024;

2.poll

用数据结构对FD的类型、FD标识进行了封装。

首先创建pollfd数组,向其中添加关注的fd信息,数组大小自定义;

调用poll函数,将pollfd数组拷贝到内核空间,转变为链表存储(没有上限);

内核遍历fd,判断是否就绪;

数据就绪或者超时后,拷贝pollfd数组到用户空间,返回就绪fd数量n;

用户进程判断n是否大于0(是否有就绪);

大于0则遍历pollfd数组,找到就绪的fd。

缺点:

  • select的缺点依然存在;
  • 每一个fd拥有了状态,多了一个统计就绪总数的变量;
  • 无上限的fd监听有时候会带来额外开销;

3.epoll

数据结构:红黑树存储需要监听的fd,链表存储就绪的fd。

函数调用:

  • int epoll_create(int size),会在内核创建eventpoll结构体{红黑树 + 链表},返回对应的句柄epfd;
  • int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event),添加需要监听的fd到红黑树中,同时关联callback;
  • 回调函数:触发后将fd加入到rdlist这个就绪链表中;
  • epoll_wait(int epfd, struct epoll_event *event, int maxevent, int timeout),等待fd就绪;

image

epoll_create在内核中创建epoll对象(红黑树和就绪列表);

epoll_ctl实现将需要监听的fd放入红黑树中,同时设置回调函数

触发回调函数后,将在就绪链表添加就绪的fd中;

epoll_wait实现通知用户进程数据就绪,并将就绪的fd拷贝到用户空间内存中;

用户态中直接对fd进行处理。

image

要点:

  • 三个主要操作:epoll_create、epoll_ctl、epoll_wait
  • 拷贝次数减少,用户到内核态只在第一次拷贝一次,后续不用拷贝,每次有数据就绪,就从将内核中的就绪链表拷贝到用户态(1 + n次,而前面两个需要2n次)。这主要是因为epoll将fd对象的状态进行了拆分,红黑树存储监听的fd,链表存储就绪的fd。
  • 用户无需遍历即可知道哪些就绪了(拷贝的就是就绪链表)
4.事件通知机制

用户态程序调用epoll_wait()后,内核的链表根据触发模式,将自己的数据复制到用户态

epoll边缘触发和水平触发

当FD有数据可读时,调用epoll_wait()

  • 边缘触发(EdgeTriggered,ET),当FD有数据可读时,只会被通知一次,无论后续数据是否处理完成;
  • 水平触发模式(LevelTriggered,LT),当FD有数据可读时,会重复通知多次,直到数据处理完成,epoll默认模式;

其具体实现是对就绪链表进行操作,ET模式在通知后就将链表清除,而LT模式会将FD再添加到就绪链表中。

LT对性能会有影响(多次拷贝),以及惊群问题;

ET的实现更加具有扩展性,可以结合非阻塞IO实现一次读/写完数据。

5.web服务流程

image

服务端(例如redis)有三种数据:

  • Server Socket事件,即客户端连接事件:accept()获取socket对应的fd,然后监听该fd
  • Client Socket事件,即客户端请求数据:对数据进行处理和响应
  • 异常

4.信号驱动IO

image

第一阶段真正非阻塞,但是由于没有多路复用机制,sigio信号的交换会有性能问题。同时大量IO操作可能会让信号丢失。

5.异步IO

image

2.Redis网络模型

参考参考

多线程还是单线程?

核心业务操作命令上用的是单线程,而整体看是多线程,多线程实现例如异步删除操作,提高多核CPU利用率。

为什么使用单线程?

Redis使用的纯内存操作,执行非常快,因此执行速度的瓶颈在于网络延时,多线程并没有带来很大的提升。

多线程会带来额外的延时,如上下文切换,以及额外的线程安全管理,如锁机制。

Redis单线程到多线程

4.0引入了多线程来处理一些异步的耗时操作,例如删除,此时利用的是单Reactor模型(单线程的多路复用);而6.0在核心网络引入了多线程,把读取客户端请求命令回写响应数据的逻辑异步化了,交给 I/O 线程去完成,变为了多线程单Reactor模型。

从单Reactor模型转变为多Reactor模型。

image

image

对多路复用api的封装:

  • aeApiCreate():创建多路复用程序,红黑树和就绪链表,对应epoll_create()
  • aeApiAddEvent():监听并注册FD,将需要等待的FD加入到红黑树中,对应epoll_ctl()
  • aeApiPoll():等待FD就绪,返回结果,对应epoll_wait()。执行之前调用beforeSleep(),绑定写处理器(sendReplyToClient)到客户端fd。
单线程模型:
1.绑定回调

首先Client Socket发送建立连接的请求到Server Socket上;

创建多路复用程序(第一次),然后创建多路复用程序,并等待fd就绪返回事件,Server Socket的fd上会绑定一个tcpAccepthanderClient Socket的fd添加到多路复用程序中;

fd(Client Socket) 的读请求绑定一个readQueryFromClient

数据准备好后,会遍历clients_pending_write队列中的client,并为每一个 fd(Client Socket) 绑定一个sendReplyToClient矛盾点

2.事件发生
  • 客户端请求连接:当Server Socket的 fd 发生了可读事件,则tcpAccepthander
  • 读取客户端:当Client Socket的 fd 发生了可读事件,则readQueryFromClient
  • 写入客户端:当需要写入Client Socket的 fd时 ,调用writeToClient将client中的数据写入到客户端。一次事件循环之后,若还有数据未写完,则注册sendReplyToClient(和之前的理解有矛盾)

image

单线程中的瓶颈
  1. 命令请求处理器中,命令读取中的IO操作,受到网络和带宽的影响
  1. 命令回复处理器中,将数据写入涉及IO操作,受到网络和带宽的影响
多线程的引入

命令请求处理器中开启多线程,执行命令的读取和解析,将解析得到的命令传回主线程(单线程)处理

命令回复处理器中开启多线程,执行客户端数据的写入

多线程中,主线程在所有的I/O线程完成操作前都是在轮询

通信协议

RESP

应用

短信验证登录

session共享问题

负载均衡时,用户端请求被分配到不同的服务器,此时服务器上可能并没有之前的session,用户需要频繁登录校验。

短信登录实现

存入Redis,为String类型,key为手机号,value为验证码,设置过期时间

服务端登录校验

服务端根据用户的手机号从redis中进行验证码校验。然后保存用户的信息,以哈希类型存储,即登录凭证。

登录凭证从sessionId变为token,服务端需要签发token,并设置过期时间

用户访问后,对redis中的缓存过期时间更新。在拦截器中实现。

缓存查询

缓存一致性问题,如何同步更新数据库与缓存?

先更新数据库,再删除缓存

延迟双删策略

最佳实践:

查询:先查询缓存,缓存命中直接返回,未命中则查询数据库,然后将数据写入缓存,返回结果。

修改数据库:先修改数据库,然后删除缓存(保证两个操作原子性)

缓存内存回收策略

缓存三大问题:穿透、雪崩、击穿

穿透:

缓存空值

布隆过滤器:

面试问什么?

雪崩:

多级缓存、不同的ttl、高可用集群、限流降级策略

击穿:

互斥锁:过期时加锁,其他等待,一致性好,但是性能差

逻辑过期:异步更新,其他线程直接返回,存在一致性问题

实现工具类的封装(添加时设置逻辑过期时间、实现解决穿透的设置null、击穿设置加锁和异步更新)---函数式编程、泛型高级用法

秒杀业务

唯一订单号生成策略

时间戳+自增序号,自增序号通过redis来存储和维护,自增序号的key通过前缀+日期来区分每一天的自增序号。

构造方式:生成时间戳,向左移动32位,然后使用获运算填充自增序号,timestamp >>> bits | incrID

超卖问题:

悲观锁或者乐观锁的方式

可以使用redis中的原子操作实现缓存中库存的扣减

一人一单的实现:

需要判断当前是是否存在数据

加锁:锁的范围,应当锁住整个事务,对用户id进行加synchronized锁,需要转换:synchronized(userId.toString().intern()) {}

事务失效问题:若在一个类的方法中调用该类中加了@Transactional注解的方法,即方法的内部调用,那么该事务是不会生效的。这是因为直接调用获取的不是代理对象,那么就不会有事务。解决方法如下:

  • 事务方法拆分到其他service中,然后注入该service
  • 注入自己,然后调用方法
  • 通过AopContent类,获取代理对象:(目标对象类型) AopContext.currentProxy();

集群模式下怎么处理?

分布式锁

实时排行榜

排行榜系统设计:高并发场景下的最佳实践

消息队列

跳转

List实现的消息队列

Stream实现的消息队列

分布式锁

posted @ 2025-05-26 12:18  NingBoB  阅读(57)  评论(0)    收藏  举报