【架构实战】热点数据架构:本地缓存+多级缓存

一、热点数据问题分析

在高并发系统中,某些数据的访问量远高于其他数据,这就是热点数据

热点数据的特征:

  • 访问频率极高(QPS可能是普通数据的100倍)
  • 数据量小(通常是单条或少量数据)
  • 变化频率低(相对稳定)

常见的热点数据场景:

场景 数据 访问量 特点
秒杀 商品库存 极高 瞬间高并发
热门商品 商品详情 很高 持续高并发
用户排行 排行榜 定期更新
系统配置 配置数据 中等 很少变化
热门用户 用户信息 相对稳定

热点数据的问题:

复制代码
高并发请求 → 数据库连接池耗尽 → 其他请求无法获取连接 → 级联故障

解决方案:多级缓存架构

复制代码
请求 → L1本地缓存 → L2分布式缓存 → L3数据库

二、多级缓存架构设计

1. 三层缓存的特点

层级 存储 特点 适用场景
L1 本地内存 毫秒级,单机 热点数据
L2 Redis 微秒级,分布式 常用数据
L3 数据库 毫秒级,持久化 所有数据

2. 缓存命中率

复制代码
总请求数 = L1命中 + L2命中 + L3命中

理想情况:
- L1命中率:80%(热点数据)
- L2命中率:15%(常用数据)
- L3命中率:5%(冷数据)

三、L1本地缓存实现

1. Caffeine缓存

基础配置:

java 复制代码
@Configuration
public class CacheConfig {
    
    @Bean
    public LoadingCache<Long, Product> productCache() {
        return Caffeine.newBuilder()
            .maximumSize(10000)                    // 最多缓存10000条
            .expireAfterWrite(5, TimeUnit.MINUTES) // 5分钟过期
            .recordStats()                         // 记录统计信息
            .build(id -> loadProductFromRedis(id));
    }
    
    @Bean
    public LoadingCache<Long, User> userCache() {
        return Caffeine.newBuilder()
            .maximumSize(50000)
            .expireAfterAccess(10, TimeUnit.MINUTES)  // 10分钟未访问则过期
            .recordStats()
            .build(id -> loadUserFromRedis(id));
    }
}

使用示例:

java 复制代码
@Service
public class ProductService {
    
    @Autowired
    private LoadingCache<Long, Product> productCache;
    
    public Product getProduct(Long id) {
        try {
            return productCache.get(id);
        } catch (Exception e) {
            log.error("获取商品缓存失败", e);
            return loadProductFromRedis(id);
        }
    }
    
    // 缓存统计
    public CacheStats getCacheStats() {
        return productCache.stats();
    }
}

2. 缓存更新策略

策略1:主动更新(Push)

java 复制代码
@Service
public class ProductService {
    
    @Autowired
    private LoadingCache<Long, Product> productCache;
    
    @Autowired
    private KafkaTemplate kafkaTemplate;
    
    public void updateProduct(Product product) {
        // 1. 更新数据库
        productMapper.updateById(product);
        
        // 2. 清除本地缓存
        productCache.invalidate(product.getId());
        
        // 3. 发送消息,通知其他节点清除缓存
        kafkaTemplate.send("product:update", product.getId().toString());
    }
    
    @KafkaListener(topics = "product:update")
    public void onProductUpdate(String productId) {
        productCache.invalidate(Long.parseLong(productId));
        log.info("清除本地缓存: {}", productId);
    }
}

策略2:被动更新(Pull)

java 复制代码
@Service
public class ProductService {
    
    @Autowired
    private LoadingCache<Long, Product> productCache;
    
    @Autowired
    private RedisTemplate redisTemplate;
    
    public Product getProduct(Long id) {
        // 检查Redis中的版本号
        String version = (String) redisTemplate.opsForValue().get("product:version:" + id);
        String localVersion = (String) productCache.getIfPresent(id + ":version");
        
        // 版本不一致,清除本地缓存
        if (!Objects.equals(version, localVersion)) {
            productCache.invalidate(id);
        }
        
        return productCache.get(id);
    }
}

3. 缓存预热

java 复制代码
@Component
public class CacheWarmer {
    
    @Autowired
    private LoadingCache<Long, Product> productCache;
    
    @Autowired
    private ProductMapper productMapper;
    
    @PostConstruct
    public void warmUp() {
        log.info("开始缓存预热...");
        
        // 加载热点商品(销量TOP 1000)
        List<Product> hotProducts = productMapper.selectHotProducts(1000);
        
        for (Product product : hotProducts) {
            productCache.put(product.getId(), product);
        }
        
        log.info("缓存预热完成,共{}条数据", hotProducts.size());
    }
}

四、L2分布式缓存(Redis)

1. Redis缓存配置

