Easton Notes
八股Redis 面试题

缓存设计与实战

Redis 使用边界、分布式锁、热点治理、缓存一致性与秒杀防超卖方案。

Redis 适合哪些业务场景?

回答重点

Redis 是内存数据结构服务,适合低延迟访问、状态可重建或能接受其持久化与一致性边界的场景:

  • 缓存:缓存热点查询结果、聚合结果和配置,降低数据库与下游服务压力。
  • 计数与限流:利用原子命令实现访问计数、库存预扣、滑动窗口或令牌桶。
  • 排行榜:使用 Sorted Set 按分数排序并查询名次。
  • 会话与临时状态:保存登录会话、验证码、幂等标记等带 TTL 的数据。
  • 集合运算:用 Set、Bitmap、HyperLogLog 等实现去重、签到和近似基数统计。
  • 异步通信:用 Pub/Sub、List 或 Stream 实现不同可靠性级别的通知与任务分发。
  • 协调:用条件写入和过期时间实现有限场景下的分布式锁。

Redis 快的主要原因包括内存访问、高效数据结构、事件驱动网络模型和较短的命令执行路径。管道化还能减少网络往返。不能脱离命令复杂度、Value 大小、网络、持久化配置和硬件给出固定 QPS 承诺,应使用真实数据与访问模型压测。

扩展知识

以下情况不应只依赖 Redis:

  • 余额、账本、订单等要求事务和强一致性的事实数据。
  • 需要长期留存、复杂查询或大规模顺序扫描的数据。
  • 单次计算复杂度高、Value 很大,或者必须跨大量 Key 完成原子操作的业务。

此时应让数据库或专用系统保存权威状态,把 Redis 作为可重建的加速层。

本地缓存和 Redis 缓存有什么区别?

回答重点

维度本地缓存Redis 缓存
访问路径进程内访问,无网络往返需要网络和序列化
延迟通常更低通常高于本地缓存,但便于共享
容量受单个进程内存限制可独立扩容和分片
数据范围每个实例各有一份多个应用实例共享
一致性多副本失效和更新较难协调集中管理更容易,但仍不天然强一致
生命周期随进程重启丢失独立于应用进程,可配置持久化
故障影响单实例缓存失效Redis 故障可能影响全部调用方

少量、只读、变化不频繁且允许短暂不一致的数据适合本地缓存。需要多实例共享、较大容量、统一 TTL 或原子操作时更适合 Redis。

扩展知识

两者可以组成多级缓存:进程内 L1 缓存承担极热点流量,Redis 作为共享 L2 缓存。这样做会增加失效传播和版本管理成本,通常需要短 L1 TTL、消息通知或版本号,并为消息丢失保留 TTL 兜底。

Redis 能否替代专业消息队列?

回答重点

要根据消息可靠性要求选择 Redis 能力:

  • Pub/Sub:适合在线广播。消息不持久化,没有消费确认;订阅者离线或断线时会丢消息。
  • List:配合阻塞弹出可实现简单任务队列,但直接弹出后没有内建确认,需要业务自行实现处理中列表、重试、超时和死信。
  • Stream:支持持久化日志、消费组、待确认列表和消费确认,更适合可靠任务分发。它通常提供至少一次消费语义,消费者仍要幂等处理重复消息。

Redis 可以承担轻量通知、短链路任务和与缓存数据紧密相关的异步处理,但不是所有场景下的消息队列替代品。

扩展知识

出现以下需求时,应优先评估 Kafka、RabbitMQ、Pulsar 等专用系统:

  • 长时间保留和大规模历史回放。
  • 明确的跨机房容灾和更强持久性目标。
  • 复杂路由、死信治理、消费审计或严格的积压隔离。
  • 消息吞吐与缓存流量需要独立扩缩容和故障隔离。

