Redis 开发篇
本文范围
这篇只讲后端开发怎么用好 Redis,不讲 Redis 怎么安装、怎么搭哨兵、怎么建集群,也不展开备份、持久化和服务器运维。你可以把它当成一份缓存开发带练:先选对数据结构和 key,再设计 TTL、缓存模式、回源、失效、热点保护和降级,最后把 Redisson、缓存一致性和成本治理落到可验证的模板里。
本文覆盖这些内容:
- 数据结构: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 锁能解决很多工程问题,但不是强一致事务系统。
版本基线
截至 2026-07-10,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:20260709 user10001 user10002 user10003
PFCOUNT shop:prod:uv:home:20260709
EXPIRE shop:prod:uv:home:20260709 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 到期”当成精确调度器,结果延迟删除导致判断偏差。
- 大批量 key 使用相同 TTL,主动过期和回源压力叠在一起。
- 只看
GET结果,不看expired_keys、evicted_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重点看:
evicted_keys是否持续增加。keyspace_hits / (keyspace_hits + keyspace_misses)是否低于预期。used_memory_dataset是否长期贴近或超过maxmemory。- 写命令是否因为
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。入口简单,但默认行为必须看清楚:
RedisCacheManager默认缓存条目没有过期时间。- 默认 value 序列化可能不是你想要的 JSON 协议,跨服务和长期兼容要显式配置。
- 清理缓存时默认策略可能使用
KEYS+DEL,大 keyspace 下要改成基于SCAN的批处理策略。 - Spring Data Redis 支持固定 TTL,也支持基于 key/value 动态计算 TTL 的
TtlFunction。 - Spring Data Redis 的 TTI 行为依赖 Redis
GETEX,要求 Redis 6.2+,并且读写路径要一致使用能刷新 TTL 的访问方式。
一个更稳的基础配置长这样:
@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"常见坑:
@Cacheable写得很快,但没有按缓存名配置 TTL,最后变成大量无过期 key。- 代码里同时混用
@Cacheable、RedisTemplate和 Repository,TTL 语义不一致。 - 缓存 null 值没有统一标记和短 TTL,穿透治理变成反序列化异常。
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:20260709生产建议: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 output buffer。Pipeline 写入很快,但响应如果读得慢,会堆在 Redis 连接的输出缓冲里;大响应、慢客户端、Pub/Sub 订阅者和网络抖动都可能让输出缓冲增长,最后触发断连或让内存被连接吃掉。
排查命令:
CLIENT LIST
INFO clients
CONFIG GET client-output-buffer-limit重点看 CLIENT LIST 里的 cmd、qbuf、obl、omem、tot-mem、age、idle。如果某个连接的输出缓冲持续增长,要从客户端读取速度、pipeline 批大小、响应字节数和网络链路一起查。
生产建议: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:
是否需要巡检比对:
用户读旧值处理: