一篇文章解决redis-disturbing-key问题

213 阅读10分钟

先上盘小菜:大Key低QPS vs 小Key高QPS哪个影响大

  1. 大Key低QPS vs 小Key高QPS的影响: 大Key即使QPS低也会对整体性能产生较大影响,因为每次操作都要处理大量数据。而小Key高QPS虽然频繁,但每次操作量小,影响相对较小。
  2. 小Key高QPS影响较小的原因:
  • 单次操作快速
  • Redis的多路复用机制能高效处理并发请求
  • 内存管理更高效
  • 不易造成阻塞

大key问题

在Redis中,"大Key"问题通常指的是在数据库中存储非常大的数据结构(如大型字符串、列表、集合、哈希或有序集合)。这些大Key可能会导致一系列性能和可靠性问题。以下是一些大Key问题的主要缺点:

  1. 内存消耗高

    • 大Key会占用大量内存,这可能会导致内存不足,特别是在有限资源的环境中。
    • 内存中存储大Key会减少可用内存,可能影响其他数据和操作的性能。
  2. 阻塞操作

    • 操作大Key(如读取、写入或删除)会导致Redis实例的阻塞,因为Redis是单线程的。在处理大Key时,其他操作可能会被阻塞,从而影响服务的整体性能。
    • 特别是对于删除操作,大Key会导致较长时间的阻塞,这可能影响到Redis的响应时间。
  3. 网络带宽消耗

    • 传输大Key需要消耗较多的网络带宽,可能导致网络延迟。
    • 大量的数据传输还可能影响到其他网络活动的效率。
  4. 数据迁移和复制

    • 在进行Redis主从复制或集群中的数据迁移时,大Key会导致长时间的复制延迟。
    • 大Key的迁移会占用更多的时间和资源,从而影响集群的整体效率和可靠性。
  5. 缓存更新和失效问题

    • 当大Key发生变化时,更新和同步数据的成本很高。
    • 设置或更新大Key时可能会导致Redis实例的瞬时负载增加。
  6. 故障恢复

    • 在故障恢复过程中,加载大Key会增加启动时间,影响故障恢复的速度。
    • RDB持久化和AOF日志的生成与加载也会因为大Key而变得更慢。

识别大Key的方法主要有以下几种:

1. 使用 redis-cli 命令

  • MEMORY USAGE <key>

    • 这个命令可以查看某个键的内存使用情况。通过遍历所有键,逐个检查内存使用量,可以识别出占用内存较大的键。

    • 示例:

      redis-cli MEMORY USAGE mykey
      
    • 返回值是该键占用的字节数。

  • OBJECT ENCODING <key>OBJECT IDLETIME <key>

    • OBJECT ENCODING 可以查看键的内部编码方式,有助于理解键的存储结构。
    • OBJECT IDLETIME 可以查看键的空闲时间,帮助识别长期未使用的大Key。
  • SCAN 命令

    • SCAN 命令可以遍历Redis中的所有键。结合 MEMORY USAGE,可以逐个检查键的大小。

    • 示例:

      redis-cli --scan | while read key; do echo $(redis-cli memory usage $key) $key; done | sort -n
      
    • 这个脚本会遍历所有键,输出每个键的内存使用情况,并按大小排序。

3. 使用 Lua 脚本

  • 可以编写Lua脚本在Redis中运行,遍历所有键并记录大Key。

    • 示例:

      local bigkeys = {}
      local cursor = "0"
      repeat
          local result = redis.call("SCAN", cursor)
          cursor = result[1]
          for _, key in ipairs(result[2]) do
              local size = redis.call("MEMORY", "USAGE", key)
              if size > 1000000 then  -- 设定一个阈值,比如1MB
                  table.insert(bigkeys, {key, size})
              end
          end
      until cursor == "0"
      return bigkeys
      
    • 这个脚本会遍历所有键,并返回超过指定大小(如1MB)的键。

热key问题

在Redis中,热Key问题(Hot Key Problem)是指某些键(Key)被频繁访问,导致Redis集群中某个节点承受过大的压力,从而影响整体性能,甚至可能导致该节点的CPU过载内存不足。热Key问题常出现在热点数据的场景中,比如某个非常流行的商品、新闻文章等被大量请求访问。

为了解决Redis中的热Key问题,可以从多个角度采取优化措施。以下是一些常见的解决方案:


1. 本地缓存(Local Cache)

又可以成为多级缓存

在应用层面引入一个本地缓存(Local Cache),例如使用Java的Guava CacheCaffeine,可以缓解某些频繁访问的Key对Redis的压力。

  • 原理:本地缓存是运行在应用服务器上的缓存。在频繁请求某个Redis热Key时,先检查本地缓存中是否存在该Key的值,如果存在则直接返回,提高响应速度,减少对Redis的请求。
  • 优点:减少Redis的访问次数,降低单点压力。
  • 缺点:本地缓存的容量有限,缓存不一致性问题需要考虑(一般可以通过短时缓存或TTL来缓解)。
// 使用Guava Cache示例
LoadingCache<String, String> localCache = CacheBuilder.newBuilder()
    .maximumSize(1000)
    .expireAfterWrite(10, TimeUnit.MINUTES)
    .build(new CacheLoader<String, String>() {
        @Override
        public String load(String key) throws Exception {
            return getValueFromRedis(key);  // 从Redis加载数据
        }
    });

2. 数据预热(Cache Warming)

在系统启动或流量高峰期前,主动将热点数据加载到缓存中,避免在流量高峰期时对Redis造成突然的访问压力。

  • 原理:在Redis中提前缓存热点数据,或者在应用层通过本地缓存将热点数据预加载,避免首次访问Redis时的缓存穿透问题。
  • 应用场景:适用于可预知的热点数据,比如电商促销时的某个爆款商品。
