Redis 面试题
2024/8/8About 30 min
Redis 专题
每块分 入门 / 中等 / 高难。Redis 是 Java 面试和 Python 面试都会问的,你有 Java 时期 Redisson / JetCache 的经历,加上现项目的 Stream / ZSet / Lua,素材很足。
目录:一、基础与线程模型 → 二、数据结构与底层编码 → 三、持久化 → 四、过期与淘汰 → 五、主从、哨兵、集群 → 六、事务、Lua、Pipeline → 七、缓存设计 → 八、分布式锁 → 九、限流 → 十、消息队列(List / Pub/Sub / Stream)→ 十一、大 key 与热 key → 十二、性能排查 → 十三、Python 客户端 → 十四、项目里的 Redis → 十五、综合场景
〇、入门速答(10 题)
- 默认端口和数据库数量? 端口 6379;单机默认 16 个逻辑库(0–15),
SELECT n切换。集群模式只有 0 号库。实践中不同业务用不同实例或 key 前缀区分,而不是用多个库。 - key 怎么命名? 用冒号分层:
业务:对象:ID:字段,比如completion:stream:{card_id}、ratelimit:{key}:rpm。好处是清晰、方便按前缀SCAN、集群下配合 hash tag。key 不要太长(占内存)。 - 怎么设置和查看过期时间?
SET k v EX 60或EXPIRE k 60;TTL k返回剩余秒数,-1 表示永不过期,-2 表示 key 不存在;PERSIST k去掉过期时间。注意:对 key 重新SET(不带 EX)会清掉原来的过期时间。 - 一个 String 最大能存多少? 512MB。但实际上 value 超过 10KB 就算大 key,要避免。
- 怎么查看有哪些 key? 用
SCAN 0 MATCH user:* COUNT 100分批迭代;生产禁止KEYS *(会阻塞)。DBSIZE看 key 总数。 - 怎么实现一个计数器(PV、点赞数)?
INCR/INCRBY/DECR,原子操作,并发安全,不需要先读再写。按天计数可以INCR pv:20261021再设过期。 - 怎么批量读写?
MSET/MGET(单条命令原子);不同命令组合用 pipeline(减少往返,不保证原子)。 - Redis 适合做什么、不适合做什么? 适合:缓存、计数、排行榜、分布式锁、限流、会话、轻量队列、去重。不适合:大量冷数据长期存储(内存贵)、复杂查询和关联、对持久化要求极高的核心数据(以数据库为准)。
- 常用运维命令?
INFO(各种状态)、INFO memory、CONFIG GET maxmemory、SLOWLOG GET、CLIENT LIST(当前连接)、MONITOR(打印所有命令,生产慎用,影响性能)、FLUSHDB/FLUSHALL(清空,生产禁用,可以用rename-command禁掉)。 - Redis 怎么保证数据不丢? 开持久化(推荐 AOF everysec + 混合持久化,最多丢 1 秒)+ 主从复制 + 哨兵或集群做高可用。但复制是异步的,主库挂掉的瞬间仍可能丢最近的写入,所以真正不能丢的数据以数据库为准。
一、基础与线程模型
入门
- Redis 是什么? 基于内存的键值数据库,支持多种数据结构,常用于缓存、计数、排行榜、分布式锁、消息队列、限流。
- 为什么快? 数据在内存;单线程执行命令,没有锁和线程切换;IO 多路复用(Linux 上是 epoll),一个线程管成千上万个连接;数据结构针对性优化(小数据用紧凑编码)。
- Redis 和 Memcached? Redis 数据结构丰富、支持持久化、主从、集群、Lua;Memcached 只有字符串、多线程、无持久化。现在基本都用 Redis。
中等
- Redis 真的是单线程吗?
- 命令执行一直是单线程的(所以单条命令天然原子)。
- Redis 4.0 起有后台线程做异步删除(
UNLINK、FLUSHALL ASYNC)、AOF 刷盘、关闭文件。 - Redis 6.0 起有 IO 多线程(
io-threads):网络读写和协议解析可以多线程,命令执行仍是单线程。适合网络是瓶颈的高 QPS 场景。
- 单线程有什么风险? 一个慢命令(
KEYS *、对大集合SMEMBERS、HGETALL一个百万字段的 hash、耗时的 Lua)会阻塞所有其他请求。
高难
- 为什么命令执行不做成多线程? Redis 的瓶颈通常在内存和网络,不在 CPU;多线程要给所有数据结构加锁,复杂度和开销都上去了;单线程保证了命令的原子性,Lua、事务的实现都依赖这一点。真要多核就开多个实例 / 集群分片。
二、数据结构与底层编码
入门
- 五种基本类型和典型用途?
类型 用途 String 缓存、计数器( INCR)、分布式锁、SessionHash 对象(用户信息的各个字段) List 简单队列、最新 N 条 Set 去重、标签、共同好友(交集) ZSet(有序集合) 排行榜、延迟队列、滑动窗口限流 - 其他类型? Bitmap(签到、在线状态,一位一个用户)、HyperLogLog(基数统计 UV,固定 12KB,误差约 0.81%)、Geo(附近的人,底层是 ZSet)、Stream(消息队列)、Bitfield。Redis 8 还把 JSON、向量集合等以前的模块并进了核心。
中等
- String 底层是什么? SDS(简单动态字符串):记录长度(
O(1)取长度)、预分配空间(减少追加时的内存分配)、二进制安全(可以存任意字节)。三种编码:int(整数)、embstr(≤ 44 字节,和对象头一次分配)、raw。 - 各类型的底层编码?(数据量小时用紧凑编码省内存,超过阈值转换)
类型 小数据 大数据 List listpack(quicklist 的节点) quicklist(双向链表,每个节点是一个 listpack) Hash listpack hashtable Set intset(全是整数)/ listpack(7.2+) hashtable ZSet listpack skiplist + dict - 老版本的 ziplist 在 7.0 被 listpack 替代(ziplist 有连锁更新问题)。
- 阈值参数:
hash-max-listpack-entries(128)、hash-max-listpack-value(64 字节)等。
- ZSet 为什么用跳表 + 哈希表? 哈希表:
O(1)按成员查分数(ZSCORE);跳表:按分数有序,O(log N)插入删除,范围查询(ZRANGEBYSCORE)直接顺着链表走。 - 为什么用跳表不用红黑树 / B+ 树? 范围查询更简单(找到起点后顺着底层链表走);实现简单,好调试;通过调层数概率可以平衡内存和速度;B+ 树为磁盘设计,内存里没必要。
- 字典(hashtable)怎么扩容? 渐进式 rehash:两张表并存,每次增删改查顺带迁移一个桶,后台定时任务也迁一点,避免一次性迁移几百万个 key 卡住。期间查询要查两张表。
高难
- 跳表的结构? 多层有序链表,最底层包含所有元素,每上一层随机(概率 1/4)保留部分节点作为「快速通道」。查找从最高层开始,向右走到不能走就下一层。Redis 的跳表最高 32 层,每个节点还存了跨度(span),用来
O(log N)算排名(ZRANK)。 - HyperLogLog 为什么 12KB 能统计上亿个元素? 基于概率:对每个元素哈希,看二进制末尾连续 0 的个数,最长连续 0 越多说明元素越多。分成 16384 个桶分别统计再取调和平均,降低误差。代价:只能估计数量,不能判断某个元素是否存在。
- 怎么省内存?
- 利用紧凑编码:把大量小 key 合并成 hash(
user:1001拆成user:10这个 hash 里的字段01),每个 hash 控制在 listpack 阈值内; - key 名字短一点;
- 用合适的类型:布尔状态用 Bitmap、计数去重用 HyperLogLog;
- 设置过期时间,别让冷数据永久驻留。
- 利用紧凑编码:把大量小 key 合并成 hash(
三、持久化
入门
- 两种持久化方式?
- RDB:某个时刻的全量快照(二进制文件),体积小、恢复快,但两次快照之间的数据可能丢。
- AOF:记录每条写命令,追加写到文件,数据更完整,但文件大、恢复慢。
中等
- RDB 怎么做到不阻塞?
BGSAVEfork 一个子进程写快照,主进程继续服务。依赖操作系统的写时复制(COW):fork 时父子进程共享内存页,主进程修改某页时才复制那一页。(SAVE是在主进程里做,会阻塞,别用。) - AOF 的三种刷盘策略?
appendfsync always(每条命令 fsync,最安全最慢)、everysec(每秒一次,默认,最多丢 1 秒)、no(交给操作系统,可能丢几十秒)。 - AOF 重写? AOF 文件越来越大(对一个计数器 INCR 一万次就有一万条),
BGREWRITEAOF根据当前内存数据生成最小命令集,替换旧文件。也是 fork 子进程做,期间新命令写到重写缓冲区,最后合并。Redis 7.0 改成多文件 AOF(base + incr 文件 + manifest)。 - 混合持久化?
aof-use-rdb-preamble yes(4.0+):重写时文件开头是 RDB 格式的全量数据,后面追加 AOF 增量。兼顾恢复速度和数据完整性。生产推荐。
高难
- fork 有什么风险?
- fork 本身要复制页表,内存 20GB 的实例 fork 可能耗时几百毫秒,期间主进程卡住;
- 写多的场景 COW 会复制大量页,内存最坏翻倍,可能触发 OOM;
- 关闭透明大页(THP),否则 COW 时以 2MB 为单位复制,放大更严重。
- 所以单实例内存别太大(一般建议 10GB 以内),大了就分片。
- 作为缓存还要开持久化吗? 纯缓存可以关(重启后从数据库重建);但重启时缓存全空会让请求全部打到数据库(缓存雪崩),所以有的场景也会开 RDB 用来快速预热。我们用 Redis 做 Celery broker 和对话事件流,必须开持久化,否则重启后排队的任务全丢。
四、过期与淘汰
入门
- 过期 key 怎么删除? 两种结合:
- 惰性删除:访问时发现过期就删;
- 定期删除:后台每秒 10 次(
hz),每次随机抽 20 个带过期时间的 key,删掉过期的,过期比例超过 25% 就继续抽。 所以过期的 key 不是立刻消失的,可能还占着内存。
- 内存满了怎么办? 按
maxmemory-policy淘汰:策略 说明 noeviction(默认)不淘汰,写命令直接报错 allkeys-lru所有 key 里淘汰最近最少使用的(缓存场景最常用) volatile-lru只在设了过期时间的 key 里淘汰 allkeys-lfu/volatile-lfu按访问频率淘汰(4.0+) allkeys-random/volatile-random随机 volatile-ttl优先淘汰快过期的
中等
- Redis 的 LRU 是精确的吗? 不是。精确 LRU 要维护一个全局链表,每次访问都要移动,开销大。Redis 是近似 LRU:每个对象记录最后访问时间,淘汰时随机抽
maxmemory-samples(默认 5)个,淘汰其中最久没访问的。3.0 起维护一个候选池,效果接近精确 LRU。 - LRU 和 LFU 怎么选? LRU 怕「偶尔一次批量扫描」把热点数据挤掉(扫过的冷数据都变成「最近访问」);LFU 看频率,能抵抗这种污染,并且计数会随时间衰减。热点明显的缓存用 LFU 更好。
- 混用的坑:如果 Redis 里既有缓存又有不能丢的数据(队列、锁、限流计数),用
allkeys-lru会把不能丢的也淘汰掉。方案:分开实例;或者用volatile-lru并保证缓存都设过期时间、重要数据不设过期。我们的 Celery 队列和缓存如果在同一个实例里,就必须注意这个。
高难
- 大量 key 同时过期会怎样? 定期删除时一次要删很多,可能占用较多 CPU,造成延迟抖动;同时缓存集中失效还会引发雪崩。解法:过期时间加随机偏移。
- 主从下过期怎么处理? 从库不主动删除过期 key,等主库删除后同步
DEL过来;但从库读到过期 key 时会返回空(3.2 起),避免读到过期数据。
五、主从、哨兵、集群
入门
- 三种部署形态?
- 主从复制:一主多从,从库只读,读写分离、数据备份;主库挂了要手动切换。
- 哨兵(Sentinel):在主从基础上加哨兵进程监控,主库挂了自动选一个从库提升为主。
- 集群(Cluster):数据分片到多个主节点,每个主节点有从节点,同时解决容量和高可用。
中等
- 主从复制的流程?
- 全量同步:从库发
PSYNC,主库BGSAVE生成 RDB 发给从库,期间的新写命令缓存在复制缓冲区,RDB 发完再发增量; - 增量同步:从库断线重连后带上
replid和偏移量,如果偏移量还在主库的复制积压缓冲区(repl-backlog-size,环形缓冲区)里,就只补发差量;不在就只能全量。 - 复制是异步的,主库挂了可能丢数据。
WAIT numreplicas timeout可以等指定数量的从库确认,但不能保证强一致。
- 全量同步:从库发
- 哨兵怎么判断主库挂了、怎么选新主?
- 主观下线:一个哨兵 ping 不通主库超过
down-after-milliseconds; - 客观下线:达到
quorum个哨兵都认为下线; - 哨兵之间选一个领头(类似 Raft 的投票)执行故障转移;
- 选新主的顺序:优先级(
replica-priority)→ 复制偏移量最大(数据最新)→ run id 最小; - 通知其他从库改从新主、通知客户端新主地址。
- 主观下线:一个哨兵 ping 不通主库超过
- 集群怎么分片? 16384 个哈希槽,
CRC16(key) % 16384决定 key 在哪个槽,每个主节点负责一部分槽。客户端请求打到错误节点会收到MOVED(槽已经永久迁走)或ASK(正在迁移中,临时去另一个节点问)重定向。 - 集群的限制? 多 key 操作(
MGET、事务、Lua)要求所有 key 在同一个槽 → 用 hash tag:{user:1001}:profile和{user:1001}:orders,只有花括号里的部分参与哈希;不支持多数据库(只有 db0)。
高难
- 为什么是 16384 个槽? 节点之间用 gossip 心跳交换各自负责哪些槽,用位图表示,16384 位 = 2KB,心跳包可以接受;65536 就是 8KB,太大。另外集群规模一般不超过 1000 个主节点,16384 足够均匀分配。
- 集群扩容怎么做? 加入新节点 → 把一部分槽从旧节点迁移到新节点(
redis-cli --cluster reshard),迁移是逐个 key 的MIGRATE,期间客户端通过ASK重定向访问。大 key 迁移时会阻塞。 - 脑裂会丢数据吗? 会。网络分区时旧主还在接收写入,新主被选出来后旧主恢复会变成从库,丢掉期间的写入。缓解:
min-replicas-to-write 1+min-replicas-max-lag 10(旧主发现自己连不上足够的从库就拒绝写)。 - 哨兵和集群怎么选? 数据量单机放得下、主要要高可用 → 哨兵;数据量大或写 QPS 单机扛不住 → 集群。集群对客户端有要求(要支持集群协议),多 key 操作受限。
六、事务、Lua、Pipeline
入门
- Pipeline 是什么? 客户端把多条命令一次性发出去,再一次性读回所有结果,减少网络往返(RTT)。不保证原子性,中间可能插入别的客户端的命令。批量写入时性能提升几倍到几十倍。
中等
- MULTI / EXEC 事务?
MULTI后命令进队列,EXEC时一次性顺序执行,中间不会插入别的命令。但不支持回滚:某条命令运行时出错(比如对 String 用LPUSH),其他命令照样执行。 - WATCH 是什么? 乐观锁:
WATCH key后,如果在EXEC之前 key 被别人改了,整个事务不执行(返回 nil),客户端重试。 - Lua 脚本为什么比事务好用? 脚本整体原子执行,而且可以在中间做判断(读出值 → 判断 → 再写),事务做不到(事务里的命令在 EXEC 前拿不到结果)。限流、分布式锁释放、库存扣减都用 Lua。
- Lua 怎么用?
EVAL script numkeys key1 ... arg1 ...;脚本很长时先SCRIPT LOAD拿到 sha,之后EVALSHA sha ...,省带宽。Python 的register_script自动处理这个。
高难
- Lua 脚本的风险? 执行期间阻塞所有请求,脚本里别做循环大量 key 的事;超过
busy-reply-threshold(老名字lua-time-limit,默认 5 秒)后其他命令会收到BUSY错误,只能SCRIPT KILL(脚本还没写过数据时)或SHUTDOWN NOSAVE。 - 集群下 Lua 的限制? 脚本操作的所有 key 必须通过
KEYS参数传入,并且在同一个槽(hash tag),否则报CROSSSLOT。不要在脚本里拼 key 名,集群模式下路由不到。 - Redis 7 的 Functions? 把 Lua 脚本作为函数库持久化在 Redis 里(随 RDB / AOF 保存、随复制同步),不用每次客户端加载。
七、缓存设计
入门
- 缓存的三个经典问题?
问题 现象 解法 穿透 查不存在的数据,缓存和数据库都没有,每次都打到数据库(常见于恶意请求) 缓存空值(短过期);布隆过滤器挡住不存在的 key;参数校验 击穿 一个热点 key 过期的瞬间,大量请求同时打到数据库 互斥锁(只让一个请求重建,其他等);逻辑过期(永不过期,后台异步刷新);热点数据不过期 雪崩 大量 key 同时过期,或 Redis 宕机,请求全打到数据库 过期时间加随机值;Redis 高可用;本地缓存兜底;限流降级
中等
- 几种缓存读写模式?
- Cache Aside(旁路缓存,最常用):读:先读缓存,没有再读库并写缓存;写:先更新数据库,再删除缓存。
- Read / Write Through:应用只和缓存打交道,由缓存层负责同步读写数据库。
- Write Behind:只写缓存,异步批量写数据库,性能最好但可能丢数据。
- 为什么是「删除缓存」而不是「更新缓存」? 并发更新时,两个请求更新数据库和缓存的顺序可能交错,导致缓存是旧值;而且更新的缓存不一定有人读(浪费)。删除后由下次读请求重建,简单可靠。
- 为什么是「先更新数据库,再删缓存」而不是反过来? 先删缓存再更新数据库:删完缓存后、数据库更新前,另一个读请求读到旧值并写回缓存 → 缓存长期是旧值。先更新数据库再删缓存也有极小概率不一致(读请求在更新前读到旧值、在删除后才写回),但窗口小得多。
- 还不够一致怎么办?
- 延迟双删:更新数据库 → 删缓存 → 等一会儿(大于一次读请求的耗时)再删一次;
- 订阅 binlog 删缓存:用 Canal 监听 MySQL binlog(PG 用逻辑复制 / Debezium),数据变了由独立的消费者删缓存,删除失败可以重试,和业务代码解耦。你旧项目用过 Canal,这里可以串起来讲。
- 设置过期时间兜底,最多不一致到过期为止。
- 本地缓存 + Redis 两级缓存? 本地缓存(Caffeine / Python 的
lru_cache)最快,但多实例之间不一致。更新时通过 Redis Pub/Sub 广播让各实例清本地缓存。JetCache 就是做这件事的(你的旧项目经历)。 我们现项目的约束:多副本部署,禁止进程内业务缓存(除非能接受不一致或有广播失效),状态都放 Redis / PG。
高难
- 缓存预热? 上线或重启前,把热点数据提前加载进缓存,避免冷启动时请求全打到数据库。
- 缓存和数据库的强一致可能吗? 要强一致就别用缓存,或者用分布式锁把「读库 + 写缓存」和「写库 + 删缓存」串行化,代价是性能大幅下降。实际业务接受最终一致。
八、分布式锁
入门
- 最简单的实现?
SET lock:order:1 <唯一值> NX PX 30000:不存在才设置(互斥)+ 过期时间(防止持锁进程崩了死锁)。 - 为什么 value 要用唯一值? 释放时要确认是自己的锁。否则:A 的锁过期了,B 拿到锁,A 执行完把 B 的锁删了。
中等
- 释放锁为什么要用 Lua?「判断是不是自己的」和「删除」必须原子,否则判断完、删除前锁刚好过期被别人拿走,就删了别人的。
if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) end return 0 - 业务还没执行完锁就过期了怎么办? 看门狗续期:持锁期间后台定时(比如过期时间的 1/3)检查锁还是不是自己的,是就延长过期时间。Redisson 默认 30 秒锁、每 10 秒续一次。进程崩了续期停止,锁自然过期。
- 可重入锁怎么实现? 用 Hash 存「持有者 → 重入次数」,同一个持有者再加锁次数 +1,释放时 -1,到 0 才删。Redisson 的
RLock就是这样。 - SETNX 和 EXPIRE 分两条命令有什么问题? 不是原子的,SETNX 成功后进程崩了,EXPIRE 没执行,锁永远不释放。用
SET ... NX PX一条命令。
高难
- 主从切换时锁丢失? A 在主库加锁成功,主库还没同步给从库就挂了,从库提升为主,B 又能加锁成功 → 两个持有者。
- Redlock:向 N 个(比如 5 个)独立的 Redis 实例加锁,超过半数成功且总耗时小于锁有效期才算成功。
- 争议(Martin Kleppmann 的批评):Redlock 依赖时钟,GC 停顿 / 时钟跳变时仍然可能两个持有者。他的建议是用 fencing token:每次加锁返回一个递增的令牌,资源(比如数据库)拒绝比已见过的令牌更小的写入。
- 实践结论:效率型的锁(防止重复计算,偶尔重复也无妨)用单实例 Redis 锁就够;正确性型的锁(绝对不能两个人同时操作)要么用 ZooKeeper / etcd(基于共识),要么在资源层用 fencing token / 数据库唯一约束兜底。
- 带租约的信号量(项目做法)? 「同时最多 N 个」比互斥锁更通用:ZSet 里成员是每个持有者的 token,score 是租约到期时间;获取:先
ZREMRANGEBYSCORE删掉过期的(进程崩了的自动回收)→ZCARD小于 N 就ZADD;长任务定时续租(更新 score);释放ZREM。全部在 Lua 里。我们的模型并发数限流就是这么做的。
九、限流
中等
- 几种限流算法?
算法 Redis 实现 特点 固定窗口 INCR key+ 首次EXPIRE 60最简单;窗口边界会有两倍突发(59 秒和 61 秒各打满) 滑动窗口 ZSet:成员是请求、score 是时间戳,删掉窗口外的再计数 精确平滑;每个请求占一个成员,高 QPS 时内存大 令牌桶 Hash 存「令牌数 + 上次补充时间」,Lua 里按时间差补令牌再扣 允许一定突发,平均速率受控;最常用 漏桶 类似,按固定速率流出 输出绝对平滑 - 项目里的限流? 模型供应商有 RPM(每分钟请求数)和 TPM(每分钟 token 数)两个维度:滑动窗口 ZSet 同时维护两个 key,Lua 一次性判断两个窗口并写入;TPM 还要在流式输出过程中按实际输出 token 追加计量(不然会严重低估)。按并发限的供应商用前面的租约信号量。
高难
- 限流放在哪一层? 网关层(Nginx / API 网关)挡恶意流量;应用层按业务维度(用户、组织、接口);资源层(对下游供应商的配额)。我们的模型配额桶按 api_key 指纹而不是按模型,因为同一个 key 下的多个模型共享供应商的配额。
- 限流后是直接拒绝还是排队? 面向用户的接口直接拒绝(快速失败,返回 429);对下游的调用(比如模型)可以短时间排队等待 + 退避重试,等太久就走兜底模型。
十、消息队列(List / Pub/Sub / Stream)
中等
- 三种方式对比?
List(LPUSH + BRPOP) Pub/Sub Stream 持久化 是 否,订阅者不在线就丢 是 多个消费者各读一份 否(一条只能被一个消费者取走) 是 是(多个消费者组) 消费确认(ack) 否(取走就没了,消费失败要自己处理) 否 是 回溯 / 重放 否 否 是(按 ID 从任意位置读) 适合 简单任务队列(Celery 的 Redis broker 就是 List) 实时通知、缓存失效广播 需要可靠消费的轻量消息队列 - Stream 的核心命令?
XADD stream * field value:追加,返回 ID(时间戳-序号);MAXLEN ~ 10000近似裁剪;XREAD BLOCK 5000 STREAMS stream $:阻塞读新消息;从0-0读就是全部重放;XGROUP CREATE/XREADGROUP GROUP g c:消费者组,一条消息只分给组内一个消费者;XACK:确认;XPENDING:查已分配未确认的消息(PEL);XAUTOCLAIM:把长时间未确认的消息转给别的消费者(处理消费者挂掉)。
- 项目怎么用 Stream? 每张对话卡片一个 Stream(
completion:stream:{card_id}),后台 Agent 每产生一个事件就XADD,SSE 连接用XREAD BLOCK读。不用消费者组:每个打开页面的连接都要读到全部事件,是广播语义不是分摊语义。断线重连从0-0重读,前端从头渲染。整个 key 设 1 天 TTL。
高难
- Redis Stream 能替代 Kafka 吗? 小规模可以:持久化、消费者组、ack、回溯都有。但 Redis 数据在内存里,保留大量历史消息成本高;没有 Kafka 那样的分区并行和磁盘顺序写的吞吐;主从异步复制可能丢消息。结论:短期、量不大的事件流用 Stream(我们的对话事件 1 天就过期);需要长期留存、高吞吐、多系统订阅的用 Kafka。
- Pub/Sub 有什么坑? 订阅者消费慢时,消息堆在 Redis 的客户端输出缓冲区,超过
client-output-buffer-limit pubsub会被强制断开;断开期间的消息全丢。
十一、大 key 与热 key
中等
- 什么是大 key?危害? String 超过 10KB、集合元素超过几千(不同团队标准不同)。危害:读写耗时长阻塞其他请求;删除时阻塞(
DEL一个百万元素的集合要很久);集群下数据倾斜;迁移时阻塞。 - 怎么发现?
redis-cli --bigkeys(扫描找每种类型最大的)、MEMORY USAGE key、离线分析 RDB 文件(rdb-tools)。 - 怎么处理? 拆分(大 hash 按字段哈希拆成多个小 hash);删除用
UNLINK(异步删)或分批HSCAN+HDEL;业务上避免把大列表整个存成一个 key。 - 为什么不能用
KEYS *? 遍历所有 key,O(N),几百万 key 时阻塞几秒。用SCAN cursor MATCH pattern COUNT 100分批迭代(可能返回重复,业务要去重)。
高难
- 热 key 怎么发现和处理?
- 发现:
redis-cli --hotkeys(需要 LFU 淘汰策略)、客户端埋点统计、代理层统计; - 处理:
- 本地缓存:热点数据在应用进程内缓存几秒;
- key 分散:
hot:item:1复制成hot:item:1:0~hot:item:1:9,读的时候随机读一个,分散到集群不同节点; - 读从库:读请求分摊到多个从节点;
- 典型场景:秒杀商品、热门直播间、某个大客户的配置。
- 发现:
十二、性能排查
中等
- Redis 变慢了怎么排查?
SLOWLOG GET 10看慢命令(默认记录超过 10ms 的);INFO commandstats看各命令的调用次数和平均耗时;--bigkeys找大 key;INFO persistence看是不是在 fork(latest_fork_usec);INFO memory看内存碎片率(mem_fragmentation_ratio,大于 1.5 碎片严重,可以开activedefrag;小于 1 说明用了 swap,非常危险);LATENCY DOCTOR延迟诊断;- 客户端侧:连接池是否耗尽、网络延迟(
redis-cli --latency)。
- 常见原因? 慢命令 / 大 key;集中过期;fork 阻塞;内存不足用了 swap;AOF
always刷盘;网络带宽打满(大 value);CPU 被别的进程抢占。
十三、Python 客户端
中等
- redis-py 的连接池?
redis.Redis(connection_pool=pool),默认每个Redis对象自带一个池,线程安全。decode_responses=True自动把 bytes 转成 str。 - 异步客户端?
redis.asyncio.Redis(FastAPI 里用),所有命令都要await。 - pipeline?
with r.pipeline(transaction=True) as p: p.incr('a'); p.expire('a', 60); p.execute()。transaction=True时会包一层 MULTI / EXEC。 - Lua?
script = r.register_script(LUA),之后script(keys=[...], args=[...]),内部自动用 EVALSHA,缓存没了自动回退 EVAL。
高难
- gevent 下 redis-py 的坑(项目真实经验):redis-py 连接池里的 socket 绑定在创建它的 gevent hub(每个线程一个 hub)上,在另一个线程里复用会报
Cannot switch to a different thread。我们的 Agent 跑在后台线程里,所以做了一个线程局部的 Redis 客户端:每个线程第一次用时建自己的连接池。 BLPOP/XREAD BLOCK和超时? 阻塞命令会占住一个连接直到返回,连接池要留足;客户端的socket_timeout要比阻塞时间长,否则客户端先超时报错。
十四、项目里的 Redis(被问「你们 Redis 用来干什么」时的完整回答)
| 用途 | 数据结构 | 要点 |
|---|---|---|
| 对话事件流(SSE 断线重放) | Stream | 每张卡一个 Stream,1 天 TTL;XREAD BLOCK 读;广播语义不用消费者组 |
| 模型限流(RPM / TPM) | ZSet + Lua | 滑动窗口,两个维度一次性原子判断;按 api_key 指纹分桶 |
| 模型并发数限流 | ZSet + Lua | 带租约的信号量,进程崩了租约到期自动回收,长请求续租 |
| 取消 / 停止信号 | String | 多副本下任何一个副本都能收到「这张卡被取消了」 |
| 定时任务调度 | ZSet | score = 下次执行时间;ZREM 成功者执行(原子抢占);PG 为准,定期对账 |
| 沙箱池借还 | ZSet + Lua | 原子借出 / 归还 |
| Celery broker | List | 任务队列 |
| 通知投递 | Hash + Lua | outbox 投递时按修订号原子比较,旧修订丢弃,防乱序和重复 |
| 中途插话 | List + Pub/Sub | 先 RPUSH 落地、再 PUBLISH 叫醒(顺序不能反,反了订阅方可能被叫醒时消息还没落地);跑 Agent 的副本订阅频道,把「有新消息」记在内存里,每轮检查只是一次内存读不打 Redis;订阅没建立时回落到 LLEN 查询保证正确。List 保可靠、Pub/Sub 保实时,是两者组合使用的好例子 |
十五、综合场景
场景 1:设计一个排行榜,1000 万用户,实时更新,查前 100 名和「我的排名」
- ZSet:
ZINCRBY rank 10 user:1、ZREVRANGE rank 0 99 WITHSCORES、ZREVRANK rank user:1,都是O(log N)。 - 1000 万成员的 ZSet 是个大 key(几百 MB),集群下会落在一个节点 → 按分数段或按地区分多个 ZSet,查全局前 100 时每个子榜取前 100 再合并。
- 同分排序:score 用「分数 × 10^10 + (最大时间戳 - 达成时间)」编码,先达成的排前面。
场景 2:用 Redis 实现延迟队列(比如 30 分钟未支付自动取消订单)
- ZSet:
ZADD delay <到期时间戳> order:1;消费者轮询ZRANGEBYSCORE delay 0 <now> LIMIT 0 10,对取到的每个成员ZREM,ZREM返回 1 的那个消费者才处理(多消费者抢占去重)。 - 或者 Lua 把「取出 + 删除」合成一步。
- 可靠性:处理失败要放回;Redis 丢数据的风险用数据库状态兜底(定期扫描「待支付且超时」的订单)。
- 这就是我们定时任务调度的做法。
场景 3:统计每天的 UV(独立访客),1 亿用户
- Set:精确但每个成员几十字节,1 亿用户 GB 级;
- Bitmap:用户 ID 是连续整数时,1 亿位 ≈ 12MB,
SETBIT uv:20261021 <uid> 1,BITCOUNT统计,多天BITOP OR合并算周 UV; - HyperLogLog:12KB 固定,误差 0.81%,
PFADD/PFCOUNT/PFMERGE。不需要精确值时首选。
场景 4:10 万 QPS 读一个配置,Redis 单节点 CPU 打满
- 这是热 key:本地缓存(几秒过期 + Pub/Sub 广播失效)是最有效的,几乎可以把 Redis 的读压力降为 0;
- 或者复制多份分散到不同节点;读从库。
场景 5:Redis 宕机了,业务怎么办?
- 先分清 Redis 在业务里的角色:
- 纯缓存 → 降级直接读数据库,但数据库要有限流保护,否则缓存挂了数据库跟着挂;
- 锁、限流 → 降级策略要想清楚:限流失效时是放行(可用性优先)还是拒绝(保护下游优先);
- 队列 / 事件流 → 依赖持久化和高可用(哨兵 / 集群),数据库保存真实状态,恢复后对账补偿。
- 项目里:Redis 承担事件流和限流,是核心依赖,所以必须高可用;业务状态以 PG 为准,Redis 丢了可以从 PG 恢复(定时任务对账就是为这个设计的)。
场景 6:一个用户在 1 秒内重复提交了 3 次同一个请求,怎么保证只处理一次?
- 前端按钮防抖只是体验优化,不能当保证;
- 后端幂等:请求带唯一的幂等键(前端生成或业务主键),
SET idem:<key> 1 NX EX 600,设置成功的才处理,失败的直接返回「处理中 / 已处理」; - 结果也缓存起来,重复请求直接返回同样的结果;
- 最终兜底:数据库唯一约束。
原有提问清单
Redis 的数据结构都有哪些?
Redis 为什么快?
Redis 单线程下多路复用的底层原理是什么?
Redis 的持久化了解吗?
Redis 的集群模式了解吗?
Redis 的集群模式下是如何保证数据的高可用性的?
Redis 的哨兵模式了解吗?
Redis 的过期键如何处理?
五、Redis(补充中等 / 高难,完整专题见 08 文档)
中等
- Stream 的消费者组?
XGROUP CREATE、XREADGROUP(每条消息只分给组内一个消费者)、XACK、XPENDING(查未确认的)、XCLAIM/XAUTOCLAIM(把挂了的消费者的消息转给别人)。我们的 SSE 场景不用消费者组(每个连接都要读全量),用XREAD。 - Stream 内存怎么控制?
XADD ... MAXLEN ~ 10000近似裁剪;或者整个 key 设 TTL(我们设 1 天)。 - 大 key / 热 key? 大 key(一个 Hash 几百万字段)删除时阻塞 →
UNLINK异步删、拆分;热 key(某个 key QPS 极高打满一个分片)→ 本地缓存、key 加随机后缀分散。
高难
- Redis Cluster 下 Lua 脚本报错
CROSSSLOT? Lua 里操作的所有 key 必须在同一个 slot → 用 hash tag:ratelimit:{model_a}:rpm和ratelimit:{model_a}:tpm,花括号里的部分决定 slot。我们的限流脚本同时操作 RPM 和 TPM 两个 key,上集群就必须这么改。 - 分布式锁的业务执行时间超过了锁过期时间? 续期(看门狗:持锁期间后台线程定期延长过期时间);或者用带租约的信号量(我们的并发限流:成员 + 过期时间,长请求定时续租,进程崩了租约到期自动释放)。
- 限流的公平性:一个组织疯狂调用,把整个 api_key 的配额吃光了,其他组织怎么办?两层限流:供应商配额(按 api_key)+ 组织配额(按组织),先过组织再过供应商。
