Redis 深度研究:从数据结构到生产实践的完整拆解

举报
yd_236998400 发表于 2026/10/30 10:01:50 2026/10/30
【摘要】 Redis 深度研究:从数据结构到生产实践的完整拆解环境基线:Redis 8.0(社区版,AGPLv3)· Java 17 · Spring Boot 3.4.x · Spring Data Redis 3.4 · Lettuce 6.4 · Linux x86_64(生产建议 4C8G 起步)全文示例统一使用上述版本,配置文件、命令与 Java 代码不混用 6.x / 7.x 的旧写法。...

Redis 深度研究:从数据结构到生产实践的完整拆解

环境基线:Redis 8.0(社区版,AGPLv3)· Java 17 · Spring Boot 3.4.x · Spring Data Redis 3.4 · Lettuce 6.4 · Linux x86_64(生产建议 4C8G 起步)
全文示例统一使用上述版本,配置文件、命令与 Java 代码不混用 6.x / 7.x 的旧写法。涉及 7.x 行为的地方会单独标注。

Redis 是最容易被「用对了但没理解」的中间件。多数人第一次接触它,学的是 SET / GET / EXPIRE 三件套;真正出事故时,倒下的地方却往往是编码方式、淘汰策略、持久化 fsync 时机、集群 slot 归属这些没人教的部分。

这篇文章不做命令字典,而是沿着「一个 key 从写入到被淘汰」的完整生命周期,把 Redis 的内部机制、边界条件和工程约束逐个拆开,最后落到 Spring Boot 3.4 的可运行代码。

一、先看全景:Redis 到底是什么

1.1 常见误解与实际情况

常见说法 实际情况
Redis 是单线程的 命令执行主线程是单线程的;网络 I/O、持久化、异步删除、部分模块有多线程/子进程
Redis 就是缓存 缓存只是最常见用法,它同时是数据结构服务器、消息中间件(Stream)、计数器、分布式锁载体
Redis 数据都在内存,重启就没了 RDB / AOF 都是持久化方案,配置得当可以做到秒级丢失甚至不丢
Redis 快是因为「内存比磁盘快」 更关键的是避免锁、避免上下文切换、纯内存操作、极简协议
key 越多越占内存 空 key 不存在;真正吃内存的是 value 体积与 key 数量带来的 dict 开销

1.2 组件全景

层面 组成 关键点
网络层 单 Reactor + 多 I/O 线程(6.0+) io-threads 只处理读写与协议解析,不执行命令
协议层 RESP2 / RESP3 RESP3 支持推送、Map/Set 类型返回
数据结构层 SDS、dict、listpack、quicklist、intset、skiplist、rax 同一逻辑类型有多种物理编码,按阈值切换
内存层 jemalloc + 过期字典 + 淘汰策略 maxmemory 与 maxmemory-policy 是两条独立开关
持久化层 RDB 快照 / AOF 日志 / 混合持久化 7.0 起 AOF 为多文件(base + incr)
高可用层 主从复制 / Sentinel / Cluster Cluster 用 16384 个 slot 分片
扩展层 Functions、Lua、模块(JSON/Search/TimeSeries/Bloom/Vector Set) 8.0 把原 Redis Stack 能力并入发行版

Redis 8.0 最值得关注的两个变化:

  1. 原 Redis Stack 模块进入主发行包:JSON、Search(查询引擎)、TimeSeries、Bloom 不再需要单独装模块,docker run redis:8 即可用。
  2. 新增 Vector Set 类型:VADD / VSIM / VCARD 等命令,把向量检索内置到键空间,适合做 RAG 的召回层。这是 8.0 与 7.x 最实质的能力差异。

二、数据结构与底层编码:决定性能的真正变量

2.1 逻辑类型与物理编码对照

Redis 对外只有 String / Hash / List / Set / ZSet / Stream / Bitmap / HyperLogLog / GEO / Vector Set 这些逻辑类型,但每个类型在内部有多种物理编码。OBJECT ENCODING key 是排查内存与延迟问题的第一把钥匙。

逻辑类型 小对象编码 大对象编码 切换阈值(默认配置)
String int / embstr raw 纯数字且 ≤ long 范围用 int;≤ 44 字节用 embstr;否则 raw
Hash listpack hashtable hash-max-listpack-entries 128、hash-max-listpack-value 64
List listpack(quicklist 单节点) quicklist list-max-listpack-size 128
Set intset / listpack hashtable 全整数且 ≤ 512 用 intset;set-max-listpack-entries 128
ZSet listpack skiplist zset-max-listpack-entries 128、zset-max-listpack-value 64
Stream listpack(每个 radix 节点) 同左 stream-node-max-entries 100

