Skip to content
第 232 / 250 章架构⏱ 12 分钟阅读

第 232 章:缓存体系实战

学习目标

  • 多级缓存架构设计
  • 缓存三大问题解决方案
  • 缓存体系监控
  • 缓存与数据库一致性

一、多级缓存全景

1.1 各层职责

层级位置特点失效
浏览器客户端0 网络成本
CDN边缘全球加速
Nginx反代大文件友好
JVM应用内微秒级进程退出
Redis中间件毫秒级可持久化
DB持久层慢 IO-

二、缓存策略选择

2.1 Read-Aside(常用)

java
public Product getProduct(Long id) {
    String key = "product:" + id;
    Product product = cache.get(key);
    if (product == null) {
        product = productRepo.findById(id).orElseThrow();
        cache.put(key, product, 60, TimeUnit.SECONDS);
    }
    return product;
}

2.2 Write-Through

写时同时写缓存和数据库,读永远命中。

2.3 Write-Behind

性能高,但有丢失风险(异步写失败)。

2.4 Cache-Aside 与一致性

java
public void updateProduct(Product p) {
    // 先写库
    productRepo.save(p);
    // 再删缓存
    cache.delete("product:" + p.getId());
}

下次读时回源 DB + 重新填缓存。

三、缓存三大问题

3.1 雪崩

大量缓存同时过期,全部打到 DB。

解决方案:

yaml
# 1. 过期时间随机化
expire = 60 + random(0, 30)    # 60 ~ 90 秒

# 2. 多级缓存
JVM 缓存(本地)→ Redis(共享)→ DB

# 3. 缓存预热
服务启动时,加载热点数据

# 4. 熔断降级
DB 异常时,降级返回兜底数据

# 5. Sentinel 限流
对回源操作限流

3.2 穿透

查询不存在的数据,每次都打到 DB。

解决方案:

java
// 1. 缓存空值
public Product getProduct(Long id) {
    Product p = cache.get("product:" + id);
    if (p == null) {
        p = productRepo.findById(id);
        if (p == null) {
            // 缓存空值,避免击穿
            cache.put("product:" + id, NULL_OBJECT, 30, TimeUnit.SECONDS);
            return null;
        }
        cache.put("product:" + id, p);
    }
    return p == NULL_OBJECT ? null : p;
}

// 2. 布隆过滤器
BloomFilter<Long> bloomFilter = ...;  // 启动时填入所有商品 ID
if (!bloomFilter.mightContain(id)) {
    return null;
}

3.3 击穿

热点 key 过期瞬间,大量请求同时回源。

解决方案:

java
// 1. 分布式锁(单个请求去数据库)
// 只让 1 个请求回源,其他等
public Product getHotProduct(Long id) {
    String key = "hot:product:" + id;
    Product p = cache.get(key);
    if (p == null) {
        RLock lock = redisson.getLock(key + ":lock");
        if (lock.tryLock(100, 30_000, TimeUnit.MILLISECONDS)) {
            try {
                // 二次检查
                p = cache.get(key);
                if (p == null) {
                    p = productRepo.findById(id);
                    cache.put(key, p, 60, TimeUnit.SECONDS);
                }
            } finally {
                lock.unlock();
            }
        } else {
            // 等不到锁,稍后重试或返回
            try { Thread.sleep(50); } catch (Exception e) {}
            return getHotProduct(id);
        }
    }
    return p;
}

// 2. 逻辑过期(后台异步刷新)
@Data
class CacheWrapper<T> {
    T data;
    long expireTime;
}

四、Caffeine 本地缓存

4.1 依赖

xml
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>

4.2 配置

java
@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public CacheManager cacheManager() {
        CaffeineCacheManager manager = new CaffeineCacheManager();
        manager.setCaffeine(Caffeine.newBuilder()
            .expireAfterWrite(60, TimeUnit.SECONDS)
            .maximumSize(10_000)
            .recordStats());
        return manager;
    }
}

4.3 使用

java
@Cacheable(value = "products", key = "#id")
public Product getProduct(Long id) {
    return productRepo.findById(id);
}

@CachePut(value = "products", key = "#product.id")
public Product update(Product product) {
    return productRepo.save(product);
}

@CacheEvict(value = "products", key = "#id")
public void delete(Long id) {
    productRepo.deleteById(id);
}

4.4 高级特性

java
Caffeine.newBuilder()
    .expireAfterWrite(60, TimeUnit.SECONDS)        // 写后过期
    .expireAfterAccess(5, TimeUnit.MINUTES)          // 访问后过期
    .maximumSize(10_000)                              // 大小上限
    .maximumWeight(10_000)
    .weigher((key, value) -> ((Product) value).size())
    .softValues()                                     // 软引用
    .recordStats()                                    // 开启监控
    .removalListener((key, value, cause) -> {         // 移除监听
        log.info("移除 key={}, 原因={}", key, cause);
    });

五、Redis 缓存

5.1 序列化

java
@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String, Product> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Product> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);

        Jackson2JsonRedisSerializer<Product> serializer =
            new Jackson2JsonRedisSerializer<>(objectMapper, Product.class);

        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(serializer);
        return template;
    }
}

5.2 批量操作

java
public Map<Long, Product> batchGet(List<Long> ids) {
    List<String> keys = ids.stream().map(id -> "product:" + id).collect(Collectors.toList());

    // mget 一次拉
    List<Object> values = redisTemplate.opsForValue().multiGet(keys);

    Map<Long, Product> result = new HashMap<>();
    List<Long> missing = new ArrayList<>();

    for (int i = 0; i < ids.size(); i++) {
        Product p = (Product) values.get(i);
        if (p == null) {
            missing.add(ids.get(i));
        } else {
            result.put(ids.get(i), p);
        }
    }

    if (!missing.isEmpty()) {
        // 缺失的查库,再 mset
        List<Product> products = productRepo.findAllById(missing);
        Map<String, Product> toSet = new HashMap<>();
        products.forEach(p -> {
            result.put(p.getId(), p);
            toSet.put("product:" + p.getId(), p);
        });
        redisTemplate.opsForValue().multiSet(toSet);
        redisTemplate.opsForValue().multiSet(toSet);
        toSet.forEach((k, v) ->
            redisTemplate.expire(k, 60, TimeUnit.SECONDS)
        );
    }

    return result;
}

