缓存的基本使用场景以及常见问题

单机缓存,提高热数据查询速度

为了提高热点数据的查询效率,我们将热点数据从 Mysql 数据库加载(预热)到 Redis 缓存中,以便快速查询。

数据淘汰机制

当内存不足时,Redis 会根据配置的缓存淘汰策略淘汰一些 Key,然后写入成功。如果没有淘汰策略或没有可以淘汰的 Key 时,就会返回 out of memory 错误,也就是 Redis 宕机了。

为了避免发生这种问题,我们要在 redis.conf 中设置淘汰策略,并且要主动设置缓存的过期时间。

  • maxmemory // 最大使用内存大小,默认 512G 被注释了,也包含虚拟内存
  • 6 种淘汰策略
    • volatile-lru 从已设置过期时间的数据中选最近最少使用的淘汰
    • volatile-lfu 从已设置过期时间的数据中选择最少使用的淘汰
    • volatile-ttl 从已设置过期时间的数据中选择最近将要过期的数据淘汰
    • volatile-random 从已设置过期时间的数据中随机选择淘汰
    • allkeys-lru 从所有数据挑选最近最少使用的淘汰
    • allkeys-lfu 从所有数据挑选最少使用的淘汰
    • allkeys-random 从所有数据随机挑选淘汰
    • no-enviction 不采用淘汰策略,如果写操作则返回错误(默认选项)

数据一致性问题

缓存是用来应对大量查询,但是最终结果还是要写入到 MySQL 保存,所以就涉及到一致性问题。

1 实时同步

适合对强一致性要求较高的。查询时先查 Redis,如果查不到再查数据库,然后再保存到 Redis 以供下次查询;写入数据时,先写入数据库,然后设置缓存过期同时更新。

2 异步队列

在并发较高的情况下,可以用异步队列方式同步,可以采用 kafka 等消息中间件处理消息生产和消费。

3 阿里的同步工具 canal

原本 MySQL 有一种主从复制的机制,可以在主 MySQL 生成 binlog 文件,然后从数据库有专门的线程进行监控和读取,在从库进行 Replay 写入数据。

同样的原理,只要用 canal 工具模拟自己为从数据库,将主 MySQL 生成的 bitlog 解析,在缓存执行更新。

  • canal 是 C/S 架构,Server 会解析 binlog 文件放在内存,然后客户端(有些 C++代码要自己写)每读取一次消息(确认处理后)就删除该消息。
  • canal Server 如果挂掉,客户端也会挂掉,同步就无法继续进行。但是服务端会记录当前读取的 pos 点,下次服务端、客户端重启后,又可以继续解析。
  • 单点的话,可以用程序监控两个是否挂掉、然后重启;还有一种是利用 Zookeeper,将配置文件放在 zk,当 canal Server 挂掉后切换到其他 canal Server,由于配置文件一致(记录了 pos 点)所以可以继续解析。客户端同理。

4 采用 UDF(触发器)自定义函数的方式

在 MySQL 中,可以用 C/C++实现,在 insert delete update 时可以设置回调函数。

同样的原理,在自己的代码中,如果对缓存进行增、删、改,同时向数据库中发出变更命令。

5 Lua 脚本

定时执行同步脚本。

或者可以在系统设定一个定时任务进行同步。

例如,每天临晨 2 点,将用户对文章的点击量存入数据库。

数据一致性问题案例

  1. 先写数据库、再更新 Redis
    • 注意,这里所说的脏数据是指操作最终留下了脏数据,而不是瞬间值
    • 问题:A、B 线程先后更新,但是 B 线程先写了 Redis,之后被 A 覆盖,造成 Redis 中是脏数据
    • 错误方案 1:先删除缓存,再修改数据库。数据库操作要排队,有人读取时会排在后面,读到正确的值后再写入缓存。
    • 错误原因:读操作可能先于修改操作线程,所以可能写入 Redis 旧数据
    • 正确方案 延时双删:先删除缓存、再修改数据库、再删除缓存,注意这里第二次删除缓存要放入一个延时队列执行,以保证一定有效执行。这样即使有线程会写入一个脏数据,也很快会被删掉