两条必须记住的规则:

  • 阈值是「与」关系:Hash 只要有一个 field 的 value 超过 64 字节,整体立刻从 listpack 升级为 hashtable,而且不会降级。
  • 升级是永久的:删除元素让长度回到阈值以下,编码也不会退回。想缩回只能重建 key(DUMP/RESTORE 或业务侧迁移)。

验证方式:

redis-cli -h 127.0.0.1 -p 6379
127.0.0.1:6379> HSET user:1 name "张三" age 28
127.0.0.1:6379> OBJECT ENCODING user:1
"listpack"
127.0.0.1:6379> HSET user:1 bio "<一段超过 64 字节的自我介绍……>"
127.0.0.1:6379> OBJECT ENCODING user:1
"hashtable"

2.2 SDS:String 为什么不是 C 字符串

Redis 自己实现了 SDS(Simple Dynamic String),结构大致为 len / alloc / flags / buf[],带来三个直接收益:

能力 C 字符串 SDS
取长度 O(n) O(1)(读 len)
二进制安全 遇 \0 截断 安全,可存图片、序列化对象
追加扩容 每次 realloc 预分配 + 惰性释放,减少拷贝

embstr 与 raw 的区别在于:embstr 把 redisObject 与 SDS 一次性连续分配,适合短字符串;超过 44 字节后需要两次分配,改为 raw。

2.3 dict:哈希表的渐进式 rehash

Redis 的 dict 使用两张哈希表做渐进式 rehash:扩容时保留旧表,每次操作顺带迁移一个桶,把一次性 O(n) 的停顿摊到多次请求上。这也是 Redis 单线程还能保持低延迟的原因之一。

扩容触发条件与负载因子:

场景 触发阈值 说明
未做 BGSAVE 负载因子 ≥ 1 正常扩容
正在 BGSAVE/BGREWRITEAOF 负载因子 ≥ 5 避免 fork 期间写入触发大量内存拷贝
缩容 负载因子 < 0.1 且表大小超过 4,渐进缩容

在 hashtable 编码下,Hash 与 Set 的每次读写都要经过 dict 查找,所以「小对象用 listpack、大对象用 hashtable」的分界点会直接反映在 P99 延迟上。

2.4 listpack / quicklist / skiplist

  • listpack:紧凑的连续内存块,替代了旧的 ziplist(7.0 起全面替换),解决了 ziplist 的连锁更新问题。
  • quicklist:List 的默认实现,本质是「双向链表 + 每节点一个 listpack」,兼顾插入效率与内存局部性。
  • skiplist:ZSet 大对象编码,配合 dict 实现「按分数范围查」与「按成员查」两条路径都是 O(log n)。

实践结论:凡是「小集合」场景(配置项、对象字段、标签集合),把元素控制在上表阈值内,内存和延迟都会明显更好;一旦越过阈值,性能会呈台阶式变化,而不是线性渐变。

三、单线程模型与 I/O 多线程

3.1 为什么单线程还这么快

原因 展开
无锁 单线程执行命令,不需要加锁、不需要处理竞态
无上下文切换 不会因线程调度产生 CPU 抖动
内存操作 绝大多数命令是纯内存读写
高效协议 RESP 解析极简,且支持 pipeline 摊薄 RTT
I/O 多路复用 epoll 单线程管理海量连接

单线程的代价是一条慢命令会阻塞所有请求。KEYS *、FLUSHALL、大 key 的 DEL、SORT、复杂度 O(n) 的 Lua 脚本,都会让整实例停摆。这是 Redis 最重要的性能纪律。

3.2 6.0 之后的多线程边界

# redis.conf
io-threads 4
io-threads-do-reads yes
线程 负责什么 不负责什么
主线程 执行命令、操作数据结构、处理过期 ——
I/O 线程 socket 读写、协议解析、结果回写 不执行任何命令逻辑
后台线程 UNLINK 异步删除、lazyfree、AOF fsync 不参与命令处理
子进程 RDB / AOF rewrite 的 fork 子进程 不处理请求

所以「Redis 6 变多线程了」并不意味着命令可以并行。命令层面的串行语义没有变,变的只是网络收发。

