同一用户并发提问的互斥控制
上一篇讲了两份数据的分工:t_agent_message保存页面上的对话记录,t_agent_state保存下一轮接着运行需要的框架状态。状态每轮整行读出,结束时整行写回,写回时没有版本号校验。
以比特严选的一次选购为例。用户在电脑上打开一个已有会话,发了一句:
想买台MacBook,平时写代码,偶尔剪一下视频,预算一万五,帮我看看选哪款、什么配置合适。
Agent还在查商品配置和价格,用户又在手机上打开同一个会话,补了一句:
对了,我每天都得背着它通勤,太重的就不考虑了。
两个请求带着相同的conversationId到达服务端。开两个标签页也是一样的情况。
本篇要解决的是:同一会话能不能同时处理两次提问,一个用户能同时运行多少个会话,以及连接断开后,后台那一轮怎么结束、锁怎么释放。
同一个会话为什么不能同时跑两轮
1. 两轮可能覆盖彼此新增的状态
本文把一次请求触发的完整Agent执行称为一轮。一轮内部可以多次调用模型和工具;用户确认操作后发起的继续执行,也算新的一轮。
如果没有会话互斥,两轮同时运行时,都可能在对方保存之前读到同一份旧状态,再各自追加消息。AgentStateMapper.upsert()会覆盖整份payload,不会合并两轮各自新增的内容。下面按代码推演一次可能的执行顺序。
这里被覆盖的是t_agent_state中供Agent下一轮加载的状态。页面上的聊天记录存在另一张表t_agent_message中,每条问题和回复单独保存,所以可能仍然完整。
这就会出现:用户在页面上明明能看到电脑那一轮的选购问题和回答,Agent下一轮读取状态时,却已经找不到这部分内容。所以同一会话的下一轮,必须等上一轮把状态写回之后才能开始。
2. 前端的限制只在当前页面有效
同一页面上连续点两次发送,只要前一次请求还没结束,前端就不会发送第二个请求。agentChatStore.ts在准备发送时就把当前会话的isStreaming设为true,不用等第一段回复到达。
sendMessage()开头就检查这个状态。下文源码中的解释性行注释为本文添加。
sendMessage: async (question) => {
const trimmed = question.trim();
if (!trimmed) return;
// 当前会话的请求尚未结束时,不发第二个请求
if (get().isStreaming) return;
// 省略待确认检查与发送逻辑
},
这个状态存在当前页面里。另一个标签页、另一台设备读不到它,直接调用接口更不会经过这个判断,所以服务端还要再做互斥控制。
按会话加锁,再限制每个用户的会话数
1. 三种加锁范围的区别
不同会话的状态分别保存,可以同时运行。但一个用户启动太多会话,又会同时占用大量模型调用和工具执行资源。因此,还需要限制单用户的并发会话数。
三种方案都能阻止同一会话同时处理两次提问。区别在于:一个用户已经有会话在运行时,还能不能在其他空闲会话中发起提问。按本项目拿不到锁就拒绝请求的处理方式,对比如下:
| 方案 | 一个用户最多同时运行几个会话 | 在其他空闲会话中提问时 |
|---|---|---|
| 按用户互斥 | 1个 | 只要已有会话在运行,就拒绝新请求 |
| 只按会话互斥 | 不限制总数 | 可以继续启动其他会话,用户占用的资源可能不断增加 |
| 会话互斥,再限制用户并发数 | 配置的上限,项目默认为5个 | 未满5个时允许启动,达到5个时拒绝新请求 |
Ragent用的是最后一种。application.yaml里的相关配置如下,其余agent配置省略:
agent:
# 省略模型、MCP、迭代与超时配置
max-concurrent-runs-per-user: 5
# 省略记忆配置
默认一个用户最多同时运行5个会话,历史会话总数不受这个配置限制。
2. 会话锁和名额锁
这两项限制由AgentRunGate负责,分别用两种锁实现:
| 锁 | Redis key | 数量 | 限制什么 |
|---|---|---|---|
| 会话锁 | ragent:agent:run-lock:{userId}:{conversationId} | 每个会话一把 | 同一会话同时只能有一轮运行,状态不会被两轮互相覆盖 |
| 名额锁 | ragent:agent:run-permit:{userId}:{slot} | 每个用户若干把,slot从0到上限减1,默认是0~4 | 每一轮占用其中任意一把,全部被占时拒绝,限制单用户同时运行的会话数 |
一轮开始时先取得会话锁,再取得一把名额锁,两把都拿到才启动Agent,这一轮结束后一起归还。任何一把拿不到,请求直接被拒绝,不排队等待。
名额为什么用几把锁表示,而不是存一个运行数量?计数器要在开始时加一、结束时减一,持有任务的进程如果中途退出,就没有人执行减一,这个名额会一直被占着。每把名额锁都有自己的过期时间,进程退出后会自动过期,名额就能被其他运行使用。计数器也可以另加恢复机制,但当前实现选择复用分布式锁的过期能力。
3. 为什么用Redisson的RLock
同一会话的两个请求可能落在不同服务节点上。Java的本地锁只在一个JVM里有效,所以两种锁都放在各节点共用的Redis里,通过Redisson客户端的RLock加锁和解锁。
先看一轮运行期间,这两把锁在Redis里实际是什么样。下面是在一个临时Redis上,用AgentRunGate.acquire()为用户2001的一个会话加锁后,用redis-cli读出来的内容:
# 会话锁
> TYPE ragent:agent:run-lock:2001:2104420977583990001
hash
> HGETALL ragent:agent:run-lock:2001:2104420977583990001
1) "03afcac9-ba8a-42f9-80db-5826c39ee30e:2104420977583996928"
2) "1"
> PTTL ragent:agent:run-lock:2001:2104420977583990001
(integer) 29596
# 名额锁,占的是 0 号
> HGETALL ragent:agent:run-permit:2001:0
1) "03afcac9-ba8a-42f9-80db-5826c39ee30e:2104420977583996928"
2) "1"
> PTTL ragent:agent:run-permit:2001:0
(integer) 29587
每把锁是一个Hash,里面只有一个字段:
- 字段名是持有者,由两段用冒号拼成。前一段
03afcac9-...是Redisson客户端ID:每个服务进程创建RedissonClient时随机生成的一个UUID,同一个进程里所有锁共用它,用来区分锁是哪个服务节点加的。后一段2104420977583996928是持有者编号,默认取当前线程ID;不同节点上的线程ID可能重复,所以要拼上客户端ID才唯一。Ragent在这里传的不是线程ID,原因下一节讲。 - 字段值
1是这个持有者的加锁次数,同一持有者再次加锁会加一,解锁减一,减到0才删除这个key。 PTTL是key剩余的有效时间,单位毫秒。这段有效时间称为租约,默认30秒。
下述为任意请求示例效果:

这一轮结束、释放锁之后,这两个key都从Redis中删除了。Redisson 4.6.1加锁与解锁源码
Ragent用到它的三项能力,每一项对应Agent运行的一个特点:
| Agent运行的特点 | 用到的RLock能力 |
|---|---|
| 加锁在请求线程,释放在收尾线程;上一轮迟到的释放不能删掉下一轮的锁 | 加锁、解锁都能显式传入持有者编号;解锁时先校验持有者再删除,两步在Redis里用一个Lua脚本原子执行 |
| 一轮跑多久事先不知道,可能只查一次商品,也可能连续调用多个工具 | 不指定固定租期时启用看门狗,持锁进程活着就定期续期 |
| 持锁的节点宕机后,没有人执行释放 | 进程退出后不再续期,锁在租约到期后被Redis删除 |
持有者编号具体用什么,在后面讲加锁和释放时展开;续期和过期的细节,在讲节点宕机时展开。
4. 先加锁,再检查待确认
请求进来后,AgentChatServiceImpl.streamChat()第一步就加锁,之后才检查这个会话有没有等待确认的操作:
String userId = UserContext.getUserId();
// 新会话先生成 ID,已有会话沿用请求中的 ID
String actualConversationId = StrUtil.isBlank(conversationId)
? IdUtil.getSnowflakeNextIdStr()
: conversationId;
String taskId = IdUtil.getSnowflakeNextIdStr();
// 先取得会话锁和用户名额
Runnable releaseGate = runGate.acquire(userId, taskId, actualConversationId);
try {
// 已有会话再检查归属和待确认状态
if (StrUtil.isNotBlank(conversationId)
&& conversationService.hasPendingConfirm(actualConversationId, userId)) {
throw new ClientException("上一步操作还在等你确认,请先确认或取消");
}
startRun(question, userId, actualConversationId, taskId, emitter, releaseGate);
} catch (RuntimeException | Error e) {
// 启动失败,归还已经取得的锁和名额
cleanupFailedStart(taskId, releaseGate);
throw e;
}
会话在等用户确认时,弹出确认卡的那一轮已经结束,会话锁也释放了。这时用户再发新问题,锁拦不住,要靠hasPendingConfirm()去消息表查有没有待确认的卡片。
这项检查要放在加锁之后。确认卡是上一轮收尾时才写进消息表的,如果先检查、后加锁,可能出现下面的顺序:
- 新请求检查消息表,上一轮还没收尾,查到没有待确认;
- 上一轮收尾,写入确认卡,释放会话锁;
- 新请求拿到锁开始运行,新问题被送进了一个正在等确认的会话。
先加锁就不会这样:上一轮没结束,新请求拿不到锁;能拿到锁,说明上一轮已经收尾,确认卡已经写进消息表,检查一定能查到。
用户点确认或拒绝走的是confirmPendingTool(),入口申请的也是这把会话锁,所以确认后继续执行的那一轮,同样不会和新提问同时运行。