Redis 大多数人都"会用",但线上事故往往出在选型和边界情况上:一个 拖垮整个实例,一次缓存雪崩打穿数据库。本文不讲安装,只讲生产环境真正会遇到的问题。
一、数据结构怎么选
选错数据结构的代价,往往是内存翻几倍、或者用一堆笨拙的组合去模拟本该原生支持的能力。
1.1 五种基础类型对比
| 类型 | 底层实现 | 典型场景 | 单键建议上限 |
|---|---|---|---|
| String | SDS 动态字符串 | 缓存、计数器、分布式锁、Session | 值不超过 10 KB |
| Hash | 压缩列表 / 哈希表 | 对象存储(购物车、用户资料) | field 不超过 1000 |
| List | 快速链表 | 消息队列、最新列表、粉丝列表 | 元素不超过 1 万 |
| Set | 哈希表 / 整数集合 | 去重、标签、共同好友、抽奖 | 元素不超过 1 万 |
| ZSet | 跳表 + 哈希表 | 排行榜、延迟队列、优先级队列 | 元素不超过 1 万 |
1.2 容易被忽略的选型细节
String 存对象 vs Hash 存对象
很多人图省事直接把对象序列化成 JSON 塞进 String:
SET user:1001 '{"name":"张三","age":28,"city":"杭州"}'
问题是改一个字段要把整个对象读出来、反序列化、改完再写回去。换成 Hash 就能按字段操作:
HSET user:1001 name 张三 age 28 city 杭州
HINCRBY user:1001 age 1 # 单独自增,无需读全量
HGET user:1001 name
内存角度 Hash 也更省。当 field 数量少且值较短时,Redis 会用 ziplist(压缩列表) 紧凑存储,比多个独立 String 的 key 开销小得多。但注意一旦 field 数量超过 (默认 512)或单个值超过 (默认 64 字节),就会转成哈希表,省内存的优势消失。
ZSet 不只是排行榜
跳表结构让 ZSet 支持按 score 范围查询,天然适合做延迟队列:
# 下单后 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 这类统计完全够用。
PFADD uv:20260831 user1 user2 user3
PFCOUNT uv:20260831 # 返回去重后的近似值
PFMERGE uv:202608 uv:20260830 uv:20260831
二、缓存三大问题
这是面试必考,也是线上真会出事的三个坑。
2.1 缓存穿透:查一个根本不存在的数据
现象:请求的数据数据库里也没有,缓存自然不会命中,每次请求都直接打到数据库。如果被恶意刷不存在的 id,数据库会被打挂。
解法一:缓存空值
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;
}
解法二:布隆过滤器
在数据写入时就同步到布隆过滤器,查询前先过一遍。布隆过滤器说"不存在",那一定不存在,直接返回。
// 初始化时把所有有效 id 载入布隆过滤器
if (!bloomFilter.mightContain(id)) {
return null; // 一定不存在,不用查缓存和数据库
}
| 方案 | 优点 | 缺点 |
|---|---|---|
| 缓存空值 | 实现简单,无额外组件 | 占内存,短期数据不一致 |
| 布隆过滤器 | 内存极省,判断快 | 有误判率,删除困难,需预热 |
2.2 缓存击穿:一个热点 key 刚好过期
现象:某个被疯狂访问的热点 key(比如首页头图、秒杀商品)在失效的瞬间,成千上万的请求同时穿透到数据库。和穿透不同,击穿针对的是同一个 key。
解法一:互斥锁重建
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 发现逻辑时间已过就加锁去重建,其他线程照常返回旧数据,不用等待。
@Data
class RedisData {
private LocalDateTime expireTime; // 逻辑过期时间
private Object data;
}
逻辑过期方案的性能远好于互斥锁 —— 因为没有任何请求需要阻塞等待,代价是可能短暂返回旧数据。对热点商品、首页配置这类能容忍秒级延迟的场景,这是最优解。
2.3 缓存雪崩:大批 key 同时失效
现象:大量 key 在同一时刻集体过期,或者 Redis 实例直接宕机,所有请求瞬间涌向数据库,数据库顶不住就连锁崩溃。
解法一:TTL 加随机值
// 基础 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 后更新库,但缓存写入顺序可能反过来,导致缓存里是旧数据。删除缓存则没有这个问题,下次读取自然会从数据库加载最新值。
推荐方案是 延迟双删:
@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。
# 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(追加日志)
把每条写命令追加到日志文件末尾,重启时重新执行一遍来恢复数据。
appendonly yes
appendfilename "appendonly.aof"
# 刷盘策略
appendfsync everysec
三种刷盘策略必须搞清楚:
| 策略 | 行为 | 数据安全性 | 性能 |
|---|---|---|---|
always |
每条命令都刷盘 | 几乎不丢数据 | 最慢 |
everysec |
每秒刷盘一次 | 最多丢 1 秒数据 | 推荐,默认 |
no |
交给操作系统决定 | 可能丢较多数据 | 最快 |
AOF 重写:文件会越来越大,Redis 会自动触发重写,把多条命令合并成最终状态的等价命令。
auto-aof-rewrite-percentage 100 # 比上次重写后增长 100%
auto-aof-rewrite-min-size 64mb # 且文件至少 64MB
3.3 混合持久化(推荐)
Redis 4.0 引入,AOF 文件由「RDB 格式的前半段 + AOF 格式的后半段」组成 —— 既保留 RDB 的快速恢复,又保证 AOF 的数据完整性。
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 关键参数
# 内存上限,务必设置,建议物理内存的 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(异步删除) |
# 安全的遍历方式,不会阻塞
SCAN 0 MATCH user:* COUNT 100
# 异步删除,不阻塞主线程
UNLINK big:key
5.3 慢查询排查
# 查看慢查询日志
SLOWLOG GET 10
# 慢日志条数
SLOWLOG LEN
# 清空
SLOWLOG RESET
返回格式说明:
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 的危害:删除阻塞、网络拥塞、集群数据倾斜、迁移失败。
# 找出大 key(离线分析用,比 MEMORY USAGE 遍历更安全)
redis-cli --bigkeys
# 查看单个 key 的内存占用
MEMORY USAGE user:1001
拆分方案:
# 一个 10 万字段的 Hash,拆成 100 个
big:hash → big:hash:0 big:hash:1 ... big:hash:99
# 通过 hash 取模路由
slot = crc32(field) % 100
六、Spring Boot 整合要点
6.1 依赖与配置
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
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:
@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 缓存注解使用注意
@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) { ... }
的三个坑:一、同一个类里方法 A 调方法 B 的 不生效,因为没走代理;二、一定要加 ,否则会把 null 缓存进去,后续一直返回 null;三、 和 混用时,可能出现缓存在事务提交前就写入、随后事务回滚的情况,留下脏缓存。这种场景建议把缓存操作放到事务提交之后,用 在 回调里处理。
总结:Redis 用得好不好,差别不在命令熟不熟,而在三个地方 —— 选型是否用对了结构、边界情况是否兜住了(穿透击穿雪崩)、生产参数是否配到位了。记住禁用 、设置 、配好淘汰策略这三件事,就能躲开八成的线上事故。