分布式缓存架构详解
分布式缓存是现代互联网应用的核心组件,用于提升读性能、削峰填谷与降低后端压力。本章将详细介绍分布式缓存的架构设计、一致性策略、性能优化和最佳实践。
- 性能提升:将热点数据存储在内存中,大幅提升访问速度
- 压力缓解:减少对数据库的直接访问,降低数据库压力
- 成本优化:通过缓存减少昂贵的数据库查询和计算
- 用户体验:提升系统响应速度,改善用户体验
1. 缓存架构设计
1.1 缓存分类
分布式缓存按照部署位置和访问方式可以分为以下几类:
本地缓存(Local Cache)
- 特点:进程内缓存,访问速度极快,容量有限
- 实现:Guava Caches、Caffeine、Ehcache
- 适用场景:高频访问的配置信息、计算结果
1public class LocalCacheExample {2 // 使用Caffeine构建高性能本地缓存3 private final Cache<String, User> userCache = Caffeine.newBuilder()4 .maximumSize(10_000) // 最大容量5 .expireAfterWrite(1, TimeUnit.HOURS) // 写入后1小时过期6 .expireAfterAccess(30, TimeUnit.MINUTES) // 访问后30分钟过期7 .recordStats() // 记录统计信息8 .build();9 10 public User getUser(String userId) {11 return userCache.get(userId, key -> {12 // 缓存未命中时的加载逻辑13 return loadUserFromDatabase(key);14 });15 }16 17 private User loadUserFromDatabase(String userId) {18 // 从数据库加载用户信息19 return userRepository.findById(userId);20 }21}远程缓存(Remote Cache)
- 特点:集中管理的分布式缓存,容量大,支持集群
- 实现:Redis、Memcached、Hazelcast
- 适用场景:跨服务共享数据、大规模数据缓存
1@Service2public class RedisCacheService {3 @Autowired4 private RedisTemplate<String, Object> redisTemplate;5 6 public User getUser(String userId) {7 String key = "user:" + userId;8 9 // 先从缓存获取10 User user = (User) redisTemplate.opsForValue().get(key);11 if (user != null) {12 return user;13 }14 15 // 缓存未命中,从数据库加载16 user = userRepository.findById(userId);17 if (user != null) {18 // 设置缓存,过期时间30分钟19 redisTemplate.opsForValue().set(key, user, 30, TimeUnit.MINUTES);20 }21 22 return user;23 }24}多级缓存(Multi-Level Cache)
- 特点:本地缓存 + 远程缓存的组合,形成缓存层次
- 优势:结合本地缓存的快速访问和远程缓存的大容量
- 挑战:缓存一致性的保证
1public class MultiLevelCache {2 private final Cache<String, Object> localCache; // 本地缓存3 private final RedisTemplate<String, Object> redisCache; // 远程缓存4 5 public Object get(String key) {6 // 第一级:本地缓存7 Object value = localCache.getIfPresent(key);8 if (value != null) {9 return value;10 }11 12 // 第二级:远程缓存13 value = redisCache.opsForValue().get(key);14 if (value != null) {15 // 回填本地缓存16 localCache.put(key, value);17 return value;18 }19 20 // 第三级:数据库21 value = loadFromDatabase(key);22 if (value != null) {23 // 同时更新两级缓存24 redisCache.opsForValue().set(key, value, 30, TimeUnit.MINUTES);25 localCache.put(key, value);26 }27 28 return value;29 }30}1.2 缓存读写模式
Cache-Aside模式(旁路缓存)
- 原理:应用程序直接管理缓存,先查缓存,未命中则查数据库
- 优点:实现简单,缓存策略灵活
- 缺点:需要手动管理缓存一致性
1@Service2public class CacheAsideService {3 4 public User getUser(String userId) {5 // 1. 先查缓存6 String cacheKey = "user:" + userId;7 User user = (User) redisTemplate.opsForValue().get(cacheKey);8 9 if (user != null) {10 return user;11 }12 13 // 2. 缓存未命中,查数据库14 user = userRepository.findById(userId);15 16 if (user != null) {17 // 3. 更新缓存18 redisTemplate.opsForValue().set(cacheKey, user, 30, TimeUnit.MINUTES);19 }20 21 return user;22 }23 24 public void updateUser(User user) {25 // 1. 更新数据库26 userRepository.save(user);27 28 // 2. 删除缓存(Cache-Aside的写策略)29 String cacheKey = "user:" + user.getId();30 redisTemplate.delete(cacheKey);31 }32}Read/Write-Through模式(读写穿透)
- 原理:缓存作为数据库的代理,所有读写都经过缓存
- 优点:缓存一致性容易保证
- 缺点:增加了系统复杂性
1@Service2public class WriteThroughService {3 4 public User getUser(String userId) {5 // 直接通过缓存读取,缓存负责与数据库交互6 return cacheManager.getUser(userId);7 }8 9 public void updateUser(User user) {10 // 直接写入缓存,缓存负责同步到数据库11 cacheManager.updateUser(user);12 }13}Write-Behind模式(异步写入)
- 原理:写操作先更新缓存,然后异步批量写入数据库
- 优点:写入性能高,减少数据库压力
- 缺点:数据可能丢失,一致性较弱
1@Service2public class WriteBehindService {3 private final Queue<WriteOperation> writeQueue = new ConcurrentLinkedQueue<>();4 5 public void updateUser(User user) {6 // 1. 立即更新缓存7 String cacheKey = "user:" + user.getId();8 redisTemplate.opsForValue().set(cacheKey, user);9 10 // 2. 异步写入数据库11 writeQueue.offer(new WriteOperation(user));12 }13 14 @Scheduled(fixedRate = 5000) // 每5秒批量写入15 public void batchWrite() {16 List<WriteOperation> operations = new ArrayList<>();17 WriteOperation op;18 19 // 收集待写入的操作20 while ((op = writeQueue.poll()) != null) {21 operations.add(op);22 }23 24 // 批量写入数据库25 if (!operations.isEmpty()) {26 userRepository.saveAll(operations.stream()27 .map(WriteOperation::getUser)28 .collect(Collectors.toList()));29 }30 }31}2. 缓存策略与优化
2.1 缓存键设计
良好的缓存键设计是缓存系统成功的关键因素之一。
键命名规范
- 层级结构:使用冒号分隔,形成层级关系
- 业务标识:包含业务模块、实体类型、ID等信息
- 版本控制:支持缓存版本升级和批量失效
1public class CacheKeyGenerator {2 3 // 用户信息缓存键4 public static String userKey(String userId) {5 return String.format("user:info:%s", userId);6 }7 8 // 用户权限缓存键9 public static String userPermissionKey(String userId) {10 return String.format("user:permission:%s", userId);11 }12 13 // 商品详情缓存键14 public static String productKey(String productId) {15 return String.format("product:detail:%s", productId);16 }17 18 // 商品列表缓存键(支持分页)19 public static String productListKey(int page, int size, String category) {20 return String.format("product:list:%s:%d:%d", category, page, size);21 }22 23 // 带版本的缓存键24 public static String versionedKey(String baseKey, String version) {25 return String.format("%s:v%s", baseKey, version);26 }27}键长度优化
1public class CompactCacheKeyGenerator {2 3 // 使用短前缀4 private static final String USER_PREFIX = "u";5 private static final String PRODUCT_PREFIX = "p";6 private static final String ORDER_PREFIX = "o";7 8 // 压缩的键格式9 public static String compactUserKey(String userId) {10 return String.format("%s:%s", USER_PREFIX, userId);11 }12 13 public static String compactProductKey(String productId) {14 return String.format("%s:%s", PRODUCT_PREFIX, productId);15 }16 17 // 使用哈希值减少键长度18 public static String hashedKey(String originalKey) {19 return String.valueOf(originalKey.hashCode());20 }21}2.2 过期策略
TTL(Time To Live)策略
- 固定TTL:所有缓存项使用相同的过期时间
- 动态TTL:根据数据特性设置不同的过期时间
- 随机抖动:避免缓存雪崩,错峰过期
1public class TTLStrategy {2 3 // 固定TTL4 public static final Duration DEFAULT_TTL = Duration.ofMinutes(30);5 6 // 动态TTL策略7 public static Duration getTTL(String key, Object value) {8 if (key.startsWith("user:")) {9 return Duration.ofHours(1); // 用户信息1小时10 } else if (key.startsWith("product:")) {11 return Duration.ofMinutes(10); // 商品信息10分钟12 } else if (key.startsWith("config:")) {13 return Duration.ofDays(1); // 配置信息1天14 }15 return DEFAULT_TTL;16 }17 18 // 随机抖动TTL19 public static Duration getRandomizedTTL(Duration baseTTL) {20 long baseSeconds = baseTTL.getSeconds();21 long randomOffset = (long) (baseSeconds * 0.1 * Math.random()); // 10%随机偏移22 return Duration.ofSeconds(baseSeconds + randomOffset);23 }24 25 // 基于访问频率的TTL26 public static Duration getFrequencyBasedTTL(int accessCount) {27 if (accessCount > 1000) {28 return Duration.ofHours(2); // 高频访问,延长TTL29 } else if (accessCount > 100) {30 return Duration.ofMinutes(30); // 中频访问,标准TTL31 } else {32 return Duration.ofMinutes(5); // 低频访问,短TTL33 }34 }35}2.3 热点保护策略
互斥锁保护
防止缓存击穿,确保同一时间只有一个线程重建缓存。
1@Service2public class MutexCacheService {3 private final Map<String, ReentrantLock> locks = new ConcurrentHashMap<>();4 5 public User getUser(String userId) {6 String cacheKey = "user:" + userId;7 8 // 先查缓存9 User user = (User) redisTemplate.opsForValue().get(cacheKey);10 if (user != null) {11 return user;12 }13 14 // 获取该key的互斥锁15 ReentrantLock lock = locks.computeIfAbsent(cacheKey, k -> new ReentrantLock());16 17 try {18 lock.lock();19 20 // 双重检查,防止在获取锁期间其他线程已经重建了缓存21 user = (User) redisTemplate.opsForValue().get(cacheKey);22 if (user != null) {23 return user;24 }25 26 // 重建缓存27 user = userRepository.findById(userId);28 if (user != null) {29 redisTemplate.opsForValue().set(cacheKey, user, 30, TimeUnit.MINUTES);30 }31 32 return user;33 } finally {34 lock.unlock();35 }36 }37}SingleFlight模式
使用SingleFlight模式,确保同一时间只有一个请求重建缓存,其他请求等待结果。
1@Service2public class SingleFlightCacheService {3 private final Map<String, CompletableFuture<User>> flightMap = new ConcurrentHashMap<>();4 5 public User getUser(String userId) {6 String cacheKey = "user:" + userId;7 8 // 先查缓存9 User user = (User) redisTemplate.opsForValue().get(cacheKey);10 if (user != null) {11 return user;12 }13 14 // 检查是否已有重建任务在进行15 CompletableFuture<User> future = flightMap.get(cacheKey);16 if (future != null) {17 try {18 return future.get(5, TimeUnit.SECONDS); // 等待结果19 } catch (Exception e) {20 // 超时或异常,移除future并重新尝试21 flightMap.remove(cacheKey);22 }23 }24 25 // 创建新的重建任务26 future = CompletableFuture.supplyAsync(() -> {27 try {28 User loadedUser = userRepository.findById(userId);29 if (loadedUser != null) {30 redisTemplate.opsForValue().set(cacheKey, loadedUser, 30, TimeUnit.MINUTES);31 }32 return loadedUser;33 } finally {34 // 任务完成后移除future35 flightMap.remove(cacheKey);36 }37 });38 39 flightMap.put(cacheKey, future);40 41 try {42 return future.get(5, TimeUnit.SECONDS);43 } catch (Exception e) {44 flightMap.remove(cacheKey);45 throw new RuntimeException("Failed to load user", e);46 }47 }48}2.4 缓存一致性策略
失效策略(Cache-Aside)
写操作时删除缓存,读操作时重建缓存。
1@Service2public class InvalidationStrategy {3 4 public void updateUser(User user) {5 // 1. 更新数据库6 userRepository.save(user);7 8 // 2. 删除缓存9 String cacheKey = "user:" + user.getId();10 redisTemplate.delete(cacheKey);11 12 // 3. 可选:延迟双删,避免并发问题13 scheduleDelayedDelete(cacheKey, 100); // 100ms后再次删除14 }15 16 private void scheduleDelayedDelete(String key, long delayMs) {17 CompletableFuture.runAsync(() -> {18 try {19 Thread.sleep(delayMs);20 redisTemplate.delete(key);21 } catch (InterruptedException e) {22 Thread.currentThread().interrupt();23 }24 });25 }26}更新策略
写操作时同时更新缓存和数据库。
1@Service2public class UpdateStrategy {3 4 @Transactional5 public void updateUser(User user) {6 // 1. 更新数据库7 userRepository.save(user);8 9 // 2. 更新缓存10 String cacheKey = "user:" + user.getId();11 redisTemplate.opsForValue().set(cacheKey, user, 30, TimeUnit.MINUTES);12 }13 14 // 使用分布式锁保证更新原子性15 public void updateUserWithLock(User user) {16 String lockKey = "lock:user:" + user.getId();17 String cacheKey = "user:" + user.getId();18 19 try {20 // 获取分布式锁21 if (acquireLock(lockKey, 10, TimeUnit.SECONDS)) {22 // 更新数据库23 userRepository.save(user);24 25 // 更新缓存26 redisTemplate.opsForValue().set(cacheKey, user, 30, TimeUnit.MINUTES);27 }28 } finally {29 // 释放锁30 releaseLock(lockKey);31 }32 }33}1# Redis淘汰策略(redis.conf)2maxmemory 1gb3maxmemory-policy allkeys-lru缓存一致性
- 最终一致:写库后删缓存(或更新缓存),允许短暂不一致
- 强一致:写入链路串行化(先删后写 + 同步更新),成本高
- 订阅通知:通过发布订阅/流通知各副本失效
双删示例:
1public void updateThenEvict(User user) {2 // 先更新DB3 userRepository.save(user);4 // 删缓存5 redisTemplate.delete("user:" + user.getId());6 // 延迟双删,避免并发读写穿透旧值7 java.util.concurrent.CompletableFuture.runAsync(() -> {8 try { Thread.sleep(300); } catch (InterruptedException ignored) {}9 redisTemplate.delete("user:" + user.getId());10 });11}3. 缓存问题与解决方案
3.1 缓存穿透(Cache Penetration)
缓存穿透是指查询一个不存在的数据,由于缓存中没有,每次请求都会打到数据库。
问题分析
解决方案
1. 布隆过滤器
1@Service2public class BloomFilterCacheService {3 private final BloomFilter<String> bloomFilter;4 private final RedisTemplate<String, Object> redisTemplate;5 6 public BloomFilterCacheService() {7 // 创建布隆过滤器,预期元素数量100万,误判率0.018 this.bloomFilter = BloomFilter.create(9 Funnels.stringFunnel(Charset.defaultCharset()), 10 1_000_000, 11 0.0112 );13 }14 15 public User getUser(String userId) {16 // 1. 布隆过滤器检查17 if (!bloomFilter.mightContain(userId)) {18 return null; // 肯定不存在19 }20 21 // 2. 查询缓存22 String cacheKey = "user:" + userId;23 User user = (User) redisTemplate.opsForValue().get(cacheKey);24 if (user != null) {25 return user;26 }27 28 // 3. 查询数据库29 user = userRepository.findById(userId);30 31 if (user != null) {32 // 4. 更新缓存和布隆过滤器33 redisTemplate.opsForValue().set(cacheKey, user, 30, TimeUnit.MINUTES);34 bloomFilter.put(userId);35 } else {36 // 5. 缓存空值,防止穿透37 redisTemplate.opsForValue().set(cacheKey, null, 5, TimeUnit.MINUTES);38 }39 40 return user;41 }42}2. 缓存空值
1@Service2public class NullValueCacheService {3 4 public User getUser(String userId) {5 String cacheKey = "user:" + userId;6 7 // 查询缓存8 Object cached = redisTemplate.opsForValue().get(cacheKey);9 if (cached != null) {10 // 检查是否是空值标记11 if (cached instanceof NullValue) {12 return null;13 }14 return (User) cached;15 }16 17 // 查询数据库18 User user = userRepository.findById(userId);19 20 if (user != null) {21 // 缓存用户数据22 redisTemplate.opsForValue().set(cacheKey, user, 30, TimeUnit.MINUTES);23 } else {24 // 缓存空值标记,短TTL25 redisTemplate.opsForValue().set(cacheKey, new NullValue(), 5, TimeUnit.MINUTES);26 }27 28 return user;29 }30 31 // 空值标记类32 private static class NullValue {33 // 用于标识空值的标记类34 }35}3.2 缓存击穿(Cache Breakdown)
缓存击穿是指热点key在缓存过期的一瞬间,大量并发请求直接打到数据库。
问题分析
解决方案
1. 互斥锁保护
1@Service2public class MutexLockCacheService {3 private final Map<String, ReentrantLock> locks = new ConcurrentHashMap<>();4 5 public User getHotUser(String userId) {6 String cacheKey = "user:" + userId;7 8 // 先查缓存9 User user = (User) redisTemplate.opsForValue().get(cacheKey);10 if (user != null) {11 return user;12 }13 14 // 获取该key的互斥锁15 ReentrantLock lock = locks.computeIfAbsent(cacheKey, k -> new ReentrantLock());16 17 try {18 // 尝试获取锁,设置超时时间19 if (lock.tryLock(3, TimeUnit.SECONDS)) {20 try {21 // 双重检查22 user = (User) redisTemplate.opsForValue().get(cacheKey);23 if (user != null) {24 return user;25 }26 27 // 重建缓存28 user = userRepository.findById(userId);29 if (user != null) {30 redisTemplate.opsForValue().set(cacheKey, user, 30, TimeUnit.MINUTES);31 }32 33 return user;34 } finally {35 lock.unlock();36 }37 } else {38 // 获取锁失败,等待一段时间后重试39 Thread.sleep(100);40 return getHotUser(userId);41 }42 } catch (InterruptedException e) {43 Thread.currentThread().interrupt();44 throw new RuntimeException("Interrupted while waiting for lock", e);45 }46 }47}2. 逻辑永不过期
1@Service2public class LogicalExpirationCacheService {3 4 public User getHotUser(String userId) {5 String cacheKey = "user:" + userId;6 7 // 查询缓存8 CacheEntry<User> entry = (CacheEntry<User>) redisTemplate.opsForValue().get(cacheKey);9 10 if (entry != null && !entry.isExpired()) {11 return entry.getData();12 }13 14 // 缓存过期或不存在,异步重建15 if (entry == null || entry.isExpired()) {16 CompletableFuture.runAsync(() -> {17 try {18 User user = userRepository.findById(userId);19 if (user != null) {20 // 设置逻辑过期时间21 CacheEntry<User> newEntry = new CacheEntry<>(user, 22 System.currentTimeMillis() + 30 * 60 * 1000); // 30分钟后逻辑过期23 redisTemplate.opsForValue().set(cacheKey, newEntry);24 }25 } catch (Exception e) {26 log.error("Failed to rebuild cache for user: " + userId, e);27 }28 });29 }30 31 // 返回旧数据(如果存在)32 return entry != null ? entry.getData() : null;33 }34 35 // 缓存条目类36 private static class CacheEntry<T> {37 private final T data;38 private final long expireTime;39 40 public CacheEntry(T data, long expireTime) {41 this.data = data;42 this.expireTime = expireTime;43 }44 45 public T getData() { return data; }46 public boolean isExpired() { return System.currentTimeMillis() > expireTime; }47 }48}3.3 缓存雪崩(Cache Avalanche)
缓存雪崩是指大量缓存同时过期,导致大量请求直接打到数据库。
问题分析
解决方案
1. TTL随机抖动
1public class RandomTTLStrategy {2 3 public static Duration getRandomizedTTL(Duration baseTTL) {4 long baseSeconds = baseTTL.getSeconds();5 // 在基础TTL基础上增加随机偏移,避免同时过期6 long randomOffset = (long) (baseSeconds * 0.2 * Math.random()); // 20%随机偏移7 return Duration.ofSeconds(baseSeconds + randomOffset);8 }9 10 public void setCacheWithRandomTTL(String key, Object value) {11 Duration ttl = getRandomizedTTL(Duration.ofMinutes(30));12 redisTemplate.opsForValue().set(key, value, ttl);13 }14}2. 缓存预热
1@Service2public class CacheWarmupService {3 4 @PostConstruct5 public void warmupCache() {6 // 系统启动时预热热点数据7 List<String> hotUserIds = getHotUserIds();8 9 for (String userId : hotUserIds) {10 CompletableFuture.runAsync(() -> {11 try {12 User user = userRepository.findById(userId);13 if (user != null) {14 String cacheKey = "user:" + userId;15 Duration ttl = getRandomizedTTL(Duration.ofMinutes(30));16 redisTemplate.opsForValue().set(cacheKey, user, ttl);17 }18 } catch (Exception e) {19 log.error("Failed to warmup cache for user: " + userId, e);20 }21 });22 }23 }24 25 private List<String> getHotUserIds() {26 // 获取热点用户ID列表27 return Arrays.asList("user1", "user2", "user3", "user4", "user5");28 }29}3. 熔断降级
1@Service2public class CircuitBreakerCacheService {3 private final CircuitBreaker circuitBreaker;4 5 public CircuitBreakerCacheService() {6 this.circuitBreaker = CircuitBreaker.builder()7 .failureRateThreshold(50) // 失败率阈值50%8 .waitDurationInOpenState(Duration.ofSeconds(10)) // 熔断时间10秒9 .ringBufferSizeInHalfOpenState(2) // 半开状态下的请求数10 .ringBufferSizeInClosedState(10) // 关闭状态下的请求数11 .build();12 }13 14 public User getUser(String userId) {15 return circuitBreaker.executeSupplier(() -> {16 String cacheKey = "user:" + userId;17 18 // 先查缓存19 User user = (User) redisTemplate.opsForValue().get(cacheKey);20 if (user != null) {21 return user;22 }23 24 // 缓存未命中,查询数据库25 user = userRepository.findById(userId);26 if (user != null) {27 redisTemplate.opsForValue().set(cacheKey, user, 30, TimeUnit.MINUTES);28 }29 30 return user;31 });32 }33}逻辑永不过期:
1class CacheEntry`<T>` { T data; long expireAt; }2public CacheEntry`<User>` getUser(long id) {3 CacheEntry`<User>` entry = localCache.get(id);4 if (entry != null && System.currentTimeMillis() < entry.expireAt) {5 return entry; // 命中新鲜数据6 }7 // 过期则触发后台刷新,但继续返回旧值(若存在)8 backgroundRefresh(id);9 return entry; // 旧值容忍10}4. 缓存监控与运维
4.1 关键监控指标
性能指标
- 命中率(Hit Rate):缓存命中次数 / 总请求次数
- 平均延迟(Average Latency):缓存操作的平均响应时间
- P99延迟:99%请求的响应时间
- 失败率(Error Rate):缓存操作失败的比例
容量指标
- 内存使用率:缓存占用的内存比例
- 连接数:当前活跃的客户端连接数
- 带宽使用:网络传输的数据量
业务指标
- 热点Key排行:访问频率最高的缓存键
- 慢查询:响应时间超过阈值的查询
- 大Key:占用内存较大的缓存项
1@Service2public class CacheMonitorService {3 4 @EventListener5 public void onCacheEvent(CacheEvent event) {6 // 记录缓存事件7 recordCacheEvent(event);8 9 // 更新监控指标10 updateMetrics(event);11 }12 13 private void recordCacheEvent(CacheEvent event) {14 // 记录缓存命中/未命中15 if (event.getType() == CacheEventType.HIT) {16 hitCounter.increment();17 } else if (event.getType() == CacheEventType.MISS) {18 missCounter.increment();19 }20 21 // 记录响应时间22 latencyHistogram.record(event.getLatency());23 }24 25 private void updateMetrics(CacheEvent event) {26 // 更新热点Key统计27 hotKeyCounter.increment(event.getKey());28 29 // 检查大Key30 if (event.getValueSize() > 1024 * 1024) { // 1MB31 bigKeyAlert.alert(event.getKey(), event.getValueSize());32 }33 }34 35 // 获取缓存命中率36 public double getHitRate() {37 long hits = hitCounter.get();38 long misses = missCounter.get();39 long total = hits + misses;40 return total > 0 ? (double) hits / total : 0.0;41 }42 43 // 获取P99延迟44 public long getP99Latency() {45 return latencyHistogram.getSnapshot().get99thPercentile();46 }47}4.2 告警策略
1@Component2public class CacheAlertService {3 4 @Scheduled(fixedRate = 60000) // 每分钟检查一次5 public void checkCacheHealth() {6 // 检查命中率7 double hitRate = cacheMonitorService.getHitRate();8 if (hitRate < 0.8) { // 命中率低于80%9 alertService.sendAlert("Cache hit rate is low: " + hitRate);10 }11 12 // 检查延迟13 long p99Latency = cacheMonitorService.getP99Latency();14 if (p99Latency > 100) { // P99延迟超过100ms15 alertService.sendAlert("Cache P99 latency is high: " + p99Latency + "ms");16 }17 18 // 检查内存使用率19 double memoryUsage = cacheMonitorService.getMemoryUsage();20 if (memoryUsage > 0.9) { // 内存使用率超过90%21 alertService.sendAlert("Cache memory usage is high: " + memoryUsage);22 }23 }24}4.3 运维最佳实践
容量规划
1public class CapacityPlanningService {2 3 public CacheConfig calculateOptimalConfig(int expectedQPS, int dataSize) {4 // 根据预期QPS和数据大小计算最优配置5 int optimalConnections = Math.max(10, expectedQPS / 1000);6 long optimalMemory = dataSize * 2; // 预留2倍空间7 8 return CacheConfig.builder()9 .maxConnections(optimalConnections)10 .maxMemory(optimalMemory)11 .build();12 }13 14 public void scaleCache(int currentQPS, int targetQPS) {15 // 根据QPS变化自动扩缩容16 if (targetQPS > currentQPS * 1.5) {17 // 扩容18 scaleUp();19 } else if (targetQPS < currentQPS * 0.5) {20 // 缩容21 scaleDown();22 }23 }24}5. 分布式缓存面试题精选
5.1 基础概念
Q1: 什么是分布式缓存?它的主要作用是什么?
答: 分布式缓存是一种将数据存储在多个节点上的缓存系统,用于提升系统性能和可扩展性。
主要作用:
- 性能提升:将热点数据存储在内存中,大幅提升访问速度
- 压力缓解:减少对数据库的直接访问,降低数据库压力
- 高可用性:通过多节点部署提高系统可用性
- 水平扩展:支持动态添加节点来提升容量和性能
Q2: 本地缓存和分布式缓存的区别是什么?
答:
| 特性 | 本地缓存 | 分布式缓存 |
|---|---|---|
| 部署位置 | 应用进程内 | 独立服务器 |
| 访问速度 | 极快(内存访问) | 较快(网络访问) |
| 容量 | 有限(受JVM内存限制) | 大(可扩展) |
| 一致性 | 难以保证 | 相对容易保证 |
| 适用场景 | 高频访问的配置信息 | 跨服务共享数据 |
5.2 缓存策略
Q3: Cache-Aside、Write-Through、Write-Behind三种缓存模式的区别是什么?
答:
-
Cache-Aside(旁路缓存):
- 应用程序直接管理缓存
- 读:先查缓存,未命中则查数据库并更新缓存
- 写:先更新数据库,再删除缓存
- 优点:实现简单,缓存策略灵活
- 缺点:需要手动管理缓存一致性
-
Write-Through(读写穿透):
- 缓存作为数据库的代理
- 所有读写都经过缓存
- 优点:缓存一致性容易保证
- 缺点:增加了系统复杂性
-
Write-Behind(异步写入):
- 写操作先更新缓存,然后异步批量写入数据库
- 优点:写入性能高,减少数据库压力
- 缺点:数据可能丢失,一致性较弱
Q4: 如何解决缓存穿透问题?
答: 缓存穿透是指查询不存在的数据,每次请求都打到数据库。
解决方案:
- 布隆过滤器:快速判断数据是否存在,避免无效查询
- 缓存空值:将空结果也缓存,设置较短的TTL
- 参数校验:在应用层进行参数校验,过滤无效请求
- 限流措施:对异常请求进行限流
1public User getUser(String userId) {2 // 1. 布隆过滤器检查3 if (!bloomFilter.mightContain(userId)) {4 return null; // 肯定不存在5 }6 7 // 2. 查询缓存8 User user = (User) redisTemplate.opsForValue().get("user:" + userId);9 if (user != null) {10 return user;11 }12 13 // 3. 查询数据库14 user = userRepository.findById(userId);15 if (user != null) {16 redisTemplate.opsForValue().set("user:" + userId, user, 30, TimeUnit.MINUTES);17 } else {18 // 缓存空值,防止穿透19 redisTemplate.opsForValue().set("user:" + userId, null, 5, TimeUnit.MINUTES);20 }21 22 return user;23}5.3 缓存问题
Q5: 如何解决缓存击穿问题?
答: 缓存击穿是指热点key在缓存过期的一瞬间,大量并发请求直接打到数据库。
解决方案:
- 互斥锁:确保同一时间只有一个线程重建缓存
- SingleFlight模式:确保同一时间只有一个请求重建缓存,其他请求等待结果
- 逻辑永不过期:设置逻辑过期时间,异步重建缓存
- 预热机制:在缓存过期前主动重建缓存
1public User getHotUser(String userId) {2 String cacheKey = "user:" + userId;3 4 // 先查缓存5 User user = (User) redisTemplate.opsForValue().get(cacheKey);6 if (user != null) {7 return user;8 }9 10 // 获取该key的互斥锁11 ReentrantLock lock = locks.computeIfAbsent(cacheKey, k -> new ReentrantLock());12 13 try {14 lock.lock();15 16 // 双重检查17 user = (User) redisTemplate.opsForValue().get(cacheKey);18 if (user != null) {19 return user;20 }21 22 // 重建缓存23 user = userRepository.findById(userId);24 if (user != null) {25 redisTemplate.opsForValue().set(cacheKey, user, 30, TimeUnit.MINUTES);26 }27 28 return user;29 } finally {30 lock.unlock();31 }32}Q6: 如何解决缓存雪崩问题?
答: 缓存雪崩是指大量缓存同时过期,导致大量请求直接打到数据库。
解决方案:
- TTL随机抖动:在基础TTL基础上增加随机偏移,避免同时过期
- 缓存预热:系统启动时主动加载热点数据到缓存
- 熔断降级:当数据库压力过大时,启用熔断机制
- 多级缓存:使用本地缓存作为最后一道防线
1public static Duration getRandomizedTTL(Duration baseTTL) {2 long baseSeconds = baseTTL.getSeconds();3 // 在基础TTL基础上增加随机偏移,避免同时过期4 long randomOffset = (long) (baseSeconds * 0.2 * Math.random()); // 20%随机偏移5 return Duration.ofSeconds(baseSeconds + randomOffset);6}5.4 高级问题
Q7: 双删策略为什么要延迟?延迟多长时间合适?
答: 双删策略是为了解决缓存一致性问题,延迟删除是为了避免并发问题。
原因:
- 并发问题:在删除缓存和更新数据库之间,可能有其他线程读取到旧数据并更新缓存
- 时序问题:延迟删除可以确保在并发操作完成后再次清理缓存
延迟时间:
- 一般设置为100-500ms
- 需要根据业务场景和系统负载调整
- 可以通过监控和测试确定最优值
1public void updateUser(User user) {2 // 1. 更新数据库3 userRepository.save(user);4 5 // 2. 删除缓存6 String cacheKey = "user:" + user.getId();7 redisTemplate.delete(cacheKey);8 9 // 3. 延迟双删,避免并发问题10 scheduleDelayedDelete(cacheKey, 100); // 100ms后再次删除11}1213private void scheduleDelayedDelete(String key, long delayMs) {14 CompletableFuture.runAsync(() -> {15 try {16 Thread.sleep(delayMs);17 redisTemplate.delete(key);18 } catch (InterruptedException e) {19 Thread.currentThread().interrupt();20 }21 });22}Q8: 多级缓存一致性如何保证?
答: 多级缓存一致性是一个复杂的问题,需要综合考虑性能和一致性。
解决方案:
- 失效策略:写操作时同时失效所有级别的缓存
- 版本控制:使用版本号控制缓存更新
- 订阅通知:通过消息队列通知各节点更新缓存
- 最终一致性:接受短暂不一致,通过TTL保证最终一致性
1@Service2public class MultiLevelCacheService {3 private final Cache<String, Object> localCache;4 private final RedisTemplate<String, Object> redisCache;5 6 public void updateUser(User user) {7 // 1. 更新数据库8 userRepository.save(user);9 10 // 2. 同时失效两级缓存11 String cacheKey = "user:" + user.getId();12 localCache.invalidate(cacheKey);13 redisCache.delete(cacheKey);14 15 // 3. 发送消息通知其他节点16 messagePublisher.publish("cache:invalidate", cacheKey);17 }18 19 @EventListener20 public void onCacheInvalidate(String cacheKey) {21 // 收到失效消息时,清理本地缓存22 localCache.invalidate(cacheKey);23 }24}Q9: 如何识别与治理热点Key?
答: 热点Key是指访问频率异常高的缓存键,需要特殊处理。
识别方法:
- 监控统计:通过监控系统统计Key的访问频率
- 实时告警:设置阈值,当Key访问频率超过阈值时告警
- 日志分析:分析访问日志,识别热点Key
治理策略:
- 本地缓存:将热点Key缓存到本地,减少网络开销
- 分片策略:将热点Key分散到多个节点
- 预加载:主动预加载热点数据
- 降级策略:当热点Key压力过大时,启用降级策略
1@Service2public class HotKeyService {3 4 @Scheduled(fixedRate = 60000) // 每分钟检查一次5 public void detectHotKeys() {6 Map<String, Long> keyAccessCount = getKeyAccessCount();7 8 for (Map.Entry<String, Long> entry : keyAccessCount.entrySet()) {9 if (entry.getValue() > 1000) { // 访问次数超过100010 String hotKey = entry.getKey();11 handleHotKey(hotKey);12 }13 }14 }15 16 private void handleHotKey(String hotKey) {17 // 1. 预加载到本地缓存18 Object value = redisTemplate.opsForValue().get(hotKey);19 if (value != null) {20 localCache.put(hotKey, value);21 }22 23 // 2. 发送告警24 alertService.sendAlert("Hot key detected: " + hotKey);25 26 // 3. 记录日志27 log.warn("Hot key detected: {} with access count: {}", hotKey, getAccessCount(hotKey));28 }29}5.5 性能优化
Q10: 如何优化缓存性能?
答: 缓存性能优化需要从多个维度考虑。
优化策略:
-
键设计优化:
- 使用短而清晰的键名
- 避免过长的键名
- 使用压缩算法减少键长度
-
数据结构优化:
- 选择合适的Redis数据结构
- 避免存储冗余数据
- 使用序列化优化
-
网络优化:
- 使用连接池
- 批量操作减少网络往返
- 使用Pipeline减少网络延迟
-
内存优化:
- 合理设置TTL
- 使用LRU等淘汰策略
- 监控内存使用情况
1@Service2public class OptimizedCacheService {3 4 // 使用连接池5 private final RedisTemplate<String, Object> redisTemplate;6 7 // 批量操作8 public Map<String, Object> batchGet(List<String> keys) {9 return redisTemplate.opsForValue().multiGet(keys);10 }11 12 // Pipeline操作13 public void batchSet(Map<String, Object> data) {14 redisTemplate.executePipelined((RedisCallback<Object>) connection -> {15 for (Map.Entry<String, Object> entry : data.entrySet()) {16 connection.set(17 entry.getKey().getBytes(),18 serialize(entry.getValue())19 );20 }21 return null;22 });23 }24 25 // 压缩键名26 public String compressKey(String originalKey) {27 return String.valueOf(originalKey.hashCode());28 }29}6. 总结
分布式缓存是现代互联网应用的核心组件,通过合理的设计和优化,可以显著提升系统性能。在实际应用中,需要根据业务场景选择合适的缓存策略,并注意解决缓存穿透、击穿、雪崩等问题。
关键要点
- 选择合适的缓存模式:根据业务需求选择Cache-Aside、Write-Through或Write-Behind
- 解决缓存问题:使用布隆过滤器、互斥锁、TTL随机抖动等技术
- 保证缓存一致性:通过失效策略、版本控制等方式保证数据一致性
- 监控和优化:建立完善的监控体系,持续优化缓存性能
通过深入理解和熟练运用这些技术,我们能够构建出高性能、高可用的分布式缓存系统。
评论