MyBatisPlus自定义SQL查询HunyuanOCR识别耗时统计
在智能文档处理系统日益普及的今天,一个看似简单的问题却常常困扰开发者:这次OCR识别到底花了多久?
这个问题背后,其实是企业对AI服务可观测性的迫切需求。我们不再满足于“能识别”,而是要清楚地知道“什么时候识别的、用了多长时间、成功率如何”。尤其是在批量处理合同、票据或证件的场景中,一次异常的长延迟可能意味着用户体验的崩塌。
本文不讲大而全的监控体系,也不引入Flink或ELK这类重型组件,而是聚焦一个轻量但实用的技术组合——用MyBatisPlus的自定义SQL能力,精准统计腾讯HunyuanOCR模型的识别耗时。这套方案已在多个实际项目中落地,帮助团队建立起第一道性能基线防线。
为什么是 HunyyanOCR?
传统OCR系统通常由检测+识别两个独立模型串联而成,有些甚至还要加上后处理规则引擎。这种架构虽然灵活,但也带来了推理链路过长、部署复杂、维护成本高等问题。
而近年来兴起的端到端多模态OCR模型正在改变这一局面。其中,腾讯推出的HunyuanOCR是一个值得关注的代表作。它基于混元大模型技术,将文字定位、内容识别和结构化输出统一在一个轻量化网络中,仅用1B参数就实现了多项SOTA表现。
更关键的是,它的部署门槛极低——单张NVIDIA 4090D显卡即可流畅运行,支持PyTorch原生和vLLM加速两种模式,并提供API与Web界面双入口访问。这意味着无论是嵌入现有系统还是快速原型验证,都能迅速上手。
不过,再快的模型如果没有配套的监控手段,也容易变成“黑盒”。特别是在高并发调用下,我们很难凭直觉判断系统是否健康。这时候,就需要把每一次请求的时间消耗记录下来,形成可分析的数据资产。
耗时从哪里来?数据建模是第一步
要统计耗时,首先要明确“开始”和“结束”的定义。我们的做法是在业务层埋点:
- 开始时间(start_time):客户端发起OCR请求时,生成任务记录并写入数据库;
- 结束时间(end_time):收到HunyuanOCR返回结果后,更新该记录的状态与完成时间;
- 耗时计算:通过数据库函数
TIMESTAMPDIFF(MICROSECOND, start_time, end_time) / 1000得出毫秒级精度的结果。
对应的MySQL表结构如下:
CREATE TABLE ocr_task ( id BIGINT PRIMARY KEY AUTO_INCREMENT, task_id VARCHAR(64) NOT NULL UNIQUE, image_path VARCHAR(255), status TINYINT COMMENT '0: pending, 1: success, 2: failed', start_time DATETIME NOT NULL, end_time DATETIME, duration_ms INT COMMENT '识别耗时(毫秒)', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME ON UPDATE CURRENT_TIMESTAMP );这里有几个细节值得注意:
- 使用
DATETIME而非TIMESTAMP,避免时区转换带来的混乱; duration_ms字段虽然是冗余的,但可以作为缓存字段提升查询效率;- 对
start_time,status,task_id建立联合索引,确保按时间范围查询时走索引扫描。
有人可能会问:“为什么不直接在应用层计算耗时?”
答案是:时钟偏差不可忽视。不同服务器之间可能存在几十毫秒的时间漂移,尤其在分布式环境下。而在数据库层面统一计算,能保证所有记录都使用同一时钟源,数据更具一致性。
自定义SQL怎么写?MyBatisPlus的灵活性在这里体现
MyBatisPlus大家都知道用来做CRUD增强,但它对原生SQL的支持其实非常强大。我们正是利用@Select注解,在Mapper接口中嵌入复杂的聚合查询语句。
实体类映射
先定义Java实体类,保持与数据库字段一致:
@Data @TableName("ocr_task") public class OcrTask { private Long id; private String taskId; private String imagePath; private Integer status; private LocalDateTime startTime; private LocalDateTime endTime; private Integer durationMs; private LocalDateTime createTime; private LocalDateTime updateTime; }编写自定义查询方法
接下来在Mapper中编写几个核心统计逻辑:
@Mapper public interface OcrTaskMapper extends BaseMapper<OcrTask> { /** * 查询指定时间范围内成功任务的平均识别耗时(毫秒) */ @Select("SELECT AVG(TIMESTAMPDIFF(MICROSECOND, start_time, end_time) / 1000) " + "FROM ocr_task " + "WHERE start_time BETWEEN #{start} AND #{end} " + " AND status = 1 " + " AND end_time IS NOT NULL") Double selectAvgDuration(@Param("start") LocalDateTime start, @Param("end") LocalDateTime end); /** * 查询最近N次成功任务中的最大耗时 */ @Select("SELECT MAX(TIMESTAMPDIFF(MICROSECOND, start_time, end_time) / 1000) " + "FROM ocr_task " + "WHERE status = 1 AND end_time IS NOT NULL " + "ORDER BY start_time DESC LIMIT #{limit}") Integer selectMaxRecentDuration(@Param("limit") int limit); /** * 按状态统计任务数量 */ @Select("SELECT status, COUNT(*) AS count FROM ocr_task GROUP BY status") List<Map<String, Object>> selectStatusCount(); }可以看到,这些SQL并没有脱离MyBatisPlus的安全框架:
- 所有参数均通过
@Param注入,防止SQL注入; - 即使是原生SQL,也能享受自动分页、条件构造器等周边功能;
- 返回类型可根据需要灵活设置为基本类型、Map或实体集合。
比如上面的selectStatusCount()方法返回的是List<Map<String, Object>>,非常适合前端图表直接消费。你可以轻松将其转化为饼图数据:
[ {"status": 0, "count": 12}, {"status": 1, "count": 893}, {"status": 2, "count": 5} ]如何使用?封装成服务更易复用
为了让统计逻辑更容易被调用,我们在Service层进行封装:
@Service public class OcrMonitorService { @Autowired private OcrTaskMapper ocrTaskMapper; public void printPerformanceReport(LocalDateTime start, LocalDateTime end) { Double avgDur = ocrTaskMapper.selectAvgDuration(start, end); Integer maxDur = ocrTaskMapper.selectMaxRecentDuration(100); System.out.printf("【性能报告】%s 至 %s\n", start, end); System.out.printf("→ 平均识别耗时:%.2f ms\n", avgDur != null ? avgDur : 0); System.out.printf("→ 最近100次最大耗时:%s ms\n", maxDur != null ? maxDur : "N/A"); List<Map<String, Object>> counts = ocrTaskMapper.selectStatusCount(); counts.forEach(map -> { Integer status = (Integer) map.get("status"); Long count = ((BigInteger) map.get("count")).longValue(); String desc = status == 0 ? "待处理" : (status == 1 ? "成功" : "失败"); System.out.printf("→ [%s]: %d 条\n", desc, count); }); } }这个方法可以被定时任务每日凌晨触发,生成日报;也可以暴露给管理后台,供运维人员手动查看实时指标。
更重要的是,它为后续扩展留足了空间。例如:
- 加入P95/P99耗时统计;
- 按用户、文件类型、图像尺寸等维度做分组分析;
- 当最大耗时超过阈值时自动发送告警邮件。
整体架构与工作流程
整个系统的协作关系可以用一张简图表示:
+------------------+ +---------------------+ | 客户端请求 | ----> | HunyuanOCR Web/API | +------------------+ +----------+----------+ | v +------------+-------------+ | Spring Boot + MyBatisPlus | | 数据库:MySQL | +------------+-------------+ | v +-----------+-----------+ | Prometheus + Grafana | | (可选:监控可视化) | +-----------------------+典型的工作流如下:
- 用户上传图片 → 触发 OCR 请求;
- 系统生成唯一
task_id,插入ocr_task表,记录start_time; - 调用本地或远程部署的 HunyuanOCR 接口;
- 收到响应后,更新
end_time和status,触发耗时计算; - 后续通过自定义SQL查询历史数据,生成报表或接入可视化平台。
这套流程看似简单,却解决了几个长期存在的痛点:
- 缺乏性能基线:过去只能模糊地说“差不多一秒内完成”,现在可以精确回答“过去一小时平均耗时387ms”;
- 难以定位瓶颈:如果发现某段时间整体变慢,可以通过最大耗时分布判断是普遍性延迟还是个别异常任务拖累;
- 运维决策无据可依:有了真实数据支撑,资源扩容、模型替换、SLA承诺才不再是拍脑袋决定;
- 商业化计费困难:未来若要推出按调用量收费的服务,每条记录的耗时和状态都是计费依据。
工程实践建议:哪些坑我们踩过
在实际落地过程中,我们也遇到过一些意料之外的问题,总结出以下几点最佳实践:
✅ 推荐做法
优先使用数据库函数计算时间差
避免应用层System.currentTimeMillis()计算,防止因机器间时钟不同步导致负耗时。为高频查询字段建立复合索引
如(status, start_time)组合索引,能显著提升按状态+时间段查询的性能。异步写入日志以降低主流程压力
在高并发场景下,建议通过消息队列(如Kafka/RabbitMQ)解耦任务记录的持久化操作。定期归档冷数据
超过3个月的任务数据可迁移至历史表或数据仓库,保持主表查询效率稳定。
⚠️ 注意事项
统一时区标准
所有服务务必使用 UTC+8 时间,避免因开发机、测试环境、生产环境时区不一致引发bug。注意NULL值处理
未完成任务的end_time为 NULL,聚合查询必须加上AND end_time IS NOT NULL条件,否则可能导致结果偏移。禁止动态拼接表名或字段名
即便使用MyBatisPlus,也不要为了“灵活性”而允许${tableName}这样的表达式,极易引发SQL注入风险。事务边界要清晰
任务创建与状态更新应在同一事务中完成,避免出现“有开始无结束”的脏数据。
写在最后:让AI跑得明明白白
HunyuanOCR这样的端到端轻量模型,让我们可以用极低成本实现高质量的文字识别。但真正的工程化落地,从来不只是“能不能用”,而是“好不好管”。
通过MyBatisPlus自定义SQL的方式,我们将每次OCR调用的生命周期纳入监控视野,既没有引入复杂的中间件,又能快速响应业务变化。这种“小而美”的设计思路,特别适合中小规模系统构建初步的可观测能力。
更重要的是,它传递了一种理念:AI服务不应该是个黑盒。我们不仅要让它看得清文字,更要让它跑得明明白白。每一次识别的耗时、每一个失败的原因、每一笔调用的归属,都应该成为可追溯、可分析、可优化的数据资产。
当你的团队开始讨论“昨天P95耗时上升了15%是不是因为新图片格式”时,你就知道,这套简单的统计机制,已经悄然推动了智能化运营的进程。