)
SpringBoot整合Caffeine与Redis缓存序列化避坑实战当我们在SpringBoot项目中引入Caffeine或Redis作为缓存解决方案时序列化问题往往是开发者最容易掉入的隐形陷阱。我曾在一个电商项目中因为忽略了自定义对象的序列化配置导致用户会话信息频繁丢失也见过团队因为Lombok的Data注解与Jackson的配合问题花费两天时间排查缓存异常。本文将分享这些实战中积累的经验教训。1. 内存缓存与分布式缓存的序列化本质差异1.1 Caffeine的内存序列化特性Caffeine作为本地堆内缓存其序列化行为有显著特点// 典型的问题示例直接存储自定义对象 Caffeine.newBuilder() .maximumSize(1000) .build();这种配置下对象实际上以Java原生引用形式存储而非真正的序列化。这会导致三个典型问题对象修改会影响缓存中的值同一引用不支持跨JVM实例的缓存共享重启应用后缓存数据丢失提示即使不显式配置序列化Caffeine也会在达到最大容量时使用比较器进行对象比较此时若对象未实现Serializable接口会抛出异常1.2 Redis的强制序列化要求与Caffeine不同Redis作为分布式缓存所有数据必须经过序列化。常见配置陷阱Bean public RedisTemplateString, Object redisTemplate() { RedisTemplateString, Object template new RedisTemplate(); // 缺少valueSerializer配置将导致存储异常 template.setKeySerializer(new StringRedisSerializer()); return template; }Redis序列化需要特别注意序列化方式优点缺点JDK序列化兼容性好体积大、可读性差Jackson2JsonRedisSerializer可读性强类型信息可能丢失GenericJackson2JsonRedisSerializer保留类型信息需要配置ObjectMapper2. Lombok与序列化的化学反应2.1 Data注解的隐藏风险Lombok的Data会生成equals()/hashCode()方法但可能引发缓存异常Data public class UserSession { private String userId; private ListString permissions; // 默认生成的equals/hashCode会包含所有字段 }当permissions字段被修改后缓存查询时的hashCode变化可能导致缓存消失的假象反序列化时类型擦除问题解决方案EqualsAndHashCode(onlyExplicitlyIncluded true) Data public class UserSession { EqualsAndHashCode.Include private String userId; // 只使用业务主键字段 private ListString permissions; }2.2 Jackson的配置要点在application.yml中添加以下配置可解决多数序列化问题spring: jackson: default-property-inclusion: NON_NULL visibility: getter: NON_PRIVATE field: ANY serialization: fail-on-empty-beans: false对于需要特殊处理的类可添加MixIn配置JsonIgnoreProperties(ignoreUnknown true) public abstract class UserSessionMixIn {} // 在配置类中注册 objectMapper.addMixIn(UserSession.class, UserSessionMixIn.class);3. NULL值的处理策略对比3.1 Caffeine的NULL值陷阱Caffeine默认不允许存储null值这会导致cache.put(nonExistKey, null); // 抛出NullPointerException解决方案Caffeine.newBuilder() .maximumSize(1000) .build(key - null); // 允许null值加载 // 或者在CacheManager层面配置 caffeineCacheManager.setAllowNullValues(true);3.2 Redis的NULL值序列化Redis需要特殊处理null值否则会出现缓存穿透RedisCacheConfiguration config RedisCacheConfiguration .defaultCacheConfig() .serializeValuesWith( RedisSerializationContext.SerializationPair .fromSerializer(new GenericJackson2JsonRedisSerializer())) .disableCachingNullValues(); // 显式配置是否允许null建议采用空对象模式替代nullpublic class EmptyValue implements Serializable { private static final long serialVersionUID 1L; } // 使用示例 cache.put(nonExistKey, new EmptyValue());4. 混合缓存架构的最佳实践4.1 多级缓存配置示例结合Caffeine和Redis的优势Bean public CacheManager cacheManager(RedisConnectionFactory factory) { return new CaffeineRedisCacheManager( caffeineCache(), redisCache(factory), true // 开启双写模式 ); } private CaffeineCache caffeineCache() { return new CaffeineCache(local, Caffeine.newBuilder() .maximumSize(1000) .expireAfterWrite(10, TimeUnit.MINUTES) .build()); } private RedisCache redisCache(RedisConnectionFactory factory) { RedisCacheConfiguration config RedisCacheConfiguration .defaultCacheConfig() .serializeValuesWith(RedisSerializationContext.SerializationPair .fromSerializer(new GenericJackson2JsonRedisSerializer())); return new RedisCache(distributed, RedisCacheWriter.nonLockingRedisCacheWriter(factory), config); }4.2 缓存一致性保障采用先更新DB再删除缓存策略Transactional public void updateProduct(Product product) { // 1. 更新数据库 productDao.update(product); // 2. 删除相关缓存 evictProductCache(product.getId()); } private void evictProductCache(Long id) { // 先删除本地缓存 caffeineCache.evict(product:: id); // 再删除分布式缓存 redisTemplate.delete(product:: id); // 可选发送MQ事件通知其他节点 rabbitTemplate.convertAndSend( cache.evict, product:: id); }5. 诊断工具与问题排查5.1 缓存命中率监控通过Micrometer暴露Caffeine指标Caffeine.newBuilder() .maximumSize(1000) .recordStats() // 开启统计 .build(); // 在SpringBoot中暴露指标 Bean public MeterRegistryCustomizerMeterRegistry metrics() { return registry - registry.config() .commonTags(application, myapp); }关键监控指标cache.gets缓存查询次数cache.hits命中次数cache.loads加载次数cache.evictions淘汰数量5.2 序列化问题诊断技巧当遇到奇怪的缓存行为时检查实际存储值// 对于Redis String actualValue redisTemplate.opsForValue() .get(cacheKey); log.debug(Cached value: {}, actualValue); // 对于Caffeine Object value cache.asMap().get(cacheKey);验证序列化循环引用objectMapper.configure( SerializationFeature.FAIL_ON_SELF_REFERENCES, false);使用调试序列化器public class DebugSerializer implements RedisSerializerObject { Override public byte[] serialize(Object o) { log.debug(Serializing: {}, o.getClass()); return new Jackson2JsonRedisSerializer(Object.class) .serialize(o); } }在微服务架构下我曾遇到一个棘手的案例某个POJO因为使用了Hibernate的延迟加载特性在序列化时触发了额外的数据库查询。最终通过配置Hibernate5Module解决了这个问题Bean public Module hibernateModule() { return new Hibernate5Module() .disable(Hibernate5Module.Feature.USE_TRANSIENT_ANNOTATION) .enable(Hibernate5Module.Feature.FORCE_LAZY_LOADING); }