Redis 实战指南:缓存、分布式锁、持久化
Redis 是 CSDN 上后端技术流量最高的主题之一,也是大厂面试必考。本文从数据结构讲起,覆盖缓存三大问题(穿透/击穿/雪崩)、分布式锁、持久化、集群、性能调优,全部带可运行代码。
一、Redis 是什么
Redis 是内存数据库------数据存在内存里,读写纳秒级。相比 MySQL(磁盘),它快几个数量级,代价是数据不是主要存储。
用途:
- 缓存(加速读);
- 分布式锁(并发控制);
- 计数器(点赞、库存);
- 消息队列(List/Stream);
- 排行榜(ZSet);
- 会话共享(Session 存 Redis)。
核心特点:
- 单线程执行命令(6.0 后多线程处理网络 IO,但命令执行仍单线程)------所以命令都是原子的;
- 丰富的数据结构;
- 支持持久化(RDB/AOF);
- 支持主从、哨兵、集群。
二、五种基本数据结构
2.1 String
最常用。存 JSON、计数、分布式锁。
bash
SET user:1001 '{"name":"张三","age":25}'
GET user:1001
INCR visit:count # 自增
SETNX lock:order 1 # 不存在才设置(分布式锁用)
EXPIRE user:1001 3600 # 设置过期
2.2 Hash
适合存对象字段,可单独更新某个字段。
bash
HSET user:1001 name 张三 age 25
HGET user:1001 name
HINCRBY user:1001 score 10
为什么比 String 好:对象场景下,Hash 只改一个字段不用整体反序列化,内存占用也低。
2.3 List
双向链表。消息队列、时间线。
bash
LPUSH news:tech "Spring Boot 3 发布"
LRANGE news:tech 0 -1
LPOP news:tech
2.4 Set
去重、集合运算。关注关系、标签。
bash
SADD user:1001:following 2001 2002
SISMEMBER user:1001:following 2001 # 是否关注
SINTER user:1001:following user:1002:following # 共同关注
2.5 ZSet(有序集合)
带分数的集合。排行榜、延迟队列。
bash
ZADD rank:game 1000 "playerA"
ZADD rank:game 800 "playerB"
ZRANGE rank:game 0 -1 WITHSCORES # 升序
ZREVRANGE rank:game 0 -1 # 降序(排行榜)
ZINCRBY rank:game 100 "playerA" # 加分数
三、缓存三大经典问题
这是面试必问、生产必踩的三座山。
3.1 缓存穿透
现象 :查一个不存在的 key------缓存没有,数据库也没有。每次请求都打到数据库。
危害:恶意请求用不存在的 id 刷接口,数据库直接被打挂。
解决:
- 缓存空值:查询结果为空也缓存,过期时间设短(如 5 分钟);
- 布隆过滤器:请求先过 Bloom Filter,不存在的 key 直接拦截(不用查库)。
java
// 方案一:缓存空值
public User getUser(Long id) {
String key = "user:" + id;
User user = redis.get(key);
if (user != null) return user;
user = userMapper.selectById(id);
if (user == null) {
redis.set(key, "", 300); // 缓存空值 5 分钟
return null;
}
redis.set(key, user, 3600);
return user;
}
3.2 缓存击穿
现象 :某个热点 key 过期的瞬间,大量请求同时打到数据库。
区别:穿透是"查不存在",击穿是"查一个存在但缓存刚好过期"。
解决:
- 互斥锁:只有一个线程去查库,其他线程等待;
- 逻辑过期:key 永不过期,value 里带过期时间,异步刷新。
java
// 方案:互斥锁
public User getUser(Long id) {
User user = redis.get("user:" + id);
if (user != null) return user;
String lockKey = "lock:user:" + id;
boolean locked = redis.setIfAbsent(lockKey, "1", 30, TimeUnit.SECONDS);
if (!locked) {
Thread.sleep(50);
return getUser(id); // 递归等待
}
try {
user = userMapper.selectById(id); // 只有一个线程进来
redis.set("user:" + id, user, 3600);
return user;
} finally {
redis.delete(lockKey);
}
}
3.3 缓存雪崩
现象 :大量 key 同时过期,或 Redis 宕机,请求全部打到数据库。
解决:
- 过期时间加随机值 :
set key val (3600 + random(0,600)),避免同时过期; - 多级缓存:本地缓存(Caffeine)+ Redis;
- Redis 高可用:主从 + 哨兵;
- 限流降级:数据库扛不住时直接降级。
三者对比:
| 问题 | 场景 | 核心解决 |
|---|---|---|
| 穿透 | 查不存在的 key | 空值缓存 / 布隆过滤器 |
| 击穿 | 热点 key 过期瞬间 | 互斥锁 / 逻辑过期 |
| 雪崩 | 大量 key 同时过期 / Redis 宕机 | 随机过期 / 高可用 / 多级缓存 |
四、分布式锁
4.1 为什么需要分布式锁
单体时代用 synchronized,多实例部署后(两个 JVM 各持一把锁)就不管用了------需要跨进程的锁。
场景:防止重复下单、库存扣减、定时任务重复执行。
4.2 基于 Redis 的分布式锁
版本一:简单版(不推荐生产用)
java
// 加锁
Boolean ok = redis.setIfAbsent("lock:order:1001", "1", 30, TimeUnit.SECONDS);
if (ok) {
try {
doBiz();
} finally {
redis.delete("lock:order:1001");
}
}
问题:
- 误删锁:线程 A 的锁过期了,线程 B 拿到锁,A 执行完把 B 的锁删了------要校验 value 是自己的;
- 过期时间太短:业务没执行完锁就过期了。
版本二:校验 value + 续期
java
String token = UUID.randomUUID().toString();
Boolean ok = redis.setIfAbsent(lockKey, token, 30, TimeUnit.SECONDS);
if (ok) {
// 看门狗续期(Redisson 自动做)
try {
doBiz();
} finally {
// 只删自己的锁
if (token.equals(redis.get(lockKey))) {
redis.delete(lockKey);
}
}
}
生产建议 :直接用 Redisson,它实现了看门狗自动续期、可重入锁、公平锁,别自己造轮子:
java
RLock lock = redisson.getLock("lock:order:" + orderId);
if (lock.tryLock(3, 30, TimeUnit.SECONDS)) {
try {
doBiz();
} finally {
lock.unlock();
}
}
4.3 Redlock 与主从问题
Redis 主从切换时,锁可能丢(主挂了,从没同步到锁)。极端场景用 Redlock(多节点加锁),但复杂且有争议。大多数业务场景:单节点 Redis + 哨兵 + 合理过期时间就够,别过度设计。
五、持久化
Redis 数据在内存,重启会丢------持久化解决这个问题。
5.1 RDB(快照)
定时把内存数据 dump 到磁盘。
conf
# redis.conf
save 900 1 # 900秒内1次写就触发
save 300 10
save 60 10000
优点 :文件小、恢复快、适合备份。
缺点:可能丢最后一次快照后的数据。
5.2 AOF(追加日志)
每次写命令追加到日志文件。
conf
appendonly yes
appendfsync everysec # 每秒刷盘(推荐)
优点 :最多丢 1 秒数据。
缺点:文件大、恢复慢(可 AOF rewrite 压缩)。
5.3 怎么选
- 能丢 1 分钟数据:RDB 够;
- 不能丢数据(订单、支付):AOF everysec;
- 生产标配:RDB + AOF 都开。
六、高可用架构
6.1 主从复制
一个主节点写,多个从节点读(读写分离)。
bash
# 从节点配置
replicaof 192.168.1.10 6379
- 主节点写命令同步到从节点;
- 读压力大时,读请求走从节点;
- 缺点:主挂了要手动切换(用哨兵解决)。
6.2 哨兵(Sentinel)
监控主节点,挂了自动选新主:
Sentinel 集群(3 个以上)
↓ 监控
Redis 主节点 ←→ 从节点1 ←→ 从节点2
bash
redis-sentinel sentinel.conf
作用:故障自动切换、通知客户端新主地址。
6.3 Cluster 集群
数据量大 + 写量大,用 Cluster 分片:
- 16384 个 hash slot,key 按
CRC16(key) % 16384分配到节点; - 每个节点管一部分 slot;
- 支持自动扩容/缩容。
选型建议:
| 规模 | 方案 |
|---|---|
| 单机 < 10G | 主从 + 哨兵 |
| 读写分离 | 主从 |
| 数据量大/写量大 | Cluster |
| 云厂商 | 直接用云 Redis(自带高可用) |
七、Redis 性能调优
7.1 慢查询
bash
SLOWLOG GET 10 # 看最近慢命令
config set slowlog-log-slower-than 10000 # 阈值 10ms
常见慢命令 :KEYS *(全量扫)、大 key 的 HGETALL、SMEMBERS。
禁止 KEYS :生产环境 KEYS * 会阻塞 Redis 几秒。用 SCAN 游标式遍历。
7.2 大 Key 问题
单个 key 值超过 10KB 或集合元素超过 1 万个叫大 key:
- 读写慢、阻塞;
- 删除时卡顿。
处理 :拆分 key、压缩值、删除用 UNLINK(异步删除)。
7.3 热点 Key
某个 key 被超高并发访问,单节点扛不住:
- 本地缓存:JVM 里缓存热点数据,Redis 再挂也是走本地;
- 读写分离:读走多个从节点;
- key 分片 :
key:1、key:2...分散到不同节点。
7.4 内存淘汰策略
conf
maxmemory 4gb
maxmemory-policy allkeys-lru # 淘汰最近最少使用
策略:
allkeys-lru:所有 key 按 LRU 淘汰(常用);volatile-lru:只淘汰有过期时间的;allkeys-lfu:按访问频率(热数据稳定场景)。
八、Spring Boot 集成 Redis
8.1 依赖与配置
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
yaml
spring:
data:
redis:
host: localhost
port: 6379
lettuce:
pool:
max-active: 20
8.2 封装工具类
java
@Component
public class RedisUtil {
@Autowired
private StringRedisTemplate redis;
public void set(String key, String value, long timeout) {
redis.opsForValue().set(key, value, timeout, TimeUnit.SECONDS);
}
public String get(String key) {
return redis.opsForValue().get(key);
}
public boolean setIfAbsent(String key, String value, long timeout) {
return redis.opsForValue().setIfAbsent(key, value, timeout, TimeUnit.SECONDS);
}
public void delete(String key) {
redis.delete(key);
}
}
坑 :RedisTemplate 默认 JDK 序列化,存进去的 key 带乱码前缀。用 StringRedisTemplate 或配置 JSON 序列化器。
九、实战案例:商品详情缓存
java
@Service
public class ProductService {
// 商品详情:本地缓存 → Redis → 数据库
public ProductDetail getDetail(Long productId) {
// 1. 本地缓存
ProductDetail local = localCache.getIfPresent(productId);
if (local != null) return local;
// 2. Redis(带逻辑过期防击穿)
String key = "product:detail:" + productId;
ProductDetail redisData = redis.get(key);
if (redisData != null) {
if (redisData.isExpired()) {
// 逻辑过期:异步刷新,先返回旧数据
asyncRefresh(productId, key);
}
localCache.put(productId, redisData);
return redisData;
}
// 3. 互斥锁查库
String lockKey = "lock:product:" + productId;
boolean locked = redis.setIfAbsent(lockKey, "1", 30, TimeUnit.SECONDS);
if (!locked) {
Thread.sleep(50);
return getDetail(productId);
}
try {
ProductDetail detail = productMapper.selectDetail(productId);
if (detail == null) {
redis.set(key, "", 300); // 缓存空值防穿透
return null;
}
redis.set(key, detail, 3600);
localCache.put(productId, detail);
return detail;
} finally {
redis.delete(lockKey);
}
}
}
这一套组合拳同时防了穿透、击穿、雪崩:空值缓存防穿透,互斥锁防击穿,随机过期+本地缓存防雪崩。
本章小结
Redis 看似简单(set/get),但生产级的难点全在"缓存一致性、高可用、性能边界"这些细节上。掌握三大问题 + 分布式锁 + 持久化 + 集群,就掌握了 Redis 的核心战场。
下一篇讲 Docker------部署的标准化,和 Redis 一样是 CSDN 热门。
(全文完)