一:项目背景介绍(可以直接跳到第二节)
目前博主是自己搭建的一个智能体项目,并且自己搭建了一套记忆机制(包括全局记忆,滑动窗口实现短期记忆,长期记忆与记忆摘要,这一块具体构建在我们记忆篇笔记中会实现),博主搭建完了,但是细品一下,感觉差点味道,对了就是记忆评估制度,我们创建了一个observe表,设计多个指标,将记忆检索的情况记在表中,来评价记忆检索质量。后来博主又细品一下,缺点东西----对了,需要在前端搭建一个评测界面,来可视化我们的观测表,经过我们结合ai,熬夜爆肝终于实现了,具体的评测页面以及实现,我们都放在记忆笔记篇哪里了,或许看到这里了,大伙还不知道那里和redis有关了,博主其实想灌输的是:项目不是一下就做成的,需要先搭建框架,然后再慢慢优化,填充细节,在这里我其实是想分享一下,我们这里为什么会想到用redis优化,以及具体方案选取与落地,以及后续优化点
这里是博主具体评测界面的其中一项,他的目标是,根据我们观测表,结合数字公式计算出我们具体记忆的一些风险指数,其中我们引入AI来根据我们的评分,给出具体的判断原因以及建议,算是一个小型的基于AI的记忆风险评估报告机制,说到这里,博主想灌输各位的是,项目是先在我们目前的框架上一点一点优化的,就拿我们的这个例子,博主做完这套,发现这个机制并不友好,用户每次不小心点击评测页,他都会发送http请求来获取最新报告,但是因为报告的建议和判断指标会调用ai,实际返回响应会很慢,如果用户每次点击都这样来一套,并不友好,所以有没有一种办法来完美解决呢
说到这里,就引入我们的主题了,博主通过与ai探讨思路,通过修改多个版本建议,最后决定增加“生成实时报告功能(图片上展示了)”,用户点击,我们就发送请求生成一份最新的风险报告,这时候会比较慢,我们页面提醒用户“需要等待几分钟”来提醒用户,报告生成后返回给用户,同时存入redis,下次用户如果不需要查看最新报告,直接读取redis中的数据即可,完美解决了我们的问题
现在大致方案出来了,但是还需要考虑多线程问题,就是说如果用户多次点击或者多个用户一起点击,发送多个请求而出现的多个线程一起调用redis,导致数据错乱情况,我们项目实现了以下两个兜底策略
- 前端用户每次点击生成最新报告,点击后用户不可再点击,直到数据返回(防止一个用户多次点击)
- 后端考虑到我们常用的redis策略,比如逻辑过期和加锁,我们选择加锁,第一是用户本意就是获取最新数据,不能返回旧的,第二就是项目体量很小,本来就不支持很多人同时访问,减少了加锁的线程阻塞问题,综合下来我们选择加锁
那么到底怎么加锁呢,因为博主之前看的黑马点评,跟着做了自定义分布式锁,比如setnx+UUID+Lua脚本,实现下来挺麻烦的,所以我们放弃了这个方案,转向用redisson,直接复用别人已经提供的成熟的分布式锁实现。
目前完整的思路已经定下来了,现在进入下一章详细解释redisson锁以及我们项目怎么用的
二:redisson的简要讲解与使用
Redisson 是一个基于 Redis 的 Java 高级客户端与分布式框架,也是 Redis 官方推荐的 Java 客户端之一。它不仅封装了 Redis 的基础操作,更提供了一整套开箱即用的分布式数据结构与服务,极大简化了分布式系统的开发。
1、核心定位
- 本质:Java 驻内存数据网格(In-Memory Data Grid)
- 底层:基于高性能 Netty NIO 框架 构建,支持异步、非阻塞通信
- 定位:将 Redis 从单纯的 K-V 缓存,升级为分布式应用的协调中枢
2、核心功能
Redisson 提供了 50 多种分布式对象和服务,最常用的包括:
(1). 分布式锁与同步器(最核心优势)
- RLock (可重入锁) :支持可重入、公平锁、读写锁、联锁、红锁(RedLock)
- 看门狗 (Watchdog) 机制:自动为未完成业务的锁续期(默认 30 秒),防止锁提前过期导致并发问题
- 其他同步器:
RSemaphore(信号量)、RCountDownLatch(倒计时器)、RRateLimiter(限流)
(2). 分布式集合
- 映射:
RMap(支持本地缓存、过期、事件监听) - 列表:
RList、队列:RQueue、RDeque(双端队列) - 集合:
RSet、RScoredSortedSet(有序集合)
(3). 分布式对象
- 通用对象桶
RBucket、二进制流、地理空间对象 - 布隆过滤器
RBloomFilter:解决缓存穿透问题 - 原子长整型
RAtomicLong、RAtomicDouble
(4). 其他高级功能
- 分布式消息队列(
RTopic、RQueue) - 分布式调度任务(
RScheduledExecutorService) - 支持 Redis 所有部署模式:单机、主从、哨兵、集群
3、核心原理(以分布式锁为例)
-
加锁:通过 Lua 脚本 原子执行
HSET+PEXPIRE-- 伪代码 if (锁不存在) then HSET 锁名 线程ID 1 PEXPIRE 锁名 超时时间 else if (当前线程持有锁) then HINCRBY 锁名 线程ID 1 PEXPIRE 锁名 超时时间 else 返回锁剩余时间 end -
Watchdog:后台定时任务(默认 10 秒 / 次),自动重置锁过期时间
-
解锁:
HDEL删除线程 ID,当重入次数为 0 时删除整个锁
4、具体使用
(1)、引入依赖
<!-- SpringBoot 整合 Redisson 依赖 -->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.23.5</version>
</dependency>
(2)、配置 Redis 连接
方式 1:yml 最简配置
spring:
redis:
host: 127.0.0.1
port: 6379
password: # 无密码留空
database: 0
Redisson 会自动读取SpringRedis 配置,无需额外写配置类。
(3)、核心常用场景代码
直接注入 RedissonClient 即可使用
@Autowired
private RedissonClient redissonClient;
5. 分布式可重入锁(最常用)
自带看门狗自动续期,解决锁超时、死锁问题
public void business() {
// 1. 获取锁对象
RLock lock = redissonClient.getLock("order:lock:1001");
try {
// 2. 加锁(无参:一直等待,看门狗默认30s超时、10s续期)
lock.lock();
// 业务逻辑
System.out.println("执行业务操作");
} finally {
// 3. 必须释放锁
if (lock.isLocked() && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
一、常用加锁方式区别
// 1. 阻塞等待锁,自动续期(推荐业务使用)
lock.lock();
// 2. 尝试获取锁,最多等待3秒,上锁后10秒自动过期
boolean tryLock = lock.tryLock(3, 10, TimeUnit.SECONDS);
注意,我们只可需要注意一下他的加锁方式,如果未指定锁持有时间,就走看门狗机制,自动续期锁,如果指定了,那么就会到期自动释放锁,防止死锁
二、分两种加锁方式,核心区别
1. 无参上锁(推荐,走看门狗)
// 无参,不指定过期时间
lock.lock();
-
Redisson 默认给锁设置30s 过期时间
-
后台开启 看门狗定时任务:每 10 秒 检查一次
- 如果当前线程还持有锁、业务没跑完 → 自动续期重置为 30s
- 如果服务宕机 / 线程挂了 → 续期停止,30s 后锁自动释放,避免死锁
2. 带过期时间上锁(关闭看门狗)
// 手动指定 leaseTime 过期时间
lock.lock(15, TimeUnit.SECONDS);
- 直接关闭看门狗,完全不会自动续期
- 锁到点强制过期释放
- 风险:业务没跑完、锁到期自动释放 → 并发安全问题
三、tryLock 同理
// 1. 不指定过期时间 → 看门狗生效
lock.tryLock();
// 2. 指定 leaseTime → 看门狗失效
lock.tryLock(long waitTime, long leaseTime, TimeUnit unit);
6. 读写锁(读多写少场景)
读读共享、读写互斥、写写互斥
RReadWriteLock rwLock = redissonClient.getReadWriteLock("rw:lock:goods");
// 读锁
RLock readLock = rwLock.readLock();
// 写锁
RLock writeLock = rwLock.writeLock();
7、联锁 MultiLock(多把锁捆绑)
一次性锁住多个 key,要么全部加锁成功,要么全都失败,防止分段锁死锁。
获取方式
RLock lock1 = redissonClient.getLock("lock:1");
RLock lock2 = redissonClient.getLock("lock:2");
// 组合成联锁
RLock multiLock = redissonClient.getMultiLock(lock1, lock2);
8、红锁 RedLock(集群多节点加锁)
适用于 Redis 多实例部署,防止单节点故障锁失效。
获取方式
RLock lockA = redissonClient1.getLock("lock:red");
RLock lockB = redissonClient2.getLock("lock:red");
RLock lockC = redissonClient3.getLock("lock:red");
// 红锁:多数节点加锁成功才算上锁
RLock redLock = redissonClient.getRedLock(lockA, lockB, lockC);
9、统一加 / 解锁模板(所有 RLock 通用)
RLock lock = xxx; // 上面任意一种锁
try {
lock.lock();
// 业务代码
} finally {
if (lock.isLocked() && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
10.再来解释以下redisson是怎么操作redis的,有没有一种统一API操作redis
Redisson 不只是分布式锁,它能完整操作 Redis 所有数据类型,用法像 Java 集合,简单好记。只要注入:
@Autowired
private RedissonClient redissonClient;
注意我们配置redisclient时就已经注入redis的地址啥的,所以通过redisclient是可以操作我们的redis的
(1)、通用 KV 字符串(String)
// 1. 获取操作对象
RBucket<String> bucket = redissonClient.getBucket("user:name");
// 2. 存
bucket.set("张三");
// 带过期时间
bucket.set("李四", 60, TimeUnit.SECONDS);
// 3. 查
String name = bucket.get();
// 4. 删除
bucket.delete();
(2)、Hash 结构(对应 Redis Hash)
RMap<String, Object> map = redissonClient.getMap("user:1001");
// 存
map.put("age", 20);
map.put("sex", "男");
// 取
Integer age = (Integer) map.get("age");
// 原子累加
map.addAndGet("score", 5);
(3)、List 列表
RList<String> list = redissonClient.getList("msg:list");
list.add("消息1");
list.add("消息2");
// 查全部
List<String> all = list.readAll();
(4)、Set 集合
RSet<String> set = redissonClient.getSet("tag:list");
set.add("java");
set.add("redis");
// 判断是否存在
boolean has = set.contains("java");
(5)、ZSet 有序集合
RScoredSortedSet<String> zset = redissonClient.getScoredSortedSet("rank");
// 元素+分数
zset.add(99.5, "小明");
zset.add(98.0, "小红");
(6)、全局原子数字(计数器)
RAtomicLong count = redissonClient.getAtomicLong("pv:count");
count.incrementAndGet(); // +1
count.addAndGet(10); // +10
long num = count.get();
(7)、直接执行原生 Redis 命令(兜底)
有些特殊命令 Redisson 没封装,可以原生调用:
redissonClient.getRedisCommands().set("key","val");
String val = redissonClient.getRedisCommands().get("key");
(8)、重点:和 SpringRedisTemplate 区别
-
RedisTemplate
- 贴近 Redis 原生命令
- 序列化要自己配、容易乱码
- 只有基础缓存能力,没有分布式锁 / 限流 / 队列
-
Redisson
-
以Java 对象 / 集合方式操作 Redis
-
自带序列化、开箱即用
-
自带:分布式锁、读写锁、红锁、限流、队列、延时队列
-
企业级微服务首选
-
介绍到这里,基本基础是介绍完了,就可用它来实现redis分布式锁了,但是在那之前,博主还需要解释一个点,也是博主的困惑点,
RLock lock = redissonClient.getLock(lockKey);我们调用它,他就会在我们的redis中添加锁标识吗
核心答案
1. 只执行这一行代码:
RLock lock = redissonClient.getLock(lockKey);
不会往 Redis 存任何数据 不会加锁、无任何网络请求、无 Redis 写入
分步拆解全过程
第一步:只是「创建锁对象」
// 仅仅是本地Java创建一个RLock对象,单纯内存对象
RLock lock = redissonClient.getLock("lock:order");
- 只在 JVM 本地生成对象
- 不连 Redis、不发命令、不写 key
第二步:才是真正加锁、写入 Redis
lock.lock();
// 或
lock.tryLock();
执行这行才会发 Lua 脚本到 Redis:
- 在 Redis 中创建 Hash 类型的 key
- key = 你传入的
lockKey - hash field = 当前线程唯一 ID
- hash value = 重入次数
- 同时设置过期时间(无参就是默认 30s,开启看门狗)
你去 Redis 里就能查到这个 key,就是锁标识。
看 Redis 里锁真实结构
假设锁 key:lock:orderRedis 中实际存储:
key:lock:order
type:hash
field:uuid:threadId (唯一线程标识)
value:重入次数(1、2、3...可重入)
解锁的时
lock.unlock();
- 执行 Lua 脚本
- 重入次数 -1
- 次数减到 0 → 删除整个 lockKey
- Redis 锁标识消失
这是博主为了验证时截的图,我们这里调用redisson的lock方法,确实会加锁,维护一个Hash结构,这里的value表示的是锁重入次数,底层是通过state变量来计数的,符合我们项目中加的是可重复锁这个需求
三、具体项目中的使用流程
┌─────────────────────────────────────────────────────────────────┐
│ generateReportWithCache(userId, agentId, range, scope, refresh) │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 步骤1: 参数标准化和Key构建 │
│ - normalizedRange: 标准化时间范围 │
│ - scopeKey/scopeLabel: 范围标识 │
│ - reportCacheKey: 缓存Key (memory:evaluation:report:{hash}) │
│ - lockKey: 分布式锁Key (memory:evaluation:lock:{hash}) │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 步骤2: 首次缓存检查 │
│ IF !refresh THEN │
│ 读取缓存 → 命中? → 直接返回缓存报告 │
│ END IF │
└─────────────────────────────────────────────────────────────────┘
│
缓存未命中 ─┘
▼
┌─────────────────────────────────────────────────────────────────┐
│ 步骤3: 获取分布式锁 (Redisson) │
│ lock.tryLock(1ms, 15min) │
│ - 等待1毫秒获取锁 │
│ - 锁租约15分钟(防止死锁) │
└─────────────────────────────────────────────────────────────────┘
│
┌───────────────┴───────────────┐
▼ ▼
获取锁成功 获取锁失败
│ │
▼ ▼
┌─────────────────────┐ ┌─────────────────────────────────┐
│ 继续步骤5 │ │ 步骤4: 等待其他线程生成完成 │
└─────────────────────┘ │ waitForCachedReport(2分钟) │
│ - 轮询检查缓存是否就绪 │
│ - 命中? → 返回缓存报告 │
│ - 超时? → 抛出异常 │
└─────────────────────────────────┘
│
等待成功 ─┘
┌─────────────────────────────────────────────────────────────────┐
│ 步骤5: 双重检查缓存 │
│ IF !refresh THEN │
│ 再次读取缓存 → 命中? → 返回缓存报告 │
│ END IF │
└─────────────────────────────────────────────────────────────────┘
│
缓存仍不存在 ─┘
▼
┌─────────────────────────────────────────────────────────────────┐
│ 步骤6: 生成报告并写入缓存 │
│ 1. markProcessing(): 标记生成状态 │
│ 2. buildReport(): 生成评测报告(耗时操作,可能调用AI) │
│ 3. cacheReport(): 写入Redis缓存,设置TTL │
│ 4. 返回报告 │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 步骤7: 清理和释放锁 (finally块) │
│ 1. clearProcessing(): 清除生成状态标记 │
│ 2. lock.unlock(): 释放分布式锁 │
└─────────────────────────────────────────────────────────────────┘
这里博主就偷懒以下的,让ai生成的我们项目的流程图,因为博主实在懒的画流程图了,现在我来讲解一下其中的关键点
(1)步骤5的双重检查
获取有人会问,为什么需要双重检查,别的线程生成报告并且写入缓存,释放锁后,其他线程直接读取缓存不就行了吗
这里其实会有突发情况,我们项目是用户选择是否读取最新报告数据,如果用户选择读取缓存,但是缓存过期了,那就应该去生成最新报告数据并加入缓存,这里有个漏洞,线程1判断缓存为空,先判断锁是否被占有,判断锁未被占有后,获取锁成功,会去生成报告,写入缓存,线程2这时来了,他判断缓存为空(此时线程1缓存还未写完毕),尝试获取锁并获取成功(线程1刚好写入缓存并释放锁),好了,如果没有双重检查,线程1会再去生成一份报告,并写入缓存,而双重检查就是让线程2在获取锁时再去检查一遍缓存中有没有报告,防止报告重复生成
线程A: 获取锁成功 ──→ 双重检查 ──→ 未命中 ──→ 生成报告 ──→ 写入缓存
↑
线程B: 获取锁失败 ──→ 等待 ──────────────────────┘
↑
线程C: 获取锁失败 ──→ 等待 ──────────────────────┘
↓
线程B被唤醒: 获取锁成功 ──→ 双重检查 ──→ 【命中!直接返回】✅
(2)步骤4的轮询检查
还是拿上面线程1的例子,我们线程1持有锁的期间,其他线程就无法获取锁,按道理说会阻塞等待,但是我们这里面不是让他们一直获取锁,而是让他们去轮询检查缓存是否写入数据了,比如每个1s检查一次,并且设置最多轮询时间,比如最多10秒,也就是该线程最多轮询检查10次,如果均没有获取到缓存数据,那么就直接放弃,返回null,而不是一直阻塞,提醒用户“过一会再尝试”
线程A: 获取锁成功 ──→ 正在生成报告(耗时10秒)──→ 写入缓存
↑
线程B: 获取锁失败 ──→ 进入 waitForCachedReport
↓
每隔1秒轮询缓存 ──→ 等待线程A完成 ──→ 读取新缓存
(3)锁租期机制
就是引入了我们的redisson锁,设置了过期时间,避免长时间持有锁造成线程堵塞与死锁
说到这里,其实我们核心点就已经做完了,对于我们本身就不是高并发体系,并不需要做的很复杂,做成这样就够我们项目使用了,避免堆积其他高并发的技术栈,导致项目冗余
这里我们附上项目源码,仅供参考,这只是一部分
public MemoryEvaluationReportDTO generateReportWithCache(String userId,
String agentId,
String range,
String scope,
boolean refresh) {
// ========== 步骤1:参数标准化和Key构建 ==========
String normalizedRange = normalizeRange(range);
String scopeKey = normalizeScopeKey(scope, agentId);
String scopeLabel = buildScopeLabel(scope, agentId);
// 构建报告缓存Key:memory:evaluation:report:{hash}
String reportCacheKey = buildReportCacheKey(userId, agentId, normalizedRange, scopeKey);
// 构建分布式锁Key:memory:evaluation:lock:{hash}
String lockKey = buildLockKey(reportCacheKey);
// ========== 步骤2:首次缓存检查(从缓存中尝试读取报告,如果用户传递刷新,那么就调用ai生成报告,再加入缓存)==========
if (!refresh) {//非强制刷新模式下,优先读取缓存
MemoryEvaluationReportDTO cachedReport = readCachedReport(reportCacheKey);
if (cachedReport != null) {
log.info("[MemoryEvaluation] cache hit userId={}, agentId={}, range={}, scopeKey={}",
userId, agentId, normalizedRange, scopeKey);
return cachedReport; // 缓存命中,直接返回
}
}
// ========== 步骤3:获取分布式锁 ==========
RLock lock = redissonClient.getLock(lockKey);
boolean locked = false;
try {
// 尝试获取锁,等待1毫秒,锁租约15分钟,超过1秒未获取到锁,抛出异常,如果持有锁,最多持有15分钟,当时间自动释放锁
locked = lock.tryLock(1, LOCK_LEASE_MILLIS, TimeUnit.MILLISECONDS);
// ========== 步骤4:获取锁失败,说明其他线程正在生成报告,等待其他线程生成完成 ==========
if (!locked) {
// 等待缓存生成完成,最多等待2分钟
MemoryEvaluationReportDTO waitingReport = waitForCachedReport(reportCacheKey, CACHE_WAIT_MILLIS);
if (waitingReport != null) {
log.info("[MemoryEvaluation] cache ready after wait userId={}, agentId={}, range={}, scopeKey={}",
userId, agentId, normalizedRange, scopeKey);
return waitingReport; // 等待成功,返回缓存数据
}
// 等待超时,抛出异常
throw new IllegalStateException("评测报告正在生成中,请稍后重试");
}
// ========== 步骤5:获取锁成功,双重检查缓存 ==========
if (!refresh) {
MemoryEvaluationReportDTO cachedReport = readCachedReport(reportCacheKey);
if (cachedReport != null) {
log.info("[MemoryEvaluation] cache hit after lock userId={}, agentId={}, range={}, scopeKey={}",
userId, agentId, normalizedRange, scopeKey);
return cachedReport; // 其他线程已生成,直接返回
}
}
// ========== 步骤6:生成报告并写入缓存 ==========
// 标记生成状态,用于其他线程等待时检查
markProcessing(reportCacheKey, userId, agentId, normalizedRange, scopeKey);
// 构建评测报告(耗时操作,可能涉及AI调用)
MemoryEvaluationReportDTO report = buildReport(userId, agentId, normalizedRange, scopeLabel, refresh);
// 写入缓存,设置TTL
cacheReport(reportCacheKey, report, cacheTtl(normalizedRange));
log.info("[MemoryEvaluation] report generated and cached userId={}, agentId={}, range={}, scopeKey={}",
userId, agentId, normalizedRange, scopeKey);
return report;
} catch (InterruptedException e) {
// 线程被中断,恢复中断状态并抛出异常
Thread.currentThread().interrupt();
throw new IllegalStateException("评测报告生成被中断", e);
} finally {
// ========== 步骤7:清理和释放锁 ==========
clearProcessing(reportCacheKey); // 清除生成状态标记
if (locked && lock.isHeldByCurrentThread()) {
lock.unlock(); // 释放分布式锁
}
}
}
最后附上我们的redis存储的报告表的结构图,以助于大家理解
这只是一部分,整个的评测数据太多了,下面展示一下前端页面
这也只是部分展览图,详细看后续的记忆篇
四、后续的优化功能
当前我们只是实现了报告存入redis,但是有个问题是不好溯源,不好查询历史评测记录,做人工判断,统计啥的,所以后续需要增加一个历史评测表,放在Mysql,通过字段拆分,构建索引,避免将我们整个redis的内容塞在mysql,然后做好数据库与redis的主从一致性问题,比如常见的延迟双删,先更新数据库再删除缓存,加redisson读写锁,异步mq通知等等,后续有待优化