适用版本:Redis 7.2.x
在实际业务里,Redis 很少只是一个“把数据放进去再取出来”的简单 KV 存储。真正到了线上环境,缓存设计往往决定了系统的吞吐能力、稳定性和故障边界。很多线上问题并不是 Redis 不够快,而是缓存模式选错了:有的场景适合 Cache-Aside,有的适合读穿透加载,有的更适合多级缓存,有的则必须引入异步刷新与热点隔离。
这篇文章从工程实践角度梳理几种常见缓存设计模式,重点关注:什么时候用、怎么落地、会踩什么坑、风险如何控制。
一、为什么缓存设计不能只停留在“查不到就回源”
很多同学初学 Redis 时,最先接触的是下面这类逻辑:
- 先查 Redis;
- 没命中就查数据库;
- 把数据库结果写回 Redis;
- 返回结果。
这当然没错,但在线上场景里,缓存设计至少还要回答这些问题:
- 缓存 key 如何设计,才能避免冲突与难以排查?
- 空值要不要缓存?
- 过期时间如何设置,才能避免雪崩?
- 更新数据库后,缓存什么时候删、什么时候改?
- 热点 key 被高并发访问时,怎么避免瞬时击穿?
- 一个业务对象是否应该拆成多个 key?
- 是否需要本地缓存 + Redis 两级架构?
所以,工程实践里的缓存设计,本质上是在做三件事:
- 提升命中率:尽量减少不必要的回源;
- 控制一致性风险:让缓存与数据库在业务可接受范围内一致;
- 约束故障影响面:即使缓存失效,也别把数据库拖垮。
二、模式一:Cache-Aside(旁路缓存)
这是最常见、最通用的缓存模式,也是绝大多数业务系统的默认选择。
1. 基本流程
读流程
- 先读缓存;
- 命中则直接返回;
- 未命中则查询数据库;
- 将结果写入缓存,并设置过期时间;
- 返回结果。
写流程
- 先更新数据库;
- 再删除缓存。
2. 为什么不是“更新数据库后更新缓存”
很多人会直觉认为:既然缓存里也有这份数据,那更新数据库之后直接更新缓存不是更好吗?
但从工程实践看,删除缓存通常比更新缓存更稳妥,原因有三点:
- 删除简单:只要删掉 key,不需要关心缓存对象字段是否齐全;
- 避免复杂写路径:有些缓存是聚合对象、派生视图,更新逻辑比数据库更复杂;
- 降低脏数据窗口:删掉后下次读会自动回源构建最新值。
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. 风险点
风险一:并发读写导致短暂脏读
典型时序:
- 线程 A 更新数据库;
- 线程 B 正好读缓存未命中,回源读取到旧值;
- 线程 A 删除缓存;
- 线程 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. 读取流程
- 先读本地缓存;
- 本地未命中再读 Redis;
- Redis 未命中再回源数据库;
- 回填 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 设计的,而是围绕业务读写特征、可接受一致性级别和故障恢复能力设计的。
📝 版权声明:本文为原创技术博客,转载请注明出处。
如文章中存在错误或不准确之处,欢迎在评论区指正,感谢您的阅读与支持!