Redis 学习指南:从「会用 set/get」到「能讲清楚 why」
面向人群:Java 后端初学者 / 准备校招实习的同学
目标:看完这篇,你能独立在 Spring Boot 项目里用好 Redis,并且面对面试官关于缓存、分布式锁、持久化的追问不慌。
一、先搞清楚:为什么后端一定要学 Redis
大部分同学学 Redis 的起点是「缓存」,把它当成 MySQL 前面的一层加速层。这个理解对,但不完整。
Redis 在后端系统里实际承担的角色有这些:
| 角色 | 典型场景 | 用到的能力 |
|---|---|---|
| 缓存 | 热点数据、查询结果、Session | String / Hash + TTL |
| 分布式锁 | 防止并发重复下单、定时任务单实例执行 | SET NX PX + Lua |
| 计数器 / 限流 | 点赞数、接口 QPS 限流、短信发送频率控制 | INCR + 过期时间 / ZSet 滑动窗口 |
| 排行榜 | 积分榜、热搜榜 | ZSet |
| 消息队列 | 简单异步解耦、延迟任务 | List / Stream / ZSet |
| 去重 | 海量数据判重、UV 统计 | Set / HyperLogLog / Bloom |
| 社交关系 | 关注、粉丝、共同好友 | Set 的交并差 |
| 地理信息 | 附近的人、附近门店 | Geo |
一句话概括:Redis 解决的是「单机内存装不下、MySQL 扛不住读写、JVM 内数据跨不了进程」这三件事。
和 HashMap、Guava Cache、Caffeine 的本质区别:
HashMap/Caffeine:进程内缓存,只在当前 JVM 里有效。多实例部署时,每台机器缓存一份,数据不一致、利用率低、还有重复加载的问题。- Redis:独立进程,所有应用实例共享同一份数据,天生支持跨进程、跨机器的一致视图,代价是多一次网络 IO(本机约 0.1~0.3ms,跨机房 1~3ms)。
所以判断要不要上 Redis 的标准很简单:这份数据需要被多个服务实例共享,或者需要在应用重启后保留,那就放 Redis;否则本地缓存就够了。
二、学习路线:别从头到尾啃文档
Redis 官方文档很全,但从头读到尾效率极低。按这条路径走更快:
阶段一(1~2 天):跑起来 + 五种基础类型
Docker 起 Redis → redis-cli 敲命令 → Spring Boot 集成 RedisTemplate
阶段二(3~5 天):做成一个真实项目模块
缓存商品详情 → 处理穿透/击穿/雪崩 → 加分布式锁
阶段三(1 周):理解「为什么」
单线程为什么快 → 持久化怎么选 → 主从怎么同步 → 淘汰策略
阶段四(持续):边界场景
分布式锁的正确姿势、延迟队列、限流、Redisson
建议边学边记命令。 Redis 命令不常用就忘,但常用的就那 30 来个,练熟之后写业务的效率差别很大。
三、快速起步:Docker 起一个 Redis
bash
# 拉取镜像(7.x 版本)
docker pull redis:7.2
# 启动容器,持久化目录挂载出来
docker run -d --name redis-dev \
-p 6379:6379 \
-v /opt/redis/data:/data \
-v /opt/redis/conf/redis.conf:/usr/local/etc/redis/redis.conf \
redis:7.2 redis-server /usr/local/etc/redis/redis.conf \
--appendonly yes --requirepass 123456
# 进入命令行
docker exec -it redis-dev redis-cli -a 123456
几个必练的基础命令:
bash
SET name xieshuai EX 60 # 设值 + 60 秒过期,原子操作
GET name
TTL name # 剩余秒数,-1 永不过期,-2 已不存在
DEL name
EXISTS name
EXPIRE name 120 # 给已存在的 key 设置过期
KEYS user:* # 生产环境禁用!O(n) 会阻塞主线程
SCAN 0 MATCH user:* COUNT 100 # 正确的遍历方式,游标分批返回
TYPE name
DBSIZE
⚠️
KEYS、FLUSHALL、FLUSHDB这三个命令在生产环境应当通过rename-command禁用掉。 它们会长时间阻塞单线程,线上一次误调用就是一次 P0 事故。
四、数据类型:别只会 String
Redis 的每一种类型背后都有具体的内存编码实现。选对了类型,内存占用能差一个数量级。
4.1 String ------ 最万能,也最容易被滥用
底层是 SDS(简单动态字符串),可以存字符串、整数、浮点数、甚至二进制数据。
bash
SET article:1 "{json}" # 序列化后的对象 JSON
INCR article:1:view # 计数器,原子自增
INCRBY article:1:view 10
SETNX lock:order:1 uuid # 抢锁(后面详细讲)
MSET k1 v1 k2 v2 # 批量写,减少网络往返
MGET k1 k2
String 的两种典型用法:
- 序列化对象(JSON / Protobuf):读写方便,但改一个字段要整体反序列化再写回。
- 分布式计数 / 限流 :
INCR天然原子。
坑点: 很多同学一上来就把对象 JSON 塞进 String,这在对象字段多、更新频繁的场景下是浪费。比如用户信息有 20 个字段,只想改 nickname,用 Hash 更好。
4.2 Hash ------ 对象存储的正确姿势
bash
HSET user:1 name xieshuai age 24 city baoding
HGET user:1 name
HGETALL user:1 # 字段多时慎用,用 HMGET 指定字段
HMGET user:1 name age
HINCRBY user:1 age 1
HDEL user:1 city
HLEN user:1
为什么 Hash 比 String 存 JSON 省内存?
Redis 在 Hash 字段较少且值较短时会使用 ziplist(Redis 7 起是 listpack) 紧凑编码,所有字段挤在一块连续内存里,省掉了大量指针开销。同样存 100 万个用户对象,Hash + ziplist 可能只有 String + JSON 的三分之一到一半内存。
触发 listpack 编码的条件(Redis 7 默认):
conf
hash-max-listpack-entries 512 # 字段数 ≤ 512
hash-max-listpack-value 64 # 每个值长度 ≤ 64 字节
超过阈值会转成 hashtable,内存占用立刻翻倍。所以别无脑往 Hash 里塞大 JSON。
4.3 List ------ 队列和栈
bash
LPUSH queue:email task1 task2
RPOP queue:email # 右出队:FIFO 队列
BLPOP queue:email 30 # 阻塞版,没数据就等 30 秒,避免空轮询
LRANGE queue:email 0 -1 # 遍历(大列表慎用)
LLEN queue:email
List 做消息队列的问题: 没有 ACK 机制,消费者 RPOP 之后进程崩了,这条消息就永久丢了;也不支持多消费者组。轻量场景够用,可靠场景请用 Redis 5.0+ 的 Stream。
bash
XADD mystream * user xieshuai action login # 追加消息
XREAD COUNT 10 BLOCK 5000 STREAMS mystream $ # 阻塞读
XGROUP CREATE mystream mygroup $ MKSTREAM # 创建消费组
XREADGROUP GROUP mygroup c1 COUNT 1 STREAMS mystream > # 组内消费
XACK mystream mygroup 1719999999999-0 # 确认,支持消息重试
Stream 有持久化、消费组、ACK、PEL(待确认列表),是 Redis 里唯一算得上「正规 MQ」的结构。
4.4 Set ------ 去重与社交关系
bash
SADD tags:article:1 java redis mysql
SREM tags:article:1 mysql
SISMEMBER tags:article:1 java # O(1) 判重
SCARD tags:article:1 # 元素个数
SMEMBERS tags:article:1 # 大集合慎用,用 SSCAN
# 社交关系:交集/并集/差集
SADD follow:u1 u2 u3 u4
SADD follow:u2 u3 u4 u5
SINTER follow:u1 follow:u2 # 共同关注:u3 u4
SDIFF follow:u1 follow:u2 # u1 关注但 u2 没关注
SINTER 计算复杂度是 O(N*M),千万级用户的关注列表做交集会把主线程打挂。真实业务里这种关系一般交给图数据库或者离线计算。
4.5 ZSet ------ 排行榜的唯一答案
ZSet = Set(member 唯一)+ score(浮点分数),用跳表(skiplist)+ 哈希表实现,查询和插入都是 O(log N)。
bash
ZADD rank:2026 100 xieshuai 95 zhangsan 88 lisi
ZINCRBY rank:2026 5 xieshuai # 加分,原子操作
ZREVRANGE rank:2026 0 9 WITHSCORES # Top10(分数从高到低)
ZREVRANK rank:2026 xieshuai # 我的排名,从 0 开始
ZSCORE rank:2026 xieshuai
ZREVRANGEBYSCORE rank:2026 +inf 90 WITHSCORES # 分数段查询
ZREMRANGEBYRANK rank:2026 100 -1 # 只保留前 100,防止无限增长
延迟队列的经典实现: 用时间戳当 score,ZRANGEBYSCORE 捞到期的任务。
bash
ZADD delay:queue 1719999999000 "taskId:1001"
# 每秒轮询到期任务(score 在 0 到当前时间戳之间)
ZRANGEBYSCORE delay:queue 0 1719999999999 LIMIT 0 10
ZREM delay:queue "taskId:1001" # 抢到再删,配合 Lua 保证原子
4.6 三种特殊类型:省内存的黑科技
| 类型 | 用途 | 误差 | 内存 |
|---|---|---|---|
| HyperLogLog | UV 去重计数 | 约 0.81%,标准误差 | 每个 key 最多 12KB |
| Bitmap | 二值状态(签到、活跃标记) | 无 | 1 亿用户签到约 12MB |
| Geo | 经纬度、附近的人 | 底层是 ZSet | 取决于数据量 |
bash
# HyperLogLog:统计一亿个 UV,12KB 搞定,误差 0.81%
PFADD uv:20261002 user1 user2 user3
PFCOUNT uv:20261002
PFMERGE uv:202610Week uv:20261002 uv:20261003 # 合并多天
# Bitmap:记录用户 365 天签到
SETBIT sign:user1:2026 0 1 # 第 1 天签到
SETBIT sign:user1:2026 1 1 # 第 2 天签到
BITCOUNT sign:user1:2026 # 今年签到天数
BITFIELD sign:user1:2026 GET u8 0 # 取第 1 个字节
# Geo
GEOADD shops 116.40 39.90 "store:A"
GEORADIUS shops 116.40 39.90 5 km WITHDIST COUNT 10 ASC
面试常问 :HyperLogLog 为什么这么省内存?------ 它不存元素本身,只根据元素的哈希值更新一组「桶」的最大前导零位数,用概率统计推断基数。所以只能计数,不能取值、不能判断某个元素是否存在。需要精确判重的场景请用 Set 或布隆过滤器。
五、Spring Boot 集成:三个必踩的坑
5.1 依赖与配置
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- 连接池,lettuce 默认依赖它 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
yaml
spring:
data:
redis:
host: localhost
port: 6379
password: 123456
database: 0
timeout: 3s
lettuce:
pool:
max-active: 16 # 最大连接数
max-idle: 8
min-idle: 2
max-wait: 2s # 拿不到连接最多等 2 秒,超时报错而不是无限等待
坑 1:SpringBoot 2.x 默认用 Lettuce,不是 Jedis。
Lettuce 基于 Netty,连接是线程安全可复用的,多线程共享一个连接即可;Jedis 是 BIO 模型,必须靠连接池。别搞混,也别手动一连接一线程。
5.2 坑 2:默认的 JDK 序列化器
RedisTemplate<Object, Object> 默认用 JdkSerializationRedisSerializer,存进去的东西在 redis-cli 里长这样:
"\xac\xed\x00\x05sr\x00\x1acom.example.User\xf2..."
三个问题: 不可读、跨语言不兼容、体积膨胀约 3 倍。
必须自定义序列化配置:
java
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
// JSON 序列化,存入对象时带上 @class 字段用于反序列化
GenericJackson2JsonRedisSerializer jsonSerializer =
new GenericJackson2JsonRedisSerializer();
StringRedisSerializer stringSerializer = new StringRedisSerializer();
// key / hashKey 用字符串序列化
template.setKeySerializer(stringSerializer);
template.setHashKeySerializer(stringSerializer);
// value / hashValue 用 JSON
template.setValueSerializer(jsonSerializer);
template.setHashValueSerializer(jsonSerializer);
template.afterPropertiesSet();
return template;
}
}
GenericJackson2JsonRedisSerializer会写入@class全限定类名。如果 Java 类改了包名,老数据就反序列化失败。想要干净的纯 JSON,
value用Jackson2JsonRedisSerializer<User>(User.class),但这样就只能存单一类型。取舍:多类型图方便用 Generic,单类型图干净用 Jackson2 指定 Class。
5.3 坑 3: opsForXxx 别串用
java
redisTemplate.opsForValue().set("k", "v"); // String
redisTemplate.opsForHash().put("h", "f", "v"); // Hash
redisTemplate.opsForList().rightPush("l", "v");// List
redisTemplate.opsForSet().add("s", "v"); // Set
redisTemplate.opsForZSet().add("z", "v", 1.0); // ZSet
用错类型会报 WRONGTYPE Operation against a key holding the wrong kind of value。 这个错误在多人协作、多服务共用同一个库时非常常见,所以 key 的命名前缀一定要有规范,比如 业务:模块:标识,例如 img:feature:123、user:profile:456。
六、缓存三大经典问题
这是面试必考,也是真实业务里一定会遇到的。
6.1 缓存穿透 ------ 查一个根本不存在的数据
现象:请求 id = -1 或者一个随机不存在的 id。Redis 没有,MySQL 也没有,每次都打到数据库。如果被恶意刷量,数据库直接被打崩。
解决方案一:缓存空值
java
private static final String NULL_MARK = "#NULL#";
public User getById(Long id) {
String key = "user:profile:" + id;
Object cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
// 命中。可能是真实对象,也可能是我们塞进去的空值标记
return NULL_MARK.equals(cached) ? null : ((User) cached);
}
User user = userMapper.selectById(id);
if (user == null) {
// 空值也写进缓存,挡住后续穿透请求。TTL 短一点,别让无效 key 长期占位
redisTemplate.opsForValue().set(key, NULL_MARK, Duration.ofMinutes(2));
return null;
}
redisTemplate.opsForValue().set(key, user, Duration.ofMinutes(30));
return user;
}
上面用了
#NULL#这个字符串标记,而不是真的存空对象 ------ 省内存,也不会让 equals 判断变复杂。缺点:攻击者用海量不同 id 打过来,会塞满一堆无效 key。所以 TTL 要短,2~5 分钟比较合适;更彻底的办法是配合布隆过滤器(见下)。
解决方案二:布隆过滤器
在缓存前面加一层布隆过滤器,所有合法的 id 在启动时预热进 Bloom,查询时先过 Bloom:
java
// Redisson 实现
RBloomFilter<Long> bloom = redisson.getBloomFilter("user:id:bloom");
// 预计元素 1000 万,误判率 0.01
bloom.tryInit(10_000_000L, 0.01);
// 预热
userMapper.selectAllIds().forEach(bloom::add);
// 查询前置
if (!bloom.contains(id)) {
return null; // 一定不存在,直接返回
}
布隆过滤器的特点:说「不存在」一定不存在(无假阴性);说「存在」可能不存在(有假阳性)。所以它兜住了穿透攻击,偶有误判放行几个请求到 DB,扛得住。
原生 Redis 没有 BloomFilter 数据类型,需要装 RedisBloom 模块,或者用 Redisson 客户端实现(底层是一个大 Bitmap + 多个 Hash 函数)。
6.2 缓存击穿 ------ 一个热点 key 正好过期
现象:一个超高热度的 key(比如爆款商品详情)在某一刻过期,瞬间几万请求全部穿透到 DB。
这是三大问题里最需要重视的一个,因为它在正常业务逻辑下就会发生。
方案一:互斥锁重建(强一致,推荐用于金融类)
java
private static final String LOCK_PREFIX = "lock:rebuild:";
public Product getHotProduct(Long id) {
String key = "product:" + id;
Product p = (Product) redisTemplate.opsForValue().get(key);
if (p != null) return p;
String lockKey = LOCK_PREFIX + id;
String uuid = UUID.randomUUID().toString();
try {
// 抢重建锁,10 秒自动释放防止死锁
Boolean ok = redisTemplate.opsForValue()
.setIfAbsent(lockKey, uuid, Duration.ofSeconds(10));
if (Boolean.TRUE.equals(ok)) {
try {
// 双重检查:可能别的线程刚写好
p = (Product) redisTemplate.opsForValue().get(key);
if (p != null) return p;
p = productMapper.selectById(id);
if (p != null) {
redisTemplate.opsForValue().set(key, p, Duration.ofMinutes(30));
}
return p;
} finally {
unlock(lockKey, uuid); // 必须用 Lua 校验再删,见第七节
}
} else {
// 没抢到锁,睡一会重试
Thread.sleep(50);
return getHotProduct(id);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
}
}
方案二:逻辑过期(高并发场景,性能优先)
不设置 Redis 的 TTL,而是把过期时间写进 value 里:
java
@Data
public class RedisData<T> {
private T data;
private LocalDateTime expireTime; // 逻辑过期时间
}
查询时发现逻辑过期,就直接返回旧数据,另外开一个异步线程去重建。这样所有请求都不会阻塞,代价是短时间内拿到的是脏数据。
什么时候用哪个?
- 数据一致性要求极高 → 互斥锁
- 追求极致并发、能容忍秒级不一致 → 逻辑过期
- 大部分普通业务 → 不要放任它过期,给热点 key 加随机 TTL 或者干脆永不过期 + 主动更新即可。
6.3 缓存雪崩 ------ 大批 key 同时失效
现象:系统上线时批量预热了一万条数据,都设了 30 分钟 TTL,30 分钟后全部失效,数据库压力瞬间拉满。
解决方案:
- TTL 加随机值,把失效时间打散:
java
long base = 30 * 60; // 基础 30 分钟
long jitter = ThreadLocalRandom.current().nextLong(300); // ±5 分钟随机
redisTemplate.opsForValue()
.set(key, value, Duration.ofSeconds(base + jitter));
- 多级缓存:本地 Caffeine + Redis + MySQL,Redis 挂了本地还能扛一会儿。
- Redis 高可用:哨兵 / Cluster,防止 Redis 整体宕机导致「无缓存可用」。
- 降级限流:数据库压力到阈值时对部分非核心接口熔断,返回兜底数据。
三者对比记忆:
| 问题 | 触发条件 | 关键特征 | 核心手段 |
|---|---|---|---|
| 穿透 | 查不存在的数据 | 请求根本不该进来 | 空值缓存 / 布隆过滤器 |
| 击穿 | 单个热点 key 过期 | 一个点被打穿 | 互斥锁 / 逻辑过期 |
| 雪崩 | 大批 key 同时过期或 Redis 挂了 | 面被打穿 | 随机 TTL / 高可用 / 降级 |
七、分布式锁:从 SETNX 到 Redisson
这是 Redis 面试里区分度最高的题。很多同学只知道 SETNX,但只写 SETNX 的代码在生产上是错的。
7.1 错误写法和它的两个致命问题
java
// ❌ 错误写法 1:非原子
if (setnx(key, 1) == 1) { // 抢到锁
expire(key, 10); // ← 如果这行之前进程崩了,锁永远不释放 = 死锁
// ...业务...
del(key);
}
// ❌ 错误写法 2:删别人的锁
del(key); // 线程 A 超时了,锁被 B 抢走,A 醒过来把 B 的锁删了
7.2 正确的基础写法(Lua 保证原子)
加锁:一条命令搞定,原子性由 Redis 保证
java
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, uuid, Duration.ofSeconds(30));
等价于 SET lock:order:1 uuid NX EX 30,一个 Redis 命令,天然原子。
解锁:判断是不是自己的锁再删,必须用 Lua 把「读 + 删」合成一个原子操作
java
private static final RedisScript<Long> UNLOCK_SCRIPT = RedisScript.of(
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end", Long.class);
public void unlock(String lockKey, String uuid) {
redisTemplate.execute(UNLOCK_SCRIPT,
Collections.singletonList(lockKey), uuid);
}
为什么 Lua 能原子? Redis 是单线程执行命令的,Lua 脚本作为一个整体提交后会被一次性执行完,期间不会插入其他客户端的命令。可以理解为「Redis 层面的 synchronized」。
7.3 还剩一个问题:超时时间设多久?
设短了,业务没跑完锁就自己没了;设长了,进程挂了要等很久。
答案:看门狗(Watchdog)自动续期。 这也是 Redisson 的核心能力,不用你自己写。
7.4 直接上 Redisson(生产推荐)
xml
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.27.2</version>
</dependency>
java
@Resource
private RedissonClient redissonClient;
public void createOrder(OrderDTO dto) {
RLock lock = redissonClient.getLock("lock:order:" + dto.getUserId());
try {
// 尝试等待 5 秒,锁默认 30 秒,watchdog 每 10 秒自动续期
boolean acquired = lock.tryLock(5, TimeUnit.SECONDS);
if (!acquired) {
throw new BizException("操作太频繁,请稍后再试");
}
try {
// 业务逻辑
doCreateOrder(dto);
} finally {
// 只有持有者才能解锁,Redisson 内部已处理
lock.unlock();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
Redisson 帮你做了什么:
- 原子加锁:底层是 Lua 脚本,可重入(同一线程多次 lock 用计数器累加)
- Watchdog 续期 :不指定
leaseTime时启动后台定时任务,默认每lockWatchdogTimeout / 3 = 10 秒检查一次,锁还在就重置为 30 秒 - 释放时校验:unlock 时校验线程 ID,不会误删别人的锁
- 宕机自动释放:持有锁的进程挂了,watchdog 停止,锁靠 TTL 自然过期
⚠️ 注意 :如果手动指定了
leaseTime(比如tryLock(5, 30, SECONDS)),watchdog 就不生效了,30 秒到点必释放,业务超长照样出并发问题。
7.5 Redlock:要不要用?
Redis 官方提出的 Redlock 算法要求向 N 个独立主节点(通常 5 个)依次抢锁,超过半数成功才算拿到。
现实中的结论:
- 单实例 + 主从 + 哨兵架构下,Redisson 普通锁在绝大多数业务够用。
- Redlock 需要部署多套独立 Redis 实例,运维成本高,性能也下降明显。
- 它对 GC STW、时钟漂移这些场景依然存在争议(Martin Kleppmann 和 antirez 有过一场著名的争论)。
- 真要强一致请直接用 etcd / ZooKeeper。Redis 锁的定位就是「兼顾性能与可用性的非严格锁」。
主从切换时的锁失效:主节点加锁成功但还未同步给从节点就宕机了,从升主后新的客户端又能加锁成功,同一把锁被两个人拿到。这是普通 Redis 锁在 AP 取舍下的固有缺陷,面试官问到这里,说清楚这个边界比背 Redlock 更有价值。
八、持久化:RDB、AOF 怎么选
Redis 数据在内存里,断电即失。持久化就是「内存 → 磁盘」的过程。
8.1 RDB ------ 快照
在某个时间点 把内存全量数据 dump 成一个紧凑的二进制文件(dump.rdb)。
conf
save 900 1 # 900 秒内至少 1 次修改就触发
save 300 10 # 300 秒内至少 10 次修改
save 60 10000 # 60 秒内至少 10000 次修改
dbfilename dump.rdb
dir /var/lib/redis
也可以手动触发:
bash
SAVE # 主线程执行,会阻塞所有请求,生产禁用
BGSAVE # fork 子进程执行,主线程继续服务(推荐)
核心机制:写时复制(Copy-On-Write)
BGSAVE 时 Redis fork 一个子进程。子进程共享父进程的内存页,只有在父进程要修改某个数据页时,内核才复制那一页。所以内存峰值最多到原来的 2 倍,规划机器内存时留足余量。
特点:
- ✅ 文件紧凑,适合做冷备份 和全量复制,恢复速度快
- ❌ 会丢数据 ------ 最后一次快照到宕机之间的所有修改全都丢失
8.2 AOF ------ 追加日志
把每一条写命令追加到文件末尾,重启时重放命令恢复数据。
conf
appendonly yes
appendfilename "appendonly.aof"
appendfsync always # 每条命令都刷盘:最安全,性能最差
appendfsync everysec # 每秒刷盘:默认推荐,最多丢 1 秒数据
appendfsync no # 交给操作系统:最快,可能丢很多
AOF 重写(Rewrite) :随着运行,AOF 会越来越大。Redis 会 fork 子进程,根据当前内存数据生成最简命令集,覆盖旧文件。
conf
auto-aof-rewrite-percentage 100 # 比上次重写后的体积增长 100%
auto-aof-rewrite-min-size 64mb # 且至少达到 64MB
举个例子,对同一个 key 执行了 1000 次 INCR,重写后只会保留一条 SET key 1000。
8.3 混合持久化(Redis 4.0+,生产推荐开启)
conf
aof-use-rdb-preamble yes
重写时文件格式:RDB 二进制头 + 后续增量的 AOF 命令日志。兼顾了 RDB 的快速加载和 AOF 的低丢失率。
8.4 怎么选
| 需求 | 建议 |
|---|---|
| 纯缓存、丢点数据无所谓 | 关掉持久化,或只用 RDB |
| 要求最多丢 1 秒 | AOF everysec + 混合持久化 |
| 既要又要 | RDB + AOF 混合,RDB 做定期冷备,AOF 保实时 |
注意:Redis 重启时会优先加载 AOF(如果开启),因为 AOF 数据更完整。加载一个几十 GB 的 AOF 可能要几分钟,这期间服务不可用。
** fork 阻塞风险**:无论 RDB 还是 AOF 重写都要
fork。在虚拟机上,实例内存越大 fork 耗时越长(每 GB 约 10~20ms)。一个 20GB 的实例 fork 可能卡住几百毫秒。所以单实例不宜过大,控制在几 GB 以内,超了就分片。
九、内存管理与淘汰策略
Redis 内存是有上限的(maxmemory),超了就得按策略删 key。
conf
maxmemory 4gb
maxmemory-policy allkeys-lru
9.1 八种淘汰策略
| 策略 | 作用范围 | 算法 |
|---|---|---|
noeviction |
--- | 不淘汰,写请求直接报错(默认) |
volatile-lru |
设了 TTL 的 key | LRU |
allkeys-lru |
所有 key | LRU |
volatile-lfu |
设了 TTL 的 key | LFU |
allkeys-lfu |
所有 key | LFU |
volatile-random |
设了 TTL 的 key | 随机 |
allkeys-random |
所有 key | 随机 |
volatile-ttl |
设了 TTL 的 key | 剩余存活时间最短的先删 |
LRU vs LFU:
- LRU(Least Recently Used):淘汰最久没被访问的。适合「热点集中、周期性访问」的场景。
- LFU(Least Frequently Used):淘汰访问次数最少的。适合「有长期稳定热点」的场景,比如热门商品、爆款视频。LFU 能避免一个「只是刚创建还没到 Freshrate」的新 key 被误杀,也不会因为一次批量扫描导致 LRU 被污染。
Redis 的 LRU 是近似 LRU ,不是严格双向链表实现。它在每个对象的
lru字段用 24 bit 存最近访问时间戳,淘汰时随机采样 N 个 key (默认 5,maxmemory-samples可调)取其中最久未访问的。这是用一点点准确率换取极低的实现成本。
实践建议:
- 纯缓存、允许随意淘汰 →
allkeys-lru - 缓存 + 少量持久数据混布 →
volatile-lru,但核心数据绝对不能混在同一个 Redis 里 - 有明显长期热点(如榜单、热门内容)→
allkeys-lfu
9.2 过期删除策略
key 到点了不会立刻被删,Redis 用了两个机制配合:
- 惰性删除:访问时才检查,过期就删并返回 nil。好处是省 CPU,坏处是过期但没人访问的垃圾 key 会一直占内存。
- 定期删除 :默认每 100ms(10 次/秒)随机抽查一批 key 删除过期的,由
hz参数控制频率。
一个经典陷阱 :Redis 单机内存占用经常远高于实际数据量,原因往往就是大量已过期的 key 还没被清理 。解决:适当提高
hz(比如从 10 调到 100,注意会多耗 CPU),或者主动SCAN清理。
十、高可用:主从 → 哨兵 → Cluster
10.1 主从复制
写走主节点,读走从节点,读写分离 + 数据冗余。
bash
# 在从节点执行
REPLICAOF 127.0.0.1 6379
# 或配置文件
replicaof 192.168.1.10 6379
复制流程:
从节点执行 replicaof
↓
主节点 BGSAVE 生成 RDB → 期间新命令写进 replication buffer
↓
RDB 传输给从节点 → 从节点清空旧数据,加载 RDB
↓
主节点把 buffer 里的增量命令发给从节点
↓
进入稳定期:主每执行一条写命令,异步发给从(增量复制)
增量复制的 repl_backlog :从节点短暂断线重连后,主节点从环形缓冲区(默认 1MB,repl-backlog-size)里补发缺失的命令。如果断太久 buffer 被覆盖,就只能全量重同步。所以这个值可以适当调大到 16~64MB。
主从切换期间的数据安全问题 :默认
min-replicas-to-write 0,主节点挂了或者网络分区时仍然接受写。为了提高安全性,可以设min-replicas-to-write 1+min-replicas-max-lag 10,要求至少有 1 个从节点延迟在 10 秒内才允许写。代价是极端情况下主节点会短暂拒写。
10.2 哨兵 Sentinel
主从不能自动故障转移,哨兵解决这个问题。
哨兵做三件事:监控 (不断 ping)、通知 (异常告警)、自动故障转移(选新主)。
conf
# sentinel.conf
sentinel monitor mymaster 192.168.1.10 6379 2 # 2 个哨兵认为挂了才算客观下线
sentinel auth-pass mymaster 123456
sentinel down-after-milliseconds mymaster 30000 # 30 秒无响应判定主观下线
sentinel parallel-syncs mymaster 1
sentinel failover-timeout mymaster 180000
客观下线 → 哨兵 leader 选举(Raft)→ 按优先级(replica-priority)→ 复制偏移量 → run id 顺序选新主 → 通知其它从节点复制新主 → 旧主恢复后降级为从。
哨兵解决的是高可用,没解决容量问题 ------ 所有节点都是全量数据,写只能打到主节点,单机的内存和写性能还是天花板。
10.3 Cluster 集群(海量数据方案)
核心:哈希槽(hash slot),一共 16384 个。
slot = CRC16(key) % 16384
每个节点负责一部分槽,客户端请求打到哪个 key,就路由到负责那个槽的节点。
bash
# 创建集群(6 个节点:3 主 3 从)
redis-cli --cluster create 127.0.0.1:7000 ... 127.0.0.1:7005 \
--cluster-replicas 1
为什么是 16384 而不是 65536? 官方作者的解释:集群节点间要互相发送心跳包携带槽位 bitmap,16384 个槽的 bitmap 是 2KB(16384/8),65536 就是 8KB,心跳包会过大;而且 Redis 节点通常不会超过 1000 个,16384 足够,不会有分配不均的问题。
Cluster 的限制:
- ❌ 不支持跨槽的多 key 操作(
MGET、SINTER),除非用 Hash Tag 强制让多个 key 落到同一槽:{user1001}:profile和{user1001}:orders只看{}里的内容计算槽位。 - ❌ 不支持多数据库,只有 db0。
- ❌ 事务 / Lua 里的 key 必须在同一个槽。
10.4 怎么选
| 场景 | 方案 |
|---|---|
| 学习 / 小项目 | 单机 + RDB/AOF |
| 数据量小、读压力大 | 主从 + 哨兵 |
| 数据量大(> 单机内存)或写压力大 | Cluster |
Cloud 优先原则 :生产环境优先考虑云厂商的托管 Redis(阿里云、腾讯云),省了绝大部分运维成本。但原理必须自己会,不然出问题只能干等。
十一、为什么单线程的 Redis 这么快
这是面试百问之一,也经常被答偏。
先纠正一个说法 :Redis 的「单线程」指的是 命令执行(网络读 → 解析 → 执行 → 网络写)由一个主线程串行完成。
- Redis 4.0:
UNLINK、FLUSHALL ASYNC这些耗时操作交给后台线程 - Redis 6.0:网络 IO 支持多线程 (
io-threads),但命令执行依然单线程
快的真正原因:
- 纯内存操作,没有磁盘 IO 瓶颈,绝大多数操作是纳秒到微秒级
- 没有锁竞争。单线程执行保证了天然原子性,省掉了多线程上下文切换和锁的开销 ------ 这反而是最大的收益
- IO 多路复用。epoll/kqueue 让一个线程可以同时处理成千上万个连接
- 高效的数据结构。跳表、哈希表、SDS、ziplist/listpack、intset 都是针对场景专门优化过的
- 单线程 + 无共享 ,使得 CPU 不是瓶颈,瓶颈通常在网络带宽 和内存容量
单线程带来的副作用(也是线上常见的坑):
一个慢查询会阻塞后面所有请求。以下命令在线上要极度小心:
| 危险命令 | 原因 | 替代方案 |
|---|---|---|
KEYS * |
O(N) 全表扫描 | SCAN 游标分批 |
SMEMBERS / HGETALL / LRANGE 0 -1 |
大集合全量返回 | SSCAN / HSCAN / 分段 LRANGE |
FLUSHALL / FLUSHDB |
全量删除 | UNLINK(异步) |
| 单个 key 存了几十 MB 的 value | 传输时间长 | 拆分成多个 key |
一次 DEL 一个百万元素的 Set |
释放大量内存耗时长 | UNLINK 异步删除 |
DEL和UNLINK的区别 :DEL在主线程同步释放内存;UNLINK只是把 key 从数据库字典摘掉,真正的内存释放交给后台线程惰性完成。删大 key 一定用UNLINK。
排查慢查询:
bash
SLOWLOG GET 10 # 查看慢日志
CONFIG SET slowlog-log-slower-than 10000 # 超过 10 毫秒才算慢
LATENCY DOCTOR # Redis 自带延迟诊断
--bigkeys # redis-cli 参数,扫描大 key
redis-cli --bigkeys
redis-cli --hotkeys # 需要 maxmemory-policy 是 LFU 相关
十二、实践练习清单
光看不动手,一周就忘。下面这些最好自己敲一遍:
Level 1 --- 基础(必做)
- Docker 起 Redis,用
redis-cli把五大数据类型各练 10 个命令 - Spring Boot 项目接入 Redis,自定义序列化器,实现一个带缓存的查询接口
- 用
INCR实现一个简单的接口限流(每分钟最多 100 次)
Level 2 --- 进阶(强烈建议)
- 手写缓存工具类,实现:查缓存 → 未命中查 DB → 写入缓存,TTL 带随机值
- 实现缓存穿透的空值缓存方案,写个脚本模拟恶意请求压测对比效果
- 用
ZSet做一个实时排行榜接口,支持查 Top10 和查自己的排名 - 用 Redisson 实现防重复提交:同一个用户 5 秒内只能下一单
Level 3 --- 综合(写进简历)
- 完整设计并实现「点赞 / 取消点赞」功能:Redis Hash 存点赞状态、ZSet 存点赞时间线,定时任务批量落库到 MySQL
- 实现一个简易延迟队列(订单 30 分钟未支付自动取消),对比 ZSet 轮询和 Stream 两种方案
- 搭建主从 + 哨兵,手动 kill 主节点观察自动切换过程,验证应用侧是否需要改配置
- 模拟百万用户签到场景,用 Bitmap 实现并和 MySQL 方案对比存储成本
十三、面试高频题速查
Q:Redis 为什么快?
纯内存 + 单线程无锁竞争 + IO 多路复用 + 高效数据结构。注意指出「单线程」的准确含义是命令执行线程。
Q:Redis 事务和 MySQL 事务的区别?
Redis 事务(MULTI/EXEC)不支持回滚 。命令入队时的语法错误会导致整个事务拒绝执行;运行时错误(比如命令和 key 类型不匹配)只会让那条命令失败,其他命令照样执行 。它没有 MySQL 的原子性和隔离性。
(需要严格原子性请用 Lua 脚本。)
Q:缓存和数据库一致性怎么保证?
没有完美方案,只有取舍:
- 读多写少 :先更新 DB,再删缓存(Cache-Aside Pattern)。注意为什么是删除而不是更新 ------ 更新缓存可能因为时序问题写入脏数据,而且有些缓存值计算成本很高,没人查就白算了。
- 删缓存失败怎么办:消息队列重试 / Canal 订阅 binlog 异步删 / 延迟双删(更新 DB 后删缓存,sleep 一小会儿再删一次,兜掉主从延迟带来的脏读)。
- 要求强一致:加分布式锁,串行化读写,牺牲性能。
- 本质上是 AP 系统和 CP 系统的取舍,说清楚这个认知比背一个方案重要。
Q:Pipeline 有什么用?
把多条命令打包一次性发送,省掉 N 次网络往返时间(RTT)。本机可能提升不明显,跨机房能提升几十倍。
注意:Pipeline 不是事务,它不保证原子性,中间可能被其他命令插入。
Q:Redis 的热 key 和大 key 问题怎么处理?
- 热 key(访问集中在某几个 key) :本地缓存一份 + key 拆分成多个副本(
product:1:shard0/1/2随机读)+ 读写分离多挂几个从节点。 - 大 key(单个 value 过大) :拆分。Hash 拆成多个小 Hash,List 按时间分片,String 的 JSON 拆成多个字段。删除用
UNLINK而不是DEL。
Q:Redis 挂了,业务怎么办?
降级到本地缓存 → 熔断保护 DB → 限流少量放量后再旁路重建缓存。核心是不要让 Redis 挂了导致 DB 也跟着挂。
十四、下一步学什么
按这条主线继续往下:
Redis 基础 ← 你在这里
↓
Redis 高级(Redisson 源码、Lua 脚本、Stream)
↓
分布式系统基础(CAP、一致性、主从脑裂)
↓
消息队列(Kafka / RocketMQ)------ 和 Redis Stream 对比理解
↓
分布式协调(etcd / ZooKeeper)------ 理解为什么有些场景不用 Redis 锁
推荐资料:
- 官方文档 :
https://redis.io/docs/latest/commands/------ 最权威,查命令只信这个 - 《Redis 设计与实现》(黄健宏)------ 讲数据结构和实现的原理,中文写得最好
- 《Redis 深度历险》(钱文品)------ 偏实战和踩坑
- Redis 源码(可选):核心的
dict.c、t_zset.c、sds.c值得读,代码量都不大
小结
学 Redis 真正的门槛不在命令,在于理解「它是单机、内存、单线程、AP 倾向」这个本质,然后所有的问题 ------ 为什么持久化默认配置会丢数据、为什么分布式锁不严谨、为什么主从切换会丢锁、为什么大 key 会拖垮整个实例 ------ 都能从这个本质推出来。
先把上面这些命令练熟(尤其是 SCAN、Lua、Redisson 锁这几块),做一个真实项目用起来,再回头补原理,比倒过来高效很多。
下一篇预告:《深入理解 Kafka / RocketMQ:消息队列该怎么选》
本篇完 · 有任何问题欢迎在评论区讨论