5.3 Pipeline

java
public List<Product> pipelineQuery(List<Long> ids) {
    return redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
        for (Long id : ids) {
            connection.stringCommands().get(("product:" + id).getBytes());
        }
        return null;
    });
}

六、缓存一致性

6.1 设计方案

方案说明
Cache-Aside先 DB 再缓存,延迟双删
延迟双删删缓存 → 写库 → 延迟 X ms → 删缓存
MQ 异步数据库 binlog → MQ → 缓存同步
Canal监听 MySQL binlog

6.2 Canal 监听 binlog

java
@CanalEventListener
public class MyCanalListener {

    @Autowired
    private CacheService cacheService;

    @ListenPoint(schema = "shop", table = "product")
    public void onUpdate(CanalEntry.EventType eventType, CanalEntry.RowChange row) {
        if (eventType == CanalEntry.EventType.UPDATE) {
            Long id = extractId(row);
            cacheService.delete("product:" + id);
        }
    }
}

6.3 写策略详解

java
@Service
public class ProductService {

    @Autowired
    private CacheService cacheService;
    @Autowired
    private ProductMapper productMapper;

    public void update(Product product) {
        // 1. 写库
        productMapper.update(product);

        // 2. 删缓存(删除,而不是更新)
        cacheService.delete("product:" + product.getId());

        // 3. 异步延迟双删(避免并发脏读)
        CompletableFuture.runAsync(() -> {
            try { Thread.sleep(500); } catch (Exception e) {}
            cacheService.delete("product:" + product.getId());
        });
    }
}

七、缓存粒度

7.1 选择

粒度优点缺点
(整表数据)简单浪费,可能存无关数据
(单条)精确多次 IO

7.2 实践

java
// ❌ 把列表当 key
List<Product> products = cache.get("product:list:hot");
products.get(0) == 单条缓存

// ✅ 单条 key
Product p = cache.get("product:" + id);

八、热点数据识别

8.1 统计方式

java
// 计数:Redis INCR
String key = "hot:counter:" + productId;
Long count = redisTemplate.opsForValue().increment(key);

// 定期扫描
List<String> topKeys = redisTemplate.scan(
    ScanOptions.scanOptions().match("hot:counter:*").count(100).build()
);

8.2 多级缓存

java
// Level 1: Caffeine
// Level 2: Redis
// Level 3: DB

读取顺序: JVM → Redis → DB 写入顺序: DB → Redis → JVM(广播清空)

九、缓存监控

9.1 命中率指标

java
// Prometheus + Grafana
registry.counter("cache.hit", "key", key).increment();
registry.counter("cache.miss", "key", key).increment();

// 业务显式监控
"cache_hit_ratio" = hit / (hit + miss)

9.2 容量监控

java
// Redis INFO 监控
INFO memory
# used_memory
# used_memory_human
# mem_fragmentation_ratio

9.3 关键指标

指标阈值
命中率> 95%
P99 RT< 5ms
连接数< max-pool
内存使用率< 70%
键数监控增长

十、实战案例

10.1 商品详情页

java
@Service
@RequiredArgsConstructor
public class ProductService {

    private final Cache<String, Product> localCache;
    private final RedisTemplate<String, Product> redis;
    private final ProductMapper productMapper;
    private final BloomFilter<Long> productBloom;

    public Product getDetail(Long id) {
        // 1. 布隆过滤
        if (!productBloom.mightContain(id)) {
            return null;
        }

        // 2. 本地缓存
        Product p = localCache.getIfPresent("p:" + id);
        if (p != null) return p;

        // 3. Redis
        p = redis.opsForValue().get("product:" + id);
        if (p == null) {
            // 4. DB
            p = productMapper.selectById(id);
            if (p == null) {
                redis.opsForValue().set("product:" + id, NULL_OBJ, 30, TimeUnit.SECONDS);
                return null;
            }
            // 写 Redis + 本地
            redis.opsForValue().set("product:" + id, p, 60, TimeUnit.SECONDS);
        }

        localCache.put("p:" + id, p);
        return p;
    }
}

10.2 缓存预热

java
@Component
public class CacheWarmer implements ApplicationRunner {

    @Autowired
    private ProductService productService;
    @Autowired
    private RedisTemplate<String, Product> redis;

    @Override
    public void run(ApplicationArguments args) {
        // 启动时加载前 1000 个热门商品到缓存
        List<Long> hotIds = hotProductRepo.findHotIds(0, 1000);
        Map<String, Product> map = new HashMap<>();
        for (Long id : hotIds) {
            Product p = productService.getDetail(id);
            if (p != null) {
                map.put("product:" + id, p);
            }
        }
        redis.opsForValue().multiSet(map);
    }
}

十一、本章小结

层级用途
JVM高频热点Caffeine
Redis共享缓存Redis Cluster
DB持久化MySQL

动手练习

  1. 设计一个三级缓存(浏览器/Caffeine/Redis)
  2. 用 Caffeine + Redis 实现商品缓存
  3. 处理缓存三大问题(雪崩/穿透/击穿)
  4. 用延迟双删保证缓存一致性

下一章:第 233 章:秒杀系统设计

本站基于 VitePress 构建 · 由 Codebook 团队维护