第 5 章:Redis 实战
学习目标
- 实现分布式锁(Redisson)
- 用 Redis 做接口限流
- 掌握缓存三大问题(穿透/击穿/雪崩)
- 实现分布式 Session 共享
一、分布式锁
1.1 最简 SETNX 版
public boolean tryLock(String key, long expireSeconds) {
String result = redis.opsForValue()
.setIfAbsent(key, "1", Duration.ofSeconds(expireSeconds));
return "OK".equals(result); // ⚠️ setIfAbsent 返回 Boolean
}
public void unlock(String key) {
redis.delete(key);
}⚠️ 坑 1:
SETNX不带过期时间,加锁方崩溃会导致死锁。必须用SET key value EX seconds NX(一步原子完成,2.6.12+ 才支持)。
1.2 问题:误删别人的锁
A 加锁 30s,执行了 40s,锁过期自动释放;B 此时加锁成功;A 醒来 DEL → 删了 B 的锁。
解决:每个锁加唯一 token,只能删自己的:
String token = UUID.randomUUID().toString();
redis.opsForValue().setIfAbsent("lock:order:1001", token, Duration.ofSeconds(30));
// 解锁:Lua 脚本保证"检查 + 删除"原子
String lua = """
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
end
return 0
""";
redis.execute(new DefaultRedisScript<>(lua, Long.class), List.of("lock:order:1001"), token);逐行解释:
| 行 | 干啥 |
|---|---|
UUID.randomUUID() | 每次拿锁生成唯一 ID,区分锁的主人 |
setIfAbsent(...) | 拿锁,把 token 存进 key(只有 key 不存在才设成功) |
| Lua 脚本 | "如果 key 的值还是我的 token,就删" |
List.of("lock:order:1001") | → 传到 Lua 里就是 KEYS[1] |
token | → 传到 Lua 里就是 ARGV[1] |
Lua 脚本逐行翻译:
if redis.call('get', KEYS[1]) == ARGV[1] then -- 取出 key 的值,跟我的 token 比对
return redis.call('del', KEYS[1]) -- 一样 → 是我的锁,删
end
return 0 -- 不一样 → 不是我的锁,啥也不干,返回 0为啥 Java 这行这么写?
redis.execute(
new DefaultRedisScript<>(lua, Long.class), // 包装 Lua 脚本(返回 Long)
List.of("lock:order:1001"), // 第 1 个参数(锁 key)→ KEYS[1]
token // 第 2 个参数(我的 token)→ ARGV[1]
);| Java 参数 | Lua 里叫啥 | 实际值 |
|---|---|---|
List.of(...) 里的元素 | KEYS[1]、KEYS[2]... | 锁 key 等 |
| 后面逗号的参数 | ARGV[1]、ARGV[2]... | token 等普通值 |
KEYS vs ARGV(两类参数):
| 类型 | 放啥 | 编号 |
|---|---|---|
KEYS | Redis 的 key(锁的 key) | KEYS[1] = 第 1 个 |
ARGV | 普通参数(token 等) | ARGV[1] = 第 1 个 |
为啥分两类? 集群模式下 Redis 根据 key 算 hash 槽,必须知道哪个是 key 才能路由到对的节点。
为啥 Lua 脚本"一气呵成"?
Redis 是单线程执行命令的。当跑 EVAL(Lua)时,Redis 把整段脚本当成一个命令,中间不让其他命令插队:
普通 Java:get → 返回 → 别人改 key → 比对 → del ← 中间会插队,可能误删
Lua 脚本:get + 比对 + del 一口气跑完 ← 中间不会被打断类比:普通 Java = 排队买奶茶中途离开窗口(别人能改价格表);Lua = 一笔交易一手交钱一手交货(中间不被打断)。
1.3 生产用 Redisson
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.23.4</version>
</dependency>@Resource
private RedissonClient redisson;
public void handleOrder() {
RLock lock = redisson.getLock("lock:order:1001");
try {
// 30 秒过期,200ms 自动续期(watchdog)
if (lock.tryLock(5, 30, TimeUnit.SECONDS)) {
// 业务逻辑
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock(); // ⚠️ 坑 2:必须在 finally 里释放
}
}
}秒杀场景解读(100 件商品,1 万人抢):
public String seckill(Long userId) {
RLock lock = redisson.getLock("lock:stock:1001");
try {
// 等 5 秒抢锁,锁 30 秒后自动过期(防止崩了死锁)
if (!lock.tryLock(5, TimeUnit.SECONDS)) {
return "手慢了,下次再来"; // 1万人抢,前面排50+人就超时了
}
// 业务:扣库存
int stock = Integer.parseInt(redis.get("stock:1001"));
if (stock <= 0) return "已抢完";
redis.set("stock:1001", stock - 1);
return "秒杀成功";
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return "系统繁忙";
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}4 个关键点为啥这么写:
| 代码 | 业务含义 |
|---|---|
tryLock(5, 30, SECONDS) | 等 5 秒抢锁(等不到就走,别浪费用户时间);锁 30 秒过期(防崩死锁) |
| 200ms 看门狗(watchdog) | 业务跑 35 秒也没事——看门狗每 200ms 自动续期,业务跑多久锁都不会过期。前提:不传过期时间才生效 |
Thread.currentThread().interrupt() | 服务被运维 kill 时,礼貌地告诉上层"我被打断过",让 Spring 优雅停机(不丢请求) |
isHeldByCurrentThread() | 防止没抢到锁的线程也跑 unlock() 报错(锁不是你的,不能解) |
为啥不抢到锁 = "手慢了"?
1 万人抢同一把锁,锁一次只让 1 个人进,假设每个用户扣库存 100ms:
- 第 1 个人:0 ms
- 第 50 个人:5000 ms(刚好到 5 秒)
- 第 51 个人:5100 ms → 超时 → "手慢了"
100 件就够 100 个人,后面 9900 人等到天荒地老也买不到,所以给个 5 秒超时走人。
为啥 200ms? 看门狗续期间隔,经验值:
- 太频繁(100ms)→ 浪费性能
- 太慢(1秒)→ 业务跑久了锁可能过期
- 200ms 是平衡点
⚠️ 坑 2:忘记
unlock()或tryLock()失败后还 unlock → 抛IllegalMonitorStateException。
二、限流(Sliding Window)
// Lua 脚本:固定窗口限流
String lua = """
local current = redis.call('incr', KEYS[1])
if current == 1 then
redis.call('expire', KEYS[1], ARGV[1])
end
if current > tonumber(ARGV[2]) then
return 0
end
return 1
""";
public boolean allowRequest(String userId, int limit, int windowSec) {
Long result = redis.execute(
new DefaultRedisScript<>(lua, Long.class),
List.of("rate:" + userId),
String.valueOf(windowSec), String.valueOf(limit)
);
return result != null && result == 1;
}业务场景:每个用户 60 秒内最多发 5 条消息,防止刷屏。
boolean ok = allowRequest("1001", 5, 60);
// 用户ID 限5次 60秒窗口
if (ok) {
// 放行:发消息
} else {
return "操作太频繁,60 秒后再试";
}Lua 脚本逐行翻译:
local current = redis.call('incr', KEYS[1]) -- 计数器 +1,返回新值
if current == 1 then -- 第一次 incr
redis.call('expire', KEYS[1], ARGV[1]) -- 才设过期时间(60 秒后自动清零)
end
if current > tonumber(ARGV[2]) then -- 超过限制?
return 0 -- 是 → 拒绝
end
return 1 -- 否 → 通过Java 参数对应:
| Java 参数 | Lua 里叫 | 实际值 |
|---|---|---|
List.of("rate:" + userId) | KEYS[1] | 每个用户一个 key |
String.valueOf(windowSec) | ARGV[1] | 窗口秒数(60) |
String.valueOf(limit) | ARGV[2] | 限制次数(5) |
完整工作流程(60 秒内点 7 次发送):
第 1 次:incr=1 → 设过期 60s → 1 ≤ 5 → 通过 ✅
第 2 次:incr=2 → 跳过 expire → 2 ≤ 5 → 通过 ✅
第 3~5 次:同上 → 通过 ✅
第 6 次:incr=6 → 6 > 5 → 拒绝 ❌
第 7 次:incr=7 → 7 > 5 → 拒绝 ❌
60 秒后:key 自动过期 → 下次 incr 从 1 重新开始两个关键点:
| 设计 | 为啥 |
|---|---|
| 只在第一次设过期 | 每次重置 expire 会让窗口无限延长(永远不归零) |
| 用 Lua 不用 Java | incr + expire 必须原子,中间崩了会导致 key 永远不消失 → 计数卡死 |
三、缓存三大问题
3.1 缓存穿透(查不存在的数据)
现象:恶意请求 id=-1,Redis 没有,直接打 DB。
解决:把空结果也缓存(短过期)。
public User getById(Long id) {
String key = "user:" + id;
String cached = redis.opsForValue().get(key);
if (cached != null) {
return cached.isEmpty() ? null : JSON.parse(cached);
}
User user = db.findById(id);
redis.opsForValue().set(key,
user == null ? "" : JSON.toJSONString(user),
Duration.ofMinutes(user == null ? 1 : 30)); // 空值短过期
return user;
}更狠的方案:布隆过滤器(先过一道)。
3.2 缓存击穿(热点 key 过期瞬间)
现象:某热门商品 key 过期瞬间,几万请求同时打 DB。
解决:互斥锁重建缓存或逻辑过期。
public User getHotUser(Long id) {
String key = "user:" + id;
String cached = redis.opsForValue().get(key);
if (cached != null) return JSON.parse(cached);
// 抢锁重建
String lockKey = "lock:" + key;
if (tryLock(lockKey, 10)) {
try {
User user = db.findById(id);
redis.opsForValue().set(key, JSON.toJSONString(user), Duration.ofHours(1));
return user;
} finally {
unlock(lockKey);
}
}
// 没抢到锁,睡一下重试
Thread.sleep(100);
return getHotUser(id);
}逐行解释:
| 行 | 干啥 |
|---|---|
redis.opsForValue().get(key) | ① 查缓存,有就返回 |
if (cached != null) return ... | ② 命中直接返回(不走 DB) |
tryLock(lockKey, 10) | ③ 抢锁(10 秒过期,防崩死锁) |
db.findById(id) | ④ 抢到锁的:查 DB(只 1 个请求) |
redis.opsForValue().set(...) | ⑤ 写回缓存(1 小时) |
unlock(lockKey) | ⑥ 释放锁 |
Thread.sleep(100) | ⑦ 没抢到锁:睡 100ms 等前面的人写完缓存 |
return getHotUser(id) | ⑧ 递归重试(此时缓存已有数据) |
核心思路:只让 1 个请求查 DB,其他睡一下重试,从缓存拿。
1 万请求同时来 → 缓存都 miss
↓
A 抢到锁 → 查 DB → 写回缓存 → 释放(50ms)
↓
B/C/D... 睡 100ms → 醒来重试 → 缓存命中 → 返回
↓
DB 只被查 1 次 ✅为啥睡 100ms? 给 A 留时间把数据写回缓存;为啥 10 秒过期? 兜底防崩。
和雪崩区别:击穿是单个热点 key,雪崩是一批 key 同时过期(用 TTL 加随机值解决)。
tryLock / unlock 是哪来的?
是 1.1 节自己定义的方法(SETNX + DEL 的简单封装):
// 1.1 节定义
public boolean tryLock(String key, long expireSeconds) {
return "OK".equals(
redis.opsForValue().setIfAbsent(key, "1", Duration.ofSeconds(expireSeconds))
);
}
public void unlock(String key) {
redis.delete(key);
}3.2 直接复用。生产推荐换成 1.3 节的 Redisson 版(带看门狗自动续期):
RLock lock = redisson.getLock(lockKey);
lock.tryLock(5, 10, TimeUnit.SECONDS); // 阻塞 5 秒,锁 10 秒过期
lock.unlock();3.3 缓存雪崩(大量 key 同时过期)
现象:整层缓存同时过期,DB 被打爆。
解决:
- 过期时间加随机扰动:
expire + Random(60)秒 - 多级缓存(本地 + Redis)
- 熔断降级(Sentinel / Resilience4j)
⚠️ 坑 3:不要用固定过期时间(整点过期)—— 凌晨 0 点过期雪崩 是真实生产事故。
四、分布式 Session
@Configuration
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 1800)
public class SessionConfig {
@Bean
public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
return new GenericJackson2JsonRedisSerializer();
}
}加上依赖:
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>之后 Spring 自动把 HttpSession 存到 Redis,集群下任意节点都能读到。
⚠️ 坑 4:自定义对象放进 session 必须实现
Serializable,否则反序列化失败。
五、本章小结
| 要点 | 关键 |
|---|---|
| 分布式锁 | SET NX EX + 唯一 token + Lua 释放 |
| Redisson | 生产首选,自带 watchdog 自动续期 |
| 穿透 | 空值短缓存 + 布隆过滤器 |
| 击穿 | 互斥锁 / 逻辑过期 |
| 雪崩 | 过期时间加随机 + 多级缓存 |
| Session | @EnableRedisHttpSession 一行启用 |
动手练习
- 用 Redisson 实现"商品库存扣减"分布式锁
- 实现一个简单限流接口,1 秒最多 5 次
- 模拟缓存击穿:故意让 key 过期,观察 DB 查询数
下一章:第 6 章:Elasticsearch 入门 →