3.3 延迟敏感场景的配置建议

参数 建议值 理由
io-threads 4(不超过 CPU 核数) 超过 8 收益递减
io-threads-do-reads yes 读多场景收益明显
maxmemory-policy 见第五节 缓存实例必须设置
slowlog-log-slower-than 10000(微秒) 超过 10ms 的命令值得复盘
slowlog-max-len 1024 保留足够现场
latency-monitor-threshold 100 开启延迟监控基线

别把 io-threads 当成「加线程就变快」。它在高并发、小 value、多连接的场景收益最大;value 很大时收益有限,因为瓶颈在内存带宽与协议序列化。

四、持久化:RDB、AOF 与混合模式

4.1 三种方案对比

维度 RDB AOF 混合(RDB 前言 + AOF 增量)
数据形态 二进制快照 命令日志 头部 RDB + 尾部增量命令
恢复速度 快 慢(重放全部命令) 快(先加载 RDB)
数据丢失 两次快照之间的数据 取决于 appendfsync 同 AOF
文件体积 小(压缩) 大 中等
对主线程影响 fork 时有内存拷贝开销 fsync 策略决定 两者兼有
适用场景 允许分钟级丢失 要求秒级/不丢 生产默认推荐

4.2 RDB 的关键参数

save 900 1
save 300 10
save 60 10000
dbfilename dump.rdb
dir /var/lib/redis
rdbcompression yes
rdbchecksum yes
stop-writes-on-bgsave-error yes

stop-writes-on-bgsave-error yes 是一个容易被忽略的保护:快照失败时拒绝写入,避免「以为有持久化、其实早就断了」。但它在磁盘满时会直接让实例只读,需要配合监控。

4.3 AOF:7.0 起是多文件结构

Redis 7.0 把单文件 AOF 改成多部分 AOF,目录结构如下:

/var/lib/redis/
└── appendonlydir/
    ├── appendonly.aof.1.base.rdb      # 基线(混合模式下是 RDB)
    ├── appendonly.aof.1.incr.aof      # 增量命令
    └── appendonly.aof.manifest        # 清单文件

对应配置:

appendonly yes
appendfilename "appendonly.aof"
appenddirname "appendonlydir"
aof-use-rdb-preamble yes
aof-timestamp-enabled yes

appendfsync everysec
no-appendfsync-on-rewrite no
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb

appendfsync 的三种取值直接决定可靠性与吞吐:

取值 行为 最坏丢失 吞吐影响
always 每个命令都 fsync 1 条命令 最大,通常只有金融类场景接受
everysec 每秒 fsync 一次 约 1 秒 生产默认,平衡点
no 交给操作系统 由内核刷盘决定,可能数十秒 最小,风险最高

7.0 之前的单文件 AOF 在 rewrite 期间可能产生「base 与 incr 混合」的重放歧义;多文件结构 + manifest 正是为解决这个问题。如果你的运维脚本还在按 appendonly.aof 单文件路径做备份,升级后必须改成备份 appendonlydir 整个目录。

4.4 fork 与内存放大

BGSAVE 与 AOF rewrite 都依赖 fork + COW(写时复制)。经验关系:

指标 经验值
峰值内存 约为数据量的 1.5~2 倍(写入越频繁,COW 页越多)
fork 耗时 与页表大小相关,几十 GB 实例可能达数百毫秒
建议 vm.overcommit_memory 1,否则 fork 可能失败

线上必须显式设置:

sysctl -w vm.overcommit_memory=1
echo 'never' > /sys/kernel/mm/transparent_hugepage/enabled

透明大页(THP)会让 fork 与 COW 的延迟抖动放大,Redis 官方长期建议关闭。

五、内存管理与淘汰策略

5.1 两层机制要分清

机制 作用对象 触发条件 关键参数
过期删除 设置了 TTL 的 key 惰性删除 + 定期抽样删除 hz、active-expire-effort
内存淘汰 全部或部分 key 已用内存超过 maxmemory maxmemory、maxmemory-policy

没有设置 maxmemory 时,淘汰策略永远不会触发,实例会一直吃到被 OOM Killer 干掉或触发 swap。这是 Redis 最常见的生产配置缺失。

5.2 八种淘汰策略

