后端接口的 “缓存策略” 设计:从 “重复计算” 到 “数据复用”

103 阅读6分钟

缓存是提升接口性能的 “利器”—— 通过将频繁访问的数据暂存在内存中,减少对数据库或远程服务的依赖,从而降低响应时间、提高吞吐量。但缓存设计不当会导致 “数据不一致”“缓存雪崩” 等问题,合理的策略需平衡 “命中率” 与 “一致性”,实现高效的数据复用。

缓存的核心价值与适用场景

为什么需要缓存?

  • 降低数据库压力:减少重复查询(如首页商品列表每天被查询 10 万次,缓存后可减少 90% 的数据库访问)
  • 提升响应速度:内存访问(微秒级)比磁盘 IO(毫秒级)快 100-1000 倍
  • 应对流量峰值:缓存可扛住更高并发(如 Redis 单机可支持 10 万 QPS,远超普通数据库)

适合缓存的数据类型

  • 高频访问 + 低频修改:如商品基本信息(ID、名称、价格)、地区编码表

  • 计算成本高的数据:如复杂报表(需聚合多表数据)、接口聚合结果

  • 热点数据:如秒杀商品库存、热门文章详情

不适合缓存的场景

  • 实时性要求极高:如股票价格(每秒变动多次,缓存易失效)
  • 低频访问数据:如一年才查一次的历史订单(缓存利用率低,浪费空间)
  • 数据量过大:如 10GB 的日志文件(内存放不下,缓存成本高)

缓存的实现方案与最佳实践

1. 本地缓存:轻量快速的内存缓存

适合单机场景或无需集群共享的缓存(如配置信息),常用 Caffeine、Guava:

// 引入Caffeine依赖
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
    <version>3.1.8</version>
</dependency>

// 配置本地缓存
@Configuration
public class LocalCacheConfig {
    // 商品信息缓存(过期时间10分钟,最大容量1000条)
    @Bean
    public Cache<Long, Product> productLocalCache() {
        return Caffeine.newBuilder()
                .expireAfterWrite(10, TimeUnit.MINUTES) // 写入后10分钟过期
                .maximumSize(1000) // 最大缓存1000条
                .recordStats() // 记录缓存统计(命中率等)
                .build();
    }
}

// 业务中使用缓存
@Service
public class ProductService {
    @Autowired
    private ProductMapper productMapper;
    @Autowired
    private Cache<Long, Product> productLocalCache;
    
    public Product getProductById(Long id) {
        // 1. 先查缓存
        Product product = productLocalCache.getIfPresent(id);
        if (product != null) {
            return product;
        }
        // 2. 缓存未命中,查数据库
        product = productMapper.selectById(id);
        if (product != null) {
            // 3. 存入缓存
            productLocalCache.put(id, product);
        }
        return product;
    }
}

本地缓存优势

  • 无网络开销:直接访问内存,速度最快

  • 实现简单:无需额外部署服务

局限性

  • 集群环境下缓存不共享(如 A 机器更新了数据,B 机器缓存仍是旧值)
  • 受单机内存限制,无法存储大量数据

2. 分布式缓存:集群共享的缓存方案

适合分布式系统(多机部署),常用 Redis、Memcached:

// Spring Boot集成Redis
@Configuration
@EnableCaching
public class RedisCacheConfig {
    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
        // 商品缓存配置(过期时间5分钟)
        RedisCacheConfiguration productCacheConfig = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(5))
                .serializeKeysWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(new GenericJackson2JsonRedisSerializer()));
        
        // 不同缓存名对应不同配置
        Map<String, RedisCacheConfiguration> configMap = new HashMap<>();
        configMap.put("productCache", productCacheConfig);
        configMap.put("userCache", RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofHours(1)));
        
        return RedisCacheManager.builder(factory)
                .withInitialCacheConfigurations(configMap)
                .build();
    }
}

// 用注解简化缓存操作
@Service
public class ProductService {
    @Autowired
    private ProductMapper productMapper;
    
    // 查询时自动缓存结果,缓存名productCache,key为id
    @Cacheable(value = "productCache", key = "#id")
    public Product getProductById(Long id) {
        return productMapper.selectById(id);
    }
    
    // 更新时自动刷新缓存
    @CachePut(value = "productCache", key = "#product.id")
    public Product updateProduct(Product product) {
        productMapper.updateById(product);
        return product;
    }
    
