Redis 生产环境实战:数据结构选型、持久化与缓存三大问题

Redis 大多数人都”会用”,但线上事故往往出在选型和边界情况上:一个 KEYS * 拖垮整个实例,一次缓存雪崩打穿数据库。本文不讲安装,只讲生产环境真正会遇到的问题。

一、数据结构怎么选

选错数据结构的代价,往往是内存翻几倍、或者用一堆笨拙的组合去模拟本该原生支持的能力。

1.1 五种基础类型对比

类型 底层实现 典型场景 单键建议上限
String SDS 动态字符串 缓存、计数器、分布式锁、Session 值不超过 10 KB
Hash 压缩列表 / 哈希表 对象存储(购物车、用户资料) field 不超过 1000
List 快速链表 消息队列、最新列表、粉丝列表 元素不超过 1 万
Set 哈希表 / 整数集合 去重、标签、共同好友、抽奖 元素不超过 1 万
ZSet 跳表 + 哈希表 排行榜、延迟队列、优先级队列 元素不超过 1 万

1.2 容易被忽略的选型细节

String 存对象 vs Hash 存对象

很多人图省事直接把对象序列化成 JSON 塞进 String:

1
SET user:1001 '{"name":"张三","age":28,"city":"杭州"}'

问题是改一个字段要把整个对象读出来、反序列化、改完再写回去。换成 Hash 就能按字段操作:

1
2
3
HSET user:1001 name 张三 age 28 city 杭州
HINCRBY user:1001 age 1 # 单独自增,无需读全量
HGET user:1001 name

内存角度 Hash 也更省。当 field 数量少且值较短时,Redis 会用 ziplist(压缩列表) 紧凑存储,比多个独立 String 的 key 开销小得多。但注意一旦 field 数量超过 hash-max-ziplist-entries(默认 512)或单个值超过 hash-max-ziplist-value(默认 64 字节),就会转成哈希表,省内存的优势消失。

ZSet 不只是排行榜

跳表结构让 ZSet 支持按 score 范围查询,天然适合做延迟队列

1
2
3
4
5
6
# 下单后 30 分钟未支付自动关闭,用时间戳做 score
ZADD order:delay 1756646400 "ORDER_20260831_001"

# 定时任务轮询已到期的订单
ZRANGEBYSCORE order:delay 0 1756646460 LIMIT 0 100
ZREM order:delay "ORDER_20260831_001"

1.3 高级类型:能省下大量内存

类型 用途 内存效果
Bitmap 每日签到、活跃用户标记 1 亿用户每日签到仅约 12.5 MB
HyperLogLog UV 统计 每个 key 固定约 12 KB,误差 0.81%
Geo 附近的人、门店距离 基于 ZSet 实现
Stream 消息队列(Redis 5.0+) 支持消费组、ACK、持久化

HyperLogLog 的内存优势非常夸张:统计 1 亿个独立用户,用 Set 存所有 userId 大约要几个 GB,用 HyperLogLog 只要 12 KB —— 代价是 0.81% 的误差,对 UV 这类统计完全够用。

1
2
3
PFADD uv:20260831 user1 user2 user3
PFCOUNT uv:20260831 # 返回去重后的近似值
PFMERGE uv:202608 uv:20260830 uv:20260831

二、缓存三大问题

这是面试必考,也是线上真会出事的三个坑。

2.1 缓存穿透:查一个根本不存在的数据

现象:请求的数据数据库里也没有,缓存自然不会命中,每次请求都直接打到数据库。如果被恶意刷不存在的 id,数据库会被打挂。

解法一:缓存空值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public User getUser(Long id) {
String key = "user:" + id;
String cached = redisTemplate.opsForValue().get(key);

if (cached != null) {
// 空值标记,避免重复查库
if ("__NULL__".equals(cached)) return null;
return JSON.parseObject(cached, User.class);
}

User user = userMapper.selectById(id);
if (user == null) {
// 数据库也没有,缓存空值,TTL 设短一点
redisTemplate.opsForValue().set(key, "__NULL__", 2, TimeUnit.MINUTES);
return null;
}
redisTemplate.opsForValue().set(key, JSON.toJSONString(user), 30, TimeUnit.MINUTES);
return user;
}