策略 候选集合 淘汰依据 适用场景
noeviction 无 写入直接报错 OOM command not allowed 数据不可丢、DB 型用途
allkeys-lru 全部 key 近似 LRU 纯缓存,最常见
allkeys-lfu 全部 key 访问频率 热点明显、长尾访问的缓存
allkeys-random 全部 key 随机 访问分布均匀时省 CPU
volatile-lru 有 TTL 的 key 近似 LRU 混合用途:缓存与持久数据共存
volatile-lfu 有 TTL 的 key 访问频率 同上,热点更集中
volatile-random 有 TTL 的 key 随机 少用
volatile-ttl 有 TTL 的 key 剩余 TTL 最短优先 短期会话类数据

选型决策链:

  1. 这个实例是否允许丢数据?不允许 → noeviction,并配置告警。
  2. 是否全部 key 都可淘汰?是 → 在 allkeys-lru 与 allkeys-lfu 中二选一。
  3. 只有部分 key 可淘汰 → 用 volatile-*,并确保可淘汰的 key 都设置了 TTL(否则候选集为空,行为退化为 noeviction)。

5.3 LRU 与 LFU 的真实实现

Redis 的 LRU 不是严格 LRU,而是采样近似:

maxmemory 4gb
maxmemory-policy allkeys-lru
maxmemory-samples 5

每次需要淘汰时随机采样 maxmemory-samples 个 key,挑最久未使用的淘汰。采样数越大越接近真实 LRU,CPU 开销也越高,5 是官方默认平衡点。

LFU 则维护一个 8 位的计数器(对数计数,lfu-log-factor 控制增长速度)与一个 16 位的衰减时间(lfu-decay-time 分钟)。LFU 更适合「热点长期稳定」的场景,LRU 更适合「访问模式随时间漂移」的场景。

5.4 内存碎片

mem_fragmentation_ratio = used_memory_rss / used_memory:

比值 判断 处理
1.0 ~ 1.5 正常 无需处理
> 1.5 碎片偏多 开启 activedefrag yes
< 1 已发生 swap 严重问题,立即扩容或排查内存
activedefrag yes
active-defrag-ignore-bytes 100mb
active-defrag-threshold-lower 10
active-defrag-threshold-upper 100
active-defrag-cycle-min 1
active-defrag-cycle-max 25

碎片治理的根因通常是「频繁增删不同大小的 value」与「大量过期 key 未及时释放」。前者靠对象复用与批量重建,后者靠 hz 与异步删除(lazyfree-lazy-expire yes、lazyfree-lazy-user-del yes)。

六、高可用与集群

6.1 三种部署形态

形态 节点数 故障转移 数据分片 适用规模
主从复制 2+ 手动 无(全量副本) 读扩展、备份
Sentinel 3+ Sentinel + 主从 自动选主 无 单机数据量可容纳
Cluster ≥ 3 主 3 从 自动选主 16384 slot 数据量或吞吐超出单机

6.2 主从复制的关键参数

replicaof 10.0.0.11 6379
masterauth YourPassword
masteruser default

repl-backlog-size 64mb
repl-diskless-sync yes
repl-diskless-sync-delay 5
repl-diskless-load disabled

min-replicas-to-write 1
min-replicas-max-lag 10

min-replicas-to-write 1 + min-replicas-max-lag 10 是防脑裂的核心配置:当没有健康副本、或副本延迟超过 10 秒时,主节点拒绝写入,避免旧主继续接受写请求造成数据分叉。

repl-backlog-size 决定「短暂断连后能否增量同步」。默认 1MB 在高写入场景下很容易被冲掉,导致全量同步(fork + 传输),进而引发雪崩式延迟。写入量大的实例建议 64MB 以上。

6.3 Cluster 的 slot 与重定向

Cluster 把键空间切成 16384 个 slot,slot = CRC16(key) mod 16384。

redis-cli --cluster create \
  10.0.0.1:6379 10.0.0.2:6379 10.0.0.3:6379 \
  10.0.0.4:6379 10.0.0.5:6379 10.0.0.6:6379 \
  --cluster-replicas 1

两种重定向必须区分:

响应 含义 客户端行为
MOVED 3999 10.0.0.2:6379 slot 永久归属另一个节点 更新本地 slot 映射后重试
ASK 3999 10.0.0.2:6379 slot 正在迁移,本次先去目标节点 只本次重试,不更新映射

把 ASK 当成 MOVED 处理、或反之,都会导致迁移期间的大量失败。成熟的客户端(Lettuce、Jedis)已经实现,自研客户端必须自己处理。

多 key 命令的约束:

