一、学习目标
- 理解 本地缓存 + Redis 多级缓存 的架构与取舍。
- 会用 Caffeine 实现本地缓存,并与 Redis 组成二级缓存。
- 掌握 Redis 分布式锁 的正确实现:SET NX EX、唯一值、Lua 释放锁。
- 了解 Redisson 看门狗机制对分布式锁的增强。
- 掌握 热点 Key 的识别与应对(逻辑过期、本地缓存兜底)。
- 实现 库存预扣:Redis 预扣 + 异步落库,解决高并发超卖。
- 把第42天悲观/乐观锁、第43天 Cache-Aside,升级到 高并发秒杀级 写场景。
二、为什么第44天要学多级缓存与分布式锁
第43天用 Redis 做了读加速,但还有几个没解决的问题:
- Redis 也有网络开销,极热数据(如首页配置)每次还要走一次网络。
- 第43天的"互斥锁"用
setIfAbsent实现得比较简陋,没考虑 误删别人的锁 、锁过期但业务没执行完。 - 秒杀、抢购场景下,第42天的数据库行锁/乐观锁扛不住瞬时高并发。
- 多个应用实例同时执行定时任务、同时刷新缓存,需要 只让一个实例执行。
第44天目标:把缓存做成 多级 ,把锁做成 正确的分布式锁 ,并用 Redis 预扣库存 应对高并发写。
三、多级缓存架构
3.1 为什么要本地缓存
| 层级 | 访问速度 | 容量 | 一致性 |
|---|---|---|---|
| 本地缓存(Caffeine) | 纳秒级,无网络 | 小,受 JVM 限制 | 各实例独立,弱 |
| Redis 缓存 | 微秒到毫秒,有网络 | 大,可集群 | 多实例共享 |
| 数据库 | 毫秒级,磁盘 IO | 最大 | 强一致来源 |
多级缓存读取顺序:
text
1. 查本地缓存(L1)命中直接返回
2. 未命中查 Redis(L2)命中则回填本地缓存并返回
3. 再未命中查数据库,回填 Redis 和本地缓存
3.2 本地缓存的代价
- 数据不一致:实例 A 更新了,实例 B 的本地缓存还是旧值。
- 所以本地缓存只适合:变更不频繁、能容忍秒级不一致的小数据,如字典、配置、极热商品。
- TTL 要短(如几秒到几十秒),或借助消息广播失效。
四、Caffeine 本地缓存
4.1 依赖
xml
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
Spring Boot 已集成 Spring Cache,可直接把 Caffeine 作为 CacheManager。
4.2 直接使用 Caffeine API
java
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.stereotype.Component;
import java.time.Duration;
@Component
public class LocalCache {
private final Cache<String, Object> cache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofSeconds(30))
.recordStats()
.build();
public Object getIfPresent(String key) {
return cache.getIfPresent(key);
}
public void put(String key, Object value) {
cache.put(key, value);
}
public void invalidate(String key) {
cache.invalidate(key);
}
}
关键参数:
maximumSize:最大条目数,超出按 W-TinyLFU 淘汰。expireAfterWrite:写入后多久过期。expireAfterAccess:访问后多久过期。recordStats:开启命中率统计。
4.3 作为 Spring Cache 的 CacheManager
java
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.CacheManager;
import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.concurrent.TimeUnit;
@Configuration
public class CaffeineConfig {
@Bean
public CacheManager caffeineCacheManager() {
CaffeineCacheManager manager = new CaffeineCacheManager();
manager.setCaffeine(Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(30, TimeUnit.SECONDS)
.recordStats());
return manager;
}
}
注意:项目里若同时有 Redis CacheManager 和 Caffeine CacheManager,需要用 @Primary 指定默认,或在 @Cacheable(cacheManager = "caffeineCacheManager") 显式指定。
五、手写多级缓存服务
把第43天的订单详情缓存升级为 L1(Caffeine)+ L2(Redis):
java
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
@Service
public class OrderDetailMultiCacheService {
private static final String KEY_PREFIX = "order:detail:";
private static final long REDIS_TTL = 3600;
private final LocalCache localCache;
private final StringRedisTemplate redis;
private final OrderRepository orderRepository;
private final ObjectMapper objectMapper;
public OrderDetailMultiCacheService(LocalCache localCache,
StringRedisTemplate redis,
OrderRepository orderRepository,
ObjectMapper objectMapper) {
this.localCache = localCache;
this.redis = redis;
this.orderRepository = orderRepository;
this.objectMapper = objectMapper;
}
public OrderDetailDTO getById(Long orderId) {
String key = KEY_PREFIX + orderId;
Object local = localCache.getIfPresent(key);
if (local != null) {
return (OrderDetailDTO) local;
}
String cached = redis.opsForValue().get(key);
if (cached != null) {
OrderDetailDTO dto = fromJson(cached);
localCache.put(key, dto);
return dto;
}
OrderEntity entity = orderRepository.getById(orderId);
if (entity == null) {
return null;
}
OrderDetailDTO dto = toDetail(entity);
redis.opsForValue().set(key, toJson(dto), REDIS_TTL, TimeUnit.SECONDS);
localCache.put(key, dto);
return dto;
}
public void invalidate(Long orderId) {
String key = KEY_PREFIX + orderId;
redis.delete(key);
localCache.invalidate(key);
}
// toDetail / toJson / fromJson 同第43天
}
5.1 本地缓存失效的难点
写操作时,当前实例 localCache.invalidate(key) 只清了 自己 的本地缓存,其他实例还是旧值。
解决思路:
- TTL 设短(如 30 秒),容忍短暂不一致。
- 用 Redis 发布订阅 广播失效消息,各实例收到后清本地缓存。
- 用消息队列(如 RocketMQ、Kafka)广播。
5.2 Redis Pub/Sub 广播失效(思路)
java
// 写操作后发布失效消息
redis.convertAndSend("cache:invalidate", "order:detail:" + orderId);
java
// 各实例订阅,收到后清本地缓存
@Component
public class CacheInvalidateListener implements MessageListener {
private final LocalCache localCache;
public CacheInvalidateListener(LocalCache localCache) {
this.localCache = localCache;
}
@Override
public void onMessage(Message message, byte[] pattern) {
String key = new String(message.getBody());
localCache.invalidate(key);
}
}
第44天理解机制即可,生产可用 Redisson 的 RMapCache 或专门的多级缓存框架。
六、Redis 分布式锁
6.1 为什么需要分布式锁
多个应用实例(或多线程跨实例)要保证 同一时刻只有一个执行:
- 定时任务防重复执行。
- 缓存击穿时只让一个实例查库。
- 同一订单的并发操作串行化。
- 防止重复下单、重复发券。
单机的 synchronized、ReentrantLock 只在 单 JVM 内有效,多实例无效,必须用分布式锁。
6.2 错误实现一:只 SETNX 不设过期
java
// 反例:拿锁后宕机,锁永不释放,死锁
redis.opsForValue().setIfAbsent("lock:order:1", "1");
6.3 错误实现二:SETNX 和 EXPIRE 分两步
java
// 反例:两条命令非原子,setIfAbsent 后宕机,expire 没执行,死锁
redis.opsForValue().setIfAbsent("lock:order:1", "1");
redis.expire("lock:order:1", 10, TimeUnit.SECONDS);
6.4 正确实现:SET NX EX 原子 + 唯一值
java
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.UUID;
@Component
public class RedisDistributedLock {
private final StringRedisTemplate redis;
public RedisDistributedLock(StringRedisTemplate redis) {
this.redis = redis;
}
public String tryLock(String lockKey, long expireSeconds) {
String token = UUID.randomUUID().toString();
Boolean success = redis.opsForValue()
.setIfAbsent(lockKey, token, Duration.ofSeconds(expireSeconds));
return Boolean.TRUE.equals(success) ? token : null;
}
public boolean unlock(String lockKey, String token) {
String script =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
Long result = redis.execute(
new org.springframework.data.redis.core.script.DefaultRedisScript<>(script, Long.class),
java.util.Collections.singletonList(lockKey),
token);
return result != null && result > 0;
}
}
要点:
- SET NX EX 必须原子 :
setIfAbsent(key, value, duration)一条命令完成。 - value 用唯一 token(UUID):释放锁时校验是不是自己的锁,避免删别人的。
- 释放锁用 Lua 脚本:判断 + 删除原子执行,否则"判断通过后、删除前"锁过期被别人拿走,会误删。
6.5 使用示例
java
@Service
public class OrderPayService {
private final RedisDistributedLock lock;
private final OrderRepository orderRepository;
public void payOrder(Long orderId) {
String lockKey = "lock:order:pay:" + orderId;
String token = lock.tryLock(lockKey, 10);
if (token == null) {
throw new IllegalStateException("操作太频繁,请稍后重试");
}
try {
// 业务:幂等校验 + 更新状态
OrderEntity order = orderRepository.getById(orderId);
if (!"CREATED".equals(order.getStatus())) {
throw new IllegalStateException("订单状态不允许支付");
}
order.setStatus("PAID");
orderRepository.updateById(order);
} finally {
lock.unlock(lockKey, token);
}
}
}
6.6 锁过期但业务没执行完怎么办
如果业务执行时间超过锁的过期时间,锁自动释放,别人拿到锁,出现并发。
解决方案:
- 估算并设置合理过期时间(业务最大耗时的 2 到 3 倍)。
- 用 看门狗(watchdog)自动续期,即下面的 Redisson。
七、Redisson 分布式锁(推荐生产使用)
7.1 依赖
xml
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.27.0</version>
</dependency>
7.2 使用
java
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
@Service
public class OrderPayRedissonService {
private final RedissonClient redissonClient;
private final OrderRepository orderRepository;
public OrderPayRedissonService(RedissonClient redissonClient,
OrderRepository orderRepository) {
this.redissonClient = redissonClient;
this.orderRepository = orderRepository;
}
public void payOrder(Long orderId) throws InterruptedException {
RLock lock = redissonClient.getLock("lock:order:pay:" + orderId);
boolean locked = lock.tryLock(5, 30, TimeUnit.SECONDS);
if (!locked) {
throw new IllegalStateException("操作太频繁,请稍后重试");
}
try {
OrderEntity order = orderRepository.getById(orderId);
if (!"CREATED".equals(order.getStatus())) {
throw new IllegalStateException("订单状态不允许支付");
}
order.setStatus("PAID");
orderRepository.updateById(order);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
7.3 Redisson 的优势
- 看门狗自动续期:默认锁 30 秒,业务没执行完会自动续期,避免提前释放。
tryLock(waitTime, leaseTime, unit):waitTime 内等待获取锁,leaseTime 为持有时间。- 支持 可重入锁、公平锁、读写锁、联锁、红锁。
- 自动处理唯一标识与 Lua 释放,无需手写脚本。
注意:tryLock 指定了 leaseTime 时,看门狗 不 自动续期;不传 leaseTime 才启用看门狗。按业务选择。
八、用分布式锁修正缓存击穿
第43天的击穿互斥锁可用正确的分布式锁重写:
java
public OrderDetailDTO getByIdSafe(Long orderId) {
String key = "order:detail:" + orderId;
String cached = redis.opsForValue().get(key);
if (cached != null) {
return fromJson(cached);
}
String lockKey = "lock:rebuild:order:detail:" + orderId;
String token = lock.tryLock(lockKey, 10);
if (token == null) {
// 没拿到锁,短暂等待后重试读缓存
sleep(50);
cached = redis.opsForValue().get(key);
return cached != null ? fromJson(cached) : null;
}
try {
cached = redis.opsForValue().get(key);
if (cached != null) {
return fromJson(cached);
}
OrderEntity entity = orderRepository.getById(orderId);
if (entity == null) {
redis.opsForValue().set(key, "NULL", 300, TimeUnit.SECONDS);
return null;
}
OrderDetailDTO dto = toDetail(entity);
redis.opsForValue().set(key, toJson(dto), 3600, TimeUnit.SECONDS);
return dto;
} finally {
lock.unlock(lockKey, token);
}
}
九、热点 Key 处理
9.1 什么是热点 Key
某个 key 被极高频访问(如爆款商品、明星动态),即使有 Redis,单个 key 也可能成为瓶颈,甚至打满某个 Redis 节点。
9.2 应对方案
方案一:本地缓存兜底
热点数据放 Caffeine,请求大部分在本地命中,不走 Redis。
方案二:逻辑过期,避免击穿
不给热点 key 设真实 TTL,而是把 逻辑过期时间 存进 value,后台异步刷新:
java
public class CacheData<T> {
private T data;
private long logicalExpireAt; // 逻辑过期时间戳
// getter setter
}
读取时:
text
1. 命中且未逻辑过期 -> 直接返回
2. 命中但逻辑过期 -> 返回旧值,同时异步起线程刷新缓存
3. 异步刷新加分布式锁,保证只有一个线程查库
这样请求永远不会因为 key 过期而阻塞查库,适合极热数据。
方案三:热点 key 拆分
把一个热点 key 拆成多个副本 product:3001:0 ... product:3001:9,随机读其中一个,分散到不同节点。
9.3 热点发现
- Redis 4.0+ 的
redis-cli --hotkeys。 - 监控 QPS、
INFO commandstats。 - 业务埋点统计高频访问对象。
十、库存预扣(高并发写实战)
10.1 问题回顾
第42天用数据库原子 SQL UPDATE ... WHERE stock >= ? 解决了超卖,但秒杀场景下:
- 瞬时上万请求都打到数据库,数据库压力大。
- 行锁竞争激烈,响应慢。
思路:把库存放 Redis,在 Redis 里原子扣减,异步再落库。
10.2 Redis 预扣库存(Lua 保证原子)
java
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;
import java.util.Collections;
@Service
public class StockPreDeductService {
private final StringRedisTemplate redis;
private static final String DEDUCT_SCRIPT =
"local stock = tonumber(redis.call('get', KEYS[1])) " +
"if stock == nil then return -1 end " +
"if stock < tonumber(ARGV[1]) then return 0 end " +
"redis.call('decrby', KEYS[1], ARGV[1]) " +
"return 1";
public StockPreDeductService(StringRedisTemplate redis) {
this.redis = redis;
}
public void initStock(Long productId, int stock) {
redis.opsForValue().set("product:stock:" + productId, String.valueOf(stock));
}
/**
* @return 1 成功,0 库存不足,-1 库存未初始化
*/
public long preDeduct(Long productId, int quantity) {
DefaultRedisScript<Long> script = new DefaultRedisScript<>(DEDUCT_SCRIPT, Long.class);
return redis.execute(script,
Collections.singletonList("product:stock:" + productId),
String.valueOf(quantity));
}
}
为什么用 Lua:
- "判断库存是否足够 + 扣减"必须 原子,否则并发下仍会超卖。
- Lua 脚本在 Redis 中单线程执行,天然原子。
10.3 完整下单流程
java
@Service
public class SeckillService {
private final StockPreDeductService stockService;
private final OrderMessageProducer producer;
public SeckillService(StockPreDeductService stockService,
OrderMessageProducer producer) {
this.stockService = stockService;
this.producer = producer;
}
public void seckill(Long userId, Long productId, int quantity) {
long result = stockService.preDeduct(productId, quantity);
if (result == -1) {
throw new IllegalStateException("活动未开始");
}
if (result == 0) {
throw new IllegalStateException("库存不足");
}
// 预扣成功,发消息异步创建订单与落库
producer.sendCreateOrder(new CreateOrderMessage(userId, productId, quantity));
}
}
要点:
- Redis 预扣成功后,异步 通过消息队列创建订单、扣减数据库库存。
- 数据库最终库存以 Redis 预扣结果为准,需对账校验。
- 若异步落库失败,需 回补 Redis 库存(incrby)并通知用户。
10.4 一致性与对账
- Redis 与数据库可能短暂不一致,定时任务对账。
- 防止超卖优先(Redis 原子扣减),少卖可后续补偿。
- 防重复下单用第38天 Idempotency-Key 或用户 + 商品维度去重 Set。
十一、与前面课程的整合
| 天数 | 知识点 | 第44天如何衔接 |
|---|---|---|
| 第38天 | 幂等 Idempotency-Key、限流 | 秒杀防重复下单、限流挡住超量请求 |
| 第42天 | 乐观锁、悲观锁、原子 SQL | 普通写用 DB 锁,秒杀写用 Redis 预扣 |
| 第43天 | Cache-Aside、击穿 | 本节用分布式锁修正击穿、加 L1 本地缓存 |
| 第44天 | 多级缓存、分布式锁、预扣 | 高并发读写的完整方案 |
11.1 选型决策表
| 场景 | 推荐方案 |
|---|---|
| 普通订单状态更新 | 数据库乐观锁(第42天) |
| 防定时任务重复执行 | Redis/Redisson 分布式锁 |
| 缓存击穿保护 | 分布式锁 + 逻辑过期 |
| 极热只读数据 | Caffeine 本地缓存 + Redis |
| 秒杀/抢购库存 | Redis Lua 预扣 + 异步落库 |
十二、实战任务
任务 1:Caffeine 本地缓存(约 1 小时)
- 引入 Caffeine,写
LocalCache,maximumSize=10000,expireAfterWrite=30s。 - 把第43天订单详情改造成 L1 + L2 多级缓存。
- 打印
recordStats命中率,多次请求观察本地命中。
任务 2:手写 Redis 分布式锁(约 1.5 小时)
- 实现
RedisDistributedLock:tryLock(SET NX EX + UUID),unlock(Lua 校验删除)。 - 用两个线程并发对同一 orderId 调用
payOrder,验证只有一个成功。 - 故意让业务执行时间超过锁过期时间,观察问题,理解为何需要看门狗。
任务 3:Redisson(约 1 小时)
- 引入 redisson-spring-boot-starter。
- 用
RLock重写支付加锁,体验看门狗自动续期。 - 对比手写锁与 Redisson 的代码量与可靠性。
任务 4:库存预扣(约 1.5 小时)
- 写 Lua 脚本实现 Redis 原子扣减库存。
initStock初始化某商品库存为 100。- 用 200 个并发请求
seckill,验证:成功数恰好等于 100,无超卖。 - (进阶)成功后打日志模拟发消息,异步落库先不实现也可。
任务 5:自检清单
- 多级缓存读取顺序是什么,本地缓存的最大风险是什么。
- 为什么分布式锁 value 要用唯一 token。
- 为什么释放锁要用 Lua 脚本。
- Redisson 看门狗解决了什么问题。
- 库存预扣为什么必须用 Lua 原子执行。
- 热点 key 有哪些应对方案。
十三、常见错误与避坑
13.1 分布式锁
- 不要
setIfAbsent不设过期时间,会死锁。 - 不要用固定 value,会误删别人的锁。
- 不要
if get==token then del分两步(非原子),用 Lua。 - 释放锁前判断
isHeldByCurrentThread(Redisson)。
13.2 本地缓存
- 不要缓存频繁变更、强一致的数据。
- TTL 要短,或配合广播失效。
- 注意 JVM 内存,
maximumSize必须设置,避免 OOM。
13.3 库存预扣
- Redis 与数据库要对账,防止异步落库丢失。
- 异步失败要回补 Redis 库存。
- 预扣成功不等于下单成功,前端文案要准确。
13.4 锁粒度
- 锁 key 要精确到具体资源(如
lock:order:pay:{id}),不要用全局大锁,否则并发退化为串行。