本地缓存:为什么要用本地缓存?用它会有什么问题?

本文介绍了在高性能服务架构中,本地缓存作为一级缓存和远程缓存作为二级缓存的两级缓存设计,以提高访问速度和减轻数据库压力。本地缓存方案包括ConcurrentHashMap、GuavaCache、Caffeine和Ehcache,其中Caffeine因性能优异而被推荐。同时,文章讨论了缓存一致性问题,提出了MQ和Canal+MQ的解决方案,并关注如何提高本地缓存命中率和选择合适的技术方案。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

背景

在高性能的服务架构设计中,缓存是一个不可或缺的环节。在实际的项目中,我们通常会将一些热点数据存储到Redis或Memcached 这类缓存中间件中,只有当缓存的访问没有命中时再查询数据库。在提升访问速度的同时,也能降低数据库的压力。

随着不断的发展,这一架构也产生了改进,在一些场景下可能单纯使用Redis类的远程缓存已经不够了,还需要进一步配合本地缓存使用,例如Guava cache或Caffeine,从而再次提升程序的响应速度与服务性能。于是,就产生了使用本地缓存作为一级缓存,再加上远程缓存作为二级缓存的两级缓存架构。

在先不考虑并发等复杂问题的情况下,两级缓存的访问流程可以用下面这张图来表示:
在这里插入图片描述

为什么要使用本地缓存

  • 本地缓存基于本地环境的内存,访问速度非常快,对于一些变更频率低、实时性要求低的数据,可以放在本地缓存中,提升访问速度
  • 使用本地缓存能够减少和Redis类的远程缓存间的数据交互,减少网络I/O开销,降低这一过程中在网络通信上的耗时

设计一个本地内存需要有什么功能

  1. 存储;并可以读、写;
  2. 原子操作(线程安全),如ConcurrentHashMap
  3. 可以设置缓存的最大限制;
  4. 超过最大限制有对应淘汰策略,如LRU、LFU
  5. 过期时间淘汰,如定时、懒式、定期;
  6. 持久化
  7. 统计监控

本地缓存方案选型

1. 使用ConcurrentHashMap实现本地缓存

缓存的本质就是存储在内存中的KV数据结构,对应的就是jdk中线程安全的ConcurrentHashMap,但是要实现缓存,还需要考虑淘汰、最大限制、缓存过期时间淘汰等等功能;

优点是实现简单,不需要引入第三方包,比较适合一些简单的业务场景。缺点是如果需要更多的特性,需要定制化开发,成本会比较高,并且稳定性和可靠性也难以保障。对于比较复杂的场景,建议使用比较稳定的开源工具。

2. 基于Guava Cache实现本地缓存

Guava是Google团队开源的一款 Java 核心增强库,包含集合、并发原语、缓存、IO、反射等工具箱,性能和稳定性上都有保障,应用十分广泛。Guava Cache支持很多特性:

  • 支持最大容量限制
  • 支持两种过期删除策略(插入时间和访问时间)
  • 支持简单的统计功能
  • 基于LRU算法实现

使用代码如下:

 <dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>31.1-jre</version>
</dependency>
@Slf4j
public class GuavaCacheTest {
    public static void main(String[] args) throws ExecutionException {
        Cache<String, String> cache = CacheBuilder.newBuilder()
                .initialCapacity(5)  // 初始容量
                .maximumSize(10)     // 最大缓存数,超出淘汰
                .expireAfterWrite(60, TimeUnit.SECONDS) // 过期时间
                .build();

        String orderId = String.valueOf(123456789);
        // 获取orderInfo,如果key不存在,callable中调用getInfo方法返回数据
        String orderInfo = cache.get(orderId, () -> getInfo(orderId));
        log.info("orderInfo = {}", orderInfo);

    }

    private static String getInfo(String orderId) {
        String info = "";
        // 先查询redis缓存
        log.info("get data from redis");

        // 当redis缓存不存在查db
        log.info("get data from mysql");
        info = String.format("{orderId=%s}", orderId);
        return info;
    }
}
3. Caffeine

