4 min read

Redis 读多写少场景优化实战:从缓存未命中到高可用架构

Table of Contents

在很多中后台系统里,我们经常会遇到一种很典型的架构矛盾: 业务服务实例很多,但真正承接读流量的 Redis 实例并不多。

表面上看,这似乎只是“缓存资源不足”的问题;但只要线上流量起来,它往往会演变成一连串连锁反应:

  • 缓存未命中增多,数据库回源压力急剧上升
  • 热点 key 失效后,大量请求瞬间打到数据库
  • 某几个 Redis 节点被集中访问,延迟突然抖动
  • 服务越扩越多,但整体读性能并没有线性提升

很多团队会先想到“再加 Redis 节点”,但仅靠堆机器通常解决不了根因。因为真正要回答的问题不是“Redis 少不少”,而是:

  1. 缓存未命中为什么这么多?
  2. 回源数据库为什么没有缓冲层?
  3. 热点流量为什么会集中打到少数节点?
  4. 故障或高峰来临时,系统有没有降级和保护能力?

这篇文章就围绕这个场景,拆解一套更适合工程落地的优化思路。

Redis 读路径与多级缓存架构示意图

上图对应的不是某个厂商的固定产品图,而是本文讨论的真实工程结构抽象: 先用本地缓存吸收热点,再由 Redis 承担共享读流量,最后只让真正的未命中回源数据库。

一、先看问题本质: 不是 Redis 节点少,而是读路径太单薄

如果你的系统读链路是下面这样:

客户端 -> 应用服务 -> Redis -> MySQL

那么一旦 Redis 未命中,所有压力都会直接传导到数据库。服务实例越多,这个问题越明显,因为每个服务实例都会并发回源。

也就是说,真正脆弱的不是某一个 Redis 节点,而是整个读路径缺少“缓冲带”。这时你需要的不是单点优化,而是分层治理

二、优先改造为多级缓存体系

我通常建议先把“单层 Redis 缓存”升级为“本地缓存 + 分布式缓存 + 数据库”的多级结构。

客户端 -> 应用服务
               -> 本地缓存(Caffeine/Guava)
               -> Redis
               -> MySQL

为什么多级缓存有效?

因为它能把不同类型的读请求分流到不同层级:

  • 本地缓存吸收最热点、最频繁的重复查询
  • Redis承担跨实例共享的数据访问
  • 数据库只兜底真正的缓存未命中和一致性修复

这会带来两个非常直接的收益:

  • 降低 Redis 的读压力
  • 大幅减少缓存未命中时的数据库回源流量

本地缓存适合放什么数据?

比较适合以下几类:

  • 变化不频繁的基础资料
  • 高频读取的热点配置
  • 用户维度的短期热点数据
  • 可以容忍秒级延迟刷新的查询结果

如果数据变更非常频繁,或者一致性要求极高,就不适合放太长时间的本地缓存。

一个简单的本地缓存示例

private final Cache<String, ProductDTO> localCache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .expireAfterWrite(Duration.ofSeconds(30))
    .build();

public ProductDTO getProduct(Long productId) {
    String key = "product:" + productId;
    ProductDTO localValue = localCache.getIfPresent(key);
    if (localValue != null) {
        return localValue;
    }

    String redisValue = redisTemplate.opsForValue().get(key);
    if (redisValue != null) {
        ProductDTO dto = JsonUtils.toObject(redisValue, ProductDTO.class);
        localCache.put(key, dto);
        return dto;
    }

    Product product = productMapper.selectById(productId);
    if (product == null) {
        return null;
    }

    ProductDTO dto = ProductDTO.from(product);
    redisTemplate.opsForValue().set(key, JsonUtils.toJson(dto), Duration.ofMinutes(30));
    localCache.put(key, dto);
    return dto;
}

这里本地缓存不是为了替代 Redis,而是为了挡掉最前面的高频读取。

三、Redis 层面不要只加节点,还要拆分读流量