# 同一个 slot(用 hash tag 强制同槽)
SET {order:1001}:amount 99
SET {order:1001}:status paid

# 跨 slot 会直接报错
MGET user:1 user:2
# (error) CROSSSLOT Keys in request don't hash to the same slot

所以 Cluster 下的批量操作要么用 hash tag 让相关 key 同槽(代价是热点集中),要么拆成多次单 key 请求 + pipeline,要么改用 RedisTemplate 的 executePipelined 逐 key 发送。

七、缓存三大问题与一致性

7.1 问题全景与解法

问题 现象 根因 解法
缓存穿透 大量请求打穿到 DB 查询不存在的数据,缓存永远不命中 空值缓存(短 TTL)、布隆过滤器、参数校验
缓存击穿 单个热点 key 失效瞬间 DB 被打爆 热点 key 过期,重建并发 互斥锁重建、逻辑过期、永不过期 + 后台刷新
缓存雪崩 大面积 key 同时失效或实例宕机 TTL 集中、实例故障 TTL 加随机抖动、多级缓存、熔断降级、集群

7.2 空值缓存与布隆过滤器的取舍

方案 优点 缺点
空值缓存 实现简单,命中即返回 占用内存;恶意随机 key 会撑爆缓存
布隆过滤器 内存极省,可拦截绝大多数无效 key 有假阳性;不支持删除(需 Counting Bloom 或重建)

布隆过滤器适合「ID 空间可枚举」的场景(如商品 ID、用户 ID);空值缓存适合「无效请求量可控」的场景。两者通常组合使用:布隆过滤挡第一层,空值缓存兜住假阳性。

7.3 Cache Aside 的正确顺序

操作 推荐顺序 原因
读 读缓存 → 未命中读 DB → 回写缓存 标准路径
写 先更新 DB,再删除缓存 删除比更新缓存更安全,避免并发写造成脏数据

为什么是「删除」而不是「更新」:两个并发写请求,若都去更新缓存,可能因执行顺序错乱导致缓存保留旧值;删除则让下一次读自然回源重建。

即便如此,仍有极小概率的不一致窗口(读请求在写请求删缓存之后、DB 更新之前读到旧值并回写)。常见兜底是延迟双删:

1. 删除缓存
2. 更新数据库
3. 延迟 500ms~1s 再删除一次缓存

延迟时间要大于「一次读请求的回源 + 回写」耗时,具体值需按业务压测确定,没有通用数字。

八、分布式锁:从 SET NX 到 Redisson

8.1 最小可用实现

# 加锁:原子地 SET NX + 过期时间,绝不要分成两条命令
SET lock:order:1001 "8f14e45f-ce" NX PX 30000

两个必须遵守的纪律:

  1. SET key value NX PX ms 必须一条命令完成。先 SETNX 再 EXPIRE,中间进程崩溃就会产生永久锁。
  2. value 必须是唯一标识(UUID / 请求 ID),释放时校验,避免误删别人的锁。

8.2 释放锁的 Lua 脚本

-- scripts/unlock.lua
if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('DEL', KEYS[1])
else
    return 0
end

Redis 保证单条 Lua 脚本原子执行,因此「比较 + 删除」不会被打断。

8.3 三种实现对比

方案 自动续期 可重入 公平锁 适用场景
手写 SET NX PX + Lua 无,需自己实现 无 无 简单短任务
Redisson RLock 有(看门狗) 有 有 FairLock 生产通用
RedLock(多实例) 需自行处理 无 无 对一致性要求极高且能接受复杂度

Redisson 看门狗机制:默认锁租约 30 秒,后台任务每 10 秒(租约的 1/3)续期一次,业务执行完主动 unlock 后停止续期。注意:只有「不显式指定 leaseTime」时看门狗才生效,一旦传入 leaseTime,Redisson 认为你自行管理超时,不再续期。

8.4 RedLock 的争议要点

RedLock 要求在 N 个独立 Redis 实例上多数派加锁成功才算成功。它的争议集中在:

  • 依赖各实例的时钟速率假设,无法完全抵御进程暂停(GC 停顿、虚拟机迁移)。
  • 运维成本高:需要维护 N 套互不相关的实例。
  • 对绝大多数业务,单实例 + 合理超时 + 幂等设计已经足够。

结论:分布式锁的正确性最终应该由「幂等 + 唯一约束」兜底,而不是由锁本身保证。 锁是性能优化,不是正确性保证。

