Redis 八股汇总
字数: 0 字 时长: 0 分钟
第一部分 | 基础概念
1. Redis 的数据类型有哪些
- String:存文本、数字、二进制。常用来存用户信息、Session、查询结果、阅读量、点赞数
- List:有序列表,底层使用 quicklist,可以用作简单的消息队列
- Hash:键值对集合,把多个字段和值存在同一个 key 下面
- Set:无序不重复,适用于标签系统
- ZSet:类似于 Set,底层使用跳表+哈希表,每个元素都携带一个分数用来排序,常用于排行榜、热搜榜等场景
- BitMap:用位来存数据,每个 bit 只有 0/1,常用于签到场景(setbit 设置状态,getbit 读取状态)
点击查看示例
shellSETBIT user:sign:202409 12345 1 # 用户 12345 在 9 月签到 GETBIT user:sign:202409 12345 # 查询签到状态 - HyperLogLog:一种概率性去重算法,通过牺牲极小的精度(约 0.81%),换取固定且极小的内存开销(固定 12KB),适用于独立访客(UV)这种对精度要求不高但数据量巨大的场景
点击查看示例
shellPFADD page:uv user1 user2 user3 # 记录访问用户 PFCOUNT page:uv # 估算独立用户数 - GEO:存地理位置信息,支持经纬度存储和空间查询,底层使用 ZSet,经纬度会被编码成 score,典型场景就是“附近的人”、外卖配送距离计算
点击查看示例
shellGEOADD riders 116.403 39.915 "rider001" # 存骑手位置 GEORADIUS riders 116.4 39.9 5 km # 查 5 公里内的骑手 - Stream:专为消息队列设计,相比 List 更适合做消息队列,支持消息 ID,消费组、消息确认和失败重试
2. Redis 的数据结构有哪些
简单动态字符串(SDS):不是使用 C 语言原生的 char 数组,而是 Redis 自己实现的字符串结构。可以直接获取长度、存二进制。SDS 有三个核心字段,len 记录当前长度;alloc 记录可分配空间;buf 存储数据。扩容后小于 1M 翻倍,大于 1M 加 1M。当值是 long 范围内的整数使用 int 编码;<=44 字节使用 embstr 编码;>44 字节使用 raw 编码
哈希表(HashTable):数组+链表,精确查找 O(1),但内存分散指针开销大
整数集合(IntSet):本质是一个有序整数数组+元信息,内存连续、有序不重复、不存指针
压缩列表(ZipList):一块连续的内存,把所有元素紧凑的放到一起,省内存但不适合存大量数据。开头zlbytes 记录整个链表占多少字节,zltail 记录最后一个节点的偏移量用于快速定位尾部,zllen 记录节点个数,中间是一个个 entry 节点紧密排列,最后用 zlend(0xFF)标记结束
紧凑列表(ListPack):Redis 7 及之后 ziplist 的替代品【其实是 5.0 引入的,只是那时只用于 stream】。内存连续无指针开销,顺序查找 O(n),解决了 ZipList 的级联更新问题(ziplist 每个 entry 存了前一个entry 的长度,这个 pre_entry_length 是变长的,前一个 entry 小于 254 字节时占 1 字节,大于 254 就占5 字节,如果有一串 entry 都为 253 字节,在前面插入一个 260 字节的新 entry 时,后面记录长度的字段就要从 1 字节变为 5 字节,一路传递;listpack 改成只存当前节点的长度,往前遍历的时候,先读当前节点的element-tot-len,就能算出这个节点的起始位置,跳转到前一个元素)
QuickList:Redis 3.2 之后 List 的默认实现,本质是双向链表穿起来一些小的 ziplist,每个 ziplist 控制在一定大小,级联更新最多影响一小块不会波及整个链表(Redis7 时 ziplist 就变为 listpack 了)
跳表(SkipList):本质就是多层链表,最底层存放所有元素,往上依次是相邻下层链表的子集。并且 Redis 中的跳表多了回退指针(只在最底层)。查找:从最上层开始,逐层向下进行查找,如果在该层得到数据,直接返回,否则确定查找元素的范围继续进行下一层查找,时间复杂度 O(logn)。插入:查询所要插入的位置,随机决定新节点的层数(25% 往上加一层),插入节点并更新指针。删除:查询所要删除的位置,删除节点并更新指针
3. 数据类型与数据结构的对应关系
- String:用 SDS,支持 O(1) 获取长度、存二进制
- List:使用 QuickList
- Hash:数据量小用 listpack 存储节省内存(Redis 7 之前使用的是 ziplist)【字段数 ≤ 512 且单个值 ≤ 64 字节】;数据量大用 hashtable 查询效率为 O(1)
- Set:数据量小且元素都是整数时,用 intset 存储节省内存【元素个数 ≤ 512】;数据量大或出现非整数元素时,自动转换为 hashtable,支持 O(1) 的增删查
- ZSet:数据量小用 listpack 存储节省内存(Redis 7 之前使用的是 ziplist)【元素个数 ≤ 128 且每个元素长度 ≤ 64字节】;数据量大用 skiplist+hashtable(跳表用来存储数据和快速查找,哈希表用来存储成员与其分数的映射)
4. Redis 数据过期后的删除策略有哪些
- 惰性删除:被动触发,每次读写某个 key 之前,先判断一下是否过期,过期了就删掉返回空
- 优点:不占用额外的 CPU 去扫描
- 缺点:如果 key 过期了一直没人访问,会一直存在,导致内存泄漏
- 定期删除:每隔一定时间抽取一批(20个)设置了过期时间的 key,将过期的 key 删掉,如果本次过期比例超过 25%,就会继续取下一批进行操作,如果过期低于 25%,结束本轮。每轮启动的时候会计时,达到 25ms 直接强制结束
- 优点:25% 阈值保证在过期 key 多的情况下加速清理,25ms 限制避免清理动作影响正常请求处理
- 缺点:可能清理不完全
- 所以一般是两种策略配合使用(惰性删除+定期删除)
- Redis 维护了一个专门存放设置了过期时间 key 的字典,key 指向实际的 key 对象,value 是过期时间的毫秒级 unix 时间戳。检查 key 是否过期就是拿当前时间和字典里存的时间戳对比。惰性删除是每次访问 key 前检查,定期删除是在字典里随机抽样检查
- 在主从场景下,从节点不会主动删除自己过期的 key,而是读到过期 key 就返回空(注意 3.2 版本之前依然能读取过期的 key ),等主节点同步过来 del 命令再执行删除
5. Redis 的内存淘汰策略有哪些
- 有 8 种淘汰策略
- noeviction:Redis 默认策略,内存满了直接报错拒绝写入
- volatile-lru:最近最少使用的先淘汰(设置了过期时间的 key)
- volatile-lfu:使用频率最低的先淘汰(设置了过期时间的 key)
- volatile-random:随机淘汰(设置了过期时间的 key)
- volatile-ttl:剩余存活时间短的先淘汰(设置了过期时间的 key)
- allkeys-lru:最近最少使用的先淘汰(所有 key)
- allkeys-lfu:使用频率最低的先淘汰(所有 key)
- allkeys-random:随机淘汰(所有 key)
- 关于 LRU 和 LFU 的缺点:LRU-某段时间有大量冷数据被扫描,真正的热点数据会被挤出去;LFU-如果历史访问了多次,近期内都不访问,也不会删除(4.0 对 LFU 进行了优化,访问计数会随时间缩减,避免“僵尸热点”)
- noeviction 一般用于消息队列、allkeys-lru 适用于大多数场景、allkeys-lfu 适用于有明确的热点数据
- 注意:如果没有任何 key 设置过期时间,那么用 volatile 毫无作用,跟没设置一样
- LRU/LFU 并不是精确算法,而是默认采样 5 个 key,选一个符合条件的淘汰。将这个值调大能提高淘汰精度,但会增加 CPU 开销,一般 5-10 个就行
6. Redis 的持久化机制有哪些
- RDB:快照持久化,某个时间点将整份内存数据转储为一个二进制文件,文件体积小,恢复速度快,适合做备份和灾难恢复。但是如果两次备份期间 Redis 挂了,就丢失了这段时间的数据
- 方式:bgsave,通过 fork 子进程来生成快照,fork 期间会阻塞主进程,之后主进程继续处理请求,子进程把内存数据写到临时文件,写完后替换掉旧的 dump.rdb 文件(还有一种就是 save 命令,但它是用主进程生成 RDB,期间 Redis 会阻塞,不推荐)
- 流程:检查是否有正在执行的 AOF/RDB 子进程,有的话直接报错--->调用 rdbSaveBackground 触发持久化--->fork 子进程生成 RDB,主进程继续处理请求--->新文件替换旧文件
- 注意:子进程创建时并不是真的复制一份内存而是复制页表(页表里存的是物理内存地址),父子进程共享一块内存,只有当某个进程要修改数据时,才会把对应的内存页复制一份出来单独修改
- AOF:日志持久化,将每条写命令追加到文件里,最多丢失 1s 数据,但是文件体积大,恢复速度慢
- 三种策略:always-每条命令执行完立刻写入;everysec-每秒写一次(默认);no-让操作系统决定什么时候刷盘
- AOF重写:随着时间推移,有很多命令执行,但最终有意义的只有少数,所以重写来生成新的精简的AOF文件
- 重写流程:fork 出子进程--->子进程生成新的 AOF 文件,期间主进程写的新命令追加到旧 AOF 和重写缓冲区--->子进程写完后,主进程将重写缓冲区内容追加到新的 AOF 文件--->新文件替换旧文件
- 混合持久化(4.0 之后引入):AOF 重写时先写 RDB 快照,后面的增量命令用 AOF 追加。恢复时先加载RDB 部分,再加载 AOF 部分,恢复速度和安全性提高
- 为了解决重复数据导致的内存、CPU、磁盘浪费问题,7.0 引入了 MP-AOF 机制,将单个 AOF 拆为基础文件、增量文件和清单文件。重写期间写命令添加到新的增量文件,子进程生成新的基础文件,重写完成后更新清单文件(增量文件+基础文件)
7. 缓存击穿/穿透/雪崩
- 缓存击穿:请求一个不存在的数据,无法进行缓存,每次都打到数据库
- 解决方案:参数合法性校验/布隆过滤器/缓存空值(推荐三者结合使用)
- 缓存穿透:某个 key 过期的瞬间,大量请求同时涌进来,全部打到数据库(一个)
- 解决方案:互斥锁/热点数据永不过期
- 缓存雪崩:一批 key 同时过期,请求全部打到数据库上(一片);还有一种可能就是 Redis 挂掉
- 解决方案:设置随机过期时间;对于 Redis 方面而言,可以考虑主从+哨兵、本地缓存兜底、熔断降级等措施
- 详细说说布隆过滤器:可以使用 Redis 自带的 RedisBloom 模块,把所有合法的 key 预先加载进去,请求来了先问布隆过滤器看看这个 key 存不存在,如果不存在直接返回。布隆过滤器可能会误判,但只会误判存在,不会误判不存在(位数组越大、哈希函数越多,误判率就越低。但内存占用和计算开销也会变高。一般把误判率控制在 1% 以内就可以了)
- 关于热点数据永不过期的方案:第一种就是不设置过期时间;第二种是在 value 中存一个过期时间戳,后台起一个线程定期检查,快过期了就刷新一下
第二部分 | 主从集群
1. Redis 主从复制的实现原理是什么
- 从节点连接主节点时会发送 psync 命令、runid、offset(首次:psync、?、-1),主节点收到命令判断为全量同步,返回 fullresync、master runid、offset,从节点保存 master runid 和 offset
- 主节点生成一份 rdb 快照发给从节点,从节点拿到后先清空自己的数据,然后加载这份快照(在生成和发送快照这段时间,主节点收到新的写命令会存在Replication Buffer的缓冲区内,等快照发完后,再将这个缓冲区的命令发送给从节点)
- 全量同步完成后,会在主从之间建立长连接,主节点收到命令后会异步发给从节点(期间也会进行心跳检测)
- 如果从节点断线重连,会告诉主节点自己当前的偏移量,主节点拿着这个偏移量去repl_backlog_buffer环形缓冲区查找,如果找到执行增量同步,将断线这段时间的命令发给从节点;如果找不到,说明缓冲区数据已经被覆盖了,只能进行全量同步(环形缓冲区的大小默认 1m,可适当调大)
- 注意点:从节点加载 rdb 时无法对外服务;主节点发送数据是异步的,自己写完不管从节点是否成功,直接返回,可以使用 wait 命令等待指定数量的从节点同步完成
2. Redis 主从复制延迟的常见原因有哪些,如何解决
- 网络层面:带宽不足、网络抖动、跨机房部署延迟高
- 解决方案:监控流量、检查网络稳定性、主从节点同机房部署
- 主节点瓶颈:写入 QPS 过高、生成 RDB/AOF 重写占用资源、大 key 写入阻塞
- 解决方案:分片、关掉主节点的持久化、大 key 拆为小 key
- 从节点瓶颈:机器配置低、磁盘 IO 跟不上、从节点数量太多
- 解决方案:升级硬件、使用 SSD、限制从节点数量考虑级联复制
- 复制机制:复制积压缓冲区配置过小,导致从节点频繁进行全量复制
- 解决方案:增加环形缓冲区大小
- 如果一定要读最新的数据(写入立刻读),可以采用:关键业务读主库;使用版本号/时间戳,如果从库数据太旧就读主库;使用 wait 命令,等至少一个从节点同步完成在返回,但性能大幅降低
第三部分 | 分布式锁
1. 如何使用 Redis 实现分布式锁
- 核心就是加锁时使用set key value ex seconds nx,nx 保证互斥,ex 设置过期时间防止死锁;解锁时使用 Lua 脚本先校验再删除,保证原子性
- 加锁流程:执行set key uuid ex 30 nx,Redis 检查该 key 是否存在,不存在则加锁成功;存在则返回 nil,加锁失败
- 解锁流程:执行 Lua 脚本,先获取当前 key 的值,判断值是否等于自己的 uuid,相等则进行解锁,否则锁不是自己的
点击查看示例
javapublic class RedisLock { private Jedis jedis; private String lockKey; private String uuid; private int expireTime = 30; // 秒 public boolean tryLock() { uuid = UUID.randomUUID().toString(); String result = jedis.set(lockKey, uuid, SetParams.setParams().nx().ex(expireTime)); return "OK".equals(result); } public void unlock() { String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " + "return redis.call('del', KEYS[1]) else return 0 end"; jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(uuid)); } }
2. Redis 实现分布式锁需要注意哪些问题
- 锁过期但业务没跑完:锁过期自动释放,其他线程就能拿到锁,可能导致数据不一致
- 解决方案:使用Redisson 提供的看门狗机制,锁自动续期
- 误删别人的锁:锁过期,另一个请求拿到锁,之前的请求执行完后删的锁就是新请求的锁了
- 解决方案:每次加锁带上唯一标识,使用 Lua 脚本先判断再删除
- 主从同步延迟:主节点刚写入锁就挂了,数据还没来得及同步到从节点,新主上来后锁就丢失了
- 解决方案:使用 RedLock,加锁时向所有实例申请加锁,超过一半的实例加锁成功才能真正拿到锁,但这种是并列实例,不适用于主从延迟
- 正确方案:做幂等校验或者使用 zookeeper/etcd 这种强一致性系统
- 时钟漂移:多节点的系统时间不一致,ttl判断不准确,锁可能提前释放或延迟释放
- 配置NTP(Network Time Protocol,网络时间协议)服务同步时间,将时钟偏差控制在毫秒级
- 锁不可重入:同一线程想要再次获取同一把锁,被拒绝了,比如递归场景
- 解决方案:使用 Hash 类型,key 是锁名,field 是客户端标识+线程 ID,value 是重入次数,减到 0 才真正删除锁
3. RedLock 是什么
- RedLock 专门用来解决主从架构下锁丢失问题:主节点加锁成功,还没同步到从节点就挂了,从节点晋升为主节点但锁的数据没有同步过来,另一个客户端来加锁也能成功,此时就会有两个客户端同时操作临界区资源,可能导致数据不一致
- RedLock 实现思路是独立部署多台Redis实例(一般 5 台),通过投票的方式判断加锁是否成功。不需要主从复制,也不需要哨兵,完全独立
- 加锁流程:记录当前时间 t1--->向所有 Redis 实例发送加锁命令--->统计成功加锁的实例数量以及当前时间 t2 --->只有成功数量≥半数以上且加锁总耗时 < 锁过期时间(t2-t1)才算加锁成功--->否则向所有实例发送解锁命令
- 不足:时钟漂移、STW 等场景可能导致锁被释放,另一个客户端就能拿到锁,同时操作临界区资源,造成数据不一致。所以 RedLock 不能保证 100% 可靠;并且 RedLock 需要部署多台实例,访问多个节点,增大了资源开销与延迟
- 所以大多数场景还是采用主从+哨兵方案,配合 Redisson 看门狗机制续期,以及幂等性校验;如果需要强一致性的分布式锁,可以考虑 zookeeper 或 etcd
4. Redisson 分布式锁的原理
- 核心就是使用 Redis 的 Hash 结构+ Lua 脚本保证原子性+后台线程自动续期
- 加锁流程
- 如果锁不存在,直接创建 Hash 结构,field 是线程标识,value 设为 1,设置过期时间,加锁成功
- 如果锁存在且 field 是当前线程,说明是重入,将 value 加1,刷新过期时间,加锁成功
- 如果 field 不匹配,说明锁被占用,返回这个锁的剩余存活时间,加锁失败。等收到消息或者到达返回的剩余存活时间再重新加锁
- 解锁流程
- 锁不存在或者 field 不匹配当前线程,直接返回
- field 匹配,value 减 1,如果减完 value 依然大于 0,说明还有重入没退出,刷新锁的过期时间
- value 减到 0,删除 key,推送消息通知其他等待的线程
- Redisson 锁的类型
- 可重入锁:同一线程可多次获取同一把锁
- 公平锁:按顺序排队,先到先得
- 读写锁:读锁共享,写锁独占
第四部分 | 看门狗机制
1. 说一说 Redisson 的看门狗机制
- 看门狗机制是用来解决分布式场景下锁的续期问题。比如业务逻辑没执行完锁就到期释放了,别的线程拿到锁,两个线程同时操作临界资源,造成数据不一致
- 看门狗的实现原理是:如果不指定过期时间,Redisson 会启动一个定时任务,每隔 10s 向 Redis 发一次请求,如果任务还没结束,就通过 Lua 脚本将锁的过期时间重置为 30s,所以只要任务没结束,锁就不会释放(期间如果出现客户端宕机等问题,定时任务也就没了,到了 30s 会自动释放锁)
- 续期间隔设置为锁过期时间的 1/3 的原因:在续期频率和网络开销取了个平衡值,太频繁对 Redis 压力大; 1/3 的过期时间也保证了出现网络抖动等问题续期失败后,还有机会重新续期
- 注意:看门狗机制不一定 100% 解决冲突问题,如果某些原因打断了锁的自动续期,到了时间锁就释放了,这时候任务还在跑着,另一个线程拿到锁就会同时操作临界区资源了。所以关键业务必须保证幂等和进行数据校验
- 注意:如果业务代码出现死循环,看门狗就会一直对锁进行续期,锁永远不会释放。所以一定要保证 unlock在 finally 里调用;或者使用 try-with-resources;或者放弃看门狗,主动设置过期时间等机制
2. Redisson 看门狗机制的进一步探究
- 续期主要涉及来个方法:获取续期任务(scheduleExpirationRenewal)、定期刷新过期时间(renewExpiration)
- scheduleExpirationRenewal:客户端拿到锁之后调用,将当前锁注册到续期 map 里,然后启动定时任务
- 创建一个 entry 对象存锁的过期时间
- 使用 putIfAbsent 往 map 里塞,如果已经有了就把当前线程 ID 加进去
- 否则,调用 renewExpiration 启动定时任务;如果线程被中断,调用 cancelExpirationRenewal 取消续期
- renewExpiration:通过 Netty 时间轮创建定时任务
- 从 map 里拿 entry,如果拿不到说明锁已经释放,直接返回
- 检查里面有没有线程 ID,没有就说明没人持有锁,直接返回
- 调用 renewExpirationAsync 异步续期,续期成功就重新调度自己,失败就取消续期
- renewExpirationAsync 的逻辑其实很简单,就是检查锁是不是自己的,是的话就续期
- cancelExpirationRenewal 的逻辑是移除线程 ID,然后判断是否还有任务,没有的话就取消定时任务并移除 entry
- 使用 Netty 时间轮的原因:添加和取消任务都是 O(1),并且 Redisson 底层通信依赖 Netty,直接复用 Netty 的时间轮也避免引入额外的线程池
第五部分 | 事务
1. Redis 是否支持事务,如果支持如何实现
- Redis 是支持事务的,但和 Mysql 中的事务不同,Redis 只保证将命令打包执行不被其他客户端插队,不支持回滚(因为 Redis 命令只有语法错误/类型错误的情况下才会执行出错,属于程序方面的问题,应该在开发阶段修正)
- Redis 通过 multi、exec、watch、discard 这四个命令实现事务
- multi 用来开启事务,之后的命令不会立刻执行,而是进入一个队列排队。可以放多条命令,Redis 会回复 queued 表示入队成功
- exec 会一次性把队列中的任务全部执行,期间不会被其他客户端的命令打断
- 如果不想执行了,使用 discard 丢弃队列
- watch 则用来在 multi 前监视某些 key,如果这些 key 在 exec 前被改了,整个事务作废
- Redis 会在入队阶段做语法检查,如果有问题(比如参数个数不对),入队会报错,后续 exec 会拒绝执行整个事务;如果是类型错误,则会成功入队,但在执行时这条命令会报错,其他命令正常执行
- 再说一说 watch 这个命令,它类似于乐观锁,watch 之后的 key 如果被别的客户端改了,exec 时会返回 nil表示事务被放弃,需要重新读取 key 的值重试。底层实现就是 Redis 给每个被 watch 的 key 维护一个客户端列表,key 被修改时标记这些客户端的事务为失效状态
- 在实际开发中,要想实现多条命令原子执行,还是推荐 Lua 脚本。Lua 脚本在 Redis 中执行是原子性的,并且可以写判断逻辑,比事务更灵活,并且网络往返只需一次
2. Redis 的 Pipeline 是什么,与事务、Lua 脚本、原生批处理命令有什么区别
- pipeline 可以让客户端将多条不同类型的命令打包一次性发给 Redis,减少网络往返次数。但是它不能保证原子性,批量执行命令的时候,失败不会回滚,而是继续执行;执行过程中可能穿插其他客户端的命令;底层原理是把命令的收发放到内存缓冲区,如果命令数量太多会导致两边内存占用增加。如果涉及多个节点,需要客户端先进行处理
- 而事务使用 multi/exec 包裹命令,虽然也不支持回滚,但是具有原子性,这批命令执行期间不会穿插其他客户端的命令
- Lua 脚本在 Redis 中执行是原子性的并且支持条件判断,脚本一次性执行,其他客户端没法穿插命令,但是同样不支持回滚
- mget/mset 等原生处理命令只能用于同一种数据类型,天生具有原子性,中间不会被插入其他命令。如果涉及多个节点,需要客户端手动处理
第六部分 | 性能优化
1. 如何解决热点 key 的问题
- 首先明确热点 key 的定义:访问频率占比大、带宽占比大、CPU 使用时间占比大等场景
- 其次是发现热点 key
- 业务预判:提前预判哪些 key 可能成为热点,但无法应对突发情况,适用于可预期的活动
- Redis 自带的命令redis-cli-hotkeys:扫描慢,非实时,适用于临时排查
- monitor 命令:监控所有命令执行,配合脚本统计 key 的频次,但会损失 50% 的性能,生产环境慎用!
- 客户端埋点:在 Redis 客户端 SDK 加统计逻辑,进行上报监控,但是改造成本高,适用于有统一 SDK 的场景
- proxy 层收集:客户端无感,但是需要部署代理,适用于已有代理架构的场景
- 最后针对性的解决热点 key 的问题
- 拆分 key:将 key 放到不同节点(全量复制/分片存储)
- 多级缓存:在 Redis 前加 cdn/本地缓存
- 读写分离:一主多从,主节点只负责写,读请求发到从节点
- 限流降级:当某个 key 访问频率过高,进行限流,必要时返回降级的数据或空值
2. Redis 达到性能瓶颈后应该如何处理
- 分析瓶颈点
- 内存不足:频繁淘汰数据,响应时间飙升
- CPU 打满:耗时操作、大 key 操作等
- 网络带宽:比如传输一个达到最大限制的 512M 的数据
- 持久化阻塞:AOF 重写或生成 RDB 快照的时候,fork 子进程会拷贝页表,内存越大 fork 越慢,这段时间主进程是卡死的
- 解决方案
- 垂直扩容:看单机资源有没有用满,如果还有资源就加内存
- 读写分离:比如一主三从,读请求全部路由到从节点,QPS 直接翻 3 倍
- Redis 集群分片:数据量太大或者写请求压力大,就要使用分片集群,16384 个槽位分散到多个主节点,横向拓展写能力
- 多级缓存:加一层本地缓存(Caffeine/Guava Cache),热点数据放本地,减少向 Redis 请求的次数(本地缓存的时间调小一点,减小不一致的窗口期时间;如果对实时性要求高,可以使用发布订阅或者消息队列广播消息清空本地缓存)
- 实际项目中这几种策略往往结合使用:读写分离+集群分片+多级缓存
- 对于 RDB 持久化的优化:控制内存大小增加 fork 的速度、使用混合持久化减少 RDB 的频率等
3. Redis 中的内存碎片是什么,如何处理
Redis 默认使用 jemalloc 做内存分配器(在多线程环境下碎片率低,虽然 Redis 是单线程,但后台有 IO 线程和持久化线程,jemalloc 综合表现更好),按固定大小分配内存,比如实际只需要 8KB,分配器却给了 12KB,多出来的 4KB 就浪费了,这就是内存碎片
并且经过频繁的创建、删除,内存块的大小和位置会变得不连续,碎片化越来越多
可以通过查看操作系统层面 Redis 的内存(used_memory_rss)占用和 Redis 真实的内存(used_memory)占用计算出碎片率(used_memory_rss/used_memory)。一般来说 1-1.5 算正常;超过 1.5 就应该考虑清理一下;小于 1 说明 Redis 内存不够用,已经在进行 swap 了,性能大幅度下跌(物理内存不够,把内存数据暂时放到磁盘上,这块磁盘空间就叫做 swap)
碎片整理的方案 a. 重启 Redis:最简单,重启后内存会重新分配,碎片自然就没了。但服务会中断,生产环境慎用 b. 使用 activedefrag 自动碎片整理:4.0 引入,会在运行时自动整理碎片,增量式处理,每次处理一小部分。但会消耗额外资源
点击查看示例
shell# 开启自动碎片整理 activedefrag yes # 碎片占用的字节数超过 100MB 时才开始整理 active-defrag-ignore-bytes 100mb # 碎片率达到多少百分比才触发 active-defrag-threshold-lower 10 active-defrag-threshold-upper 100 # CPU 使用率控制,避免整理影响正常请求 active-defrag-cycle-min 1 active-defrag-cycle-max 25
c. 手动触发:手动执行memory purge,期间会阻塞主线程,生产环境慎用 5. 预防方案:让 key 的大小固定或接近;给 key 设置过期时间让 Redis 自己清理;7.0 版本将 ziplist 换成了 liskpack,内存利用率更高,从源头减少了碎片
第八部分 | 其他
1. Redis 快的原因有哪些
- 基于内存:访问内存的速度比访问 SSD 磁盘快了将近 1000 倍
- 单线程+ IO 多路复用:Redis 采用单线程执行,没有锁竞争和上下文切换;采用多路复用,一个线程监听多个连接
- 高效的数据结构:比如 String 底层是 SDS,O(1) 获取长度;Hash 小数据量用 listzip 省内存,大数据量用hashtable 保证 O(1) 查询;ZSe t使用跳表,插入查询都是 O(logN)
- Redis 6.0 引入了多线程来处理网络 IO,命令执行还是单线程的
2. Redis 的适用场景有哪些
- 作缓存提升性能:常用 String 类型来存储用户信息、Session、查询结果,查询时直接操作内存,速度快
- 作分布式锁解决并发问题:Redis 是单线程,天生能保证原子性,谁先拿到锁谁先执行。可以使用 Redis 的setnx 命令或者使用 Redisson 框架(使用 setnx 时需要注意锁要设置过期时间、用 set ex nx这种原子命令、释放锁要检查是不是自己的)
- 作计数器与限流:比如阅读量、点赞数,如果让这些数据都去操作数据库,锁竞争太大了。Redis 基于内存,并且单线程原子性,incr 自增,性能高且不会算错
- 作排行榜:需要获取实时排名的情况,用数据库会经常全表扫描,开销太大;Redis 中的 ZSet 数据类型,会自动进行排序(zadd/zrange)
- 作消息队列:如果只是做简单的异步处理,或者其他原因不想单独引入 mq,就可以用 Redis 的 List 数据类型实现简单的任务队列(lpush/brpop)[Redis 5.0 引入了 Stream,支持消费者组、消息 ID、ACK 确认、消费失败重新处理]
3. Java 中有哪些 Redis 客户端
- Jedis:同步阻塞、API 简单直接、线程不安全需要连接池
- Lettuce:异步非阻塞、基于 Netty、线程安全可共享链接(SpringBoot 2.x 之后默认客户端)
- Redisson:异步非阻塞、高级封装、提供分布式锁、限流器等开箱即用的组件(RLock 分布式锁、RReadWriteLock 读写锁、RSemaphore 信号量、RRateLimiter 限流器、RBloomFilter 布隆过滤器、RDelayedQueue 延迟队列)
4. 保证缓存和数据库一致性的方案有哪些
- 先更新数据库,再删缓存:但有个临界问题,如果此时缓存刚好过期,且此次读操作慢于更新数据库删除缓存,那么旧数据又被写回缓存了,但这种情况出现概率极低
- 缓存双删:先删缓存,再更新数据库,一段时间后再删缓存。解决“先删缓存,再更新数据库”的并发问题。但延迟时间不好确定
- Binlog 异步更新:只写数据库,canal 伪装成数据库的从节点,订阅 binlog,解析出变更事件,发送到消息队列,由消费者负责删除缓存,这样即使失败了也能重试,可以保证最终一致性(但要保证顺序消费!幂等处理)
- 强一致性方案:使用分布式读写锁,读读不互斥,读写/写写互斥,但会导致性能下降
5. 聊一聊 Lua 脚本
- Lua 脚本的特性:在Redis中是原子性的、减少网络往返次数、封装复杂操作等特性,超过了单个 Redis 命令的能力
- 典型场景是分布式锁:在分布式锁场景下,释放锁前要判断锁是不是自己的,是的话再删除。由于判断和删除不是原子的,可能会误删别人的锁(比如刚判断完这个锁就过期了,另一个请求拿到锁,那么接下来删除的就是新请求的锁了),Lua 脚本把判断和删除放在一起,解决了这个问题
- 在 Redis 中还有预加载功能:如果每次执行都要把完整的脚本发给 Redis,脚本传输会有网络开销,因此可以先把脚本缓存到 Redis,返回一个 sha1 哈希值,以后直接发送哈希值就行,省掉脚本内容传输的开销,生产环境建议使用这种方式(注意 Redis 重启后脚本缓存会丢失,所以要在代码里处理对应错误,重新加载一次)
- Lua 脚本超时逻辑:默认 5s 超时,超时后不会立刻停止,而是接受客户端的 kill 命令,如果脚本还没执行写操作,会直接终止掉;如果已经写了一部分,再想停止,只能重启 Redis。所以 Lua 脚本不要太复杂
6. 如何使用 Redis 实现布隆过滤器
- 布隆过滤器用于判断某个元素存不存在,可能会误判,但只会误判存在的,不存在就一定不存在。实现方式主要有两种,一种是位图手动实现,另一种是用官方的 RedisBloom 模块
- 位图手动实现:自己管理哈希函数和位数组,主要通过 Redis 的 setbit/getbit 命令,将位置设为 1 或者获取对应位置的值
- RedisBloom:官方提供,只需要指定误判率和容量,自动算出最优的位数组大小和哈希函数个数;支持 Cuckoo Filter 用来删除;支持动态扩容
- 本质:由一个位数组和多个哈希函数组成。添加元素时通过 k 个哈希函数计算出 k 个位置,将这些位置设为 1;查询时同样使用 k 哈希函数计算 k 个位置,如果全为 1 则可能存在,有一个为 0 一定不存在
- 还有一点就是布隆过滤器是单向的,只能新增不能删除。因为位数组里某个位可能是多个元素共同设置的,如果为了一个元素变更状态,其他元素也会收到影响。当然后续也出现了很多变种,允许删除
- 适用于数据量大、允许误判、只需判断存在性的场景:缓存击穿、垃圾邮件过滤、推荐系统去重
7. 说一下 Redis 的哨兵机制
- 使用 Redis sentinel 来对 Reids 主从进行监控、故障转移、通知;此时客户端会先向哨兵询问主节点的地址,拿到地址后再去连接 Redis
- 奇数个(>=3)哨兵组成一个集群,可以相互通信,每个哨兵监控所有 Reids 的状态
- 哨兵判断节点下线分为两步,主观下线和客观下线。每个哨兵每隔 1s 向所有 Redis 发送 ping,如果指定时间没收到 pong,则这个哨兵会认为该节点主观下线。如果是主节点,为了避免是因为网络抖动而未收到响应,这时这个哨兵会向其他哨兵询问并投票,如果票数达到指定值,则会认为主节点客观下线,开始走故障转移流程(这个值一般配置为哨兵总数/2+1)
- 确定主节点客观下线了,会选出一个哨兵来主持故障转移,选举策略是:第一个发现主节点挂掉的哨兵先投自己一票,然后让其他哨兵投票,每个哨兵只有一票,并且谁先来就投谁,候选者拿到一半以上的票就成为Leader(可能会出现同票的情况,那么过一段时间会重新投票,所以最好哨兵设置为奇数个)
- 然后选举主节点,哨兵 Leader 会从剩下的从节点选出一个来当主节点,选举策略是:先看优先级(0 表示不参选,值越小优先级越高),优先级相同就选 offset 大的那个,offset 还相同就选 runid 最小的那个
- 最后 Leader 哨兵会给新主节点发送slavof no one成为主节点,给其他从节点发送新节点 IP 和端口,与新节点同步。如果原来的主节点恢复了,也是成为新主节点的从节点
