第一次把 YOLO 检测服务塞进 Spring Boot 单体应用的时候,我以为这只是“引入一个模型库”的事:图片上传、目标检测、结果落库,三个接口就能串起来。直到生产环境并发从几个涨到几十,Tomcat 线程被检测请求打满、JVM 频繁触发 Full GC、甚至偶尔报出 OutOfMemoryError: insufficient memory,我才反应过来:AI 推理和普通业务接口在资源模型上完全是两种东西,硬塞在同一个进程里,早晚要出事。这篇内容想用一个完整的改造案例,聊聊如何用 Java + Spring Boot 把 YOLO 检测从单体重构为可横向扩展的微服务架构,核心链路包括服务拆分、异步任务队列、分布式锁、状态补偿和推理服务独立部署。全程不回避踩过的坑,也不只给结论,尽量把为什么这样做的理由说透,适合正在做目标检测服务化、或者准备把 AI 推理往微服务方向迁移的同学参考。
1. 单机单体的困境:YOLO 检测在 Spring Boot 单体里的连锁反应
1.1 一个真实场景:从能跑到不敢扩
最早一版功能很简单:用户上传一张图,Spring Boot 的 UploadController 接收 MultipartFile,转成 Mat/byte[],调用封装好的 YOLO 检测器,拿到检测框后保存 MySQL 并返回前端。单机部署,模型用 ONNX Runtime 加载,YOLOv8s 的检测耗时大约 300 到 500 毫秒,加上图像解码和缩放,长尾能到 800 毫秒。这个表现初期完全够用,日均几百张图,Tomcat 默认 200 个线程绰绰有余。问题出在流量涨起来之后:一次营销活动让图片检测量翻了二十倍,检测接口的 TP99 从 900 毫秒拉到 6 秒,紧接着用户模块、订单模块全部跟着变慢,最后整台服务器的 Tomcat 线程池被打满,连环雪崩。
这种场景里最典型的连锁反应是什么?当一个请求线程检测等待时,它占住的不仅是自己的超时时间,还有整个容器处理其他请求的能力。Tomcat 线程池不是无限大,200 个线程里如果 150 个在等推理结果,剩下 50 个要扛住所有登录、查询、下单流量,业务系统的响应自然全线恶化。更麻烦的是,Spring Boot 默认同步模型和 YOLO 推理的耗时节奏完全不匹配,业务接口要求毫秒级响应,推理却天然是百毫秒到秒级,两者放在一个线程池里,必然互相拖累。
1.2 单体架构对 AI 推理天然不友好的三个原因
第一是资源冲突。JVM 堆内存可以用 -Xmx 控制,但 ONNX Runtime 执行推理时会额外分配堆外内存和 native memory,这块经常不归 -Xmx 管。我碰到过最典型的情况:一个实例加载了 YOLOv8s 和 YOLO 实例分割两个模型,业务代码再堆一些缓存,堆内存看着还剩不少,但直接内存先爆了,进程报 OutOfMemoryError: insufficient memory。这个报错不是“Java 堆不够”,而是 native 内存不足,很多人第一步就去调 -Xmx,结果完全没用。
第二是发布影响面。单体应用 reload 一次模型,或者想升级 YOLO 版本,都需要完整重启 Spring Boot 进程。模型加载本身不是秒级操作,要读权重、初始化执行会话,几十秒甚至一分钟都很正常,这期间整个服务的所有接口不可用。用户只是想把照片里的车辆检测出来,结果连登录接口都跟着重启,这样的体验在业务方眼里就是“系统不稳定”。
第三是扩容粒度太粗。流量涨了,单体应用只能整机复制,业务代码、推理代码、数据库连接池、模型文件全打包在一起。如果只是检测模块压力大,最后等于把一套完整系统复制出去,成本高不说,GPU/CPU 资源还经常被业务代码占用。AI 推理模块要的资源和业务 Web 模块完全不同,前者要一致的算力、可控的并发、充足的显存或 CPU 核心,后者更看重 IO 和弹性伸缩,混在一个部署单元里很难兼顾。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 服务边界怎么划:从“业务服务”里拆出一个干净的推理服务
2.1 拆成五个模块,每个只干一件事
在正式动手拆之前,我重新画了一遍系统里的数据流:上传图片 -> 保存原图 -> 发起检测任务 -> 模型推理 -> 写检测结果 -> 通知业务方。按这条流把职责切分开,最终拆出五个相对独立的后端模块,分工如下表。
| 模块 | 主要职责 | 部署特征 |
|---|---|---|
| api-gateway / 接入服务 | 图片上传、鉴权、参数校验、调用编排服务 | 无状态,可多副本 |
| 编排服务 | 存储任务状态、写入 Redis Stream、调用结果通知 | 依赖 Redis / DB |
| 推理服务 | 预加载模型、执行图像预处理、YOLO 推理、后处理输出 | 需要大内存或 GPU,单独部署 |
| 结果服务 | 消费推理结果、写入结果表、触发回调通知 | 依赖 DB,异步消费 |
| 定时补偿服务 | 扫描超时任务、清理过期消息、兜底重发 | 挂载到调度平台 |
实际项目里结果服务可以和编排服务合并,如果业务不复杂,四个模块也够用。但“推理服务”必须独立出来,这是整个拆分里最关键的一步。它的核心使命只有一个:接收一张图片的地址或字节,返回一组检测框。不要让它关心用户是谁、订单号是多少,也不要在里面做业务权限校验,否则它又会慢慢膨胀回一个“什么都会”的服务。
2.2 我坚持用的拆分原则:按资源、按变更、按故障半径
很多人拆微服务是从“代码层”出发,想着哪些类能独立,结果拆得支离破碎。我的经验是反过来,从三个维度判断边界。
按资源分:推理服务独占 GPU 或高 CPU 核数,其他模块走普通 x86 服务器。这是一个硬性边界,因为模型推理对计算资源的需求和普通 Web 接口差距太大。Spring Boot 的 Web 模块跑 4 核 8G 的机器很舒服,YOLO 推理如果要在 CPU 上跑出吞吐,最好固定到 16 核以上的机器;如果上 GPU,则要确保机器上只部署推理相关进程,不要让无关业务进程来抢显存。
按变更频率分:业务接口每周都要改,需求方今天加一个字段,明天改一个阈值,这类服务适合快速迭代。推理服务相对稳定,模型的迭代节奏通常是按周或按月,而且每次模型变更要做比较重的回归验证。把两者拆开,业务改代码不会牵连到模型服务重启,模型升级也不会让业务服务频繁跟随发布。
按故障半径分:如果推理模块崩溃,比如某个 ONNX 会话初始化失败导致进程退出,影响范围应该被限制在一个任务重试的范围内,而不是让用户上传、登录、下单全部挂掉。这其实是微服务最朴素的价值之一:故障隔离。在单体里一个 native 崩溃可能让整个 JVM 退出,拆开后推理服务的故障最多让检测任务延迟,核心业务链路还能继续跑。
3. 推理服务落地:模型加载、图像预处理、NMS 与并发控制
3.1 集成方式选型:ONNX Runtime 与 DJL 的取舍
Java 生态里集成 YOLO,我试过几条路:直接包一层 Python 推理服务(Flask + ultralytics)然后用 HTTP 调用,用 Python 写推理脚本然后 Java 侧通过 subprocess 调用,以及纯 Java 方案。前两种开发快,但绕不开跨语言部署和进程管理成本;Java 方案里主流是 ONNX Runtime 和 Deep Java Library(DJL)。DJL 对模型库和推理流程封装更友好,如果团队 Java 水平一般,上手更快。ONNX Runtime 偏底层,胜在可控性强、内存和线程模型直接暴露给开发者,也更容易从原生推理脚本转换过来。我最终选了 ONNX Runtime,原因主要有两个:一是模型导出成 ONNX 后,和训练框架解耦,无论是 YOLOv5、YOLOv8 还是更新的 YOLO11,都能统一用一套推理代码;二是 ONNX Runtime 在 CPU 和 CUDA 上都有优化过的执行后端,性能不会比原始框架差太多。
3.2 模型生命周期管理:启动预加载与热更新思路
推理服务启动时,在 @PostConstruct 方法里一次性加载模型到内存,不要等到第一个请求来了再加载。第一次推理的冷启动比后续推理慢一个数量级,如果每个实例都这样冷启动,扩容的一瞬间流量会把实例打垮。加载完成后可以跑一张纯黑图做预热,让 ONNX Runtime 完成内存分配和 CUDA context 初始化。在 Spring Boot 里我会用一个 ModelHolder 类的单例持有 OrtSession,整个服务生命周期内复用同一个会话。模型更新则走“先传新模型文件到共享目录,再通过配置下发切换标记”的方式,或者更稳妥的做法是部署两个推理服务实例,一个保持旧版本,一个加载新版本,验证完再切流量。
3.3 YOLO 输出解码与重叠框处理的 Java 实现要点
YOLO 模型在 Java 侧处理起来有几处容易出错,尤其是预处理和后处理。预处理要用 letterbox,也就是把原图按比例缩放到 640x640,剩余区域用灰色填充,而不是直接 resize 成 640x640,否则长宽比变化会直接拉偏检测框。用 OpenCV 可以这样做:先算缩放比例 Math.min(640.0 / src.width(), 640.0 / src.height()),再在 copyMakeBorder 里补边。后处理更考验基本功:ONNX 输出的原始张量形状是 [1, 84, 8400],8400 是三个尺度特征图预测框的总数,84 是 bbox 四项加 80 个类别置信度。先遍历这些候选框,过滤掉置信度低于阈值的,再用 NMS(非极大值抑制)去掉重叠框。重叠框是目标检测里最常见的现象——同一目标会被多个锚点同时预测到,不处理就会在图上画出很多重复框。NMS 按置信度排序,保留最高分框,并以 IoU 为指标抑制其他高度重叠的框,阈值一般取 0.45 到 0.5。这段逻辑虽然不难,但如果用双循环实现,8400 乘 8400 的规模在纯 Java 里也扛不住,最终还是要压缩候选框数量,最好控制在几百个以内再跑