缓存穿透

之前已经写过Redis 缓存穿透应对方案

缓存击穿

数据库里有数据,缓存里没有数据,突然一瞬间有大量并发访问这条数据。可能是这条数据没有人访问过或者刚好失效。

应对方案

通过单机锁或网络锁,使访问者排队访问。只有第一个访问者会查库并写入缓存,后面的可以直接访问缓存而不必查库。

  • 加锁 如果是并发引起的:注意代码中锁前后要进行双判断缓存,避免重复查库
  • 防止过期 如果是特殊的数据,比如双十一热点商品,可以:
    • 设置 Key 不过期
    • 定期根据算法提前延期

缓存雪崩

如果缓存集中在一段时间内失效,可能导致大量的缓存穿透,所有的查询都将落到数据库上,导致缓存雪崩。 缓存雪崩的原因往往是 Redis 挂了或者某种原因导致大部分数据失效了。

应对方案

  • 有效期均匀分布
    • 首先要有措施避免缓存雪崩,在设定缓存失效时间时要均匀分布,不要发生同时失效的情况。比如系统启动时,所有缓存设定相同的失效时间,就可能导致缓存雪崩,应该设定一个一定范围内的随机值。
    • 设置热点数据永不过期
  • 数据预热,对于某些热点 Key,访问非常频繁,当热点 Key 失效时会有大量线程来构建缓存,导致系统崩溃。往往需要单独处理
    • 对这种 Key 使用锁,单机锁或分布式锁。
    • 对这种 Key 不设置过期时间,把过期时间设置在 Value 中,如果检测到过期则异步更新。
    • 在 Value 中设置一个比过期时间更早一点的时间,当设置的时间到的时候延长 Key 对应的过期时间。
    • 设置标签缓存,在标签缓存设置过期时间,过期后异步更新实际缓存。
    • 在系统不太繁忙的时候,可以进行缓存预热,即我们自己的程序读一下缓存,设置延后一下缓存的过期时间。
  • 高可用
    • 如果缓存是分布式的,注意把热点数据分布在不同主机的缓存中
    • 对于 Redis 挂了的情况,要通过集群提高 Redis 的可用性
    • 如果发生了缓存雪崩,考虑用加锁、排队的方式进行单线程的写操作,避免大量访问导致数据库崩溃。
  • 另外可以通过降级、熔断,防止用户不断刷新导致系统雪崩

作为任务队列(Task Queue)

在高并发情况下,数据来不及写入 MySQL,可以先写入 Redis,然后逐步将数据插入 MySQL 持久化。Redis 作为消息/任务队列是不够专业的,但是基本原理相同,可以实现简单应用。

如何保证消息被取走后完成处理,而不是丢失?

每条任务取走时,不删除。消费者拿去进行消费,在确定完成了处理后,返回一个 ACK,然后才删除。

如何保证消息不被重复执行?

例如消费者拿走消息执行后,还没来得及 ACK 就挂了,之后重启执行,就可能重复执行消息。

要在消费者端实现幂等操作,对于重复数据可以进行校验,例如在消息上附加一个序号。对于非幂等操作,比如累加等,业务重要性要很低,要慎重。

如何保证消息被执行时的原子性?

用类似 MySQL 的回滚日志的方式,在写入前记录动作,被打断后下次启动时进行回滚。

如何记录以前执行的消息,特定时间统一存储备份?

每消费一个消息后,可以将这个消息删除,并同时在另一个已完成队列插入一个消息。

如何利用多线程,加快消息的处理?

这要看瓶颈在哪,通常是在硬盘,所以多线程并不能加快操作,反而会降低效率。如果在某种情况下多线程可以加速,那就需要在消息上增加序号,这样保证消息的执行有一定的顺序。

其他加速写入的方法

  • 利用更快的硬盘 SSD
  • 批量获取消息,减少网络延迟
  • 用事务的方式批量插入数据,减少资源切换成本