java 复制代码
@Configuration
public class RedisConfig {
    
    @Bean
    public RedisTemplate<String, Object> redisTemplate(
            RedisConnectionFactory connectionFactory) {
        
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);
        
        // 使用Jackson序列化
        Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = 
            new Jackson2JsonRedisSerializer(Object.class);
        
        ObjectMapper om = new ObjectMapper();
        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
        jackson2JsonRedisSerializer.setObjectMapper(om);
        
        StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
        
        // key采用String的序列化方式
        template.setKeySerializer(stringRedisSerializer);
        template.setHashKeySerializer(stringRedisSerializer);
        
        // value采用jackson的序列化方式
        template.setValueSerializer(jackson2JsonRedisSerializer);
        template.setHashValueSerializer(jackson2JsonRedisSerializer);
        
        template.afterPropertiesSet();
        return template;
    }
}

2. Redis缓存操作

java 复制代码
@Service
public class ProductService {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    private static final String PRODUCT_KEY_PREFIX = "product:";
    private static final long CACHE_TIMEOUT = 30; // 30分钟
    
    public Product getProductFromRedis(Long id) {
        String key = PRODUCT_KEY_PREFIX + id;
        
        // 先查Redis
        Product product = (Product) redisTemplate.opsForValue().get(key);
        
        if (product != null) {
            return product;
        }
        
        // Redis未命中,查数据库
        product = productMapper.selectById(id);
        
        if (product != null) {
            // 写入Redis
            redisTemplate.opsForValue().set(
                key, product, CACHE_TIMEOUT, TimeUnit.MINUTES);
        } else {
            // 缓存空值,防止穿透
            redisTemplate.opsForValue().set(
                key, "NULL", 5, TimeUnit.MINUTES);
        }
        
        return product;
    }
}

3. 缓存穿透防护

问题: 查询一个不存在的数据,每次都会穿透到数据库

解决方案1:缓存空值

java 复制代码
public Product getProduct(Long id) {
    String key = "product:" + id;
    
    Object cached = redisTemplate.opsForValue().get(key);
    
    // 缓存中存在"NULL"标记
    if ("NULL".equals(cached)) {
        return null;
    }
    
    if (cached != null) {
        return (Product) cached;
    }
    
    // 查数据库
    Product product = productMapper.selectById(id);
    
    if (product == null) {
        // 缓存空值
        redisTemplate.opsForValue().set(key, "NULL", 5, TimeUnit.MINUTES);
    } else {
        redisTemplate.opsForValue().set(key, product, 30, TimeUnit.MINUTES);
    }
    
    return product;
}

解决方案2:布隆过滤器

java 复制代码
@Configuration
public class BloomFilterConfig {
    
    @Bean
    public BloomFilter<Long> productBloomFilter() {
        // 预期10万条数据,误判率1%
        BloomFilter<Long> filter = BloomFilter.create(
            Funnels.longFunnel(),
            100000,
            0.01
        );
        
        // 预热:加载所有存在的商品ID
        List<Long> productIds = productMapper.selectAllIds();
        productIds.forEach(filter::put);
        
        return filter;
    }
}

@Service
public class ProductService {
    
    @Autowired
    private BloomFilter<Long> productBloomFilter;
    
    public Product getProduct(Long id) {
        // 布隆过滤器判断是否可能存在
        if (!productBloomFilter.mightContain(id)) {
            return null;  // 一定不存在
        }
        
        // 继续查询缓存和数据库
        return getProductFromCache(id);
    }
}

4. 缓存雪崩防护

问题: 大量缓存同时过期,导致请求全部打到数据库

解决方案:随机过期时间

java 复制代码
public void setProductCache(Long id, Product product) {
    String key = "product:" + id;
    
    // 基础过期时间 + 随机时间(0-5分钟)
    long timeout = 30 + new Random().nextInt(5);
    
    redisTemplate.opsForValue().set(
        key, product, timeout, TimeUnit.MINUTES);
}

5. 缓存热key防护

问题: 某个key的访问量极高,Redis单线程处理不过来

解决方案1:本地缓存

java 复制代码
// 热key自动降级到本地缓存
@Service
public class ProductService {
    
    private final LoadingCache<Long, Product> hotKeyCache = Caffeine.newBuilder()
        .maximumSize(1000)
        .expireAfterWrite(1, TimeUnit.MINUTES)
        .build(id -> getProductFromRedis(id));
    
    public Product getProduct(Long id) {
        // 检查是否是热key
        if (isHotKey(id)) {
            return hotKeyCache.get(id);
        }
        
        return getProductFromRedis(id);
    }
    
    private boolean isHotKey(Long id) {
        // 基于访问频率判断
        long count = redisTemplate.opsForValue().increment("hotkey:count:" + id);
        return count > 1000;  // 1秒内访问超过1000次
    }
}

