缓存问题

系列 - 缓存技术系列
警告
本文最后更新于 2023-07-09,文中内容可能已过时。

缓存和数据库数据一致性是缓存使用中的一个重要问题。主要涉及的操作是读和写。

  • 读操作:读取数据时,先从缓存系统中读取。如果在缓存中没有找到,再去存储系统(例如,数据库)中读取。这种方式可以大大提高读取性能,因为缓存的读取速度通常远高于存储系统
  • 写操作
    1. 先写缓存后写存储:这种方式可能会导致一些问题。如果写入缓存成功,但写入存储系统失败,那么用户在读取这个数据时不会有问题,因为数据在缓存中。但是,对于那些需要访问存储系统的关联业务,可能会出现异常。例如,一个订单数据,用户自己可以在缓存中看到这个订单,但是系统在统计订单时因为在存储系统中找不到这个订单,会出现统计错误。
    2. 先写存储后写缓存:如果写入存储系统成功,但写入缓存失败,那么用户在读取这个数据时可能会读到旧的数据,因为新的数据还没有被写入到缓存中。只有当缓存中的旧数据失效后,才会从存储系统中读取到新的数据。
    3. 先删除缓存再写存储系统(推荐):在正常情况下,这种方式可以保证数据的一致性。因为每次写入数据时,都先删除缓存中的旧数据,然后再写入存储系统。但是,如果在删除缓存的过程中出现异常,可能会导致缓存和存储系统中的数据不一致。为了不影响写入存储系统,还需要继续写入,这同样会导致数据不一致。

在 Cache-Aside 模型中,无论是==读取操作导致的缓存缺失回填==,还是==修改数据时同步更新缓存==,甚至包括==通过消息队列进行的异步缓存补偿==,都无法满足"Happens-Before"关系,因此都可能存在数据互相覆盖的风险。

https://image.linux88.com/2023/07/10/a4e98d938403ca8e47f9b796efcc00ba.svg

  1. 服务 A 执行从缓存获取,缓存中不存在数据
  2. 服务 A 再从 DB 中获取数据
  3. 服务 B 更新 DB
  4. 服务 B 删除缓存中的脏数据
  5. 服务 A 再写入刚从 DB 中获取的数据

https://image.linux88.com/2023/07/10/180bcd295d91322f6ac558fac6dac369.svg

读/写同时操作:

  1. 线程A读操作,读缓存,缓存 MISS
  2. 线程A读操作,读 DB,读取到数据
  3. 线程B写操作,更新 DB 数据
  4. 线程B 写操作 SET/DELETECache(可 Job 异步操作) 写操作 SET Cache(可异步 job 操作,Redis 可以使用SET key value XX key 存在就操作)
  5. 线程A 读操作,SET 操作数据回写缓存(可 Job 异步操作) 读操作,ADD 操作数据回写缓存(可 Job 异步操作,Redis 可以使用SETNX操作)

删除线的这种交互下,由于 4 和 5 操作步骤都是设置缓存,导致写入的值相互覆盖;并且操作的顺序性不确定,从而导致 Cache 存储在脏缓存的情况。

正确的操作步骤

  • 写操作使用 SET 操作命令,覆盖写缓存;
  • 读操作使用 ADD 操作回写 MISS 数据,从而保证写操作的最新数据不会被读操作的回写数据覆盖。

容忍不一致性是最简单的解决方案,不需要复杂的设计或额外的系统。根据业务需求,一定程度的数据不一致是可以接受的。例如,对于一些不需要实时更新的信息(如新闻、博客文章、商品信息等),我们可以设定缓存的有效期,让数据在一定的时间范围内保持一致。

关系数据库本地表事务是一个复杂度较高但可以缩短数据不一致时间的解决方案。

  1. 正常情况下,我们会先删除缓存,然后再更新数据库。
  2. 如果缓存系统异常,我们可以使用数据库事务,将删除缓存的操作作为一个待处理的任务存储在数据库中,由后台进程定期检查并执行这些任务。

消息队列异步删除是一种复杂度较高的解决方案,但同样可以缩短数据不一致的时间。与“关系数据库本地表事务”类似,

  1. 正常情况下,我们会先删除缓存,然后再更新数据库。
  2. 如果缓存系统异常,我们可以将删除缓存的任务发送到消息队列中,后台进程会从队列中取出任务并执行。但需要注意的是,这种方案的稳定性依赖于消息队列的稳定性,如果消息队列服务出现问题,可能会影响到数据一致性的处理。

