拾物清单有个核心功能:新增物品时上传一张电商订单截图,AI 自动识别商品名称、实付金额、购买日期、平台、分类等字段回填表单。第一版实现很直接——Controller 里同步调 DashScope 多模态接口,等 AI 返回 JSON 再响应。结果就是用户全屏遮罩干等 5~20 秒,AI 一抖动整个页面就卡死。
这次改造把链路升级为任务化异步处理 + WebSocket 实时推送,本文按”为什么改 → 怎么设计 → 怎么实现 → 踩了什么坑”完整记录一遍。
一、原实现的痛点
原链路就是最朴素的同步调用:上传图片 → 调 DashScope → 等 5~20 秒 → 返回结果。用起来暴露了一串问题:
| 痛点 | 表现 |
|---|---|
| HTTP 线程被长时间占用 | DashScope 同步调用 5~20 秒,Tomcat 工作线程全程挂起,识别一多整体吞吐下降 |
| 无超时、无重试 | AI 偶发失败直接整体失败,用户只能重新选图再来 |
| 任务不落库 | 进程重启/崩溃后任务丢失,没有任何历史痕迹 |
| 前端阻塞式体验 | 全屏遮罩,识别期间无法离开页面、无法继续编辑 |
| 结果不可感知 | 只能死等同一个 HTTP 请求返回 |
二、方案选型:为什么不上消息队列
标准答案肯定是 MQ:任务投递到队列,消费者异步处理,结果事件广播。但服务器就 1.8GB 内存,部署环境不允许再加 RocketMQ/RabbitMQ 这种中间件,运维成本也压不住。
最终选择的组合是:MySQL 任务表 + 线程池 + WebSocket + 轮询兜底。
- 任务表:承担消息队列的持久化语义,天然支持重启恢复、失败重试、轮询查询;
- 线程池:承担异步执行,零额外依赖,单机单实例足够;
- CAS 更新:保证同一任务只被一个 Worker 执行,未来多实例也成立;
- WebSocket:实时推送,体验好;
- 轮询:兜底通道,弱网/代理不支持 WS 时结果仍可达。
代码按”提交/消费”边界组织,流量真上来了把提交层换成 MQ 就行,任务表可以退化为消费记录。
三、数据模型:一张任务表撑起整个状态机
新增 ocr_task 表,字段不多:
1 | CREATE TABLE `ocr_task` ( |
状态机就四个状态:
1 | PENDING → PROCESSING → SUCCESS |
任务表在这里承担了五个角色:异步解耦的持久化载体、断点恢复的数据基础、重试的依据、轮询查询的数据源、CAS 防重的条件列。一张表干了 MQ + 延迟队列 + 死信队列的活,单机场景下够用了。
四、后端实现
4.1 上传即返回
1 |
|
Service 层三步走:传 OSS → 插任务行(PENDING)→ 提交异步,立即返回 taskId:
1 | String ossUrl = ossUtil.upload(file, "ocr"); |
线程池配置刻意用了 AbortPolicy 而不是 CallerRunsPolicy——队列满时回退到调用线程执行,等于把压力反压回 HTTP 线程,异步就白做了。拒绝时直接标失败,让用户稍后手动重试,链路更可控。
1 |
|
参数 core=2 / max=4 / queue=50 是照着 1.8GB 内存的服务器定的——AI 调用是 IO 密集型,线程多了也只是一起挂着等网络。
4.2 CAS 抢防重复识别
Worker 拿到任务后,第一件事是用数据库 CAS 抢占:
1 | UPDATE ocr_task SET status = 'PROCESSING', update_time = NOW() |
影响行数 = 1 抢占成功继续干活;= 0 说明已被别人接管直接返回。比”先查状态再更新”安全得多,而且天然支持多实例——将来水平扩展也不用引分布式锁。
4.3 自动重试 + 指数退避
1 | for (int attempt = 1; attempt <= maxAttempts; attempt++) { |
默认最多 3 次。注意异步线程里没有请求上下文,UserContext(ThreadLocal)要手动注入、finally 里清掉,否则线程复用会串用户:
1 | UserContext.setUserId(task.getUserId()); |
4.4 重启恢复
OcrTaskRecoveryRunner 实现 ApplicationRunner,启动时扫 PENDING 或超时 5 分钟的 PROCESSING 任务(LIMIT 20,防止启动瞬间打爆 AI 接口),逐个重新入队。配合 CAS,”假死的慢任务”和”恢复的任务”不会重复识别。
五、WebSocket 实时推送
5.1 握手鉴权:token 只能放 query
H5 的浏览器 WebSocket API 不能自定义 Header,所以 token 走 query 参数,由握手拦截器校验:
1 | public boolean beforeHandshake(ServerHttpRequest request, ..., |
安全上的已知妥协:token 会出现在访问日志里,生产要配 HTTPS,更严谨的做法是握手后换短期一次性票据。另外 JWT 过期后已建立的连接仍然存活,后续可以做心跳时复验。
同时记得把 /api/ws 从 JWT 拦截器的放行清单里处理好——握手拦截器和 MVC 拦截器是两套,别重复校验也别双双漏掉。
5.2 会话管理与推送
会话容器按 userId 维度管理,支持同一用户多设备同时在线:
1 | private static final ConcurrentMap<Long, CopyOnWriteArraySet<WebSocketSession>> SESSIONS = |
选型说明:ConcurrentHashMap 保证并发安全;CopyOnWriteArraySet 适合”读远多于写”的场景——推送时遍历是读,连接开关是写,一个用户也就一两个连接,写时复制开销可忽略。连接关闭时在 afterConnectionClosed 里移除会话,空集合也删掉,不会泄漏。
心跳很简单:客户端每 30 秒发 ping,服务端回 pong,防止中间设备掐掉空闲连接。
六、前端:WS + 轮询双通道
uni-app 用 uni.connectSocket 拿到 socketTask,封装成单例模块 utils/websocket.js。核心是这个 waitOcrTask:
1 | export function waitOcrTask(taskId, { timeout = 60000 } = {}) { |
两个通道谁先到谁生效,settled 标志保证只处理一次,重复消息无害。WS 挂了、代理不支持升级、弱网断连——轮询都能把结果捞回来。
断线重连用指数退避,1s → 2s → 4s… 上限 10s,且只在有活跃订阅时才重连,避免页面闲置后还在后台无限重试:
1 | function scheduleReconnect() { |
交互层面,上传后拿到 taskId 就把全屏遮罩换成表单内的进度提示,识别期间用户可以继续填别的字段;完成时 applyOcrResult(result) 自动回填,失败弹窗给”重试”按钮直接走 POST /api/ocr/tasks/{taskId}/retry。
七、闭环:识别记录页
做了异步就自然冒出一个新问题:用户上传后直接退出编辑页,任务在后台完成了,结果去哪看?
补了两个接口(GET /api/ocr/tasks 分页列表 + DELETE /api/ocr/tasks/{taskId} 放弃识别),首页展示”待处理识别”横幅,点击进识别记录页,点任意一条复用 edit-item.vue 的 review 模式核对。保存时 GoodsSaveDTO.ocrTaskId 随物品一起提交,同一个事务里插物品 + 逻辑删除任务,杜绝重复保存。识别 → 核对 → 保存,业务上 AI 只辅助填写,不直接生成物品。
八、踩坑记录
1、@Async 自调用不生效
@Async 基于 Spring AOP 代理,同类内 this.submit() 不走代理,会退化成同步执行。必须跨 Bean 调用(OcrServiceImpl 调 OcrTaskProcessor.submit())。一开始写在同一个类里,异步压根没触发,线程名还是 http-nio-8080-exec-*,查了半晌。
2、H5 联调 WS 不通
HBuilderX 的 devServer 代理默认不开 WebSocket 升级,manifest.json 里 /api 代理必须加 ws: true,否则 H5 环境永远连不上,APP 真机却一切正常——平台差异型坑。
3、测试上下文撞车
集成测试里 @MockBean 会创建第二个 Spring 上下文,和主上下文共享同一个 H2 内存库(DB_CLOSE_DELAY=-1),表被重复初始化直接主键冲突。解决:给测试类配独立库名 jdbc:h2:mem:ocrtask。另外 Mockito 里 thenThrow 之后再 when() 重打桩会先触发旧桩,改用 doReturn/doThrow 风格更稳。
4、BigDecimal 精度丢失
result_json 用 Hutool 序列化,BigDecimal("6999.00") 往返后变 6999,scale 丢了。表单回填无感知,但哪天要做精确展示就会踩上。根因是 DTO 层用了字符串透传、VO 层才转 BigDecimal,中间那一次 JSON 往返丢了标度。
5、雪花 ID 精度
项目全局把 Long 序列化成 String,任务 taskId 也是。前端如果按 Number 处理 taskId,比较和作为对象 key 都会出问题,订阅时统一 String(taskId) 归一化。
九、测试与遗留
4 个集成用例覆盖主链路:创建后自动识别成功、连续失败 3 次再重试成功、他人任务不可查询、非 FAILED 不可重试。全量 mvn test 通过。
明确知道但还没做的:
- 孤儿 OSS 清理:失败/放弃任务的截图没删(识别失败时还留着给重试用),需要定时任务兜底;
- 单任务总超时:慢调用会长占线程,应加 60s 级别的硬超时;
- 多实例广播:WS 会话是内存态,用户连实例 A、任务在实例 B 完成时 A 收不到,要上 Redis Pub/Sub 或 MQ 广播——这是当前单实例部署的已知演进边界;
- 消息队列演进:流量增大后把提交层换成 RocketMQ,任务表转为消费记录。
写在最后
这套”任务表 + 线程池 + WS + 轮询”的组合算不上什么高大上的架构,但它在零中间件、小内存的约束下把不阻塞、不丢失、可重试、能感知四个目标都兜住了。工程上大部分问题都不是”选哪个最先进的技术”,而是”在约束条件下把每条边界都封上”。MQ、租约、分布式广播都在演进清单里,等真有那个量级再上不迟。
条评论