解法二:布隆过滤器

在数据写入时就同步到布隆过滤器,查询前先过一遍。布隆过滤器说”不存在”,那一定不存在,直接返回。

1
2
3
4
// 初始化时把所有有效 id 载入布隆过滤器
if (!bloomFilter.mightContain(id)) {
return null; // 一定不存在,不用查缓存和数据库
}
方案 优点 缺点
缓存空值 实现简单,无额外组件 占内存,短期数据不一致
布隆过滤器 内存极省,判断快 有误判率,删除困难,需预热

2.2 缓存击穿:一个热点 key 刚好过期

现象:某个被疯狂访问的热点 key(比如首页头图、秒杀商品)在失效的瞬间,成千上万的请求同时穿透到数据库。和穿透不同,击穿针对的是同一个 key

解法一:互斥锁重建

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public Product getHotProduct(Long id) {
String key = "product:" + id;
Product p = getFromCache(key);
if (p != null) return p;

String lockKey = "lock:product:" + id;
boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);

if (locked) {
try {
// 双重检查,防止等待期间已被其他线程写入
p = getFromCache(key);
if (p != null) return p;

p = productMapper.selectById(id);
setCache(key, p, 30, TimeUnit.MINUTES);
return p;
} finally {
redisTemplate.delete(lockKey);
}
} else {
// 没抢到锁,短暂休眠后重试读缓存
Thread.sleep(50);
return getHotProduct(id);
}
}

解法二:逻辑过期(推荐)

不给 key 设置真实 TTL,而是把过期时间放进 value 里。线程 A 发现逻辑时间已过就加锁去重建,其他线程照常返回旧数据,不用等待。

1
2
3
4
5
@Data
class RedisData {
private LocalDateTime expireTime; // 逻辑过期时间
private Object data;
}

逻辑过期方案的性能远好于互斥锁 —— 因为没有任何请求需要阻塞等待,代价是可能短暂返回旧数据。对热点商品、首页配置这类能容忍秒级延迟的场景,这是最优解。

2.3 缓存雪崩:大批 key 同时失效

现象:大量 key 在同一时刻集体过期,或者 Redis 实例直接宕机,所有请求瞬间涌向数据库,数据库顶不住就连锁崩溃。

解法一:TTL 加随机值

1
2
3
4
// 基础 30 分钟,随机浮动 5 分钟,打散过期时间
int baseTtl = 30 * 60;
int random = new Random().nextInt(5 * 60);
redisTemplate.opsForValue().set(key, value, baseTtl + random, TimeUnit.SECONDS);

解法二:多级缓存

本地缓存(Caffeine)+ Redis + 数据库,Redis 挂了还有本地缓存兜底。

解法三:高可用架构

  • 主从 + 哨兵,主节点故障自动切换
  • Redis Cluster 分片,避免单点
  • 服务降级与熔断,数据库压力过大时直接返回兜底数据

2.4 缓存与数据库一致性

经典问题是:更新数据库后,是更新缓存还是删除缓存

结论:删除缓存,而不是更新缓存。因为更新缓存存在并发写乱序问题 —— 线程 A 先更新库、线程 B 后更新库,但缓存写入顺序可能反过来,导致缓存里是旧数据。删除缓存则没有这个问题,下次读取自然会从数据库加载最新值。

推荐方案是 延迟双删

1
2
3
4
5
6
7
8
9
10
@Transactional
public void updateProduct(Product product) {
// 1. 先删缓存
redisTemplate.delete("product:" + product.getId());
// 2. 更新数据库
productMapper.updateById(product);
// 3. 延迟一小段时间再删一次,清掉期间可能被写入的脏数据
scheduledExecutor.schedule(() ->
redisTemplate.delete("product:" + product.getId()), 500, TimeUnit.MILLISECONDS);
}

追求强一致的话,可以用 Canal 订阅 MySQL binlog,异步删除缓存,把缓存维护逻辑从业务代码里彻底剥离出去。

三、持久化:RDB 与 AOF 怎么选

3.1 RDB(快照)

在指定时间间隔内,把内存数据生成二进制快照文件 dump.rdb