即使使用 Stream,Redis 的异步复制、AOF 策略和故障切换仍会影响消息持久性。消费者应在业务提交成功后确认消息,并对重试、重复、积压和待确认消息进行监控。

如何正确实现 Redis 分布式锁,它有哪些局限?

回答重点

单实例上的基本实现需要同时满足互斥、自动过期和所有者校验。

加锁时用一条原子命令写入随机且不可复用的令牌,并同时设置租约:

SET lock:order:1001 550e8400-e29b-41d4-a716-446655440000 NX PX 10000

只有返回成功才能进入临界区。释放锁时必须比较令牌后再删除,并把两个动作放在 Lua 脚本中原子执行:

if redis.call('GET', KEYS[1]) == ARGV[1] then
  return redis.call('DEL', KEYS[1])
end

return 0

业务执行时间可能超过租约时,可以在确认仍持有同一令牌的前提下续期。续期任务本身也会失败,因此业务不能假设“启动看门狗后锁一定有效”。

扩展知识

Redis 锁有以下关键边界:

  • 客户端长时间暂停或网络阻塞,锁可能已过期并被其他客户端取得,而旧客户端恢复后仍在执行。
  • 主从复制是异步的。主节点写入锁后、复制完成前发生故障,提升后的副本可能没有该锁,从而出现两个持有者。
  • 锁只协调遵守协议的客户端,不能自动回滚已经发生的外部副作用。

对正确性要求高的资源操作,应增加 fencing token:每次成功获取租约时得到单调递增的序号,下游资源拒绝比当前序号更旧的请求。这个序号必须来自能满足所需一致性的权威系统。数据库唯一约束、条件更新或事务仍应作为最终防线。

Redlock 尝试在多个相互独立的 Redis 主节点上取得多数锁,并校验获取耗时。它能降低单节点故障风险,但正确性依赖时钟、网络延迟和暂停时间等假设,业界对其安全模型存在争议。不能把 Redlock 描述为绝对安全方案;强一致协调应优先评估 etcd、ZooKeeper 或数据库锁,并继续使用 fencing token 保护资源。

什么是大 Key 和热 Key,如何发现与治理?

回答重点

  • 大 Key:单个 Key 的 Value 过大或集合成员过多,导致单次命令占用过多 CPU、内存、网络或复制资源。
  • 热 Key:请求高度集中在少数 Key,导致单个 Redis 节点、CPU 核或网络链路成为瓶颈。

两者没有通用的固定阈值,也互不等价。一个很小的 Key 可以非常热,一个很大的 Key 也可能很少访问。应以延迟目标、带宽、命令复杂度、分片倾斜和故障恢复时间确定告警阈值。

排查时可结合:

  • SLOWLOG、延迟监控、命令统计、网络流量和分片负载。
  • MEMORY USAGE 检查目标 Key 的近似内存占用。
  • redis-cli --bigkeys 或 redis-cli --memkeys 采样扫描大 Key;生产环境应控制扫描速率。
  • 在启用 LFU 统计的场景使用 redis-cli --hotkeys,并结合客户端埋点或代理层统计定位热 Key。

扩展知识

大 Key 的常见治理方法:

  • 按时间、业务 ID 或哈希桶拆成多个 Key。
  • 避免一次性执行 HGETALL、SMEMBERS 或全量范围查询,改用分页或 HSCAN、SSCAN、ZSCAN。
  • 删除大 Key 时优先使用 UNLINK;超大集合可先分批移除成员,再删除空 Key。
  • 不适合 Redis 的大对象迁回对象存储、数据库或搜索系统。

热 Key 的常见治理方法:

  • 增加短 TTL 本地缓存,并处理失效通知与陈旧数据。
  • 在应用侧合并相同的并发请求,避免同时回源。
  • 对只读热点使用副本分担读流量,但接受复制延迟。
  • 复制热点数据到多个带后缀的 Key 并按规则路由,但要承担多副本更新成本。
  • 对入口限流、热点预热,并避免单个超高复杂度命令持续占用主线程。

