Redis 高并发缓存治理
Redis 用在高并发系统里,最容易出事故的不是 set、get 不会写,而是这些问题没有治理好:
- 大 Key:一个 Key 太大,导致网络、内存、删除、迁移、持久化都变慢。
- 热 Key:一个 Key 请求量太高,单个 Redis 节点或单个分片被打满。
- 缓存穿透:大量不存在的数据绕过缓存打数据库。
- 缓存击穿:热点 Key 过期瞬间大量请求回源。
- 缓存雪崩:大量 Key 同时过期或 Redis 故障,流量压到数据库。
- 慢命令和阻塞:Redis 单线程执行命令,一个慢命令会影响后续请求。
- 连接池和超时:应用线程卡在 Redis,进一步拖垮服务。
一句话理解:
Redis 高并发治理的核心不是“让缓存更快”,而是让缓存、数据库和应用在流量异常时都不会被瞬间打垮。
学习目标
| 目标 | 需要掌握什么 |
|---|---|
| 知道是什么 | 分清大 Key、热 Key、穿透、击穿、雪崩、慢命令、连接池耗尽 |
| 知道为什么 | 理解 Redis 单线程、内存模型、网络传输、Cluster 分片热点 |
| 知道怎么发现 | 会用 --bigkeys、MEMORY USAGE、SLOWLOG、INFO、监控指标定位 |
| 知道怎么治理 | 会拆 Key、本地缓存、逻辑过期、互斥锁、限流、降级、异步删除 |
| 会写 Demo | 能写热点缓存、本地缓存、逻辑过期、分片 Key、Lua 限流 |
| 会生产排查 | 能从现象反推是数据库被打穿、Redis 被打满,还是应用连接池堵塞 |
高并发问题全景
Redis 在商业系统里通常站在应用和数据库之间。它不是单独变慢,而是会和应用线程、数据库连接、网络带宽、对象序列化、缓存命中率一起形成连锁反应。
flowchart TD
A["高并发请求"] --> B["应用线程池"]
B --> C["Redis 连接池"]
C --> D["Redis 节点"]
D --> E["数据库回源"]
D --> F["主从复制和持久化"]
D --> G["Cluster 分片"]常见事故可以按“压力打到哪里”来分类:
| 压力位置 | 常见问题 | 典型现象 | 关键治理 |
|---|---|---|---|
| Redis 单个 Key | 热 Key、大 Key | 单节点 CPU 高、网络高、timeout | 本地缓存、拆 Key、限流、异步删除 |
| Redis 命令线程 | 慢命令、长 Lua、全量查询 | slowlog 有大命令,延迟抖动 | 禁用危险命令、分页、控制脚本时间 |
| Redis 内存 | 大 Key、空值缓存过多、TTL 缺失 | 内存持续升高、频繁淘汰 | Key 规范、容量评估、淘汰策略 |
| Redis 客户端 | 连接池耗尽、超时配置不合理 | 应用线程堆积,接口大量超时 | 连接池监控、超时、熔断降级 |
| 数据库 | 穿透、击穿、雪崩 | DB QPS 飙升,慢 SQL 变多 | 空值缓存、布隆过滤器、互斥锁、逻辑过期 |
| Cluster 分片 | slot 热点、数据倾斜 | 某个节点很忙,其他节点空闲 | 热点识别、分片 Key、迁移 slot、业务拆分 |
不要把这些问题割裂看。比如一个大 Key 可能先导致 Redis 命令变慢,进而应用连接池被占满,最后接口超时和线程池堆积;一个热点 Key 过期可能先造成缓存击穿,随后数据库慢查询变多,再反过来拖慢缓存重建。
Redis 为什么高并发下也会慢
Redis 很快,但不是无限快。
flowchart TD
A["客户端请求"] --> B["网络传输"]
B --> C["Redis 单线程执行命令"]
C --> D["读取或修改内存数据结构"]
D --> E["返回结果"]
C --> F["持久化、过期删除、复制、集群迁移等后台影响"]Redis 慢通常来自这些地方:
| 原因 | 解释 |
|---|---|
| 单个命令太重 | 大 Key、复杂集合操作、Lua 脚本时间太长 |
| 返回结果太大 | 网络传输慢,客户端反序列化慢 |
| 单个 Key 太热 | 请求集中到一个节点或一个分片 |
| 持久化抖动 | RDB、AOF rewrite、fork 带来延迟 |
| 主从复制压力 | 大量写入导致复制延迟 |
| Cluster 热点槽 | 某个 slot 所在节点压力远高于其他节点 |
| 连接池耗尽 | 应用线程等待 Redis 连接,接口超时 |
| 数据库被打穿 | 缓存失效后 DB 慢,反过来拖住应用线程 |
所以排查 Redis 问题时不能只看 Redis 本身,还要看应用、数据库、网络和客户端连接池。
单线程 多线程和 IO 多路复用
面向零基础时,很容易产生一个疑问:Redis 既然常说是单线程,为什么还能承受很高并发?
这里要分清三件事:
| 概念 | 说明 |
|---|---|
| 命令执行线程 | Redis 核心命令执行主要是单线程,避免复杂锁竞争 |
| IO 多路复用 | 一个线程可以同时监听大量连接事件,不需要一个连接一个线程 |
| Redis 6/7 多线程 | 多线程主要用于网络 IO、后台释放内存、AOF 等辅助工作,不等于多个线程同时乱改同一份数据 |
简化流程:
flowchart TD
A["多个客户端连接"] --> B["IO 多路复用监听事件"]
B --> C["读请求数据"]
C --> D["命令队列"]
D --> E["单线程顺序执行命令"]
E --> F["写回响应"]为什么单线程反而快:
- 不需要为每个命令加锁,少了大量锁竞争。
- 命令大多是内存操作,单条命令很短。
- IO 多路复用让一个线程处理很多连接事件。
- 数据结构实现针对具体场景优化。
为什么单线程也会出问题:
- 只要一个命令执行很久,后面的命令就要排队。
- 大 Key 返回结果很大时,网络写回也会拖慢链路。
- Lua 脚本执行时间太长,会阻塞其他命令。
KEYS、SMEMBERS、HGETALL这类全量命令会让延迟抖动。
所以 Redis 的高并发能力来自“单条命令短、内存快、IO 模型好”,而不是“不管怎么用都快”。
大 Key 是什么
大 Key 不是只看 Key 名字长不长,而是看这个 Key 对应的 value 是否太大。
常见大 Key:
| 类型 | 示例 | 风险 |
|---|---|---|
| String 大对象 | 一个 Key 存 1MB 到 10MB JSON | 网络慢、反序列化慢、内存浪费 |
| Hash 大字段 | 一个 Hash 有几十万 field | hgetall、迁移、删除都慢 |
| List 大列表 | 一个 List 存几十万元素 | 范围查询、删除、持久化压力大 |
| Set 大集合 | 一个 Set 存大量用户 ID | smembers 可能阻塞 |
| ZSet 大排行 | 一个 ZSet 存百万成员 | 范围查询、更新、内存占用高 |
大 Key 没有绝对统一阈值,要结合业务和机器能力判断。常见经验:
- String value 超过几十 KB 就要关注,超过几百 KB 要谨慎。
- Hash、Set、ZSet、List 元素超过几千到几万就要关注。
- 单次命令返回大量数据,比“存储很大”更危险。
- Cluster 中大 Key 会造成分片内存和迁移不均衡。
大 Key 为什么危险
flowchart TD
A["大 Key"] --> B["网络传输慢"]
A --> C["命令执行时间长"]
A --> D["删除阻塞"]
A --> E["内存分布不均"]
A --> F["主从复制和 AOF 压力"]
A --> G["Cluster 迁移慢"]典型事故:
| 事故现象 | 背后原因 |
|---|---|
| Redis timeout 增多 | 大 Key 命令占用执行线程或网络传输慢 |
hgetall 很慢 | Hash 字段太多,一次返回全量 |
| 删除 Key 后 Redis 抖动 | del 同步释放大量内存 |
| Cluster 某节点内存特别高 | 大 Key 落在某个 slot |
| 主从同步延迟升高 | 大对象写入和复制压力大 |
| AOF 文件膨胀 | 大对象频繁写入 |
大 Key 怎么发现
1. --bigkeys
redis-cli --bigkeys它会扫描 keyspace,输出每种数据结构里最大的 Key。优点是简单,缺点是只能给出概览,且扫描会增加 Redis 压力,生产要谨慎在低峰执行。
2. MEMORY USAGE
MEMORY USAGE user:profile:1001查看单个 Key 的内存占用,适合对可疑 Key 精确验证。
3. SCAN 分批扫描
不要在生产使用 KEYS *,它会阻塞 Redis。
SCAN 0 MATCH product:detail:* COUNT 1000SCAN 是渐进式扫描,但仍会消耗资源。生产扫描要限速,并最好走从库或离线分析。
4. 慢日志和监控
SLOWLOG GET 20
INFO memory
INFO commandstats如果慢日志里频繁出现 hgetall、smembers、lrange 0 -1、zrange 0 -1,通常要怀疑大 Key 或一次取太多数据。
大 Key 怎么治理
| 场景 | 错误做法 | 正确做法 |
|---|---|---|
| 大 JSON | 一个 Key 存完整对象和大量子列表 | 拆成对象基础信息 + 子集合分页 |
| 大 Hash | hgetall 一次取所有字段 | 按业务维度拆 Hash,或 HSCAN 分批 |
| 大 List | lrange 0 -1 | 只取分页范围,限制长度 |
| 大 Set | smembers 全量取 | SSCAN 或改为分页索引 |
| 删除大 Key | DEL big:key | UNLINK big:key 异步释放 |
| 排行榜无限增长 | 一个 ZSet 永久追加 | 只保留 TopN 或按时间拆分 |
异步删除:
UNLINK big:hash:1001UNLINK 会把释放内存的工作交给后台线程,减少主线程阻塞。它不是不消耗资源,而是把同步阻塞风险降低。
大 Key 拆分 Demo:商品评论
错误设计:
product:comments:1001 -> 一个 List 存所有评论问题:评论越来越多,分页、删除、迁移都变慢。
更稳的设计:
product:comment:summary:1001 -> 评论总数、好评率
product:comment:page:1001:1 -> 第 1 页评论 ID
product:comment:page:1001:2 -> 第 2 页评论 ID
product:comment:detail:commentId -> 单条评论详情这样页面只查需要的页,不会一次把所有评论取出来。
热 Key 是什么
热 Key 是访问频率远高于其他 Key 的 Key。
典型热 Key:
- 秒杀商品库存。
- 热门商品详情。
- 热门文章、热榜、首页配置。
- 全站字典配置。
- 登录态、权限、用户画像中的少数热点用户。
- 某个租户或机构的热点数据。
热 Key 的危险在于:Redis Cluster 是按 slot 分片的,一个 Key 只能落到一个 slot,一个 slot 属于一个主节点。某个 Key 极热,就会把单个节点打满。
flowchart TD
A["大量请求访问 hot:product:1001"] --> B["hash slot 固定"]
B --> C["落到 Redis 节点 A"]
C --> D["节点 A CPU 或网络打满"]
C --> E["其他节点仍然空闲"]这就是为什么“集群有很多节点,但还是被一个热点打挂”。
热 Key 怎么发现
| 方法 | 说明 | 注意 |
|---|---|---|
| 应用侧埋点 | 统计缓存 Key 访问频率 | 最准确,推荐长期做 |
| 网关/接口统计 | 找到热点接口,再定位 Key | 粒度较粗 |
Redis --hotkeys | 基于 LFU 统计热点 Key | 需要 LFU 策略支持 |
MONITOR | 观察实时命令 | 生产慎用,开销大 |
| 代理层统计 | Twemproxy、Codis、网关统计 | 看架构是否支持 |
| 慢日志 | 不直接等于热 Key,但能发现重命令 | 适合辅助判断 |
--hotkeys 示例:
redis-cli --hotkeys注意:它依赖 Redis 对 Key 访问频率的统计,通常需要 LFU 相关淘汰策略才能更有价值。生产更推荐应用侧做 Key 访问统计。
热 Key 怎么治理
| 方案 | 适合场景 | 风险 |
|---|---|---|
| 本地缓存 | 热点详情、字典、配置 | 多实例数据短暂不一致 |
| 多级缓存 | 本地缓存 + Redis + DB | 架构复杂,需要过期策略 |
| 热点预热 | 秒杀、活动页、榜单 | 预热数据要准确 |
| 逻辑过期 | 允许短暂旧数据 | 需要异步重建 |
| 拆分 Key | 计数、库存、读热点 | 写入合并复杂 |
| 限流降级 | 秒杀、活动、热门接口 | 用户体验下降 |
| 请求合并 | 单实例内相同 Key 只回源一次 | 实现复杂 |
本地缓存治理热 Key
本地缓存适合读多写少、允许短时间旧值的热点数据,例如商品详情、字典配置、资产元数据。
flowchart TD
A["请求热点数据"] --> B["查本地缓存"]
B --> C{"命中"}
C -- "是" --> D["直接返回"]
C -- "否" --> E["查 Redis"]
E --> F{"命中"}
F -- "是" --> G["写本地缓存并返回"]
F -- "否" --> H["查数据库并重建缓存"]本地缓存的问题是每台应用都有一份缓存,更新后不能强一致同步。因此它适合“允许短暂旧数据”的场景,不适合账户余额、库存扣减、权限强一致判断。
Java Demo:Caffeine + Redis 多级缓存
下面演示热点资产详情查询。Caffeine 作为本地一级缓存,Redis 作为二级缓存,MySQL 作为事实来源。
public class AssetCacheService {
private final Cache<Long, AssetDTO> localCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofSeconds(30))
.build();
private final StringRedisTemplate redisTemplate;
private final AssetRepository assetRepository;
private final ObjectMapper objectMapper;
public AssetCacheService(StringRedisTemplate redisTemplate,
AssetRepository assetRepository,
ObjectMapper objectMapper) {
this.redisTemplate = redisTemplate;
this.assetRepository = assetRepository;
this.objectMapper = objectMapper;
}
public AssetDTO getAsset(Long assetId) throws JsonProcessingException {
AssetDTO local = localCache.getIfPresent(assetId);
if (local != null) {
return local;
}
String redisKey = "asset:detail:" + assetId;
String json = redisTemplate.opsForValue().get(redisKey);
if (json != null) {
AssetDTO dto = objectMapper.readValue(json, AssetDTO.class);
localCache.put(assetId, dto);
return dto;
}
AssetDTO dto = assetRepository.findDetail(assetId);
if (dto == null) {
redisTemplate.opsForValue().set(redisKey, "NULL", Duration.ofMinutes(2));
return null;
}
redisTemplate.opsForValue().set(
redisKey,
objectMapper.writeValueAsString(dto),
Duration.ofMinutes(30).plusSeconds(ThreadLocalRandom.current().nextInt(300))
);
localCache.put(assetId, dto);
return dto;
}
}这个 Demo 体现三点:
- 热点请求优先命中本地缓存,减少 Redis 压力。
- Redis 命中后回填本地缓存。
- Redis 未命中才查数据库,并且设置随机 TTL。
缓存击穿和热 Key 的区别
| 对比 | 热 Key | 缓存击穿 |
|---|---|---|
| 关注点 | 某个 Key 访问量极高 | 热点 Key 过期后并发回源 |
| 是否必须过期 | 不一定 | 通常发生在过期瞬间 |
| 主要压力 | Redis 单节点或分片 | 数据库 |
| 治理 | 本地缓存、拆 Key、限流 | 互斥锁、逻辑过期、异步重建 |
热 Key 是流量集中问题,击穿是热点失效后的回源风暴问题。一个热点 Key 如果没有过期治理,就很容易演变成缓存击穿。
逻辑过期治理击穿
逻辑过期的思想是:Redis 里的物理 Key 不轻易过期,而是在 value 里保存一个逻辑过期时间。过期后先返回旧数据,同时异步重建缓存。
flowchart TD
A["请求热点 Key"] --> B["读取 Redis"]
B --> C{"逻辑是否过期"}
C -- "否" --> D["返回数据"]
C -- "是" --> E["尝试获取重建锁"]
E --> F{"是否拿到锁"}
F -- "是" --> G["异步查 DB 重建缓存"]
F -- "否" --> H["直接返回旧值"]
G --> I["更新 Redis 里的数据和逻辑过期时间"]适合场景:
- 热门商品详情。
- 首页配置。
- 数据字典。
- 热门资产元数据。
- 允许短时间旧数据的查询。
不适合场景:
- 库存扣减。
- 账户余额。
- 支付状态。
- 权限强一致判断。
Java Demo:逻辑过期
public class CacheEnvelope<T> {
private T data;
private LocalDateTime expireAt;
public T getData() {
return data;
}
public LocalDateTime getExpireAt() {
return expireAt;
}
public boolean expired() {
return LocalDateTime.now().isAfter(expireAt);
}
}
public AssetDTO getHotAsset(Long assetId) throws JsonProcessingException {
String dataKey = "asset:hot:" + assetId;
String lockKey = "lock:asset:hot:" + assetId;
String json = redisTemplate.opsForValue().get(dataKey);
if (json == null) {
return null;
}
CacheEnvelope<AssetDTO> envelope = readEnvelope(json);
if (!envelope.expired()) {
return envelope.getData();
}
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, UUID.randomUUID().toString(), Duration.ofSeconds(10));
if (Boolean.TRUE.equals(locked)) {
rebuildExecutor.submit(() -> rebuildHotAsset(assetId, dataKey, lockKey));
}
return envelope.getData();
}逻辑过期的关键不是“永不过期”,而是把过期后的数据库回源从同步变成异步,保护数据库。
缓存穿透治理
缓存穿透是查询不存在的数据,Redis 和数据库都没有。如果攻击者不断构造不存在的 ID,就会绕过缓存打数据库。
flowchart TD
A["请求不存在 ID"] --> B["Redis 未命中"]
B --> C["MySQL 未命中"]
C --> D["下次请求仍然重复"]治理方式:
| 方式 | 说明 | 风险 |
|---|---|---|
| 参数校验 | 非法 ID、格式错误直接拒绝 | 只能挡明显非法 |
| 缓存空值 | DB 无数据也缓存短 TTL 空值 | 无效 Key 太多会占内存 |
| 布隆过滤器 | 判断 ID 是否可能存在 | 有误判,删除困难 |
| 限流 | 防止恶意请求打穿 | 需要合理阈值 |
布隆过滤器适合数据集合相对稳定的场景,例如商品 ID、资产 ID、机构 ID。它能判断“一定不存在”或“可能存在”。底层原理、误判率、初始化同步和 RedisBloom Demo 看:布隆过滤器原理。
缓存雪崩治理
缓存雪崩是大量 Key 同时失效,或 Redis 整体不可用,导致请求同时打到数据库。
常见原因:
- TTL 都设置为 30 分钟,批量同时过期。
- 系统启动时统一预热,过期点集中。
- Redis 主节点故障或网络抖动。
- 发布活动导致大量缓存同时失效。
治理:
flowchart TD
A["雪崩风险"] --> B["TTL 加随机值"]
A --> C["热点数据预热"]
A --> D["多级缓存"]
A --> E["限流和降级"]
A --> F["Redis 高可用"]
A --> G["数据库保护"]TTL 随机值:
Duration ttl = Duration.ofMinutes(30)
.plusSeconds(ThreadLocalRandom.current().nextInt(300));
redisTemplate.opsForValue().set(key, value, ttl);Redis 慢命令和阻塞
Redis 单线程执行命令。一个慢命令会阻塞后续命令处理。
高风险命令:
| 命令 | 风险 | 替代 |
|---|---|---|
KEYS * | 扫全库阻塞 | SCAN |
HGETALL big_hash | 返回大量字段 | HSCAN、按字段查询 |
SMEMBERS big_set | 返回全量集合 | SSCAN |
LRANGE key 0 -1 | 返回整个列表 | 分页范围 |
ZRANGE key 0 -1 | 返回整个排行 | 只取 TopN |
DEL big_key | 同步释放大内存 | UNLINK |
| 长 Lua | 阻塞主线程 | 控制脚本复杂度 |
慢日志:
CONFIG GET slowlog-log-slower-than
SLOWLOG GET 20
SLOWLOG LENslowlog-log-slower-than 单位是微秒。生产要根据业务延迟要求设置合适阈值。
连接池和超时
很多 Redis 事故表现为应用报 timeout,但根因可能不是 Redis 慢,而是连接池被耗尽。
flowchart TD
A["请求进入应用"] --> B["获取 Redis 连接"]
B --> C{"连接池是否有空闲连接"}
C -- "有" --> D["执行 Redis 命令"]
C -- "没有" --> E["线程等待连接"]
E --> F["接口超时和线程堆积"]常见原因:
- Redis 命令慢,连接长时间不释放。
- 连接池太小。
- 应用并发突然升高。
- 网络抖动。
- 没有设置命令超时。
- 大 Key 返回导致连接占用时间长。
Spring Boot Lettuce 示例:
spring.data.redis.timeout=2s
spring.data.redis.lettuce.pool.max-active=32
spring.data.redis.lettuce.pool.max-idle=16
spring.data.redis.lettuce.pool.min-idle=4
spring.data.redis.lettuce.pool.max-wait=500ms参数不是越大越好。连接池过大可能把 Redis 打得更重,也会放大下游压力。
过期删除和内存淘汰
高并发缓存不是只设置 TTL 就完事,还要理解 Key 到期以后 Redis 怎么处理,以及内存满了以后会发生什么。
Redis 过期删除可以简单理解为两类机制一起工作:
flowchart TD
A["Key 设置 TTL"] --> B["惰性删除"]
A --> C["定期删除"]
B --> D["访问 Key 时发现过期再删除"]
C --> E["后台周期抽样清理过期 Key"]为什么不是到期瞬间立刻删除所有 Key:如果 Redis 为每个 Key 都维护一个精确删除定时器,Key 数量很大时定时器管理成本会很高。惰性删除和定期删除是性能与内存回收之间的折中。
如果大量 Key 在同一时间过期,会出现两个问题:
- 请求同时未命中,数据库被回源流量打满。
- Redis 需要清理大量过期 Key,带来额外 CPU 开销。
所以生产里要给 TTL 加随机值,避免过期点集中。
内存达到 maxmemory 后,Redis 会按淘汰策略处理:
| 策略 | 含义 | 适合场景 |
|---|---|---|
noeviction | 不淘汰,写入报错 | 不希望缓存静默丢数据 |
allkeys-lru | 从所有 Key 中淘汰最近最少使用 | 通用缓存场景常用 |
allkeys-lfu | 从所有 Key 中淘汰低频访问 | 热点更稳定的场景 |
volatile-lru | 只从有 TTL 的 Key 中按 LRU 淘汰 | 混合存储但只允许淘汰缓存 |
volatile-ttl | 优先淘汰更快过期的 Key | 临时数据较多 |
allkeys-random | 随机淘汰 | 对命中率要求不高 |
配置示例:
maxmemory 4gb
maxmemory-policy allkeys-lru如果淘汰策略设计不当,可能出现“Redis 没挂,但命中率突然下降”的事故:热数据被淘汰后,大量请求回源数据库,数据库慢了又拖慢缓存重建,最终表现成全链路超时。
Pipeline 批量命令和输出缓冲区
高并发接口经常会一次查很多缓存。很多人会本能地循环 get:
for (Long id : ids) {
redisTemplate.opsForValue().get("asset:" + id);
}这样每个 get 都要一次网络往返,接口耗时会被网络 RTT 放大。更好的方式是使用 MGET 或 Pipeline 减少网络往返。
List<String> keys = ids.stream()
.map(id -> "asset:" + id)
.collect(Collectors.toList());
List<String> values = redisTemplate.opsForValue().multiGet(keys);Pipeline 的意义是把多个命令一次发给 Redis,再批量读取响应:
flowchart TD
A["客户端准备多条命令"] --> B["一次发送到 Redis"]
B --> C["Redis 顺序执行"]
C --> D["批量返回结果"]但是 Pipeline 不是越大越好:
- Redis 仍然是顺序执行这些命令,不能把重命令变轻。
- 一次返回结果太大,会增加网络和客户端反序列化压力。
- 客户端来不及读取响应时,输出缓冲区可能增大。
- Cluster 下批量 key 如果跨 slot,要看客户端是否能自动拆分。
经验上要控制批量大小,例如每批 100 到 1000 个,具体根据 value 大小、网络、Redis 延迟和业务 P99 来压测。
降级熔断和数据库保护
Redis 是用来保护数据库的,但 Redis 自己异常时,也必须防止所有请求瞬间打到数据库。
flowchart TD
A["Redis 异常或命中率下降"] --> B["应用快速失败或降级"]
B --> C["限制数据库回源并发"]
C --> D["返回兜底数据"]
D --> E["异步重建缓存"]商业项目常见策略:
| 场景 | 处理 |
|---|---|
| 字典、配置缓存不可用 | 返回本地兜底缓存或默认配置 |
| 热门详情缓存不可用 | 限制回源并发,返回旧数据 |
| 排行榜缓存不可用 | 返回上一周期快照 |
| 非核心统计缓存不可用 | 临时关闭统计展示 |
| 数据库压力过高 | 熔断部分非核心接口 |
真正的高并发缓存设计,必须明确“缓存挂了能不能查 DB、能查多少、查不到返回什么、多久恢复、如何告警”。
Lua 限流 Demo
高并发热点接口要能限流。下面是固定窗口限流示例。
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local ttl = tonumber(ARGV[2])
local current = tonumber(redis.call('GET', key) or '0')
if current >= limit then
return 0
end
current = redis.call('INCR', key)
if current == 1 then
redis.call('EXPIRE', key, ttl)
end
return 1Java 调用时,按接口、用户、租户等维度设计 key:
rate:asset:detail:user:1001
rate:login:ip:192.168.1.10
rate:tenant:2001限流的目标不是让所有请求成功,而是在异常流量下保护 Redis、数据库和业务服务。
Cluster 下的热点问题
Redis Cluster 通过 16384 个 slot 分片。普通 Key 会根据 hash 结果落到某个 slot。
slot = CRC16(key) % 16384热 Key 的问题是:一个 Key 只能属于一个 slot,一个 slot 只能由一个主节点负责。即使集群有很多节点,单个 Key 热点也不能天然被多个主节点分担。
治理思路:
- 热点读:本地缓存、多级缓存。
- 热点计数:分片计数,最后汇总。
- 热点库存:预扣库存、队列削峰、Lua 控制。
- 热点排行榜:只缓存 TopN,按时间拆分。
- 热点租户:按业务拆 Key,避免所有数据堆到一个 Key。
分片计数示例:
article:read:1001:shard:0
article:read:1001:shard:1
article:read:1001:shard:2
article:read:1001:shard:3写入时随机选择一个 shard 自增,读取时汇总多个 shard。这样可以分散写压力,但读取和一致性更复杂。
高并发缓存整体架构
flowchart TD
A["用户请求"] --> B["网关限流"]
B --> C["应用本地缓存"]
C --> D{"本地命中"}
D -- "是" --> E["返回"]
D -- "否" --> F["Redis"]
F --> G{"Redis 命中"}
G -- "是" --> H["回填本地缓存"]
G -- "否" --> I["互斥锁或逻辑过期"]
I --> J["查询数据库"]
J --> K["写 Redis,TTL 随机"]
K --> E
H --> E这个链路里,每一层都在保护下一层:
- 网关限流保护应用。
- 本地缓存保护 Redis。
- Redis 保护数据库。
- 互斥锁和逻辑过期保护数据库。
- 降级保护整体可用性。
生产排查流程
flowchart TD
A["接口 Redis timeout"] --> B["看应用连接池"]
B --> C["看 Redis slowlog"]
C --> D{"是否有慢命令"}
D -- "是" --> E["查大 Key 或复杂命令"]
D -- "否" --> F["看 QPS、CPU、网络、内存"]
F --> G{"是否单 Key 或单节点热点"}
G -- "是" --> H["本地缓存、拆 Key、限流"]
G -- "否" --> I["查持久化、主从、网络、数据库回源"]生产排查清单:
| 现象 | 优先排查 |
|---|---|
| Redis timeout | 连接池、慢命令、大 Key、网络、Redis CPU |
| DB QPS 飙升 | 穿透、击穿、雪崩、Redis 故障、缓存命中率 |
| Redis 单节点 CPU 高 | 热 Key、复杂命令、Lua、Cluster 热点 |
| Redis 内存高 | 大 Key、TTL 缺失、缓存空值过多、淘汰策略 |
| 删除后卡顿 | 是否 DEL 大 Key,改用 UNLINK |
| 主从延迟高 | 大对象写入、网络、磁盘、复制积压 |
| Cluster 节点不均衡 | 大 Key、slot 分布、热点业务 |
| 应用线程堆积 | 连接池耗尽、命令超时过长、回源 DB 慢 |
Redis timeout 全链路排查
Redis timeout 不是一个原因,而是一个结果。它可能发生在应用拿连接、网络传输、Redis 排队执行、返回大响应、客户端反序列化、回源数据库等多个位置。
零基础可以先把一次缓存读取拆成这条链:
flowchart TD
A["业务线程进入缓存方法"] --> B["从连接池借 Redis 连接"]
B --> C["发送命令到 Redis"]
C --> D["Redis 排队等待执行"]
D --> E["执行命令"]
E --> F["返回响应数据"]
F --> G["客户端反序列化"]
G --> H{"是否命中"}
H -- "命中" --> I["返回业务结果"]
H -- "未命中" --> J["回源数据库"]
J --> K["写回 Redis"]
K --> I每一步的证据不同:
| 位置 | 典型现象 | 证据 |
|---|---|---|
| 借连接慢 | 应用线程卡在连接池等待 | Lettuce/Jedis 连接池指标、线程栈 |
| 网络慢 | Redis 自身不慢,但客户端耗时高 | 同机房网络延迟、丢包、跨机房访问 |
| Redis 排队 | 单节点延迟升高,后续命令都慢 | SLOWLOG、INFO commandstats、CPU |
| 命令执行慢 | 某些命令耗时高 | SLOWLOG GET、大 Key、复杂 Lua |
| 返回数据大 | Redis 执行不算慢,但客户端耗时高 | 响应体大小、MEMORY USAGE、网卡流量 |
| 反序列化慢 | Java CPU 高,GC 增加 | 应用 CPU、火焰图、对象大小 |
| 回源慢 | Redis 命中率下降后 DB 被打满 | 缓存命中率、DB QPS、慢 SQL |
所以排查 Redis timeout 不要只盯 Redis 服务端。很多线上事故是“Redis 未命中 -> 大量回源 DB -> DB 慢 -> 业务线程堆积 -> Redis 连接还不及时归还 -> Redis timeout 更多”,这是连锁反应。
flowchart TD
A["缓存命中率下降"] --> B["大量请求回源数据库"]
B --> C["DB 慢 SQL 和连接池等待"]
C --> D["业务线程占用时间变长"]
D --> E["Redis 连接归还变慢"]
E --> F["应用侧 Redis 连接池等待"]
F --> G["更多请求 timeout"]第一步:先判断 timeout 发生在哪
| 问题 | 判断方式 |
|---|---|
| 是拿连接超时吗 | 看连接池等待时间、活跃连接数、最大连接数、等待队列 |
| 是命令执行超时吗 | 看 Redis SLOWLOG、节点 CPU、慢命令分布 |
| 是网络超时吗 | 看 Redis 节点延迟、网卡、客户端到 Redis 的 RTT |
| 是大响应拖慢吗 | 看 key 大小、响应字节数、客户端反序列化耗时 |
| 是回源拖慢导致连锁吗 | 看缓存命中率、DB QPS、慢 SQL、应用线程池 |
常见误判:
| 误判 | 为什么错 |
|---|---|
| Redis timeout 一定是 Redis 挂了 | 应用连接池、网络、DB 回源都可能让调用超时 |
SLOWLOG 没慢命令就没问题 | 热 Key 单次命令不慢,但频率太高也能打满 CPU 或网卡 |
| 只扩 Redis 连接池就能解决 | 如果 Redis 或 DB 已经慢,扩连接池只会放大压力 |
| 只看平均耗时 | 高并发事故要看 P95/P99、等待队列和突刺 |
第二步:看应用连接池
如果应用使用 Lettuce、Jedis 或 Redisson,要先看连接池和线程池。连接池满时,业务线程可能不是卡在 Redis 命令执行,而是卡在“借连接”。
排查指标:
| 指标 | 意义 |
|---|---|
| active connections | 活跃连接数是否接近上限 |
| idle connections | 空闲连接是否长期为 0 |
| wait queue | 是否有线程等待连接 |
| borrow latency | 借连接耗时是否升高 |
| command timeout | Redis 命令超时是否集中在某些接口 |
治理原则:
- 不要无脑把连接池调很大。Redis 单节点承载能力有限,连接太多会让排队更严重。
- 先降低单请求 Redis 次数,例如批量
MGET、Pipeline、合并查询。 - 对低价值接口做限流,避免把连接池占满。
- 对热点数据加本地缓存,减少 Redis 调用次数。
- 缩短不必要的大事务或慢业务链路,及时归还连接。
第三步:看 Redis 服务端
常用命令:
redis-cli slowlog get 20
redis-cli info commandstats
redis-cli info clients
redis-cli info stats
redis-cli info memory
redis-cli memory usage your:key判断逻辑:
| 证据 | 可能原因 | 下一步 |
|---|---|---|
slowlog 有 HGETALL、SMEMBERS | 大 Key 或全量命令 | 拆 Key、改 HSCAN、禁止全量 |
cmdstat_get 调用极高 | 热 Key 或高频接口 | 应用侧 TopN、加本地缓存 |
used_memory 接近上限 | 大 Key、TTL 缺失、空值缓存过多 | 查 bigkeys、淘汰策略、Key 生命周期 |
connected_clients 异常高 | 连接泄漏或池配置过大 | 查应用连接池和客户端 |
instantaneous_ops_per_sec 突刺 | 流量峰值或缓存雪崩 | 限流、随机 TTL、预热 |
第四步:看数据库回源
Redis timeout 有时是数据库先被打满导致的。比如热点 key 过期,所有请求未命中,业务线程都在查 DB;查 DB 期间 Redis 连接、应用线程、HTTP 线程都被占住,最后表现成 Redis timeout 和接口 timeout 一起出现。
证据:
| 证据 | 说明 |
|---|---|
| Redis 命中率突然下降 | 缓存失效或雪崩 |
| DB QPS 突然升高 | 大量回源 |
| DB 慢 SQL 增加 | 回源查询本身慢 |
| 应用线程栈大量卡在 DB | timeout 根因可能不在 Redis |
| Redis 连接池等待升高 | 业务线程长时间不释放资源 |
治理:
- 热点 key 用逻辑过期或互斥锁,避免同时回源。
- 普通缓存 TTL 加随机值,避免同一时间大面积失效。
- 不存在数据用参数校验、空值缓存和布隆过滤器。
- DB 回源加限流和降级,保护事实源。
- 对关键热点提前预热。
连接池、线程池和 Redis 的关系
高并发下,应用线程池、Redis 连接池、DB 连接池是连在一起的。任何一个池耗尽,都会让其他池被拖住。
flowchart TD
A["HTTP 线程池"] --> B["业务逻辑"]
B --> C["Redis 连接池"]
B --> D["DB 连接池"]
C --> E["Redis 服务端"]
D --> F["MySQL 服务端"]
E --> G{"Redis 慢或未命中"}
G -- "未命中" --> D
F --> H{"DB 慢"}
H -- "是" --> A典型事故过程:
- 热点缓存失效。
- 大量请求同时未命中 Redis。
- 请求回源数据库。
- DB 连接池满,SQL 变慢。
- HTTP 线程被占住。
- Redis 连接归还变慢,连接池也开始等待。
- 接口整体 timeout。
所以调参时要看整体容量,不是哪个报错就只调哪个:
| 调大什么 | 可能副作用 |
|---|---|
| HTTP 线程池 | 放更多请求进来,可能打爆 Redis/DB |
| Redis 连接池 | Redis 服务端排队更长,网络更拥塞 |
| DB 连接池 | DB 并发更高,慢 SQL 更严重 |
| timeout 时间 | 用户等待更久,线程占用更久 |
正确思路是:限流入口,减少无效请求,降低单请求 Redis 次数,保护 DB 回源,热点本地化,慢命令治理,而不是只把池子和超时时间调大。
常用命令清单
# 慢命令
redis-cli slowlog get 20
# 大 Key 概览
redis-cli --bigkeys
# 热 Key,依赖访问频率统计
redis-cli --hotkeys
# 单 Key 内存
redis-cli memory usage your:key
# Redis 状态
redis-cli info memory
redis-cli info stats
redis-cli info commandstats
redis-cli info clients
redis-cli info replication
# 渐进扫描
redis-cli scan 0 match 'asset:*' count 1000不要在生产高峰随意使用 MONITOR、KEYS * 或大范围扫描。
商业场景:医疗数据采集平台
在医疗数据采集与资产平台里,Redis 可能用于:
- 采集任务状态缓存。
- 字典、机构、表结构、字段元数据缓存。
- 数据资产详情缓存。
- 限流和防重复提交。
- 分布式锁保护任务调度。
风险示例:
| 场景 | 风险 | 治理 |
|---|---|---|
| 资产详情热点查询 | 某个资产被大量访问形成热 Key | 本地缓存、逻辑过期 |
| 字段元数据一次缓存全量 | 大 JSON 形成大 Key | 按表、字段、分页拆分 |
| 任务状态列表过大 | List/Hash 过大 | 按批次、机构、分页拆 Key |
| 采集任务开始时缓存同时过期 | 雪崩 | TTL 随机、预热 |
| 异常 ID 查询很多 | 穿透 | 参数校验、缓存空值、布隆过滤器 |
| Redis 故障 | DB 被打满 | 限流、降级、只读兜底 |
关联知识点
| 知识点 | 继续学习什么 |
|---|---|
| 缓存问题 | 穿透、击穿、雪崩基础 |
| Redis 高级 | 持久化、高可用、慢命令、生产排查总览 |
| 主从、哨兵与集群 | Cluster、slot、MOVED、ASK、主从复制 |
| 分布式锁 | 互斥锁、Lua 释放、Redisson 看门狗 |
| 缓存一致性 | 数据库和缓存如何保持最终一致 |
| 消息堆积与背压 | 缓存重建、异步补偿、削峰思路 |
小结
Redis 高并发问题的本质是流量、数据结构和资源保护。大 Key 让单次命令变重,热 Key 让单点流量集中,穿透/击穿/雪崩会把压力打到数据库,慢命令和连接池耗尽会拖垮应用。成熟方案一定会组合使用 Key 拆分、本地缓存、逻辑过期、互斥锁、随机 TTL、布隆过滤器、限流降级、异步删除、监控告警和补偿任务。
