Redis与MySQL的数据一致性,缓存穿透/击穿/雪崩,大key/热key问题

172 阅读10分钟

Redis与MySQL的数据一致性

在实际项目中,一般用MySQL来做数据持久化,用Redis来做数据缓存。 由于Redis和MySQL的读写性能差异较大(Redis的QPS在几万上下,MySQL的QPS在几百上下),故Redis与MySQL之间会存在数据不一致问题,一般需要确保两者数据的最终一致性。 以下是四种更新策略及其存在的问题:

  1. 先更新数据库再更新缓存:多线程场景下,更新数据库和redis的顺序不一致,导致redis中存在脏数据,不使用。
  2. 先更新缓存再更新数据库:同样有多线程更新顺序问题,不使用。
  3. 先删除缓存再更新数据库:Redis中仍然可能存在脏数据。A线程先删除了缓存再去更新数据库,还未更新完,B线程来读取数据,redis是空的,直接到数据库中读取到旧值,又把旧值写回缓存,等A线程更新完,redis和mysql中的数据不一致。本质是数据库更新速度慢,而redis删除速度快。
  4. 先更新数据库再删除缓存:也存在更新数据库成功但是删除缓存失败的问题。

一般使用"先删除缓存再更新数据库"或"先更新数据库再删除缓存"策略。

  1. 对于先删除缓存再更新数据库的问题,有以下方式解决:

    • 延时双删
      • 更新数据库前先删除一次缓存,等更新完后延迟一段时间再删除一次缓存(把可能存在的脏数据删除)。
      • 但是删除时间比较难确定,需要确保更新期间的读请求已经完成(包括将旧数据重新加载到缓存中)。
      • 另外,即使使用了延时双删,如果mysql使用主从架构,主从同步还会有一定的延时,线程A删除redis缓存后去更新主库,然后又删除redis,线程B到来读取不到redis去读从库,读到的还是旧数据,更新缓存还是脏数据。这本质上是主从延迟的问题,解决方法是遇到要填充redis的操作,强制去主库读。
      def update_data(key, new_value):
      // 第一次删除缓存
      cache.delete(key)
      // 更新数据库
      db.update(key, new_value)
      // 延时(如 500 毫秒)
      sleep(500)
      // 第二次删除缓存
      cache.delete(key)
      
    • 更新与读取操作异步串行化
      • redis中产生脏数据本质是MySQL更新操作和Redis写回操作是异步的,所以可以想办法将其变为同步。
      • 维护一些队列,更新数据时,把MySQL更新操作放到队列中,读取数据时,若发现缓存中无数据,但是队列中有更新数据操作,则将读取数据操作发送到队列中,读请求在写请求后面,需要等数据更新完成再去读取数据。另外如果请求等待较长时间还没有执行,则直接返回旧值。
  2. 对于先更新数据库再删除缓存的问题,有以下方式解决;

    • 使用消息队列进行删除的补偿,将redis中需要删除的key作为消息体发送到消息队列,重试删除操作。
    • 直接用cannel订阅binlog获取数据库操作再执行删除逻辑(重试直到成功),以减少对业务代码的侵入。

    image.png

大key问题

bigkey是指key对应的value很大,例如String的值大于10kb,list等集合的元素大于5000。 bigkey会导致主线程阻塞,网络带宽压力,内存分布不均,所以需要找出bigkey并拆分。

// 查找bigkey
redis-cli --bigkeys  // 该命令统计每个数据类型的最大key,对于集合类型只能统计元素个数而不是占用内存

// 查找bigkey
SCAN 0 MATCH * COUNT 100 // 使用SCAN命令遍历所有key
MEMORY USAGE key_name    // 对SCAN返回的key使用MEMORY命令统计大小
// SCAN为渐进式遍历命令,避免阻塞,使用格式为SCAN 游标 MATCH 匹配模式 COUNT 返回数量

// 也可以使用Redis监控工具查找bigkey

// 删除bigkey
定时任务+SCAN命令 // 定期扫描
unlink key_name  // 异步删除清理bigkey

// 对bigkey进行拆分string类型的数据使用序列化对其压缩
对集合类型数据进行分片

热key问题

短时间内被频繁访问的key就是热key,例如QPS/带宽使用率/CPU使用率集中在特定的key上。 热key会造成redis集群流量不均衡。

  • 处理热key关键是对热key的监控
    • 使用redis-cli monitor命令统计热key。
    • 使用专门监控热key的中间件。
  • 找到热key后
    • 给热key加上前后缀,将其打散到不同的服务器,降低压力。
    • 将热key加入本地缓存,如JVM缓存。
  • 另外还存在热key重建问题
    • 热key失效时有大量线程来重建缓存,且重建缓存是一个高消耗操作,这会导致服务器压力飙升。
    • 可以使用互斥锁,仅允许一个线程重建缓存。
    • 热key可以不设置过期时间或快要过期时自动续期。

缓存穿透

