在现代分布式系统中,缓存已成为降低数据库负载、提升响应速度的核心基础设施。Redis 作为事实标准的内存数据库,其 Cluster 模式提供了自动分片与高可用能力;然而,真正在生产环境中驾驭分布式缓存,远不止部署一个 Redis Cluster 那么简单——从数据分片的一致性哈希选址,到缓存穿透、击穿、雪崩的三重灾备方案,再到 Caffeine + Redis 的多级缓存架构与 Hot Key 治理,每一层都需要系统设计级别的深度思考。
本文以 Java 为主要实现语言,完整覆盖分布式缓存的十大核心议题,包含 15+ 可运行代码示例、2 张策略对比表与 5 个高频 FAQ,旨在为读者提供一套可直接落地的缓存工程化指南。
1. Redis Cluster 深度解析:Slot、Shard、Replication 与 Sentinel
1.1 架构总览
Redis Cluster 采用无中心架构,所有节点通过 Gossip 协议交换状态信息。数据按 Key 的 CRC16 哈希值映射到 0~16383 共 16384 个 Slot(槽),每个主节点负责一部分 Slot,从节点复制主节点数据并在主节点故障时自动 failover。
import redis.clients.jedis.JedisCluster;
import redis.clients.jedis.HostAndPort;
import java.util.HashSet;
import java.util.Set;
/**
* RedisCluster 客户端初始化示例
* 配置集群节点列表,Jedis 会自动发现全部节点与槽映射关系
*/
public class RedisClusterDemo {
public static void main(String[] args) {
Set<HostAndPort> nodes = new HashSet<>();
// 仅需配置部分节点,客户端会自动发现全部集群拓扑
nodes.add(new HostAndPort("192.168.1.101", 6379));
nodes.add(new HostAndPort("192.168.1.102", 6379));
nodes.add(new HostAndPort("192.168.1.103", 6379));
JedisCluster cluster = new JedisCluster(nodes);
// 写入操作:Jedis 根据 key 计算槽位,路由到对应主节点
cluster.set("user:1001:profile", "{name:'Alice',level:12}");
// 读取操作:优先读主节点,可配置读写分离读从节点
String value = cluster.get("user:1001:profile");
System.out.println("读取结果: " + value);
cluster.close();
}
}
1.2 Slot 计算与 Key 路由原理
Redis Cluster 使用 CRC16(key) % 16384 确定 Slot 位置。理解 Slot 分布是排查数据倾斜与热 Key 的基础。
import java.util.zip.CRC32;
/**
* 模拟 Redis Cluster 的 Slot 计算逻辑
*/
public class SlotCalculator {
// Redis 实际使用 CRC16,此处用 CRC32 演示哈希计算过程
public static int calculateSlot(String key) {
// 提取 Hash Tag:若 key 包含 {tag},仅对 tag 部分计算哈希
// 例如 user:{1001}:profile 与 order:{1001}:detail 会落到同一槽位
int start = key.indexOf('{');
int end = key.indexOf('}', start);
String hashKey = (start != -1 && end != -1 && end != start + 1)
? key.substring(start + 1, end)
: key;
CRC32 crc32 = new CRC32();
crc32.update(hashKey.getBytes());
// Redis 实际是 CRC16 & 0x3FFF
return (int) (crc32.getValue() % 16384);
}
public static void main(String[] args) {
System.out.println("user:1001:profile 的 Slot: " + calculateSlot("user:1001:profile"));
System.out.println("user:{1001}:orders 的 Slot: " + calculateSlot("user:{1001}:orders"));
System.out.println("user:{1001}:cart 的 Slot: " + calculateSlot("user:{1001}:cart"));
}
}
1.3 主从复制与 Sentinel 高可用
Sentinel 为 Redis 主从架构提供监控、通知与自动故障转移能力。虽然 Cluster 自带 failover,但在非 Cluster 场景下 Sentinel 仍是标准高可用方案。
import redis.clients.jedis.JedisSentinelPool;
import redis.clients.jedis.Jedis;
import java.util.HashSet;
import java.util.Set;
/**
* Sentinel 模式客户端:自动感知主节点切换
*/
public class SentinelDemo {
public static void main(String[] args) {
Set<String> sentinels = new HashSet<>();
sentinels.add("192.168.1.201:26379");
sentinels.add("192.168.1.202:26379");
sentinels.add("192.168.1.203:26379");
// masterName 需与 Sentinel 配置文件中的名称一致
JedisSentinelPool pool = new JedisSentinelPool("mymaster", sentinels);
try (Jedis jedis = pool.getResource()) {
jedis.set("sentinel:test", "high_availability_value");
System.out.println("写入成功,当前主节点: " + pool.getCurrentHostMaster());
}
pool.close();
}
}
1.4 Cluster 调优建议
| 参数 | 建议值 | 说明 |
|---|---|---|
cluster-node-timeout | 15000 | 节点超时阈值,单位毫秒 |
cluster-require-full-coverage | no | 允许部分槽位不可用时集群继续服务 |
maxmemory-policy | allkeys-lru | 全键 LRU 淘汰策略 |
repl-backlog-size | 256mb | 复制积压缓冲区,避免全量同步 |
appendonly | yes | 开启 AOF 持久化 |
2. 一致性哈希算法实现
2.1 算法原理
传统哈希取模 hash(key) % N 在节点扩容或缩容时会导致几乎所有缓存失效。一致性哈希将节点与数据映射到同一个 hash 环上,通过顺时针查找最近节点定位数据,使得单节点变更仅影响环上相邻区间的数据。
2.2 Java 实现(带虚拟节点)
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.*;
/**
* 一致性哈希实现,支持虚拟节点以解决数据倾斜问题
*/
public class ConsistentHash<T> {
// 虚拟节点倍数:每个物理节点对应 150 个虚拟节点
private final int numberOfReplicas;
// 哈希环:TreeMap 保持节点有序,便于顺时针查找
private final SortedMap<Long, T> circle = new TreeMap<>();
public ConsistentHash(int numberOfReplicas, Collection<T> nodes) {
this.numberOfReplicas = numberOfReplicas;
for (T node : nodes) {
add(node);
}
}
/**
* 添加节点:为每个物理节点生成多个虚拟节点,均匀散列到环上
*/
public void add(T node) {
for (int i = 0; i < numberOfReplicas; i++) {
// 虚拟节点标识:节点名 + 序号
long hash = hash(node.toString() + ":" + i);
circle.put(hash, node);
System.out.println("虚拟节点加入: " + node + "#" + i + " -> " + hash);
}
}
/**
* 移除节点:清理该节点对应的所有虚拟节点
*/
public void remove(T node) {
for (int i = 0; i < numberOfReplicas; i++) {
long hash = hash(node.toString() + ":" + i);
circle.remove(hash);
}
}
/**
* 获取 key 对应的节点:顺时针查找第一个大于等于 key 哈希的虚拟节点
*/
public T get(Object key) {
if (circle.isEmpty()) {
return null;
}
long hash = hash(key.toString());
// 若不存在大于等于该 hash 的节点,则取环首节点(环形回绕)
if (!circle.containsKey(hash)) {
SortedMap<Long, T> tailMap = circle.tailMap(hash);
hash = tailMap.isEmpty() ? circle.firstKey() : tailMap.firstKey();
}
return circle.get(hash);
}
/**
* MD5 哈希:将字符串转换为long型哈希值
*/
private long hash(String key) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
md.update(key.getBytes());
byte[] digest = md.digest();
// 取前 8 字节构成 long
long h = 0;
for (int i = 0; i < 8; i++) {
h <<= 8;
h |= ((int) digest[i]) & 0xFF;
}
return h;
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
}
public static void main(String[] args) {
List<String> nodes = Arrays.asList("Redis-A", "Redis-B", "Redis-C");
ConsistentHash<String> hashRing = new ConsistentHash<>(150, nodes);
// 测试 key 分布
String[] keys = {"user:1001", "product:2001", "order:3001", "cart:4001"};
for (String key : keys) {
System.out.println(key + " -> " + hashRing.get(key));
}
// 模拟节点下线
System.out.println("\n--- Redis-B 下线 ---");
hashRing.remove("Redis-B");
for (String key : keys) {
System.out.println(key + " -> " + hashRing.get(key));
}
}
}
2.3 虚拟节点的必要性
在物理节点较少时,一致性哈希环上节点分布可能不均匀,导致某些节点承载过多数据。引入虚拟节点(如每个物理节点映射 100~200 个虚拟节点)可将数据均匀打散,同时保证节点变更时仅影响约 1/N 的数据迁移量。
3. 缓存穿透:布隆过滤器方案
3.1 问题定义
缓存穿透指查询一个数据库与缓存中都不存在的数据,由于每次都不命中,请求直达数据库,可能导致 DB 压力骤增。
3.2 布隆过滤器原理
布隆过滤器是一个空间高效的概率型数据结构,用于判断"一个元素一定不存在或可能存在"。它由位数组和多个哈希函数组成,存在误判率(False Positive)但无漏报。
3.3 Guava BloomFilter 实战
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
import java.nio.charset.Charset;
import java.util.concurrent.TimeUnit;
/**
* 布隆过滤器解决缓存穿透问题
* 适用于商品 ID、用户 ID 等可预判全集的场景
*/
public class CachePenetrationDefense {
// 预期插入 100 万条记录,误判率 1%
private static final BloomFilter<String> bloomFilter = BloomFilter.create(
Funnels.stringFunnel(Charset.defaultCharset()),
1000000,
0.01
);
// 模拟 Redis 缓存与 MySQL 数据库
private final MockCache cache = new MockCache();
private final MockDatabase db = new MockDatabase();
/**
* 系统初始化时预热布隆过滤器:加载所有有效 ID
*/
public void initBloomFilter() {
// 实际场景从数据库全量加载商品 ID
for (long i = 1; i <= 1000000; i++) {
bloomFilter.put("product:" + i);
}
System.out.println("布隆过滤器预热完成,已加载 100 万条商品 ID");
}
/**
* 查询商品:先查布隆过滤器,再查缓存,最后查数据库
*/
public Product getProduct(String productId) {
// 第 1 层:布隆过滤器拦截无效请求
if (!bloomFilter.mightContain(productId)) {
System.out.println("[布隆过滤器] " + productId + " 不存在,直接返回 null");
return null; // 一定不存在,避免穿透
}
// 第 2 层:查询 Redis 缓存
Product product = cache.get(productId);
if (product != null) {
System.out.println("[缓存命中] " + productId);
return product;
}
// 第 3 层:查询数据库并回写缓存
System.out.println("[数据库查询] " + productId);
product = db.queryById(productId);
if (product != null) {
cache.set(productId, product, 30, TimeUnit.MINUTES);
}
return product;
}
public static void main(String[] args) {
CachePenetrationDefense defense = new CachePenetrationDefense();
defense.initBloomFilter();
// 正常请求
defense.getProduct("product:50000");
// 穿透攻击:查询不存在的商品
defense.getProduct("product:-999999");
defense.getProduct("product:hack");
defense.getProduct("product:2000000");
}
// 模拟类定义
static class Product { String id; String name; }
static class MockCache {
private final Map<String, Product> data = new ConcurrentHashMap<>();
Product get(String k) { return data.get(k); }
void set(String k, Product v, long t, TimeUnit u) { data.put(k, v); }
}
static class MockDatabase {
Product queryById(String id) {
// 模拟仅 1~100 万 ID 有效
try {
long num = Long.parseLong(id.replace("product:", ""));
if (num >= 1 && num <= 1000000) {
Product p = new Product();
p.id = id;
p.name = "产品-" + num;
return p;
}
} catch (Exception ignored) {}
return null;
}
}
}
3.4 Redisson 分布式布隆过滤器
import org.redisson.Redisson;
import org.redisson.api.RBloomFilter;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
/**
* Redisson 提供的分布式布隆过滤器,适用于集群环境
*/
public class RedissonBloomFilterDemo {
public static void main(String[] args) {
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
RedissonClient redisson = Redisson.create(config);
RBloomFilter<String> bloomFilter = redisson.getBloomFilter("product:bloom");
// 初始化:预期 100 万条数据,误判率 0.03
bloomFilter.tryInit(1000000L, 0.03);
bloomFilter.add("product:1001");
bloomFilter.add("product:1002");
System.out.println("是否存在 1001: " + bloomFilter.contains("product:1001"));
System.out.println("是否存在 9999: " + bloomFilter.contains("product:9999"));
redisson.shutdown();
}
}
4. 缓存击穿:逻辑过期 + 互斥锁方案
4.1 问题定义
缓存击穿指热点 Key 在失效瞬间,大量并发请求同时涌入数据库,造成 DB 瞬时压力峰值。与穿透不同,击穿的数据是存在的,只是缓存刚好过期。
4.2 互斥锁方案(Mutex)
import org.redisson.Redisson;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import java.util.concurrent.TimeUnit;
/**
* 互斥锁方案:热点 Key 过期时,仅允许一个线程重建缓存
*/
public class CacheBreakdownMutex {
private final RedissonClient redisson;
private final MockCache cache = new MockCache();
private final MockDatabase db = new MockDatabase();
public CacheBreakdownMutex() {
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
this.redisson = Redisson.create(config);
}
/**
* 查询热点数据:缓存失效时使用分布式互斥锁防止击穿
*/
public HotData getHotData(String key) {
// 先查缓存
HotData data = cache.get(key);
if (data != null) {
return data;
}
// 缓存未命中,尝试获取分布式锁重建缓存
String lockKey = "lock:" + key;
RLock lock = redisson.getLock(lockKey);
try {
// 尝试获取锁,最多等待 10 秒,锁持有 30 秒(看门狗自动续期)
boolean isLocked = lock.tryLock(10, 30, TimeUnit.SECONDS);
if (isLocked) {
try {
// 双重检查:获取锁后再次确认缓存是否已被其他线程重建
data = cache.get(key);
if (data != null) {
return data;
}
// 查询数据库
System.out.println("[互斥锁内] 数据库查询: " + key);
data = db.queryHotData(key);
// 写入缓存,设置 60 分钟 TTL
if (data != null) {
cache.set(key, data, 60, TimeUnit.MINUTES);
}
} finally {
lock.unlock();
}
} else {
// 未获取到锁:短暂休眠后递归重试(或返回降级数据)
Thread.sleep(50);
return getHotData(key);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return data;
}
static class HotData { String key; String content; long version; }
static class MockCache {
private final Map<String, HotData> data = new ConcurrentHashMap<>();
HotData get(String k) { return data.get(k); }
void set(String k, HotData v, long t, TimeUnit u) { data.put(k, v); }
}
static class MockDatabase {
HotData queryHotData(String key) {
HotData d = new HotData();
d.key = key;
d.content = "热点数据内容-" + key;
d.version = System.currentTimeMillis();
return d;
}
}
}
4.3 逻辑过期方案(永不过期 + 异步重建)
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
/**
* 逻辑过期方案:Value 内嵌过期时间戳,缓存永不过期,后台异步重建
* 优点:无锁、无缓存失效瞬间、性能最优
* 缺点:数据一致性窗口较长
*/
public class CacheBreakdownLogicalExpire {
// 逻辑过期时间:30 分钟
private static final long LOGIC_EXPIRE_SECONDS = 30 * 60;
private final MockCache cache = new MockCache();
private final MockDatabase db = new MockDatabase();
// 线程池异步重建缓存
private final ExecutorService executor = Executors.newFixedThreadPool(10);
// 重建标记:防止多个线程同时重建同一 key
private final ConcurrentHashMap<String, AtomicBoolean> rebuilding = new ConcurrentHashMap<>();
/**
* 包装对象:存储数据 + 逻辑过期时间
*/
static class CacheWrapper<T> {
T data;
// 过期时间戳(毫秒)
long expireTime;
CacheWrapper(T data, long expireSeconds) {
this.data = data;
this.expireTime = System.currentTimeMillis() + expireSeconds * 1000;
}
boolean isExpired() {
return System.currentTimeMillis() > expireTime;
}
}
/**
* 查询数据:逻辑过期时返回旧数据并触发异步重建
*/
@SuppressWarnings("unchecked")
public HotData getHotData(String key) {
CacheWrapper<HotData> wrapper = (CacheWrapper<HotData>) cache.get(key);
// 缓存不存在:首次加载需要同步查询(可配合互斥锁)
if (wrapper == null) {
System.out.println("[雪崩防护] 首次加载: " + key);
HotData data = db.queryHotData(key);
wrapper = new CacheWrapper<>(data, LOGIC_EXPIRE_SECONDS);
cache.set(key, wrapper);
return data;
}
// 未过期:直接返回
if (!wrapper.isExpired()) {
return wrapper.data;
}
// 已过期:返回旧数据,异步触发重建
System.out.println("[逻辑过期] 返回旧数据,异步重建: " + key);
rebuildAsync(key);
return wrapper.data;
}
/**
* 异步重建缓存:仅一个线程执行重建
*/
private void rebuildAsync(String key) {
AtomicBoolean flag = rebuilding.computeIfAbsent(key, k -> new AtomicBoolean(false));
if (flag.compareAndSet(false, true)) {
executor.submit(() -> {
try {
System.out.println("[异步重建] 开始: " + key);
HotData data = db.queryHotData(key);
CacheWrapper<HotData> newWrapper =
new CacheWrapper<>(data, LOGIC_EXPIRE_SECONDS);
cache.set(key, newWrapper);
} finally {
flag.set(false);
System.out.println("[异步重建] 完成: " + key);
}
});
}
}
static class HotData { String content; long timestamp = System.currentTimeMillis(); }
static class MockCache {
private final ConcurrentHashMap<String, Object> map = new ConcurrentHashMap<>();
Object get(String k) { return map.get(k); }
void set(String k, Object v) { map.put(k, v); }
}
static class MockDatabase {
HotData queryHotData(String key) {
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
HotData d = new HotData();
d.content = "最新数据-" + key + "-" + System.currentTimeMillis();
return d;
}
}
}
5. 缓存雪崩:随机 TTL + 多级缓存
5.1 问题定义
缓存雪崩指大量 Key 在同一时间集中过期,或 Redis 集群整体故障,导致所有请求直达数据库。雪崩的破坏力远大于击穿,因为它影响的是整个缓存层而非单个热点 Key。
5.2 随机 TTL 方案
import java.util.Random;
import java.util.concurrent.TimeUnit;
/**
* 随机 TTL 方案:为基础 TTL 增加随机偏移,分散过期时间点
*/
public class RandomTTLStrategy {
private final Random random = new Random();
private final MockCache cache = new MockCache();
private final MockDatabase db = new MockDatabase();
// 基础 TTL:30 分钟
private static final int BASE_TTL_MINUTES = 30;
// 随机偏移范围:0 ~ 10 分钟
private static final int RANDOM_OFFSET_MINUTES = 10;
/**
* 写入缓存时附加随机 TTL
*/
public void setWithRandomTTL(String key, Object value) {
int ttl = BASE_TTL_MINUTES + random.nextInt(RANDOM_OFFSET_MINUTES);
System.out.println("[随机 TTL] Key=" + key + ", TTL=" + ttl + " 分钟");
cache.set(key, value, ttl, TimeUnit.MINUTES);
}
/**
* 查询:常规缓存查询逻辑
*/
public Object get(String key) {
Object value = cache.get(key);
if (value == null) {
value = db.query(key);
if (value != null) {
setWithRandomTTL(key, value);
}
}
return value;
}
/**
* 批量预热:大批量写入时使用随机 TTL 尤为重要
*/
public void batchPreload() {
for (int i = 1; i <= 1000; i++) {
String key = "product:" + i;
setWithRandomTTL(key, "产品数据-" + i);
}
System.out.println("批量预热完成,1000 个 Key 的过期时间已分散");
}
static class MockCache {
private final Map<String, Object> data = new ConcurrentHashMap<>();
Object get(String k) { return data.get(k); }
void set(String k, Object v, long t, TimeUnit u) { data.put(k, v); }
}
static class MockDatabase {
Object query(String key) { return "DB-" + key; }
}
}
5.3 熔断与降级策略
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
/**
* 简易熔断器:Redis 故障时自动降级,保护数据库
*/
public class CircuitBreaker {
// 失败阈值:连续失败 5 次触发熔断
private static final int FAILURE_THRESHOLD = 5;
// 熔断持续时间:60 秒
private static final long COOLDOWN_MS = 60000;
private final AtomicInteger failureCount = new AtomicInteger(0);
private final AtomicLong lastFailureTime = new AtomicLong(0);
private volatile State state = State.CLOSED;
enum State { CLOSED, OPEN, HALF_OPEN }
/**
* 判断当前是否允许请求通过
*/
public boolean allowRequest() {
if (state == State.CLOSED) {
return true;
}
if (state == State.OPEN) {
// 检查是否已过冷却期
if (System.currentTimeMillis() - lastFailureTime.get() > COOLDOWN_MS) {
state = State.HALF_OPEN;
failureCount.set(0);
return true;
}
return false;
}
// HALF_OPEN 状态下放行探测请求
return true;
}
public void recordSuccess() {
failureCount.set(0);
if (state == State.HALF_OPEN) {
state = State.CLOSED;
}
}
public void recordFailure() {
lastFailureTime.set(System.currentTimeMillis());
int count = failureCount.incrementAndGet();
if (count >= FAILURE_THRESHOLD) {
state = State.OPEN;
System.out.println("[熔断器] 触发熔断,进入 OPEN 状态");
}
}
}
6. 多级缓存:Caffeine L1 + Redis L2 架构
6.1 架构设计
多级缓存利用内存级本地缓存(Caffeine)作为 L1,Redis 作为 L2,数据库作为 L3。L1 访问速度在纳秒级,L2 在亚毫秒级,L3 在毫秒级。多级缓存的核心挑战是一致性管理。
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.Cache;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.Jedis;
import com.alibaba.fastjson.JSON;
import java.util.concurrent.TimeUnit;
/**
* Caffeine L1 + Redis L2 多级缓存实现
* L1 命中:~100ns 级延迟
* L2 命中:~1ms 级延迟
* L3 命中:~10ms+ 级延迟
*/
public class MultiLevelCache {
// L1:本地缓存,TTL 5 分钟,最大 10 万条目
private final Cache<String, Object> localCache = Caffeine.newBuilder()
.maximumSize(100000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.recordStats() // 开启命中率统计
.build();
// L2:Redis 缓存
private final JedisPool jedisPool = new JedisPool("127.0.0.1", 6379);
// L3:数据库
private final MockDatabase db = new MockDatabase();
/**
* 读取数据:L1 -> L2 -> L3 三级穿透
*/
@SuppressWarnings("unchecked")
public <T> T get(String key, Class<T> clazz) {
// 第 1 层:Caffeine 本地缓存
Object value = localCache.getIfPresent(key);
if (value != null) {
System.out.println("[L1 命中] " + key);
return (T) value;
}
// 第 2 层:Redis 分布式缓存
try (Jedis jedis = jedisPool.getResource()) {
String json = jedis.get(key);
if (json != null) {
System.out.println("[L2 命中] " + key);
T obj = JSON.parseObject(json, clazz);
// 回填 L1:防止下次访问再次穿透到 L2
localCache.put(key, obj);
return obj;
}
}
// 第 3 层:数据库查询
System.out.println("[L3 查询] " + key);
T obj = db.query(key, clazz);
if (obj != null) {
// 回填 L2 与 L1
put(key, obj, 30); // L2 TTL 30 分钟
}
return obj;
}
/**
* 写入数据:更新数据库后,先删 L2 再淘汰 L1
* 策略:Cache Aside 变体
*/
public <T> void put(String key, T value, long minutesTTL) {
// 写入 L2 Redis
try (Jedis jedis = jedisPool.getResource()) {
jedis.setex(key, (int) (minutesTTL * 60), JSON.toJSONString(value));
}
// 回填 L1
localCache.put(key, value);
}
/**
* 删除/更新数据:保证多级缓存一致性
* 采用先删缓存再更新 DB,延迟双删策略
*/
public void delete(String key) {
// 第 1 步:删除 L1
localCache.invalidate(key);
// 第 2 步:删除 L2
try (Jedis jedis = jedisPool.getResource()) {
jedis.del(key);
}
// 第 3 步:数据库更新(业务层执行)
// db.update(...);
// 第 4 步:延迟双删(500ms 后再次删除,避免读写并发导致脏数据)
new Thread(() -> {
try {
Thread.sleep(500);
localCache.invalidate(key);
try (Jedis jedis = jedisPool.getResource()) {
jedis.del(key);
}
System.out.println("[延迟双删] 完成: " + key);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}).start();
}
/**
* 打印缓存统计信息
*/
public void printStats() {
System.out.println("L1 统计: " + localCache.stats());
}
static class MockDatabase {
<T> T query(String key, Class<T> clazz) {
try { Thread.sleep(10); } catch (InterruptedException ignored) {}
if (clazz == User.class) {
User user = new User();
user.id = key;
user.name = "User-" + key;
return clazz.cast(user);
}
return null;
}
}
static class User { String id; String name; }
}
6.2 Spring Cache 抽象集成
import org.springframework.cache.annotation.Cacheable;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Caching;
import org.springframework.stereotype.Service;
/**
* Spring Cache + Caffeine + Redis 注解驱动多级缓存
*/
@Service
public class UserService {
/**
* 查询用户:优先走 L1,L1 未命中走 L2,L2 未命中执行方法体
*/
@Cacheable(value = "user", key = "#userId", cacheManager = "caffeineCacheManager")
public User getUserFromLocal(String userId) {
// L1 未命中时,调用带 Redis 注解的方法
return getUserFromRedis(userId);
}
@Cacheable(value = "user", key = "#userId", cacheManager = "redisCacheManager")
public User getUserFromRedis(String userId) {
// L2 未命中,查询数据库
return queryDatabase(userId);
}
/**
* 更新用户:同时清除 L1 和 L2
*/
@Caching(evict = {
@CacheEvict(value = "user", key = "#userId", cacheManager = "caffeineCacheManager"),
@CacheEvict(value = "user", key = "#userId", cacheManager = "redisCacheManager")
})
public void updateUser(String userId, User user) {
// 更新数据库
saveToDatabase(user);
}
private User queryDatabase(String userId) {
User user = new User();
user.id = userId;
user.name = "DB-User-" + userId;
return user;
}
private void saveToDatabase(User user) {
System.out.println("保存到数据库: " + user.id);
}
static class User {
String id;
String name;
}
}
7. Hot Key 检测与治理
7.1 Hot Key 识别方法
- Redis 监控命令:
redis-cli --hotkeys(需配置maxmemory-policy为 LFU) - Proxy 层统计:Twemproxy、Codis 可在代理层统计 Key 访问频次
- 客户端埋点:在业务代码中通过计数器统计访问频率
7.2 本地热点检测实现
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.Cache;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
import java.util.Map;
/**
* 基于滑动窗口的 Hot Key 检测器
* 检测最近 10 秒内访问频率超过阈值的 Key
*/
public class HotKeyDetector {
// 窗口大小:10 秒
private static final long WINDOW_MS = 10_000;
// 热点阈值:10 秒内访问超过 1000 次
private static final int HOT_THRESHOLD = 1000;
// 访问计数器:key -> 最近访问次数
private final ConcurrentHashMap<String, AtomicLong> counter = new ConcurrentHashMap<>();
// 已识别的热点 Key 缓存
private final Cache<String, Boolean> hotKeyCache = Caffeine.newBuilder()
.expireAfterWrite(1, TimeUnit.MINUTES)
.build();
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
public HotKeyDetector() {
// 每 10 秒清理一次计数器
scheduler.scheduleAtFixedRate(this::resetCounter, WINDOW_MS, WINDOW_MS, TimeUnit.MILLISECONDS);
}
/**
* 记录 Key 访问:返回是否为热点 Key
*/
public boolean recordAccess(String key) {
AtomicLong count = counter.computeIfAbsent(key, k -> new AtomicLong(0));
long current = count.incrementAndGet();
// 若已标记为热点,直接返回
if (hotKeyCache.getIfPresent(key) != null) {
return true;
}
// 超过阈值,标记为热点
if (current >= HOT_THRESHOLD) {
hotKeyCache.put(key, true);
System.out.println("[HotKey 检测] 发现热点 Key: " + key + ", 访问量: " + current);
onHotKeyDetected(key);
return true;
}
return false;
}
/**
* 热点 Key 治理:本地缓存复制、通知集群
*/
private void onHotKeyDetected(String key) {
// 策略 1:本地 LRU 缓存该 Key,不再穿透到 Redis
// 策略 2:限流:对该 Key 的访问进行令牌桶限流
// 策略 3:Key 拆分:将热点 Key 拆分为多个副本,如 hotkey#1, hotkey#2
System.out.println("[热点治理] Key " + key + " 已加入本地缓存并拆分副本");
}
private void resetCounter() {
System.out.println("[窗口滑动] 重置计数器,上轮热点 Key 数量: " + counter.size());
counter.clear();
}
public void shutdown() {
scheduler.shutdown();
}
public static void main(String[] args) {
HotKeyDetector detector = new HotKeyDetector();
String hotKey = "flash_sale:product_888";
// 模拟高并发访问
for (int i = 0; i < 1500; i++) {
boolean isHot = detector.recordAccess(hotKey);
if (isHot && i == 1499) {
System.out.println("第 " + i + " 次访问确认为热点");
}
}
detector.shutdown();
}
}
7.3 Hot Key 副本拆分方案
import java.util.Random;
/**
* 热点 Key 副本拆分:将单个 Key 拆分为 N 个副本,分散读压力
*/
public class HotKeySharding {
private static final int REPLICA_COUNT = 10;
private final Random random = new Random();
private final MockCache cache = new MockCache();
/**
* 写入热点数据:同时写入 N 个副本
*/
public void setHotData(String baseKey, Object value) {
for (int i = 0; i < REPLICA_COUNT; i++) {
String replicaKey = baseKey + "#" + i;
cache.set(replicaKey, value);
}
System.out.println("[HotKey 写入] " + baseKey + " 已拆分为 " + REPLICA_COUNT + " 个副本");
}
/**
* 读取热点数据:随机选择一个副本读取
*/
public Object getHotData(String baseKey) {
int index = random.nextInt(REPLICA_COUNT);
String replicaKey = baseKey + "#" + index;
Object value = cache.get(replicaKey);
System.out.println("[HotKey 读取] " + replicaKey + " -> " + (value != null ? "命中" : "未命中"));
return value;
}
static class MockCache {
private final Map<String, Object> data = new ConcurrentHashMap<>();
Object get(String k) { return data.get(k); }
void set(String k, Object v) { data.put(k, v); }
}
public static void main(String[] args) {
HotKeySharding sharding = new HotKeySharding();
sharding.setHotData("flash_sale:88", "秒杀商品-88");
// 模拟 1000 次并发读取,分散到 10 个副本
for (int i = 0; i < 1000; i++) {
sharding.getHotData("flash_sale:88");
}
}
}
8. 缓存一致性模式对比
8.1 Cache Aside vs Write Through vs Write Behind
| 维度 | Cache Aside | Write Through | Write Behind |
|---|---|---|---|
| 写流程 | 先写 DB,再删缓存 | 先写缓存,缓存同步写 DB | 先写缓存,异步批量写 DB |
| 读流程 | 先读缓存,未命中读 DB 并回填 | 直接读缓存,缓存必命中 | 直接读缓存,缓存必命中 |
| 一致性 | 最终一致(有脏读窗口) | 强一致 | 最终一致(最大延迟取决于刷盘频率) |
| 写延迟 | 低(仅 DB 写入) | 高(需等缓存+DB 双写) | 极低(仅内存写入) |
| 读延迟 | 低(命中时) | 极低(始终命中) | 极低(始终命中) |
| 实现复杂度 | 低 | 中(需事务保证) | 高(需队列+刷盘+故障恢复) |
| 适用场景 | 读多写少,允许短暂不一致 | 读写均衡,强一致需求 | 写多读少,高吞吐可容忍延迟 |
| 风险点 | 并发读写可能导致脏数据 | 写竞争锁可能拖慢性能 | 宕机丢数据,需 WAL/日志补偿 |
8.2 推荐的 Cache Aside 改进版(延迟双删)
/**
* Cache Aside 延迟双删策略
* 解决并发场景下:读线程在写线程删除缓存后、更新 DB 前读取旧数据并回填缓存的问题
*/
public void updateWithDelayedDoubleDelete(String key, Object newValue) {
// 第 1 次删除缓存
cache.delete(key);
// 更新数据库
database.update(key, newValue);
// 延迟 500ms 后第 2 次删除缓存
// 保证在这 500ms 内开始的读线程有足够时间读取旧数据并回填缓存
// 回填后第二次删除将其清理
scheduledExecutor.schedule(() -> {
cache.delete(key);
System.out.println("[延迟双删] 完成: " + key);
}, 500, TimeUnit.MILLISECONDS);
}
9. Redis vs Memcached 详细对比
| 对比维度 | Redis | Memcached |
|---|---|---|
| 数据结构 | String、List、Set、Hash、Sorted Set、Bitmap、HyperLogLog、Stream | 仅 Key-Value(二进制安全字符串) |
| 持久化 | RDB 快照 + AOF 日志,支持混合持久化 | 不支持持久化,纯内存存储 |
| 高可用 | 原生 Sentinel + Cluster,自动 failover | 无原生集群,依赖客户端分片或 Twemproxy |
| 复制 | 主从复制,支持级联复制 | 不支持复制 |
| 事务 | MULTI/EXEC,支持乐观锁(WATCH) | 不支持事务 |
| Lua 脚本 | 支持服务器端 Lua 脚本原子执行 | 不支持 |
| 内存管理 | 支持多种淘汰策略(LRU/LFU/TTL/Random) | 仅 LRU |
| 单 Value 大小 | 最大 512 MB | 默认最大 1 MB |
| 集群分片 | 原生 Cluster(Slot 分片) | 需第三方代理(如 Twemproxy) |
| 线程模型 | 单线程 IO 多路复用 | 多线程(主线程 accept + worker 线程处理) |
| 性能 | 极高(10w+ QPS 单节点),命令原子执行 | 极高(20w+ QPS),多核扩展性更好 |
| 适用场景 | 复杂数据结构、持久化、高可用、消息队列、分布式锁 | 纯缓存、简单 KV、超高吞吐、多核利用 |
| 协议 | RESP 二进制安全文本协议 | 基于文本的协议 |
| 内存碎片 | jemalloc 分配,可能产生碎片 | Slab 分配,减少碎片但可能浪费内存 |
10. 生产案例研究
10.1 电商秒杀系统:三级缓存与热点治理
某头部电商平台大促期间,单款秒杀商品 QPS 达到 50 万。架构团队设计了如下方案:
/**
* 秒杀系统三级缓存架构示意
*/
public class FlashSaleCacheStrategy {
// L1:JVM 级本地缓存(Caffeine),存储活动配置、库存布尔标记
private final Cache<String, Object> localCache = Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(1, TimeUnit.MINUTES)
.build();
// L2:Redis Cluster,存储详细库存数量、用户购买记录
private final JedisCluster redisCluster;
// L3:MySQL 或 TiDB,最终库存一致性校验与订单持久化
/**
* 查询秒杀商品:三级缓存穿透
*/
public FlashSaleProduct getFlashSaleProduct(String activityId) {
// L1:活动配置几乎不变,本地缓存命中率 99%+
Object local = localCache.getIfPresent(activityId);
if (local != null) return (FlashSaleProduct) local;
// L2:Redis 存储序列化后的商品信息
String json = redisCluster.get("flash:sale:" + activityId);
if (json != null) {
FlashSaleProduct product = JSON.parseObject(json, FlashSaleProduct.class);
localCache.put(activityId, product);
return product;
}
// L3:数据库兜底(实际大促前已完成预热,理论上不会走到这里)
return loadFromDB(activityId);
}
/**
* 扣减库存:Redis 原子 decr + 异步落库
*/
public boolean deductStock(String activityId, long userId) {
String stockKey = "flash:stock:" + activityId;
// Redis 原子扣减,避免并发超卖
long remain = redisCluster.decr(stockKey);
if (remain >= 0) {
// 扣减成功:发送 MQ 异步创建订单、落库
sendCreateOrderMessage(activityId, userId);
return true;
} else {
// 扣减失败:库存已空,将库存恢复为 0(避免负库存)
redisCluster.set(stockKey, "0");
return false;
}
}
/**
* 热点 Key 治理:活动开始前 5 分钟,将热点商品 Key 拆分为 100 个副本预热到各节点本地缓存
*/
public void preheatHotKeys(String activityId) {
for (int i = 0; i < 100; i++) {
String replicaKey = "flash:stock:" + activityId + "#" + i;
// 各副本均匀分布在 Redis Cluster 不同槽位与节点上
redisCluster.set(replicaKey, "10000");
}
System.out.println("[秒杀预热] 热点 Key 已拆分 100 副本并预热");
}
private FlashSaleProduct loadFromDB(String id) { return null; }
private void sendCreateOrderMessage(String activityId, long userId) {}
static class FlashSaleProduct {
String activityId;
long stock;
long startTime;
long endTime;
}
}
关键指标:
- L1 命中率:99.5%
- Redis 峰值 QPS:50 万(含副本拆分后)
- 数据库峰值 QPS:< 500(仅异步订单写入)
- 超卖率:0(Redis 原子操作保证)
10.2 金融交易系统:强一致缓存与延迟敏感
某证券行情系统对延迟要求极为苛刻(亚毫秒级),同时必须保证行情数据的强一致性。
import java.util.concurrent.*;
import java.util.concurrent.locks.*;
/**
* 金融行情缓存:强一致性 + 低延迟
* 采用 Write Through + 读写锁确保并发安全
*/
public class MarketDataCache {
// 内存双缓冲:当前缓存 + 写入缓存,交换时无锁
private volatile ConcurrentHashMap<String, MarketData> currentBuffer = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, MarketData> writeBuffer = new ConcurrentHashMap<>();
private final ReadWriteLock swapLock = new ReentrantReadWriteLock();
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
public MarketDataCache() {
// 每 100ms 交换一次缓冲区:将 writeBuffer 原子切换为 currentBuffer
scheduler.scheduleAtFixedRate(this::swapBuffers, 100, 100, TimeUnit.MILLISECONDS);
}
/**
* 行情更新:写入 writeBuffer,不阻塞读线程
*/
public void updateMarketData(String symbol, double price, long timestamp) {
MarketData data = new MarketData(symbol, price, timestamp);
writeBuffer.put(symbol, data);
}
/**
* 行情读取:读取 currentBuffer,无锁、极低延迟
*/
public MarketData getMarketData(String symbol) {
// 直接读 volatile 引用的当前缓冲区,无需锁
return currentBuffer.get(symbol);
}
/**
* 原子交换缓冲区
*/
private void swapBuffers() {
if (writeBuffer.isEmpty()) return;
swapLock.writeLock().lock();
try {
// 新缓冲区包含旧数据 + 增量更新
ConcurrentHashMap<String, MarketData> newBuffer = new ConcurrentHashMap<>(currentBuffer);
newBuffer.putAll(writeBuffer);
writeBuffer.clear();
// volatile 写:保证之后所有读线程立即可见新缓冲区
currentBuffer = newBuffer;
} finally {
swapLock.writeLock().unlock();
}
}
static class MarketData {
final String symbol;
final double price;
final long timestamp;
MarketData(String symbol, double price, long timestamp) {
this.symbol = symbol;
this.price = price;
this.timestamp = timestamp;
}
}
public static void main(String[] args) {
MarketDataCache cache = new MarketDataCache();
// 生产者:高频更新行情
ScheduledExecutorService producer = Executors.newScheduledThreadPool(2);
producer.scheduleAtFixedRate(() -> cache.updateMarketData("AAPL", 150 + Math.random(), System.nanoTime()),
0, 1, TimeUnit.MILLISECONDS);
// 消费者:高频读取行情
producer.scheduleAtFixedRate(() -> {
MarketData data = cache.getMarketData("AAPL");
if (data != null) {
System.out.println("AAPL Price: " + data.price + " @ " + data.timestamp);
}
}, 0, 1, TimeUnit.MILLISECONDS);
}
}
架构要点:
- 行情更新不走 Redis(延迟不可控),直接走内存双缓冲
- Redis 仅作为副本同步与历史数据回放使用
- 读写完全分离:读无锁,写有锁但仅操作 writeBuffer
- 100ms 交换周期保证读写一致性窗口可控
FAQ
Q1:Redis Cluster 的 16384 个槽位为什么是 16384 而不是 65536?
16384 = 2^14,是 Redis 在心跳包大小与槽位数量之间权衡的结果。每个槽位用 1 bit 表示,16384 个槽位需要 2048 字节;若使用 65536 则需要 8192 字节,而 Redis 的心跳包设计限制了其大小不宜超过 8KB(含其他元数据)。16384 足以支撑数百个节点的集群,且槽位迁移粒度适中。
Q2:一致性哈希与 Redis Cluster 的哈希槽分片有什么区别?
一致性哈希将节点映射到 hash 环,数据通过顺时针查找最近节点定位,节点变更时仅影响相邻区间数据。Redis Cluster 则采用固定 16384 个槽位,先算 key 属于哪个槽,再查槽到节点的映射表。Cluster 的槽迁移更灵活(可以只迁移一个槽),且通过 Gossip 协议全局同步映射表;一致性哈希更适用于客户端分片或代理层(如 Twemproxy)。
Q3:布隆过滤器的误判率如何降低?如何删除元素?
降低误判率的方法:增大位数组长度(m)、增加哈希函数数量(k)或降低插入元素数量(n)。三者满足最优关系 k = (m/n) * ln2。布隆过滤器不支持直接删除,因为删除一个元素的某个 bit 可能影响其他元素。若需删除能力,可使用计数布隆过滤器(Counting Bloom Filter):位数组的每个 bit 扩展为多个 bit 的计数器。
Q4:多级缓存中 Caffeine L1 和 Redis L2 的数据不一致如何解决?
核心策略有三:(1)写操作先删缓存再写 DB,采用延迟双删策略;(2)设置合理的 TTL:L1 TTL 短于 L2,让不一致自然收敛;(3)消息通知:通过 Redis Pub/Sub 或 Canal 监听 MySQL binlog,在数据变更时广播通知各节点清理 L1。对于强一致场景,可禁用 L1 或采用本地锁保证串行化。
Q5:缓存击穿与缓存雪崩的防护策略可以同时使用吗?
完全可以且推荐组合使用。实际生产环境通常采用分层防御:
- 针对热点 Key(击穿):逻辑过期 + 互斥锁
- 针对批量过期(雪崩):随机 TTL + 熔断降级 + 多级缓存
- 针对缓存层整体故障:熔断器 + 降级到数据库限流 + 预热恢复
不同层次的保护应对不同的故障模式,组合使用才能构建真正健壮的缓存体系。
总结
分布式缓存的设计是一项系统工程,需要在性能、一致性、可用性之间反复权衡。本文从 Redis Cluster 的底层架构出发,覆盖了一致性哈希的算法实现、缓存三灾(穿透/击穿/雪崩)的工程化防护、Caffeine + Redis 多级缓存的性能优化、Hot Key 的实时检测与副本治理、以及缓存一致性模式的选型方法论。
在生产落地时,建议遵循以下原则:
- 默认开启缓存:读多写少的场景优先使用 Cache Aside 模式
- 防御性设计:任何缓存系统都必须同时考虑穿透、击穿、雪崩的联合防护
- 监控先于优化:通过 Redis INFO、Slow Log、Caffeine Stats 和自定义埋点建立完整观测体系
- 渐进式多级缓存:不要一次性引入 L1 + L2 + L3,先验证 L2 收益,再按需叠加 L1
- Hot Key 预案:大促前通过历史数据预测热点,提前完成 Key 拆分与本地预热
缓存不是万金油,但在正确的架构设计下,它能让系统的吞吐能力提升一到两个数量级,同时将数据库从崩溃边缘拯救回来。希望本文的代码示例与策略分析能为你的分布式缓存建设提供可直接落地的工程参考。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。