Skip to content

八、Redis(15 题)

8.1 ★★★★Redis 常用的数据类型有哪些?各自的底层数据结构和典型使用场景是什么?

📖 点击查看答案

五种基本数据类型

1. String(字符串)

  • 底层:SDS(Simple Dynamic String),支持动态扩容、二进制安全、O(1) 获取长度
    • Redis 3.2 后 SDS 分 5 种类型(sdshdr5/8/16/32/64),根据长度选择,节省内存
    • int 编码(整数)、embstr(<=44 字节)、raw(>44 字节)
  • 场景
    • 缓存对象(JSON)
    • 计数器(INCR
    • 分布式锁(SET NX EX
    • 全局唯一 ID(INCR
    • 限流(INCR + 过期)

2. Hash(哈希)

  • 底层
    • listpack(Redis 7.0+,替代 ziplist):元素少且短时(默认 128 个、64 字节)
    • hashtable:元素多或长时
  • 场景
    • 存储对象(比 String 存 JSON 更节省内存,可单独修改字段)
    • 购物车(HSET cart:userId productId quantity
    • 计数器分组(HINCRBY

3. List(列表)

  • 底层
    • listpack(Redis 7.0+):短列表
    • quicklist(快速列表):双向链表 + 每个节点是 listpack,兼顾内存和性能
  • 场景
    • 消息队列(LPUSH + BRPOP,或 Stream)
    • 最新消息列表
    • 文章列表
    • 栈(LPUSH + LPOP

4. Set(集合)

  • 底层
    • intset(整数集合):元素都是整数且少(默认 512 个)
    • hashtable:其他情况
  • 场景
    • 标签、分类
    • 共同好友(SINTER
    • 抽奖(SRANDMEMBERSPOP
    • 去重(SADD

5. ZSet(有序集合)

  • 底层
    • listpack(Redis 7.0+):元素少且短
    • skiplist + hashtable:跳表支持范围查询,hashtable 支持 O(1) 查找
  • 场景
    • 排行榜
    • 延迟队列(score 为执行时间)
    • 带权重的消息队列
    • 范围查询(ZRANGEBYSCORE

三种特殊数据类型

6. Bitmap(位图)

  • 基于 String,按位操作
  • 场景:签到打卡、布隆过滤器、活跃用户统计

7. HyperLogLog(基数统计)

  • 固定 12KB,误差 0.81%
  • 场景:UV 统计、基数去重

8. Geo(地理位置)

  • 基于 ZSet,使用 Geohash 编码
  • 场景:附近的人、距离计算

9. Stream(流,Redis 5.0+)

  • 基于 radix tree
  • 场景:消息队列(支持消费组、ACK)
bash
# 各类型常用命令
SET key value            # String
HSET hash field value     # Hash
LPUSH list value          # List
SADD set member           # Set
ZADD zset score member    # ZSet
SETBIT bitmap 10 1        # Bitmap
PFADD hll member          # HyperLogLog
GEOADD geo lng lat member # Geo
XADD stream * field value # Stream

编码转换条件(Redis 7.0+):

  • Hash:hash-max-listpack-entries=128hash-max-listpack-value=64
  • List:list-max-listpack-size=-2(每个 listpack 8KB)
  • Set:set-max-intset-entries=512
  • ZSet:zset-max-listpack-entries=128zset-max-listpack-value=64

8.2 ★★Redis 中的跳表(SkipList)的实现原理是什么?为什么不用红黑树?

📖 点击查看答案

跳表原理

  • 有序的多层链表,最底层包含所有元素
  • 上层是下层的"索引",每隔几个元素抽一个,形成多层索引
  • 查询时从最高层开始,向右走到不了再下一层,类似二分查找
Level 3:  1 -----------------------> 9
Level 2:  1 --------> 5 -----------> 9
Level 1:  1 ---> 3 --> 5 ---> 7 ---> 9
Level 0:  1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8 -> 9

ZSet 中的跳表结构

c
typedef struct zskiplistNode {
    sds ele;                          // 元素值
    double score;                     // 分值
    struct zskiplistNode *backward;   // 后退指针
    struct zskiplistLevel {
        struct zskiplistNode *forward; // 前进指针
        unsigned long span;            // 跨度(用于计算排名)
    } level[];                        // 多层
} zskiplistNode;

核心操作

  • 查找:从最高层开始,向右比较 score,大于则下一层,直到最底层
  • 插入:随机生成层数(ZSKIPLIST_MAXLEVEL=32,概率 1/4 升层),调整前后指针
  • 删除:找到节点后调整前后指针
  • 范围查询:找到起点后沿最底层 forward 指针遍历

层数生成

c
int zslRandomLevel(void) {
    int level = 1;
    while ((random() & 0xFFFF) < (ZSKIPLIST_P * 0xFFFF))  // ZSKIPLIST_P = 0.25
        level += 1;
    return (level < ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}

为什么用跳表而不用红黑树

  1. 范围查询高效

    • ZSet 常用 ZRANGEBYSCOREZRANGE 范围查询
    • 跳表找到起点后,沿最底层链表遍历即可,O(logN + M)
    • 红黑树范围查询需中序遍历,复杂
  2. 实现简单

    • 跳表代码量少(几百行),易于理解和维护
    • 红黑树实现复杂(旋转、染色),易出错
  3. 内存灵活

    • 跳表每个节点层数随机,平均 O(logN) 指针
    • 红黑树每个节点固定两个子指针
    • 跳表内存占用略高但可接受(平均 1.33 个指针/节点)
  4. 并发友好(理论上):

    • 跳表局部修改,红黑树插入可能引起多级旋转
    • Redis 单线程,这点不关键,但跳表实现简单
  5. 缓存友好

    • 跳表节点连续访问局部性好

跳表 + 字典组合

  • ZSet 同时使用跳表和字典(hashtable)
  • 跳表支持范围查询和有序性
  • 字典支持 O(1) 查找元素分值(ZSCORE
  • 两者共享元素和分值的指针,不额外存储数据

8.3 ★★★★Redis 为什么这么快?请从架构、数据结构、I/O 模型等角度分析。为什么之前一直是单线程的?

📖 点击查看答案

Redis 快的原因

1. 基于内存

  • 数据存储在内存,读写速度比磁盘快几个数量级
  • 避免磁盘 I/O 瓶颈

2. 单线程模型

  • 避免多线程的上下文切换开销
  • 避免锁竞争(数据结构操作无需加锁)
  • 代码简洁,逻辑清晰

3. 高效的 I/O 多路复用

  • 使用 epoll(Linux)、kqueue(BSD)实现 I/O 多路复用
  • 单线程监听大量客户端连接,事件驱动
  • 非阻塞 I/O,避免线程阻塞

4. 高效的数据结构

  • SDS:O(1) 获取长度,二进制安全
  • 字典:渐进式 rehash,避免阻塞
  • 跳表:范围查询高效
  • intset、listpack:小数据紧凑存储,节省内存
  • 压缩列表(ziplist/listpack):连续内存,缓存友好

5. 合理的编码转换

  • 小数据用紧凑编码(listpack、intset),大数据用高效结构(hashtable、skiplist)
  • 自动根据数据量切换编码

6. 简单的协议

  • RESP 协议简单,解析快
  • 客户端发送命令,服务端单线程顺序执行

7. 无锁设计

  • 单线程无需锁
  • 数据结构操作原子性

为什么之前一直是单线程

  1. 瓶颈不在 CPU

    • Redis 性能瓶颈是内存和网络,不是 CPU
    • 单核已能达到 10 万+ QPS,多线程对 CPU 提升有限
  2. 避免并发问题

    • 多线程需要锁,增加复杂度和开销
    • 单线程天然原子性,数据结构简单
  3. 维护简单

    • 单线程代码易理解、调试、维护
    • 避免多线程的竞态、死锁问题
  4. 客户端协议简单

    • 命令顺序执行,结果可预测
  5. 历史选择

    • Redis 设计于 2009 年,当时多核不普及
    • 单线程已满足需求

Redis 6.0 引入多线程的原因

  • 网络吞吐成为瓶颈(大 Value、大量连接)
  • 单线程处理网络读写成为瓶颈
  • 多线程只处理网络 I/O,命令执行仍单线程
  • 保持命令执行的原子性,同时提升网络性能

Redis 6.0 多线程模型

主线程(单线程):
  - 接受连接
  - 命令执行(保持单线程)
  - 数据结构操作

I/O 线程(多线程):
  - 读取客户端命令(socket → buffer)
  - 写回响应(buffer → socket)
ini
io-threads 4              # I/O 线程数
io-threads-do-reads yes   # 开启读多线程

性能对比

  • 单线程:10-15 万 QPS
  • 多线程(6.0+):网络密集场景可提升 1-2 倍

8.4 ★★Redis 6.0 引入的多线程模型是怎样的?Redis 7.0 有哪些重要特性?

📖 点击查看答案

Redis 6.0 多线程模型

核心思想

  • 命令执行仍单线程,保证原子性和数据安全
  • 网络 I/O 多线程化,解决网络瓶颈

工作流程

  1. 主线程接受客户端连接
  2. 客户端发送命令,主线程将 socket 分配给 I/O 线程组
  3. I/O 线程读取 socket 数据到缓冲区(并行)
  4. 主线程串行执行所有命令
  5. I/O 线程将结果写回 socket(并行)
  6. 主线程清理
Client1 ─┐
Client2 ─┼─> I/O 线程1 读 ─┐
Client3 ─┼─> I/O 线程2 读 ─┼─> 主线程执行 ─> I/O 线程写回
Client4 ─┘ I/O 线程3 读 ─┘

配置

ini
io-threads 4              # I/O 线程数(建议 CPU 核数的一半)
io-threads-do-reads yes   # 开启读多线程(默认只写多线程)

适用场景

  • 大量连接(如 10 万+)
  • 大 Value 读写(网络传输耗时)
  • 单线程 CPU 未跑满但网络是瓶颈

不适用场景

  • 命令本身耗 CPU(如 SORT、ZUNIONSTORE)
  • 连接数少、Value 小

Redis 7.0 重要特性

  1. Function(函数)

    • 替代 EVAL/EVALSHA,支持持久化 Lua 函数
    • 函数库管理,避免每次传输脚本
    lua
    FUNCTION LOAD '#!lua name=mylib
    redis.register_function("myfunc", function(keys, args)
        return redis.call("GET", keys[1])
    end)'
    FUNCTION CALL mylib myfunc 1 mykey
  2. listpack 替代 ziplist

    • Hash、List、ZSet 的小数据编码从 ziplist 改为 listpack
    • 解决 ziplist 的连锁更新问题(cascade update)
    • 内存更紧凑,性能更好
  3. Multi-Part AOF

    • AOF 拆分为 base(RDB 快照)+ increment(增量 AOF)+ manifest(清单)
    • 避免重写时的大文件 IO 压力
    • 重启恢复更灵活
  4. Sharded Pub/Sub

    • 发布订阅按分片分发,跨集群节点可用
    • SPUBLISHSSUBSCRIBE 命令
  5. ACL v2

    • 更细粒度的权限控制
    • 支持用户管理、命令分类
  6. Cluster 管理增强

    • CLUSTER LINKS 查看节点连接
    • 集群性能优化
  7. 性能优化

    • 渐进式 rehash 优化
    • 内存使用优化

Redis 7.2+ 特性

  • 引入 Triggers(实验性)
  • 性能持续优化

8.5 ★★★★Redis 的持久化机制有哪些?RDB 和 AOF 的优缺点对比是什么?混合持久化是如何工作的?

📖 点击查看答案

Redis 持久化机制

1. RDB(Redis Database)

  • 在某个时间点对内存数据的快照,二进制文件 dump.rdb
  • 触发方式:
    • SAVE:同步,阻塞
    • BGSAVE:异步,fork 子进程
    • 配置 save m n:m 秒内 n 次修改触发
    • 主从复制时从库请求触发
  • 原理:BGSAVE 通过 fork() 创建子进程(COW,Copy-On-Write),子进程将内存数据写入临时 RDB 文件,替换旧文件

2. AOF(Append-Only File)

  • 记录每次写命令,追加到 appendonly.aof
  • 触发:每条写命令都记录
  • 刷盘策略(appendfsync):
    • always:每次写都刷盘(最安全,性能差)
    • everysec:每秒刷盘(默认,折中)
    • no:由 OS 决定刷盘(性能好,可能丢数据)
  • AOF 重写(Rewrite):压缩 AOF 文件,去除冗余命令(如多次 SET 同一 key 只保留最后一条)

RDB vs AOF 对比

特性RDBAOF
数据完整性时间点快照,可能丢最后一次快照后的数据每秒刷盘,最多丢 1s 数据
文件大小小(二进制压缩)大(文本命令)
恢复速度快(直接加载二进制)慢(重放命令)
性能影响小(子进程写)较大(每条写都记录)
可读性不可读可读(文本命令)
适用场景备份、灾难恢复数据安全要求高

混合持久化(Redis 4.0+)

原理

  • AOF 重写时,先以 RDB 格式写入当前内存快照,再追加增量 AOF 命令
  • 恢复时先加载 RDB 部分(快),再重放 AOF 部分(增量)
  • 结合 RDB 的快速恢复和 AOF 的数据完整性
AOF 文件结构(混合持久化):
┌─────────────────────────────────┐
│  RDB 二进制数据(全量快照)       │  ← BGREWRITEAOF 时写入
├─────────────────────────────────┤
│  AOF 命令文本(增量)             │  ← 重写后的新命令
│  SET key1 value1                │
│  SET key2 value2                │
└─────────────────────────────────┘

配置

ini
aof-use-rdb-preamble yes   # 开启混合持久化(Redis 4.0+,5.0+ 默认开启)

恢复流程

  1. 读取 AOF 文件
  2. 检测开头是否为 RDB 格式(魔数 REDIS
  3. 是 RDB:先加载 RDB 部分恢复全量数据
  4. 再重放后续 AOF 命令恢复增量数据

生产建议

  • 开启混合持久化(兼顾恢复速度和数据完整性)
  • appendfsync everysec(平衡性能和安全)
  • 定期 BGSAVE 做备份
  • 监控 AOF 文件大小,及时重写

Redis 7.0 Multi-Part AOF

  • AOF 拆分为 base(RDB 快照)+ increment(增量 AOF)+ manifest(清单)
  • 避免重写时的大文件 IO 压力
  • 重启恢复更灵活

8.6 ★★★★Redis 的过期删除策略和内存淘汰策略有哪些?LRU 和 LFU 的实现差异是什么?惰性删除和定期删除如何配合工作?

📖 点击查看答案

过期删除策略

1. 惰性删除(Lazy Expiration)

  • 访问 key 时检查是否过期,过期则删除
  • 优点:CPU 友好,不主动扫描
  • 缺点:过期 key 不被访问会一直占用内存(内存泄漏)

2. 定期删除(Periodic Expiration)

  • 每隔一段时间(hz 配置,默认 10 次/秒)随机抽取部分设置了过期时间的 key 检查
  • 若过期比例 > 25%,继续扫描,直到比例降低或时间用完
  • 优点:主动清理过期 key
  • 缺点:可能清理不及时,随机抽样有遗漏

3. 配合工作

  • Redis 同时使用惰性删除 + 定期删除
  • 惰性删除保证访问的 key 一定不过期
  • 定期删除主动清理过期但未被访问的 key
  • 两者互补,平衡 CPU 和内存
c
// 伪代码
void activeExpireCycle() {
    while (time < 25ms && expired_ratio > 25%) {
        随机抽样 20 个设置了过期时间的 key;
        删除已过期的;
        if (expired_ratio > 25%) continue;
        else break;
    }
}

内存淘汰策略maxmemory-policy):

策略说明
noeviction不淘汰,写入报错(默认)
allkeys-lru所有 key 中淘汰最近最少使用
allkeys-lfu所有 key 中淘汰最少使用频率(Redis 4.0+)
allkeys-random随机淘汰
volatile-lru设置过期的 key 中淘汰 LRU
volatile-lfu设置过期的 key 中淘汰 LFU
volatile-random设置过期的 key 中随机淘汰
volatile-ttl设置过期的 key 中淘汰 TTL 最短的

LRU(Least Recently Used)实现

  • Redis 的 LRU 是近似 LRU,非精确 LRU(精确 LRU 需双向链表,内存开销大)
  • 每个 key 有 24 位 lru 字段,记录最后访问时间戳(秒级,截断)
  • 淘汰时随机采样 N 个 key(maxmemory-samples,默认 5),淘汰最久未使用的
  • Redis 3.0+ 引入候选池:采样后放入候选池(默认 16),淘汰池中最久未使用的

LFU(Least Frequently Used)实现(Redis 4.0+):

  • 每个 key 有 24 位字段,分为 16 位时间戳 + 8 位计数器
  • 8 位计数器范围 0-255,使用对数概率递增算法:
    c
    double r = random();
    double baseval = log(counter - LFU_INIT_VAL + 1);
    double p = 1.0 / (baseval + 1);
    if (r < p) counter++;   // 访问次数越多,计数器增长越慢
  • 长期未访问的 key,计数器会衰减(lfu-decay-time,默认 1 分钟衰减 1)

LRU vs LFU 差异

特性LRULFU
依据最后访问时间访问频率
数据结构24 位时间戳16 位时间 + 8 位计数
算法近似 LRU + 候选池对数计数器 + 衰减
场景时间局部性强的数据频率访问差异大的数据
问题偶尔访问的 key 可能"续命"新 key 计数低易被淘汰

生产建议

  • 缓存场景:allkeys-lruallkeys-lfu
  • 有持久数据 + 缓存:volatile-lru
  • maxmemory 设置为物理内存的 60-70%
  • maxmemory-samples 调大到 10 提升精度

8.7 ★★★★Redis 的高可用方案有哪些?主从复制、哨兵、集群模式的原理和区别是什么?故障转移的过程是怎样的?

📖 点击查看答案

Redis 高可用方案

1. 主从复制(Master-Slave)

  • 一主多从,从库复制主库数据
  • 读写分离,主写从读
  • 故障需手动切换(无自动故障转移)

主从复制原理

  • 全量同步:从库首次连接主库,主库 BGSAVE 生成 RDB 发送给从库,从库加载
  • 增量同步:主库将写命令发送给从库(通过 replication backlog)
  • PSYNC:2.8+ 支持部分重同步,断线重连后只同步缺失部分
全量同步流程:
1. 从库发送 PSYNC ? -1
2. 主库 BGSAVE 生成 RDB
3. 主库发送 +FULLRESYNC runid offset
4. 主库发送 RDB 文件
5. 从库加载 RDB
6. 主库发送缓冲区中的写命令

2. 哨兵模式(Sentinel)

  • 在主从基础上增加 Sentinel 节点(独立进程),负责监控、通知、自动故障转移
  • 哨兵集群(3+ 节点)避免单点故障

哨兵职责

  • 监控:定时 ping 主从节点和其他哨兵
  • 通知:节点异常时通知运维或客户端
  • 自动故障转移:主库宕机时选举新主库
  • 配置提供:客户端连哨兵获取主库地址

故障转移过程

  1. 主观下线(SDOWN):单个哨兵发现主库 30s(down-after-milliseconds)未响应
  2. 客观下线(ODOWN):超过 quorum 个哨兵确认主库下线
  3. 选举 Leader 哨兵:Raft 协议选出一个哨兵执行故障转移
  4. 选举新主库
    • 排除故障从库
    • slave-priority(优先级)选择
    • 优先级相同选 offset 最大的(数据最新)
    • offset 相同选 runid 最小的
  5. 切换
    • 将新主库执行 SLAVEOF NO ONE
    • 将其他从库指向新主库 SLAVEOF new_master
    • 通知客户端新主库地址
    • 旧主库恢复后变为从库

3. 集群模式(Cluster)

  • 去中心化,多主多从,数据分片
  • 通过 16384 个哈希槽 分片,每个主节点负责一部分槽
  • 客户端直连任意节点,节点转发或返回 MOVED/ASK
  • 支持自动故障转移(节点间 Gossip 通信)

三种方案对比

特性主从复制哨兵集群
数据分片是(16384 槽)
自动故障转移
容量扩展垂直(单机内存)垂直水平(多节点)
复杂度
适用场景读多写少中小规模大规模、高并发

集群故障转移

  1. 节点间通过 Gossip 协议通信,检测节点存活
  2. 主节点下线,从节点发起选举(Raft 变种)
  3. 获得半数以上主节点投票的从节点成为新主节点
  4. 新主节点广播 PONG,接管槽位
  5. 客户端收到集群状态更新
ini
# 哨兵配置
sentinel monitor mymaster 192.168.1.1 6379 2   # quorum=2
sentinel down-after-milliseconds mymaster 30000
sentinel parallel-syncs mymaster 1
sentinel failover-timeout mymaster 180000

8.8 ★★★★Redis Cluster 的原理是什么?数据分片(哈希槽)机制?节点间如何通信?Gossip 协议是什么?

📖 点击查看答案

Redis Cluster 原理

1. 数据分片 - 哈希槽(Hash Slot)

  • 16384 个哈希槽(不是 65536,原因:心跳包小、位运算高效)
  • 每个 key 通过 CRC16(key) % 16384 计算所属槽
  • 每个主节点负责一部分槽(如 3 主节点:0-5460、5461-10922、10923-16383)
  • 节点增减时迁移槽
bash
# 客户端路由
SET key value
# 1. 客户端计算 CRC16(key) % 16384 = 槽号
# 2. 槽在本节点:直接处理
# 3. 槽不在本节点:返回 MOVED 错误,客户端缓存槽映射

MOVED 响应

GET key
(error) MOVED 5798 192.168.1.2:6379   # 槽 5798 在 192.168.1.2:6379

ASK 响应(迁移中):

GET key
(error) ASK 5798 192.168.1.2:6379   # 槽迁移中,临时到目标节点查询

2. 节点通信 - Gossip 协议

Gossip 原理

  • 每个节点定期向部分节点发送 PING 消息
  • PING 包含自己已知的集群信息(节点状态、槽位)
  • 接收节点更新自己的集群视图,并回复 PONG
  • 信息以"传染病"方式扩散到全集群

通信消息类型

  • MEET:新节点加入集群
  • PING:心跳,包含集群信息
  • PONG:回复 PING/MEET
  • FAIL:通知某节点下线

通信频率

  • 每秒执行 10 次(cluster-node-timeout / 10)
  • 每次选 5 个最久未通信的节点发送 PING
  • 每次包含 1/10 的集群节点信息

Gossip 消息结构

c
typedef struct clusterMsg {
    char sig[4];              // "RCmb"
    uint16_t totlen;          // 消息总长
    uint16_t ver;             // 协议版本
    uint16_t port;            // 端口
    uint16_t type;            // 消息类型
    char sender[CLUSTER_NAMELEN];  // 发送者节点 ID
    unsigned char myslots[CLUSTER_SLOTS/8];  // 槽位 bitmap(16384/8=2048 字节)
    char slaveof[CLUSTER_NAMELEN];
    uint16_t notused1;
    uint16_t cport;           // 集群总线端口
    uint16_t flags;           // 节点状态
    unsigned char state;     // 节点状态
    clusterMsgData data;     // Gossip 数据
} clusterMsg;

3. 故障检测与转移

故障检测

  1. 节点 A 向节点 B 发 PING,超时未回复(cluster-node-timeout,默认 15s)
  2. A 标记 B 为 PFAIL(主观下线)
  3. A 通过 Gossip 传播 B 的 PFAIL 状态
  4. 半数以上主节点标记 B 为 PFAIL
  5. 标记 B 为 FAIL(客观下线),广播 FAIL 消息

故障转移

  1. B 的从节点发现 B 下线
  2. 从节点发起选举(Raft 变种):
    • 当前纪元(epoch)+1
    • 向其他主节点请求投票
    • 获得半数以上主节点投票的从节点胜出
  3. 胜出从节点执行 SLAVEOF NO ONE 成为新主
  4. 接管原主的所有槽位
  5. 广播 PONG 通知集群

为什么是 16384 槽(作者 antirez 解释):

  1. 心跳包大小:16384/8 = 2KB bitmap,若 65536 则 8KB,过大
  2. 集群规模:Redis Cluster 建议不超过 1000 节点,16384 足够
  3. 位运算CRC16 & 16383 高效

8.9 ★★★★缓存穿透、缓存击穿、缓存雪崩的区别是什么?各自的解决方案有哪些?

📖 点击查看答案

三者区别

问题描述原因
缓存穿透查询不存在的数据,缓存和数据库都没有恶意攻击、参数错误
缓存击穿热点 key 过期瞬间,大量请求打到数据库热点数据过期
缓存雪崩大量 key 同时过期或 Redis 宕机,请求打到数据库过期时间相同、Redis 故障

缓存穿透解决方案

  1. 缓存空值

    java
    Object value = redis.get(key);
    if (value == null) {
        value = db.query(key);
        if (value == null) {
            redis.set(key, "NULL", 60);  // 缓存空值,短过期
        } else {
            redis.set(key, value, 3600);
        }
    }
    • 缺点:占用内存,可能不一致
  2. 布隆过滤器(Bloom Filter)

    • 启动时加载所有存在的 key 到布隆过滤器
    • 查询前先过布隆过滤器,不存在则直接返回
    java
    if (!bloomFilter.mightContain(key)) {
        return null;  // 一定不存在
    }
    // 再查缓存和数据库
    • 缺点:有误判率(说存在可能不存在,说不存在一定不存在)
  3. 参数校验

    • 非法参数(如 id < 0)直接拒绝

缓存击穿解决方案

  1. 互斥锁(Mutex)

    • 缓存未命中时,加锁查询数据库并回填缓存
    java
    String value = redis.get(key);
    if (value == null) {
        if (redis.setnx("lock:" + key, "1", 10)) {  // 加锁
            try {
                value = db.query(key);
                redis.set(key, value, 3600);
            } finally {
                redis.del("lock:" + key);
            }
        } else {
            Thread.sleep(50);  // 短暂等待后重试
            return get(key);
        }
    }
  2. 逻辑过期(不设置物理过期时间):

    • Value 中记录逻辑过期时间
    • 过期后返回旧值,异步更新
    java
    ValueData data = redis.get(key);
    if (data.getExpireTime().before(now)) {
        // 异步更新
        executor.submit(() -> updateCache(key));
    }
    return data.getValue();  // 返回旧值
  3. 热点 key 永不过期

    • 配合后台定时刷新

缓存雪崩解决方案

  1. 过期时间随机化

    java
    redis.set(key, value, 3600 + random.nextInt(300));  // 基础时间 + 随机
  2. 多级缓存

    • 本地缓存(Caffeine)+ Redis + 数据库
    • Redis 宕机时本地缓存兜底
  3. 熔断降级

    • 数据库压力大时,返回默认值或降级页
  4. Redis 高可用

    • 哨兵或集群,避免单点故障
  5. 预热

    • 上线前提前加载热点数据

统一防护

  • 限流:Sentinel 限制数据库 QPS
  • 缓存预热:定时任务刷新热点 key
  • 监控告警:监控缓存命中率、数据库 QPS

8.10 ★★★★如何保证 Redis 缓存与数据库的数据一致性?缓存旁路模式的详细流程?延迟双删策略的原理和缺陷?

📖 点击查看答案

一致性策略

1. 缓存旁路模式(Cache Aside,推荐)

  • 读:先查缓存,未命中查数据库并回填缓存
  • 写:先更新数据库,再删除缓存
java
// 读
public Object get(Long id) {
    Object value = redis.get("user:" + id);
    if (value == null) {
        value = db.query(id);
        if (value != null) {
            redis.set("user:" + id, value, 3600);
        }
    }
    return value;
}

// 写
public void update(Long id, Object value) {
    db.update(id, value);
    redis.del("user:" + id);   // 删除缓存,下次读时回填
}

为什么是删除而不是更新缓存

  1. 避免并发更新顺序问题(多线程更新数据库和缓存顺序不一致)
  2. 懒加载,避免频繁更新但很少读取的数据浪费缓存
  3. 缓存计算复杂时(如多表聚合),更新成本高

2. 读写穿透(Read/Write Through)

  • 应用只操作缓存,缓存层同步写数据库
  • 缓存作为数据库的代理

3. 异步缓存写入(Write Behind)

  • 应用只更新缓存,异步更新数据库
  • 性能最高,但数据可能丢失

一致性问题分析

场景 1:先删缓存,再更新数据库(有并发问题):

线程 A:删除缓存
线程 B:读缓存(未命中),读数据库(旧值),回填缓存(旧值)
线程 A:更新数据库
结果:缓存是旧值,数据库是新值,不一致

场景 2:先更新数据库,再删缓存(推荐):

线程 A:更新数据库
线程 B:读缓存(旧值),返回(读到旧值,但短暂)
线程 A:删除缓存
结果:最终一致,短暂不一致

场景 3:先更新数据库再删缓存的极端并发

线程 A:读缓存(未命中),读数据库(旧值)
线程 B:更新数据库(新值),删除缓存
线程 A:回填缓存(旧值)
结果:缓存是旧值,不一致(概率极低,需读数据库后、回填前有写操作)

延迟双删策略

  • 先删除缓存 → 更新数据库 → 延迟一段时间再次删除缓存
java
public void update(Long id, Object value) {
    redis.del("user:" + id);       // 第一次删除
    db.update(id, value);          // 更新数据库
    // 延迟第二次删除(避免读旧值回填)
    executor.schedule(() -> {
        redis.del("user:" + id);
    }, 500, TimeUnit.MILLISECONDS);
}

延迟双删原理

  1. 第一次删除:让读线程下次读时从数据库读最新值
  2. 第二次删除:防止在读线程读到旧值并回填缓存后,缓存保留旧值
  3. 延迟时间需大于"一次读操作(查数据库 + 回填缓存)"的时间

延迟双删缺陷

  1. 延迟时间难确定:需预估读操作耗时,过长影响性能,过短仍可能不一致
  2. 不保证强一致:第二次删除前仍可能读到旧值
  3. 删除失败仍不一致:第二次删除失败则缓存仍是旧值
  4. 性能开销:多一次删除操作 + 延迟线程占用

更好的方案

  1. 消息队列 + 重试

    • 更新数据库后发消息,消费者删除缓存,失败重试
    • 保证最终一致
  2. 订阅 binlog(Canal)

    • Canal 监听 MySQL binlog,解析变更后删除对应缓存
    • 解耦应用,可靠性高
java
// Canal 消费者
@CanalEventListener
public void onUpdate(CanalEntry.Entry entry) {
    // 解析变更,删除对应缓存
    redis.del("user:" + changedId);
}
  1. 分布式锁(强一致要求高时):
    • 读写互斥,保证一致性
    • 性能差,慎用

总结

  • 大多数场景:Cache Aside(先更新数据库,再删缓存)+ TTL 兜底
  • 一致性要求高:Cache Aside + Canal 订阅 binlog 删除缓存
  • 强一致:分布式锁或串行化

8.11 ★★★★如何设计一个 Redis 分布式锁?基于 SET NX 和 Redisson 的 RedLock 有什么区别?

📖 点击查看答案

基于 SET NX 的简单分布式锁

java
// 加锁
String lockKey = "lock:order:" + orderId;
String requestId = UUID.randomUUID().toString();
Boolean locked = redis.set(lockKey, requestId, SetOption.NX, 30, TimeUnit.SECONDS);
if (Boolean.FALSE.equals(locked)) {
    throw new RuntimeException("获取锁失败");
}
try {
    // 业务逻辑
} finally {
    // 释放锁(Lua 脚本保证原子性,防止误删)
    String script =
        "if redis.call('get', KEYS[1]) == ARGV[1] then " +
        "  return redis.call('del', KEYS[1]) " +
        "else return 0 end";
    redis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
}

问题与改进

  1. 锁过期但业务未完成

    • 业务执行超过锁过期时间,锁被释放,其他线程获取锁
    • 解决:看门狗机制(自动续期)
  2. 误删他人锁

    • 线程 A 业务未完成,锁过期;线程 B 获得锁;线程 A 执行完删除了 B 的锁
    • 解决:value 存唯一 requestId,删除前判断(Lua 脚本保证原子性)
  3. 不可重入

    • 同一线程无法多次获取同一把锁
    • 解决:Redisson 的可重入锁
  4. Redis 主从故障切换丢锁

    • 主库加锁后宕机,从库未同步锁信息,新主库无锁
    • 解决:RedLock 算法

Redisson 分布式锁

java
RLock lock = redisson.getLock("lock:order:" + orderId);
try {
    lock.lock(30, TimeUnit.SECONDS);  // 或 lock.tryLock(waitTime, leaseTime, unit)
    // 业务逻辑
} finally {
    lock.unlock();
}

Redisson 特性

  1. 看门狗自动续期:默认 30s 过期,每 10s 续期到 30s
  2. 可重入:基于 Hash 结构,key=锁名,field=线程 ID,value=重入次数
  3. 公平锁redisson.getFairLock()
  4. 读写锁redisson.getReadWriteLock()
  5. 信号量redisson.getSemaphore()
  6. RedLock 支持redisson.getRedLock(lock1, lock2, lock3)

RedLock 算法(多 Redis 实例):

原理:在 N 个(通常 5 个)独立 Redis 实例上获取锁,半数以上成功才算加锁成功。

流程

  1. 客户端获取当前时间 T1
  2. 依次向 N 个 Redis 实例发送 SET NX 加锁请求,超时时间短(如 50ms)
  3. 若 N/2+1(3/5)个实例加锁成功,且总耗时 < 锁过期时间,则加锁成功
  4. 否则向所有实例发送 DEL 释放锁
java
RLock lock1 = redisson1.getLock("lock:order:" + orderId);
RLock lock2 = redisson2.getLock("lock:order:" + orderId);
RLock lock3 = redisson3.getLock("lock:order:" + orderId);
RLock lock4 = redisson4.getLock("lock:order:" + orderId);
RLock lock5 = redisson5.getLock("lock:order:" + orderId);

RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3, lock4, lock5);
redLock.lock();
try {
    // 业务
} finally {
    redLock.unlock();
}

SET NX vs RedLock 对比

特性SET NXRedLock
复杂度简单复杂
可用性单点(主从故障丢锁)高(多实例多数派)
性能快(1 次 RTT)慢(5 次 RTT)
可重入需自己实现Redisson 支持
续期需自己实现看门狗自动续期
成本单 Redis多 Redis 实例

争议

  • Martin Kleppmann 批评 RedLock 有时钟漂移、GC 暂停等问题
  • antirez(Redis 作者)反驳,认为大多数场景可接受
  • 生产建议:单 Redis + Redisson(多数场景够用),强一致用 Zookeeper/etcd

8.12 ★★★★Redisson 分布式锁的底层实现是什么?看门狗机制如何实现锁续期?可重入锁原理是什么?

📖 点击查看答案

Redisson 分布式锁底层实现

加锁 - Lua 脚本(保证原子性):

lua
-- KEYS[1]: 锁名
-- ARGV[1]: 过期时间(看门狗 30s)
-- ARGV[2]: 线程标识(UUID:threadId)

if (redis.call('exists', KEYS[1]) == 0) then
    -- 锁不存在,加锁
    redis.call('hset', KEYS[1], ARGV[2], 1);
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return nil;
end;
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
    -- 锁存在但持有者是当前线程,重入次数+1
    redis.call('hincrby', KEYS[1], ARGV[2], 1);
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return nil;
end;
-- 锁被其他线程持有
return redis.call('pttl', KEYS[1]);

数据结构

  • Hash:key=锁名,field=UUID:threadId,value=重入次数
  • 使用 Hash 而非 String,支持可重入

解锁 - Lua 脚本

lua
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then
    return nil;  -- 不是当前线程的锁
end;
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
if (counter > 0) then
    -- 重入次数减 1,但不为 0,续期
    redis.call('pexpire', KEYS[1], ARGV[2]);
    return 0;
else
    -- 重入次数为 0,删除锁
    redis.call('del', KEYS[1]);
    redis.call('publish', ARGV[1], 'unlock');  -- 通知等待线程
    return 1;
end;

看门狗(Watch Dog)机制

原理

  • 加锁成功后,启动一个定时任务,每隔 1/3 锁过期时间(默认 10s)检查锁是否还被当前线程持有
  • 若持有,续期到 30s
  • 业务执行完后,取消定时任务
java
// Redisson 源码核心
private void renewExpiration() {
    ExpirationEntry entry = new ExpirationEntry();
    EXPIRATION_RENEWAL_MAP.put(entryName, entry);
    entry.addTask(() -> {
        // 定时任务逻辑
        RFuture<Boolean> future = renewExpirationAsync(threadId);
        future.onComplete((res, e) -> {
            if (res) {
                // 续期成功,继续下一轮
                renewExpiration();
            } else {
                // 续期失败,取消
                cancelExpirationRenewal(threadId);
            }
        });
    });
    // 延迟 10s 执行(30s / 3)
    Timeout task = commandExecutor.getConnectionManager()
        .newTimeout(entry, 10, TimeUnit.SECONDS);
}

续期 Lua 脚本

lua
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return 1;
end;
return 0;

看门狗触发条件

  • 只有调用 lock()(无过期时间参数)时才启用看门狗
  • 调用 lock(leaseTime, TimeUnit) 时不启用,锁到期自动释放
java
lock.lock();                    // 启用看门狗,默认 30s 续期
lock.lock(10, TimeUnit.SECONDS); // 不启用看门狗,10s 后自动释放

可重入锁原理

  1. 数据结构:Hash 结构,field=线程标识,value=重入次数
  2. 加锁
    • 锁不存在:HSET lock UUID:threadId 1,设置过期
    • 锁存在且持有者是当前线程:HINCRBY lock UUID:threadId 1,续期
    • 锁被其他线程持有:等待
  3. 解锁
    • 重入次数 -1
    • 次数 > 0:续期
    • 次数 = 0:DEL lock,发布解锁消息
java
RLock lock = redisson.getLock("myLock");
lock.lock();           // 第一次加锁,count=1
lock.lock();           // 重入,count=2
lock.unlock();         // 解锁,count=1
lock.unlock();         // 完全解锁,count=0,删除锁

等待机制

  • 加锁失败时,订阅 Redis 的 unlock 频道
  • 解锁时发布消息,等待线程被唤醒重试
  • 支持 tryLock(waitTime, leaseTime, unit) 带超时等待

公平锁

  • 基于 Redis List 维护等待队列
  • 按入队顺序获取锁

8.13 ★★★★Redis 中的 Big Key 和热 Key 问题如何排查和处理?有什么预防措施?

📖 点击查看答案

Big Key 问题

定义

  • String:单个 value > 10KB
  • Hash/List/Set:元素数 > 1 万,或总大小 > 10MB
  • ZSet:元素数 > 1 万

危害

  1. 内存不均:集群模式下单节点内存倾斜
  2. 阻塞:操作大 key 耗时长(如 DEL 大 key 阻塞主线程)
  3. 网络:传输大 value 占用带宽
  4. 过期删除:大 key 过期时删除耗时
  5. 迁移困难:集群 slot 迁移时大 key 阻塞

排查方法

  1. redis-cli --bigkeys

    bash
    redis-cli --bigkeys
    # 扫描各类型最大的 key
  2. MEMORY USAGE(Redis 4.0+):

    bash
    MEMORY USAGE key
    # 返回 key 占用字节数
  3. redis-rdb-tools

    • 分析 RDB 文件,找出大 key
  4. SCAN + DEBUG OBJECT

    • 遍历 key,检查大小

处理方法

  1. 拆分

    • 大 Hash 拆分为多个小 Hash:user:1:baseuser:1:detail
    • 大 List 拆分:list:1:part0list:1:part1
  2. 压缩

    • Value 用压缩算法(如 Snappy、LZ4)
  3. 删除大 key

    • Redis 4.0+ UNLINK(异步删除,不阻塞)
    • 分批删除:HSCAN + HDEL
    bash
    # 大 Hash 分批删除
    while true; do
        fields=$(redis-cli HSCAN bigkey 0 COUNT 100 | tail -n +2)
        redis-cli HDEL bigkey $fields
    done

热 Key 问题

定义:某个 key 访问量极高(如 QPS > 1 万)

危害

  1. 单节点压力:集群模式下单个节点 CPU、带宽打满
  2. 缓存击穿:热 key 过期瞬间打垮数据库

排查方法

  1. redis-cli --hotkeys

    bash
    redis-cli --hotkeys
    # 需开启 LFU 淘汰策略:maxmemory-policy allkeys-lfu
  2. MONITOR(慎用,影响性能):

    bash
    redis-cli MONITOR | grep -o 'GET [^ ]*' | sort | uniq -c | sort -nr
  3. 代理层统计

    • 在客户端或代理层统计 key 访问频率
  4. Redis 6.0+ OBJECT FREQ

    bash
    OBJECT FREQ key  # 查看 LFU 访问频率

处理方法

  1. 本地缓存

    • 客户端用 Caffeine/Guava 缓存热 key,减少 Redis 访问
    • 设置短 TTL,保证最终一致
  2. 多副本

    • 将热 key 复制到多个 key:hotkey:0hotkey:1hotkey:2
    • 客户端随机访问不同副本,分散压力
  3. 集群分片

    • 将热 key 拆分到不同节点(但单 key 无法拆分,需业务层改造)

预防措施

  1. Big Key 预防

    • 规范设计:单个 key 不超过 10KB,集合元素不超过 1 万
    • 定期清理无用的大 key
    • 大集合用 Hash 分片存储
    • 业务拆分,避免一个 key 存储过多数据
  2. 热 Key 预防

    • 监控告警:QPS 异常时告警
    • 设计阶段避免热点(如不要所有请求打到同一个 key)
    • 热点打散:用哈希分散到多个 key
    • 多级缓存:本地缓存 + Redis + 数据库
  3. 通用监控

    • Prometheus + Grafana 监控 Redis 指标
    • 慢查询日志:SLOWLOG GET
    • 定期巡检 Big Key

8.14 ★★★Redis 的 Pipeline 和事务的区别是什么?MULTI/EXEC 和 Lua 脚本如何保证原子性?

📖 点击查看答案

Pipeline(管道)

原理

  • 客户端批量发送命令,服务端依次执行后批量返回
  • 减少网络 RTT(往返时间),提升吞吐量
  • 不保证原子性:命令在服务端可能被其他客户端命令穿插
java
Pipeline pipeline = jedis.pipelined();
for (int i = 0; i < 1000; i++) {
    pipeline.set("key" + i, "value" + i);
}
pipeline.sync();  // 执行并返回结果

适用场景

  • 批量写入/读取
  • 不要求原子性的场景

事务(MULTI/EXEC)

原理

  • MULTI 开启事务,后续命令进入队列
  • EXEC 顺序执行队列中的命令
  • 保证命令顺序执行,不被穿插(原子性有限)
  • 不支持回滚(某条命令失败,其他仍执行)
bash
MULTI
SET key1 value1
SET key2 value2
INCR counter
EXEC

WATCH 乐观锁

  • WATCH key 监视 key
  • 事务执行前 key 被修改,整个事务失败
  • 实现乐观锁
bash
WATCH balance
val = GET balance
MULTI
SET balance (val - 100)
EXEC
# 若 balance 被 others 修改,EXEC 返回 nil,事务不执行

Pipeline vs 事务

特性Pipeline事务
目的减少网络往返保证命令顺序执行
原子性部分(顺序执行,不回滚)
命令穿插可能不会
性能提升显著(网络)
适用批量操作顺序敏感操作

Lua 脚本

原理

  • Redis 2.6+ 支持
  • 脚本在服务端单线程原子执行,期间不处理其他命令
  • 保证多条命令的原子性
lua
-- 转账脚本
local from = KEYS[1]
local to = KEYS[2]
local amount = tonumber(ARGV[1])

local balance = tonumber(redis.call('GET', from))
if balance < amount then
    return -1  -- 余额不足
end

redis.call('DECRBY', from, amount)
redis.call('INCRBY', to, amount)
return 1  -- 成功
java
String script = "local from = KEYS[1] ...";
Object result = jedis.eval(script, 2, "account:1", "account:2", "100");

MULTI/EXEC vs Lua 原子性对比

特性MULTI/EXECLua 脚本
原子性顺序执行(不回滚)完全原子(单线程执行)
条件逻辑不支持(命令入队即执行)支持(if/else)
失败处理命令失败不回滚脚本中可控制
网络 RTT多次(入队 + 执行)1 次(发送脚本)
复杂逻辑不支持支持
持久化FUNCTION(7.0+)持久化

Lua 脚本注意事项

  1. 不要执行耗时操作:脚本执行期间阻塞其他命令
  2. 脚本缓存EVALSHA 传脚本 SHA1,避免重复传输
  3. 只读脚本EVAL_RO / FCALL_RO 只读模式
  4. 集群兼容:脚本中操作的 key 必须在同一 slot(用 {hashtag} 保证)

原子性总结

  • Pipeline:无原子性
  • MULTI/EXEC:命令顺序执行,但无回滚,中间可被其他命令穿插(只是入队阶段不穿插)
  • Lua 脚本:完整原子性,整个脚本执行期间不处理其他命令

8.15 ★★如何用 Redis 实现延迟队列?有哪些方案?

📖 点击查看答案

方案一:ZSet(有序集合)

原理

  • ZSet 的 score 存储执行时间戳
  • 消费者定时扫描到期的元素
java
// 生产者:添加延迟任务
long executeTime = System.currentTimeMillis() + 60000;  // 60s 后执行
redis.zadd("delay:queue", executeTime, "taskId:123");

// 消费者:扫描到期任务
while (true) {
    Set<String> tasks = redis.zrangeByScore(
        "delay:queue", 0, System.currentTimeMillis(), 0, 10);
    for (String task : tasks) {
        // ZREM 返回 1 表示抢到任务
        if (redis.zrem("delay:queue", task) > 0) {
            executeTask(task);
        }
    }
    Thread.sleep(100);
}

优点:简单,支持多消费者 缺点:需轮询,有一定延迟;高并发下 ZREM 竞争

方案二:Keyspace Notification(键空间通知)

原理

  • 监听 key 过期事件
  • 设置带 TTL 的 key,过期时触发通知
java
// 生产者
redis.setex("delay:task:123", 60, "taskData");

// 消费者(订阅过期事件)
redis.configSet("notify-keyspace-events", "Ex");
redis.psubscribe(new KeyExpiredListener(), "__keyevent@0__:expired");

优点:实时性好,无需轮询 缺点

  • 过期事件可能丢失(Redis 重启、网络问题)
  • 无法获取 key 的 value(已过期删除)
  • 不保证可靠投递

方案三:Redis Stream(5.0+)

原理

  • Stream 支持消费组、ACK
  • 配合定时任务实现延迟
java
// 生产者
redis.xadd("delay:stream", "*", "task", "taskData", "executeTime", executeTime);

// 消费者:定时读取
while (true) {
    List<StreamEntry> entries = redis.xrange("delay:stream", "-", "+", 10);
    for (StreamEntry entry : entries) {
        if (entry.getFields().get("executeTime") <= now) {
            executeTask(entry);
            redis.xack("delay:stream", "consumerGroup", entry.getID());
        }
    }
}

优点:支持消费组、ACK、持久化 缺点:延迟实现稍复杂

方案四:Redisson 延迟队列(RDelayedQueue)

java
RDelayedQueue<String> delayedQueue = redisson.getDelayedQueue(
    redisson.getQueue("delay:queue"));

// 添加延迟任务
delayedQueue.offer("taskData", 60, TimeUnit.SECONDS);

// 消费者
RQueue<String> queue = redisson.getQueue("delay:queue");
String task = queue.poll();  // 到期后才能取到

原理

  • 基于 ZSet 实现
  • 内部维护一个普通队列,到期后从 ZSet 移到队列
  • 封装了扫描逻辑

优点:开箱即用,可靠性高 缺点:依赖 Redisson

方案对比

方案可靠性实时性复杂度适用场景
ZSet中(轮询)通用
键空间通知实时性要求高,允许丢失
Stream需要持久化和消费组
Redisson推荐,开箱即用

生产建议

  • 简单场景:Redisson RDelayedQueue
  • 强可靠:专业 MQ(RabbitMQ TTL + DLX、RocketMQ 延迟消息)
  • 避免用键空间通知(不可靠)

完整示例(ZSet 方案)

java
@Component
public class DelayQueueConsumer {
    @Scheduled(fixedRate = 1000)
    public void consume() {
        long now = System.currentTimeMillis();
        Set<DelayedTask> tasks = redisTemplate.opsForZSet()
            .rangeByScore("delay:queue", 0, now, 0, 100);
        if (tasks == null || tasks.isEmpty()) return;

        for (DelayedTask task : tasks) {
            if (redisTemplate.opsForZSet().remove("delay:queue", task) > 0) {
                executor.submit(() -> {
                    try {
                        taskProcessor.process(task);
                    } catch (Exception e) {
                        // 失败重试或记录
                        redisTemplate.opsForZSet().add("delay:queue",
                            task, now + 60000);  // 60s 后重试
                    }
                });
            }
        }
    }
}

基于 4 份主流面试题库整合精选,共 200 题