当你发现 Redis 确实已经成为瓶颈时,再考虑扩容。但扩容也分很多方式,不是所有方案都适合当前阶段。

1. 读写分离

如果当前瓶颈主要集中在读取,可以优先考虑:

  • 主节点负责写入
  • 多个只读副本承接查询

这样做的好处是实现相对直接,尤其适合读多写少的业务。但也要注意:

  • 主从同步存在延迟
  • 对强一致性要求高的场景要谨慎
  • 热点 key 仍然可能集中打在某个副本上

2. 分片集群

如果数据量和请求量都明显上来了,可以考虑:

  • 使用 Redis Cluster
  • 或者按业务域进行逻辑拆分

例如:

  • 用户缓存一组
  • 商品缓存一组
  • 订单缓存一组
  • 配置类缓存单独一组

分片的核心价值,不只是“容量变大”,更重要的是把流量热点分散到不同资源池

四、缓存未命中高,不一定是 Redis 不够,可能是策略不对

很多系统的 Redis 命中率上不去,不是因为缓存层能力弱,而是因为缓存策略设计得不够精细。

下面这几个点,通常比“多买两台 Redis”更值得先做。

1. 缓存预热

如果你知道哪些数据会在固定时间段被大量访问,就不应该等第一个用户请求来触发回源。

例如:

  • 服务启动时加载热点数据
  • 每天业务高峰前主动预热
  • 对首页、榜单、活动页等热点内容定时刷新

示例:

@PostConstruct
public void initHotData() {
    List<String> hotKeys = getHotKeysFromDB();
    hotKeys.forEach(key -> {
        Object value = getFromDB(key);
        redisTemplate.opsForValue().set(key, value, Duration.ofMinutes(30));
    });
}

2. 空值缓存

如果有大量查询会命中不存在的数据,就会形成缓存穿透,数据库会被这些无效请求反复打。

此时建议把“查不到”的结果也短暂缓存起来:

def get_data(key):
    data = redis.get(key)
    if data is None:
        data = db.get(key)
        if data is None:
            redis.set(key, "NULL", ex=300)
            return None
        redis.set(key, data, ex=3600)
        return data
    if data == "NULL":
        return None
    return data

3. 布隆过滤器

如果非法 key 请求非常多,例如爬虫、恶意探测、业务参数异常,就可以在 Redis 前面再加一层存在性校验。

它不能解决全部问题,但能有效挡掉明显无效的请求流量。

五、高并发下必须处理热点 key 击穿

对大多数系统来说,最危险的不是“平均未命中”,而是“某一个热点 key 恰好失效”。

这时经常发生的情况是:

  1. 热点 key 过期
  2. 大量并发请求同时发现缓存未命中
  3. 所有请求一起打到数据库
  4. 数据库瞬间被放大流量击穿

常用方案一: 互斥锁

func GetData(key string) interface{} {
    data := redis.Get(key)
    if data == nil {
        if acquireLock(key) {
            defer releaseLock(key)
            data = db.Query(key)
            redis.Set(key, data, TTL)
        } else {
            time.Sleep(100 * time.Millisecond)
            return GetData(key)
        }
    }
    return data
}

思路很直接:

  • 只有一个线程允许回源数据库
  • 其他线程短暂等待后重试

常用方案二: 热点数据逻辑不过期

对于极端热点数据,可以考虑:

  • Redis 中不设真实过期
  • 增加一个业务过期时间字段
  • 后台线程异步刷新

这样做的好处是不会在同一时刻把大量请求全部导向数据库,但缺点是实现复杂度更高,对异步刷新机制要求也更高。

六、批量访问和数据结构也会影响整体读效率

如果你的业务读取不是单 key,而是一次要查几十个相关对象,那么单纯地 GET 多次效率会比较差。

1. 使用 Pipeline 批量获取

echo -e "GET key1\nGET key2\nGET key3" | redis-cli --pipe

在实际代码里,更推荐使用客户端自带的 pipeline 能力,把网络往返次数降下来。

