返回首页

Redis工程实践6.1: 缓存设计模式详解

适用版本:Redis 7.2.x

在实际业务里,Redis 很少只是一个“把数据放进去再取出来”的简单 KV 存储。真正到了线上环境,缓存设计往往决定了系统的吞吐能力、稳定性和故障边界。很多线上问题并不是 Redis 不够快,而是缓存模式选错了:有的场景适合 Cache-Aside,有的适合读穿透加载,有的更适合多级缓存,有的则必须引入异步刷新与热点隔离。

这篇文章从工程实践角度梳理几种常见缓存设计模式,重点关注:什么时候用、怎么落地、会踩什么坑、风险如何控制


一、为什么缓存设计不能只停留在“查不到就回源”

很多同学初学 Redis 时,最先接触的是下面这类逻辑:

  1. 先查 Redis;
  2. 没命中就查数据库;
  3. 把数据库结果写回 Redis;
  4. 返回结果。

这当然没错,但在线上场景里,缓存设计至少还要回答这些问题:

  • 缓存 key 如何设计,才能避免冲突与难以排查?
  • 空值要不要缓存?
  • 过期时间如何设置,才能避免雪崩?
  • 更新数据库后,缓存什么时候删、什么时候改?
  • 热点 key 被高并发访问时,怎么避免瞬时击穿?
  • 一个业务对象是否应该拆成多个 key?
  • 是否需要本地缓存 + Redis 两级架构?

所以,工程实践里的缓存设计,本质上是在做三件事:

  • 提升命中率:尽量减少不必要的回源;
  • 控制一致性风险:让缓存与数据库在业务可接受范围内一致;
  • 约束故障影响面:即使缓存失效,也别把数据库拖垮。

二、模式一:Cache-Aside(旁路缓存)

这是最常见、最通用的缓存模式,也是绝大多数业务系统的默认选择。

1. 基本流程

读流程

  • 先读缓存;
  • 命中则直接返回;
  • 未命中则查询数据库;
  • 将结果写入缓存,并设置过期时间;
  • 返回结果。

写流程

  • 先更新数据库;
  • 再删除缓存。

2. 为什么不是“更新数据库后更新缓存”

很多人会直觉认为:既然缓存里也有这份数据,那更新数据库之后直接更新缓存不是更好吗?

但从工程实践看,删除缓存通常比更新缓存更稳妥,原因有三点:

  1. 删除简单:只要删掉 key,不需要关心缓存对象字段是否齐全;
  2. 避免复杂写路径:有些缓存是聚合对象、派生视图,更新逻辑比数据库更复杂;
  3. 降低脏数据窗口:删掉后下次读会自动回源构建最新值。

3. 典型示例

以商品详情查询为例:

public ProductDTO getProduct(long productId) {
    String key = "product:detail:" + productId;
    String cached = redis.get(key);
    if (cached != null) {
        return Jsons.parse(cached, ProductDTO.class);
    }

    Product product = productDao.selectById(productId);
    if (product == null) {
        redis.setex(key, 60, "NULL");
        return null;
    }

    ProductDTO dto = convert(product);
    redis.setex(key, 1800, Jsons.toJson(dto));
    return dto;
}

public void updateProduct(ProductUpdateCommand cmd) {
    productDao.update(cmd);
    redis.del("product:detail:" + cmd.getProductId());
}

4. 适用场景

Cache-Aside 适合以下场景:

  • 读多写少;
  • 数据允许短时间最终一致;
  • 缓存生命周期较明确;
  • 业务代码层可以接管缓存加载逻辑。

5. 风险点

风险一:并发读写导致短暂脏读

典型时序:

  1. 线程 A 更新数据库;
  2. 线程 B 正好读缓存未命中,回源读取到旧值;
  3. 线程 A 删除缓存;
  4. 线程 B 又把旧值写回缓存。

虽然概率不高,但在高并发下确实可能发生。

常见缓解方案:

  • 删除缓存后短暂延迟双删;
  • 关键场景通过 binlog/消息队列异步失效缓存;
  • 对强一致要求高的场景,不要过度依赖缓存。

风险二:缓存 key 设计混乱

实际线上排查时,最怕看见这种 key:

12345
user_1
cache:abc

建议采用业务前缀 + 对象类型 + 主键 + 视图维度的结构,例如:

user:profile:10001
product:detail:20086
feed:list:user:10001:page:1

这样做的好处是:

  • 可读性强;
  • 便于批量统计;
  • 更容易在监控与排障时定位问题。

三、模式二:Read Through / Write Through(读穿透 / 写穿透)

这类模式常见于带缓存能力的中间层或统一缓存组件中,业务代码不直接关心缓存未命中后的回源细节。

1. 模式理解

  • Read Through:应用只读缓存,缓存未命中时由缓存层负责加载数据;
  • Write Through:应用写缓存时,缓存层同步写到底层存储。

在原生 Redis 中,这不是默认能力,通常需要业务自己封装一层组件来实现。

