缓存三大问题

缓存穿透

缓存穿透指的是客户端请求查询一个在缓存中和数据库中都“绝对不存在”的数据

由于缓存中没有(缓存未命中),请求会“穿透”缓存层,直接打到数据库层。如果这种请求量很大(例如,恶意的攻击者使用大量不存在的ID进行查询),数据库将承受巨大的压力,可能导致服务宕机。

做法 (Solutions)

  1. 缓存空值 (Cache Null Values)
    • 做法: 当数据库查询返回为空(null)时,我们依然将这个“空结果”缓存起来,但为其设置一个较短的过期时间(TTL),例如60秒。
    • 优点: 实现简单,立竿见影。
    • 缺点:
      • 会占用缓存空间(尽管很短)。
      • 存在短暂的数据不一致性:如果在这60秒内,数据库中恰好创建了这条数据,缓存返回的依然是“空”,直到缓存过期。
  2. 布隆过滤器 (Bloom Filter)
    • 做法: 布隆过滤器是一种高效的、概率性的数据结构,它可以用极小的空间判断一个元素“一定不存在”或“可能存在”。
    • 流程:
      1. 启动时,将数据库中所有合法的Key加载到布隆过滤器中。
      2. 当请求到来时,先去布隆过滤器查询Key是否存在。
      3. 如果布隆过滤器判断“一定不存在”,则立即返回空,绝对不会去查缓存和数据库。
      4. 如果布隆过滤器判断“可能存在”,则继续执行后续的查询缓存、查询DB的流程。
    • 优点: 空间效率和查询效率极高,完美解决了穿透问题。
    • 缺点:
      • 实现相对复杂。
      • 存在“误判率”(False Positive):它判断“可能存在”时,数据实际上可能不存在(但它绝不会把“存在”的判为“不存在”)。
      • 数据新增时,需要同步更新布隆过滤器(删除操作不支持或很麻烦)。

面试要点

  • Q: 什么是缓存穿透?它和击穿有什么区别?
    • A: 穿透是查不存在的数据,导致请求绕过缓存直达DB。击穿是查存在的数据,但这个热点数据刚过期,导致请求直达DB。
  • Q: 两种解决方案(缓存空值 vs 布隆过滤器)如何选择?
    • A: 缓存空值简单,适用于恶意攻击不频繁、允许短暂不一致的场景。布隆过滤器更可靠,适用于数据“黑名单”明确、查询QPS极高、对恶意攻击防护要求高的场景(例如,防止用户ID的恶意爬取)。

缓存击穿

缓存击穿指的是某一个访问量极高的热点Key (Hotspot Key),在它失效的那一瞬间,海量的并发请求同时涌入,导致这些请求全部“击穿”缓存,直接访问数据库,造成数据库瞬时压力剧增,甚至宕机。

做法 (Solutions)

  1. 互斥锁 / 分布式锁 (Mutex Lock / Distributed Lock)

    • 做法: 这是最经典的解决方案。当缓存未命中时,不是所有线程都去查数据库。
      1. 线程A尝试获取该Key的分布式锁(例如 lock:product:123)。
      2. 线程A获取锁成功,开始查询数据库、重建缓存。
      3. 线程B、C、D… 发现缓存未命中,尝试获取锁,但失败。
      4. 线程B、C、D… 去查数据库,而是进入“等待”状态(例如,自旋或休眠后重试)。
      5. 线程A重建缓存后,释放锁
      6. 线程B、C、D… 再次(或被唤醒)查询时,直接命中缓存。
    • 优点: 强一致性,只允许一个请求“穿透”到DB,有效保护数据库。
    • 缺点:
      • 实现复杂,需要引入分布式锁(如Redis或Zookeeper)。
      • 性能下降,大量线程在锁上等待,导致系统吞吐量降低。
  2. 逻辑过期 (Logical Expiration)

    • 做法: 这是一种以牺牲少量一致性来换取极高可用性的方案。我们给缓存设置物理TTL(EXPIRE),而是将“过期时间”作为value的一部分存储起来。

    • 数据结构示例:

      1
      2
      3
      4
      
      {
        "data": { ...product data... },
        "expireAt": 1678886400 // 逻辑过期时间戳
      }
      
    • 流程:

      1. 线程A读取缓存,发现数据逻辑过期(expireAt > now),直接返回数据。
      2. 线程B读取缓存,发现数据逻辑过期(expireAt <= now)。
      3. 关键: 线程B立即返回旧数据(stale data)给用户,保证可用性。
      4. 同时,线程B尝试获取一个轻量级锁(用于防止多个线程同时刷新)。
      5. 获取锁成功的线程B,异步开启一个新线程去执行“查询数据库、重建缓存(并更新expireAt)”的操作,然后释放锁。
    • 优点: 极高的可用性(用户永远不会等待),数据库压力平缓(只有一个后台线程刷新)。

    • 缺点:

      • 实现更复杂。
      • 用户会短暂读到“旧数据”(数据一致性非最高)。
      • 需要一个预热机制(启动时就需要将热点数据加载并设置逻辑过期时间)。

