【WMS 仓储系统集成 AI Agent 实战】第 8 讲(终篇):生产部署与并发安全——Semaphore 放进 Flux.defer 的坑,压测抓了一晚上

系列最后一讲。系统功能全齐了,问题是:它扛得住真实流量吗?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 里不生效"这种坑,握个手。

相关推荐
杨杨杨大侠1 小时前
Jev、Kev、Laya:决策模型怎么选,什么时候需要微调?
人工智能·python·agent
代码方舟1 小时前
零信任架构实战:基于天远人企关联构建自动化供应链金融网关
运维·人工智能·架构·自动化
虹科网络安全1 小时前
“顶会”看安全(十八):超越越狱:揭示由能力边界模糊引发的 LLM 应用安全风险
人工智能·安全
hasty2 小时前
OAuth 登录成功,不代表邮箱可信:Flarum 账号关联漏洞的安全编码启示
安全
Maiko Star2 小时前
* LangChain 提示词模板详解:ChatPromptTemplate 的使用与高级特性
java·人工智能·langchain
鬓戈2 小时前
Rust 语言与 AI 应用生态调研及学习路径
人工智能·学习·rust
天远API2 小时前
零信任架构实战:基于天远人企关联构建自动化图谱网关
网络·人工智能·架构·自动化
爱吃提升2 小时前
文生视频模型发展趋势(2026)
人工智能·音视频
AIGS0012 小时前
什么是本体语义平台?和知识图谱、传统数据中台的区别在哪
人工智能·知识图谱·数据中台·ai问数·本体语义平台·智能数据中台