缓存穿透是指查询一个不存在的数据,由于缓存中也不存在,因此每次请求都会穿透缓存,直接请求数据库,造成数据库压力增大。如果有大量这样的穿透请求,数据库可能会承受很大的压力。

这常常出现在黑客攻击的情况下。大量无效的请求不断发起,试图获取不存在的数据。每次这样的请求都穿透了缓存直接查询数据库,但由于数据实际上并不存在,因此数据库的每次搜索都将是无效的。

此方法主要应对的是存储系统中不存在的数据被频繁查询的情况,例如被黑客攻击或爬虫试探。当一个请求查询的数据在缓存和数据库中都不存在时,可以在缓存中为这个查询键设定一个空值或特殊标识。这样,当下一次这个不存在的数据被查询时,请求会直接从缓存中获取到空值或特殊标识,而不需要去数据库中查询。当然,这个空值缓存需要设定一个适当的过期时间,以便在数据库中添加了这个本来不存在的数据时,能够在缓存中获取到。

布隆过滤器是一种空间效率极高的随机数据结构,它利用位数组很好地解决了元素是否在集合中的问题。我们可以把所有可能存在的数据哈希到一个足够大的 bitmap 中,一个一定不存在的数据会被这个 bitmap 拦截掉,从而避免了对底层存储系统的查询。这个方法对于一些需要维护大量元素并且查询频繁的场景十分有效。

在查询之前,可以对输入进行校验,例如检查用户 ID 是否符合特定的格式或范围。这可以在许多情况下避免对不存在的数据进行查询,因为许多不存在的数据请求都是由于用户输入错误或恶意攻击造成的。

缓存击穿是指一个有效的 key 在缓存中没有,且在数据库中有。这种情况常常发生在某些热点数据过期的时候,此时会有大量的请求击中数据库,可能会对数据库造成极大的压力。

假设你有一个非常热门的商品,其信息在缓存中存有,并且其过期时间快到了。这时如果有大量用户并发查询这个商品信息,结果发现缓存已经过期,那么这些请求就都会转向数据库。

对于一些非常热门的数据,你可以设置它们在缓存中永不过期,或者手动刷新其在缓存中的过期时间。

在缓存过期后,不是立刻去加载数据库的数据到缓存,而是先使用缓存工具的某些带成功操作返回值的命令,比如 Redis 的 SETNX 或者 Zookeeper 的分布式锁来设置一个互斥锁。当访问缓存的线程发现缓存过期后,它就尝试获取互斥锁。如果获取到锁的话,它就可以从数据库加载数据并放入缓存。如果没有获取到锁,说明有其他线程已经在加载数据,那么它可以稍等或者返回一些旧的数据。

当我们谈论缓存雪崩时,我们是在描述一种场景,当大量的缓存项同时过期或失效,可能会导致大量的请求直接打到后端的存储系统。由于缓存的失效,系统的负载会急剧增加,可能会导致系统的性能急剧下降,甚至可能会导致系统崩溃。

生成缓存的计算任务太耗时。比如,如果生成缓存涉及到复杂的数据查询或大量的计算,那么缓存生成的过程可能会很慢。当大量的缓存项同时失效时,系统可能需要花费大量的时间来重新生成这些缓存,期间系统的性能会大幅度下降。

缓存失效后,并发请求量大。例如,如果缓存项失效后,有大量的请求(比如超过 50 个)同时到达,那么这些请求都需要直接访问后端的存储系统,可能会导致后端系统的负载急剧增加。

由于某种原因(可能是硬件故障、网络故障、或者是软件错误),你的缓存系统的一部分节点突然崩溃,导致了大量的商品信息无法从缓存中获取。这个时候,所有的这些商品信息请求都会直接打到数据库上,导致数据库的负载急剧增加。如果数据库无法承受这个突然增加的负载,它可能会出现性能问题,甚至完全崩溃。

在实际使用之前先将数据加载到缓存中。这种方法在系统启动或者数据更新后非常有用,可以减少首次访问数据时的延迟。预热可以通过模拟请求、后台批量生成或者灰度发布/预发布等方式实现。这样可以尽可能地确保当真实请求发生时,缓存已经准备就绪。

预热的主要挑战在于预测哪些数据将会被访问。如果预测不准确,可能会浪费宝贵的缓存空间存储不常用的数据,同时常用的数据却没有被缓存。