九、Spring Boot 3.4 集成实战

9.1 依赖

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>

    <dependency>
        <groupId>org.apache.commons</groupId>
        <artifactId>commons-pool2</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
</dependencies>

Spring Boot 3.x 的 Redis starter 默认使用 Lettuce(基于 Netty,连接共享、线程安全)。commons-pool2 只有启用连接池时才需要,但不引入它而配置了 lettuce.pool,配置会静默失效。

9.2 配置文件

单机:

spring:
  data:
    redis:
      host: 127.0.0.1
      port: 6379
      password: "YourStrong@Passw0rd"
      database: 0
      timeout: 2s
      connect-timeout: 2s
      lettuce:
        pool:
          max-active: 16
          max-idle: 8
          min-idle: 2
          max-wait: 1s
        shutdown-timeout: 200ms

Cluster:

spring:
  data:
    redis:
      password: "YourStrong@Passw0rd"
      timeout: 2s
      cluster:
        nodes:
          - 10.0.0.1:6379
          - 10.0.0.2:6379
          - 10.0.0.3:6379
        max-redirects: 3
      lettuce:
        cluster:
          refresh:
            adaptive: true
            period: 30s

adaptive: true 让 Lettuce 在收到 MOVED 后自动刷新拓扑,比固定周期刷新更及时。

9.3 序列化配置(最容易踩坑的一环)

package com.example.redis.config;

import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);

        ObjectMapper mapper = new ObjectMapper();
        mapper.registerModule(new JavaTimeModule());
        mapper.activateDefaultTyping(
                LaissezFaireSubTypeValidator.instance,
                ObjectMapper.DefaultTyping.NON_FINAL,
                JsonTypeInfo.As.PROPERTY
        );

        StringRedisSerializer keySerializer = new StringRedisSerializer();
        GenericJackson2JsonRedisSerializer valueSerializer =
                new GenericJackson2JsonRedisSerializer(mapper);

        template.setKeySerializer(keySerializer);
        template.setHashKeySerializer(keySerializer);
        template.setValueSerializer(valueSerializer);
        template.setHashValueSerializer(valueSerializer);
        template.afterPropertiesSet();
        return template;
    }
}

关键点:

问题 说明
默认 JDK 序列化 可读性差、体积大、跨语言不可用,必须替换
activateDefaultTyping 让 JSON 里带 @class,反序列化才能还原具体类型;代价是不能信任外部输入
LaissezFaireSubTypeValidator 放行所有子类型,存在反序列化风险,仅适用于内部可信数据
JavaTimeModule 否则 LocalDateTime 序列化直接抛异常
key 用 String 序列化 否则 redis-cli 里看到的是乱码,排查成本极高

如果缓存内容可能来自外部或不可信来源,应改用 Jackson2JsonRedisSerializer<T> 绑定具体类型,或干脆只用 StringRedisTemplate 存 JSON 字符串,由业务层显式转换。

9.4 业务服务:缓存 + 防击穿

package com.example.redis.service;

import com.example.redis.config.RedisKeys;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import java.time.Duration;
import java.util.concurrent.ThreadLocalRandom;

@Service
public class ProductService {

    private final StringRedisTemplate redis;
    private final ProductRepository repository;

    public ProductService(StringRedisTemplate redis, ProductRepository repository) {
        this.redis = redis;
        this.repository = repository;
    }

