微服务架构设计- 12缓存设计

缓存在系统设计中扮演着重要的角色,不仅能够提升系统性能,还能够提供一定程度的防御攻击的能力。根据作用域的不同,缓存可以分为本地缓存和分布式缓存两类。

本地缓存常见的有 Ehcache 和 Guava Cache。Ehcache具有数据持久化的能力,支持集群部署,并且可以方便地集成到主流框架中,功能丰富。Guava Cache则更为轻量化,简单易用,适用于一些简单的场景。此外,我们还可以基于 LinkedHashMap 等工具手动实现一个简单的本地缓存。

分布式缓存则常见于 Memcached、Redis、Hazelcast 等。这些工具在性能上都能够满足绝大部分业务场景的需求。Memcached不支持持久化,且数据结构较为单一。Redis支持多种数据结构,并且在4.x版本中引入了Stream计算能力。Hazelcast虽然知名度相对较低,但作为分布式内存计算平台,在缓存处理上表现出色。在既需要分布式缓存又需要一些集群特性(如分布式Map、锁、远程服务调用等)时,Redis,Hazelcast或Ignite可能是不错的选择。此外,一些特殊场景下,我们也会选择Elasticsearch作为缓存的解决方案。

与本地缓存相比,使用分布式缓存需要在设计和使用上注意以下几点:

1. 缓存失效:

在使用缓存时,缓存失效是一个不可避免的问题。失效可能由多种原因引起,例如过期时间设置不合理,导致大量缓存同时过期;请求直接访问数据库,导致数据库压力骤增;或者批量重建缓存导致缓存雪崩。为解决这个问题,有多种解决方案。对于缓存本身而言,一种策略是错开各个 Key 的过期时间,以及分批次刷新缓存。
确保缓存的过期时间设置得合理,可以根据业务场景和数据特性来调整过期时间。同时,对于大量缓存同时过期的情况,可以采用错开过期时间,通过随机化过期时间来分散缓存失效的时刻,避免同时触发缓存重建。
另外,通过分批次刷新缓存可以有效避免缓存雪崩。不要在同一时刻触发大规模的缓存重建操作,而是通过定时任务或其他机制,分批次地刷新缓存,减轻数据库和缓存服务器的负担。

2. 缓存穿透:

在缓存失效中,更为普遍的问题是缓存穿透。缓存穿透通常发生在请求一个数据库中不存在的记录时,导致程序无法从缓存中获取对应的 key,从而每次请求都需要查询数据库。这是缓存设计最容易被遗漏的问题之一。在一般情况下,这种“错误”的请求不会引起大量问题,但如果被攻击者利用,可能会对系统造成致命的影响,使缓存失去对数据库资源的保护作用。
解决这个问题的方案比较简单:无论请求的条件在数据库中存不存在,都将其加入到缓存中。然而,这种做法可能带来两个问题:

  • 缓存一致性问题: 如果 key 是查询条件,需要设置业务上比较合理的过期时间。如果 key 是 ID,可以在持久化到数据库时更新缓存中对应 ID 的值。
  • 可能存在大量值为空的垃圾记录: 在攻击情况下,可能产生很多值为空的记录。对于像 Redis 这样的常用操作(时间复杂度为 O(1))而言,这些记录只会占用一定的空间,对性能的影响有限。如果考虑存储的压力,可以使用 BloomFilter 或 Hyperloglog 来避免这一问题。对于 IP 路由等情况,可以使用本地 BloomFilter 或 Hyperloglog,也可以选择分布式方案,例如 Redis 的 Orestes-Bloomfilter、Rebloom(以 Redis 模块的方式加载)、Redisson。

此外,缓存穿透还可能在高并发访问某一新增的、未被缓存的记录时发生。在这种情况下,第一条请求从数据库读取并加入缓存生效的时间窗口内,大量并发请求可能穿透缓存给数据库带来巨大的压力,这也可能被用于攻击。解决方法可以是限流,限制并发请求的数量。另一种方法是在数据变更时先写缓存再写数据库,尽管可能带来一致性风险。也可以在写完数据库后立即更新缓存,不等到读取时再更新缓存,但并不是所有记录都需要缓存,可能导致不必要的缓存开销。最优雅的方法可能是对穿透到数据库查询的代码进行加锁。以下是一个伪代码示例:

public Object getData(String key) {
    // 尝试从缓存中获取数据
    Object data = cache.get(key);
    // 如果缓存中没有数据,加锁防止并发请求同时查询数据库
    if (data == null) {
        synchronized (lock) {
            // 再次检查缓存,防止其他线程已经查询过数据库并更新了缓存
            data = cache.get(key);
            // 如果仍然没有数据,查询数据库并更新缓存
            if (data == null) {
                data = database.queryData(key);
                cache.put(key, data);
            }
        }
    }
    return data;
}

