系列最后一讲。系统功能全齐了,问题是:它扛得住真实流量吗?Ollama 挂了会怎样? 这一讲先做并发安全审计,再上 P0-P3 四层保护,最后给完整部署方案。压测部分有个值得所有 Reactor 用户警惕的坑。
本讲复现环境与版本
| 项 | 版本/说明 |
|---|---|
| Spring Boot | 3.5.16 · JDK 17(发布编译时用 JDK 21) |
| Nginx | 1.30.4(Windows) |
| 压测工具 | Python 3.13 + threading.Barrier(N) 同步并发(自带 requests) |
| 验证实例 | 后端临时起 8090 端口做压测(避免影响 8089 生产实例),压完即停 |
| 浏览器 | Chrome |
本讲问题均按「版本号 → 复现环境 → 真实报错 → 项目实际现象」四要素记录,压测输出均为脚本真实运行结果。
先做审计:并发场景下哪里会出事
上线前我系统过了一遍代码,问自己三个问题:
Q1:同一个用户快速连发多条消息会怎样?
没有保护。多个流式请求并发写同一个会话的 ToolEventEmitter sink------串流。用户看到 A 请求的回答里混着 B 请求的工具卡片。
Q2:Ollama 进程挂了会怎样?
请求全部卡住,直到 HTTP 客户端 300 秒超时。用户面对一个卡死的气泡等 5 分钟。
Q3:50 个用户同时开 SSE 对话会怎样?
Tomcat 线程池和 Ollama 推理队列被无上限连接打爆,全站雪崩。
对应的四层修复(P0-P3):
| 级别 | 问题 | 方案 |
|---|---|---|
| P0 | 同会话串流 | CONVERSATION_BUSY 并发锁 |
| P1 | Ollama 宕机卡 300s | 30s 探活 + 快速失败 |
| P2 | SSE 无上限 | Semaphore(50) 全局限流 |
| P3 | ToolEventEmitter 线程安全 | ConcurrentHashMap 无锁化 |
P0:同会话并发锁
思路:ConcurrentHashMap.putIfAbsent 当忙标记,天然原子:
java
private static final ConcurrentHashMap<String, Boolean> CONVERSATION_BUSY = new ConcurrentHashMap<>();
// 控制器方法体内(注意这个位置,后面有故事)
if (CONVERSATION_BUSY.putIfAbsent(conversationId, true) != null) {
return Flux.<ServerSentEvent<String>>just(sseData("【提示】当前会话已有请求正在处理,请等待完成后再次发送"))
.concatWith(Flux.just(sseData("[DONE]")));
}
// ... 正常流程 ...
.doFinally(signal -> CONVERSATION_BUSY.remove(conversationId)); // 流结束释放
putIfAbsent 返回旧值:非 null 说明已有请求占着,直接拒绝;null 说明抢锁成功。doFinally 保证流无论正常结束、取消还是异常都释放锁。
压测验证 (Python 3.13 + threading.Barrier(8) 同步发起,真实输出):
text
=== 同会话 8 并发压测结果 ===
OK : 1 (正常流式回答)
BUSY : 1 (收到「当前会话已有请求正在处理」提示)
其余 6 个请求被 P2 全局上限拦截(见下)
PASS------同一会话同一时刻只有一条流在跑,无串流。
P1:Ollama 探活 + 快速失败
新建 OllamaHealthChecker,每 30 秒调一次 Ollama 的 /api/tags(轻量接口,只是列模型):
java
@Slf4j
@Component
public class OllamaHealthChecker {
@Value("${spring.ai.ollama.base-url:http://127.0.0.1:11434}")
private String baseUrl;
private volatile boolean healthy = true; // volatile:状态变化对所有线程立即可见
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(3)).build();
@PostConstruct
public void init() { check(); } // 启动时先探一次
@Scheduled(fixedDelay = 30000, initialDelay = 30000)
public void check() {
try {
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/api/tags"))
.timeout(Duration.ofSeconds(5)).GET().build();
HttpResponse<Void> resp = httpClient.send(req, HttpResponse.BodyHandlers.discarding());
boolean wasHealthy = healthy;
healthy = resp.statusCode() == 200;
if (wasHealthy != healthy) log.warn("Ollama health status changed: {} -> {}", wasHealthy, healthy);
} catch (Exception e) {
boolean wasHealthy = healthy;
healthy = false;
if (wasHealthy) log.error("Ollama health check failed: {}", e.getMessage());
}
}
/** AI 接口入口统一调用:不健康直接返回提示,不打 Ollama */
public Optional<String> failFastMessage() {
if (!healthy) return Optional.of("AI 推理服务暂时不可用(Ollama 未响应),请稍后重试或联系管理员检查 Ollama 服务。");
return Optional.empty();
}
}
四个 AI 入口(同步对话 / 流式对话 / ReAct / 知识库问答)统一检查:
java
var failMsg = ollamaHealthChecker.failFastMessage();
if (failMsg.isPresent()) return Result.error(failMsg.get());
效果:Ollama 宕机后最长 30 秒内进入"快速失败"状态,用户秒级得到明确提示,而不是卡 300 秒。
为什么不直接调聊天接口测健康? 探活请求本身要轻------/api/tags 毫秒级返回;用对话接口探活等于每 30 秒跑一次推理,纯属浪费。
P2:SSE 全局并发上限------本讲的主角
java
private static final int SSE_MAX_CONCURRENT = 50;
private static final Semaphore SSE_CONCURRENT_LIMIT = new Semaphore(SSE_MAX_CONCURRENT, true);
逻辑上很简单:拿不到许可就拒绝,流结束释放:
java
if (!SSE_CONCURRENT_LIMIT.tryAcquire()) { // 无参 tryAcquire:立即返回,不等待
CONVERSATION_BUSY.remove(conversationId); // 记得回滚 P0 的锁
return Flux.<ServerSentEvent<String>>just(sseData("【提示】AI 服务繁忙,当前在线对话较多,请稍后重试"))
.concatWith(Flux.just(sseData("[DONE]")));
}
// ... 正常流程 ...
.doFinally(signal -> {
CONVERSATION_BUSY.remove(conversationId);
SSE_CONCURRENT_LIMIT.release();
});
然后坑来了:压测怎么测都是 0 拦截
我临时把上限调成 2,写了个 Python 压测脚本,8 个线程同时发请求(用 threading.Barrier(8) 保证真正"同时"------不然 GIL 会把请求错开)。
结果:busy = 0,8 个请求全放行了??
📋 问题档案(压测不生效)
- 版本:Spring Boot 3.5.16(Spring MVC 异步 + Reactor)· Semaphore(2, true)
- 复现环境 :临时实例 8090 端口,limit=2,
threading.Barrier(8)同步并发 8 个请求- 真实报错 :无异常------所有请求都返回 200,压测脚本统计
busy=0:text
=== P2 压测(第一版,Flux.defer 内检查) === OK=8 BUSY=0 ← 期望 OK=2 BUSY=6
- 项目实际现象 :代码逻辑 review 了三遍没毛病,字节码反编译(
javap -c)验证分支正确,但压测死活拦不住------逻辑对、位置错
根因:检查逻辑放错了地方
我最初的代码是这样的:
java
return Flux.defer(() -> {
if (!SSE_CONCURRENT_LIMIT.tryAcquire()) { // ← 放在 Flux.defer 里
return Flux.just(sseData("繁忙"));
}
...
});
问题出在 Spring MVC 的异步处理机制 :Controller 返回 Flux<ServerSentEvent> 后,Tomcat 线程立刻释放 ,Spring 在之后的某个时机才订阅这个 Flux------订阅时机不确定,可能是串行的、可能有延迟。
Flux.defer 里的代码在订阅时才执行。8 个请求的 defer 体没有在"同一瞬间"执行,前 2 个请求完成得太快,许可已释放,后面的 tryAcquire 全部成功。
解法:把检查逻辑移到控制器方法体内(Tomcat 线程直接执行,方法被调用就是"现在"):
java
@PostMapping(value = "/stream", produces = ...)
public Flux<ServerSentEvent<String>> chatStream(@RequestBody ChatRequest req, ...) {
// ... 参数校验 ...
// P0、P2 的检查全部在这里------方法体 = 请求到达的确定性时刻
if (CONVERSATION_BUSY.putIfAbsent(req.getConversationId(), true) != null) { ... }
if (!SSE_CONCURRENT_LIMIT.tryAcquire()) { ... }
// 构建 Flux(冷流,此时还没执行)
return Flux.merge(mainEventFlux, heartbeatFlux)
.concatWith(Mono.just(sseData("[DONE]")))
.doFinally(signal -> { ... });
}
改完重测:2 个 OK + 6 个 BUSY ,日志里 tryAcquire=false available=0 出现 6 次。PASS。
修复后的压测输出(真实运行结果):
text
=== P2 压测(第二版,控制器方法体内检查,limit=2) ===
OK : 2 (正常流式回答)
BUSY: 6 (收到「AI 服务繁忙,当前在线对话较多,请稍后重试」)
后端日志关键行(真实日志原文节选):
text
[P2] tryAcquire=true available=1 ← 第 1 个请求拿到许可
[P2] tryAcquire=false available=0 ← 第 2 个之后的 6 个全部被拒
还有一个隐藏的坑顺带说:压测脚本最初统计 busy=0 还有一个原因------脚本里匹配的关键字写的是「AI服务繁忙」(没空格),而代码里的提示语是「AI 服务繁忙」(有空格),匹配失败导致统计为 0。压测脚本本身也可能是错的,验证工具也要被验证。
敲黑板(本系列最贵的一条经验):
在 Spring MVC 返回 Flux 的接口里做"准入控制",逻辑必须放控制器方法体内,不能放 Flux.defer 或任何"订阅时才执行"的位置。 Tomcat 线程执行方法体的时机是确定的(请求到达),Flux 的订阅时机是不确定的。tryAcquire / putIfAbsent 这类时序敏感的操作,只能放确定性的位置。
顺带说 tryAcquire() 的选择:无参版本立即返回 false;带超时的 tryAcquire(3, SECONDS) 会排队等 3 秒------等待期间前面的请求可能已经完成释放了许可,测试永远看不到拦截。限流语义要"快速拒绝"时,用无参。
P3:ToolEventEmitter 无锁化
最初实现是 HashMap + synchronized:
java
// 旧版
private final Map<String, Sinks.Many<...>> sinks = new HashMap<>();
private final Map<String, Long> lastUsed = new HashMap<>();
public Flux<...> stream(String id) {
synchronized (this) {
return sinks.computeIfAbsent(id, k -> Sinks.many().unicast().onBackpressureBuffer()).asFlux();
}
}
synchronized 块内做 IO 级操作(Sink 创建),高并发下是串行瓶颈,而且锁粒度是整个组件。
ConcurrentHashMap 原子方法直接消灭锁:
java
private final ConcurrentMap<String, Sinks.Many<Map<String, Object>>> sinks = new ConcurrentHashMap<>();
private final ConcurrentMap<String, Long> lastUsed = new ConcurrentHashMap<>();
public Flux<Map<String, Object>> stream(String conversationId) {
Sinks.Many<Map<String, Object>> sink = sinks.computeIfAbsent(
conversationId, k -> Sinks.many().unicast().onBackpressureBuffer()); // 原子操作
lastUsed.put(conversationId, System.currentTimeMillis());
return sink.asFlux();
}
computeIfAbsent 在 ConcurrentHashMap 上是每 key 级原子------不同会话的 sink 创建互不阻塞。
部署:Nginx 完整配置
前端 pnpm build 出 dist/,后端 mvn clean package 出 jar。Nginx 完整配置(每一行都有血泪):
nginx
http {
client_max_body_size 100m; # 知识库大文件上传(默认 1m 必 413)
server {
listen 80;
server_name _;
root /opt/erp-ai-frontend/dist;
index index.html;
# ============ 静态资源 ============
# index.html 禁缓存:发新版后用户被旧 index.html 坑过(引用旧 hash 的 js 404)
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# SPA 路由回退:history 模式刷新 /ai/chat 不能 404
location / {
try_files $uri $uri/ /index.html;
}
# ============ 后端 API ============
location /api/ {
proxy_pass http://127.0.0.1:8089;
proxy_set_header Host $host;
proxy_set_header Authorization $http_authorization; # 不透传 = 全站 401
}
location /ai/ {
proxy_pass http://127.0.0.1:8089;
proxy_set_header Host $host;
proxy_set_header Authorization $http_authorization;
proxy_buffering off; # SSE 必须关缓冲,否则假死
proxy_read_timeout 3600s; # SSE 长连接超时
proxy_http_version 1.1; # keep-alive
proxy_set_header Connection "";
# SPA 路由与 API 前缀冲突的解法(见下文)
if ($http_accept ~* "text/html") {
rewrite ^ /index.html last;
}
}
}
}
那个 SPA 路由冲突的坑,兑现第 1 讲的伏笔
前端路由 /ai/chat、/ai/knowledge,后端 API 也是 /ai/** 前缀------重叠了。
📋 问题档案
- 版本:Nginx 1.30.4 + Vue Router 4.5(history 模式)+ Spring Security 6.x
- 复现环境 :部署到 Nginx 后,在
/ai/knowledge页面按 F5 刷新- 真实报错:页面直接显示一坨 JSON(Security 的 401 响应被浏览器当页面渲染):
json
{"timestamp":"...","status":401,"error":"Unauthorized","path":"/ai/knowledge"}
- 项目实际现象 :点导航、对话、上传全部正常;唯独刷新和直接输入 URL 白屏出 JSON------开发环境(Vite 代理)完全复现不了,因为 Vite 的 history 回退和代理是分开处理的
炸的方式:用户在 /ai/knowledge 页面按 F5 刷新。浏览器刷新是页面导航 (Accept: text/html),请求 /ai/knowledge 直达后端 Spring Security → 401 JSON 被浏览器当页面渲染 → 白屏一坨 JSON。
解法就是配置里那三行:在 Nginx 层按 Accept 头区分------
nginx
if ($http_accept ~* "text/html") {
rewrite ^ /index.html last; # 页面导航 → 回 index.html 由前端路由接管
}
XHR/fetch 请求的 Accept 是 application/json 或 text/event-stream,正常转发后端;只有浏览器导航(text/html)才回退 SPA。一行 if 解决两类流量的分流。
根治方案 是前期规划时就让 SPA 路由前缀与 API 前缀不重叠 (比如页面用 /wms/ai/chat,API 用 /ai/)------可惜我意识到的时候代码已经写了一半,只能用 Accept 头分流兜底。新项目请从第一天就分开。
SSE 相关三件套
nginx
proxy_buffering off; # Nginx 不攒批,token 实时下发
proxy_read_timeout 3600s; # 长连接不被掐(默认 60s 会断流)
proxy_http_version 1.1; # HTTP/1.1 keep-alive
后端再双保险:响应头 X-Accel-Buffering: no(即使某天 Nginx 配置丢了缓冲设置,这个头也能让 Nginx 对该响应关缓冲)。
发布编译环节的两个真实坑
功能全合并后打最终 jar,又踩了两个与代码无关的环境坑------记录在这里,因为它们专门挑"你以为万事俱备"的时候出现。
坑 1:内网 Nexus 私服不可达,mvn clean package 失败
📋 问题档案
- 版本:Maven 3.9.9 / 3.6.2 + 内网 Nexus(192.168.8.112:8781)
- 复现环境 :公司内网 Nexus 服务临时不可达时执行
mvn clean package- 真实报错(构建日志节选):
text
Could not transfer artifact ... from/to nexus (http://192.168.8.112:8781/...):
Connection timed out
...
(artifact) was cached in the local repository, resolution will not be
reattempted until the update interval of nexus has elapsed
- 项目实际现象 :依赖明明早就下载到本地仓库了,构建还是失败------本地仓库的
_remote.repositories元数据记录了来源仓库 ID,Maven 3.9 校验时不认,宁可失败也不用缓存
解法:临时用一份 settings.xml 切换到阿里云公共镜像(https://maven.aliyun.com/repository/public)完成编译,打完包删除临时配置。内网私服是单点,CI 环境建议配镜像兜底。
坑 2:本机默认 JDK 8,编译 Spring Boot 3 项目直接失败
📋 问题档案
- 版本 :本机
JAVA_HOME→ JDK 8;项目要求 JDK 17(实际用 JDK 21 编译)- 复现环境 :shell 里直接
mvn clean package(未显式指定 JDK)- 真实报错(构建日志节选):
text
Fatal error compiling: 无效的目标发行版: 17
- 项目实际现象 :IDE 里一切正常(IDE 用的是项目级 JDK 17 配置),命令行打包必挂------IDE 和命令行的 JDK 是两套环境,这个问题专挑"在 IDE 里好好的、一到流水线就挂"的场景
解法:构建命令显式指定 JAVA_HOME,或在 Maven toolchains / CI 里固定 JDK 版本。
压测验证汇总
| 修复项 | 验证环境 | 压测方式 | 结果 |
|---|---|---|---|
| P0 同会话锁 | 8090 临时实例 · Python 3.13 | 8 线程同 conversationId 并发 | 1 OK + 1 "已有请求处理中",无串流 |
| P1 探活 | Ollama Windows 版 | 停掉 Ollama 后对话 | 秒级返回"AI 推理服务暂时不可用" |
| P2 SSE 上限 | 8090 临时实例 · limit=2 | Barrier(8) 并发 | 2 OK + 6 "AI 服务繁忙" |
| P3 无锁化 | 正常对话链路 | 并发工具调用事件 | tool_call/tool_result 全部完整送达 |
部署清单(裸机版)
bash
# 1. 基础设施
PostgreSQL 17 + pgvector 扩展(CREATE EXTENSION vector)
Redis 7
Ollama + 三个模型(qwen2.5:7b-instruct-q4_K_M / qwen2.5:7b / bge-m3)
# 2. 数据库初始化
psql -U postgres -d erp_ai -f db/schema.sql
# 3. 后端(建议注册成 systemd 服务)
java -jar erp-ai-parent-0.0.1-SNAPSHOT.jar --spring.config.location=/opt/erp-ai/application.yml
# 4. 前端
pnpm build && 部署 dist/ 到 Nginx root
# 5. 验证
curl http://127.0.0.1:11434/api/tags # Ollama 活着
curl http://localhost:8089/actuator/health # 后端健康
浏览器访问 http://<host>/ 登录 → AI 对话 → 工具调用 → 知识库问答
Docker 化是下一步计划(镜像拆分:postgres-pgvector / ollama-models / backend / nginx),本系列不再展开,开源仓库的 TODO 里有 issue 跟踪。
系列总结:40+ 个坑的一句话复盘
| 类别 | 最贵的教训 |
|---|---|
| 依赖 | springdoc 3.x 配 Boot 3 = 全家桶冲突;mybatis-plus starter 差一个词天壤之别 |
| 安全 | addFilterBefore 一词之差全站 401;SSE 异步必须新建 SecurityContext |
| Reactor | Flux 冷流多次订阅 = 多次执行;准入控制放方法体,别放 defer |
| 模型 | keepAlive 决定首 token 延迟;8B 模型 Function Calling 要实测字符串参数 |
| RAG | 检索质量上限在分片时就定了;多轮追问必须做查询改写 |
| 部署 | SSE 三件套(buffering off / read_timeout / http 1.1);SPA 路由别和 API 前缀重叠 |
写在最后
八讲写完,回头看这个项目最难得的不是某个技术点,而是每个坑都有根因和验证------"改了之后压测数据变了"比"应该能好"值钱一百倍。
项目已开源(MIT 协议),前后端仓库链接见系列总纲。如果你正在做类似的"传统系统 + AI"改造,希望这一系列能帮你少走几天弯路。
有问题欢迎评论区交流------特别是如果你也踩过"Semaphore 放 defer 里不生效"这种坑,握个手。