如何保证 Redis 与数据库的缓存一致性?

回答重点

常见方案是 Cache Aside:

  1. 读请求先查缓存。
  2. 未命中时查询数据库,再写入带 TTL 的缓存。
  3. 写请求先提交数据库事务,成功后删除缓存,而不是直接更新两份数据。

“先更新数据库,再删除缓存”通常更稳妥。若先删缓存再更新数据库,并发读可能在数据库提交前读到旧值并重新写回缓存。删除而非更新缓存也能减少双写部分失败和并发写乱序问题。

不过该方案仍只能追求最终一致性。例如,读请求缓存未命中并读到旧数据库值,随后写请求提交并删除缓存,最后先前的读请求才把旧值写入缓存。常见缓解方式包括:

  • 给缓存设置合理 TTL,限制脏数据的最长存活时间。
  • 缓存值携带数据版本,写入缓存时拒绝旧版本覆盖新版本。
  • 对同一业务实体串行化关键更新,或者在回填前再次确认版本。
  • 在确有必要且延迟经过评估时做延迟二次删除;它只是降低竞态概率,不是强一致性证明。

扩展知识

删除缓存失败时,应建立可靠重试链路:

  • 在数据库事务内写入 Outbox 事件,由后台任务投递并重试删除。
  • 订阅数据库 binlog,通过 CDC 产生失效事件。
  • 消费者按业务 Key 幂等删除,监控积压、重试次数和死信。

直接在数据库提交后发送一次消息仍有进程崩溃窗口,Outbox 或 binlog CDC 才能把“数据已提交”与“最终能观察到变更”关联起来。

Redis 与数据库之间通常没有覆盖两者的原子事务,缓存也通常不能承诺强一致性。余额、库存和权限判定等强一致操作应读取或更新权威存储;缓存只能作为加速结果,并在失败时旁路或降级。

缓存雪崩、击穿和穿透有什么区别?

回答重点

问题含义典型原因
缓存雪崩大量请求在一段时间内同时回源大批 Key 同时过期,或 Redis 整体不可用
缓存击穿某个存在的热点 Key 失效后,大量请求同时回源热点过期、删除或尚未完成重建
缓存穿透请求的数据在缓存和数据库中都不存在,每次都绕过缓存查询数据库恶意随机 ID、参数错误或不存在数据被反复查询

对应治理方式如下:

  • 雪崩:TTL 加随机抖动,分批预热和刷新;部署高可用缓存;入口限流、熔断和降级;为数据库设置独立并发保护。
  • 击穿:按 Key 使用互斥重建或 singleflight,并在拿到锁后再次检查缓存;也可使用逻辑过期和 stale-while-revalidate,由后台异步刷新热点。
  • 穿透:参数校验、鉴权与限流;短时间缓存空值;用布隆过滤器提前拒绝确定不存在的 ID。

扩展知识

互斥重建的锁粒度应落到具体业务 Key,避免把不同数据的回源全部串行化。等待方需要超时和降级策略,持锁方异常时也要能释放或过期。

“热点永不过期”通常指不设置物理 TTL、在 Value 中记录逻辑过期时间并异步刷新。它减少了击穿风险,但会返回短暂旧数据,还需要后台任务、容量治理和失效兜底。

布隆过滤器的原理和限制是什么?

回答重点

布隆过滤器由位数组和多个哈希函数组成:

  1. 写入元素时,计算多个哈希位置并把对应位设为 1。
  2. 查询元素时,只要有任意一位为 0,该元素就一定不在集合中。
  3. 如果所有位都为 1,该元素只是“可能存在”,仍可能因哈希冲突产生误判。

在元素都被正确写入且位没有被错误清除的前提下,标准布隆过滤器没有假阴性,但存在假阳性。它不能直接清除某个元素对应的位,因为这些位可能被其他元素共享;需要删除能力时可评估计数布隆过滤器或其他索引结构。

