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#1key#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%,但业务方坚持要缓存,你会从哪些角度分析?