Caffeine是基于java8实现的新一代缓存工具,缓存性能接近理论最优。可以看作是Guava Cache的增强版,功能上两者类似,不同的是Caffeine采用了一种结合LRU、LFU优点的算法:W-TinyLFU,在性能上有明显的优越性

使用代码如下:

<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
    <version>2.9.3</version>
</dependency>
@Slf4j
public class CaffeineTest {
    public static void main(String[] args) {
        Cache<String, String> cache = Caffeine.newBuilder()
                .initialCapacity(5)
                // 超出时淘汰
                .maximumSize(10)
                //设置写缓存后n秒钟过期
                .expireAfterWrite(60, TimeUnit.SECONDS)
                //设置读写缓存后n秒钟过期,实际很少用到,类似于expireAfterWrite
                //.expireAfterAccess(17, TimeUnit.SECONDS)
                .build();

        String orderId = String.valueOf(123456789);
        String orderInfo = cache.get(orderId, key -> getInfo(key));
        System.out.println(orderInfo);
    }

    private static String getInfo(String orderId) {
        String info = "";
        // 先查询redis缓存
        log.info("get data from redis");

        // 当redis缓存不存在查db
        log.info("get data from mysql");
        info = String.format("{orderId=%s}", orderId);
        return info;
    }
}
4. Ehcache

Ehcache是一个纯Java的进程内缓存框架,具有快速、精干等特点,是Hibernate中默认的CacheProvider。同Caffeine和Guava Cache相比,Ehcache的功能更加丰富,扩展性更强:

  • 支持多种缓存淘汰算法,包括LRU、LFU和FIFO
  • 缓存支持堆内存储、堆外存储、磁盘存储(支持持久化)三种
  • 支持多种集群方案,解决数据共享问题

使用代码如下:

 <dependency>
    <groupId>org.ehcache</groupId>
    <artifactId>ehcache</artifactId>
    <version>3.9.7</version>
</dependency>
@Slf4j
public class EhcacheTest {
    private static final String ORDER_CACHE = "orderCache";
    public static void main(String[] args) {
        CacheManager cacheManager = CacheManagerBuilder.newCacheManagerBuilder()
                // 创建cache实例
                .withCache(ORDER_CACHE, CacheConfigurationBuilder
                        // 声明一个容量为20的堆内缓存
                        .newCacheConfigurationBuilder(String.class, String.class, ResourcePoolsBuilder.heap(20)))
                .build(true);
        // 获取cache实例
        Cache<String, String> cache = cacheManager.getCache(ORDER_CACHE, String.class, String.class);

        String orderId = String.valueOf(123456789);
        String orderInfo = cache.get(orderId);
        if (StrUtil.isBlank(orderInfo)) {
            orderInfo = getInfo(orderId);
            cache.put(orderId, orderInfo);
        }
        log.info("orderInfo = {}", orderInfo);
    }

    private static String getInfo(String orderId) {
        String info = "";
        // 先查询redis缓存
        log.info("get data from redis");

        // 当redis缓存不存在查db
        log.info("get data from mysql");
        info = String.format("{orderId=%s}", orderId);
        return info;
    }
}

本地缓存问题及解决

1. 缓存一致性

两级缓存与数据库的数据要保持一致,一旦数据发生了修改,在修改数据库的同时,本地缓存、远程缓存应该同步更新。

解决方案1: MQ

一般现在部署都是集群部署,有多个不同节点的本地缓存; 可以使用MQ的广播模式,当数据修改时向MQ发送消息,节点监听并消费消息,删除本地缓存,达到最终一致性;
在这里插入图片描述

解决方案2:Canal + MQ

如果你不想在你的业务代码发送MQ消息,还可以适用近几年比较流行的方法:订阅数据库变更日志,再操作缓存。Canal 订阅Mysql的 Binlog日志,当发生变化时向MQ发送消息,进而也实现数据一致性。
在这里插入图片描述

2. 如何提供本地缓存命中率
3. 本地内存的技术选型问题
  • 从易用性角度,Guava Cache、Caffeine和Ehcache都有十分成熟的接入方案,使用简单。
  • 从功能性角度,Guava Cache和Caffeine功能类似,都是只支持堆内缓存,Ehcache相比功能更为丰富
  • 从性能上进行比较,Caffeine最优、GuavaCache次之,Ehcache最差(下图是三者的性能对比结果)
    在这里插入图片描述

