Java学习第44天 - 本地二级缓存 Caffeine、Redis 分布式锁与热点 Key / 库存预扣

一、学习目标

  • 理解 本地缓存 + 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 为什么需要分布式锁

多个应用实例(或多线程跨实例)要保证 同一时刻只有一个执行

  • 定时任务防重复执行。
  • 缓存击穿时只让一个实例查库。
  • 同一订单的并发操作串行化。
  • 防止重复下单、重复发券。

单机的 synchronizedReentrantLock 只在 单 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 小时)

  1. 引入 Caffeine,写 LocalCachemaximumSize=10000expireAfterWrite=30s
  2. 把第43天订单详情改造成 L1 + L2 多级缓存。
  3. 打印 recordStats 命中率,多次请求观察本地命中。

任务 2:手写 Redis 分布式锁(约 1.5 小时)

  1. 实现 RedisDistributedLocktryLock(SET NX EX + UUID),unlock(Lua 校验删除)。
  2. 用两个线程并发对同一 orderId 调用 payOrder,验证只有一个成功。
  3. 故意让业务执行时间超过锁过期时间,观察问题,理解为何需要看门狗。

任务 3:Redisson(约 1 小时)

  1. 引入 redisson-spring-boot-starter。
  2. RLock 重写支付加锁,体验看门狗自动续期。
  3. 对比手写锁与 Redisson 的代码量与可靠性。

任务 4:库存预扣(约 1.5 小时)

  1. 写 Lua 脚本实现 Redis 原子扣减库存。
  2. initStock 初始化某商品库存为 100。
  3. 用 200 个并发请求 seckill,验证:成功数恰好等于 100,无超卖。
  4. (进阶)成功后打日志模拟发消息,异步落库先不实现也可。

任务 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}),不要用全局大锁,否则并发退化为串行。
相关推荐
浮游本尊1 小时前
Java学习第43天 - Redis 缓存基础、Cache-Aside 模式与缓存一致性
后端
云技纵横1 小时前
线程池 OOM 实战:无界队列配错,5 万个任务撑爆 JVM
后端
渣波1 小时前
拒绝 SQL 焦虑!手把手带你用 NestJS + Prisma + DTO 写出“防弹”级后端代码
javascript·数据库·后端
用户61541317281272 小时前
# 写接口自动化时,我在断言上栽过的两个跟头
后端
SamDeepThinking2 小时前
Java微服务练习方式
java·后端·微服务
IT_陈寒2 小时前
Vue的响应式真把我坑惨了,原来问题出在这
前端·人工智能·后端
codedx3 小时前
LangChain 和 LangGraph 构建的 Agent 项目模版
后端·langchain·agent
葫芦和十三3 小时前
图解 MongoDB 08|ESR 原则:复合索引的字段顺序怎么定
后端·mongodb·agent
葫芦和十三11 小时前
图解 MongoDB 07|索引类型:七种索引,七种访问形状
后端·mongodb·agent