MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

缓存失效策略详解与实践

2023-11-251.8k 阅读

缓存失效策略的重要性

在后端开发中,缓存扮演着至关重要的角色,它能够显著提升系统性能,减少数据库等数据源的负载。然而,如果缓存使用不当,可能会带来诸如数据不一致、缓存雪崩、缓存穿透等问题。缓存失效策略就是为了解决这些问题而存在的,它决定了缓存中的数据何时应该被更新或删除,从而保证缓存数据的有效性和系统的正常运行。

常见的缓存失效策略

  1. 定时失效(Time - to - Live,TTL)
    • 原理:为缓存中的每个数据项设置一个过期时间(TTL),当数据在缓存中存放的时间超过这个过期时间时,该数据就会被标记为失效,下次访问时如果发现数据已失效,则从数据源重新获取数据并更新缓存。
    • 优点:实现简单,易于理解和控制。可以根据数据的更新频率设置不同的过期时间,对于更新频率低的数据可以设置较长的 TTL,对于更新频率高的数据设置较短的 TTL。
    • 缺点:如果数据在 TTL 内发生了变化,缓存中的数据就会与数据源不一致。而且可能会出现大量缓存同时过期的情况,导致缓存雪崩。
    • 代码示例(以 Python 和 Redis 为例)
import redis

# 连接 Redis
r = redis.Redis(host='localhost', port=6379, db = 0)

# 设置带有 TTL 的缓存数据
key = 'example_key'
value = 'example_value'
ttl = 3600  # 1 小时过期
r.setex(key, ttl, value)

# 获取缓存数据
result = r.get(key)
if result:
    print(result.decode('utf - 8'))
else:
    # 从数据源获取数据并重新设置缓存
    data_from_source = 'new_data_from_source'
    r.setex(key, ttl, data_from_source)
    print(data_from_source)
  1. 基于事件驱动的失效
    • 原理:当数据源中的数据发生变化时,系统会触发一个事件,这个事件通知缓存系统,让缓存中对应的键值对失效。这种方式可以保证缓存数据与数据源的实时一致性。
    • 优点:能够及时响应数据的变化,确保缓存数据的准确性。
    • 缺点:实现相对复杂,需要建立数据源与缓存之间的事件通信机制。而且如果事件处理不当,可能会导致缓存误失效或失效不及时。
    • 代码示例(以 Java 和 Spring Boot 结合 Redis 为例)
    • 首先,配置 Redis 消息监听器:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.listener.PatternTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.data.redis.listener.adapter.MessageListenerAdapter;

@Configuration
public class RedisConfig {

    @Bean
    RedisMessageListenerContainer container(RedisConnectionFactory connectionFactory,
                                            MessageListenerAdapter listenerAdapter) {
        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(connectionFactory);
        container.addMessageListener(listenerAdapter, new PatternTopic("__keyevent@0__:expired"));
        return container;
    }

    @Bean
    MessageListenerAdapter listenerAdapter(CacheEvictionListener cacheEvictionListener) {
        return new MessageListenerAdapter(cacheEvictionListener, "handleMessage");
    }
}
  • 然后,定义缓存失效监听器:
import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.connection.MessageListener;
import org.springframework.stereotype.Component;

@Component
public class CacheEvictionListener implements MessageListener {

    @Override
    public void onMessage(Message message, byte[] pattern) {
        String key = message.toString();
        // 这里可以根据 key 去删除对应的缓存
        System.out.println("Cache key " + key + " has expired, can be evicted from application - level cache.");
    }
}
  1. 最少使用(Least Recently Used,LRU)
    • 原理:当缓存达到容量限制时,LRU 策略会淘汰最近最少使用的数据。它通过记录每个数据项的访问时间,当需要淘汰数据时,选择访问时间最早的数据进行删除。
    • 优点:能够优先保留经常被访问的数据,提高缓存命中率。
    • 缺点:实现相对复杂,需要额外的空间来记录数据的访问时间。而且对于一些突发访问模式的数据,可能会误淘汰一些短期内不会再使用但后续可能会大量使用的数据。
    • 代码示例(以 Python 实现简单的 LRU 缓存为例)
from collections import OrderedDict


