Skip to content
第 5 章 ⏱ 15 分钟阅读

第 5 章:Redis 实战 ​

学习目标 ​

  • 实现分布式锁(Redisson)
  • 用 Redis 做接口限流
  • 掌握缓存三大问题(穿透/击穿/雪崩)
  • 实现分布式 Session 共享

一、分布式锁 ​

1.1 最简 SETNX 版 ​

java
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,只能删自己的:

java
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 脚本逐行翻译:

lua
if redis.call('get', KEYS[1]) == ARGV[1] then   -- 取出 key 的值,跟我的 token 比对
    return redis.call('del', KEYS[1])             -- 一样 → 是我的锁,删
end
return 0                                          -- 不一样 → 不是我的锁,啥也不干,返回 0

为啥 Java 这行这么写?

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(两类参数):

类型放啥编号
KEYSRedis 的 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 ​

xml
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.23.4</version>
</dependency>
java
@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 万人抢):

java
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) ​

java
// 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 条消息,防止刷屏。

java
boolean ok = allowRequest("1001", 5, 60);
//                 用户ID  限5次  60秒窗口
if (ok) {
    // 放行:发消息
} else {
    return "操作太频繁,60 秒后再试";
}

Lua 脚本逐行翻译:

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 不用 Javaincr + expire 必须原子,中间崩了会导致 key 永远不消失 → 计数卡死

三、缓存三大问题 ​

3.1 缓存穿透(查不存在的数据) ​

现象:恶意请求 id=-1,Redis 没有,直接打 DB。

解决:把空结果也缓存(短过期)。

java
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。

解决:互斥锁重建缓存或逻辑过期。

java
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 的简单封装):

java
// 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 版(带看门狗自动续期):

java
RLock lock = redisson.getLock(lockKey);
lock.tryLock(5, 10, TimeUnit.SECONDS);  // 阻塞 5 秒,锁 10 秒过期
lock.unlock();

3.3 缓存雪崩(大量 key 同时过期) ​

现象:整层缓存同时过期,DB 被打爆。

解决:

  1. 过期时间加随机扰动:expire + Random(60) 秒
  2. 多级缓存(本地 + Redis)
  3. 熔断降级(Sentinel / Resilience4j)

⚠️ 坑 3:不要用固定过期时间(整点过期)—— 凌晨 0 点过期雪崩 是真实生产事故。

四、分布式 Session ​

java
@Configuration
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 1800)
public class SessionConfig {

    @Bean
    public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
        return new GenericJackson2JsonRedisSerializer();
    }
}

加上依赖:

xml
<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 一行启用

动手练习 ​

  1. 用 Redisson 实现"商品库存扣减"分布式锁
  2. 实现一个简单限流接口,1 秒最多 5 次
  3. 模拟缓存击穿:故意让 key 过期,观察 DB 查询数

下一章:第 6 章:Elasticsearch 入门 →

本站基于 VitePress 构建 · 由 StackHub 团队维护