动态数据缓存是后端服务里最常见的需求之一。所谓懒加载,就是数据第一次被访问时才写入缓存,后续请求直接命中,而不是在服务启动或者数据变更时就把缓存填满。这种策略对读多写少的动态数据特别友好,能避免大量冷数据白白占用Redis内存。下面从原理、实现和常见坑三个层面,把这套方案讲透。

懒加载和预加载到底差在哪
预加载也叫预热,指的是在服务启动或数据更新时,主动把数据写入缓存。它的优点是请求一来就能命中缓存,缺点是很多数据可能根本没人访问,白白占用内存,尤其对数据量大的业务来说浪费明显。懒加载则是反向思路:第一个请求发现缓存里没有,回源到数据库查询,查完写入缓存,后面的请求就能享受缓存加速了。
两者的选择标准主要看访问模式。如果某批数据几乎每次启动后都会被高频访问,比如配置信息、首页轮播,可以预热;如果数据量大、访问分散,比如用户维度的个性化数据,懒加载明显更划算。实际项目中两者经常混用:基础配置预热,动态业务数据懒加载。
懒加载的典型实现是旁路缓存模式,读和写的流程分别是:读的时候先查Redis,命中直接返回;未命中则查数据库,把结果写入Redis再返回。写的时候先更新数据库,再删除缓存,让下一次读取触发重新加载。这套流程看起来简单,但细节上有很多容易踩的坑。
核心实现代码与关键细节
下面是一段典型的懒加载实现,以伪Java代码展示读路径的完整逻辑:
public User getUser(Long userId) {
String key = "user:" + userId;
// 第一步:先查缓存
String cached = redis.get(key);
if (cached != null) {
// 命中空值标记,说明该数据不存在,防止穿透
if ("NULL".equals(cached)) {
return null;
}
return JSON.parseObject(cached, User.class);
}
// 第二步:回源数据库
User user = userDao.selectById(userId);
if (user == null) {
// 写入空值标记,短过期时间
redis.setex(key, 60, "NULL");
return null;
}
// 第三步:写入缓存,TTL加随机值防止雪崩
int ttl = 1800 + new Random().nextInt(600);
redis.setex(key, ttl, JSON.toJSONString(user));
return user;
}这段代码里有三个值得注意的设计。第一个是空值标记:如果数据库里也不存在这条数据,就往缓存里写一个特殊标记并设置较短过期时间,避免恶意请求拿着不存在的ID反复穿透到数据库。第二个是TTL随机化:如果大量Key在同一时刻写入且TTL相同,过期时间到了会集中失效,数据库瞬间承受全部流量,也就是缓存雪崩。加一个随机偏移量把过期时间打散,是很低成本的防护手段。第三个是Key的命名规范,建议统一为业务前冒号加唯一标识的形式,方便后续排查和批量管理。
写路径同样有讲究,推荐先更新数据库再删缓存,而不是更新数据库后去更新缓存:
public void updateUser(User user) {
// 先更新数据库
userDao.updateById(user);
// 再删除缓存,让下次读取重新懒加载
redis.del("user:" + user.getId());
}选择删除而不是更新缓存,原因是并发的两个写请求可能导致缓存里留下旧值,而删除操作天然幂等,下一次读请求会触发懒加载拿到最新数据。这个策略叫延迟双删时可以进一步加固:更新前删一次,更新后延迟几百毫秒再删一次,把主从延迟窗口内的脏数据清掉。
高并发下的热点Key与一致性问题
懒加载有个天然的薄弱点:缓存失效的瞬间,如果同时涌进来大量请求,这些请求都会发现缓存未命中,然后一齐打到数据库,这就是缓存击穿。对热点数据,最直接的解法是加互斥锁,让只有一个请求去回源,其他请求短暂等待后读缓存:
public User getUserSafe(Long userId) {
String key = "user:" + userId;
String cached = redis.get(key);
if (cached != null) {
return "NULL".equals(cached) ? null : JSON.parseObject(cached, User.class);
}
String lockKey = "lock:" + key;
// 尝试获取互斥锁
if (redis.set(lockKey, "1", "NX", "EX", 5)) {
try {
// 双重检查,可能别的请求已经加载完
cached = redis.get(key);
if (cached != null) {
return JSON.parseObject(cached, User.class);
}
User user = userDao.selectById(userId);
int ttl = 1800 + new Random().nextInt(600);
redis.setex(key, ttl, user == null ? "NULL" : JSON.toJSONString(user));
return user;
} finally {
redis.del(lockKey);
}
}
// 没抢到锁,短暂等待后重试读取
Thread.sleep(50);
return getUserSafe(userId);
}注意获取锁之后要做双重检查,因为在你等待锁的这段时间里,前一个请求可能已经把缓存填好了,直接读缓存返回即可,没必要再查一次库。这个细节能省掉相当一部分数据库压力。
一致性是另一个绕不开的话题。先更新数据库再删缓存,在正常情况下最多只有毫秒级的数据不一致窗口。但如果删除缓存这一步失败了,脏数据会一直留到过期。工程上的兜底办法有两个:一是给缓存设置兜底TTL,就算删除失败,过期后也会自动重新加载;二是把删除动作发到消息队列异步重试,或者利用Redis的Keyspace Notifications监听过期事件做补偿。根据业务对一致性的敏感程度选择即可,绝大多数场景下兜底TTL加短重试队列已经够用。
落地时的几条实践建议
第一,过期时间不要一刀切。不同业务的数据变化频率不同,配置类可以设长一点,用户行为类数据设短一些,再叠加随机偏移。第二,监控命中率。Redis的info命令可以看到keyspace命中的统计,命中率长期低于某个阈值说明缓存策略有问题,要么Key设计不合理,要么TTL太短。第三,序列化方式要统一,团队里混用JSON和 JDK 序列化会导致反序列化失败,建议在公共缓存模块里封装好。第四,懒加载首请求会有一次回源延迟,对延迟敏感的场景可以在上线前做一次预热脚本,把已知的重点数据批量灌入,两种策略结合使用效果最好。
总的来说,懒加载的核心价值是按需使用内存,配合空值防穿透、随机TTL防雪崩、互斥锁防击穿这三板斧,再辅以删除缓存的一致性策略,就能覆盖绝大多数动态数据缓存的场景。先把基本流程跑通,再根据监控数据逐步加固,比一上来就堆复杂方案更稳妥。