class LRUCache:
    def __init__(self, capacity):
        self.capacity = capacity
        self.cache = OrderedDict()

    def get(self, key):
        if key not in self.cache:
            return -1
        value = self.cache.pop(key)
        self.cache[key] = value
        return value

    def put(self, key, value):
        if key in self.cache:
            self.cache.pop(key)
        elif len(self.cache) == self.capacity:
            self.cache.popitem(last=False)
        self.cache[key] = value


# 测试 LRU 缓存
cache = LRUCache(2)
cache.put(1, 1)
cache.put(2, 2)
print(cache.get(1))  
cache.put(3, 3)  
print(cache.get(2))  
cache.put(4, 4)  
print(cache.get(1))  
print(cache.get(3))  
print(cache.get(4))  
  1. 最不经常使用(Least Frequently Used,LFU)
    • 原理:LFU 策略根据数据项的访问频率来淘汰数据。它记录每个数据项的访问次数,当缓存达到容量限制时,淘汰访问次数最少的数据。如果有多个数据项的访问次数相同,则淘汰最早插入的那个数据。
    • 优点:相比 LRU,它更注重数据的访问频率,对于长期访问频率低的数据会优先淘汰,更适合一些访问模式相对稳定的场景。
    • 缺点:实现复杂,需要额外的空间记录每个数据项的访问次数。而且如果数据的访问频率发生突然变化,可能会导致一些有用的数据被误淘汰。
    • 代码示例(以 Python 实现简单的 LFU 缓存为例)
import heapq
from collections import defaultdict


class LFUCache:
    def __init__(self, capacity):
        self.capacity = capacity
        self.key_to_value = {}
        self.key_to_freq = {}
        self.freq_to_keys = defaultdict(list)
        self.min_freq = 0

    def get(self, key):
        if key not in self.key_to_value:
            return -1
        freq = self.key_to_freq[key]
        self.freq_to_keys[freq].remove(key)
        if not self.freq_to_keys[freq] and self.min_freq == freq:
            self.min_freq += 1
        self.key_to_freq[key] += 1
        self.freq_to_keys[freq + 1].append(key)
        return self.key_to_value[key]

    def put(self, key, value):
        if self.capacity == 0:
            return
        if key in self.key_to_value:
            self.key_to_value[key] = value
            self.get(key)
            return
        if len(self.key_to_value) == self.capacity:
            while not self.freq_to_keys[self.min_freq]:
                self.min_freq += 1
            removed_key = self.freq_to_keys[self.min_freq].pop()
            del self.key_to_value[removed_key]
            del self.key_to_freq[removed_key]
        self.key_to_value[key] = value
        self.key_to_freq[key] = 1
        self.freq_to_keys[1].append(key)
        self.min_freq = 1


# 测试 LFU 缓存
cache = LFUCache(2)
cache.put(1, 1)
cache.put(2, 2)
print(cache.get(1))  
cache.put(3, 3)  
print(cache.get(2))  
print(cache.get(3))  
cache.put(4, 4)  
print(cache.get(1))  
print(cache.get(3))  
print(cache.get(4))  

