岩屑识别项目的一些Q&A
前端 Vue 使用 multipart/form-data 上传图片和业务参数;Nginx 做上传大小和超时限制;Spring Boot 接收 MultipartFile,但不调用 getBytes(),不转 Base64,不解码图片;Spring Boot 通过 InputStream 把图片流式写入 MinIO;MySQL 保存文件元数据和任务状态;Kafka 只传 taskId、fileId、bucketName、objectKey、modelVersion;消费者通过 HTTP REST 调用 Python/FastAPI 模型服务;模型服务根据 bucketName + objectKey 从 MinIO 读取图片并推理;Spring Boot 接收结果后落库,进入人工复核、专家判定、归档流程。
一、项目总体架构 Q&A
Q1:你这个项目整体是做什么的?
这个项目是面向井场录井岩屑图像识别场景,建设一套集图片上传、模型识别、人工复核、专家判定和结果归档于一体的闭环业务系统。
从业务上看,它不是单纯上传图片然后调用模型,而是把岩屑图片识别做成一个可追踪、可复核、可归档的业务流程。用户上传岩屑图片和样本信息后,系统创建识别任务,通过 Kafka 异步触发模型服务识别;模型返回初步岩性类别和置信度后,进入人工复核;对于低置信度、争议样本或关键井段样本,再进入专家判定;最终结果归档。
从技术上看,系统核心是“图片流式上传 + MinIO 存储 + Kafka 异步识别 + 状态机流转 + Redis 并发控制 + Spring Security 权限隔离”。重点解决的是图片大对象处理、模型调用解耦、异步任务可靠性、重复消费幂等、人工复核闭环和结果可追溯问题。
Q2:你这个项目的完整技术链路是什么?
Vue 前端 ↓ multipart/form-data 上传图片和业务参数 Nginx ↓ 限制上传大小、上传超时 Spring Boot Controller ↓ MultipartFile 接收 file UploadService ↓ 校验文件大小、后缀、MIME、文件头、业务参数 MinIO ↓ Spring Boot 通过 InputStream 流式写入图片 MySQL ↓ 保存图片元数据、创建识别任务、写 outbox 消息 Kafka ↓ 发送识别任务消息,只传 taskId、fileId、bucketName、objectKey、modelVersion Kafka Consumer ↓ 做幂等校验、状态流转、调用模型服务 Python/FastAPI 模型服务 ↓ 根据 bucketName + objectKey 从 MinIO 读取图片并推理 Spring Boot ↓ 接收识别结果、写结果表、更新任务状态 人工复核 ↓ 专家判定 ↓ 归档
Q3:这个项目最核心的难点是什么?
核心难点不是上传图片,也不是简单调用模型接口,而是把一个长耗时、不稳定、需要人工校验的图像识别能力工程化接入业务系统。
主要有五个难点。
第一,图片是大对象,不能长期进入 Java 服务内存,不能放 Kafka,也不能存 MySQL,所以要设计 MinIO 流式上传链路。
第二,模型识别是外部服务调用,可能耗时、超时、失败,不能阻塞上传接口,所以要用 Kafka 异步解耦。
第三,Kafka 是至少一次投递,可能重复消费,所以业务侧要做幂等,包括状态判断、分布式锁、数据库唯一约束和条件更新。
第四,模型识别结果只是初判,不能直接归档,需要人工复核和专家判定,所以任务状态机要清楚。
第五,模型结果是异步回写的,不能覆盖人工复核或专家判定结果,所以结果回写必须基于当前状态做条件更新。
二、图片上传全流程 Q&A
Q4:你的图片上传流程到底是什么?请讲确定流程。
这个项目采用确定流程:前端 Vue 使用
multipart/form-data上传图片和业务参数,后端 Spring Boot 接收 MultipartFile,校验后通过 InputStream 把图片流式上传到 MinIO。具体流程是:
前端把图片 file、样本编号 sampleNo、井号 wellNo、井段 depthStart/depthEnd、采集时间 collectTime 一起提交。请求先经过 Nginx,Nginx 限制上传文件大小和上传超时时间。Spring Boot Controller 用 MultipartFile 接收图片,但业务代码不调用
file.getBytes(),不把图片转 Base64,也不在 Java 里解码图片。UploadService 校验文件大小、后缀、MIME、文件头和业务参数后,生成 taskId、fileId 和 objectKey,然后通过 MinIO Java SDK 使用 InputStream 把图片流式写入 MinIO。MinIO 中固定使用
cuttings-image这个 bucket,objectKey 按日期、井号、任务 ID 和 UUID 生成。图片上传 MinIO 成功后,MySQL 保存文件元数据和识别任务,Kafka 发送识别任务消息。Kafka 消息只包含 taskId、fileId、bucketName、objectKey、modelVersion,不包含图片本体。
Q5:multipart/form-data 是什么意思?为什么你这里用它?
multipart/form-data是 HTTP 文件上传常用的 Content-Type。它不是两个方案,而是一种确定的请求格式,表示一个请求体里可以同时包含文件字段和普通表单字段。这个项目里,一个上传请求中既要传岩屑图片,也要传样本编号、井号、井段、采集时间等业务参数,所以使用
multipart/form-data。请求内容固定包括:
file 岩屑图片 sampleNo 样本编号 wellNo 井号 depthStart 起始深度 depthEnd 结束深度 collectTime 采集时间 remark 备注前端基础校验只用于提升用户体验,后端仍然会重新校验文件大小、后缀、MIME、文件头和业务参数。
Q6:为什么图片要过一下后端?为什么不让前端直接传 MinIO?
这个项目里让图片先过 Spring Boot 后端,主要是为了业务管控、安全校验和任务闭环。
第一,后端要做统一业务校验。上传图片不是单纯存文件,还要绑定样本编号、井号、井段、上传人、采集时间等业务信息。后端需要校验这些参数是否合法,并生成 taskId 和 fileId。
第二,后端要做文件安全校验。不能只相信前端。后端会校验文件大小、后缀、MIME 类型和文件头,避免用户上传伪装文件。
第三,后端要统一生成 MinIO objectKey。objectKey 按日期、井号、任务 ID 和 UUID 生成,避免文件名重复、路径混乱和原始文件名带来的安全问题。
第四,后端要计算 fileMd5,做防重复提交和文件元数据记录。
第五,后端要把 MinIO 文件、MySQL 文件记录、识别任务、Kafka 消息串成一条业务链路。也就是说,上传图片成功后,系统必须能立即创建识别任务,并进入 Kafka 异步识别流程。
如果前端直传 MinIO,确实可以减少后端流量压力,但会增加签名管理、回调校验、文件归属校验和任务一致性处理复杂度。这个项目当前更重视业务流程闭环、安全校验和统一审计,所以采用“前端传后端,后端流式写 MinIO”的方案。
如果后续上传量非常大,可以演进为前端直传 MinIO,但当前项目固定流程是后端中转流式上传。
Q7:Nginx 在上传链路中做什么?
Nginx 是上传链路的第一层保护,主要防止超大文件和慢连接直接打到 Spring Boot。
配置上可以限制:
client_max_body_size 20m; client_body_timeout 30s; proxy_read_timeout 60s; proxy_send_timeout 60s;含义是:单张图片最大 20MB;客户端上传超时 30 秒;后端响应和转发都有超时限制。
如果没有 Nginx 限制,恶意用户上传超大文件,请求会直接进入 Spring Boot,可能占满 Tomcat 线程、临时目录磁盘和后端带宽。
Q8:Spring Boot 怎么接收图片?怎么避免图片进入内存?
Spring Boot Controller 使用 MultipartFile 接收图片:
@PostMapping("/cuttings/upload") public Result<String> upload(@RequestParam("file") MultipartFile file, UploadCuttingsRequest request) { return uploadService.upload(file, request); }关键是业务代码不调用:
file.getBytes()因为这会把整张图片一次性读入 JVM 堆内存。正确方式是使用:
InputStream inputStream = file.getInputStream();然后通过 MinIO Java SDK 流式上传。
Spring Boot multipart 配置可以这样设:
spring: servlet: multipart: max-file-size: 20MB max-request-size: 25MB file-size-threshold: 0B location: /data/app/tmp/upload
file-size-threshold: 0B表示尽量让 multipart 内容写入临时磁盘,而不是保留在内存中。所以这里不是说内存里完全没有任何缓冲,而是不让整张图片以 byte[]、Base64 或 Java 图片对象的形式长期存在于 JVM 堆中。
Q9:为什么内存里放图片危险?
图片对 Java 服务来说是大对象,尤其在并发上传时风险很高。
一张图片文件可能是 10MB,如果调用
file.getBytes(),就会产生一个 10MB 的 byte[]。如果 100 个请求同时上传,就可能瞬间产生 1GB 级别的大对象压力。如果再把图片转成 Base64,体积会膨胀约三分之一。如果写入 Kafka JSON、日志或中间对象,还会产生更多副本。
更危险的是图片解码。一张 4000×3000 的 RGB 图片,解码后内存大概是:
4000 × 3000 × 3 ≈ 36MB所以这个项目里 Java 业务服务只做流式上传和元数据管理,不做图片解码,不转 Base64,不存 byte[],不把图片写入 Redis、Kafka 或 MySQL。
图片真正解码成像素矩阵只发生在 Python/FastAPI 模型服务里,因为模型推理必须读取像素。
Q10:后端上传前具体校验什么?
后端固定做五类校验。
第一,文件非空:
file.isEmpty()第二,文件大小限制,比如不超过 20MB:
file.getSize() <= 20 * 1024 * 1024第三,后缀校验:
jpg / jpeg / png / tif第四,MIME 类型校验:
image/jpeg image/png image/tiff第五,文件头 magic number 校验:
JPEG: FF D8 FF PNG: 89 50 4E 47不能只看后缀,因为用户可以把非图片文件改成
.jpg上传。如果要更严格,可以读取图片宽高 metadata,限制最大像素,避免超大分辨率图片在模型服务解码时造成内存暴涨。
Q11:MinIO 的 bucket 和 objectKey 怎么设计?
这个项目固定使用一个 bucket:
cuttings-imageobjectKey 固定规则:
cuttings/{yyyy}/{MM}/{dd}/{wellNo}/{taskId}/{uuid}.{ext}例如:
cuttings/2025/06/18/WELL-A/T202506180001/8f3a9c2d.jpg这样设计有几个好处。
第一,避免原始文件名重复。
第二,避免中文、空格、特殊字符导致路径问题。
第三,按日期、井号、任务 ID 组织,后续排查方便。
第四,图片路径和业务任务天然关联,方便追溯。
MySQL 中保存 originalName 用于展示,保存 bucketName 和 objectKey 用于真实访问。
Q12:Spring Boot 怎么把图片写入 MinIO?
用 MinIO Java SDK,使用 InputStream 流式上传。
核心逻辑是:
try (InputStream inputStream = file.getInputStream()) { minioClient.putObject( PutObjectArgs.builder() .bucket("cuttings-image") .object(objectKey) .stream(inputStream, file.getSize(), -1) .contentType(file.getContentType()) .build() ); }重点是:
Spring Boot 通过 InputStream 上传;
不调用
file.getBytes();不转 Base64;
不在 Java 里解码图片;
上传成功后只保存 bucketName、objectKey、fileSize、contentType、fileMd5 等元数据。
Q13:MySQL 保存图片吗?
MySQL 不保存图片二进制,只保存图片元数据和业务任务状态。
文件表可以保存:
file_id task_id original_name bucket_name object_key file_size content_type file_md5 upload_user upload_time任务表保存:
task_id sample_no well_no depth_start depth_end status retry_count fail_reason create_user create_time update_timeMySQL 适合存结构化数据,不适合存图片大对象。图片本体固定放 MinIO
Q14:Kafka 消息里传什么?
Kafka 消息只传任务元数据,不传图片。
消息固定类似:
{ "taskId": "T202506180001", "fileId": "F202506180001", "bucketName": "cuttings-image", "objectKey": "cuttings/2025/06/18/WELL-A/T202506180001/8f3a9c2d.jpg", "sampleNo": "S001", "wellNo": "WELL-A", "modelVersion": "v1.2.0", "retryCount": 0 }Kafka 是任务事件通道,不是图片传输通道。图片本体已经在 MinIO 里,Kafka 只告诉消费者要处理哪个任务、图片在哪里、用哪个模型版本处理。
Q15:模型服务怎么拿图片?
Kafka 消费者拿到消息后,通过 HTTP REST 调用 Python/FastAPI 模型服务,请求体传 bucketName 和 objectKey。
请求示例:
POST http://model-service:9000/api/v1/cuttings/recognize Content-Type: application/json { "taskId": "T202506180001", "bucketName": "cuttings-image", "objectKey": "cuttings/2025/06/18/WELL-A/T202506180001/8f3a9c2d.jpg", "modelVersion": "v1.2.0" }模型服务配置 MinIO 内网地址和访问凭证,收到 bucketName 和 objectKey 后,用 MinIO Python SDK 从 MinIO 下载图片到临时目录,然后用 OpenCV 读取图片并推理。
推理完成后删除临时文件,返回岩性类别、置信度、耗时和模型版本。
三、上传一致性与事务 Q&A
Q16:MinIO 上传、MySQL 落库、Kafka 发消息怎么保证一致?
这三个组件之间不能用强分布式事务,项目采用本地事务、outbox、补偿任务和状态机保证最终一致性。
流程是:
第一,Spring Boot 校验图片后,将图片流式上传到 MinIO。
第二,MinIO 上传成功后,开启 MySQL 本地事务。
第三,在同一个 MySQL 事务中插入文件元数据表、识别任务表和 outbox 消息表。
第四,事务提交后,由 outbox 发送器扫描待发送消息,投递到 Kafka。
第五,Kafka 发送成功后,把 outbox 消息状态改为已发送。
如果 MinIO 上传成功但 MySQL 落库失败,会删除刚上传的 MinIO objectKey;如果删除失败,由后台任务清理没有绑定 taskId 的孤儿文件。
如果 MySQL 任务创建成功但 Kafka 发送失败,outbox 表中仍然有待发送消息,后续补偿任务会重新投递。
Q17:为什么不用强分布式事务?
因为这个场景是异步识别任务,模型推理本身就是长耗时外部调用,没有必要把 MinIO、MySQL、Kafka 强行放到一个分布式事务里。
强分布式事务会显著增加复杂度和性能开销,也不适合这种长链路任务。这个项目更适合最终一致性设计:任务先落库,状态可追踪,消息可补偿,失败可重试,异常可人工兜底。
所以采用 MinIO + MySQL 本地事务 + outbox + Kafka + 状态机补偿,比强事务更符合业务场景。
四、Kafka 异步识别 Q&A
Q18:为什么引入 Kafka?
因为模型识别是长耗时、外部依赖型操作,不能放在上传接口里同步执行。
如果上传接口同步调用模型,一旦模型服务变慢、超时或并发升高,Tomcat 工作线程会被占满,上传接口响应变慢,甚至影响整个系统可用性。
Kafka 的作用有四点:
第一,解耦上传和识别。上传接口只负责接收图片、写 MinIO、创建任务、投递消息,不等待模型结果。
第二,削峰填谷。集中上传时,任务先进入 Kafka,消费者按模型服务和数据库能力逐步处理。
第三,提高可靠性。Kafka 支持消息持久化、ack、offset 和失败重试,比内存队列可靠。
第四,便于扩展。后续可以增加识别消费者、通知消费者、统计消费者,不影响上传接口。
Q19:为什么不用线程池直接异步识别?
线程池只能解决单机内异步,不适合做这个项目的主异步链路。
第一,线程池任务在 JVM 内存中,服务宕机后任务会丢失。
第二,线程池不适合跨服务解耦,模型服务是独立部署的 Python/FastAPI 服务,Kafka 更适合服务间异步通信。
第三,线程池削峰能力有限,大量上传任务堆积时容易打满队列,甚至拖垮当前服务。
第四,线程池没有消息持久化、offset、消费位点、重试和死信机制。
所以线程池可以在消费者内部做受控并发,但上传到识别之间的主链路固定用 Kafka。
Q20:Kafka 有哪些 topic?
核心有三个 topic。
第一个是主识别任务 topic:
cuttings-recognition-task上传任务创建后,outbox 发送器向这个 topic 投递识别任务。
第二个是重试 topic:
cuttings-recognition-retry模型服务短暂不可用、网络超时、5xx 错误等可重试异常进入这个 topic。
第三个是死信 topic:
cuttings-recognition-dlq超过最大重试次数、图片损坏、参数非法、模型返回格式严重异常等不可自动恢复任务进入死信 topic。
主任务 topic 负责正常识别,重试 topic 负责延迟重试,死信 topic 负责异常兜底和人工处理。
Q21:消费者组怎么设计?
识别任务主链路使用一个消费者组:
recognition-worker-group这个组里可以部署多个消费者实例,共同消费
cuttings-recognition-task。同一个消费者组内部是竞争关系,一条消息只会被组内一个消费者处理。如果后续需要独立做通知和统计,可以分别设置:
recognition-notify-group recognition-metrics-group不同消费者组之间是广播关系,每个组都能收到同一 topic 的完整消息。
所以不是一个消费者一个组,而是同一类业务逻辑一个组。识别处理一个组,通知一个组,统计一个组。
Q22:消费者内部逻辑怎么拆?
Kafka Listener 不写成一个大方法,而是分层处理。
第一层,MessageListener:接收 Kafka 消息、反序列化、记录入口日志。
第二层,IdempotentGuard:根据 taskId 做幂等判断,判断任务是否已经处理。
第三层,TaskStateService:负责状态机流转,比如待识别到识别中,识别中到待复核。
第四层,ModelClient:负责通过 HTTP REST 调用 Python/FastAPI 模型服务,设置超时、捕获异常。
第五层,ResultParser:解析模型返回 JSON,提取 label、confidence、elapsedMs、modelVersion。
第六层,ResultService:识别结果落库,更新任务状态。
第七层,RetryHandler:判断异常是否可重试,决定进入重试 topic、死信 topic 还是人工兜底。
主流程是:
消费消息 → 幂等判断 → 状态改为识别中 → 调模型服务 → 解析结果 → 写识别结果 → 状态改为待复核 → 手动提交 offset
Q23:Kafka 怎么保证消息不丢?
从生产端、Broker 端、消费端三层保证。
生产端设置
acks=all,要求 leader 和 ISR 副本确认后才认为消息发送成功;同时开启 retries。业务上通过 outbox 表保证消息发送失败后可补偿。Broker 端设置副本数,例如 replication factor 为 3,同时设置
min.insync.replicas=2,避免只有 leader 写成功就返回。消费端关闭自动提交 offset,使用手动提交。只有模型调用成功、识别结果落库成功、任务状态更新成功后,才提交 offset。
但 Kafka 更准确地说是至少一次投递,不保证业务只执行一次,所以业务侧必须做幂等。
Q24:Kafka 重复消费怎么处理?
重复消费通过四层机制处理。
第一,任务状态判断。消费者收到消息后,根据 taskId 查询状态。如果任务已经是待复核、已复核、专家已判定或已归档,说明已经处理过,直接跳过。
第二,分布式锁。处理前使用 Redisson 获取锁:
lock:recognition:task:{taskId}拿到锁才处理,拿不到说明可能有其他消费者正在处理。
第三,数据库唯一约束。识别结果表对 taskId 或 taskId + modelVersion 建唯一索引,防止重复插入。
第四,状态条件更新。模型结果回写时,只允许从“识别中”推进到“待复核”,不能覆盖已复核或已归档状态。
Q25:消费者处理成功,但 offset 提交失败怎么办?
这种情况下 Kafka 会认为消息还没消费成功,后续可能再次投递。
所以业务成功不能依赖
offset,而要以数据库状态为准。第一次消费已经把识别结果落库,并把任务状态改为待复核;第二次重复消费时,消费者查询任务状态发现已经处理完成,就直接跳过。这就是为什么要做业务幂等。offset 是 Kafka 消费进度,不是业务完成状态。
Q26:消费失败怎么重试?
先区分异常类型。
模型服务超时、网络抖动、5xx 错误属于可重试异常,进入重试 topic,最多重试 3 次,并采用退避策略,例如 10 秒、30 秒、1
分钟。图片不存在、图片损坏、参数非法、模型返回格式严重异常属于不可重试异常,直接标记任务识别失败,进入死信 topic 或人工兜底。
不能在消费者线程里无限重试,否则一条坏消息会卡住整个 partition。重试必须有限、有状态、有记录。
Q27:Kafka 消费者能不能无限加?为什么不能?
不能无限加。
第一,同一个消费者组内,有效消费者数量受 partition 数限制。比如 topic 有 8 个 partition,那么同一个
group 里最多 8 个消费者能真正工作,多出来的消费者会空闲。第二,消费者最终要调用模型服务。消费者太多会把压力打到 Python/FastAPI 模型服务,造成排队、超时甚至雪崩。
第三,识别结果要写 MySQL。消费者太多会增加数据库连接池压力、索引写入压力和事务冲突。
第四,还受 MinIO 读取能力、网络带宽、Kafka broker 负载限制。
所以扩消费者之前要看 Kafka lag、模型服务 QPS、数据库连接池、MinIO 访问延迟、CPU/GPU
使用率。瓶颈不在消费者时,盲目加消费者只会放大下游压力。
Q28:partition 数怎么设计?
partition 数决定同一个消费者组的最大并行度,但不是越多越好。
可以根据峰值任务量、单消费者处理能力和模型服务吞吐来估算。
例如模型单图平均推理耗时约 0.1 秒,理论上单个串行消费者每秒可处理 10 张,但实际还要包括 HTTP 调用、MinIO
读取、结果解析、数据库写入,所以单消费者可以按每秒 5 到 8 张估算。如果峰值希望支撑 40 张每秒,可以初步设置 8 到 12 个
partition,再通过压测调整。如果同一样本批次要求有序处理,可以用 sampleId 或 taskGroupId 作为 message key,让同一业务对象进入同一个
partition,保证局部有序。
五、模型服务对接 Q&A
Q29:Spring Boot 和模型服务怎么通信?
Spring Boot 消费者通过 HTTP REST 调用 Python/FastAPI 模型服务。
接口固定为 POST JSON 请求,请求体包含:
taskId bucketName objectKey modelVersion模型服务根据 bucketName 和 objectKey 从 MinIO 读取图片,完成推理后返回:
taskId label confidence elapsedMs modelVersion code message这个项目采用 HTTP REST 是因为 Spring Boot 和 Python/FastAPI
跨语言对接成本低,调试方便,接口语义清晰。当前模型单图推理耗时约 0.1 秒,系统瓶颈主要不在 JSON 序列化,所以没有必要一开始就上
gRPC。
Q30:为什么传 bucketName + objectKey,而不是 Spring Boot 把图片转发给模型服务?
因为图片已经在 MinIO 里,Spring Boot 再下载图片并转发给模型服务,会让 Spring Boot
变成图片中转站,增加网络、线程和内存压力。当前链路是:
Spring Boot → MinIO:上传图片 Kafka Consumer → 模型服务:传 bucketName +objectKey 模型服务 → MinIO:读取图片
Spring Boot 不做图片二次搬运。它只告诉模型服务“图片在哪里”,模型服务自己从 MinIO 读取图片。
这样能避免 Java 服务长期持有图片二进制,也能降低 Kafka 和业务服务压力。
Q31:模型调用超时怎么设置?
超时时间根据模型服务实际耗时分布设置。
模型单图平均推理约 0.1 秒,但不能只看平均值,还要看 P95 和 P99。假设 P99 在 1 秒以内,可以把连接超时设置为 1 到 2
秒,读取超时设置为 3 到 5 秒。连接超时不能太长,因为内网服务连接慢通常说明模型服务异常或网络异常。读取超时也不能无限大,否则消费者线程会被长时间阻塞,导致 Kafka
lag 增长。对于超时、网络异常、5xx 错误做有限重试;对于参数错误、图片损坏、4xx 错误不重试。
Q32:模型服务挂了怎么办?
不能让消费者持续打挂掉的模型服务。
第一,单条任务调用失败后有限重试,超过次数进入重试 topic 或死信 topic。
第二,重试采用退避策略,避免模型服务刚恢复时被大量请求打满。
第三,可以在 ModelClient 层做熔断。如果短时间内连续失败率过高,就暂时熔断,把任务转入延迟重试。
第四,限制消费者并发数。Kafka 可以堆积任务,但消费者不能无节制地把压力打到模型服务。
第五,任务状态必须可见。模型不可用时,任务不能一直卡在识别中,要标记为识别失败可重试或待补偿。
Q33:模型返回结果怎么保存?
识别结果表不能只保存最终类别,至少要保存:
task_id model_version predict_label confidence elapsed_ms
raw_response threshold status error_code error_msg create_time这样后续才能追溯:哪个模型版本识别的、置信度是多少、原始响应是什么、人工复核是否修改过。
如果只存最终类别,后续无法做模型评估、错误分析和专家复盘。
Q34:低置信度结果怎么处理?
低置信度结果不直接归档。
可以按阈值分流:高置信度结果进入普通复核,中等置信度进入重点复核,低置信度直接进入专家判定。
阈值不能凭感觉定,要基于验证集统计。上线后还可以结合人工复核数据继续调整。对于容易混淆的岩性类别,可以设置类别级阈值,而不是所有类别用同一个阈值。
Q35:模型版本升级后,历史结果怎么办?
每次调用模型都记录 modelVersion。模型升级后,新任务走新版本,旧任务保留旧版本结果。
如果要对历史图片重新识别,不直接覆盖旧结果,而是生成一条重新识别记录,保存新模型版本的识别结果。最终归档结果仍然以人工复核或专家判定后的
finalResult 为准。这样可以比较新旧模型效果,也能追溯历史结果是哪个模型版本产生的。
Q36:你说准确率 92.9%、单图耗时 0.1s,这两个指标怎么解释?
准确率 92.9%
是模型在验证集或测试集上的统计指标,需要明确样本量、类别分布、标注标准和评价口径。如果是分类任务,可以按识别正确图片数除以总图片数计算;如果是检测任务,还要看
precision、recall、mAP 等指标。单图耗时 0.1 秒指的是模型服务对单张图片的平均推理耗时,不包括前端上传、Nginx 转发、Spring Boot 写
MinIO、Kafka 排队、消费者调度、数据库落库和人工复核时间。所以面试时不能把模型单图耗时直接说成系统端到端耗时。系统端到端耗时要按完整链路统计。
六、任务状态机与结果闭环 Q&A
Q37:任务状态机怎么设计?
任务状态分为自动识别阶段、人工处理阶段和归档阶段。
自动识别阶段包括:
待识别 识别中 识别成功 识别失败人工处理阶段包括:
待复核 复核中 已复核 待专家判定 专家已判定归档阶段包括:
已归档状态推进必须受控。比如模型结果回写只能从“识别中”推进到“待复核”,不能覆盖“已复核”或“已归档”。
Q38:怎么防止模型异步结果覆盖人工复核结果?
靠状态条件更新和结果分层保存。
模型回写结果时,不是简单:
update task set status = 'PENDING_REVIEW' where task_id = ?而是:
update task set status = ‘PENDING_REVIEW’ where task_id = ? and
status = ‘PROCESSING’;只有任务仍处于识别中,模型结果才允许回写。如果任务已经进入已复核、专家判定或已归档,模型结果不能覆盖。
同时,模型初判结果、人工复核结果、专家判定结果分开保存。最终归档结果以人工复核或专家判定后的结果为准。
Q39:人工复核和专家判定有什么区别?
人工复核是对模型识别结果做常规确认和修正。复核人员查看原图、模型类别、置信度,如果模型识别明显错误,可以修正并填写复核意见。
专家判定主要处理低置信度、争议样本、关键井段样本和复核人员无法判断的样本。专家判定结果优先级更高,通常作为最终归档依据。
所以模型识别是初判,人工复核是质量控制,专家判定是最终裁定。
Q40:这个项目的闭环体现在哪里?
闭环有两层。
第一层是业务闭环。任务从上传开始,经过异步识别、结果回写、人工复核、专家判定,最后归档。每个状态都有记录,不会停留在不可见的中间状态。
第二层是数据闭环。模型识别结果、人工复核结果和专家判定结果都会保留下来。后续可以统计哪些岩性类别容易识别错误、哪些样本置信度低、哪个模型版本效果更好,这些数据可以用于模型优化、阈值调整和训练集补充。
七、Redis、Redisson 与 AOP Q&A
Q41:Redis 在项目里具体用在哪里?
Redis 主要用于四类场景。
第一,热点数据缓存,例如岩性类别字典、模型版本配置、用户权限信息、井号井段基础信息。
第二,接口限流,例如上传接口、登录接口、任务查询接口。
第三,防重复提交,例如同一个用户短时间内重复上传同一张图片。
第四,消费幂等和分布式锁,例如同一个 taskId 的识别任务不能被多个消费者并发处理。
典型 key 包括:
repeat:upload:{userId}:{sampleNo}:{fileMd5}
lock:recognition:task:{taskId} rate:upload:{userId} rate:login:{ip}
dict:lithology:type model:config:{modelVersion}
user:permission:{userId}
Q42:防重复提交怎么做?
上传时根据 userId、sampleNo、fileMd5 生成 Redis key:
repeat:upload:{userId}:{sampleNo}:{fileMd5}使用:
SET key value NX EX 30如果设置成功,继续处理;如果设置失败,说明短时间内重复提交,直接返回提示。
Redis 防重解决短时间重复点击问题,最终还要靠数据库唯一约束兜底,例如 sampleNo + fileMd5
唯一,防止极端并发下重复落库。
Q43:限流和防重复提交有什么区别?
限流控制单位时间内请求次数,比如一个用户一分钟最多上传 10 次。
防重复提交控制同一个业务动作不能重复执行,比如同一个用户同一张图片 30 秒内不能重复上传。
限流 key 是:
rate:upload:{userId}防重 key 是:
repeat:upload:{userId}:{fileMd5}限流关注频率,防重关注业务唯一性。
Q44:AOP 限流怎么实现?
自定义
@RateLimit注解,在注解中配置限流时间窗口和最大请求次数。AOP 切面拦截接口,根据用户 ID、IP、接口路径生成 Redis key。
简单场景可以用固定窗口算法:请求来一次 Redis
INCR一次,第一次设置过期时间,超过阈值拒绝。上传接口属于重操作,更适合滑动窗口。滑动窗口用 Redis ZSet
保存请求时间戳,每次请求删除窗口外数据,统计窗口内请求数,超过阈值拒绝。
Q45:分布式锁怎么设计?
识别任务消费时,锁 key 固定为:
lock:recognition:task:{taskId}这个粒度正好对应业务冲突对象:同一个 taskId 不能被多个消费者同时处理,但不同 taskId 可以并行处理。
不能把锁设计成
lock:recognition,否则所有任务串行,吞吐量很低;也不能加随机数,否则锁失效。
Q46:为什么用 Redisson?
Redis 原生命令可以用
SET key value NX EX实现简单锁,但复杂业务里有几个问题。第一,锁过期时间不好设置。业务没执行完锁过期,会导致并发处理。
第二,释放锁必须判断锁归属,不能直接 DEL,否则可能误删别人的锁。
第三,原生锁不支持可重入和自动续期。
Redisson 封装了这些能力,支持可重入锁和 watchdog 自动续期。业务线程正常运行时,watchdog
会续期;服务挂掉后,watchdog 停止续期,锁最终释放。
Q47:缓存和 MySQL 怎么保证一致?
采用 Cache Aside 模式。
读数据时,先查 Redis,缓存未命中再查 MySQL,然后写入 Redis。
写数据时,先更新 MySQL,再删除 Redis 缓存,而不是直接更新缓存。
对于字典、模型配置、用户权限这类读多写少数据,可以缓存。对于任务状态这种变化频繁的数据,不做长时间缓存,最多设置很短
TTL,避免用户看到旧状态。
八、Spring Security + JWT + 数据权限 Q&A
Q48:Spring Security + JWT 登录流程是什么?
用户登录时,后端根据用户名查询用户信息,用 PasswordEncoder 校验密码。校验通过后生成 JWT,JWT 中放
userId、username、role、过期时间等非敏感信息。前端后续请求在 Header 中携带:
Authorization: Bearer token后端自定义 JWT 过滤器解析 token,校验签名和过期时间。校验通过后,根据 userId 查询权限,或者从 Redis
读取权限缓存,构造 Authentication 放入 SecurityContext。后续接口通过
@PreAuthorize或权限拦截器判断用户是否有对应权限点。
Q49:JWT 里能放什么,不能放什么?
JWT 可以放 userId、username、role、权限版本号、过期时间。
不能放密码、手机号明文、身份证号、密钥、敏感信息和过大的权限列表。
JWT 只是签名防篡改,不是天然加密。前端可以解析 payload,所以敏感信息不能放进去。
Q50:角色和权限点怎么设计?
采用 RBAC:用户—角色—权限点。
角色包括上传人员、复核人员、专家、管理员。
权限点例如:
task:upload task:view:own task:review task:expert:confirm
task:view:all model:config user:manage接口层控制权限点,数据层控制数据范围。这样比硬编码角色更灵活,后续新增角色只需要配置权限点。
Q51:数据权限怎么控制?
接口权限控制能不能访问接口,数据权限控制能看到哪些数据。
普通用户只能看自己上传的任务:
uploader_id = currentUserId复核人员只能看分配给自己的复核任务:
reviewer_id = currentUserId专家能看待专家判定的任务:
status = 'PENDING_EXPERT'管理员可以看全部。
实现上用 AOP + MyBatis 查询条件注入,在查询方法上加
@DataScope
注解,切面根据当前用户角色生成数据范围条件,并传给 Mapper 层。数据权限必须落到 SQL 条件里,不能查出全部数据后在 Java 内存过滤。
九、并发、压测与性能 Q&A
Q52:你这个系统并发量支持多少?怎么测出来的?
这个问题不能笼统回答一个数字,必须先说明压测口径。这个项目的压测分三类:
第一类是上传任务创建链路压测,包括 multipart 上传、Nginx 转发、Spring Boot 校验、MinIO 写入、MySQL
落库、outbox 记录。这类压测受图片大小、网络带宽和 MinIO 写入速度影响。第二类是任务创建接口压测,使用已经存在的 MinIO objectKey 模拟任务创建,只测试 MySQL 写入和 Kafka
投递能力。这类压测结果通常高于完整上传链路。第三类是识别消费链路压测,包括 Kafka 消费、HTTP 调用模型服务、模型从 MinIO
读取图片、推理、结果落库。这类压测受模型服务吞吐、GPU/CPU、MinIO 读取、数据库写入影响。所以如果面试官问“支持多少并发”,我会回答:我们不会把模型单图 0.1 秒直接等同于系统
TPS。并发能力是按链路拆开测的。上传接口看完整文件上传耗时和 P95/P99;任务创建看 MySQL + Kafka;识别吞吐看
Kafka lag、模型服务 QPS 和结果落库耗时。如果必须给数字,建议只说你实际压过的口径。比如可以说:
“我们重点压测的是任务创建和异步识别链路,不把人工复核算进去。任务创建接口在去掉真实大文件上传、使用已存在 MinIO objectKey
的情况下,可以达到较高 TPS;完整上传链路会受单图大小和 MinIO
写入影响,吞吐会低很多。识别链路的上限主要由模型服务吞吐决定,而不是由 Spring Boot 上传接口决定。”不要把“任务创建接口 TPS”说成“完整端到端识别 TPS”。
Q53:如果你说 800 TPS,怎么解释才不容易被问穿?
如果简历或面试中提到 800 TPS,必须限定口径。可以这样说:
800 TPS 指的是任务创建接口或核心业务接口在压测环境下的吞吐,不是完整图片上传 + 模型识别 + 人工复核的端到端 TPS。
压测时使用 JMeter 模拟并发请求,压的是“任务创建 + MySQL 落库 + outbox/Kafka 投递”这一段,图片使用已上传到
MinIO 的 objectKey,不包含真实 multipart 大文件上传,也不包含模型推理耗时。完整链路如果包含图片上传,吞吐会受图片大小、Nginx 带宽、Spring Boot 流式写 MinIO、MinIO
写入能力影响;如果包含模型识别,还会受模型服务吞吐、GPU/CPU、MinIO 读取和数据库写结果影响。所以 800 TPS 不能泛化成整个系统端到端吞吐,只能说是某个接口或某段链路的压测结果。
Q54:压测具体看哪些指标?
主要看这些指标:
上传接口平均响应时间、P95、P99;
接口错误率;
Nginx 请求体超时和 4xx/5xx;
Spring Boot Tomcat 线程池使用情况;
JVM 堆内存、GC 次数、Full GC;
MinIO 写入耗时和失败率;
MySQL QPS、慢 SQL、连接池使用率;
Kafka 生产耗时、consumer lag;
模型服务平均推理耗时、P95、P99、QPS、错误率;
Redis 命令耗时和 slowlog。
压测不是只看 TPS。TPS 高但 P99 很差、错误率上升、Kafka lag 持续增长,都不能算系统稳定。
Q55:如果 QPS 翻倍,你怎么优化?
先定位瓶颈,不盲目加机器。
如果上传接口慢,看 Nginx 配置、Tomcat 线程、图片大小、MinIO 写入、网络带宽。
如果 Kafka lag 增长,看 partition 数、消费者数量和消费者处理耗时。
如果消费者卡在模型调用,瓶颈在模型服务,需要扩模型服务实例、增加 GPU 资源、做批量推理或限制消费者并发。
如果数据库慢,看慢 SQL、索引、连接池、事务范围、结果表写入压力。
如果 Redis 慢,看热点 key、大 key、连接数和 slowlog。
如果 MinIO 慢,看磁盘 I/O、网络、bucket 访问延迟和模型服务读取频率。
优化不是简单加消费者,因为消费者加多后压力会转移到模型服务、MySQL 和 MinIO。
Q56:消息堆积怎么办?
先看 Kafka consumer lag。如果 lag 持续增长,说明生产速度大于消费速度。
然后定位:
消费者 CPU 高,说明消费者处理能力不足;
HTTP 调用模型服务耗时高,说明模型服务瓶颈;
MySQL 连接池耗尽或慢 SQL 增多,说明结果落库瓶颈;
MinIO 读取慢,说明图片读取瓶颈;
Kafka broker 磁盘或网络高,说明 Kafka 本身压力大。
短期可以增加消费者实例、扩模型服务、限流上传入口。长期要结合瓶颈扩 partition、优化模型服务吞吐、优化数据库索引和连接池、优化
MinIO 访问。
十、项目负责人视角 Q&A
Q57:你作为项目负责人,具体负责什么?
我负责的不只是写接口,而是核心业务流程设计和关键技术链路落地。
业务上,我梳理了从图片上传、异步识别、结果回写、人工复核、专家判定到归档的完整状态流转,明确不同角色的操作边界。
技术上,我负责图片上传链路设计,确定图片本体存 MinIO,MySQL 存元数据,Kafka 传任务事件,模型服务从 MinIO 读取图片。
异步链路上,我设计了 Kafka topic、消费者组、消息内容、消费幂等、失败重试和死信兜底。
可靠性上,我通过
Redis、Redisson、AOP、状态机、数据库唯一约束和条件更新,解决重复提交、重复消费、并发处理和异步回写覆盖问题。权限上,我基于 Spring Security + JWT 做认证鉴权,并结合 AOP 做操作日志、异常记录和数据权限过滤。
Q58:这个项目 AI 体现在哪里?是不是只是调模型接口?
不是简单调模型接口,而是把模型能力工程化嵌入业务流程。
模型服务负责输出岩性类别和置信度,但业务系统做了四件关键工作。
第一,把模型识别纳入任务状态机,使每次识别都有 taskId、状态、结果、异常和日志。
第二,对模型结果做置信度分流,低置信度和争议样本进入人工复核或专家判定。
第三,记录模型版本、原始响应、识别耗时和人工修正结果,为后续模型优化提供数据。
第四,通过 Kafka 异步、失败重试、消费幂等、人工兜底,保证模型服务异常时业务系统仍然可控。
所以 AI 落地重点是模型服务工程化、结果可追溯和业务闭环,而不是单纯调用一个接口。
Q59:这个项目最大的风险点是什么?
最大的风险点是异步链路中的状态不一致。
比如图片已经上传 MinIO,但 MySQL 任务创建失败;MySQL 任务创建成功,但 Kafka 消息没发出去;Kafka
消息重复消费,导致重复写结果;模型结果延迟返回,覆盖人工复核结果;模型服务异常,任务长时间卡在识别中。我的处理方式是:
MinIO 上传失败直接返回;
MinIO 上传成功但 MySQL 失败,删除 MinIO objectKey 或后台清理孤儿文件;
MySQL 任务和 outbox 在同一个事务中写入,Kafka 发送失败后补偿投递;
Kafka 消费做状态判断、Redisson 锁、唯一约束和条件更新;
模型失败进入重试 topic、死信 topic或人工兜底;
任务状态全程可见,异常可追踪。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)