1
2
3
4
5
6
7
# 900 秒内至少 1 次修改
save 900 1
save 300 10
save 60 10000

dbfilename dump.rdb
dir /var/lib/redis

触发方式

  • 自动:满足 save 条件
  • SAVE —— 主线程执行,会阻塞所有请求,生产环境禁用
  • BGSAVE —— fork 子进程执行,主进程继续服务,推荐
优点 缺点
文件紧凑,适合备份与异地容灾 会丢失最后一次快照之后的数据
恢复速度快,远超 AOF fork 时内存翻倍,数据量大时耗时明显
最大化 Redis 性能 频繁 fork 会影响响应

3.2 AOF(追加日志)

把每条写命令追加到日志文件末尾,重启时重新执行一遍来恢复数据。

1
2
3
4
5
appendonly yes
appendfilename "appendonly.aof"

# 刷盘策略
appendfsync everysec

三种刷盘策略必须搞清楚:

策略 行为 数据安全性 性能
always 每条命令都刷盘 几乎不丢数据 最慢
everysec 每秒刷盘一次 最多丢 1 秒数据 推荐,默认
no 交给操作系统决定 可能丢较多数据 最快

AOF 重写:文件会越来越大,Redis 会自动触发重写,把多条命令合并成最终状态的等价命令。

1
2
auto-aof-rewrite-percentage 100   # 比上次重写后增长 100%
auto-aof-rewrite-min-size 64mb # 且文件至少 64MB

3.3 混合持久化(推荐)

Redis 4.0 引入,AOF 文件由「RDB 格式的前半段 + AOF 格式的后半段」组成 —— 既保留 RDB 的快速恢复,又保证 AOF 的数据完整性。

1
aof-use-rdb-preamble yes

3.4 选型建议

场景 建议
纯缓存,丢了能从库里重建 RDB 就够,甚至可以不持久化
数据重要,允许丢几秒 混合持久化(aof-use-rdb-preamble yes
数据绝对不能丢 AOF + appendfsync always,并搭配主从
只做缓存且追求极致性能 关闭持久化

四、过期策略与内存淘汰

4.1 过期 key 怎么被删除

Redis 同时使用两种策略来平衡 CPU 和内存:

  • 惰性删除:访问 key 时才检查是否过期,过期就删。对 CPU 友好,但过期 key 一直没人访问就一直占内存。
  • 定期删除:默认每 100ms 随机抽取一批设置了过期时间的 key 检查并删除,通过限制执行时长来避免阻塞。

4.2 八种淘汰策略

内存达到 maxmemory 上限时触发。

策略 范围 算法 适用场景
noeviction 不淘汰,写请求报错 默认,不推荐
allkeys-lru 所有 key LRU 纯缓存,最常用
volatile-lru 设了 TTL 的 key LRU 想保留持久数据
allkeys-lfu 所有 key LFU 有长期热点数据
volatile-lfu 设了 TTL 的 key LFU Redis 4.0+
allkeys-random 所有 key 随机 访问分布均匀
volatile-random 设了 TTL 的 key 随机
volatile-ttl 设了 TTL 的 key 剩余 TTL 短的优先 希望优先淘汰快过期的

LRU 和 LFU 怎么选?LRU 看的是”最近什么时候访问过”,LFU 看的是”访问频次”。如果有一批老数据是历史热点但现在不怎么访问了,LRU 会淘汰它们,LFU 则会因为累计频次高而一直保留 —— 这种”旧热点赖着不走”的场景,用 LRU 更合适。

五、生产环境实操

5.1 redis.conf 关键参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 内存上限,务必设置,建议物理内存的 60%~70%
maxmemory 4gb
maxmemory-policy allkeys-lru

# 网络
tcp-backlog 511
timeout 0 # 0 表示不断开空闲连接
tcp-keepalive 300

# 安全
requirepass your-strong-password
# 危险命令重命名,防误操作
rename-command FLUSHALL ""
rename-command KEYS ""
rename-command CONFIG "b840fc02d5240454299"

# 慢查询
slowlog-log-slower-than 10000 # 超过 10ms 记录
slowlog-max-len 128

5.2 生产禁用命令

命令 风险 替代方案
KEYS * 单线程遍历全量 key,数据量大时直接卡死 SCAN cursor MATCH pattern COUNT 100
FLUSHALL / FLUSHDB 清空全部数据 重命名禁用
DEBUG SEGFAULT 让实例崩溃 禁用
CONFIG SET 运行时改配置,风险不可控 重命名
大 value 的 DEL 删除大 key 会阻塞 UNLINK(异步删除)
1
2
3
4
# 安全的遍历方式,不会阻塞
SCAN 0 MATCH user:* COUNT 100
# 异步删除,不阻塞主线程
UNLINK big:key

5.3 慢查询排查

1
2
3
4
5
6
# 查看慢查询日志
SLOWLOG GET 10
# 慢日志条数
SLOWLOG LEN
# 清空
SLOWLOG RESET

返回格式说明:

1
2
3
4
5
6
7
1) (integer) 12                  # 日志 id
2) (integer) 1756646400 # 时间戳
3) (integer) 15230 # 执行耗时(微秒)
4) 1) "KEYS" # 命令与参数
2) "user:*"
5) "127.0.0.1:54321"
6) ""