    public Product getProduct(long id) {
        String key = RedisKeys.product(id);
        String cached = redis.opsForValue().get(key);
        if (cached != null) {
            return cached.isEmpty() ? null : JsonUtils.fromJson(cached, Product.class);
        }

        String lockKey = RedisKeys.productLock(id);
        Boolean locked = redis.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(10));
        try {
            if (Boolean.TRUE.equals(locked)) {
                Product product = repository.findById(id).orElse(null);
                if (product == null) {
                    redis.opsForValue().set(key, "", randomTtl(60, 120));
                } else {
                    redis.opsForValue().set(key, JsonUtils.toJson(product), randomTtl(1800, 2400));
                }
                return product;
            }

            sleepQuietly(50);
            String retry = redis.opsForValue().get(key);
            if (retry != null) {
                return retry.isEmpty() ? null : JsonUtils.fromJson(retry, Product.class);
            }
            return repository.findById(id).orElse(null);
        } finally {
            if (Boolean.TRUE.equals(locked)) {
                redis.delete(lockKey);
            }
        }
    }

    private Duration randomTtl(int minSeconds, int maxSeconds) {
        int seconds = ThreadLocalRandom.current().nextInt(minSeconds, maxSeconds);
        return Duration.ofSeconds(seconds);
    }

    private void sleepQuietly(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

三个设计意图:

  1. 空值缓存用空字符串而不是 null:opsForValue().get 对不存在的 key 返回 null,缓存空对象必须用可区分的非空值。
  2. TTL 加随机抖动:避免同一批次 key 同时失效造成雪崩。
  3. 互斥锁失败后短暂等待再重试读缓存:把并发重建降为一次,其余请求要么命中新缓存,要么直接回源一次。

9.5 分布式锁的正确写法(StringRedisTemplate + Lua)

package com.example.redis.lock;

import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.stereotype.Component;

import java.time.Duration;
import java.util.List;
import java.util.UUID;

@Component
public class RedisLock {

    private static final RedisScript<Long> UNLOCK_SCRIPT =
            new DefaultRedisScript<>(
                    "if redis.call('GET', KEYS[1]) == ARGV[1] then " +
                    "return redis.call('DEL', KEYS[1]) else return 0 end",
                    Long.class);

    private final StringRedisTemplate redis;

    public RedisLock(StringRedisTemplate redis) {
        this.redis = redis;
    }

    public String tryLock(String key, Duration ttl) {
        String token = UUID.randomUUID().toString();
        Boolean ok = redis.opsForValue().setIfAbsent(key, token, ttl);
        return Boolean.TRUE.equals(ok) ? token : null;
    }

    public boolean unlock(String key, String token) {
        if (token == null) {
            return false;
        }
        Long result = redis.execute(UNLOCK_SCRIPT, List.of(key), token);
        return result != null && result == 1L;
    }
}

使用 StringRedisTemplate 而不是自定义 RedisTemplate 的原因很实际:Lua 脚本的 KEYS / ARGV 会被模板的序列化器处理。若 value 序列化器是 JSON,ARGV[1] 会变成带引号的 "uuid",而 GET 返回的原始值不一定带引号,比较必然失败,锁永远删不掉。这是一个非常典型的线上事故。

9.6 注解式缓存

package com.example.redis.service;

import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

@Service
public class UserQueryService {

    private final UserRepository repository;

    public UserQueryService(UserRepository repository) {
        this.repository = repository;
    }

    @Cacheable(cacheNames = "user", key = "#id", unless = "#result == null")
    public User findById(long id) {
        return repository.findById(id).orElse(null);
    }

    @CacheEvict(cacheNames = "user", key = "#user.id")
    public void update(User user) {
        repository.save(user);
    }
}

启用注解需要 @EnableCaching,并注册 CacheManager:

package com.example.redis.config;

import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;

import java.time.Duration;

@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(30))
                .prefixCacheNameWith("cache:")
                .disableCachingNullValues()
                .serializeKeysWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(new GenericJackson2JsonRedisSerializer()));

        return RedisCacheManager.builder(factory)
                .cacheDefaults(config)
                .build();
    }
}

@Cacheable 有几个必须知道的行为边界:

行为 说明
同类内部调用不生效 代理模式,this.findById() 绕过代理,缓存注解失效
异常不缓存 方法抛异常时不写入缓存
unless 条件 用于排除 null 等值,但要与 disableCachingNullValues 配合
默认 key 生成 无参方法用 SimpleKey.EMPTY,多参用参数组合,建议显式写 key

十、常见坑与排查手册

10.1 生产事故高发清单

坑 后果 正确做法
线上执行 KEYS * 全实例阻塞,P99 直接爆表 用 SCAN 游标遍历,且限速
大 key 直接 DEL 删除大集合阻塞主线程数秒 用 UNLINK 异步删除,或分批 HDEL/SREM
未设 maxmemory 内存打满被 OOM Killer 杀掉 必设上限 + 淘汰策略 + 内存告警
未设 maxmemory-policy 默认 noeviction,写满后所有写请求报错 缓存实例显式设为 allkeys-lru 或 allkeys-lfu
volatile-* 策略但 key 没 TTL 候选集为空,退化为拒写 使用前校验 TTL 覆盖率
AOF 备份仍拷单文件 7.0+ 恢复丢数据 备份整个 appendonlydir
单实例 repl-backlog-size 太小 网络抖动触发全量同步 高写入实例调到 64MB+
Cluster 下跨 slot 批量操作 CROSSSLOT 报错 hash tag 或拆请求
Lua 脚本里遍历大集合 阻塞时间不可控 脚本内操作元素数设上限
用 RedisTemplate 执行锁脚本 序列化不一致,锁删不掉 锁统一用 StringRedisTemplate
连接池不引入 commons-pool2 池配置静默失效,连接数失控 显式加依赖并核对 pool 生效

