RedisNotes

第 25 章:缓存架构设计:从会用缓存到设计缓存

zjc 于 2026-01-25 发布

这是《Redis 零基础实战指南》的独立章节版。本章从概念、实操和生产排查三个视角展开,代码块保留了原书可直接运行的版本。 缓存是 Redis 最常见的用途,也是最容易在生产环境出事故的用途。很多团队接入 Redis 时只做了“查库前先查缓存”,上线后却发现命中率忽高忽低、数据库偶发被打穿、缓存和数据库数据不一致、热点 key 把单个节点打满。

本章把缓存架构拆成四个核心问题:

  1. 数据该不该缓存、缓存多久;
  2. 读路径和写路径如何设计;
  3. 穿透、击穿、雪崩如何防护;
  4. 一致性、热点与容量如何治理。

读完本章,你应该能独立完成一次缓存架构评审,而不是只会说“加一个 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,即“先更新数据库,再删除缓存”。原因:

  1. 删除是幂等操作,重复执行没有副作用;
  2. 避免并发写时旧值覆盖新值;
  3. 懒加载避免冷数据常驻内存;
  4. 不需要计算完整缓存对象。

示例:

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);
}

布隆过滤器特点:

  1. 判断不存在则一定不存在;
  2. 判断存在则可能存在;
  3. 标准布隆过滤器不能删除元素;
  4. 重建时通常使用版本号切换。

适合商品、用户、订单号这类可枚举数据,不适合无限增长且没有边界的随机查询。

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
}

读取时:

  1. 未到逻辑过期时间,直接返回旧数据;
  2. 已过期,尝试拿锁异步重建;
  3. 拿不到锁也返回旧数据。

优点是读路径不阻塞,缺点是重建期间返回旧值,适合“可用性优先”的热点数据。

方案三:热点 key 不过期,由变更事件主动删除或更新。

适合首页配置、活动页、热门榜单等可控数据。

25.4.3 缓存雪崩

雪崩有两类。

第一类是大量 key 同时过期:

00:00 活动开始,预热了 100 万个 key,TTL 全部 1 小时
01:00 全部过期,数据库被打崩

解决:

  1. TTL 加随机抖动;
  2. 分批预热;
  3. 预热时间错开;
  4. 热点数据使用逻辑过期;
  5. 设置数据库侧限流。

第二类是 Redis 本身不可用:

Redis 主从切换 / 网络分区 / 大量慢命令 / 内存达到 maxmemory

解决:

  1. 应用侧缓存兜底;
  2. 数据库查询加限流;
  3. 核心接口降级;
  4. Redis 使用哨兵或 Cluster;
  5. 客户端设置合理超时和熔断;
  6. 告警先行,不要等到数据库崩溃才发现。

一个可用的读路径骨架:

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)]

业务写路径只负责数据库提交,缓存失效由独立组件异步完成。

优点:

  1. 不依赖业务代码双写;
  2. 能覆盖漏删、失败重试和存量修正;
  3. 与应用语言解耦;
  4. 可按表和行精确失效。

挑战:

  1. 事件顺序要保证,通常同 key 串行;
  2. 消费要幂等;
  3. 需要处理删库、DDL、重放和归档;
  4. 链路变长,排查成本上升;
  5. 仍不能提供强一致,只能提供可审计的最终一致。

消费示例:

@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、可旧值 大部分共享缓存

本地缓存更新方式:

  1. 短 TTL 自动过期;
  2. Redis 变更后发布 Pub/Sub 通知;
  3. 通过 Kafka 广播失效事件;
  4. 定时刷新热点集合。

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 缓存预热

预热适合可预测流量,例如大促、活动上线、新版本发布。

预热清单:

  1. 热点商品、活动页、类目树;
  2. 用户维度短期热点;
  3. 配置、开关、字典表;
  4. 排行榜和库存;
  5. 防穿透空值集合。

分批预热脚本:

@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;
}

治理建议:

  1. 命中率低于 60% 时重新评估缓存价值;
  2. 空 key 大量出现时检查恶意请求;
  3. miss 突增优先看是否 TTL 到期;
  4. Redis 延迟升高的同时命中率下降,通常是故障前兆;
  5. 每天输出 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;
}

这个模板体现五个原则:

  1. 参数先校验;
  2. 本地缓存保护极热点;
  3. Redis 失败不影响进程存活;
  4. 查库必须限流;
  5. 空值和正常值都有 TTL。

25.11 本章小结

25.12 思考题

  1. 为什么“先删除缓存,再更新数据库”更容易造成长期旧值?
  2. 逻辑过期方案在什么业务下比互斥加载更合适?
  3. 如果缓存删除失败,你会设计怎样的重试表和补偿任务?
  4. Redis Cluster 中某个分片 CPU 达到 100%,如何确认是否为热点 key,如何治理?
  5. 一个接口缓存命中率只有 40%,但业务方坚持要缓存,你会从哪些角度分析?