面试要点

  • Q: 击穿和雪崩的区别?
    • A: 击穿是一个热点Key失效。雪崩是大量Key同时失效,或缓存服务整体宕机。
  • Q: 互斥锁方案,如果第一个线程重建缓存失败了怎么办?
    • A: 这是一个很好的追问。失败时,锁必须释放。其他线程会重试。为了防止“失败风暴”,可以结合“缓存空值”策略,即重建失败也缓存一个“空值”或“错误标识”,并设置很短的TTL,防止请求持续冲击DB。
  • Q: 逻辑过期方案,返回旧数据,产品经理不接受怎么办?
    • A: 这取决于业务场景。如果业务(如金融)对一致性要求极高,则必须使用互斥锁方案。如果业务(如电商首页、新闻)对可用性要求高于一致性,逻辑过期是最佳方案。

缓存雪崩

缓存雪崩指的是在某一时间段,缓存集中失效,或者缓存服务(如Redis)本身发生故障(宕机)。

这导致所有(或大量的)请求都无法命中缓存,流量像“雪崩”一样,瞬间全部涌向数据库,导致数据库被压垮。

做法 (Solutions)

针对场景一:集中过期

  1. 过期时间加随机值 (Random Jitter)
    • 做法: 在设置Key的TTL时,不要使用固定值,而是在基础TTL上增加一个小的随机“抖动”(Jitter)。
    • 示例: 原本TTL是 3600 秒。改为 3600 + random(1, 600) 秒。
    • 优点: 简单高效,将过期时间打散,避免集中失效。

针对场景二:缓存服务不可用

  1. 构建高可用缓存集群 (HA Cluster)
    • 做法: 永远不要使用单点Redis。使用Redis哨兵(Sentinel)或Redis集群(Cluster)来保证缓存服务的高可用性。
    • 优点: 解决了缓存的“单点故障”问题,是架构上的根本保障。
  2. 服务降级与熔断 (Service Degradation / Circuit Breaking)
    • 做法: 当检测到缓存服务不可用或DB压力过大时,启动应急预案。
    • 降级:
      • 兜底数据: 暂时关闭“写”操作,只提供“读”服务,并且返回一些本地缓存的、默认的或“过时”的数据(类似逻辑过期)。
      • 部分可用: 限制非核心功能(如推荐、评论),只保留核心功能(如主页、下单)。
    • 熔断: 当缓存服务持续失败时,熔断器打开,后续请求在入口处(如网关)就直接返回“服务繁忙”的错误,完全切断对下游(DB)的访问,以保护数据库。
    • 优点: 系统的“最后一道防线”,防止整个系统被拖垮。
  3. 多级缓存 (Multi-Level Cache)
    • 做法: 使用 Nginx/OpenResty 的 lua-resty-cache 或本地内存缓存(如 Guava Cache, Caffeine)作为一级缓存,Redis作为二级缓存。
    • 优点: 即使Redis挂了,本地缓存依然能扛住大部分流量。

面试要点

  • Q: 雪崩的两个成因是什么?
    • A: (必须答出)1. 大面积Key同时过期;2. 缓存服务本身宕机。
  • Q: 针对这两个成因,你的架构设计方案是什么?
    • A: (展示系统设计能力)针对“集中过期”,使用“随机TTL”;针对“服务宕机”,使用“Redis高可用集群”作为基础保障,并配合“多级缓存”和“服务降级/熔断”作为应急预案。

分布式锁

基于 SETNX 的锁(单点/主从模式)

实现:

  1. 获取锁: SET lock:key random_value NX PX 30000 (原子操作)
  2. 释放锁: 使用 Lua 脚本,先比对 random_value 是否匹配,再 DEL。(防误删)

假设我们使用的是 Redis 主从(Master-Slave) 架构。客户端 A 向 Master 节点成功 SET ... NX PX,获取了锁。此时,Master 突然宕机。这个锁的数据还没来得及异步复制(Replication)到 Slave 节点。哨兵(Sentinel)介入,将 Slave 提升(Promote)为新的 Master。客户端 B 此时向新的 Master(原来的Slave)发起同一个锁的 SET ... NX PX 请求。由于数据未同步,新 Master 认为这个锁不存在,于是客户端 B 也成功获取了锁。结果客户端 A 和 B 同时持有了同一个锁,分布式锁失效,导致数据不一致。

RedLock 算法

Redis 的作者 Antirez 为了解决上述主从切换导致锁失效的问题,设计了 RedLock 算法。

RedLock 的核心思想是放弃主从架构,转而采用多个(奇数个)独立的 Master 节点(例如5个),通过“少数服从多数”的原则来保证锁的可靠性。

它假设我们有 N 个(例如 N=5)完全独立、互不通信的 Redis Master 节点,部署在不同的机器或机房。

