一、热点数据问题分析
在高并发系统中,某些数据的访问量远高于其他数据,这就是热点数据。
热点数据的特征:
- 访问频率极高(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数据库:数据持久化,最终一致性
实施要点:
- 合理设置缓存大小和过期时间
- 实现缓存穿透、雪崩、热key防护
- 保证缓存一致性
- 完善监控告警
思考题:你们系统有没有热点数据?如何处理的?
个人观点,仅供参考
- List item