3. 缓存一致性

缓存一致性问题取决于具体的应用场景,大多数情况下都要求数据库与缓存中的数据保持一致。即数据库数据变更后,需要及时同步刷新到缓存中。然而,刷新缓存可能是一个耗时、耗IO的操作,特别是在数据批量变更时,缓存数据的延时不容忽视。此外,在存在多级缓存的情况下,还需要考虑缓存内数据的同步。

在缓存设计时,数据同步和一致性问题是需要重点考虑的方面。以下是一些应对这些问题的常见策略:

  1. 异步刷新缓存: 在数据变更后,可以采用异步的方式刷新缓存,以减小对业务请求的影响。通过消息队列或异步任务来触发缓存的刷新操作,避免直接在业务逻辑中执行同步刷新。
  2. 定时刷新缓存: 定期地刷新缓存,不等待具体的数据变更操作。这种方式可以减少对业务请求的干扰,但会增加数据同步的延迟。
  3. 增量更新: 在数据发生变更时,只更新变更的部分数据,而不是全量刷新缓存。这样可以降低刷新缓存的开销。
  4. 多级缓存同步: 如果系统采用多级缓存,确保不同级别的缓存数据保持一致。可能需要设计一套缓存同步机制,使得底层缓存的变更能够传递到上层缓存。
  5. 合理设置缓存过期时间: 根据业务场景和数据特性,合理设置缓存的过期时间。过期时间的设置需要在保证一致性的前提下,尽量减小缓存的失效频率。
  6. 缓存预热: 在系统启动或重启时,通过预热缓存的方式,提前加载热点数据,减少冷启动时的性能影响。
  7. 使用分布式锁: 在多实例、多线程环境中,使用分布式锁来确保只有一个实例或线程能够执行缓存刷新操作,避免同时触发大量刷新操作。

通过合理选择和组合这些策略,可以在保障缓存一致性的同时,尽量减小对系统性能的影响。

4. 热点数据

在现实场景中,数据很难做到均匀分布,通常会出现冷热分化的情况。缓存被用于存放热点数据,但在一些特殊情况下,可能会存在极热点的数据,这些数据可能会对分布式缓存中的个别节点造成巨大的压力。如果业务中存在这种场景,需要考虑采取多级缓存的策略,为这些极热点的数据添加前置缓存层,或者通过将 Key 进行 Hash 化来规避数据倾斜。

在应对极热点数据的场景时,以下是一些可能的解决方案:

  1. 多级缓存: 引入多级缓存体系,将极热点数据放置在前置缓存层,使得极热点数据能够更快速地被访问,减轻分布式缓存的压力。
  2. Key Hash 化: 对于极热点的数据,可以通过对 Key 进行 Hash 化的方式,使得数据更均匀地分布在不同的缓存节点上,避免某个节点负载过重。
  3. 热点数据监控: 实时监控系统中的热点数据,通过监控工具分析数据访问情况,及时发现并处理潜在的热点数据问题。
  4. 动态调整缓存策略: 根据实时的业务情况,动态调整缓存策略,例如调整缓存的过期时间、调整缓存的大小等,以适应热点数据的变化。
  5. 限流和降级: 在高并发的情况下,对极热点数据进行限流,确保系统的稳定性。在必要时可以考虑降级一些非关键业务,减轻整体系统压力。

这些策略的选择要根据具体的业务场景和系统需求来定制,以保障系统的稳定性和性能。

在系统架构中,对于缓存的设计必须认真对待。上文简要介绍了分布式缓存设计的几个要点,实际上,现实中我们使用的各种中间件和工具也都会带有缓存功能,包括浏览器、Nginx、Hystrix、MySQL等。在缓存设计中,我们应该充分利用这些已有的缓存特性。
这意味着我们需要考虑如何与各种中间件和工具集成,以优化系统的性能和可用性。以下是一些考虑的方面:
浏览器缓存: 利用浏览器的缓存机制,合理设置 HTTP 缓存头,减少对服务器的请求,提升页面加载速度。
Nginx缓存: Nginx 作为反向代理服务器,可以配置缓存规则,将静态资源缓存起来,减轻后端服务器的压力。
Hystrix缓存: Hystrix 是一种用于处理分布式系统的容错库,它可以带有缓存功能,通过缓存来减少对故障服务的依赖,提高系统的可用性。
数据库缓存: 关系型数据库如MySQL也有自身的缓存机制,合理配置数据库缓存可以提高查询性能。

在设计系统架构时,需要综合考虑这些缓存特性,制定合理的缓存策略,并确保各个组件之间的协同工作。通过充分利用已有的缓存特性,可以更好地优化系统性能、提高响应速度,从而提供更好的用户体验。

©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容