做法

获取锁 (Acquire Lock):

  1. 获取时间: 客户端记录当前时间戳(T_start)。
  2. 依次请求: 客户端使用相同的 Key唯一的 Valuerandom_value),依次(或并发)向 N 个独立的 Master 节点发送 SET ... NX PX ttl 请求。
  3. 设置超时: 客户端在请求每个 Master 时,需要设置一个很短的通信超时(例如 50ms),这个超时远小于锁的TTL(例如 ttl=10s)。
  4. 统计成功: 客户端统计有多少个 Master 节点成功返回了 “OK”(即 SET 成功)。
  5. 判断胜利:
    • 条件一(多数票): 客户端必须成功地从超过半数N/2 + 1)的节点上获取了锁。例如,5 个节点中至少成功 3 个。
    • 条件二(耗时检查): 客户端计算总耗时(T_elapsed = T_now - T_start)。只有当锁的剩余有效时间(ttl - T_elapsed)大于一个合理的漂移时间时,才认为锁获取成功。
  6. 成功: 如果条件一和二都满足,客户端认为获取锁成功
  7. 失败: 如果任一条件不满足(例如,只拿到了 2 个锁,或者耗时过长导致锁即将过期),客户端认为获取锁失败。此时,它必须所有(N个)节点(包括那些获取成功的和失败的)发起释放锁的操作(使用 Lua 脚本),以清理“半成品”锁。

释放锁 (Release Lock):

  • 客户端向所有 N 个 Master 节点发送释放锁的 Lua 脚本(比对 random_valueDEL)。
  • 这一步没有成功与否的概念,尽力通知所有节点释放即可。

RedLock 的巨大争议

RedLock 提出后,立刻遭到了分布式系统专家(特别是 Martin Kleppmann)的猛烈批评。

Martin Kleppmann 的核心论点: RedLock 是一个“既不安全又不高效”的算法。

  1. 严重依赖时钟 (Clock Drift)
    • RedLock 的安全性强依赖于所有服务器和客户端的时钟是基本同步的。
    • 场景: 5 个节点(A, B, C, D, E)。
      1. 客户端 1 在 A, B, C 上获取了锁(N/2 + 1 成功)。
      2. 此时,节点 C 的时钟发生了一次大的跳跃(例如 NTP 同步导致时钟向前调快了)。
      3. 这导致 C 上的锁提前过期了。
      4. 客户端 2 发起请求,在 C, D, E 上获取了锁(也成功了)。
      5. 结果: 客户端 1 和 2 再次同时持有了锁。
  2. GC 暂停 (Stop-The-World Pauses)
    • 场景:
      1. 客户端 1 在 A, B, C 上获取了锁,非常快,准备执行业务逻辑。
      2. 此时,客户端 1 发生了长时间的 GC Pause(例如持续了 1 分钟)。
      3. 在这 1 分钟内,A, B, C 上的锁(假设 TTL 是 30 秒)全部自动过期了。
      4. 客户端 2 在这段时间里,在 A, B, C (或 A,B,D,E) 上成功获取了锁,并开始执行业务。
      5. 1 分钟后,客户端 1 的 GC 结束,它并不知道自己的锁已经失效了,它依然认为自己持有锁,开始执行业务。
      6. 结果: 再次导致两个客户端同时执行了临界区代码。
  3. 复杂度与运维成本
    • 为了一个锁,你需要维护 N 个(例如 5 个)独立的 Redis Master 实例,它们之间不做复制。这个运维成本和资源成本远高于一个标准的主从集群。

Antirez (Redis作者) 的反驳:

  • Antirez 认为 Martin 提出的 GC 暂停和时钟跳跃是极端情况。
  • 他认为 RedLock 只是提供了一种“尽力而为”的、比单点主从更安全的锁(Probabilistically Safer)。
  • 他提出可以通过增加“fencing token”(类似于 Zookeeper 的 zxid)来缓解 GC 暂停问题,但这会让实现更复杂。

业界的主流观点(Martin 获胜):

  • 结论: RedLock 试图用一个“高可用”的系统(Redis)去解决一个“强一致性”的问题(锁),这是“用错了工具”。
  • 面试标准答案:
    • 如果你的业务绝对无法容忍(哪怕万分之一的概率)锁失效(例如,金融交易、库存扣减),请不要使用 Redis 分布式锁,必须使用 Zookeeper 或 Etcd。它们基于 Raft/Paxos 协议,是为强一致性(CP)而生的。
    • 如果你的业务可以容忍(例如主从切换的几秒内)锁的短暂失效(例如,防刷、防止缓存击穿),那么单点 Redis + 看门狗续期(即 Redisson 的默认实现)是最高效、最简单、最实用的方案。

RedLock 最终处于一个非常尴尬的境地:它比 SETNX 复杂 N 倍,但提供的安全性又远不如 Zookeeper。