解决方案2:Redis集群

复制代码
使用Redis Cluster分散热key的访问压力

五、缓存一致性保证

1. 更新流程

复制代码
更新数据库 → 删除缓存 → 通知其他节点

为什么先更新数据库?
- 如果先删除缓存,更新数据库失败,缓存已删除,会导致数据不一致
- 如果先更新数据库,删除缓存失败,下次查询会重新加载最新数据

2. 消息通知

java 复制代码
@Service
public class ProductService {
    
    @Autowired
    private KafkaTemplate kafkaTemplate;
    
    @Transactional
    public void updateProduct(Product product) {
        // 1. 更新数据库
        productMapper.updateById(product);
        
        // 2. 删除本地缓存
        productCache.invalidate(product.getId());
        
        // 3. 删除Redis缓存
        redisTemplate.delete("product:" + product.getId());
        
        // 4. 发送消息,通知其他节点
        kafkaTemplate.send("product:update", 
            new ProductUpdateEvent(product.getId()));
    }
    
    @KafkaListener(topics = "product:update")
    public void onProductUpdate(ProductUpdateEvent event) {
        // 清除本地缓存
        productCache.invalidate(event.getProductId());
        log.info("收到更新通知,清除缓存: {}", event.getProductId());
    }
}

3. 缓存版本号

java 复制代码
@Service
public class ProductService {
    
    public Product getProduct(Long id) {
        // 获取缓存版本号
        String version = (String) redisTemplate.opsForValue()
            .get("product:version:" + id);
        
        // 从本地缓存获取
        Product cached = productCache.getIfPresent(id);
        
        // 版本一致,直接返回
        if (cached != null && Objects.equals(version, cached.getVersion())) {
            return cached;
        }
        
        // 版本不一致或缓存未命中,重新加载
        Product product = getProductFromRedis(id);
        productCache.put(id, product);
        
        return product;
    }
}

六、监控与告警

1. 缓存监控指标

java 复制代码
@Component
public class CacheMonitor {
    
    @Autowired
    private LoadingCache<Long, Product> productCache;
    
    @Scheduled(fixedRate = 60000)
    public void monitorCache() {
        CacheStats stats = productCache.stats();
        
        log.info("缓存统计 - 命中率: {}%, 加载成功: {}, 加载失败: {}",
            String.format("%.2f", stats.hitRate() * 100),
            stats.loadSuccessCount(),
            stats.loadFailureCount());
        
        // 发送到监控系统
        metricsService.record("cache.hit.rate", stats.hitRate());
        metricsService.record("cache.size", productCache.size());
    }
}

2. 告警规则

yaml 复制代码
groups:
  - name: cache_alerts
    rules:
      - alert: CacheHitRateLow
        expr: cache_hit_rate < 0.7
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "缓存命中率低于70%"
      
      - alert: CacheLoadFailure
        expr: cache_load_failure_total > 100
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "缓存加载失败次数过多"

七、总结

多级缓存架构有效应对热点数据:

  • L1本地缓存:毫秒级响应,减轻Redis压力
  • L2分布式缓存:数据一致性,支持分布式
  • L3数据库:数据持久化,最终一致性

实施要点:

  1. 合理设置缓存大小和过期时间
  2. 实现缓存穿透、雪崩、热key防护
  3. 保证缓存一致性
  4. 完善监控告警

思考题:你们系统有没有热点数据?如何处理的?


个人观点,仅供参考

  1. List item
相关推荐
学嵌入式的小杨同学10 小时前
STM32 进阶封神之路(三十三):W25Q64 任意长度写入深度实战 —— 从页限制到工业级通用读写(附完整代码 + 避坑指南)
stm32·单片机·嵌入式硬件·架构·硬件架构·嵌入式·flash
wefly201713 小时前
纯前端架构深度解析:jsontop.cn,JSON 格式化与全栈开发效率平台
java·前端·python·架构·正则表达式·json·php
odoo中国14 小时前
Claude Code 架构总览
架构·claude·自动编程·claude cdoe
a东方青15 小时前
Claude Code 架构概览:从启动入口、查询引擎到工具链与远程桥接
架构
ANii_Aini15 小时前
Claude Code源码架构分析(含可以启动的源码本地部署)
架构·agent·claude·claude code
言之。15 小时前
Claude Code架构与设计原理深度解析(AI编程Agent核心课)
架构·ai编程
架构师沉默17 小时前
为什么 Dubbo 从 ZooKeeper 转向 Nacos?
java·后端·架构
fy1216317 小时前
网卡驱动架构以及源码分析
架构
_李小白17 小时前
【OSG学习笔记】Day 25: OSG 设计架构解析
笔记·学习·架构