4. 缓存设计(上):本地缓存与分布式缓存的原理与选型

缓存这东西,说白了就是用空间换时间。我在高并发系统里摸爬滚打这么多年,发现很多性能问题,加一层缓存就能解决七八成。但缓存也不是银弹,选错了、用错了,反而会引入数据不一致、雪崩、穿透这些头疼的问题。

这一章我们先聊本地缓存和分布式缓存。嗯,内容比较多,我拆成上下两讲。今天重点讲原理和选型,下讲再深入实战和坑点。

4.1 本地缓存:应用进程内的“快照”

本地缓存,就是把数据放在应用进程的内存里。每次请求来了,直接从内存拿,不走网络,延迟通常在微秒级。你想想看,这比查数据库快了几个数量级?

Java 生态里,最常用的两个本地缓存库是 Guava Cache 和 Caffeine。Caffeine 是 Guava Cache 的升级版,性能更好,现在 Spring Boot 2.x 默认的本地缓存就是 Caffeine。

4.1.1 Guava Cache:老牌选手

Guava Cache 功能很全,支持过期策略、最大容量、回调函数等。我早期做项目时经常用它,简单可靠。

// 构建一个 Guava Cache
LoadingCache<String, User> cache = CacheBuilder.newBuilder()
        .maximumSize(10000)          // 最大条目数
        .expireAfterWrite(10, TimeUnit.MINUTES) // 写入后10分钟过期
        .recordStats()               // 开启统计
        .build(new CacheLoader<String, User>() {
            @Override
            public User load(String key) {
                return getUserFromDB(key); // 缓存未命中时自动加载
            }
        });

// 使用
User user = cache.get("user_123");

这里要注意,expireAfterWrite 是写入后固定时间过期。还有 expireAfterAccess,是最后一次访问后固定时间过期。两者区别很大,别搞混了。

4.1.2 Caffeine:新一代性能王者

Caffeine 的 API 和 Guava Cache 几乎一样,但底层用了更高效的 W-TinyLFU 淘汰算法。我做过压测,同样场景下 Caffeine 的吞吐量比 Guava Cache 高 30% 以上。

// 构建一个 Caffeine 缓存
Cache<String, User> cache = Caffeine.newBuilder()
        .maximumSize(10000)
        .expireAfterWrite(10, TimeUnit.MINUTES)
        .recordStats()
        .build();

// 手动存取
User user = cache.get("user_123", key -> getUserFromDB(key));
我的建议:新项目直接用 Caffeine,别犹豫。Guava Cache 已经进入维护模式,Caffeine 才是未来。而且 Caffeine 的 API 设计更优雅,支持异步加载。

4.1.3 本地缓存的适用场景

  • 数据量小:几万条以内,比如配置信息、字典表。
  • 读多写少:比如商品详情、用户基本信息。
  • 允许短暂不一致:本地缓存没有集群同步,更新后其他节点需要时间才能看到新数据。
避坑:我曾经在一个项目里把用户会话信息放在本地缓存,结果部署了 10 个节点,用户登录后请求被路由到不同节点,导致频繁踢下线。后来改用 Redis 集中存储才解决。本地缓存不适合跨节点共享的数据。

4.2 分布式缓存:跨节点的“共享内存”

当系统规模变大,单机内存不够用,或者需要多个服务共享数据时,就得请出分布式缓存了。目前主流的是 Redis 和 Memcached。

4.2.1 Redis:功能丰富的瑞士军刀

Redis 支持丰富的数据结构:String、Hash、List、Set、Sorted Set、HyperLogLog、Bitmap 等。还支持持久化、主从复制、哨兵、集群。我个人的习惯是,只要不是特别简单的场景,优先选 Redis。

// 使用 Jedis 客户端
Jedis jedis = new Jedis("localhost", 6379);
jedis.set("user:123", "{\"name\":\"张三\",\"age\":30}");
String json = jedis.get("user:123");

// 使用 Hash 存储对象字段
jedis.hset("user:123", "name", "张三");
jedis.hset("user:123", "age", "30");
String name = jedis.hget("user:123", "name");