缓存失效策略的选择与实践

  1. 根据数据特性选择策略
    • 静态数据:对于很少变化的静态数据,如网站的一些配置信息、不经常更新的帮助文档等,可以使用较长 TTL 的定时失效策略。这样既能充分利用缓存提升性能,又不会因为数据变化频繁导致缓存不一致问题。例如,一个电商网站的运费规则,可能几个月才会调整一次,就可以设置一个以月为单位的 TTL。
    • 动态数据:对于实时性要求高的动态数据,如股票价格、实时订单状态等,基于事件驱动的失效策略更为合适。当数据源中的数据发生变化时,立即通知缓存失效,确保用户获取到的是最新数据。以股票交易系统为例,当股票价格在数据库中更新时,通过事件驱动机制通知缓存,删除或更新对应的缓存数据。
    • 访问模式数据:如果数据的访问模式呈现出明显的冷热不均,即有一部分数据经常被访问,而另一部分很少被访问,LRU 或 LFU 策略可以发挥很好的效果。比如在一个新闻网站中,热门新闻的访问量会远远高于普通新闻,使用 LRU 或 LFU 策略可以优先保留热门新闻的缓存,提高缓存命中率。
  2. 结合多种策略实践
    • TTL + LRU/LFU:可以先为缓存数据设置 TTL,同时结合 LRU 或 LFU 策略。当缓存达到容量限制时,使用 LRU 或 LFU 策略淘汰数据;而在日常运行中,通过 TTL 保证数据不会长时间处于缓存中,避免数据过旧。例如,在一个内容管理系统中,文章缓存可以设置一个较短的 TTL(如 1 小时),同时当缓存空间不足时,按照 LRU 策略淘汰文章缓存。
    • 事件驱动 + TTL:对于一些对实时性要求较高但又不能完全依赖事件驱动(因为可能存在事件丢失等情况)的数据,可以同时使用事件驱动和 TTL 策略。当数据源数据变化时,通过事件驱动立即使缓存失效;同时设置一个相对较短的 TTL,以防止事件处理异常导致缓存数据长时间不一致。比如在一个在线游戏的玩家信息缓存中,当玩家的等级、装备等信息在数据库更新时,通过事件驱动通知缓存失效,同时设置一个 10 分钟的 TTL。
  3. 应对缓存雪崩和缓存穿透
    • 缓存雪崩:缓存雪崩是指大量缓存同时过期,导致请求瞬间全部落到数据源上,可能使数据源不堪重负甚至崩溃。为了避免缓存雪崩,可以采用以下方法:
      • 随机化 TTL:在设置 TTL 时,不要设置固定的过期时间,而是在一个范围内随机取值。例如,原本设置 TTL 为 1 小时,可以改为在 50 分钟到 70 分钟之间随机取值,这样可以分散缓存过期的时间点。
      • 使用二级缓存:设置一级缓存和二级缓存,一级缓存设置较短的 TTL,二级缓存设置较长的 TTL。当一级缓存失效时,先从二级缓存获取数据,如果二级缓存也失效,再从数据源获取数据并更新两级缓存。
    • 缓存穿透:缓存穿透是指查询一个不存在的数据,由于缓存中也没有该数据,导致请求每次都落到数据源上。可以采用以下方法应对:
      • 布隆过滤器:在缓存之前使用布隆过滤器,布隆过滤器可以快速判断一个数据是否存在。如果布隆过滤器判断数据不存在,就直接返回,不再查询数据源和缓存;如果判断数据可能存在,再去查询缓存和数据源。当数据源新增数据时,也要相应地更新布隆过滤器。
      • 空值缓存:当查询到一个不存在的数据时,也将这个空值缓存起来,并设置一个较短的 TTL。这样下次查询同样不存在的数据时,直接从缓存返回空值,避免重复查询数据源。

总结缓存失效策略在不同场景下的应用要点

  1. 高并发读场景:在高并发读场景下,如电商的商品详情页展示、新闻网站的文章浏览等,要优先考虑缓存命中率。可以结合 TTL 和 LRU/LFU 策略,确保热门数据长时间留在缓存中,同时通过 TTL 控制数据的新鲜度。例如,在电商商品详情页缓存中,对于热门商品可以设置较长的 TTL,并且使用 LRU 策略保证缓存空间被有效利用。
  2. 读写均衡场景:对于读写操作频率相对均衡的场景,如社交平台的用户信息更新和查询,要在保证数据一致性的前提下提高缓存性能。可以采用事件驱动和 TTL 相结合的策略。当用户信息更新时,通过事件驱动及时通知缓存失效;同时设置适当的 TTL,以应对事件处理可能出现的异常情况。
  3. 大数据量缓存场景:在处理大数据量缓存时,如数据库的全表缓存,要考虑缓存的存储效率和失效策略的性能。可以使用 LFU 策略,优先淘汰访问频率低的数据,同时结合 TTL 防止数据长时间占用缓存空间。例如,对于一个包含大量用户数据的数据库表缓存,可以按照 LFU 策略淘汰很少被访问的用户数据缓存,并设置一个合理的 TTL 来保证数据的新鲜度。

通过深入理解和合理应用各种缓存失效策略,并结合具体业务场景进行优化,可以有效地提升后端系统的性能和稳定性,为用户提供更好的服务体验。在实际开发中,需要不断地测试和调整策略,以适应业务的发展和变化。