扩展知识

容量和可接受误判率必须在创建时规划。实际元素数远超设计容量后,误判率会明显上升,需要重建或滚动到新过滤器。

Redis 核心数据类型没有直接提供 BF.* 命令,可通过 Bitmap 自行实现,也可安装 RedisBloom 模块。例如:

BF.RESERVE product:ids <error_rate> <capacity>
BF.ADD product:ids <product_id>
BF.EXISTS product:ids <product_id>

布隆过滤器本身也有一致性问题。数据库新增数据后若过滤器尚未更新,直接相信“不存在”会错误拒绝真实数据。应通过同步写入、Outbox 或 CDC 更新过滤器,并对同步延迟设计回退策略;删除遗漏只会增加假阳性,新增遗漏则可能破坏“无假阴性”的业务前提。

秒杀系统如何抗高并发并防止超卖?

回答重点

秒杀设计的核心是分层削峰、原子预扣、可靠异步处理和数据库最终约束:

  1. 静态资源由 CDN 承担;入口校验活动时间、资格和请求签名,并按用户与商品限流。
  2. 活动前把可售额度加载到 Redis。使用 Lua 脚本或 Redis Function 原子完成“一人一单检查、库存判断、预扣和排队记录”,避免 GET 后再 DECR 的竞态。
  3. 请求进入可靠队列后立即返回排队状态,由消费者异步创建订单。
  4. 消费者以请求 ID 幂等处理,在数据库事务中使用唯一约束和带库存条件的更新作为最终防超卖防线。
  5. 订单失败、超时或取消时按状态机幂等补偿库存,并通过对账任务修复 Redis、队列与数据库之间的差异。

下面的脚本示意把同一商品相关 Key 放在相同哈希标签中,并原子完成预扣和写入 Redis Stream:

-- KEYS[1]: seckill:{sku}:stock
-- KEYS[2]: seckill:{sku}:buyer:<user_id>
-- KEYS[3]: seckill:{sku}:orders
-- ARGV: quantity, reservation_ttl, request_id, sku_id, user_id
if redis.call('EXISTS', KEYS[2]) == 1 then
  return 2
end

local stock = tonumber(redis.call('GET', KEYS[1]) or '-1')
local quantity = tonumber(ARGV[1])
if stock < quantity then
  return 0
end

redis.call('DECRBY', KEYS[1], quantity)
redis.call('SET', KEYS[2], ARGV[3], 'EX', ARGV[2])
redis.call(
  'XADD',
  KEYS[3],
  '*',
  'request_id', ARGV[3],
  'sku_id', ARGV[4],
  'user_id', ARGV[5],
  'quantity', ARGV[1]
)
return 1

数据库扣减必须带非负条件,不能采用“先读库存,再无条件更新”的方式:

UPDATE sku_stock
SET available = available - :quantity
WHERE sku_id = :sku_id
  AND available >= :quantity;

应用检查受影响行数,只有更新成功才创建或确认订单;订单表还应对请求 ID,或“用户与活动商品”的组合建立唯一约束。

扩展知识

Redis 原子预扣主要用于拦截流量,数据库条件更新才是持久化层的最终防线。Redis 异步复制和故障切换可能造成预扣状态回退,因此不能把缓存库存当成绝对权威。

不建议用一个商品级分布式锁串行处理全部请求,它会把热门商品变成单点队列。仅使用分段库存虽然提高并发,但会增加额度不均、跨段重试和补偿复杂度。Lua 原子校验配合队列削峰通常更直接。

如果使用外部消息队列,Redis 预扣与消息发布之间没有天然原子事务。可记录可重放的预约状态并由可靠投递任务转发,或使用同一 Lua 脚本写 Redis Stream,再由消费者幂等落库。无论选择哪种链路,都需要超时关闭、补偿、死信处理和定期对账。

评论

On this page