5.4 大 key 治理

big key 的危害:删除阻塞、网络拥塞、集群数据倾斜、迁移失败。

1
2
3
4
5
# 找出大 key(离线分析用,比 MEMORY USAGE 遍历更安全)
redis-cli --bigkeys

# 查看单个 key 的内存占用
MEMORY USAGE user:1001

拆分方案

1
2
3
4
# 一个 10 万字段的 Hash,拆成 100 个
big:hash → big:hash:0 big:hash:1 ... big:hash:99
# 通过 hash 取模路由
slot = crc32(field) % 100

六、Spring Boot 整合要点

6.1 依赖与配置

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
spring:
data:
redis:
host: 127.0.0.1
port: 6379
password: your-password
database: 0
timeout: 3s
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 2
max-wait: 3s

6.2 必须自定义序列化器

默认的 JdkSerializationRedisSerializer 存进去是二进制乱码,既占空间又不可读,换成 JSON:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@Configuration
public class RedisConfig {

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

Jackson2JsonRedisSerializer<Object> jsonSerializer =
new Jackson2JsonRedisSerializer<>(Object.class);

ObjectMapper om = new ObjectMapper();
om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
om.activateDefaultTyping(
LaissezFaireSubTypeValidator.instance,
ObjectMapper.DefaultTyping.NON_FINAL);
jsonSerializer.setObjectMapper(om);

StringRedisSerializer stringSerializer = new StringRedisSerializer();

template.setKeySerializer(stringSerializer);
template.setHashKeySerializer(stringSerializer);
template.setValueSerializer(jsonSerializer);
template.setHashValueSerializer(jsonSerializer);

template.afterPropertiesSet();
return template;
}
}

这样用 redis-cli 直接看缓存内容也是清晰的 JSON,排查问题方便很多。

6.3 缓存注解使用注意

1
2
3
4
5
6
7
8
@Cacheable(value = "user", key = "#id", unless = "#result == null")
public User getUser(Long id) { ... }

@CacheEvict(value = "user", key = "#user.id")
public void updateUser(User user) { ... }

@CacheEvict(value = "user", key = "#id")
public void deleteUser(Long id) { ... }

@Cacheable 的三个坑:一、同一个类里方法 A 调方法 B 的 @Cacheable 不生效,因为没走代理;二、一定要加 unless = "#result == null",否则会把 null 缓存进去,后续一直返回 null;三、@Transactional@Cacheable 混用时,可能出现缓存在事务提交前就写入、随后事务回滚的情况,留下脏缓存。这种场景建议把缓存操作放到事务提交之后,用 TransactionSynchronizationManager.registerSynchronization()afterCommit 回调里处理。

总结:Redis 用得好不好,差别不在命令熟不熟,而在三个地方 —— 选型是否用对了结构、边界情况是否兜住了(穿透击穿雪崩)、生产参数是否配到位了。记住禁用 KEYS、设置 maxmemory、配好淘汰策略这三件事,就能躲开八成的线上事故。