定时缓存预热与分布式锁通用业务文档
1. 文档目的
本文用于沉淀“定时缓存预热 + 分布式锁”这一类通用业务方案。它适用于首页推荐、热门商品、排行榜、活动配置、热点文章、公共字典等访问频繁、数据变化相对可控的场景。
文档同时以当前伙伴匹配系统的推荐缓存任务作为案例,区分以下三类内容:
- 通用业务设计原则;
- 当前项目中的具体实现;
- 当前实现中需要继续改进的地方。
2. 业务背景
2.1 普通缓存的访问模式
常见的缓存旁路模式是“Cache Aside”:请求到达后先查缓存,缓存没有数据时再查询数据库,并将查询结果写入缓存。
用户请求
→ 查询 Redis
→ 命中:直接返回
→ 未命中:查询数据库
→ 写入 Redis
→ 返回结果
这种方式实现简单,但在以下时机可能出现问题:
- 系统刚启动,缓存还是空的;
- 缓存批量过期;
- 热点 Key 同时失效;
- 低峰期没有访问,缓存没有提前生成;
- 大量用户在同一时间首次访问首页。
如果大量请求同时发生缓存未命中,就可能出现数据库瞬时压力升高、接口响应变慢,甚至缓存击穿等问题。
2.2 缓存预热的定义
缓存预热是指:在用户真正访问之前,系统主动把预先确定的热点数据加载到缓存中。
定时任务或启动任务
→ 查询数据库或其他数据源
→ 组装业务结果
→ 写入 Redis
→ 用户请求直接读取缓存
它把原本由用户请求承担的第一次查询成本,提前转移到系统任务中完成。
3. 业务目标
缓存预热主要解决以下问题:
- 减少热点接口的首次访问延迟。
- 降低高峰期数据库查询压力。
- 避免大量请求同时回源数据库。
- 让系统在启动、缓存刷新或活动开始前处于可服务状态。
- 在多实例部署时,避免相同预热任务被重复执行。
需要注意,缓存预热不是为了让所有数据永远存在 Redis,而是针对有访问价值的数据进行主动准备。
4. 适用场景与不适用场景
4.1 适用场景
适合同时满足以下特征的数据:
- 读请求明显多于写请求;
- 访问具有明显热点,例如首页、热门榜单或重点用户;
- 数据允许存在一定时间的延迟;
- 数据可以通过查询或计算得到;
- 预热任务可以重复执行,或者已经设计了幂等机制。
典型例子:
- 首页推荐列表;
- 热门商品和排行榜;
- 热门文章、课程和活动信息;
- 城市、行业、标签等公共配置;
- 大促开始前的活动商品数据。
4.2 不适用场景
以下数据不宜简单采用定时预热:
- 强实时数据,例如账户余额、库存余量和支付状态;
- 数据量巨大且访问分布均匀;
- 计算成本非常高,预热本身会压垮数据库;
- 数据权限高度个性化,无法确定哪些用户会访问;
- 数据变化频繁,刚预热就可能失效。
这类场景通常需要实时失效、消息驱动更新、局部缓存或其他一致性方案配合。
5. 总体设计
5.1 角色划分
| 角色 | 职责 |
|---|---|
| 定时调度器 | 按固定时间或固定间隔触发预热任务 |
| 热点数据选择器 | 确定需要预热的用户、商品、榜单或其他对象 |
| 数据查询模块 | 从数据库或其他数据源获取原始数据 |
| 结果组装模块 | 生成前端或业务接口需要的结果 |
| 缓存写入模块 | 生成 Key、设置 TTL 并写入 Redis |
| 分布式锁 | 保证同一任务在多个实例中只执行一次 |
| 监控告警模块 | 记录耗时、成功数、失败数和锁竞争情况 |
5.2 通用处理流程
flowchart TD
A[定时触发预热任务] --> B[确定热点数据范围]
B --> C[尝试获取分布式锁]
C -->|未获取| D[记录跳过并结束]
C -->|获取成功| E[查询数据源]
E --> F[组装并校验结果]
F --> G[写入缓存并设置 TTL]
G --> H[记录成功结果]
H --> I[释放分布式锁]
5.3 关键设计思想
该方案的核心不是“定时执行”本身,而是将下面几个决策组合起来:
热点识别
+ 低峰期执行
+ 缓存结果复用
+ 多实例互斥
+ 失败可重试
+ 写入可幂等
6. 为什么选择定时预热
6.1 将高峰期成本转移到低峰期
例如首页推荐数据每天凌晨刷新,那么可以在凌晨访问量较低时查询数据库并写入缓存。白天用户访问时,只需要读取 Redis,不必等待第一次计算。
没有预热:高峰期由用户触发查询
有预热:低峰期由后台任务提前完成查询
6.2 平滑数据库压力
如果缓存完全依赖用户首次访问,数据库压力会随着流量突然增加。定时任务可以把查询集中在可控时间执行,也可以通过分批预热、限流和分页来平滑压力。
6.3 只预热高价值数据
系统通常不会为所有用户或所有数据生成缓存,而是维护一个热点集合:
重点用户列表
热门商品列表
高访问量文章列表
运营配置的活动列表
这样可以控制 Redis 容量和数据库查询成本。
7. 为什么需要分布式锁
7.1 单实例和多实例的区别
在单实例应用中,定时任务通常只有一个 JVM 执行,重复执行风险相对较小。但在生产环境中,应用经常会部署多个实例:
实例 A ─┐
实例 B ─┼─ 同一个 cron 时间触发任务
实例 C ─┘
Spring Scheduler 只知道当前 JVM 内的任务状态,并不知道其他实例是否已经执行。因此,3 个实例可能同时查询数据库并写入同一个 Redis Key。
7.2 没有锁会发生什么
如果预热任务只是简单写缓存,最终结果可能看起来没有错误,因为多个实例写入的是同一个结果。但系统会产生重复成本:
- 数据库查询被执行多次;
- Redis 写入被执行多次;
- 网络、线程和 CPU 被重复占用;
- 任务本身如果包含发送消息、生成文件等副作用,可能被重复执行;
- 多实例同时回源可能形成新的流量峰值。
因此,分布式锁主要解决的是“跨实例的执行互斥”,而不是单纯解决缓存读写。
7.3 锁的粒度
锁 Key 应该对应一个明确的任务范围,例如:
cache:preheat:recommend:lock
cache:preheat:hot-products:lock
cache:preheat:ranking:lock
如果不同业务任务使用同一把锁,就会造成不必要的串行执行。一个任务内部如果可以并行处理不同对象,也可以进一步使用更细粒度的锁:
cache:preheat:recommend:{userId}:lock
cache:preheat:product:{productId}:lock
锁粒度越细,并发能力通常越好,但锁的管理和故障处理也会更复杂。
8. 分布式锁的执行规则
8.1 获取锁
预热任务开始时先尝试获取锁:
RLock lock = redissonClient.getLock("cache:preheat:lock");
if (!lock.tryLock(0, -1, TimeUnit.MILLISECONDS)) {
// 其他实例正在执行,本实例跳过本轮任务
return;
}
tryLock(0, -1, ...) 通常可以理解为:
- 等待时间为 0,抢不到就立即放弃;
- 租约时间使用 Redisson 看门狗机制自动续期;
- 避免多个实例长时间排队等待同一个预热任务。
是否需要等待,要根据任务类型决定。对于按时执行、下一轮还可以重试的预热任务,抢不到锁直接跳过通常是可以接受的。
8.2 释放锁
释放锁必须放在 finally 中,并确认当前线程持有该锁:
try {
// 执行预热逻辑
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
这样可以避免因为异常没有释放锁,也可以避免当前线程误释放其他线程持有的锁。
8.3 锁不是最终一致性方案
分布式锁只能限制同一时间谁执行任务,不能自动保证:
- 数据库和 Redis 永远一致;
- 缓存一定不会过期;
- 任务失败后一定重试成功;
- 应用宕机后一定恢复任务。
因此还需要设计 TTL、重试、监控和失效策略。
9. 缓存 Key 与 TTL 设计
9.1 Key 必须包含影响结果的参数
缓存 Key 应覆盖所有会影响查询结果的条件。例如分页接口至少要考虑:
业务对象 + 用户标识 + 页码 + 页大小 + 筛选条件
推荐的 Key 形式:
recommend:{userId}:page:{pageNum}:size:{pageSize}
如果只使用 recommend:{userId},不同分页请求可能读取到同一份数据,导致结果错误。
9.2 TTL 应与刷新周期匹配
如果任务每天执行一次,缓存 TTL 至少要覆盖到下一次刷新,通常还会增加一定缓冲时间:
刷新周期 = 24 小时
缓存 TTL = 24~26 小时
如果 TTL 只有几十秒,而任务每天才执行一次,那么预热数据很快就会失效,定时预热的收益会大幅下降。
另一种方案是缩短任务周期,例如每 5 分钟刷新一次,并设置 10~15 分钟 TTL。
9.3 防止同一时刻大量过期
多个 Key 如果设置了完全相同的 TTL,可能在同一时间集中失效。可以给 TTL 增加随机值:
实际 TTL = 基础 TTL + 随机时间
这样可以分散回源请求,降低缓存集中失效的风险。
10. 数据一致性与幂等性
10.1 预热任务应尽量幂等
幂等的含义是:同一个任务执行一次或多次,最终结果基本一致。
缓存预热通常使用覆盖写入:
查询最新数据 → set 相同 Key → 设置新的 TTL
即使任务因为网络重试或实例切换执行多次,也不会产生重复业务数据。
10.2 数据更新后的处理
如果源数据发生变化,不能只依赖下一次定时刷新。可以根据实时性要求选择:
- 更新数据库后主动删除相关缓存;
- 更新数据库后主动重建相关缓存;
- 通过消息队列异步刷新缓存;
- 接受短时间旧数据,等待下一轮预热;
- 使用短 TTL 降低旧数据存活时间。
10.3 失败时不要写入错误结果
推荐的处理方式是:
- 查询数据源;
- 校验结果是否完整;
- 结果有效时再写入缓存;
- 查询失败时保留旧缓存或记录失败,不要用空结果覆盖正常缓存。
11. 异常与故障处理
11.1 Redis 不可用
预热失败时,应记录错误并保留接口的回源能力。即使预热失败,推荐接口仍可以按照“Redis 未命中 → 查询数据库”的方式工作,只是性能会下降。
11.2 数据库不可用
数据库不可用时,不能生成新的缓存。若旧缓存仍然有效,可以继续提供旧数据;如果业务允许,可以采用“逻辑不过期”策略,在后台异步刷新。
11.3 获取锁失败
获取锁失败不一定是系统故障,可能只是其他实例正在执行。应该记录为跳过或竞争事件,不要把它当成异常反复重试,避免多个实例再次形成竞争。
11.4 执行过程中实例宕机
如果持锁实例宕机,分布式锁应通过租约或看门狗机制最终释放。任务可以在下一次调度时重新执行,必要时增加失败重试或人工触发入口。
12. 安全与数据边界
缓存里的数据同样属于系统数据,不应因为放在 Redis 中就忽略安全问题。
预热前需要确认:
- 是否只缓存必要字段;
- 是否已经完成用户信息脱敏;
- 是否会缓存密码、Token、身份证号等敏感信息;
- 不同用户的个性化数据是否可能串缓存;
- Key 是否容易被猜测并被未授权读取;
- Redis 是否设置访问密码和网络隔离。
对于用户推荐数据,建议先组装 UserVO 等安全返回对象,再写入 Redis,而不是直接缓存包含内部字段的数据库实体。
13. 监控指标
建议至少记录以下指标:
| 指标 | 作用 |
|---|---|
| 任务开始时间和结束时间 | 计算整体耗时 |
| 预热对象数量 | 判断任务范围是否异常 |
| 成功写入数量 | 判断实际完成情况 |
| 失败数量 | 触发告警和重试 |
| Redis 写入耗时 | 发现缓存服务变慢 |
| 数据库查询耗时 | 发现回源压力 |
| 锁获取成功率 | 判断实例竞争情况 |
| 跳过次数 | 判断任务是否频繁被其他实例占用 |
| 缓存命中率 | 衡量预热效果 |
| 缓存过期后的回源次数 | 发现 TTL 或刷新周期设计问题 |
任务日志中不建议打印密码、Token 或完整用户隐私信息。
14. 测试场景
14.1 功能测试
- 定时任务能够按预期触发;
- 热点对象能够成功写入 Redis;
- 用户访问时能够读取预热数据;
- 缓存 Key 与业务参数对应正确;
- TTL 设置符合预期;
- 空数据不会覆盖有效缓存。
14.2 多实例测试
- 同一时间启动多个应用实例;
- 让所有实例同时触发预热任务;
- 验证只有一个实例执行核心查询和写入;
- 验证未抢到锁的实例能够正常结束;
- 验证持锁实例异常退出后锁最终能够释放。
14.3 故障测试
- Redis 不可用;
- 数据库查询超时;
- 写缓存过程中网络中断;
- 任务执行时间超过预期;
- 任务执行过程中应用重启;
- 缓存过期后多个请求同时访问。
14.4 性能测试
- 对比预热前后的接口平均响应时间;
- 对比预热前后的缓存命中率;
- 观察数据库 QPS 是否下降;
- 测试不同热点数据规模下的任务耗时;
- 测试锁粒度变化对并发能力的影响。
15. 当前项目实现案例
当前伙伴匹配系统在 PreCacheJob.java 中实现了一个简化版的推荐缓存预热任务。
15.1 定时触发
项目启动类开启了定时任务能力:
@EnableScheduling
预热任务使用:
@Scheduled(cron = "0 31 0 * * *")
表示每天凌晨 00:31 执行一次。
15.2 重点用户
当前代码维护了一个重点用户列表:
private List<Long> mainUserList = Arrays.asList(1L);
它没有为所有用户提前生成推荐缓存,而是只处理指定用户,体现了“优先预热热点数据”的思路。
15.3 分布式锁
任务使用固定锁 Key:
"yupao:precachejob:docache:lock"
多个应用实例会竞争这把锁,只有抢到锁的实例继续执行预热。当前实现使用 Redisson 管理锁,并在释放前判断当前线程是否持有锁。
15.4 查询和写入缓存
当前任务固定查询第 1 页、20 条用户数据,然后按照用户 ID 写入推荐缓存:
Key:yupao:user:recommend:{userId}
内容:Page<User>
TTL:30000 毫秒
推荐接口在 UserController.java 中先读取该 Key,未命中时再查询 MySQL 并回写 Redis。
16. 当前项目实现的改进建议
当前代码已经体现了缓存预热和分布式锁的基本思路,但如果作为生产方案,还需要关注以下问题:
16.1 预热周期与 TTL 不匹配
任务每天执行一次,但缓存 TTL 只有 30 秒。缓存过期后,要等到第二天凌晨才会再次由预热任务写入,因此预热效果无法覆盖完整的一天。
可选方案:
- 将 TTL 调整为覆盖下一次预热的时间;
- 缩短定时任务执行周期;
- 使用定时刷新与主动失效结合的方式。
16.2 重点用户不应写死
重点用户列表可以从配置文件、数据库或运营后台读取,便于动态调整热点范围。
16.3 缓存 Key 应包含分页参数
当前推荐接口接收 pageSize 和 pageNum,但缓存 Key 只有用户 ID。不同分页请求可能读取同一个缓存结果,建议将分页参数加入 Key。
16.4 缓存前需要完成脱敏
当前预热任务直接缓存 Page<User>,而 User 实体包含密码等内部字段。正式方案应先转换成 UserVO 或其他安全对象,再写入 Redis。
16.5 预热内容应体现用户差异
当前示例查询逻辑较简单,重点用户可能拿到相同的用户列表。真正的个性化推荐应根据当前用户标签、兴趣或其他业务条件生成不同缓存内容。
16.6 任务查询应控制数据量
不建议在预热任务中无条件查询全部用户。数据量较大时,应采用分页、分批、限流或增量刷新,避免凌晨任务本身造成数据库压力。
17. 推荐伪代码
通用实现可以抽象为:
public void preheat() {
RLock lock = redissonClient.getLock("cache:preheat:lock");
try {
if (!lock.tryLock(0, -1, TimeUnit.MILLISECONDS)) {
log.info("another instance is running, skip this round");
return;
}
List<Long> hotObjectIds = loadHotObjectIds();
for (Long objectId : hotObjectIds) {
CacheData data = queryAndBuildData(objectId);
if (data == null || data.isInvalid()) {
continue;
}
String key = buildCacheKey(objectId);
redisTemplate.opsForValue().set(key, data, ttl, TimeUnit.MINUTES);
}
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
这段伪代码体现了几个要求:先抢锁、再查询数据;只处理热点对象;有效结果才写缓存;无论成功还是异常,最后都尝试安全释放锁。
18. 方案总结
定时缓存预热适合解决“热点数据访问频繁,但不要求每次实时计算”的问题。它通过后台任务提前准备数据,把高峰期的查询成本转移到低峰期;在多实例环境下,再通过 Redisson 分布式锁确保同一轮任务不会被所有实例重复执行。
完整方案可以概括为:
识别热点
→ 选择低峰时间
→ 获取分布式锁
→ 查询并组装数据
→ 校验后写入正确 Key
→ 设置合理 TTL
→ 监控结果并支持失败重试
真正可靠的设计还要同时考虑缓存 Key、TTL、数据一致性、幂等性、异常恢复、敏感信息和多实例测试。分布式锁只是其中的一环,不能替代缓存失效策略和完整的故障处理机制。