2. 工程上的好处

  • 业务代码更简洁;
  • 回源逻辑收敛,便于统一监控;
  • 可以统一处理空值缓存、TTL、序列化、熔断降级。

3. 一个统一缓存组件的思路

例如,可以封装一个 CacheClient#get(key, loader, ttl)

public <T> T get(String key, Class<T> type, Supplier<T> loader, Duration ttl) {
    String val = redis.get(key);
    if (val != null) {
        if ("NULL".equals(val)) {
            return null;
        }
        return Jsons.parse(val, type);
    }

    T result = loader.get();
    if (result == null) {
        redis.setex(key, 60, "NULL");
        return null;
    }

    redis.setex(key, ttl.getSeconds(), Jsons.toJson(result));
    return result;
}

业务侧就不需要每次重复写回源模板代码了。

4. 风险点

  • 如果统一组件设计不好,所有业务共享同一套 bug;
  • loader 执行太慢会放大线程阻塞;
  • 过度抽象后,业务方不清楚真实回源成本,容易滥用。

5. 实践建议

  • 统一缓存组件里必须打监控:命中率、回源耗时、异常数;
  • 必须支持空值缓存与随机过期时间;
  • 对热点 key 要支持互斥重建或逻辑过期。

四、模式三:Write Behind / 异步回写

Write Behind 指的是:先写缓存,再异步批量刷新到底层存储。

1. 适合什么场景

它不适合订单、账户余额这种强一致场景,但适合:

  • 计数器;
  • 排行榜积分累计;
  • 实时统计指标;
  • 非关键行为日志聚合。

例如点赞数:

  • 用户点赞时直接 INCR article:like:1001
  • 每隔 5 秒批量把增量刷入数据库。

2. 优点

  • 写入吞吐高;
  • 可以把大量随机写合并成少量批量写;
  • 显著减轻数据库压力。

3. 典型问题

问题一:Redis 宕机导致增量丢失

如果只是把增量留在内存里,没落盘也没同步,下游存储又没及时刷写,那就有丢数据风险。

解决思路:

  • 配合 AOF/RDB 持久化;
  • 使用消息队列承接异步写任务;
  • 对关键统计做定期校对补偿。

问题二:最终一致窗口过长

如果用户刚点赞,详情页展示的数据库值没变,就会觉得“怎么没生效”。

解决思路:

  • 前台展示优先读 Redis 实时值;
  • 数据库只作为最终落库介质;
  • 明确告诉业务方这是“准实时”而非强一致。

五、模式四:多级缓存(本地缓存 + Redis)

当业务读流量特别大时,仅靠 Redis 也未必是最优解。因为所有请求都打到 Redis,本身也是网络调用,热点场景下仍有成本。

于是很多系统会引入两级缓存:

  • 一级缓存:应用进程内本地缓存,例如 Caffeine;
  • 二级缓存:Redis;
  • 三级存储:MySQL / Elasticsearch / 其他主存储。

1. 读取流程

  1. 先读本地缓存;
  2. 本地未命中再读 Redis;
  3. Redis 未命中再回源数据库;
  4. 回填 Redis,再回填本地缓存。

2. 适合场景

  • 热点非常集中;
  • 对 RT 极度敏感;
  • 能接受更复杂的一致性治理。

3. 工程价值

  • 进一步降低 Redis QPS;
  • 缩短读取时延;
  • 在 Redis 短暂抖动时,本地缓存还能顶一部分流量。

4. 最大风险:一致性更难

如果数据库更新后只删了 Redis,没有删本地缓存,就会出现多节点脏读。

常见方案:

  • 更新后删除 Redis,并通过消息总线广播本地缓存失效;
  • 本地缓存 TTL 设置得更短;
  • 本地缓存只放极热点、可容忍短暂陈旧的数据。

5. 示例思路

请求 -> Caffeine -> Redis -> MySQL
                 ^       |
                 |       v
              回填本地  回填 Redis

这类方案并不复杂,复杂的是“删缓存通知”和“节点间一致性控制”。


六、模式五:预热缓存与逻辑过期

1. 为什么需要预热

一些核心数据如果等第一次请求再加载,会把首波流量直接打到数据库,比如:

  • 首页导航配置;
  • 热门商品详情;
  • 热门直播间信息;
  • 常用字典配置。

所以在系统发布、活动开始、流量高峰前,通常会做缓存预热。

2. 逻辑过期是什么

逻辑过期不是依赖 Redis 的 TTL 真正过期,而是在 value 中额外存一个过期时间字段:

{
  "data": {"id": 1001, "title": "Redis 实战"},
  "expireAt": 1780800000
}

读取时:

  • 若逻辑上未过期,直接返回;
  • 若逻辑过期,也先返回旧值,同时异步触发重建;
  • 只有极端场景才阻塞等待新值。

3. 适用场景

  • 热点 key;
  • 可接受短暂旧数据;
  • 重点目标是避免缓存击穿。

4. 风险点

  • 异步重建失败会导致旧值长期存在;
  • 如果没有并发控制,多个线程可能重复重建;
  • 开发人员容易误以为“没设置 TTL 就不会出问题”,实际上脏数据窗口更难感知。

