这是《Redis 零基础实战指南》的独立章节版。本章从概念、实操和生产排查三个视角展开,代码块保留了原书可直接运行的版本。 缓存是 Redis 最常见的用途,也是最容易在生产环境出事故的用途。很多团队接入 Redis 时只做了“查库前先查缓存”,上线后却发现命中率忽高忽低、数据库偶发被打穿、缓存和数据库数据不一致、热点 key 把单个节点打满。
本章把缓存架构拆成四个核心问题:
- 数据该不该缓存、缓存多久;
- 读路径和写路径如何设计;
- 穿透、击穿、雪崩如何防护;
- 一致性、热点与容量如何治理。
读完本章,你应该能独立完成一次缓存架构评审,而不是只会说“加一个 Redis”。
25.1 先问清楚缓存目标
设计缓存前,先回答五个问题:
| 问题 | 关键判断 | 常见错误 |
|---|---|---|
| 解决什么问题 | 降低数据库读压力、减少响应时间、减少下游调用 | 为了用 Redis 而用 Redis |
| 数据是否可丢 | 纯缓存可重建;状态数据必须考虑持久化与恢复 | 把订单状态当纯缓存 |
| 一致性要求多高 | 秒级延迟可接受还是必须强一致 | 盲目追求“永远一致” |
| 读放大有多大 | 高频读、低频写最适合缓存 | 冷数据全量入缓存 |
| 容量是否可控 | key 数量、value 大小、增长率 | 无 TTL 无限增长 |
一个典型判断公式是:
适合缓存:读多写少 + 数据可重建 + 允许短暂不一致 + 热点集中
谨慎缓存:写多读少 + 数据强一致 + 访问随机 + value 很大
对于强一致数据,正确的做法通常是数据库作为唯一事实源,Redis 只做可丢弃的读加速,并通过版本号、时间戳或失效机制降低不一致窗口。
25.2 key 与 TTL 设计
缓存设计的第一步不是写代码,而是定义 key 规范。
25.2.1 key 命名规范
推荐格式:
业务域:对象类型:业务标识[:字段]
示例:
mall:product:base:10001
mall:product:stock:10001
mall:user:profile:8888
mall:rank:daily:20260825
命名原则:
| 原则 | 说明 |
|---|---|
| 可读 | 能从 key 看出业务归属,方便排查 |
| 可查 | 支持按前缀 SCAN、监控和治理 |
| 可过期 | 每个 key 都应有明确生命周期 |
| 可隔离 | 多环境、多租户加环境或租户前缀 |
| 不太长 | 在可读性和内存占用间取得平衡 |
如果多个服务共用 Redis Cluster,建议加上环境或租户前缀:
mall:prod:product:base:10001
user:prod:profile:10001
25.2.2 TTL 策略
TTL 不是随手写的数字,而是缓存生命周期设计。
| 数据类型 | 建议 TTL | 说明 |
|---|---|---|
| 商品详情 | 30 分钟到 24 小时 | 允许短暂不一致,可主动失效 |
| 用户资料 | 10 分钟到 1 小时 | 写后失效或短 TTL |
| 配置 | 5 分钟到 1 小时 | 支持手动刷新 |
| 会话 | 与业务会话等长 | 通常 7 到 30 天 |
| 验证码 | 5 分钟 | 必须固定过期 |
| 热点排行榜 | 周期 + 少量滑差 | 避免统一过期 |
| 空值缓存 | 30 秒到 5 分钟 | 防穿透,时间要短 |
建议使用“基础 TTL + 随机抖动”:
public Duration cacheTtl(Duration base) {
long jitter = ThreadLocalRandom.current()
.nextLong(0, Math.max(1, base.toSeconds() / 10));
return base.plusSeconds(jitter);
}
抖动不要太大,否则排障困难。通常取基础值的 5% 到 10% 即可。
25.3 四种读写模式
25.3.1 Cache Aside
Cache Aside 是最常用模式:业务代码同时维护数据库和缓存。
读流程:
1. 查 Redis
2. 命中:直接返回
3. 未命中:查数据库
4. 写回 Redis 并设置 TTL
5. 返回数据
写流程通常有两种选择:
方案 A:更新数据库 -> 删除缓存
方案 B:更新数据库 -> 更新缓存
一般推荐方案 A,即“先更新数据库,再删除缓存”。原因:
- 删除是幂等操作,重复执行没有副作用;
- 避免并发写时旧值覆盖新值;
- 懒加载避免冷数据常驻内存;
- 不需要计算完整缓存对象。
示例:
public Product getProduct(Long id) {
String key = "mall:product:base:" + id;
String cached = redis.opsForValue().get(key);
if (cached != null) {
if ("NULL".equals(cached)) {
return null;
}
return objectMapper.readValue(cached, Product.class);
}
Product product = productMapper.selectById(id);
if (product == null) {
redis.opsForValue().set(key, "NULL", Duration.ofSeconds(60));
return null;
}
redis.opsForValue().set(key, writeJson(product), cacheTtl(Duration.ofMinutes(30)));
return product;
}
@Transactional
public void updateProduct(Product product) {
productMapper.updateById(product);
redis.delete("mall:product:base:" + product.getId());
}
优点:简单、通用、缓存可丢。
缺点:存在短暂不一致;缓存删除失败需要补偿;代码侵入较高。
25.3.2 Read Through
Read Through 把“未命中后加载”封装到缓存层,业务只调用缓存接口。
sequenceDiagram
participant App as 业务应用
participant Cache as 缓存组件
participant Redis as Redis
participant DB as 数据库
App->>Cache: get(id)
Cache->>Redis: GET key
alt 命中
Redis-->>Cache: value
else 未命中
Cache->>DB: select(id)
DB-->>Cache: value
Cache->>Redis: SET key TTL
end
Cache-->>App: value
优点:业务代码干净,加载、空值、序列化、监控统一治理。
缺点:需要基础设施支持,缓存层组件容易变成复杂度中心。
适合中大型团队做统一缓存 SDK。
25.3.3 Write Through
Write Through 在写请求到来时同步写缓存和数据库。
写请求 -> 缓存层 -> 更新/删除 Redis -> 更新数据库 -> 返回
如果把 Redis 写成功但数据库写失败,会出现缓存领先于事实源的问题。因此工程上更常见的是:
先数据库事务提交,再同步操作缓存,失败后补偿删除
适合更新频率可控、要求读新数据的配置或元数据。
25.3.4 Write Behind
Write Behind 先写缓存或内存缓冲,再异步批量写数据库。
写请求 -> Redis/内存队列 -> 立即返回
|
+--> 异步消费 -> 批量写数据库
适合计数、浏览量、行为日志等允许丢失或可对账的数据。
示例:
public void increaseView(Long productId) {
redis.opsForZSet().incrementScore(
"mall:product:view:buffer", String.valueOf(productId), 1
);
}
@Scheduled(fixedDelay = 5000)
public void flushViewBuffer() {
String key = "mall:product:view:buffer";
Set<ZSetOperations.TypedTuple<String>> batch =
redis.opsForZSet().rangeWithScores(key, 0, 499);
if (batch == null || batch.isEmpty()) {
return;
}
List<ViewUpdate> updates = batch.stream()
.map(tuple -> new ViewUpdate(
Long.valueOf(tuple.getValue()),
tuple.getScore().longValue()))
.toList();
for (ZSetOperations.TypedTuple<String> tuple : batch) {
redis.opsForZSet().remove(key, tuple.getValue());
}
viewMapper.batchIncrease(updates);
}
注意:上面的“先删缓冲再落库”在宕机时可能丢数据。更稳妥的做法是使用 Stream 或消息队列,消费成功后再确认;强一致数据不要用 Write Behind。
四种模式对比:
| 模式 | 写路径 | 一致性 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| Cache Aside | 更新库后删缓存 | 最终一致 | 低 | 绝大多数业务缓存 |
| Read Through | 缓存层负责加载 | 最终一致 | 中 | 统一缓存 SDK |
| Write Through | 同步写库和缓存 | 接近强一致 | 中 | 配置、元数据 |
| Write Behind | 异步批量写库 | 弱一致 | 高 | 浏览量、行为日志 |
25.4 缓存穿透、击穿与雪崩
这三个词经常被混着说,但它们对应的是三种不同故障。
| 问题 | 触发条件 | 典型现象 | 核心解法 |
|---|---|---|---|
| 穿透 | 查询不存在的数据 | 每次都打到数据库 | 空值缓存、布隆过滤器、参数校验 |
| 击穿 | 热点 key 过期瞬间 | 单点热点打崩数据库 | 互斥加载、逻辑过期、热点不过期 |
| 雪崩 | 大量 key 同时失效或 Redis 不可用 | 数据库整体飙高 | TTL 抖动、多级缓存、限流降级 |
25.4.1 缓存穿透
常见攻击形态:
恶意请求 id=-1
恶意请求 id=999999999
恶意请求随机 UUID
因为数据库里也没有这些数据,Redis 永远无法命中。
第一层防线是参数校验:
if (id == null || id <= 0) {
throw new BadRequestException("invalid product id");
}
第二层是空值缓存:
if (product == null) {
redis.opsForValue().set(key, "", Duration.ofSeconds(60));
return Product.empty(id);
}
注意空值 TTL 不要太长,否则新建数据后短时间内查不到。
第三层是布隆过滤器。它适合“能事先枚举全量合法 key”的场景:
public boolean mayExist(Long id) {
return bloomFilter.mightContain("mall:product:" + id);
}
布隆过滤器特点:
- 判断不存在则一定不存在;
- 判断存在则可能存在;
- 标准布隆过滤器不能删除元素;
- 重建时通常使用版本号切换。
适合商品、用户、订单号这类可枚举数据,不适合无限增长且没有边界的随机查询。
25.4.2 缓存击穿
一个百万级 QPS 的热点商品缓存过期,成千上万请求同时查库,数据库瞬间被打垮。
方案一:互斥加载。
public Product getProductWithLock(Long id) {
String key = "mall:product:base:" + id;
Product cached = readCache(key);
if (cached != null) {
return cached;
}
String lockKey = "lock:load:product:" + id;
String token = UUID.randomUUID().toString();
Boolean locked = redis.opsForValue()
.setIfAbsent(lockKey, token, Duration.ofSeconds(5));
if (Boolean.TRUE.equals(locked)) {
try {
cached = readCache(key);
if (cached != null) {
return cached;
}
Product product = productMapper.selectById(id);
writeCache(key, product, Duration.ofMinutes(30));
return product;
} finally {
release(lockKey, token);
}
}
sleepQuietly(30);
return getProduct(id);
}
注意:示例中的递归重试在生产中要限制次数或改为短暂自旋。热点极高时,还可以让部分请求直接降级返回旧数据。
方案二:逻辑过期。
value 中保存业务数据和逻辑过期时间,Redis key 本身不设置 TTL:
{
"data": {"id": 10001, "price": 199.00},
"expireAt": 1787600000000
}
读取时:
- 未到逻辑过期时间,直接返回旧数据;
- 已过期,尝试拿锁异步重建;
- 拿不到锁也返回旧数据。
优点是读路径不阻塞,缺点是重建期间返回旧值,适合“可用性优先”的热点数据。
方案三:热点 key 不过期,由变更事件主动删除或更新。
适合首页配置、活动页、热门榜单等可控数据。
25.4.3 缓存雪崩
雪崩有两类。
第一类是大量 key 同时过期:
00:00 活动开始,预热了 100 万个 key,TTL 全部 1 小时
01:00 全部过期,数据库被打崩
解决:
- TTL 加随机抖动;
- 分批预热;
- 预热时间错开;
- 热点数据使用逻辑过期;
- 设置数据库侧限流。
第二类是 Redis 本身不可用:
Redis 主从切换 / 网络分区 / 大量慢命令 / 内存达到 maxmemory
解决:
- 应用侧缓存兜底;
- 数据库查询加限流;
- 核心接口降级;
- Redis 使用哨兵或 Cluster;
- 客户端设置合理超时和熔断;
- 告警先行,不要等到数据库崩溃才发现。
一个可用的读路径骨架:
public Product safeGetProduct(Long id) {
Product cached = localCache.getIfPresent(id);
if (cached != null) {
return cached;
}
try {
cached = redisGetProduct(id);
if (cached != null) {
localCache.put(id, cached);
return cached;
}
} catch (RedisConnectionException e) {
log.warn("Redis unavailable, product id={}", id, e);
}
if (!databaseLimiter.tryAcquire()) {
throw new ServiceUnavailableException("cache and db are busy");
}
return productMapper.selectById(id);
}
缓存故障时最重要的原则:宁可拒绝部分流量,也不要让数据库和 Redis 一起失败。
25.5 缓存与数据库一致性
25.5.1 为什么没有简单方案
看一个并发时序:
事务 A:更新数据库 price=100
事务 B:更新数据库 price=200
事务 B:写缓存 price=200
事务 A:写缓存 price=100
数据库最终是 200,缓存却是 100。这就是“更新缓存”在并发写下的覆盖问题。
再看“先删缓存,再更新数据库”:
写请求:删除缓存
读请求:未命中,查数据库,得到旧值
写请求:更新数据库
读请求:把旧值写入缓存
缓存会长期保留旧值,直到 TTL 过期或再次变更。
因此主流选择通常是:
先提交数据库事务 -> 删除缓存 -> 失败则重试或订阅 binlog 补偿
25.5.2 延迟双删
延迟双删用于缓解“读写并发导致旧值回填”的问题:
1. 删除缓存
2. 更新数据库
3. 等待一小段时间
4. 再删除一次缓存
伪代码:
@Transactional
public void update(Product product) {
String key = "mall:product:base:" + product.getId();
redis.delete(key);
productMapper.updateById(product);
delayQueue.send(new CacheInvalidation(key), Duration.ofMillis(500));
}
延迟时间没有万能值,通常取主从同步延迟、事务提交耗时、读请求耗时的上界的组合,常见 200ms 到 1s。
延迟双删能降低概率,不能保证强一致。如果业务要求强一致,应让数据库成为唯一读取来源,或使用分布式事务与串行化控制。
25.5.3 基于 binlog 的失效
更可靠的做法是监听 MySQL binlog,由数据变更事件触发缓存删除。
flowchart LR
App[业务应用] --> DB[(MySQL)]
DB --> Canal[Canal / Debezium]
Canal --> MQ[Kafka]
MQ --> Invalidator[缓存失效服务]
Invalidator --> Redis[(Redis)]
业务写路径只负责数据库提交,缓存失效由独立组件异步完成。
优点:
- 不依赖业务代码双写;
- 能覆盖漏删、失败重试和存量修正;
- 与应用语言解耦;
- 可按表和行精确失效。
挑战:
- 事件顺序要保证,通常同 key 串行;
- 消费要幂等;
- 需要处理删库、DDL、重放和归档;
- 链路变长,排查成本上升;
- 仍不能提供强一致,只能提供可审计的最终一致。
消费示例:
@KafkaListener(topics = "mysql.mall.product")
public void onProductChange(ConsumerRecord<String, String> record) {
ProductChange event = objectMapper.readValue(record.value(), ProductChange.class);
if (event.after() != null) {
String key = "mall:product:base:" + event.after().getId();
invalidationGuard.deleteIfVersionMatch(key, event.after().getVersion());
}
}
如果数据库有版本号,可以用版本号避免旧事件覆盖新缓存。
25.5.4 一致性等级选择
| 业务要求 | 推荐方案 | 说明 |
|---|---|---|
| 可接受分钟级延迟 | TTL + 定时刷新 | 实现最简单 |
| 秒级最终一致 | 更新库后删缓存 + 重试 | 大多数业务 |
| 高可用读旧值 | 逻辑过期 + 异步重建 | 热点详情页 |
| 较强一致 | 版本号 + binlog 串行失效 | 需要基础设施 |
| 强一致 | 不缓存或读写锁串行化 | 数据库直读 |
缓存一致性口诀:数据库是事实源,缓存是投影;投影可以旧,但不能没人负责修正。
25.6 多级缓存架构
当单层 Redis 仍然扛不住读压力,或 Redis 抖动会直接影响核心接口时,可以引入多级缓存。
flowchart TD
Client[客户端] --> Edge[CDN / 边缘缓存]
Edge --> Nginx[Nginx shared dict / Lua]
Nginx --> Local[应用本地缓存 Caffeine]
Local --> Redis[(Redis Cluster)]
Redis --> DB[(MySQL)]
25.6.1 本地缓存层
本地缓存常用 Caffeine:
private final Cache<Long, Product> localCache = Caffeine.newBuilder()
.maximumSize(20_000)
.expireAfterWrite(Duration.ofSeconds(5))
.recordStats()
.build();
本地缓存特点:
| 维度 | 本地缓存 | Redis |
|---|---|---|
| 延迟 | 纳秒到微秒级 | 网络往返,通常亚毫秒到毫秒 |
| 容量 | 受 JVM 内存限制 | 可独立扩容 |
| 一致性 | 多实例更新困难 | 集中存储,较易失效 |
| 故障影响 | Redis 故障仍可兜底 | Redis 故障影响全局 |
| 适用 | 极热点、短 TTL、可旧值 | 大部分共享缓存 |
本地缓存更新方式:
- 短 TTL 自动过期;
- Redis 变更后发布 Pub/Sub 通知;
- 通过 Kafka 广播失效事件;
- 定时刷新热点集合。
Pub/Sub 通知示例:
redis.convertAndSend("cache:invalidate", "mall:product:base:10001");
订阅方:
public void onMessage(String message) {
localCache.invalidate(extractId(message));
}
Pub/Sub 不保证可靠送达。重要失效事件建议使用 Stream 或 Kafka,Pub/Sub 只做加速通知。
25.6.2 热点 key 治理
热点 key 是 Cluster 中最常见的负载不均问题。
识别方式:
redis-cli --hotkeys
redis-cli info commandstats
redis-cli monitor # 只用于短时间诊断
也可以在应用侧采样 key 并上报监控系统。
治理方案:
| 方案 | 做法 | 适用 |
|---|---|---|
| 本地缓存 | 热点放 Caffeine | 读极多、允许秒级旧值 |
| key 拆分 | key#1 到 key#N 随机读 |
可拆分的只读热点 |
| 复制多分片 | 多副本读 | 特定代理或客户端方案 |
| 限流熔断 | 控制到达 Redis 的流量 | 保护底层 |
| 静态化 | 生成静态文件或 CDN | 首页、活动页 |
热点 key 拆分示例:
String key = "mall:product:base:" + id + ":" + ThreadLocalRandom.current().nextInt(16);
Product product = readFromRedis(key);
if (product == null) {
product = loadAndFillAllCopies(id);
}
写多副本时要保证所有副本同步更新,或者只对不可变数据使用拆分。
25.7 缓存预热、降级与限流
25.7.1 缓存预热
预热适合可预测流量,例如大促、活动上线、新版本发布。
预热清单:
- 热点商品、活动页、类目树;
- 用户维度短期热点;
- 配置、开关、字典表;
- 排行榜和库存;
- 防穿透空值集合。
分批预热脚本:
@Scheduled(cron = "0 30 23 * * ?")
public void warmUpTomorrowTopProducts() {
List<Long> ids = productMapper.selectTomorrowTop(50_000);
for (int i = 0; i < ids.size(); i += 500) {
List<Long> batch = ids.subList(i, Math.min(i + 500, ids.size()));
cacheLoader.asyncWarmUp(batch);
sleepQuietly(200);
}
}
预热不是越多越好。冷数据预热会浪费内存,还会挤压真正热点。
25.7.2 降级策略
| 场景 | 降级动作 |
|---|---|
| Redis 连接失败 | 返回本地缓存或默认值 |
| 数据库压力过高 | 限流、排队、返回静态数据 |
| 非核心数据缺失 | 返回兜底空结构 |
| 商品详情失败 | 返回基础商品信息 |
| 推荐列表失败 | 返回热门榜单 |
| 评论数失败 | 隐藏字段或返回 0 |
降级要在开发期定义,不要在事故现场临时改代码。
25.7.3 限流设计
缓存失效后的数据库加载必须有限流:
RateLimiter dbLimiter = RateLimiter.create(2000); // 每秒最多 2000 次查库
public Product loadFromDatabase(Long id) {
if (!dbLimiter.tryAcquire(50, TimeUnit.MILLISECONDS)) {
throw new ServiceUnavailableException("database limiter rejected");
}
return productMapper.selectById(id);
}
限流阈值来自数据库压测,而不是拍脑袋。常见分层限流:
接口限流 -> 用户限流 -> 缓存加载限流 -> 数据库连接池限流
25.8 命中率与监控
缓存不是上线就结束,命中率决定它是否真的在工作。
核心指标:
| 指标 | 定义 | 说明 |
|---|---|---|
| 命中率 | hits / (hits + misses) | 按 key 前缀和接口分别统计 |
| 未加载数 | 未命中但也没有触发查库 | 空值、被拦截请求 |
| 平均延迟 | Redis 命令耗时 | 关注 P99 |
| 加载耗时 | miss 后到数据库返回 | 评估雪崩风险 |
| 缓存大小 | 每前缀 key 数和内存 | 容量治理 |
| 过期速率 | 单位时间过期 key 数 | 发现集中失效 |
| 旧值率 | 版本落后比例 | 一致性治理 |
| 错误率 | 超时、连接失败、反序列化失败 | 可用性 |
建议在 SDK 中埋点:
public <T> T getWithMetrics(String key, Class<T> type, Supplier<T> loader) {
long start = System.nanoTime();
String value = redis.get(key);
long redisCost = System.nanoTime() - start;
if (value != null) {
metrics.recordHit(prefix(key), redisCost);
return deserialize(value, type);
}
metrics.recordMiss(prefix(key), redisCost);
long dbStart = System.nanoTime();
T loaded = loader.get();
metrics.recordLoad(prefix(key), System.nanoTime() - dbStart);
return loaded;
}
治理建议:
- 命中率低于 60% 时重新评估缓存价值;
- 空 key 大量出现时检查恶意请求;
- miss 突增优先看是否 TTL 到期;
- Redis 延迟升高的同时命中率下降,通常是故障前兆;
- 每天输出 key 前缀 Top N 内存和 QPS。
25.9 常见错误设计
| 错误 | 后果 | 修正 |
|---|---|---|
| 所有 key 相同 TTL | 集中失效 | TTL 抖动 |
| 缓存对象无限大 | 网络和内存压力 | 拆字段、压缩、分页 |
用 KEYS 扫描生产 |
阻塞 Redis | 使用 SCAN |
| 更新缓存而不是删除 | 并发覆盖 | 删除 + 重载 |
| 强一致业务走缓存 | 数据错乱 | 数据库直读或读写锁 |
| 只缓存不监控 | 不知道缓存是否有效 | 建立命中率指标 |
| Redis 失败直接查库 | 数据库被打崩 | 限流降级 |
| 空值 TTL 很长 | 新数据不可见 | 缩短空值 TTL |
25.10 一个生产级读路径模板
综合本章内容,一个健壮的商品读路径如下:
public Product getProduct(Long id) {
validateId(id);
Product product = localCache.getIfPresent(id);
if (product != null) {
return product;
}
try {
product = redisCache.getProduct(id);
if (product != null) {
localCache.put(id, product);
return product;
}
} catch (CacheUnavailableException e) {
metrics.redisFailure("product");
}
if (!loadLimiter.tryAcquire()) {
return fallbackProduct(id);
}
product = productMapper.selectById(id);
if (product == null) {
redisCache.putEmpty(id, Duration.ofSeconds(60));
return null;
}
redisCache.putProduct(id, product, randomTtl(Duration.ofMinutes(30)));
localCache.put(id, product);
return product;
}
这个模板体现五个原则:
- 参数先校验;
- 本地缓存保护极热点;
- Redis 失败不影响进程存活;
- 查库必须限流;
- 空值和正常值都有 TTL。
25.11 本章小结
- Cache Aside 是默认选择,写路径推荐“先更新数据库,再删除缓存”;
- 穿透、击穿、雪崩分别对应不存在数据、单热点过期、批量失效或整体不可用;
- 空值缓存、TTL 抖动、互斥加载、逻辑过期、限流降级是核心防护手段;
- 延迟双删和 binlog 失效只能实现最终一致,强一致场景应放弃缓存或引入串行化;
- 本地缓存适合极热点,Redis 适合共享热点,数据库是最后的事实源;
- 缓存架构必须监控命中率、miss 加载、旧值率、内存和错误率。
25.12 思考题
- 为什么“先删除缓存,再更新数据库”更容易造成长期旧值?
- 逻辑过期方案在什么业务下比互斥加载更合适?
- 如果缓存删除失败,你会设计怎样的重试表和补偿任务?
- Redis Cluster 中某个分片 CPU 达到 100%,如何确认是否为热点 key,如何治理?
- 一个接口缓存命中率只有 40%,但业务方坚持要缓存,你会从哪些角度分析?