    // 删除时自动清除缓存
    @CacheEvict(value = "productCache", key = "#id")
    public void deleteProduct(Long id) {
        productMapper.deleteById(id);
    }
}

分布式缓存优势

  • 集群共享:多台机器访问同一缓存,数据一致

  • 容量大:可部署 Redis 集群,支持海量数据存储

注意事项

  • 网络开销:需通过网络访问 Redis, latency 比本地缓存高(但仍远低于数据库)
  • 序列化:确保缓存对象可序列化(如用 Jackson 序列化 JSON)

3. 缓存更新策略:保证数据一致性

缓存与数据库的数据一致性是核心难题,常用策略:

  • Cache Aside(旁路缓存)

    • 读:先查缓存,未命中则查库并更新缓存

    • 写:先更新数据库,再删除缓存(避免缓存脏数据)

// 写操作示例(更新数据库后删除缓存)
public void updateProductPrice(Long id, BigDecimal newPrice) {
    // 1. 更新数据库
    productMapper.updatePrice(id, newPrice);
    // 2. 删除缓存(下次查询会从数据库加载新值)
    redisTemplate.delete("productCache::" + id);
}
  • 过期时间兜底:即使缓存未及时删除,设置合理的过期时间(如 5 分钟)也能最终保证一致性

缓存的常见问题与解决方案

1. 缓存穿透:查询不存在的数据导致缓存失效

问题:恶意请求查询不存在的 ID(如id=-1),缓存未命中,每次都查库,导致数据库压力过大。

解决方案:缓存空值(如id=-1的查询结果缓存为null,设置短期过期时间):

public Product getProductById(Long id) {
    Product product = productLocalCache.getIfPresent(id);
    if (product != null) {
        // 区分空值和真实数据(如用特殊对象标记空值)
        return product == NullProduct.INSTANCE ? null : product;
    }
    product = productMapper.selectById(id);
    if (product == null) {
        // 缓存空值,过期时间设短一点(如1分钟)
        productLocalCache.put(id, NullProduct.INSTANCE, Duration.ofMinutes(1));
    } else {
        productLocalCache.put(id, product);
    }
    return product;
}

2. 缓存雪崩:大量缓存同时过期导致数据库压力骤增

问题:某一时刻大量缓存同时过期,所有请求都涌向数据库,导致数据库崩溃。

解决方案:过期时间加随机值(避免同时过期):

// 缓存时添加随机过期时间(如5-10分钟随机)
int randomSeconds = new Random().nextInt(300) + 300; // 300-600秒
redisTemplate.opsForValue().set("product:" + id, product, randomSeconds, TimeUnit.SECONDS);

3. 缓存击穿:热点数据过期瞬间被高并发请求穿透

问题:某热门商品缓存过期瞬间,1000 个并发请求同时查库,导致数据库压力过大。

解决方案:互斥锁(只允许一个请求查库,其他请求等待缓存更新):

public Product getHotProduct(Long id) {
    String key = "product:" + id;
    // 1. 查缓存
    Product product = (Product) redisTemplate.opsForValue().get(key);
    if (product != null) {
        return product;
    }
    // 2. 缓存未命中,获取锁
    String lockKey = "lock:product:" + id;
    boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
    if (locked) {
        try {
            // 3. 拿到锁,查库并更新缓存
            product = productMapper.selectById(id);
            redisTemplate.opsForValue().set(key, product, 5, TimeUnit.MINUTES);
        } finally {
            // 4. 释放锁
            redisTemplate.delete(lockKey);
        }
        return product;
    } else {
        // 5. 未拿到锁,等待后重试
        Thread.sleep(100);
        return getHotProduct(id); // 递归重试
    }
}

避坑指南

  • 缓存不是越多越好:缓存会占用内存,需根据访问频率和数据大小权衡

  • 避免缓存大对象:如 1MB 的大 JSON,序列化 / 传输成本高,可拆分缓存

  • 监控缓存命中率:命中率低于 70% 时需优化(如调整缓存策略、增加热点数据)

  • 缓存降级:系统压力大时,可关闭非核心缓存,优先保证核心业务可用

缓存策略的核心是 “用空间换时间”,但空间是有限的,需精准选择缓存对象、合理设置过期时间、妥善处理一致性问题。一个设计良好的缓存体系,能让接口性能提升 10 倍甚至 100 倍,这是后端性能优化的 “必经之路”。