说实话,最早我接触Redis缓存的时候,也迷信过“预热”这套东西。项目一上线,先把数据库里的热门数据一股脑塞进缓存,觉得接口慢了就往预热脚本里加数据。直到有一次上线,预热脚本跑了快二十分钟,把Redis内存顶到了90%以上,可真正被高频访问的数据只占其中一小部分,我才下决心把方案改成Redis懒加载。在Spring Boot这个生态里,懒加载并不是什么高深的架构概念,它要解决的问题只有一个:别让缓存存放“当前没人需要”的数据。这篇文章就把我实际项目中怎么实现、怎么避坑的过程完整写出来,适合正在做Java后端、想给接口提速但又不想被缓存预热拖垮的开发者参考。
懒加载的核心逻辑其实很朴素:数据在第一次被真正访问时才加载进缓存,之后所有相同请求都直接命中缓存。Spring Boot环境下,主流实现方式有两种,一是依赖Spring Cache的@Cacheable注解,利用AOP代理自动完成“查缓存、未命中则执行业务方法、把结果回填缓存”的完整链路;二是在自定义的缓存服务层手写一套“先查Redis、未命中再查数据库、查到后回填”的三步逻辑。两条路我都跑通过,也各自踩过坑,下面把整个搭建过程、关键参数取舍和排错经验一并讲清楚。
1. 从缓存预热的三个痛点说起:懒加载到底解决了什么
1.1 预热脚本的维护成本比想象中高
很多团队一开始选择预热,是因为上线前的接口压测需要缓存里有数据,不然首次请求会打到数据库上。这个诉求本身没问题,但预热脚本一旦写起来就会失控。我当时负责的商品详情模块,预热逻辑要处理几十种数据源:基础信息、价格、库存、评论数、活动标签……每加一种数据,预热脚本就要跟着改一版。更麻烦的是,预热的数据来自数据库查询,为了让缓存数据完整,只能把查询条件组合一遍又一遍地跑,脚本越写越像一个“定时批量任务”,如果某次查询超时导致预热中断,还得排查到底哪些key没塞进去。
1.2 热点数据会漂移,预热的命中率其实不高
预热最尴尬的地方在于,你以为的“热门数据”和用户真正访问的数据,往往对不上。有一次我们按照业务后台的热销榜单预热了五百个商品,结果上线后看监控,访问量排名前五十的商品里有三十多个根本不在预热名单里,而那些预热的商品,一半以上一整天的访问量都不到十次。换句话说,预热花了大把时间把Redis内存撑得满满的,真正给到接口提速的收益却相当有限。Redis再快也是内存资源,存了不用的数据,就是浪费。
1.3 懒加载的本质是“按需填充”,不是“完全不加载”
理解懒加载,先要纠正一个误解:懒加载并不排斥预热,它只是把“预加载”的优先级放到最低。它的核心含义是,任何一个缓存key都只会在第一次被请求时生成,后续请求全部复用。这样一来,缓存内容会自动跟随真实流量收敛到热门数据上,不需要人工维护一张“热点名单”。你只需要保证两件事:缓存里有值就返回,缓存里没值就把数据库查询结果写回去。所以在Spring Boot里落地懒加载,本质上是设计好“读取缓存”和“回填缓存”这两个动作,下面展开讲。
2. 环境准备:Spring Boot集成Redis时容易忽略的几个细节
2.1 依赖版本与Starter选型
Spring Boot集成Redis最直接的方式是引入spring-boot-starter-data-redis,它默认整合了Lettuce作为Redis客户端。我建议不要自己去单独引入Jedis,虽然Jedis性能也不差,但Lettuce在Spring Boot默认配置下支持连接池、连接复用、响应式编程,日常业务场景完全够用。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>版本号不需要单独指定,跟着Spring Boot父级依赖走就行。如果用到连接池,还需要额外引入commons-pool2,这一点很容易被忽略,不引入的话连接池配置是不会生效的。
<dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-pool2</artifactId> </dependency>2.2 连接池配置与Lettuce的懒连接机制
Lettuce本身是异步的,底层通过共享连接来减少TCP开销。但Spring Boot在单机模式下如果不对spring.redis.lettuce.pool做配置,部分版本默认是不开连接池的。为了在懒加载场景下保持稳定,我一般会显式配置连接池参数:
spring: data: redis: host: 127.0.0.1 port: 6379 password: database: 0 timeout: 3s lettuce: pool: max-active: 16 max-idle: 8 min-idle: 4 max-wait: 2s这里要专门提一下Lettuce的“懒连接”机制。默认情况下,Lettuce客户端不会在Spring容器启动时就建立Redis TCP连接,而是等到第一次真正执行命令时才创建连接。如果你的代码里有“启动时校验Redis连接是否正常”的逻辑,仅仅注入了RedisTemplate却没调用任何方法,校验会成功但不代表Redis连接真正可用,必须显式执行一次ping命令才行。
2.3 序列化器选择:JSON还是JDK序列化
这是懒加载能否正常运行的关键之一。Spring Boot默认使用JdkSerializationRedisSerializer存取数据,存进去的值是一长串二进制内容,用redis-cli看几乎不可读,而且要求所有放入缓存的实体类必须实现Serializable接口,否则会直接抛异常。我的做法是全局配置GenericJackson2JsonRedisSerializer作为默认序列化器,让缓存里的数据变成可读的JSON字符串。
@Configuration public class RedisConfig { @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(); StringRedisSerializer stringSerializer = new StringRedisSerializer(); template.setKeySerializer(stringSerializer); template.setHashKeySerializer(stringSerializer); template.setValueSerializer(jsonSerializer); template.setHashValueSerializer(jsonSerializer); template.afterPropertiesSet(); return template; } }配置完成后,缓存里的值会变成类似{"id":1001,"name":"商品A"}的JSON。需要注意的是,GenericJackson2JsonRedisSerializer反序列化时会依赖类型信息,如果你在缓存读取时用的是一个和缓存写入时类型不匹配的引用,就可能出现类型转换异常,这个坑到第5章我会具体讲排查过程。对懒加载来说,序列化配置的直接意义是:同一个key被不同业务方法读取时,格式要一致,否则一旦格式不同,Redis里存了两份数据,缓存就形同虚设了。
2.4 启动验证与第一个缓存读写
集成完成后,我习惯写一个简单的启动校验类,测试Redis连接和序列化是否正常:
@Component public class RedisStartupValidator implements ApplicationRunner { private final StringRedisTemplate stringRedisTemplate; public RedisStartupValidator(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate = stringRedisTemplate; } @Override public void run(ApplicationArguments args) { String key = "startup:check:" + UUID.randomUUID(); String value = "ok"; stringRedisTemplate.opsForValue().set(key, value, 10, TimeUnit.SECONDS); String result = stringRedisTemplate.opsForValue().get(key); if (Objects.equals(result, value)) { System.out.println("Redis connection and serialization OK"); } } }StringRedisTemplate是独立于RedisTemplate的Bean,它的key和value都固定用String序列化,用来做连接校验最干净。如果这一步能跑通,后续用RedisTemplate读写JSON数据就有了基础。到这里环境就绪了,下面进入懒加载的真正实现。
3. 注解式懒加载:@Cacheable让首次访问自动回填
3.1 @Cacheable的工作流程
Spring Cache对懒加载的支持是最直观的。在方法上添加@Cacheable注解后,每次调用该方法时Spring容器会先从缓存中查找结果,如果找到就直接返回,不会进入方法体;只有找不到缓存时才会执行方法内部逻辑,并在方法返回后自动把结果回填到Redis。这个流程天然就是“懒加载”:数据是第一次被访问时才加载的。
@Service public class ProductQueryService { @Cacheable(cacheNames = "product:detail", key = "#productId") public ProductDetail getProductDetail(Long productId) { // 模拟从数据库查询商品详情 return productMapper.selectDetailById(productId); } }用的时候需要先在启动类或配置类上开启缓存代理:
@SpringBootApplication @EnableCaching public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }这段代码跑起来之后,第一次调用getProductDetail(1001)会执行数据库查询,Redis里生成product:detail::1001这个key;第二次再调用相同参数时,Spring拦截器直接返回缓存值,方法体不会执行。这就是最基础的懒加载落地方式,代码量几乎为零。
3.2 缓存Key设计:Spring Expression Language
@Cacheable的key属性支持SpEL表达式,这是懒加载key能否精准命中的核心。刚开始做的人喜欢用key = "#productId.toString()"或者其他复杂写法,其实大多数场景直接用#productId就够了。如果需要根据多个字段组合生成缓存key,可以用#userId + ':' + #productId这种方式:
@Cacheable(cacheNames = "cart:item", key = "T(String).valueOf(#userId) + ':' + #productId") public CartItem getCartItem(Long userId, Long productId) { return cartItemMapper.selectByUserIdAndProductId(userId, productId); }key的设计原则是“唯一且可读”。我一般会在cacheNames里用业务模块名,在key里用业务主键,这样排查问题时用redis-cli扫key就能看出数据来自哪个业务。千万不要把整个请求参数对象当成key,参数会变,key的膨胀速度极快,Redis内存会快速被打满。
3.3 用RedisCacheManager控制TTL
@Cacheable注解本身没有设置过期时间的能力,TTL必须统一由RedisCacheManager管理。Spring Boot 2.x之后推荐用RedisCacheConfiguration来声明不同缓存空间的过期时间:
@Configuration public class CacheConfig { @Bean public RedisCacheManager cacheManager(RedisConnectionFactory factory) { RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(10)) .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())); Map<String, RedisCacheConfiguration> cacheConfigurations = new HashMap<>(); cacheConfigurations.put("product:detail", defaultConfig.entryTtl(Duration.ofHours(1))); cacheConfigurations.put("cart:item", defaultConfig.entryTtl(Duration.ofMinutes(30))); return RedisCacheManager.builder(factory) .cacheDefaults(defaultConfig) .withInitialCacheConfigurations(cacheConfigurations) .build(); } }这里需要再三强调:TTL一定要差异化设计。基础商品信息1小时过期,购物车条目30分钟过期,活动库存5分钟过期。如果所有缓存都用一个TTL,会出现两个极端,要么数据更新太慢被业务投诉,要么过期太频繁导致懒加载频繁回填数据库,Redis几乎成了摆设。
3.4 sync = true 对付缓存击穿
懒加载有一个天然风险:当某个热点key失效的瞬间,如果突然来了大量请求同时发现缓存不存在,这些请求会全部穿过缓存打到数据库,这就是缓存击穿。Spring Cache给了一个简单的解决方式,在@Cacheable上增加sync = true属性:
@Cacheable(cacheNames = "product:detail", key = "#productId", sync = true) public ProductDetail getProductDetail(Long productId) { return productMapper.selectDetailById(productId); }sync=true会让Spring Cache在缓存未命中时对方法调用加锁,多个并发线程只有一个能执行方法体,其余线程等待结果。这个机制在单机环境下非常有效。但它有一个限制:如果方法返回null,结果不会被缓存,所以要注意避免空值穿透带来的数据库压力。另外,sync=true后调用方拿到的结果可能是一个包装代理对象,序列化时如果没做好适配,也可能出现问题。这些问题在接近生产环境的场景下都会暴露,我的建议是先用默认配置跑通,再逐步开启同步锁,逐个验证。
3.5 失效和更新的配套操作:@CacheEvict与@CachePut
懒加载并不意味着缓存永远不动。数据一旦在后台更新,旧缓存就会变成脏数据。我的经验是:写操作和缓存删除要强绑定。商品详情更新后直接删除该商品的缓存,下次读取时懒加载机制会重新从数据库拉取最新数据:
@CacheEvict(cacheNames = "product:detail", key = "#productId") public void updateProduct(Long productId, ProductUpdateCommand command) { productMapper.updateById(productId, command.toEntity()); }@CachePut则是把方法返回值强制写进缓存,适合那种“更新接口希望立刻让下一次查询命中新值”的场景。但在实际业务中我更多使用@CacheEvict,原因是删除比更新更安全——更新缓存要保证写入的数据和数据库完全一致,一旦序列化字段漏了就出问题;删除缓存顶多多一次数据库查询,不会出错。这是典型的“用性能换一致性”的取舍。
4. 手动控制的懒加载工具:当注解解决不了问题的时候
4.1 注解方案的局限
@Cacheable解决80%的懒加载场景没问题,但有些场景它确实力不从心。比如你可能希望缓存未命中时先异步查一下数据库,而不是同步阻塞调用;或者你需要把多个字段查询合并成一个缓存值;再或者你需要在回填缓存之前做一些特殊处理,比如只缓存部分字段。注解的粒度太粗,回填逻辑完全藏在框架里,出了问题很难加日志观察。这时候手写一个懒加载工具类反而更灵活。
4.2 一个精简版懒加载工具类
我手写的懒加载工具核心逻辑就是三个步骤:先查Redis,没查到就加载数据源,最后回填Redis。关键是要把数据加载逻辑以Supplier或Function的形式传入,让接口调用方决定怎么查数据:
@Component public class LazyLoadCacheService { private final RedisTemplate<String, Object> redisTemplate; public LazyLoadCacheService(RedisTemplate<String, Object> redisTemplate) { this.redisTemplate = redisTemplate; } public <T> T lazyGet(String key, long ttl, TimeUnit unit, Supplier<T> dataLoader) { Object cachedValue = redisTemplate.opsForValue().get(key); if (cachedValue != null) { return (T) cachedValue; } T loadedValue = dataLoader.get(); if (loadedValue != null) { redisTemplate.opsForValue().set(key, loadedValue, ttl, unit); } return loadedValue; } }实际调用时,业务代码只关心key、过期时间、数据加载函数,其他细节全部封装进工具类:
ProductDetail detail = lazyLoadCacheService.lazyGet( "product:detail:" + productId, 60, TimeUnit.MINUTES, () -> productMapper.selectDetailById(productId) );这个工具类最大的优点是回填逻辑可控。我可以很方便地加入监测日志,打印出缓存未命中的key、加载耗时、回填结果,排查问题时比注解方式直观得多。
4.3 缓存穿透兜底与互斥锁设计
懒加载还有个需要防的场景是缓存穿透。用户传了一个不存在的商品ID,数据库查询返回为空,工具类因为没有回填缓存,下次同样的请求还会再去查一次数据库。恶意攻击时可以构造大量不存在的ID把数据库拖垮。我的兜底策略很简单:查询结果为空时,也写入一个短TTL的占位符缓存:
public <T> T lazyGetWithBlank(String key, long ttl, TimeUnit unit, Supplier<T> dataLoader) { Object cachedValue = redisTemplate.opsForValue().get(key); if (cachedValue != null) { return "EMPTY".equals(cachedValue) ? null : (T) cachedValue; } T loadedValue = dataLoader.get(); if (loadedValue == null) { redisTemplate.opsForValue().set(key, "EMPTY", 5, TimeUnit.MINUTES); } else { redisTemplate.opsForValue().set(key, loadedValue, ttl, unit); } return loadedValue; }当热点key失效的瞬间,并发请求也可能打到数据库种。这时候可以用Redis的SET NX命令实现互斥锁,让只有一个请求去加载数据,其他请求短暂等待或直接返回旧值。核心思路是用setIfAbsent抢锁,抢到锁的线程加载并回填缓存,最后删除锁:
public <T> T lazyGetWithLock(String key, long ttl, TimeUnit unit, Supplier<T> dataLoader) { Object cachedValue = redisTemplate.opsForValue().get(key); if (cachedValue != null) { return "EMPTY".equals(cachedValue) ? null : (T) cachedValue; } String lockKey = key + ":lock"; Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 5, TimeUnit.SECONDS); try { if (Boolean.TRUE.equals(locked)) { T loadedValue = dataLoader.get(); if (loadedValue == null) { redisTemplate.opsForValue().set(key, "EMPTY", 5, TimeUnit.MINUTES); } else { redisTemplate.opsForValue().set(key, loadedValue, ttl, unit); } return loadedValue; } else { // 没有抢到锁,等待一段时间后重新获取缓存 Thread.sleep(50); return (T) redisTemplate.opsForValue().get(key); } } finally { if (Boolean.TRUE.equals(locked)) { redisTemplate.delete(lockKey); } } }这段代码在正式项目里完全可以用,但在高并发下要特别注意锁的过期时间不能太短,否则业务线程还没执行完锁就失效了,别的线程会重复加载数据。我一般会结合业务查询耗时把锁TTL设置为3到5秒,并对加载耗时做监控,如果频繁出现超过锁TTL的情况,就需要优化数据源查询而不仅仅是调大锁过期时间。
4.4 异步回填:提升接口响应速度的进阶玩法
如果你的数据源本身很慢,同步回填会让第一次请求的响应时间变得不可接受。这时候可以换一种思路:第一次请求先返回数据(哪怕是旧版本),再异步把最新数据回填到缓存。这个做法适合那些对数据时效性容忍度比较高、但要求接口响应必须稳定的场景。实现方式也不复杂,用CompletableFuture就可以:
public void asyncFill(String key, long ttl, TimeUnit unit, Supplier<Object> dataLoader) { CompletableFuture.runAsync(() -> { try { Object loadedValue = dataLoader.get(); if (loadedValue != null) { redisTemplate.opsForValue().set(key, loadedValue, ttl, unit); } } catch (Exception e) { log.error("async fill cache failed, key: {}", key, e); } }, executor); }这里我踩过的一个坑是:一次性把大量key提交到线程池回填,结果线程池阻塞,影响了核心业务线程。所以这个玩法一定得配套一个有界队列的独立线程池,回填任务数超过阈值直接丢弃,让下次请求再触发懒加载即可。
5. 实测数据与踩坑排查全过程
5.1 懒加载前后的接口耗时与内存对比
我给商品详情模块做懒加载改造之后,上线一周后的监控数据很有参考价值。改造前用的预热策略,Redis内存使用率稳定在2GB左右;改造后一周,内存降到800MB上下,降幅超过一半。接口平均响应时间没有明显变化,因为热点数据的缓存命中率依然维持在95%左右,非热点数据即便不缓存也无感知。真正变化最大的是启动时间:原来预热脚本需要20分钟,改造后服务启动秒级完成,发布速度和迭代效率提升非常明显。
5.2 大key扫描引发的Redis阻塞事故
懒加载跑了一个月后,有一次Redis实例出现周期性卡顿,定位到最后是一个商品套餐缓存出了问题。这个套餐关联了几百个子商品,序列化后的JSON长度超过2MB,存储在单个key里。一旦该key过期,懒加载逻辑开始回填,序列化一个超大对象耗时明显增加,而且redis-cli扫描大key时也会触发Redis阻塞。解决方式是把大key拆分成多个小key,按照子商品维度分别缓存,读取时再聚合。懒加载不是万能的,它只决定“何时加载”,不解决“加载多大对象”的问题,数据模型设计阶段还是要尽量控制单个缓存值的大小。
5.3 序列化不一致导致缓存形同虚设
有一段时间我发现某个业务的缓存命中率只有30%左右,排查Redis里的key发现同样的业务数据居然存在两套key,一套前缀是product:detail,另一套前缀带上了类名加哈希值。原因是有个接口直接用了RedisTemplate读写数据,另一个接口用了@Cacheable,两边的序列化策略和key生成规则不一致。@Cacheable生成的key有cacheNames::key格式的前缀,手动RedisTemplate写入时没有这个前缀,两边永远互相找不到。这个坑提醒我:在同一个Spring Boot项目里,要么全部走Spring Cache注解,要么全部走手动工具类,尽量不要混用,如果一定要混用,需要把RedisTemplate的key拼接规则和@Cacheable完全对齐。
5.4 缓存雪崩:TTL集中失效的复盘
还有一次我设置了某个缓存空间统一30分钟过期,结果大量key在同一时刻失效,懒加载回填压力瞬间打到数据库,主库CPU上升到告警线。这就是缓存雪崩的雏形。解决方式有两个层面:在TTL设计上给不同key加入随机浮动,比如基准时间30分钟加上0到5分钟的随机偏移;在懒加载工具类回填时限制重试频率,防止热点key集中失效造成数据源被打爆。后来我把工具类的TTL入参改成了baseTtl + RandomUtils.nextInt(0, randomRange)的写法,再也没出现过集中失效的情况。
5.5 命中率监控:别让懒加载变成瞎加载
懒加载做完了,一定要能回答“缓存到底有没有用”这个问题。我比较推荐给工具类增加一个简单的计数器,把缓存命中和未命中的次数统计到日志或指标系统里。Spring Boot Actuator结合Micrometer可以看Redis的指标,但业务维度的命中率还是要在代码层自己埋点。我手写工具类时每个key都会记录:是命中、未命中后加载、还是未命中后回填。这些数据能直接告诉你哪些key长期未命中,说明根本没人在用,可以调短TTL甚至不加缓存;哪些key命中率极高,说明是热点数据,可以考虑延长TTL并重点关注缓存击穿风险。
6. 我的一些体会和最终建议
做了这个简易的Redis懒加载改造之后,我最大的体会是:懒加载不是银弹,但它是绝大多数业务场景下限度和复杂度最低的方案。如果你的项目启动慢、缓存内存浪费严重、热点数据变化频繁,不妨先把预热脚本停掉,改成按需加载,观察一段时间命中率和数据库压力曲线,往往能得到远超预期的效果。它还会逼你把缓存key、TTL、序列化这些基础配置一次想清楚,这些能力在后续任何缓存架构里都用得上。
最后分享一个我在实战中养成的习惯:每次配置新的缓存业务时,先把“过期时间、缓存的是何种数据、数据量上限、并发访问量”四项信息写进注释里,再开始编码。这样一周后回来看代码,能很快判断懒加载在这个场景是否还合理,这套方法论让我少走了很多弯路。如果你的场景刚好碰上了缓存预热的痛点,试着从这次改造里拆出合适的第一步,直接动手就好。