5. 实践建议

  • 逻辑过期通常配合互斥锁使用;
  • 必须上报“过期命中次数”和“重建失败次数”;
  • 对核心 key 配合后台巡检任务。

七、缓存对象设计:整对象缓存还是拆字段缓存?

这也是工程里经常争论的问题。

1. 整对象缓存

例如:

product:detail:1001 -> {name, price, stock, category, tags...}

优点:

  • 一次读取即可返回;
  • 代码简单;
  • 适合详情页场景。

缺点:

  • 某个小字段变更也要整对象失效;
  • 对大对象序列化成本更高;
  • 不同页面需要不同字段时可能浪费带宽。

2. 拆字段缓存

例如:

product:base:1001
product:stock:1001
product:price:1001

优点:

  • 更新粒度细;
  • 热字段可单独优化;
  • 可按页面组合。

缺点:

  • 读取链路更复杂;
  • 多 key 聚合容易增加网络往返;
  • 容易出现部分字段一致、部分字段过期的状态。

3. 怎么选

经验上:

  • 详情页、读多写少、对象不大:优先整对象缓存;
  • 库存、价格、状态等高频变更字段:单独拆 key;
  • 复杂聚合视图:考虑单独构建视图缓存,而不是临时拼装。

八、缓存 TTL 怎么定才更合理

TTL 绝不是随便拍一个数字。

1. 设计 TTL 时要考虑三件事

  • 数据变更频率;
  • 数据的重要性与一致性要求;
  • 回源成本。

例如:

数据类型 建议 TTL 说明
用户基础资料 10~30 分钟 允许短暂旧数据
商品详情 5~30 分钟 结合活动频率调整
配置字典 30~120 分钟 更适合主动失效
空值缓存 30~120 秒 防穿透,但别太长
热点榜单 30 秒~5 分钟 更关注实时性

2. 一定要加随机抖动

不要把一批 key 都设置成统一的 1800 秒,否则很容易在同一时间段集中过期。

建议:

int ttl = 1800 + ThreadLocalRandom.current().nextInt(300);
redis.setex(key, ttl, value);

这样能明显降低同一时刻缓存雪崩的概率。


九、一个可落地的缓存设计清单

如果你在新业务里要设计 Redis 缓存,建议至少按下面的清单过一遍:

1. key 设计

  • 是否有统一前缀?
  • 是否体现对象类型和主键?
  • 是否方便排查与统计?

2. value 设计

  • 是 JSON、Hash 还是 String?
  • 是否需要逻辑过期字段?
  • 是否需要版本号字段以便兼容升级?

3. TTL 设计

  • 过期时间是否和业务变化频率匹配?
  • 是否加了随机抖动?
  • 空值缓存是否单独设置较短 TTL?

4. 回源控制

  • 热点 key 是否有互斥重建?
  • 数据库异常时是否有降级策略?
  • 是否限制单次回源并发?

5. 一致性控制

  • 更新后删缓存还是改缓存?
  • 是否需要延迟双删?
  • 是否有消息通知或订阅机制同步失效?

6. 监控指标

  • 命中率;
  • key 数量与内存占用;
  • 热点 key;
  • 回源 QPS;
  • 缓存重建耗时;
  • Redis 慢查询与网络异常。

十、典型问题复盘

问题一:命中率一直不高

常见原因:

  • key 粒度太细,复用性差;
  • TTL 太短;
  • 查询条件里掺杂了大量随机参数;
  • 明明是热点数据,却没有预热。

问题二:数据库还是被打爆

常见原因:

  • 热点 key 过期后大量并发回源;
  • 空值未缓存,穿透请求太多;
  • Redis 本身异常,所有请求直接打 DB;
  • 没有限流和熔断,缓存失效后无保护。

问题三:业务反馈“数据偶尔不一致”

常见原因:

  • 更新数据库后漏删缓存;
  • 多级缓存中本地缓存未同步失效;
  • 聚合对象缓存只刷新了部分来源数据;
  • 延迟双删没有真正落地或延迟时间设置不合理。

十一、小结

缓存设计从来不是“加个 Redis 就完了”。真正的工程实践重点在于:

  • 先选对模式:Cache-Aside 是基础,异步回写、多级缓存、逻辑过期都是按场景加;
  • 控制风险边界:缓存提升的是性能,不应该引入不可控一致性问题;
  • 把治理能力做进去:TTL、监控、限流、重建、失效通知,这些都不是“后面再补”的可选项。

如果只记住一句话,我会建议是:缓存方案不是围绕 Redis 设计的,而是围绕业务读写特征、可接受一致性级别和故障恢复能力设计的。


📝 版权声明:本文为原创技术博客,转载请注明出处。

如文章中存在错误或不准确之处,欢迎在评论区指正,感谢您的阅读与支持!

上一篇

Redis 架构篇5.4: 集群扩缩容与故障处理

下一篇

Redis 工程实践6.2:分布式锁实践