Redis 深入 —— 从架构/原子性/持久化到 Zset/一致性/排行榜/高并发
10 题 · 平均 3.1/5 · low 3.33 · medium 2.75 · high 3.33 · 第 1 轮
**核心考点**:考察对 Redis Pipeline 和事务本质区别的理解,能否区分"性能优化"和"原子性保证"两个不同维度。
**知识点拆解**:
1. **Pipeline(管道)**:客户端批量发送命令,减少网络 RTT 开销。命令按顺序执行,但不保证原子性——中间某个命令失败,后续命令继续执行。
2. **MULTI/EXEC(事务)**:BEGIN 到 EXEC 之间的命令打包执行,EXEC 后按顺序执行,中间命令失败不会回滚之前的成功命令(Redis 事务无回滚语义)。
3. **WATCH + CAS(乐观锁)**:WATCH 监控 key 变更,EXEC 时若被监控 key 在事务期间被其他客户端修改,事务自动失败,不执行任何命令。
4. **Lua 脚本(EVAL)**:原子执行整段脚本,脚本内可基于 Redis 事务语义做补偿(类似 TCC),但无原生回滚。
**关键差异**:
| 维度 | Pipeline | MULTI/EXEC | WATCH+EXEC | Lua |
|------|---------|-----------|------------|-----|
| 网络开销 | 1 次 RTT | N 次 RTT | N+1 次 RTT | 1 次 RTT |
| 原子性 | 无 | 命令顺序执行,无回滚 | 失败则全部不执行 | 整段脚本原子 |
| 适用场景 | 批量写入、减少 RTT | 简单批处理 | 需要乐观锁 | 复杂业务逻辑 |
**Pipeline 风险**:阻塞主线程(一次性发太多命令)、网络缓冲溢出、命令间无依赖保证。
1. **Pipeline 和事务能不能结合使用?**
- 解答:不能直接结合。Pipeline 是客户端发送机制,事务是服务端执行机制,两者是正交的。想原子执行批量命令,用 MULTI/EXEC 或 Lua。
2. **Pipeline 阻塞主线程怎么排查?**
- 解答:SLOWLOG 看慢命令、INFO commandstats 看命令耗时分布、检查客户端是否一次性发太多命令。
3. **Lua 脚本和 MULTI/EXEC 怎么选?**
- 解答:简单批处理用 MULTI/EXEC,复杂业务逻辑(含 if/for)用 Lua。Lua 性能更好(减少 RTT),MULTI/EXEC 更简单。
面试官您好,Redis Pipeline 和事务的目的完全不同——Pipeline 是为了减少网络 RTT 开销,事务是为了保证命令按顺序执行。
Pipeline 是客户端批量发送机制,一次 RTT 把多个命令打包发给服务端,服务端按顺序执行但不保证原子性——中间某个命令失败,后续命令继续执行。适合批量写入、减少网络开销的场景。
MULTI/EXEC 是服务端事务,BEGIN 到 EXEC 之间的命令打包,EXEC 后按顺序执行。但 Redis 事务无回滚——中间某个命令失败,之前的成功命令不回滚。需要乐观锁时用 WATCH + EXEC,被监控 key 在事务期间被修改则事务失败。
想要真正的原子性和复杂业务逻辑,用 Lua 脚本。Lua 整段原子执行,脚本内可以做类似 TCC 的补偿,但本质不是 ACID 事务。
Pipeline 有风险:一次性发太多命令会阻塞主线程,SLOWLOG 里能看到慢命令。生产环境建议客户端控制批量大小,比如每次 100 条命令。
**核心考点定位**:考察对 Redis 单线程设计的架构权衡深度理解,能否跳出"单线程 = 慢"的直觉误区。
**知识点拆解**:
1. **核心逻辑——瓶颈在哪?**
- Redis 是内存 KV 存储,命令执行极其轻量:网络接收 → 解析 → 内存查找 O(1) → 网络返回
- 瓶颈在网络 I/O,不在 CPU;单核 CPU 就能处理数万 QPS
- 对比 MySQL:SQL 解析/优化器/执行器(CPU 密集)+ 事务隔离 + 锁竞争 + 磁盘 I/O → 必须多线程
2. **单线程的收益**:无锁(无 CAS/死锁/内存屏障)、无上下文切换、天然原子性、稳定性、可预测性、GC 无关
3. **技术支撑——I/O 多路复用**:epoll/kqueue 事件循环,一个线程 hold 上万连接,时间复杂度 O(1)
4. **Redis 6.0 多线程边界**:
- Socket 读写、协议解析:多线程(可配 io-threads 2-4)
- 命令执行:主线程(processCommandAndResetClient)
- 持久化 fork:子进程 + COW
- AOF fsync:子线程
- 为什么命令执行不多线程?引入锁开销抵消收益;命令本身轻;单线程保证原子性
5. **单线程的瓶颈与扩展**:瓶颈是单核 CPU 打满;正确解法是 Cluster 分片,不要"让单节点变多线程"
6. **何时绕过单线程瓶颈**:Cluster 分片 / 6.0 多线程 / 慢命令优化(UNLINK/SCAN)/ Lua 拆分
7. **与其他 KV 存储对比**:Redis(单线程 + epoll)vs Memcached(一连接一线程)vs RocksDB(多线程 LSM)vs etcd(单线程 Raft)
8. **工程实践**:避免慢命令(KEYS→SCAN,大 DEL→UNLINK);拆分大 key;Cluster 分片;监控 SLOWLOG;6.0 参数 io-threads 2-4
**考察方向**:
- 深度:aeMain 事件循环源码?为什么 6.0 命令执行不多线程?
- 广度:与 Netty / Nginx / Kafka 的 I/O 模型对比
- 应用:单核 CPU 打满如何排查?大 key 阻塞如何规避?
**延伸递进面试题**:
1. **Redis 6.0 引入多线程为什么命令执行仍是单线程?**
- 解答要点:引入锁的开销抵消收益;命令本身轻;单线程保证原子性
2. **Redis 的事件循环(aeMain)如何调度?**
- 解答要点:epoll 监听 I/O 事件 → 时间事件 serverCron → 命令队列处理;命令执行同步阻塞
3. **单核 CPU 打满如何排查?**
- 解答要点:SLOWLOG 看慢命令 → INFO commandstats 看命令耗时分布 → 拆大 key / Cluster 分片
4. **Redis 的慢命令有哪些?**
- 解答要点:KEYS、SORT、SRANDMEMBER(大集合)、大 HGETALL/SMEMBERS、LPOP 大列表、Lua 脚本
5. **为什么 Memcached 用多线程而 Redis 不用?**
- 解答要点:Memcached 早期设计,一连接一线程;Redis 用 epoll 单线程更优
6. **如果让你设计一个类似 Redis 的系统,会怎么选?**
- 解答要点:命令轻 → 单线程 + I/O 多路复用;命令重 → 多线程 + 锁;磁盘密集 → 异步 IO / io_uring
面试官您好,Redis 单线程的设计本质是架构权衡,核心判断是瓶颈在哪。
Redis 是内存 KV 存储,命令执行极其轻量——一次 GET 就是网络接收 → 解析 → 内存 O(1) 查找 → 网络返回,瓶颈在网络 I/O,不在 CPU。单核 CPU 就能处理数万 QPS,多核收益很低。
而多线程带来的成本很高:锁竞争、上下文切换、并发 bug、延迟抖动。单线程天然无锁、无死锁、无内存屏障、无 GC 暂停,稳定性极高,代码也简单。
技术支撑是关键——Redis 用 epoll/kqueue 做 I/O 多路复用,一个线程通过事件循环就能 hold 住上万连接,事件就绪才处理,时间复杂度 O(1)。这是单线程高并发的唯一基础。
Redis 6.0 引入多线程的边界要分清:多线程只做 socket 读写 + 协议解析,命令执行 processCommandAndResetClient 仍是主线程。因为命令本身轻,多线程收益低,引入锁开销抵消收益,而且单线程保证原子性语义不变。
对比 MySQL:MySQL 有 SQL 解析、优化器、执行器(CPU 密集)+ 事务隔离 + 锁竞争 + 磁盘 I/O(并发等待),所以必须多线程。Redis 命令简单,单线程足够。
单线程的瓶颈是单核 CPU 打满,正确解法是 Cluster 分片水平扩展,而不是让单节点变多线程。工程上我做过两个坑:一是 KEYS 命令阻塞主线程,改用 SCAN;二是大 key DEL 卡住,换 UNLINK 异步删除。
所以总结:Redis 单线程是基于"命令轻 + 网络密集"的最优架构选择,简单、稳定、可预测;瓶颈用分片扩展,而不是破坏单线程模型。
---
**核心考点**:考察对 Redis 持久化写回策略(fsync)的理解,区分"写回"和"加载"两个不同方向。
**知识点拆解**:
1. **RDB 写回策略**:RDB 是快照,fork 子进程 + COW 机制。触发时机:手动(SAVE/BGSAVE)、配置(save 60 10000 / save 300 1000 / save 900 1)、子进程完成后原子替换。
2. **AOF 写回策略(appendfsync)**:
- **always**:每条命令立即 fsync 到磁盘,最安全但性能最低(QPS 降到几百)
- **everysec**(默认):每秒 fsync 一次,性能和安全折中,最多丢 1 秒数据
- **no**:由 OS 决定何时 fsync(通常 30 秒),性能最高但可能丢大量数据
3. **AOF rewrite**:AOF 文件过大时,BGREWRITEAOF 触发,fork 子进程重写压缩 AOF。
4. **混合持久化(Redis 4.0+)**:AOF rewrite 时前半段用 RDB 格式(历史数据),后半段用 AOF 格式(rewrite 期间的增量),兼顾启动速度和数据安全性。
**关键参数**:
- appendfsync(always/everysec/no)
- auto-aof-rewrite-percentage(默认 100)
- auto-aof-rewrite-min-size(默认 64MB)
1. **RDB 和 AOF 同时开启,恢复优先级?**
- 解答:默认优先 AOF,因为 AOF 数据更完整。可通过 aof-use-rdb-preamble 调整。
2. **AOF 文件过大怎么办?**
- 解答:BGREWRITEAOF 自动触发(达到阈值时),或手动执行。rewrite 期间新命令写入临时 AOF,完成后合并。
3. **fsync.always 为什么生产不用?**
- 解答:每次命令都要 fsync 到磁盘,QPS 降到几百,性能损失巨大。生产一般用 everysec,最多丢 1 秒数据。
面试官您好,Redis 写回策略主要是 AOF 的 appendfsync 参数,分三档:always、everysec、no。
always 是每条命令立即 fsync 到磁盘,最安全但性能最低,QPS 降到几百,生产一般不用。
everysec 是默认值,每秒 fsync 一次,性能和安全性折中,最多丢 1 秒数据。生产环境推荐这个。
no 是让 OS 决定何时 fsync(通常 30 秒),性能最高但可能丢大量数据,一般不用。
RDB 是快照,fork 子进程 + COW 机制,触发时机是手动 SAVE/BGSAVE 或配置(save 60 10000 等)。RDB 写回后原子替换旧文件。
Redis 4.0 引入了混合持久化,AOF rewrite 时前半段用 RDB 格式存历史数据,后半段用 AOF 格式存增量,兼顾启动速度和数据安全性。
参数方面,appendfsync 控制 AOF 写回频率,auto-aof-rewrite-percentage 和 auto-aof-rewrite-min-size 控制 AOF rewrite 触发阈值。
**核心考点**:考察对 Zset 底层编码切换机制的理解,区分"编码二选一"和"递进升级"。
**知识点拆解**:
1. **Zset 两种编码**:
- **listpack**(Redis 5.0+,替代 ziplist):连续内存块,小数据高效
- **skiplist + hashtable**:跳跃表 + 哈希表组合,大数据高效
2. **编码切换阈值**(二选一,非递进):
- zset-max-listpack-entries(默认 128):元素数超过则切换 skiplist
- zset-max-listpack-value(默认 64 字节):最大元素值超过则切换 skiplist
3. **skiplist 结构**:
- 多层级跳跃表,底层 O(n),顶层 O(1)
- 每层节点挂指针到下一层,跳级定位
- 每个节点还挂哈希表(O(1) member→score 查找)
4. **ziplist vs listpack**:5.0+ ziplist 已被 listpack 完全替代,性能更好、内存更安全
**关键命令复杂度**:
- ZADD/ZSCORE/ZREMRANGEBYSCORE:listpack O(n) / skiplist O(log n)
- ZRANGE:listpack O(n) / skiplist O(log n + m)
- ZRANK:skiplist O(log n) / listpack O(n)
1. **为什么 Zset 不用 B+ 树?**
- 解答:B+ 树节点大、内存浪费、实现复杂;跳跃表内存紧凑、实现简单、插入删除效率高。Redis 用跳跃表平衡了性能和复杂度。
2. **Zset 编码切换后能回退吗?**
- 解答:不能。一旦切换到 skiplist,即使元素数减少也不会回退 listpack,避免频繁切换开销。
3. **Zset 和 SortedSet 接口区别?**
- 解答:Redis 中 Zset 就是 SortedSet,Java 客户端用 Jedis 的 zadd/zrange 等命令。
面试官您好,Zset 有两种底层编码,是二选一关系,不是递进升级。
小数据用 listpack(Redis 5.0+ 替代了 ziplist),连续内存块,内存紧凑。大数据用 skiplist + hashtable 组合。
编码切换阈值是两个:zset-max-listpack-entries(默认 128 个元素)和 zset-max-listpack-value(默认 64 字节)。任一超过就切换到 skiplist。
skiplist 是多层级跳跃表,底层 O(n),顶层 O(1),跳级定位到目标节点。每个节点还挂一个哈希表,通过 member 查找 score 是 O(1)。
ZADD/ZSCORE/ZREMRANGEBYSCORE 在 listpack 上 O(n),在 skiplist 上 O(log n)。ZRANGE 在 skiplist 上 O(log n + m),m 是返回元素数。
编码切换不可逆——一旦切换到 skiplist,即使元素数减少也不会回退 listpack,避免频繁切换的开销。
为什么不用 B+ 树?B+ 树节点大、内存浪费、实现复杂;跳跃表内存紧凑、实现简单、插入删除效率高。Redis 选跳跃表是性能和复杂度的平衡。
**核心考点**:考察对缓存一致性策略的理解,区分"延迟双删"和"Cache Aside"的层次关系。
**知识点拆解**:
1. **Cache Aside 模式**:读时先读缓存,缓存未命中则读 DB 并写回缓存;写时先更 DB,再删缓存(不是更新缓存,避免并发写导致缓存不一致)。
2. **延迟双删的本质**:Cache Aside + 二次删除,解决并发读写时序问题。
3. **并发时序问题**:
- T1:A 线程更新 DB(事务未提交)
- T2:B 线程读 DB(读到旧数据)
- T3:A 线程删除缓存
- T4:B 线程把旧数据写回缓存 → 缓存错误
4. **延迟双删方案**:更新 DB → 删除缓存 → 延迟 N ms → 再删除缓存。第二次删除清掉 B 线程写回的旧数据。
5. **延迟时间确定**:覆盖"事务提交时间 + 主从同步时间 + 读请求耗时",一般 500ms-2s。
6. **其他方案**:
- **Binlog + MQ(Canal)**:监控 DB binlog,异步删缓存,彻底解耦
- **订阅 DB 变更**:类似 Canal,但 DB 原生支持
- **先删缓存再更 DB**:风险更大(删缓存后 DB 更新失败,缓存永久缺失)
**局限性**:延迟双删无法解决所有并发问题,仍需最终一致性;延迟时间不好精确确定;网络抖动可能放大问题。
1. **先更 DB 还是先删缓存?**
- 解答:Cache Aside 推荐先更 DB 再删缓存。先删缓存再更 DB 风险更大(删缓存后 DB 更新失败,缓存永久缺失)。
2. **延迟双删和 Canal 怎么选?**
- 解答:简单场景用延迟双删,复杂/高一致性要求场景用 Canal(解耦、可靠、可追溯)。
3. **延迟双删的延迟时间怎么定?**
- 解答:覆盖事务提交时间 + 主从同步时间 + 读请求耗时,一般 500ms-2s。可通过压测确定。
面试官您好,延迟双删的本质是 Cache Aside + 二次删除,解决并发读写时序问题。
Cache Aside 模式下,写时先更 DB 再删缓存。但并发时序会导致缓存不一致:A 线程更新 DB(事务未提交),B 线程读 DB 读到旧数据,A 线程删除缓存,B 线程把旧数据写回缓存——缓存就错了。
延迟双删方案:更新 DB → 删除缓存 → 延迟 N ms → 再删除缓存。第二次删除清掉 B 线程写回的旧数据。
延迟时间需要覆盖"事务提交时间 + 主从同步时间 + 读请求耗时",一般 500ms-2s。可通过压测确定具体值。
更彻底的方案是 Canal:监控 DB binlog,异步删缓存。Canal 解耦、可靠、可追溯,适合高一致性要求的场景。
延迟双删有局限:无法解决所有并发问题,仍需最终一致性;延迟时间不好精确确定;网络抖动可能放大问题。生产环境建议用 Canal,简单场景用延迟双删。
**核心考点**:考察 Redis 工程化设计能力,能否从业务语义、性能、可维护性三个维度设计 key/value。
**知识点拆解**:
1. **Key 设计原则**:
- **业务语义**:服务:业务表:业务名:{ID} 分层,如 user:profile:info:{id}
- **长度控制**:<128 字节,避免过长浪费内存和带宽
- **避免通配符**:不用 * ? 等,避免 SCAN/KEYS 慢查询
- **避免特殊字符**:空格、换行、冒号等慎用,冒号用于分层
- **大小写敏感**:Key 区分大小写,统一用小写
2. **Value 设计原则**:
- **类型选择**:list/hash/zset/set/string 按业务选择
- **序列化**:JSON(可读性)vs Protobuf(性能)vs 原生类型
- **大小控制**:字符串 <1MB,集合 <5000 元素,避免大 key
- **大 key 拆分**:单 key >10KB 拆分,>100MB 绝对禁止
3. **过期时间原则**:
- 热点数据短(分钟级)、冷数据长(天级)
- 不缓存则不设过期(永久)
- 过期时间加随机偏移(避免雪崩)
4. **性能优化**:
- 避免 KEYS 命令(O(N) 阻塞),用 SCAN
- 避免大 key 操作(HGETALL 大 hash、LRANGE 大 list)
- 批量操作用 Pipeline 减少 RTT
**常见错误**:
- 用 DB 表名做 key(无业务语义)
- 单 key 存百万元素(大 key)
- key 含空格(解析问题)
- 不设过期时间(缓存雪崩)
1. **如何发现大 key?**
- 解答:redis-cli --bigkeys 扫描,或 INFO memory 看 used_memory,或 RDB 分析工具。
2. **Hash 和 String 存对象怎么选?**
- 解答:字段多、需单字段访问用 Hash;字段少、整体读写用 String+序列化。Hash 单字段 O(1),String 整体读写更快。
3. **过期时间如何避免雪崩?**
- 解答:过期时间加随机偏移(如 base + random(0, 300s)),避免大量 key 同时过期。
面试官您好,Key 和 Value 设计是 Redis 工程化的核心,我从业务语义、性能、可维护性三个维度说。
Key 设计:业务语义分层,如 user:profile:info:{id},服务:业务表:业务名:{ID}。长度控制在 128 字节内,避免过长浪费内存和带宽。避免通配符(* ?),避免 SCAN/KEYS 慢查询。避免特殊字符(空格、换行),冒号用于分层。统一小写,Key 区分大小写。
Value 设计:类型按业务选择,list/hash/zset/set/string。序列化 JSON 可读性好,Protobuf 性能好。大小控制:字符串 <1MB,集合 <5000 元素,单 key >10KB 拆分,>100MB 绝对禁止。
过期时间:热点数据短(分钟级)、冷数据长(天级),过期时间加随机偏移避免雪崩。不缓存则不设过期。
性能优化:避免 KEYS 命令(O(N) 阻塞),用 SCAN。避免大 key 操作(HGETALL 大 hash、LRANGE 大 list)。批量操作用 Pipeline 减少 RTT。
常见错误:用 DB 表名做 key(无业务语义)、单 key 存百万元素(大 key)、key 含空格(解析问题)、不设过期时间(缓存雪崩)。
发现大 key 用 redis-cli --bigkeys 扫描,或 INFO memory 看 used_memory。Hash 和 String 存对象:字段多、需单字段访问用 Hash;字段少、整体读写用 String+序列化。
**核心考点**:考察缓存预热的工程化能力,区分"时机、方法、失败处理、监控"四个维度。
**知识点拆解**:
1. **预热时机**:
- **服务启动时**:应用启动后异步加载热点数据(如商品列表、用户信息)
- **大促前**:手动触发预热脚本,提前加载促销商品数据
- **缓存过期后**:定时任务提前刷新,避免过期瞬间空缓存
2. **预热方法**:
- **全量加载**:从 DB 批量查询,Pipeline 写入缓存(适合数据量小)
- **增量加载**:只加载变化的数据(如按更新时间增量)
- **按需加载**:首次访问时加载,后续命中缓存(冷启动慢但省资源)
- **分布式预热**:多实例分摊预热任务,避免单点瓶颈
3. **预热数据量控制**:
- **分批加载**:每批 100-1000 条,避免一次性打满 DB 和 Redis
- **限流**:预热速率限制(如 1000 QPS),避免影响线上业务
- **优先级**:热点优先(Top N 商品先加载)
4. **预热失败处理**:
- **重试**:失败重试 3 次,指数退避
- **降级**:预热失败不影响线上,缓存未命中走 DB
- **告警**:预热失败率 >5% 告警
5. **预热后监控**:
- **命中率**:预热后命中率应 >90%
- **加载耗时**:监控预热任务耗时
- **缓存大小**:监控 used_memory,避免预热导致 OOM
**与缓存击穿/雪崩的区别**:
- **预热**:主动加载,避免冷启动
- **击穿**:热点 key 过期瞬间并发穿透,用互斥锁或永不过期
- **雪崩**:大量 key 同时过期,用过期时间随机偏移
1. **预热和缓存击穿的区别?**
- 解答:预热是主动加载(事前),击穿是被动防护(事中)。预热解决冷启动,击穿解决热点 key 过期瞬间并发穿透。
2. **预热导致 DB 压力太大怎么办?**
- 解答:分批加载、限流、优先级排序。预热速率 < 线上 QPS 的 10%。
3. **预热后缓存命中率低怎么办?**
- 解答:检查预热数据是否热点、过期时间是否过短、是否有数据变更导致缓存失效。
面试官您好,缓存预热我从时机、方法、失败处理、监控四个维度说。
时机:服务启动时异步加载热点数据,大促前手动触发预热脚本,缓存过期前定时刷新避免空缓存。
方法:全量加载(数据量小)、增量加载(只加载变化的)、按需加载(首次访问加载)、分布式预热(多实例分摊)。分批加载每批 100-1000 条,限流避免影响线上业务,热点优先。
数据量控制:预热速率限制在 1000 QPS 以内,避免打满 DB 和 Redis。按优先级加载,Top N 商品先加载。
失败处理:失败重试 3 次(指数退避),预热失败不影响线上(缓存未命中走 DB),失败率 >5% 告警。
预热后监控:命中率应 >90%,加载耗时、缓存大小(used_memory)都要监控,避免预热导致 OOM。
和缓存击穿/雪崩的区别:预热是主动加载(事前),击穿是被动防护(事中),击穿用互斥锁或永不过期,雪崩用过期时间随机偏移。
生产实践:大促前 1 小时启动预热脚本,分批加载 Top 1000 商品,预热完成后监控命中率。预热失败降级走 DB,不影响用户体验。
**核心考点**:考察 Zset 排行榜的工程化实现能力,理解分数拼接技巧。
**知识点拆解**:
1. **问题背景**:Zset 按分数排序,分数相同时按 member 字典序排序(不稳定)。业务需要分数相同时按时间排序。
2. **方案一:分数拼接**
- **原理**:分数 = 业务分数 × 1e13 + 时间戳(毫秒)
- **示例**:分数 100,时间戳 1690000000000 → 10016900000000000
- **优点**:Zset 原生支持,查询高效
- **缺点**:时间戳精度限制(毫秒内无法区分)、分数精度限制
3. **方案二:业务端二次排序**
- **原理**:Zset 存 score + member,member 中包含时间戳,读取后在业务端排序
- **优点**:灵活,支持任意排序规则
- **缺点**:分页场景困难(需加载全部数据)、性能差
4. **方案三:分数分段**
- **原理**:分数高位存业务分数,低位存时间戳
- **示例**:分数 1000000 + 时间戳毫秒(取后 6 位)
- **优点**:精确到秒
- **缺点**:分数范围有限
5. **排行榜常见扩展**:
- **Top N**:ZREVRANGE 0 N-1
- **我的排名**:ZREVRANK
- **分数变化推送**:Pub/Sub 或 Redis Stream
- **分维度**:实时榜/小时榜/日榜/周榜(多个 Zset)
**关键命令**:
- ZADD:添加/更新分数
- ZREVRANGE:倒序范围查询
- ZREVRANK:获取排名 O(log n)
- ZSCORE:获取分数 O(1)
1. **分数拼接的时间戳精度怎么选?**
- 解答:毫秒(13 位)够用,微秒(16 位)精度更高但分数范围受限。一般用毫秒。
2. **同毫秒内多个用户分数相同怎么排?**
- 解答:按 member 字典序(默认),或业务端二次排序。同毫秒概率极低,一般不影响业务。
3. **排行榜如何支持实时更新?**
- 解答:ZADD 更新分数,Pub/Sub 或 Redis Stream 推送变化,客户端订阅实时更新。
面试官您好,Zset 排行榜分数相同按时间排序有两种方案。
方案一是分数拼接:分数 = 业务分数 × 1e13 + 时间戳(毫秒)。例如分数 100,时间戳 1690000000000,拼接后是 10016900000000000。Zset 原生支持,查询高效。缺点是时间戳精度限制,毫秒内无法区分。
方案二是业务端二次排序:Zset 存 score + member,member 中包含时间戳,读取后在业务端排序。灵活,支持任意排序规则。缺点是分页场景困难(需加载全部数据)、性能差。
我推荐方案一,分数拼接。时间戳用毫秒够用,同毫秒概率极低,不影响业务。
排行榜常见扩展:Top N 用 ZREVRANGE 0 N-1,我的排名用 ZREVRANK O(log n),分数变化用 Pub/Sub 或 Redis Stream 推送,分维度(实时/小时/日/周榜)用多个 Zset。
生产实践:Top 1000 排行榜,分数拼接毫秒时间戳,ZADD 更新分数,Pub/Sub 推送变化,客户端订阅实时更新。分维度排行榜用多个 Zset,每个 Zset 独立过期时间。
**核心考点**:考察大规模排行榜的架构设计能力,理解"缓存 + DB"分层策略。
**知识点拆解**:
1. **核心思路**:缓存只存 Top N,其余走 DB 分页
2. **Top N 大小确定**:
- 业务场景:排行榜展示 Top 10/100/1000
- 性能考虑:ZREVRANGE 0 999 在百万级 Zset 上 O(log n + 1000),毫秒级
- 推荐:Top 1000,覆盖 99% 展示需求
3. **架构分层**:
- **Redis(Top N)**:Zset 存 Top 1000,实时查询
- **DB(全量)**:MySQL 存全量数据,分页查询
- **查看更多**:Top N 外用户点击"查看更多"跳转独立页面,DB 分页
4. **排行榜实时性**:
- **Lua 原子更新**:ZADD + ZREVRANGE 原子执行,避免并发更新导致数据不一致
- **MQ 异步更新**:业务事件发 MQ,消费者更新 Zset,削峰解耦
- **定时刷新**:定时任务从 DB 同步 Top N 到 Redis
5. **分维度排行榜**:
- 实时榜:Zset,实时更新
- 小时榜/日榜/周榜:定时聚合,独立 Zset
- 过期策略:每个维度独立过期时间
6. **排行榜降级**:
- **缓存宕机**:降级到只读 DB(限流)
- **DB 宕机**:只展示 Redis 缓存数据
- **MQ 积压**:降级为定时刷新
7. **防作弊**:
- 异常分数检测(分数突增告警)
- 频率限制(单用户更新频率限制)
- 黑名单机制
**关键性能指标**:
- Top 1000 查询:O(log n + 1000),毫秒级
- 更新分数:O(log n),毫秒级
- 获取排名:O(log n),毫秒级
1. **Top N 大小如何确定?**
- 解答:根据业务展示需求(Top 10/100/1000)和性能(ZREVRANGE 复杂度)确定。一般 Top 1000 覆盖 99% 展示需求。
2. **排行榜如何保证实时性?**
- 解答:Lua 原子更新(ZADD + ZREVRANGE)、MQ 异步更新(削峰解耦)、定时刷新(兜底)。
3. **排行榜如何防作弊?**
- 解答:异常分数检测(突增告警)、频率限制(单用户更新频率)、黑名单机制。
面试官您好,百万级排行榜的核心思路是缓存只存 Top N,其余走 DB 分页。
Top N 大小根据业务需求和性能确定,一般 Top 1000 覆盖 99% 展示需求。ZREVRANGE 0 999 在百万级 Zset 上 O(log n + 1000),毫秒级响应。
架构分层:Redis Zset 存 Top 1000,实时查询;MySQL 存全量数据,分页查询。Top N 外用户点击"查看更多"跳转独立页面,DB 分页查询。
排行榜实时性:Lua 原子更新(ZADD + ZREVRANGE 原子执行,避免并发不一致)、MQ 异步更新(业务事件发 MQ,消费者更新 Zset,削峰解耦)、定时刷新(兜底)。
分维度排行榜:实时榜、小时榜、日榜、周榜用多个独立 Zset,每个维度独立过期时间。
降级方案:缓存宕机降级到只读 DB(限流),DB 宕机只展示 Redis 缓存数据,MQ 积压降级为定时刷新。
防作弊:异常分数检测(突增告警)、频率限制(单用户更新频率)、黑名单机制。
性能指标:Top 1000 查询 O(log n + 1000) 毫秒级,更新分数 O(log n) 毫秒级,获取排名 O(log n) 毫秒级。