将缓存的过期时间设定为一个随机的范围,例如 3 到 5 分钟。这种方式可以有效地防止大量缓存同时失效,从而造成缓存穿透。这种策略能有效地避免“缓存雪崩”情况的发生,这是当大量缓存项在同一时间失效,导致短时间内大量请求直接落在后端存储系统上,可能会导致后端存储系统的负载激增。

设置多级缓存系统,例如设置内存缓存和硬盘缓存。在内存缓存失效的时候,硬盘缓存可以起到备份的作用。这样可以保证即使内存缓存失效,也不会全部请求都打到数据库,减少数据库的压力。

分布式高可用缓存集群通过将缓存数据分布在多个服务器或节点上,提高了系统的可用性和扩展性。

  • 方案:这种方法涉及到由后台线程更新缓存,而不是由业务线程进行缓存更新。缓存项的有效期被设置为永久,后台线程负责更新缓存,可以基于“定时更新”或者“事件触发更新”策略进行。业务线程只负责读取缓存,如果缓存不存在,则返回空值。
  • 优点:这种策略的实现相对简单,因为它将缓存更新的职责从业务线程中剥离出来,使得业务线程可以专注于处理业务逻辑。这种策略也能有效地防止缓存雪崩,因为缓存更新是由单独的后台线程进行的,不会因为大量的并发请求而导致缓存更新的并发竞争。
  • 缺点:需要确保后台线程的高可用性,因为如果后台线程出现故障,可能会导致缓存无法被及时更新,影响到业务的正常运行。

缓存热点是指在缓存中频繁被访问的数据,这种情况通常发生在热门事件或突发事件的情况下,大量的用户会集中访问同一份数据。这种集中的访问模式可能会导致某个特定的缓存服务器承受巨大的压力,甚至可能无法应对。

在社交媒体平台上,一位著名的明星发布了一条公开表明恋爱关系的微博,短时间内可能会有数以百万计的用户涌入这个平台,所有的用户都在访问这一条微博。这条微博在缓存中的数据就成为了一个“热点”。因为大量的请求都在访问这一条数据,缓存服务器可能会因为处理大量的请求而压力过大。

处理缓存热点的主要策略是分散流量,将高流量访问的热点数据分布到多个缓存服务器上。具体实施方案如下:

  1. 写入时,缓存键(Key)添加一个编号,然后写入多个缓存服务器。例如,对于热点数据 “HotData”,我们可能生成 “HotData-1”,“HotData-2” 等键,并将它们分别存储在不同的缓存服务器上。
  2. 读取时,随机生成编号来组装键(Key),然后从缓存中读取。这样做的目的是为了在读取热点数据时,可以将流量分散到多个缓存服务器上,避免集中在一台服务器上造成过载。

然而,这种策略的挑战在于很难预知哪些键将会成为热点。很多时候,这种热点是由突发事件或者热门话题引起的,很难提前预测。为了解决这个问题,我们可能需要使用一种动态决策的方法,例如,监视访问流量,如果发现某个键的访问量激增,就可以将其视为热点,然后采取上述策略进行处理。另外,也可以通过人工干预,比如运维人员根据经验或者对即将发生的大型活动的预知,来预先设定某些键为热点。

值得注意的是,这种策略并不是一劳永逸的解决方案,因为热点数据的产生往往是动态的,而且难以预测。因此,处理缓存热点的策略需要结合具体的业务情况和系统性能,进行动态调整和优化。

问题类型 描述 解决方案
数据一致性 缓存数据与数据库数据出现不一致,可能因为写操作未同步导致 先删除缓存再更新数据库、使用分布式锁、引入版本号或时间戳等策略保证一致性
缓存穿透 频繁访问缓存和数据库中都不存在的数据,可能由恶意请求导致 采用布隆过滤器、设置默认值等策略阻止非法请求通过
缓存击穿 频繁访问某一特定数据,当该数据缓存失效时,大量请求直接打到数据库 设置热点数据永不过期、使用互斥锁等策略防止并发访问数据库
缓存雪崩 大量缓存同时过期,导致高并发的请求全部打到数据库 缓存数据失效时间随机、数据预热、使用高可用的缓存策略等方法避免
缓存热点 频繁访问某一特定数据,导致某个缓存服务器压力过大 数据分片,将热点数据分布到多个缓存服务器上,分散访问压力