// 数据预热示例
for (String key : hotKeys) {
    String value = getValueFromDB(key);  // 从数据库获取
    redis.set(key, value);               // 提前写入Redis
}

3. 热点Key拆分(Key Sharding)

将一个热点Key拆分成多个Key,通过增加随机性或分片来分散访问压力。

  • 原理:通过对一个Key进行拆分,如将Key拆成多个子Key,使用一定的规则对这些Key进行分片,以达到分散流量的目的。可以根据访问时的随机数或哈希来决定访问哪个分片。
  • 示例:比如原本一个Key为product_views,可以拆分为product_views_1, product_views_2, ..., product_views_n,然后通过某个哈希规则决定访问哪个Key。
// 访问时根据随机数决定访问哪个分片
int shardId = (int) (Math.random() * 10);  // 生成0-9的随机数
String shardKey = "product_views_" + shardId;
Integer views = redis.get(shardKey);

5. 限流和降级

通过对热点Key进行限流,防止流量过大时对Redis产生过多压力 令牌桶,漏桶,滑动窗口,固定窗口。 在 Redis 中,热点Key问题是指某些Key被频繁访问,导致Redis承受过大的压力,进而影响性能。为了解决这个问题,可以通过限流降级机制来控制对热点Key的访问,防止流量过大时对Redis产生过高的压力,甚至导致系统崩溃。

1. 限流(Rate Limiting)

限流即控制对某个资源的并发访问量,避免瞬时流量过大对系统造成冲击,Redis中可以通过多种方式进行限流。

常见的限流方式:

a. 令牌桶算法

令牌桶是一种常见的限流算法,它通过控制令牌的产生速率来限制请求的速率。如果有令牌可用,请求就可以通过,否则请求被拒绝。

  • Redis实现: 在Redis中可以通过INCREXPIRE命令实现简单的令牌桶限流。每当一个请求到来时,Redis会检查当前令牌数量,如果令牌足够则允许请求,否则拒绝。

    示例:使用 Lua 脚本实现简单的令牌桶限流。

    -- 定义的 Lua 脚本
    local key = KEYS[1]
    local limit = tonumber(ARGV[1])
    local current = tonumber(redis.call('get', key) or "0")
    
    if current + 1 > limit then
        return 0
    else
        redis.call("INCRBY", key, 1)
        redis.call("EXPIRE", key, 1)
        return 1
    end
    

    解释

    • KEYS[1]:限流的Key,用于记录当前时间窗口内的请求数。
    • ARGV[1]:时间窗口内允许的最大请求数。
    • 脚本检查当前的请求数是否超过上限,如果没有超过,则增加计数并设置过期时间,否则返回 0,代表拒绝请求。
b. 漏桶算法

漏桶算法是另一种限流算法,它通过将请求放入一个“漏桶”,按固定速率处理请求,超出漏桶容量的请求将被丢弃。

  • 实现思路:可以使用Redis的队列(如 LPUSHLPOP)来模拟漏桶的行为,将请求放入队列,系统以固定的速率从队列中处理请求,超出容量的请求将被丢弃。
c. 固定窗口计数

Redis的INCREXPIRE命令可以用来实现固定窗口计数限流。比如限制某个Key在1分钟内只能被访问100次。

String key = "request_count:" + userIp;
long current = redis.incr(key);
if (current == 1) {
    redis.expire(key, 60);  // 设置过期时间为60秒
}
if (current > 100) {
    // 超过限流
    return "Rate limit exceeded";
} else {
    // 正常处理请求
    return "Request processed";
}

解释

  • 每次请求到来时,INCR命令会递增该Key的值,并且如果是第一次访问则设置其过期时间。
  • 如果当前请求数超过100次,则拒绝后续请求。

d. 滑动窗口计数

固定窗口限流有个问题,如果流量集中在某个时间点,超出限流后,在下一个时间窗口立刻又可以处理100个请求,导致短时间内流量依然过大。滑动窗口计数是对固定窗口的改进,它通过在多个小时间片内进行计数,减少流量突发的影响。

滑动窗口计数可以通过 Redis 的 ZSET(有序集合)来实现,使用时间戳作为分数来记录请求时间,并对一定时间窗口内的请求数进行统计。

local key = KEYS[1]
local limit = tonumber(ARGV[1])
local now = tonumber(ARGV[2])
local window_time = tonumber(ARGV[3])

-- 删除窗口外的数据
redis.call('ZREMRANGEBYSCORE', key, 0, now - window_time)

local current_count = redis.call('ZCARD', key)
if current_count < limit then
    redis.call('ZADD', key, now, now)
    redis.call('EXPIRE', key, window_time)
    return 1
else
    return 0
end

解释

  • ZADD命令将当前时间戳添加到有序集合中。
  • ZREMRANGEBYSCORE删除超过窗口时间的请求。
  • ZCARD获取当前窗口内的请求数,判断是否超过限制。

2. 降级(Degradation)

当 Redis 负载过高、限流策略未能缓解足够的压力时,可以使用降级策略来保证系统的可用性。降级策略的核心思想是,当系统负载过大或出现异常时,降低系统的服务质量,保证核心功能可用。

常见的降级方式:

a. 返回默认值或缓存兜底

在无法从Redis中获取热点Key的值时,可以选择返回一个默认值或从其他地方(如本地缓存)获取数据,保证系统能继续服务。

b. 降级到异步处理

对于一些非核心的操作,可以将其降级为异步处理。例如,某些数据更新操作如果不能及时完成,可以先将请求记录下来,通过后台任务异步处理,减少对Redis的依赖。

c. 部分功能关闭

在系统负载过高时,可以临时关闭一些非核心功能。例如,某些统计数据、推荐系统等服务可以暂时关闭,保证核心业务功能正常运行。