在很多中后台系统里,我们经常会遇到一种很典型的架构矛盾: 业务服务实例很多,但真正承接读流量的 Redis 实例并不多。
表面上看,这似乎只是“缓存资源不足”的问题;但只要线上流量起来,它往往会演变成一连串连锁反应:
- 缓存未命中增多,数据库回源压力急剧上升
- 热点 key 失效后,大量请求瞬间打到数据库
- 某几个 Redis 节点被集中访问,延迟突然抖动
- 服务越扩越多,但整体读性能并没有线性提升
很多团队会先想到“再加 Redis 节点”,但仅靠堆机器通常解决不了根因。因为真正要回答的问题不是“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 恰好失效”。
这时经常发生的情况是:
- 热点 key 过期
- 大量并发请求同时发现缓存未命中
- 所有请求一起打到数据库
- 数据库瞬间被放大流量击穿
常用方案一: 互斥锁
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 读节点有限时,真正的优化方向并不是简单“再加几台缓存”,而是把整条读链路做厚。
如果只提炼几个最关键的结论,我会建议记住这四点:
- 优先建立多级缓存,让热点流量就近消化
- 用空值缓存、预热和锁机制降低未命中与击穿风险
- 用读写分离和分片集群分散 Redis 层压力
- 用监控、降级和一致性治理把系统从“能跑”变成“能抗”
缓存优化的终点,不是某一层跑得更快,而是整个系统在高并发和异常场景下仍然稳定可控。