Redis 作为缓存层的几个问题

高并发情况下数据库如 MySQL 等通常会成为系统瓶颈,所以通常会引入 Redis 作为数据库的缓存层。可以缓解数据库的压力,同时提升访问速度。流程如下:

  • 查询请求先到 Redis,如果没有数据则到底层数据库查找。
  • 查完后再缓存到 Redis 中以便下次访问命中。

但在使用过程中会发生缓存穿透、缓存击穿、缓存雪崩等问题,下面来详细解析这些问题及应对方案。

1. 缓存穿透

1.1. 缓存穿透概念

缓存穿透 是指 查询一个数据库中不存在的数据,导致请求 绕过缓存 直接访问数据库。低频缓存穿透不可避免,但恶意的、高频的缓存穿透则容易引发数据库压力过大甚至宕机。

1.2. 解决方案

1.2.1. 缓存空对象

当查询请求到达 Redis 且没有命中时,请求会到达底层数据库,如果数据库中也不存在该数据,则缓存一个空对象到 Redis 中。这样就可以解决缓存穿透问题。

该方案存在的问题

  1. 后续可能数据库中真的写入了该数据,但 Redis 中并未更新,Redis 里缓存的依然的是空对象。
    • 解决办法:Redis 中缓存的空数据需要设置一个较短的过期时间,只能实现弱一致性。
  2. 黑客随机访问大量不存在的数据,会导致 Redis 中缓存大量无用的值为空的key,导致大量的内存占用。同时 Redis 有 LRU 或 LFU 的内存淘汰策略,可能会将缓存中有价值的数据淘汰掉,导致真正的用户请求会打到数据库上,造成数据库压力过大。

1.2.2. 布隆过滤器

将数据库中所有存在的数据的 id 经过 hash 放入一个 list 中,访问前先判断 list 中是否存在该数据。如果布隆过滤器返回 不存在,则直接拒绝请求。如果返回 存在,再去查缓存或数据库。

该方案优缺点:

  • 优点:内存占用极小,适合海量数据场景。
  • 缺点:有一定的误判率(可能误判存在,但不会误判不存在)。

示例(Redis + Bloom Filter)

from pybloom_live import ScalableBloomFilter

# 初始化布隆过滤器
bloom_filter = ScalableBloomFilter(initial_capacity=100000, error_rate=0.001)

# 预热数据(如所有合法 user_id)
for user_id in db.query("SELECT id FROM users"):
    bloom_filter.add(user_id)

def get_user(user_id):
    # 1. 先用布隆过滤器判断是否存在
    if user_id not in bloom_filter:
        return None  # 直接返回,不查缓存和数据库
    
    # 2. 查缓存
    user = redis.get(f"user:{user_id}")
    if user:
        return json.loads(user)
    
    # 3. 查数据库
    user = db.query("SELECT * FROM users WHERE id = ?", user_id)
    if user:
        redis.setex(f"user:{user_id}", 3600, json.dumps(user))
    return user
Python

2. 缓存击穿

2.1. 缓存击穿概念

缓存击穿是缓存穿透的一种特殊表现,某个热点 key 突然失效,大量请求直接打到数据库(通常发生在高并发场景)。例如:热搜数据缓存过期,瞬间大量请求涌入。

2.2. 解决方案

2.2.1. 热点数据不过期

最简单的方法就是缓存这些热点数据的时候不设置过期时间,但这么做也会引发新问题,数据库数据更新了但 Redis 缓存的数据没有更新会导致数据不一致。所以需要一种方式来保证数据库和 Redis 中的数据的一致性。但是强一致性很难做到,只能保证弱一致性,例如:

  • 使用中间件监听 MySQL 的 binlog,有变动则同步到 Redis 中,可以达到弱一致性。

2.2.2. 分布式锁

只允许 一个请求 去查询数据库并重建缓存,其他请求等待或直接返回旧数据。

实现方式(Redis + SETNX 分布式锁)

import redis
import time
import threading

redis_client = redis.StrictRedis()

def get_data(key, expire=3600):
    # 1. 先查缓存
    data = redis_client.get(key)
    if data is not None:
        return data
    
    # 2. 缓存不存在,尝试获取锁
    lock_key = f"lock:{key}"
    lock_acquired = redis_client.setnx(lock_key, 1)  # SETNX 实现锁
    
    if lock_acquired:
        try:
            # 3. 获取锁成功,查询数据库
            data = query_db(key)
            
            # 4. 写入缓存
            redis_client.setex(key, expire, data)
            
            # 5. 释放锁(防止死锁)
            redis_client.delete(lock_key)
            return data
        except Exception as e:
            redis_client.delete(lock_key)  # 确保异常时释放锁
            raise e
    else:
        # 6. 未获取锁,短暂等待后重试或返回默认值
        time.sleep(0.1)  # 避免频繁重试
        return get_data(key)  # 递归重试(或返回旧数据/错误提示)
Python

优缺点:

  • 优点
    • 完全避免缓存击穿,确保只有一个请求访问数据库。
  • 缺点:
    • 如果获取锁的请求 处理时间过长,可能导致其他请求长时间等待。
    • 需要处理 死锁问题(如设置锁超时 redis_client.expire(lock_key, 10))。

3. 缓存雪崩

缓存雪崩也是缓存穿透的一种特殊表现,上面介绍的缓存击穿是一个热点 key 失效,而缓存雪崩是多个热点 key 同时失效。一般出现缓存雪崩的原因如下:

  • 缓存过期的时间比较一致某一时刻 key 大面积失效。
    • 解决办法:将缓存时间设置成一个随机数。
  • Redis 服务崩溃。
    • 解决办法:通过 Redis 集群 或 哨兵模式 避免单点故障。

Redis 集群和哨兵模式在下一篇文章详细介绍。

上一篇
下一篇