2. 合理使用 Hash 等聚合结构

如果一批数据天然相关,可以考虑:

  • 把同一业务实体的多个字段放到 Hash
  • 或者把关联度高的数据按对象聚合缓存

这样做的价值在于:

  • 减少 key 数量
  • 降低多次网络请求
  • 让缓存结构更接近业务对象

当然,也不要为了“省 key”把完全不相关的数据硬塞到一起,否则更新和失效会变得更复杂。

七、缓存更新策略要和业务一致性要求匹配

不同业务,缓存更新方式应该不同。常见策略大概如下:

策略优点缺点适用场景
定时刷新实现简单实时性一般变化不频繁的数据
写时更新数据新鲜写路径更重一致性要求较高
更新数据库后删缓存简单稳妥存在短暂窗口大多数业务查询场景
过期淘汰系统压力均衡可能读到旧值最终一致性场景

如果没有特别强的写后读一致性要求,我更建议优先使用:

  • 更新数据库
  • 删除缓存
  • 由读请求自然回填

这种模式往往是复杂度最低、长期最稳的方案。

八、监控和扩缩容,决定你能不能扛住线上波动

缓存优化做完以后,如果没有监控,很多问题还是会在真实流量下暴露。

建议至少关注这些指标:

  • Redis 命中率
  • 每秒查询量和慢查询比例
  • 连接数使用率
  • CPU 和内存负载
  • 热点 key 分布
  • 数据库回源流量
  • 锁等待时间

一个很实用的判断标准

如果出现下面任一情况,就说明系统该继续治理了:

  • 命中率持续低于 90%
  • 某些 key 的访问量远高于平均值
  • Redis 延迟开始周期性抖动
  • 数据库回源流量和业务流量一起同步上涨

九、给一套更实用的实施路线

如果当前系统已经在线上运行,建议不要一步到位大改,可以按阶段推进。

短期应急

  • 为热点接口增加本地缓存
  • 加入空值缓存
  • 对核心热点 key 增加互斥锁保护
  • 先把数据库回源压力压下来

中期优化

  • 建立多级缓存体系
  • 补齐缓存预热
  • 增加 Redis 只读副本
  • 优化批量查询和对象聚合缓存

长期治理

  • 按业务域拆分 Redis 集群
  • 建立统一缓存服务层
  • 完善监控、告警、自动扩缩容
  • 用事件机制或消息机制治理缓存一致性

十、特别容易被忽略的三个点

1. 缓存一致性不是白送的

如果本地缓存和 Redis 并存,就必须考虑:

  • Redis 更新后,本地缓存何时失效
  • 多实例之间如何同步热点数据变更
  • 是否需要消息通知或版本号控制

2. 热点 key 要做分散设计

对于极高并发 key,可以考虑做热点分片:

String cacheKey = "product_" + productId;
if (isHotKey(cacheKey)) {
    cacheKey = cacheKey + "_" + ThreadLocalRandom.current().nextInt(10);
}

这类方案适合极端热点读场景,但会提高回收和聚合的复杂度,所以不要滥用。

3. 一定要准备降级方案

真正成熟的系统,不是 Redis 永远不出问题,而是 Redis 出问题后系统还能不能继续提供核心能力。

建议至少具备:

  • Redis 不可用时回退本地缓存
  • 对非核心接口做限流或熔断
  • 关键页面提供兜底数据

总结

当服务实例很多、Redis 读节点有限时,真正的优化方向并不是简单“再加几台缓存”,而是把整条读链路做厚。

如果只提炼几个最关键的结论,我会建议记住这四点:

  1. 优先建立多级缓存,让热点流量就近消化
  2. 用空值缓存、预热和锁机制降低未命中与击穿风险
  3. 用读写分离和分片集群分散 Redis 层压力
  4. 用监控、降级和一致性治理把系统从“能跑”变成“能抗”

缓存优化的终点,不是某一层跑得更快,而是整个系统在高并发和异常场景下仍然稳定可控。