大量不存在的key查询,未命中缓存,全部打到数据库上。 与缓存击穿的区别是缓存穿透的key在redis中是不存在的。 解决方案如下:

  • 对不存在的key也做缓存,设置较短过期时间,查询这个key时直接返回null,但若这些key是随机的,则缓存也没用。
  • 使用布隆过滤器。数据写入数据库时使用布隆过滤器做个标记,当缓存缺失后要查询数据库时,先查询布隆过滤器快速判断数据是否存在数据库中,存在才去查询。这样即使有大量不存在的key请求,也只会打到Redis和布隆过滤器上,不会影响数据库。布隆过滤器使用redis实现,本身就能承担较大的并发访问压力。
    • 布隆过滤器由一个bit数组和多个哈希函数组成,用于快速判断集合中是否存在某个元素。
    • 元素到来时将其hash到数组多个位置上,bit数组上对应位置值改为1。
    • 当要检查某元素是否存在时,检查其hash到的所有位置,若所有位置都为1说明该元素极大可能存在(也可能刚好这些位置是被其他元素映射的,这是误差所在);若其中一个位置为0说明该元素绝对不存在。
    • 布隆过滤器优点在于可高效地插入和查询,占用空间少;缺点在于存在误差,且无法删除。因为删除元素会增大误差,因为可能bit数组中一个位置代表了多个元素映射),另外使用布隆过滤器时尽量一次给够空间,避免扩容。

缓存击穿

大量请求查询热点key,但是这个key刚好失效,导致请求全部打到数据库上。

  • 对热点key不设置过期时间,或者每次要过期了就去续期。
  • 双检加锁:多个线程查询数据库时,让它们竞争锁,第一个拿到锁的线程去数据库查到数据,做好redis缓存后释放锁,后续请求拿到锁后走redis缓存。双检加锁策略在加锁前查询一次redis,加锁后再查询一次redis。
// 双检加锁
public String getData(String key) {
    // 1. 第一次检查:从缓存中获取数据
    String value = jedis.get(key);
    if (value != null) {
        return value;
    }
    // 2. 缓存未命中,尝试获取锁
    String lockValue = UUID.randomUUID().toString();
    boolean locked = jedis.setnx(LOCK_KEY, lockValue) == 1;
    if (locked) {
        // 设置锁的过期时间
        jedis.expire(LOCK_KEY, LOCK_EXPIRE);
        try {
            // 3. 第二次检查:再次从缓存中获取数据
            value = jedis.get(key);
            if (value == null) {
                // 4. 从数据库中获取数据
                value = fetchDataFromDB(key);
                // 5. 将数据写入缓存
                if (value != null) {
                    jedis.setex(CACHE_KEY, CACHE_EXPIRE, value);
                }
            }
        } finally {
            // 6. 释放锁
            if (lockValue.equals(jedis.get(LOCK_KEY))) {
                jedis.del(LOCK_KEY);
            }
        }
    } else {
        // 7. 未获取到锁,等待并重试
        try {
            Thread.sleep(100); // 等待 100ms
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return getData(key); // 重试
    }
    return value;
}

缓存雪崩

大量key同时过期,导致大量请求打到数据库上。

  • 将key的过期时间错开,让缓存失效时间尽量均匀。
  • 设置分级缓存,例如Redis作为主缓存,Guava Cache作为本地缓存,若从主缓存获取数据失败则尝试从本地缓存获取数据。

也可能是redis宕机,导致请求全打在数据库上。

  • 主从+哨兵或者redis集群避免redis全盘崩溃。
  • 发生雪崩时可以使用互斥锁或队列来控制访问线程数量,或启动熔断机制,限流机制,确保数据库不会被打爆。

缓存预热

系统上线后提前将相关缓存数据直接加载到缓存系统,避免用户首次查询走数据库。

多级缓存

一般Redis作为分布式缓存,Caffeine或Guava Cache作为本地缓存。 对于访问频繁但不经常更改的数据放到本地缓存中,对于需要共享或一致性要求较高的数据放在redis中。 但是分布式缓存和本地缓存之间也要确保数据一致性:

  • 本地缓存设置过期时间,过期后去找redis同步。
  • 使用redis的发布订阅机制,本地缓存订阅redis,redis数据变化时本地缓存进行同步。
  • 引入消息队列,redis数据变化时通过消息队列通知本地缓存。

redis阻塞排查

  • API或数据结构使用不合理:找出慢查询语句,修改操作(如阻塞式命令)或数据结构,将大对象拆分成小对象(删除bigkey)。
  • CPU饱和问题: 判断Redis的并发量是否到达极限,如果QPS到达几万了,应该做水平扩展来分摊压力,如果QPS较小,应该检查命令的执行或内存的使用情况。
  • 持久化相关的阻塞
    • fork阻塞:生成RDB文件或重写AOF文件时主线程都fork了子线程,若fork操作本身耗时较长可能导致主线程阻塞。
    • AOF刷盘阻塞:命令从缓冲区刷到磁盘时,若磁盘压力较大则需要等待,如果主线程发现缓存刷盘了很久还没成功,为了数据安全它会阻塞直到刷盘完成。

redis变慢排查

  • 使用了复杂度较高的命令或阻塞命令或一次查询大量数据。
  • 操作了bigkey。
  • 内存不足,redis经常需要到磁盘交换区中读取数据,此时增加redis可用内存或增加机器。
  • redis中存在大量数据并且执行了生产RDB快照或AOF重写操作,fork子进程时耗时严重。
  • AOF设置了always刷盘策略。
  • 数据量太大,网卡压力大。

如何往redis中插入大量数据

使用管道。管道就是redis一次性获取并执行多条命令并将结果一次性返回给客户端,减少客户端与服务端的网络调用次数,减少读取数据时OS的上下文切换。redis提供三种将客户端多条命令打包给服务端执行的方式:管道,事务,Lua脚本。