5324 字
约 17 分钟
1
定时缓存预热与分布式锁通用业务文档

定时缓存预热与分布式锁通用业务文档

1. 文档目的

本文用于沉淀“定时缓存预热 + 分布式锁”这一类通用业务方案。它适用于首页推荐、热门商品、排行榜、活动配置、热点文章、公共字典等访问频繁、数据变化相对可控的场景。

文档同时以当前伙伴匹配系统的推荐缓存任务作为案例,区分以下三类内容:

  • 通用业务设计原则;
  • 当前项目中的具体实现;
  • 当前实现中需要继续改进的地方。

2. 业务背景

2.1 普通缓存的访问模式

常见的缓存旁路模式是“Cache Aside”:请求到达后先查缓存,缓存没有数据时再查询数据库,并将查询结果写入缓存。

用户请求
  → 查询 Redis
  → 命中:直接返回
  → 未命中:查询数据库
  → 写入 Redis
  → 返回结果

这种方式实现简单,但在以下时机可能出现问题:

  • 系统刚启动,缓存还是空的;
  • 缓存批量过期;
  • 热点 Key 同时失效;
  • 低峰期没有访问,缓存没有提前生成;
  • 大量用户在同一时间首次访问首页。

如果大量请求同时发生缓存未命中,就可能出现数据库瞬时压力升高、接口响应变慢,甚至缓存击穿等问题。

2.2 缓存预热的定义

缓存预热是指:在用户真正访问之前,系统主动把预先确定的热点数据加载到缓存中。

定时任务或启动任务
  → 查询数据库或其他数据源
  → 组装业务结果
  → 写入 Redis
  → 用户请求直接读取缓存

它把原本由用户请求承担的第一次查询成本,提前转移到系统任务中完成。

3. 业务目标

缓存预热主要解决以下问题:

  1. 减少热点接口的首次访问延迟。
  2. 降低高峰期数据库查询压力。
  3. 避免大量请求同时回源数据库。
  4. 让系统在启动、缓存刷新或活动开始前处于可服务状态。
  5. 在多实例部署时,避免相同预热任务被重复执行。

需要注意,缓存预热不是为了让所有数据永远存在 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 失败时不要写入错误结果

推荐的处理方式是:

  1. 查询数据源;
  2. 校验结果是否完整;
  3. 结果有效时再写入缓存;
  4. 查询失败时保留旧缓存或记录失败,不要用空结果覆盖正常缓存。

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 应包含分页参数

当前推荐接口接收 pageSizepageNum,但缓存 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、数据一致性、幂等性、异常恢复、敏感信息和多实例测试。分布式锁只是其中的一环,不能替代缓存失效策略和完整的故障处理机制。

定时缓存预热与分布式锁通用业务文档
http://www.clxhxhhr.top/posts/529/
作者
clxstart
发布于
2026-09-08
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。