4.2.2 Memcached:简单纯粹的键值存储

Memcached 只支持简单的 key-value 结构,数据全部在内存中,不支持持久化。它的多线程模型在处理大并发时表现不错,但功能确实太少了。

// 使用 Memcached 客户端
MemcachedClient client = new MemcachedClient(
        new InetSocketAddress("localhost", 11211));
client.set("user:123", 3600, userObject); // 设置过期时间
User user = (User) client.get("user:123");

4.3 选型对比:到底该用哪个?

很多同学问我,Redis 和 Memcached 怎么选?我一般会反问三个问题:你需要持久化吗?你需要复杂数据结构吗?你的数据量有多大?

对比维度 Redis Memcached Guava Cache / Caffeine
数据结构 丰富(String、Hash、List 等) 仅 key-value 仅 key-value
持久化 支持 RDB / AOF 不支持 不支持
集群模式 主从、哨兵、Cluster 客户端一致性哈希 无(单机)
延迟 毫秒级(网络开销) 毫秒级(网络开销) 微秒级(本地内存)
典型场景 会话、排行榜、计数器、分布式锁 简单 KV 缓存、页面片段缓存 配置、字典、热点数据

我的选型原则:

  • 需要持久化、复杂数据结构、分布式锁 → Redis
  • 只需要简单 KV 缓存,且对延迟不敏感 → Memcached(但说实话,现在 Redis 这么成熟,我很少用 Memcached 了)
  • 数据量小、延迟要求极高、不跨节点共享 → Caffeine

4.4 缓存架构的核心逻辑

实际项目中,本地缓存和分布式缓存经常配合使用,形成多级缓存架构。我画了一张图,帮你理清这个流程。

多级缓存架构流程图 多级缓存架构流程图 客户端请求 第1层:本地缓存(Caffeine) 命中? 直接返回(微秒级) 未命中 第2层:分布式缓存(Redis) 命中? 回填本地缓存 + 返回 未命中 第3层:数据库(DB) 回填 Redis + 本地缓存 注意 回填时注意设置过期时间 防止缓存雪崩

这个流程其实很直观:请求先打本地缓存,没命中再去 Redis,Redis 也没有才查数据库。查完数据库后,逐级回填缓存。这样做的好处是,热点数据在本地缓存就能命中,延迟极低;冷数据走 Redis 共享,避免每个节点都去查数据库。

我曾经踩过的坑:有一次没给本地缓存设置过期时间,结果数据库里的数据更新了,本地缓存还是旧数据,导致线上展示错误持续了半小时。后来我养成了一个习惯:所有缓存都必须设置 TTL,哪怕你觉得数据永远不会变。

4.5 缓存淘汰策略:内存满了怎么办?

缓存的内存是有限的,满了就得淘汰旧数据。不同的缓存产品有不同的淘汰策略。

策略 说明 适用场景
LRU 淘汰最近最少使用的 大多数通用场景
LFU 淘汰访问频率最低的 热点数据集中、访问模式稳定的场景
FIFO 淘汰最先进入的 数据时效性强的场景(不推荐)
TTL 淘汰已过期的 所有缓存都必须配合使用

Caffeine 使用的 W-TinyLFU 算法,结合了 LRU 和 LFU 的优点,既能应对突发流量,又能识别长期热点。我做过对比测试,在同样的内存限制下,Caffeine 的命中率比 Guava Cache 的 LRU 高出 5-10 个百分点。

小技巧:Redis 的淘汰策略可以在配置文件中设置,比如 maxmemory-policy allkeys-lru。生产环境我一般用 allkeys-lru 或 volatile-lru,前者对所有 key 生效,后者只对设置了过期时间的 key 生效。

4.6 本章小结

缓存设计是高并发系统的基石。本地缓存快但容量小、不共享;分布式缓存容量大、可共享但有网络开销。实际项目中,我通常会用 Caffeine 做一级缓存,Redis 做二级缓存,数据库做最后屏障。