Redis
Redis
缓存
缓存穿透、击穿、雪崩
缓存穿透
问题描述:指访问数据库中不存在的数据时,导致了缓存失效,从而出现数据库访问量过大的问题。通常的利用缓存的机制中,会先在 redis 缓存中查找数据,若不存在则进一步到数据库中找,正常情况下找到后会将数据放到缓存中。但是当在数据库中也没有找到的时候,就不会放到缓存中,这样的数据请求就是缓存穿透,大量这样的请求就会导致缓存失效,数据库的压力激增。
解决方法:
- 将数据库中也未查询到的数据以 key-null 的形式存储到缓存中,这样就可以避免一些恶意的数据查询导致数据库服务宕机。
- 优点:实现简单
- 缺点:占用缓存(缓存null的时候设置ttl)、数据不一致问题
- 利用布隆过滤器(告诉我们某样东西一定不存在或者可能存在),布隆过滤器是一种基于哈希映射的解决方案,会在预热数据的时候对数据库中的 key 值进行映射,例如使用三个不同的哈希函数对 key 进行映射,当发起查询的时候,会优先通过布隆过滤器判断 key 是否存在于数据库,这样就可以一定程度上避免不存在的 key 的请求带来的影响。
- 优点:占用小
- 缺点:存在一定的误判
Redis布隆过滤器的实现
缓存击穿
问题描述:缓存击穿指的是对数据库热点数据的过期导致的问题,当热点数据过期时,需要重新到数据库中更新缓存中的数据,这一段时间对热点数据的缓存访问就会失效,转而从数据库中获取数据,这就导致数据库的压力激增,容易就被高并发的请求冲垮。
解决方法:
- 互斥锁,当缓存出现更新的时候,利用锁机制阻止其他请求对缓存的访问(让它等待或者返回默认值),待更新完毕后,请求就可以访问到最新的数据了。
- 优点:一致性高
- 缺点:性能差
- 逻辑过期缓存中的热点数据(不设置过期的时间),异步更新缓存中的过期数据,更新期间的请求依旧返回过期数据;更新完毕后的请求得到的将是最新的数据。
- 优点:高可用,性能高
- 缺点:不能保证数据绝对一致(滞后性)
缓存雪崩
问题描述:缓存雪崩指的是缓存中的大量 key 在同一时期过期或者缓存服务宕机了,导致对数据库的并发操作激增,容易造成数据库宕机。
解决方法:
- 构建 redis 的高可靠集群(主从模式、哨兵模式、cluster模式等)
- 每个 key 设置不同的随机过期时间(不会同时过期)、设置更新锁(效率低)、后台更新缓存(设置线程频繁检测、消息队列)(后两种都类似解决击穿问题的,击穿是热点 key,而对全部的 key 进行这样的操作必定会带来效率问题)
- 服务熔断或者限流机制
- 增加多种缓存服务
总结
请求限流是解决这三种缓存问题的公用解决办法。
限流如何用redis实现?
通过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 实例故障重启后,从磁盘读取快照文件,恢复数据。
如何实现备份
- 主动备份:save 和 bgsave 命令
- 条件触发备份:save 900 1、save 300 10、save 60 10000(60 秒内有 10000 个 key 被修改,执行 bgsave)
RDB 的执行原理
bgsave 开始时会 fork 主进程得到子进程,子进程共享主进程中的数据,主要是页表(记录了虚拟地址和物理地址之间的映射),然后在子进程中读取缓存中的数据并写入 RDB 文件,存入磁盘。
fork 采用 copy-on-write 技术,两个进程共享内存是 read-only 的:
当主进程执行读操作时,访问共享内存;
当主进程执行写操作时,则会拷贝一份数据,执行写操作,之后主进程的页表映射到拷贝数据上,防止脏写。
缺点:执行的效率低。
补充:
RDB 快操作的操作可以通过 bgsave 的方式在子进程上执行,但是会丢失在快照期间 Redis 中的写操作,若写操作完全修改了缓存中的数据,此时内存的占用就会变为原来的两倍,相当于旧数据白保存了(共享数据 + 修改后的数据副本)
标准操作:AOF(追加文件)
AOF(Append Only File),Redis 处理的每一个写命令都会记录在 AOF 文件,可以看做是命令的日志文件。
默认关闭,需要修改配置来开启。还可以配置文件名称
此外还可以配置记录的频率(三种写回的策略):
- always:同步刷盘,可靠性高,几乎不丢数据,但是性能影响大
- everysec:每秒刷盘,性能适中,但是会最多丢失 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
- 对 AOF 写入日志的影响
- always(每次写操作后都将日志写回磁盘):在主线程中立即调用 fsycn()主线程被阻塞的时间较久。
- everysec(每秒写回磁盘):异步执行 fsycn()
- no(不主动写回磁盘):永远不执行 fsycn()
- 对 AOF 重写和 RDB 的影响
- AOF 的重写机制是基于日志文件的大小的,大 Key 的写入会导致日志文件增长过快,从而导致很容易触发重写操作。
- AOF 重写和 RDB 快照(bgsave 命令),都会通过 fork()
- 该过程中会将主进程中的页表复制给子进程,而存储多个大 Key 导致 Redis 的占用的内存很大,对应的页表也会很大。复制页表非常耗时,导致 fork()函数发生阻塞,同时也会阻塞 Redis 主线程(fork 函数是由 Redis 主线程调用的)。
- 此外在发生写时复制(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
- 不进行数据淘汰:
- noeviction:Redis 的默认内存淘汰策略,当内存满后,不允许继续写入,对读和删除操作没有影响。
- 进行数据淘汰:
- 从设置了过期时间的淘汰策略
- volatile-random:从设置了过期时间的 key 中进行随机淘汰 key
- volatile-ttl:从设置了过期时间的 key 中淘汰快过期的 key
- volatile-lru:从设置了过期时间的 key 中淘汰最久未访问的 key
- volatile-lfu:从设置了过期时间的 key 中淘汰最少使用的 key
- 从所有数据中进行淘汰
- allkeys-random:从所有 key 中进行随机淘汰 key
- allkeys-lru:从所有 key 中淘汰最久未访问的 key
- 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
lru为LFU的时候,记录的并不是真实的访问次数,而是逻辑访问次数,它有如下的规则:
高可用
集群
设计一个高可用的 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 主持,包含了四个步骤:
- 选出新的主节点
在从节点中选出一个节点,将其转化为主节点;
为了算出最优的从节点作为新的主节点,需要依据节点的存活情况、优先级、复制进度、ID 号来进行比较。
选举出后,对该从节点发送 SLAVEOF no one
这个过程中,哨兵节点还需要对该节点进行频繁的监听,当其成功升级为主节点后,将进行后续的操作。
- 从节点重新指向新主节点
哨兵节点向从节点发送 SLAVEOF
- 通知客户端主节点的变化
在主从切换完成后需要将新的主节点的信息告知客户端。哨兵节点提供订阅-通知的机制,客户端从哨兵节点中订阅消息。当不同事件发生时,哨兵节点向其频道发送消息,客户端就会收到对应的消息。
- 将旧的主节点变为从节点
哨兵节点会继续监视旧节点,在其重新上线后,将其变为新主节点的从节点。
通知
在主从切换完成后需要将新的主节点的信息告知客户端。哨兵节点提供订阅-通知的机制,客户端从哨兵节点中订阅消息。当不同事件发生时,哨兵节点向其频道发送消息,客户端就会收到对应的消息。
客户端不仅可以在主从切换后得到新主节点的连接信息,还可以监控到主从节点切换过程中发生的各个重要事件
哨兵集群实现的关键
通知与订阅
思考哨兵集群是如何发现彼此的,
从节点信息
哨兵节点向主节点发送 INFO 命令,主节点接收到这个命令后就会将其从节点的信息发送给哨兵,然后哨兵节点依据该信息与所有的从节点建立连接。
基于上面两种方式,建立了哨兵与哨兵之间以及哨兵与主从节点之间的联系,前者构成了哨兵集群,后者建立了监控网络。
分片集群
cluster中引入哈希槽的概念,Redis一共有16384个哈希槽,将其划分为多个等份,分给多个主节点,这些主节点以及它们的从节点构成分片集群。
cluster实现了可以不用将所有数据都放在一个节点下,实现了跨redis存储。
分布式锁
redis分布式锁是如何实现的?
setnx 与 lua 脚本(保证命令执行的原子性)
基本实现:加锁与释放锁
加锁:set lock_key value nx px time
需要设置过期时间,防止服务宕机无法释放,这个操作一定要和加锁操作具有原子性。
在redis分布式锁中,加锁的操作的是原子操作,但是解锁不是,解锁包括了查询锁是否存在解锁
Redisson实现分布式锁如何合理控制锁的有效时长?
释放锁:看门狗,给分布式锁续期
Redisson可重入吗?
可以
Redisson:
示例代码
Redis实现的分布式锁不可重入,但是Redisson实现的分布式锁是可重入的
Redisson锁可以解决主从数据一致的问题吗?
不能,不同节点的锁状态更新存在一定的延时,若主节点中的部分锁未同步到从节点就挂了,那么这部分的锁就失效了。
一般是AP:但是会存在主节点宕机导致的锁丢失问题
CP实现:红锁
集群的情况下分布式锁的可靠性----Redlock(红锁):
如何向集群加锁操作
怎么样算加锁成功
如何释放
强一致性建议使用zookeeper
获取锁与释放锁
其他面试题总结
为什么Redis那么快?
- 使用内存,执行速度快
- 单线程,避免了多线程的竞争、上下文切换和多线程安全问题
- 使用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数据结构
其中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
数据格式
Set
两种数据结构
- HT(Dict)数据结构存储,使用key存储元素,value为null。
- 若存储的数据都是整数,并且元素的数量不超过set-max-intset-entries时,使用的是IntSet数据结构,节省内存。
数据格式
数据存储的时候默认是升序的。
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
性能好,但是元素不多的时候耗费内存,因此在元素少的情况下,使用ZipList数据结构
Hash
数据结构
ziplist
dict(ht):ht采用拉链法的方式解决哈希冲突问题
用法
hset user:1 name jack age 21
(integer) 2
hget user:1 name
"jack"
渐进式rehash
什么时候rehash?
- 负载因子 >= 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
- 数据就绪后,需要将数据从内核空间拷贝到用户空间;
用户进程在这两个过程中都处于等待状态。
缺点:每一个线程请求都需要阻塞等待数据的返回,那么1000个请求就会产生1000个阻塞线程,这样的内存开销是巨大的,会影响系统性能。
而多路复用io则采用一对多的方式实现,可以实现更高的并发性能。
2.非阻塞IO
非阻塞IO模型中,用户进程在第一个阶段中不会阻塞,而在第二个阶段中需要等待。
- 当用户进行发起系统调用recvfrom
- 需要等待数据从内核中拷贝到用户空间;
3.IO多路复用
文件描述符,FD,是Linux中关联文件的,在Linux中,一切皆文件,包括网络套接字Socket,因此可以使用FD来作为网络套接字Socket标识。
IO多路复用利用单个线程来同时监听多个FD。并在某个FD可读、可写时得到通知监听的单线程,从而避免无效的等待,充分利用CPU资源。
监听线程获取到就绪任务列表中也有不同的表现:
- 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就绪;
epoll_create在内核中创建epoll对象(红黑树和就绪列表);
epoll_ctl实现将需要监听的fd放入红黑树中,同时设置回调函数;
触发回调函数后,将在就绪链表添加就绪的fd中;
epoll_wait实现通知用户进程数据就绪,并将就绪的fd拷贝到用户空间内存中;
用户态中直接对fd进行处理。
要点:
- 三个主要操作: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服务流程
服务端(例如redis)有三种数据:
- Server Socket事件,即客户端连接事件:accept()获取socket对应的fd,然后监听该fd
- Client Socket事件,即客户端请求数据:对数据进行处理和响应
- 异常
4.信号驱动IO
第一阶段真正非阻塞,但是由于没有多路复用机制,sigio信号的交换会有性能问题。同时大量IO操作可能会让信号丢失。
5.异步IO
2.Redis网络模型
多线程还是单线程?
核心业务操作命令上用的是单线程,而整体看是多线程,多线程实现例如异步删除操作,提高多核CPU利用率。
为什么使用单线程?
Redis使用的纯内存操作,执行非常快,因此执行速度的瓶颈在于网络延时,多线程并没有带来很大的提升。
多线程会带来额外的延时,如上下文切换,以及额外的线程安全管理,如锁机制。
Redis单线程到多线程
4.0引入了多线程来处理一些异步的耗时操作,例如删除,此时利用的是单Reactor模型(单线程的多路复用);而6.0在核心网络引入了多线程,把读取客户端请求命令和回写响应数据的逻辑异步化了,交给 I/O 线程去完成,变为了多线程单Reactor模型。
从单Reactor模型转变为多Reactor模型。
对多路复用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(和之前的理解有矛盾)
单线程中的瓶颈
- 命令请求处理器中,命令读取中的IO操作,受到网络和带宽的影响
- 命令回复处理器中,将数据写入涉及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实现的消息队列
分布式锁

浙公网安备 33010602011771号