Redis 开发篇
先把缓存开发和 Redis 运维分开
后端开发使用 Redis,先要选对数据结构和 key,再设计 TTL、缓存模式、回源、失效、热点保护和降级,最后把 Redisson、缓存一致性和成本治理落到可验证的模板里。安装、哨兵、集群、备份与服务器运维属于 Redis 运维链路。
后面的缓存实验会依次经过:
数据结构:String、Hash、List、Set、Sorted Set、Bitmap、HyperLogLog、Stream 的开发使用边界。Key 规范:命名、版本、租户、环境、序列化、value 大小。TTL:过期时间、随机扰动、逻辑过期、空值 TTL、热点 TTL。
缓存模式:Cache Aside、缓存空值、布隆过滤器、互斥重建、预热、降级。高并发问题:穿透、击穿、雪崩、热点 key、BigKey、慢命令。Redisson:分布式锁、看门狗、tryLock、释放校验、适用边界。
缓存一致性:先写库再删缓存、延迟一致、补偿、版本化、消息或任务兜底。落地工程深水区:Key 规范、TTL、内存治理、热点治理、降级、一致性、成本。
下面几类问题交给部署、消息与一致性专题:
Redis 安装、哨兵、Cluster 搭建、AOF/RDB 备份恢复、服务器内核参数。把 Redis 当消息队列平台的完整运维方案。Stream 会讲开发边界,但不替代 MQ 专题。分布式锁的形式化证明和强一致协调系统设计。Redis 锁能解决很多工程问题,但不是强一致事务系统。
版本基线
Redis 官方 GitHub release 页面显示 Redis Open Source 8.8.0 是当前 GA 版本;官方下载页仍保留 redis-stable.tar.gz 和 SHA-256 校验入口。后续示例尽量使用 Redis 7.4+ 和 8.x 都容易理解的命令与模式。Java 侧以 Spring Data Redis 和 Redisson 为主,版本号不要写成永久答案,真实项目要以公司 BOM、兼容矩阵和官方 release notes 为准。
Redis 8.8 增加了 Array 数据结构、INCREX、hash field 级通知等能力,但这些新能力不进入缓存开发主线。后端缓存的基本盘仍是 String、Hash、List、Set、ZSet、Stream、TTL、过期淘汰、Cache Aside、热点治理和一致性补偿。新结构进入业务前,要先确认客户端支持、序列化约定、监控指标和降级方案;不要因为“当前版本有新类型”就把通用缓存模型改成小众能力依赖。
实操前准备测试实例、客户端和业务缓存清单
继续往下实操前,建议你至少准备好:
一个可以连接的 Redis 测试实例,能执行 redis-cli、INFO、SLOWLOG、MEMORY USAGE、OBJECT ENCODING。一个 Java 服务实验工程,已经接入 Spring Data Redis 或 Redisson。一份业务缓存清单,哪怕只是订单详情、用户资料、配置字典这三类典型 key。
明确权限边界:下面的命令用于测试和排查,生产上执行 MONITOR、大范围 SCAN、CONFIG SET、批量删除前必须走变更流程。
Redis 不是透明加速层
很多系统第一次加 Redis,都是因为接口慢、数据库压力高、首页或配置读取需要加速。代码看起来很简单:先查缓存,缓存没有查数据库,再把结果写回缓存。真正上线以后,问题会很快出现:
不存在的数据每次都打到数据库,形成缓存穿透。热点 key 过期时大量线程一起回源,形成缓存击穿。大量 key 同时过期,数据库瞬间被打满,形成缓存雪崩。
数据库已经更新,缓存还是旧值。key 命名混乱,误删、漏删、查不清归属。value 越来越大,Redis 慢命令和网络流量上升。
Redisson 锁没处理超时、续期和释放校验,偶发并发事故。
所以 Redis 开发的第一原则是:先定义业务一致性和容量边界,再决定缓存什么、怎么缓存、何时失效、失败怎么办。
命令调用链路:Redis 快在哪里,也会堵在哪里
先不要急着背数据类型。后端开发真正需要记住的是:一次 Redis 调用不是“客户端直接读内存”,而是沿着一条很短但很严格的链路执行。
对应到源码阅读入口,可以这样找:
| 环节 | 关键入口 | 读代码时看什么 |
|---|---|---|
| 事件循环 | src/ae.c、src/ae_epoll.c、src/ae_kqueue.c | 文件事件、时间事件、I/O 多路复用适配 |
| 网络读写 | src/networking.c、src/connection.c | 读请求、解析输入缓冲、写响应 |
| 命令分发 | src/server.c、src/commands.c、src/commands.def | 命令表、权限、调用入口、慢命令统计 |
| key 查找 | src/db.c、src/expire.c | db->keys、过期判断、删除路径 |
| 淘汰策略 | src/evict.c | maxmemory、采样、LRU / LFU / TTL 策略 |
| 底层结构 | src/sds.c、src/dict.c、src/listpack.c、src/quicklist.c、src/t_zset.c | SDS、dict、listpack、quicklist、skiplist 如何承载命令 |
这里要特别注意“单线程”的准确含义。Redis 的命令执行仍然围绕主事件循环串行推进,所以一个慢命令、一个大集合遍历、一个执行太久的 Lua 脚本,都会让后续命令排队。Redis 6 以后可以使用 I/O 线程处理部分网络读写,但这不等于多个线程同时修改同一份数据结构。开发侧要避免的不是“Redis 不够快”,而是把 O(n) 命令、BigKey、巨大的 pipeline 或长 Lua 脚本塞进这条串行执行链。
如果团队打开了 I/O 线程,边界要写清楚:io-threads 主要分担网络读写和协议处理压力,命令执行仍然在主线程串行完成。不要因为配置了 4 个 I/O 线程,就把大集合遍历、长 Lua 或超大 pipeline 当成“可并行执行”。排查时先看当前配置:
CONFIG GET io-threads
CONFIG GET io-threads-do-reads
INFO clients
INFO commandstats生产建议:I/O 线程是网络层优化,不是命令层并发改造。只有在网络读写成为瓶颈、命令本身足够短、CPU 核心有余量时才评估;否则先治理命令复杂度、BigKey 和客户端批量大小。
最小验证可以这样做:
COMMAND INFO HGETALL
OBJECT ENCODING shop:prod:user:profile:10001:v1
SLOWLOG GET 10
INFO commandstats
redis-cli --latency验证结果怎么看:
COMMAND INFO 能看到命令复杂度和命令属性,先判断这个命令是否适合在线请求。OBJECT ENCODING 能看到当前 key 的内部编码,判断它还是紧凑结构,还是已经升级成更重的数据结构。SLOWLOG 和 INFO commandstats 能反推是哪类命令耗时、调用次数异常。
redis-cli --latency 用来粗看链路抖动,不替代应用侧 P99 指标。
生产建议:所有缓存开发评审都要追问一句:“这个 key 的常用命令会不会把主线程拖住?”如果回答不上来,就先补命令复杂度、value 大小和集合元素数量上限。
Key 规范:先把归属和生命周期写清楚
缓存 key 是线上排查入口,也是团队协作契约。一个 key 至少应该能看出业务域、对象类型、对象标识和版本维度。
推荐格式:
{app}:{env}:{domain}:{object}:{id}:v{version}示例:
shop:prod:order:detail:100001:v1
shop:prod:user:profile:10001:v2
shop:prod:config:pay-channel:v3如果使用 Redis Cluster,并且需要多个 key 落到同一个 slot,可以用 hash tag,但不要滥用:
shop:prod:{user:10001}:profile:v1
shop:prod:{user:10001}:orders:v1常见坑:
key 里没有环境,测试脚本误删生产缓存。key 里没有版本,value 结构升级后旧数据反序列化失败。多个模块拼同一种 key,但失效动作没人负责。
key 太长且数量巨大,元数据成本也会累积。
生产建议:每类缓存都要有缓存契约,写清 key 格式、value 结构、TTL、归属模块、失效触发点、是否允许延迟一致和降级策略。
数据结构:先选模型,再写命令
Redis 快,不代表所有数据都应该塞进去。先按业务读写模型选结构。
底层结构决定了两个结果:命令复杂度和内存成本。开发不一定要天天读 C 源码,但要能从业务命令反推出 Redis 会动哪些结构。
| 数据结构 | 常见底层承载 | 适合场景 | 常见命令 | 常见坑 |
|---|---|---|---|---|
| String | SDS、整数编码、短字符串紧凑编码、raw 字符串 | 对象缓存、计数器、分布式锁值 | GET、SET、INCR | value 过大、序列化不兼容 |
| Hash | listpack 或 dict | 对象字段级读取、购物车小对象 | HGET、HMGET、HSET | HGETALL 拉出大对象 |
| List | quicklist,节点内使用 listpack | 简单队列、时间线片段 | LPUSH、LPOP、LRANGE | 大范围 LRANGE |
| Set | intset 或 dict | 去重集合、关系判断 | SADD、SISMEMBER | 大集合一次性读取 |
| Sorted Set | listpack 或 skiplist + dict | 排行榜、延迟任务、按分数分页 | ZADD、ZRANGE、ZREVRANGE | 高基数热点集合 |
| Bitmap | String 的位操作 | 签到、布尔状态统计 | SETBIT、GETBIT、BITCOUNT | 位偏移设计错误 |
| HyperLogLog | 概率结构编码 | UV 估算 | PFADD、PFCOUNT | 有误差,不适合精确计数 |
| Stream | radix tree + listpack | 轻量事件流 | XADD、XREADGROUP | 不替代完整 MQ 治理 |
从开发视角看,选数据类型不是选一个“好用命令”,而是在选底层结构、命令复杂度、内存和网络成本:
这张图用来防止一个常见误区:只按业务字段形状选 Hash、List 或 Sorted Set,却没有给 value 大小、字段数、集合成员数和禁止命令设置上限。Redis 主线程被拖慢,往往不是命令“不支持”,而是数据规模超出了开发时假设。
验证一个 key 的底层编码:
OBJECT ENCODING shop:prod:user:profile:10001:v1
MEMORY USAGE shop:prod:user:profile:10001:v1
TYPE shop:prod:user:profile:10001:v1
CONFIG GET '*listpack*'常见现象和反推:
Hash 一开始很小,OBJECT ENCODING 可能是紧凑编码;字段和 value 变大后会转成 dict,内存占用和遍历成本都上升。Sorted Set 小集合可以很紧凑;变成 skiplist + dict 后范围查询仍然好用,但高基数热点排行会吃内存和 CPU。List 的 quicklist 适合两端操作;大范围 LRANGE 0 -1 仍然会把大量数据搬过网络。
Bitmap 本质还是 String,最大偏移设计错了会直接撑大 value。
这些编码不是只影响内存,也影响线上行为。listpack 紧凑但适合小对象;quicklist 适合两端操作但不适合大范围拉取;skiplist 让有序集合范围查询稳定,但高基数热点集合会把 CPU、内存和网络一起抬上来。编码阈值不要在生产随手改,先用真实 value 大小和成员数压测。
生产建议:每类 key 都要定义“最大 value 字节数、最大字段数、最大集合元素数、禁止命令”。缓存契约里只写 TTL 不够,还要写命令边界。
String:对象缓存要控制大小
SET shop:prod:order:detail:100001:v1 '{"orderNo":"ORD001","status":20}' EX 1800
GET shop:prod:order:detail:100001:v1
TTL shop:prod:order:detail:100001:v1验证结果:
GET 能读到 JSON。TTL 返回正数,说明 key 有过期时间。
常见坑是把整个详情页所有关联数据都塞进一个 String。短期少查几次库,长期会制造 BigKey、序列化兼容和局部失效问题。生产上建议对象缓存只放高频且稳定的字段,复杂聚合优先拆 key 或缓存摘要。
Hash:适合字段级读取,但别滥用 HGETALL
HSET shop:prod:user:profile:10001:v1 nickname "demo" level "3" status "1"
HMGET shop:prod:user:profile:10001:v1 nickname level
EXPIRE shop:prod:user:profile:10001:v1 3600Hash 适合小对象字段级读取。不要因为 Hash 好用,就把一个用户所有订单、权限、统计和配置都放进一个 Hash。只要 HLEN 持续变大,后续迁移、删除和网络传输都会变重。
验证:
HLEN shop:prod:user:profile:10001:v1
MEMORY USAGE shop:prod:user:profile:10001:v1Sorted Set:排行榜和游标分页要控制范围
ZADD shop:prod:rank:sale:202607 981 10001 952 10002 900 10003
ZREVRANGE shop:prod:rank:sale:202607 0 9 WITHSCORES排行榜天然适合 Sorted Set,但要按业务周期拆 key,比如按天、周、月。不要把所有历史排行塞到一个永不过期的 key。
生产建议:
排行榜 key 要按周期拆分。只展示 Top N 时,不要保留无限尾部。大范围排名查询要压测,必要时异步计算。
HyperLogLog:UV 估算不是精确计数
PFADD shop:prod:uv:home:<RUN_ID> user10001 user10002 user10003
PFCOUNT shop:prod:uv:home:<RUN_ID>
EXPIRE shop:prod:uv:home:<RUN_ID> 2592000HyperLogLog 适合做大规模 UV 估算。它有误差,不适合资金、库存、权限这类必须精确的业务。生产建议是:埋点分析可以用估算,交易正确性不能用估算。
TTL:过期时间是业务设计,不是随手写个 30 分钟
TTL 决定缓存的生命周期,也决定数据库回源压力和脏数据窗口。不要所有 key 都写同一个过期时间。
基础 TTL
SET shop:prod:config:pay-channel:v1 '{"enabled":true}' EX 300
TTL shop:prod:config:pay-channel:v1如果 TTL 返回 -1,说明 key 没有过期时间。缓存数据没有 TTL,通常是生产风险,除非你有明确的主动失效和巡检机制。
随机 TTL 防雪崩
大量 key 同时过期,会造成瞬时回源。应用侧可以给 TTL 加随机扰动:
long baseSeconds = 1800;
long jitterSeconds = ThreadLocalRandom.current().nextLong(0, 300);
Duration ttl = Duration.ofSeconds(baseSeconds + jitterSeconds);常见坑是只给热点 key 加随机 TTL,普通 key 全部固定 30 分钟。实际雪崩往往来自批量预热、批量导入或发布后统一回填,所有 key 的过期时间非常接近。
空值 TTL 要短
缓存不存在数据可以防穿透,但空值 TTL 不能太长。
真实对象 TTL:30 分钟到 2 小时,按业务更新频率调整
空值 TTL:30 秒到 5 分钟,按误伤容忍度调整
热点保护 TTL:逻辑过期 + 异步刷新生产建议:TTL 要按缓存类型分层管理。配置、字典、详情、列表、排行榜、空值和热点数据不应该使用同一套过期策略。
过期字典:TTL 到点不等于立刻消失
给 key 设置过期时间以后,Redis 会把过期时间维护在过期索引里。开发侧最容易误解的一点是:TTL 到点不代表 key 在那个毫秒立刻从内存消失。Redis 会结合两条路径清理:
惰性删除:客户端访问 key 时发现已经过期,顺手删除并返回不存在。主动过期:后台周期性抽样检查带过期时间的 key,发现过期就删除。
主动过期不是“扫完整个 keyspace”。Redis 会在带过期时间的 key 中抽样,发现过期比例较高时继续加大清理力度;如果实例里积累了大量冷 key,又几乎没有访问流量,过期内存可能延迟释放一段时间。Redis 6+ 可以通过 active-expire-effort 调整主动过期努力程度,但它本质是在 CPU 时间和过期回收速度之间取舍,不是业务定时器开关。
最小实验:
SET shop:prod:tmp:expire-demo "1" EX 2
TTL shop:prod:tmp:expire-demo
GET shop:prod:tmp:expire-demo
INFO stats
CONFIG GET active-expire-effort验证结果:
TTL 倒计时到 -2 说明 key 不存在。INFO stats 里的 expired_keys 增长,说明过期删除发生过。如果 key 很少被访问,删除可能由主动过期周期完成;如果刚好被访问,就走惰性删除。
active-expire-effort 调高后要观察 CPU、延迟和 expired_keys,不能只看内存下降。
常见坑:
生产建议:TTL 是缓存生命周期,不是业务定时任务。需要精确触发的场景,用任务调度、延迟队列或可靠 MQ,不要指望 Redis 过期事件承担强语义。
淘汰策略:内存满了以后谁先走
TTL 解决“业务上什么时候可以失效”,淘汰策略解决“内存超过上限时 Redis 怎么自救”。这两个概念不能混在一起。
淘汰也不是全局精确排序。LRU、LFU、TTL 类策略都会在候选 key 中采样,maxmemory-samples 越大,近似结果越接近理想淘汰,但 CPU 成本也越高。开发侧要记住:淘汰策略是实例自救,不是业务容量规划。
先看当前配置:
CONFIG GET maxmemory
CONFIG GET maxmemory-policy
CONFIG GET maxmemory-samples
INFO memory
INFO stats常见策略怎么选:
| 策略 | 行为 | 开发侧适用判断 |
|---|---|---|
noeviction | 内存超过上限后,可能写入失败 | 不能接受缓存悄悄丢 key,愿意让写入显式失败 |
allkeys-lru | 从所有 key 里近似淘汰最近最少使用 | 纯缓存实例常见默认选择,要求数据都可回源 |
allkeys-lfu | 从所有 key 里近似淘汰低频访问 | 访问频率差异明显的缓存 |
volatile-lru | 只淘汰设置了 TTL 的 key | 同实例里混有不该淘汰的 key,但更推荐拆实例 |
volatile-ttl | 优先淘汰 TTL 更短的 key | 业务能用 TTL 表达优先级 |
allkeys-random | 随机淘汰 | key 访问分布接近均匀,或临时压测验证 |
验证是否正在淘汰:
INFO stats
INFO memory重点看:
写命令是否因为 noeviction 被拒绝。maxmemory-samples 是否被改过,改动前后 P99 和 CPU 是否有对照。
生产建议:不要把永久数据和缓存数据塞在同一个 Redis 实例里再靠 volatile-* 策略“精细治理”。能拆实例就拆实例,不能拆时至少保证所有缓存 key 都有 TTL,并且明确哪些 key 被淘汰后能回源、哪些不能。
Cache Aside:多数业务的默认缓存模式
最常见、也最可控的模式是 Cache Aside。
读写路径可以合在一张图里看:
Cache Aside 的核心不是“先查缓存再查库”这么简单,而是读路径、写路径、空值、TTL 和删除失败补偿必须成套出现。少一块,线上就会变成穿透、旧值或回源风暴。
读路径:
先查 Redis。命中直接返回。未命中查数据库。
数据存在则写入缓存。数据不存在则写短 TTL 空值。
写路径:
先更新数据库。再删除缓存。删除失败进入补偿。
伪代码:
public OrderDTO getOrder(String orderNo) {
String key = "shop:prod:order:detail:" + orderNo + ":v1";
String cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
if ("__NULL__".equals(cached)) {
return null;
}
return json.readValue(cached, OrderDTO.class);
}
OrderDTO order = orderRepository.findByOrderNo(orderNo);
if (order == null) {
redisTemplate.opsForValue().set(key, "__NULL__", Duration.ofMinutes(1));
return null;
}
redisTemplate.opsForValue().set(key, json.writeValueAsString(order), randomTtl());
return order;
}
public void updateOrderStatus(String orderNo, int status) {
orderRepository.updateStatus(orderNo, status);
redisTemplate.delete("shop:prod:order:detail:" + orderNo + ":v1");
}验证方式:
GET shop:prod:order:detail:ORD001:v1
TTL shop:prod:order:detail:ORD001:v1常见坑:
只写读路径,不写删除缓存。更新数据库后直接更新缓存,旧请求晚到时把缓存覆盖回旧值。删除缓存失败没有重试、消息或巡检补偿。
空值缓存没有区分真实空字符串和不存在标记。
生产建议:缓存写路径要和数据库写路径一起评审。凡是更新数据库但没有缓存失效动作的需求,都不能直接上线。
Spring Data Redis:不要把默认配置直接带进核心链路
很多 Spring Boot 项目会从 RedisTemplate 或 @Cacheable 开始接入 Redis。入口简单,但默认行为必须看清楚:
一个更稳的基础配置长这样:
@Bean
RedisCacheManager redisCacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration defaults = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30))
.serializeKeysWith(RedisSerializationContext.SerializationPair
.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer()))
.disableCachingNullValues();
RedisCacheWriter writer = RedisCacheWriter.nonLockingRedisCacheWriter(
connectionFactory,
BatchStrategies.scan(1000)
);
return RedisCacheManager.builder(writer)
.cacheDefaults(defaults)
.enableStatistics()
.build();
}验证方式:
SCAN 0 MATCH "orderCache::*" COUNT 100
TTL "orderCache::100001"
MEMORY USAGE "orderCache::100001"常见坑:
clear() 在大 keyspace 下触发大范围扫描或删除,业务高峰抖动。
生产建议:核心缓存不要只靠注解默认值。每个 cacheName 都要映射到一份缓存契约,写清 TTL、序列化、是否允许 null、清理策略和统计指标。
缓存穿透:不存在的数据也要有策略
缓存穿透是指请求的数据缓存没有,数据库也没有,请求每次都打到数据库。
穿透、击穿、雪崩很容易混在一起,先按故障形态分流:
这张图也能指导排障口径:先问数据库里有没有,再问是不是单个热点,再问是不是大面积同时失效。判断错了,解决方案会完全跑偏。
直接做法一:缓存短 TTL 空值
if (order == null) {
redisTemplate.opsForValue().set(key, "__NULL__", Duration.ofMinutes(1));
return null;
}验证:
GET shop:prod:order:detail:not-exists:v1
TTL shop:prod:order:detail:not-exists:v1常见坑:空值 TTL 太长,新数据创建后短时间内仍然查不到。解决方式是数据创建成功后主动删除对应空值 key,或者把空值 TTL 控制得很短。
直接做法二:布隆过滤器
当请求明显包含大量非法 ID 时,可以先用布隆过滤器过滤。布隆过滤器的特点是:判断不存在时一定不存在,判断存在时可能误判。
伪代码:
if (!bloomFilter.mightContain(orderNo)) {
return null;
}
return getOrder(orderNo);生产建议:
布隆过滤器要有初始化、增量更新和重建流程。不能删除元素的布隆过滤器不适合频繁删除的业务。误判率要结合内存成本评估。
布隆过滤器只能减轻穿透,不能替代权限校验和参数校验。
缓存击穿:热点 key 过期时要单飞重建
缓存击穿是指一个热点 key 失效,大量请求同时回源数据库。
直接做法:互斥重建。只有一个线程查数据库并回填缓存,其他线程短暂等待或返回旧值。
public OrderDTO getHotOrder(String orderNo) {
String key = "shop:prod:order:detail:" + orderNo + ":v1";
OrderDTO cached = readOrderCache(key);
if (cached != null) {
return cached;
}
String lockKey = "shop:prod:lock:order:detail:" + orderNo;
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, UUID.randomUUID().toString(), Duration.ofSeconds(10));
if (Boolean.TRUE.equals(locked)) {
try {
OrderDTO order = orderRepository.findByOrderNo(orderNo);
writeOrderCache(key, order, randomTtl());
return order;
} finally {
redisTemplate.delete(lockKey);
}
}
sleepQuietly(50);
return readOrderCache(key);
}这个示例能讲清思路,但生产里不建议手写复杂锁。手写锁必须保证加锁带过期时间、释放时校验持有者、业务执行时间小于锁有效期。更稳的方式是使用成熟客户端,例如 Redisson。
常见坑:
锁没有过期时间,线程异常后锁永远不释放。释放锁不校验持有者,删掉了别人的锁。所有等待线程无限阻塞,热点变成线程池事故。
热点 key 每次都等失效后才重建,没有预热和逻辑过期。
生产建议:核心热点数据优先使用逻辑过期和异步刷新,让用户请求尽量读旧值而不是一起回源。
缓存雪崩:不要让大量 key 同时失效
缓存雪崩可能来自 Redis 不可用,也可能来自大量 key 同时过期。开发侧至少要处理后者。
直接做法:
TTL 加随机扰动。批量预热时分批写入,不要同一秒写完。热点 key 使用逻辑过期或后台刷新。
Redis 异常时走降级,不要所有请求直接打数据库。
降级示例:
try {
return cacheService.getOrder(orderNo);
} catch (RedisConnectionFailureException ex) {
if (isCoreRead(orderNo)) {
return orderRepository.findByOrderNo(orderNo);
}
return OrderDTO.degraded(orderNo);
}生产建议:降级不能只写代码分支,要有开关、限流和观测指标。否则 Redis 一抖,所有请求绕过缓存查库,数据库仍然会被打满。
热点 key 和 BigKey:性能问题常常不是 Redis 不够快
热点 key 是访问过于集中,BigKey 是单个 key 的 value 或集合成员过大。两者都会让 Redis、网络和客户端抖动。
怎么发现
先看慢命令:
SLOWLOG GET 10看 key 的内存:
MEMORY USAGE shop:prod:order:detail:100001:v1扫描疑似大 key:
redis-cli --bigkeys按模式抽样:
SCAN 0 MATCH shop:prod:order:* COUNT 100SCAN 不是一次性全量阻塞命令,但也不是完全没有成本。生产高峰不要用大 COUNT 粗暴扫描。
怎么治理
热点 key:
本地短缓存,降低 Redis 往返。key 拆分或副本 key,分摊读压力。逻辑过期 + 后台刷新。
对热点接口限流和降级。
BigKey:
大对象拆成多个小 key。大集合分页读取,不用 HGETALL、SMEMBERS、大范围 ZRANGE。删除大 key 用 UNLINK 或渐进式删除,避免阻塞。
建立 value 大小和集合元素数量上限。
示例:
UNLINK shop:prod:large:temp:<RUN_ID>生产建议:BigKey 阈值要按业务和机器规格定。不要迷信一个固定数字。你要看的是单次命令耗时、网络流量、序列化耗时和对其他 key 的影响。
Lua、Pipeline 和客户端缓存:提高效率之前先看边界
Redis 开发里还有三类常见“高级用法”:Pipeline、Lua 脚本和客户端本地缓存。它们都能提效,也都可能把问题放大。
Pipeline:减少 RTT,不提供事务原子性
Pipeline 的作用是把多条命令一次性发给 Redis,最后再批量读取响应。它解决的是网络往返成本,不是原子性。
适合场景:
批量预热缓存。批量读取多个互不依赖的小 key。写入一批统计值或临时 key。
Java 示例:
List<Object> values = redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
for (String orderNo : orderNos) {
byte[] key = ("shop:prod:order:detail:" + orderNo + ":v1").getBytes(StandardCharsets.UTF_8);
connection.stringCommands().get(key);
}
return null;
});常见坑:
把几万条命令塞进一个 pipeline,客户端内存、Redis 输出缓冲和网络包一起变大。在 Cluster 模式下批量 key 跨 slot,客户端行为和性能都可能变复杂。误以为 pipeline 里的命令“一起成功或一起失败”。
排查命令:
CLIENT LIST
INFO clients
CONFIG GET client-output-buffer-limit生产建议:pipeline 批量大小要压测,一般按响应大小和延迟目标分批,而不是只按命令条数分批。需要原子判断时,考虑 Lua、事务或数据库约束,不要靠 pipeline。
Lua:原子执行,但会阻塞主线程
Lua 脚本适合把“读一个 key、判断值、再修改”的短逻辑放到 Redis 内部执行,减少网络往返并保证脚本执行期间不被其他命令插队。
释放锁的旧版本兼容写法:
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
endRedis 8.4+ 已经提供 DELEX key IFEQ value 这类条件删除能力,简单锁释放可以优先评估新命令;如果运行环境还在旧版本,再使用 Lua 兼容。
常见坑:
脚本里遍历大集合,阻塞主事件循环。脚本没有版本管理,应用重启、主从切换后 EVALSHA 找不到脚本。脚本里访问的 key 在 Cluster 下不在同一个 slot。
把复杂业务规则写进 Lua,后续难测试、难灰度、难回滚。
生产建议:Lua 只放短小、可证明、可压测的原子片段。超过几十行的业务脚本,一般就该回到应用层或数据库层重新设计。
客户端缓存:本地更快,但失效更难
客户端缓存可以把 Redis 读结果放到应用本地内存里,热点读会更快。Redis 的 client-side caching 支持服务端跟踪 key 并发送失效通知,但这不是默认应该打开的银弹。
适合场景:
配置、字典、规则这类读多写少数据。允许短暂旧值,有版本号或发布号兜底。应用实例数量可控,失效通知和本地缓存容量可观测。
常见坑:
本地缓存、Redis、数据库三层都可能有旧值,排查链路变长。连接断开或失效通知处理异常后,本地缓存可能继续读旧数据。服务端跟踪 key 也有内存成本,不适合无限制缓存所有读过的 key。
生产建议:客户端缓存一定要有最大容量、短 TTL、失效通知失败兜底和一键关闭开关。核心权限、资金、库存状态不要只靠本地缓存判断。
fork、AOF/RDB 和 P99:持久化不是运维才关心
Redis 安装和备份恢复属于运维链路,但开发必须知道持久化会影响在线请求。BGSAVE、AOF rewrite 等操作通常会触发 fork,父子进程通过写时复制共享内存页;如果 fork 期间实例内存很大、页面很活跃、宿主机内存紧张,在线请求 P99 可能抖动。AOF fsync 策略、磁盘抖动和 rewrite 期间的增量缓冲,也会影响写入尾延迟。
开发排障时可以按这条链路把 P99 抖动和持久化现场串起来:
这部分虽然不写成备份恢复教程,但开发必须会把 Redis 慢请求、应用 P99、latest_fork_usec 和批量任务时间线放在一起看。
先看现场:
INFO persistence
INFO memory
LATENCY LATEST
LATENCY DOCTOR重点看:
latest_fork_usec 是否在抖动时突然升高。rdb_bgsave_in_progress、aof_rewrite_in_progress 是否和接口 P99 同时出现。aof_pending_bio_fsync、aof_delayed_fsync 是否持续增长。
used_memory_rss 和 mem_fragmentation_ratio 是否让 fork 写时复制更容易放大。
生产建议:业务批量预热、大 key 删除、批量刷新和数据修复不要和备份、AOF rewrite、实例迁移挤在同一窗口。即使持久化参数由运维负责,开发也要把 Redis P99 抖动和 INFO persistence 放进事故排查路径。
Redisson:用锁可以,但要知道边界
Redis 分布式锁常用于防重复执行、热点重建、定时任务抢占等场景。生产里建议优先使用成熟客户端,不要复制一段不完整的 SETNX 代码到处用。
Redisson 基本写法:
RLock lock = redissonClient.getLock("shop:prod:lock:order:" + orderNo);
boolean locked = lock.tryLock(200, 10, TimeUnit.SECONDS);
if (!locked) {
throw new BusyException("order is processing");
}
try {
processOrder(orderNo);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}参数含义:
waitTime:最多等多久拿锁。leaseTime:拿到锁后多久自动释放。unlock 前用 isHeldByCurrentThread() 校验,避免释放别人的锁。
如果不传 leaseTime,Redisson 的看门狗机制会尝试自动续期,默认看门狗超时时间是 30 秒,可通过 Config.lockWatchdogTimeout 调整。如果传了显式 leaseTime,锁会在指定时间后自动释放,业务执行时间必须小于这个窗口。看门狗能减少业务执行时间不确定导致的锁过期问题,但不代表锁永远安全。长时间 Full GC、网络抖动、Redis 主从切换、业务线程卡死,都可能让锁行为和你想象的不一致。
适合用 Redis 锁的场景:
防止同一个缓存热点被并发重建。防止同一个定时任务多实例同时执行。防重复提交的辅助保护。
冲突代价可控,失败可重试或可降级。
不适合只靠 Redis 锁的场景:
资金、库存等强一致核心状态,没有数据库约束兜底。锁住以后要调用长时间外部流程。业务无法容忍锁丢失、重复执行或短暂并发。
生产建议:关键写入要用数据库唯一键、状态条件或版本号兜底。Redis 锁是减少并发冲突的工程手段,不是业务正确性的唯一凭证。
验证锁是否符合预期,不要只看“代码没报错”。至少做三类实验:
TTL shop:prod:lock:order:ORD001
PTTL shop:prod:lock:order:ORD001
SLOWLOG GET 10业务执行时间超过 leaseTime 时,确认是否会被其他线程抢到锁。模拟持锁线程异常退出,确认锁能自动释放。模拟并发更新,确认数据库唯一键、状态条件或版本号能挡住重复写。
缓存一致性:没有银弹,只有边界和补偿
数据库和 Redis 是两个系统,不可能靠一条普通代码语句获得天然强一致。多数业务采用“先写数据库,再删除缓存”。
@Transactional
public void updateUserProfile(String userNo, UpdateProfileCommand command) {
userRepository.updateProfile(userNo, command);
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
redisTemplate.delete("shop:prod:user:profile:" + userNo + ":v2");
}
});
}这里要注意:删除缓存最好在事务提交后执行。如果事务回滚了,缓存却被删了,虽然通常还能回源修复,但会制造不必要的抖动。
常见一致性方案:
| 方案 | 适用场景 | 风险 |
|---|---|---|
| 先写库再删缓存 | 多数 Cache Aside 业务 | 删除失败会读旧值 |
| 延迟双删 | 并发读写窗口明显的场景 | 延迟时间难精确,不能滥用 |
| 版本化 key | 配置、规则、字典 | 旧 key 清理成本 |
| 消息补偿 | 更新频繁且可最终一致 | 消息丢失或重复要处理 |
| 定时巡检 | 低频重要数据 | 延迟修复,不适合强实时 |
| CDC 失效 | 数据变更多入口 | 链路复杂,归部署和数据平台治理 |
生产建议:一致性不要只说“允许最终一致”。要写清楚最长可接受旧值时间、触发失效的入口、删除失败怎么补偿、用户看到旧值怎么办。
这一节是 Redis 开发落地最容易出事故的地方。缓存问题往往不是某一行命令错了,而是容量、归属、TTL、热点、一致性和降级长期没人治理。
Key 规范
现象:缓存很多,但没人知道哪个模块写的,改一个业务要全库 SCAN 找 key。
直接做法:
key 必须包含应用、环境、业务域、对象、ID、版本。每类 key 要有 owner。key 结构升级必须升级版本,不能让新旧 value 混用。
禁止使用真实手机号、身份证、token 等敏感信息直接做 key。
验证:
SCAN 0 MATCH shop:prod:order:* COUNT 100生产建议:缓存契约要进代码评审。没有 key 规范的缓存,不允许进入核心链路。
命令复杂度治理
现象:Redis CPU 不高但接口 P99 抖动,SLOWLOG 里反复出现 HGETALL、SMEMBERS、大范围 ZRANGE 或 Lua 脚本。
直接做法:
COMMAND INFO HGETALL
COMMAND INFO SMEMBERS
OBJECT ENCODING shop:prod:rank:sale:202607
INFO commandstats
SLOWLOG GET 10判断:
O(n) 命令是否进入在线请求链路。集合元素数量是否超过缓存契约上限。cmdstat_* 调用次数是否和业务流量匹配。
慢命令是否集中在同一类 key 或同一个接口。
生产建议:禁止在线请求执行全量集合读取。排行榜、关系集合、购物车、权限集合都要分页、限长或拆 key;确实需要全量导出时,放到后台任务并限速。
TTL 治理
现象:大量 key 没有过期时间,或者所有 key 同一时间过期。
直接做法:
TTL shop:prod:order:detail:100001:v1判断:
-2 表示 key 不存在。-1 表示 key 存在但没有 TTL。正数表示剩余秒数。
生产建议:不同缓存类型使用不同 TTL 档位,统一加随机扰动。空值 TTL 必须短,热点缓存建议逻辑过期。
内存治理
现象:Redis 内存持续增长,淘汰次数上升,命中率下降,接口偶发慢。
直接做法:
INFO memory
INFO stats
MEMORY USAGE shop:prod:order:detail:100001:v1
redis-cli --bigkeys判断:
used_memory 是否持续上涨。evicted_keys 是否增加。是否存在大量无 TTL key。
是否存在 BigKey 或大集合。
生产建议:缓存也有成本。不要因为 Redis 快,就缓存低频、巨大、频繁变化的数据。
热点治理
现象:某个活动、配置、榜单或商品 key 被打爆,Redis CPU 和网络上升,数据库也可能被击穿。
直接做法:
热点 key 预热。本地短缓存。逻辑过期 + 异步刷新。
互斥重建。限流和降级。
验证:
SLOWLOG GET 10
INFO commandstats生产建议:热点治理要提前设计。等热点发生后再加锁,通常已经进入线程池和数据库联动事故。
Pipeline 和输出缓冲
现象:批量预热、批量查询或活动接口使用 pipeline 后,客户端偶发超时,Redis 内存上涨,连接被服务端断开。
直接做法:
CLIENT LIST
INFO clients
CONFIG GET client-output-buffer-limit
INFO commandstats判断:
单个连接的 omem、obl、tot-mem 是否持续增长。pipeline 响应是否包含大 value 或大集合结果。客户端是否只管写入 pipeline,不及时读取响应。
同一应用实例是否并发打开太多大 pipeline。
生产建议:pipeline 要按响应字节数、连接数和 Redis P99 做上限,而不是只写“每批 1000 条”。大 value 批量读和小 value 批量读不是同一种风险。
持久化抖动
现象:业务代码没有发布,Redis 慢命令也不多,但接口 P99 在某些时间点规律性升高。
直接做法:
INFO persistence
INFO memory
LATENCY LATEST
LATENCY DOCTOR判断:
抖动时间是否和 rdb_bgsave_in_progress 或 aof_rewrite_in_progress 重合。latest_fork_usec 是否异常升高。aof_delayed_fsync 是否增长。
抖动前后是否有批量写入、预热、大 key 删除或数据修复。
生产建议:开发侧要把 Redis 持久化窗口当成容量约束的一部分。批量任务要可暂停、可限速、可错峰,不能只在应用里追求最快写入。
降级
现象:Redis 抖动后,所有请求回源数据库,数据库也被拖垮。
直接做法:
核心数据允许有限回源。非核心数据返回默认值或空结果。后台任务暂停预热和批量刷新。
对回源数据库加限流。
生产建议:降级要有开关和指标。不要把降级写成隐藏分支,结果没人知道什么时候触发、返回了多少默认值。
缓存一致性
现象:数据库已更新,用户还看到旧数据;或者缓存删除失败,没有补偿。
直接做法:
写库成功后删除缓存。删除失败写入重试队列或补偿表。核心配置使用版本号或发布号。
定期抽样比对数据库和缓存。
生产建议:缓存一致性的关键是可观测和可补偿。只要不能证明缓存已失效,就不要认为更新链路完成。
成本
现象:Redis 容量越买越大,但命中率不高,很多缓存只是把数据库旧问题搬到内存里。
直接做法:
对每类缓存记录命中率、平均 value 大小、key 数、TTL、回源成本。删除低命中、大 value、频繁更新的缓存。对报表、导出、聚合类需求改用异步汇总,而不是塞 Redis。
生产建议:缓存不是免费午餐。内存、网络、序列化、运维、排障和一致性都是成本。
排障手册
| 现象 | 先看什么 | 常见原因 | 修复方向 | 验证 |
|---|---|---|---|---|
| 数据库压力突然升高 | Redis 命中率、异常日志 | 缓存穿透、雪崩、Redis 不可用 | 空值缓存、随机 TTL、降级限流 | DB QPS 回落 |
| 热点接口 P99 抖动 | 热点 key、慢命令 | 热点 key 过期或 BigKey | 逻辑过期、本地缓存、拆 key | P99 下降 |
| 用户读到旧数据 | 写路径和缓存删除日志 | 删除缓存失败、事务未提交前删除 | 事务后删除、补偿重试 | 更新后读新值 |
| Redis 慢命令增多 | SLOWLOG GET | 大集合读取、BigKey 删除 | 分页读取、UNLINK、拆分 | 慢命令减少 |
| 命令排队和延迟抖动 | redis-cli --latency、INFO commandstats | O(n) 命令、长 Lua、pipeline 过大 | 拆批、限长、改异步任务 | P99 和慢命令下降 |
| Pipeline 后连接断开 | CLIENT LIST、INFO clients | 输出缓冲过大、慢客户端、大响应 | 缩小批次、限制并发、拆 value | omem 和断连下降 |
| fork/AOF/RDB 期间 P99 抖动 | INFO persistence、LATENCY LATEST | fork 写时复制、fsync、rewrite | 错峰、限速、拆批、扩容 | 抖动窗口消失 |
| 内存持续上涨 | INFO memory、TTL | 无 TTL key、缓存过大 | TTL 治理、删除低价值缓存 | 内存趋势稳定 |
| key 被意外淘汰 | INFO stats、CONFIG GET maxmemory-policy | 内存上限触发、策略不匹配 | 调整容量、TTL、淘汰策略或拆实例 | evicted_keys 不再异常增长 |
| 分布式锁偶发失效 | 业务耗时、GC、锁参数 | lease 太短、释放错误 | Redisson、释放校验、数据库兜底 | 并发测试通过 |
| 反序列化失败 | key 版本和值结构 | value 结构升级未隔离 | key 版本化、兼容读取 | 错误率归零 |
| 本地缓存读旧值 | 本地缓存命中、失效通知 | 客户端缓存未失效、连接断开 | 短 TTL、版本号、关闭开关 | 旧值窗口符合契约 |
命令速查
-- 基础读写和 TTL
GET key
SET key value EX 1800
SET lockKey token NX PX 10000
TTL key
PTTL key
DEL key
UNLINK key
TYPE key
OBJECT ENCODING key
-- Hash
HSET key field value
HMGET key field1 field2
HLEN key
-- Sorted Set
ZADD key score member
ZREVRANGE key 0 9 WITHSCORES
ZCARD key
-- 扫描和内存
SCAN 0 MATCH shop:prod:order:* COUNT 100
MEMORY USAGE key
redis-cli --bigkeys
-- 慢命令和状态
COMMAND INFO HGETALL
SLOWLOG GET 10
INFO memory
INFO stats
INFO commandstats
INFO clients
INFO persistence
LATENCY DOCTOR
LATENCY LATEST
redis-cli --latency
-- 客户端和 pipeline
CLIENT LIST
CONFIG GET client-output-buffer-limit
-- 淘汰策略
CONFIG GET maxmemory
CONFIG GET maxmemory-policy
CONFIG GET maxmemory-samples
CONFIG GET active-expire-effort
-- I/O 线程边界
CONFIG GET io-threads
CONFIG GET io-threads-do-reads每类缓存是否写清 key 格式、value 结构、TTL、owner 和版本。是否没有把敏感信息直接放进 key。每类 key 是否定义最大 value 字节数、最大字段数、最大集合元素数和禁止命令。
是否给所有缓存设置了 TTL,且 TTL 有随机扰动。空值缓存是否使用短 TTL,并在数据创建后主动删除。Redis 实例是否设置了清晰的 maxmemory 和 maxmemory-policy,且能解释淘汰后果。
热点 key 是否有预热、互斥重建、逻辑过期或本地缓存方案。BigKey 是否有大小上限、分页读取和删除方案。Pipeline 是否按响应字节数、批量大小、连接数和 P99 设置上限。
是否知道 client output buffer 上限,慢客户端和大 pipeline 是否有告警。是否知道 Redis 持久化或 rewrite 窗口,批量预热、批量修复、大 key 删除是否可错峰和限速。Lua 脚本是否短小可压测。
Spring Data Redis 是否显式配置 TTL、序列化、清理策略和统计指标。写数据库后是否有缓存失效动作,且删除失败有补偿。Redis 不可用时是否有降级和数据库回源限流。
Redisson 锁是否设置等待时间、租约时间或理解看门狗边界,释放时是否校验持有者。客户端本地缓存是否有短 TTL、失效兜底和关闭开关。是否没有把安装、哨兵、集群、备份恢复等部署运维内容混入开发方案。
落地模板
缓存契约模板
缓存名称:
业务 owner:
key 格式:
value 结构:
序列化方式:
TTL:
是否缓存空值:
失效触发点:
一致性要求:
降级策略:
回源限流:
监控指标:
清理策略:热点 key 评审模板
热点来源:活动 / 配置 / 商品 / 榜单 / 其他
预估 QPS:
value 大小:
是否允许读旧值:
是否预热:
是否本地缓存:
重建方式:互斥 / 逻辑过期 / 后台刷新
Redis 异常降级:
数据库回源上限:
上线后观察指标:缓存一致性模板
事实源:
缓存 key:
更新入口:
删除缓存时机:
删除失败补偿:
最长旧值窗口:
是否需要版本化 key:
是否需要巡检比对:
用户读旧值处理:Redis GitHub Releases。Redis 官方文档:Redis 8.8 新特性。Redis 官方下载入口
Redis 源码目录。Redis 官方文档:数据类型。Redis 官方文档:命令参考
Redis 官方文档:Key 过期。Redis 官方文档:内存淘汰。Redis 官方文档:Pipeline
Redis 官方文档:客户端处理与输出缓冲。Redis 官方文档:INFO 命令。Redis 官方文档:内存优化