对于本地缓存的方案中,我比较推荐Caffeine,性能上遥遥领先。虽然Ehcache功能更为丰富,甚至提供了持久化和集群的功能,但是这些功能完全可以依靠其他方式实现。真实的业务工程中,建议使用Caffeine作为本地缓存,另外使用redis或者memcache作为分布式缓存,构造多级缓存体系,保证性能和可靠性。

### 本地缓存的实现方式 本地缓存通常是指将数据存储在应用程序所在的同一台机器上,通常是内存中的某种形式。这种方式能够提供极低延迟的数据访问能力,因为它不需要网络通信开销。常见的实现方式包括但不限于 `HashMap`、`ConcurrentHashMap` 和开源框架如 Guava Cache、Caffeine 等[^3]。 #### 使用 `ConcurrentHashMap` 实现本地缓存 `ConcurrentHashMap` 是 JDK 提供的一个线程安全的哈希表实现,在多线程环境下具有较高的性能表现。它允许完全并发读取操作,并支持高效的写入锁分段机制。以下是基于 `ConcurrentHashMap` 的简单本地缓存实现: ```java import java.util.concurrent.ConcurrentHashMap; public class LocalCache<K, V> { private final ConcurrentHashMap<K, V> cacheMap = new ConcurrentHashMap<>(); public void put(K key, V value) { cacheMap.put(key, value); } public V get(K key) { return cacheMap.get(key); } public void remove(K key) { cacheMap.remove(key); } public void clear() { cacheMap.clear(); } } ``` 上述代码展示了如何利用 `ConcurrentHashMap` 构建一个简单的本地缓存类[^5]。 --- ### 缓存机制 缓存机制主要涉及以下几个方面: 1. **加载策略**:当请求某个键对应的值时,如果该值不存在于缓存中,则需要从底层数据源(如数据库)获取并填充到缓存中。 2. **淘汰策略**:由于内存资源有限,缓存无法无限增长,因此需要定义合理的淘汰规则来移除不再使用的条目。 3. **失效控制**:为了防止缓存中的数据长期未更新而变得陈旧,可以设定每项数据的有效期限。 例如,Guava Cache 支持通过配置指定最大容量以及超时时间 (TTL),从而自动化管理这些过程。 --- ### 常见的缓存策略 不同的业务场景可能适合不同类型的缓存策略,下面列举几种典型的算法及其适用范围: - **LRU (Least Recently Used)** LRU 表示最近最少使用原则,即优先保留那些被频繁访问过的对象,而对于长时间未曾触及的内容则予以清除。这种方案适用于大多数通用情况下的内存优化需求。 - **LFU (Least Frequently Used)** LFU 则关注的是频率维度上的差异性处理——越是经常发生的查询越应该驻留在高速缓冲区里头;反之亦然。 - **FIFO (First In First Out)** FIFO 方法按照先进先出的原则决定哪些记录应当被淘汰出局。尽管其实现起来比较简单明了,但在某些特定条件下可能会导致较差的效果。 对于更复杂的场景,还可以考虑混合型或多层架构的设计思路,比如 Redis 客户端缓存结合本地嵌套式布局等方式共同发挥作用[^1]。 --- ### 结合 MyBatis 的本地缓存功能 MyBatis 自带了一种称为“一级缓存”的特性,默认情况下会自动开启用于减少重复 SQL 执行次数所带来的额外负担。除此之外还存在另外两种扩展选项:“二级缓存”与“集群缓存”,它们分别作用于跨多个 Mapper 文件共享全局状态层面之上以及分布式的环境中保持同步一致性的目标达成之中[^2]。 启用或禁用此类行为可通过 XML 映射文档内的属性声明完成调整工作,如下所示: ```xml <select id="selectUserById" resultType="User" flushCache="false"> SELECT * FROM users WHERE id=#{id}; </select> ``` 这里设置了参数 `flushCache="false"` 意味着不会每次调用都强制刷新掉现有的临时保存区域内容。 ---
评论 13
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Gimtom

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值