10.2 排查命令对照表

现象 首先看什么 命令
延迟抖动 慢查询、延迟事件 SLOWLOG GET 10、LATENCY LATEST
内存异常 内存分布、碎片率、大 key INFO memory、MEMORY DOCTOR、--bigkeys
淘汰频繁 淘汰计数、内存水位 INFO stats 中的 evicted_keys
命中率下降 命中/未命中计数 INFO stats 中的 keyspace_hits / keyspace_misses
主从延迟 复制偏移与 lag INFO replication
集群异常 slot 归属、节点状态 CLUSTER INFO、CLUSTER NODES
连接数暴涨 客户端列表 CLIENT LIST、CLIENT KILL
key 编码异常 对象编码与内存占用 OBJECT ENCODING、MEMORY USAGE

常用扫描方式(替代 KEYS):

redis-cli --scan --pattern 'user:*' --count 1000
redis-cli --bigkeys
redis-cli --memkeys
redis-cli --hotkeys          # 需要 maxmemory-policy 为 lfu 系列

10.3 监控指标的最小集合

类别 指标 告警建议
内存 used_memory、used_memory_rss、mem_fragmentation_ratio 使用率 > 80% 告警
淘汰 evicted_keys 增速 持续 > 0 即需关注
命中率 keyspace_hits / (hits + misses) 低于基线 10% 告警
延迟 latency_percentiles_usec 的 p99 超过 SLA 告警
连接 connected_clients、blocked_clients 接近 maxclients 告警
持久化 rdb_last_bgsave_status、aof_last_write_status 非 ok 立即告警
复制 master_link_status、master_repl_offset 差值 断连或 lag 超阈值告警

十一、实践准则清单

把全文压缩成可以直接贴进团队规范的条目:

  1. 每个缓存实例必须设 maxmemory 与 maxmemory-policy,两者缺一不可。
  2. 禁止在生产使用 KEYS,统一走 SCAN;批量删除用 UNLINK。
  3. 单 value 控制在 10KB 以内,集合元素控制在 5000 以内;超出即拆 key。
  4. 相关性强的 key 用同一个 hash tag,兼顾 Cluster 多 key 操作与批量读取。
  5. TTL 必须带随机抖动,避免整批同时失效。
  6. 持久化优先混合模式(aof-use-rdb-preamble yes + appendfsync everysec)。
  7. 主从必须配 min-replicas-to-write,防止脑裂写入。
  8. 锁的 value 必带唯一 token,释放必须用 Lua 原子比较删除,且统一用 StringRedisTemplate。
  9. 序列化只用 String 与 JSON,禁用 JDK 序列化;不可信数据不要开 default typing。
  10. 所有 Redis 操作必须有超时,客户端超时要小于上游服务的超时,避免线程池被拖垮。
  11. 缓存只是优化,正确性由 DB 唯一约束与幂等逻辑兜底。
  12. 上线前跑一遍 --bigkeys 与 SLOWLOG,把问题留在压测阶段。

十二、可选的后续方向

这篇文章覆盖了机制与工程实践,但每个方向都还能再往下挖一层,按需可以继续:

方向 内容
拆成系列 数据结构篇 / 持久化篇 / 集群篇 / Spring 集成篇,各自独立成文
换版本基线 Redis 8.0 的 Vector Set 与 Search 单独成篇(RAG 召回实战)
补架构图 用 Mermaid 画主从复制、Cluster slot 迁移、Cache Aside 时序图
压测数据 用 redis-benchmark + 自建压测脚本给出不同 value 大小下的 QPS/延迟曲线
运维视角 补齐 Prometheus + Grafana 的 Redis 大盘与告警规则文件
客户端对比 Lettuce 与 Jedis 在连接模型、线程安全、Cluster 拓扑刷新上的差异实测

如果后续要把这套内容落到具体业务,建议的推进顺序是:先定数据结构与 key 规范 → 再定淘汰与持久化策略 → 最后写缓存与锁的代码。顺序反了,代码写得再漂亮也会被配置问题打回去。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。