农业虫害能不能靠“拍照识别”来解决,这个想法在农业信息化圈子里被问了不止一年。真正动手去做,是因为团队接了一个试点需求:让农户拿起手机拍一下叶片、果实甚至虫体,系统自动判断这是什么害虫、危害程度大概什么样、下一步应该怎么防治。最初以为这就是一个人脸识别的翻版,真深入进去才发现,识别模型只是整条链路的一小部分,业务怎么承接上传、异步推理怎么做、高并发下如何不拖垮其他接口,才是工程上最耗时的地方。
最终我们交付的是一套以 SpringBoot + Spring Cloud 为核心的微服务分布式系统,前端用 Vue 搭建业务界面,害虫识别模型以独立推理服务形式接入。整套系统按用户、图像、识别、告警、日志拆成了五个核心服务,通过网关统一入口,Nacos 做服务注册与配置管理,Redis 扛住热点查询,RabbitMQ 做识别任务的削峰填谷。现在系统已经跑过试点阶段,这篇博文把架构设计、核心模块实现、参数配置、问题排查和部署扩容从头到尾梳理一遍,给正在做类似农业信息化或者图像类应用的团队一份可直接参考的方案。
1. 整体设计思路:这个场景为什么必须上微服务
1.1 单体应用为什么扛不住这个场景
先说需求本身。农户拍照上传的图片,原图动辄 3MB 到 8MB,用户量不算大时没什么感觉,可一旦进入推广期,某个时间段内集中上传,带宽和内存压力会立刻上来。更麻烦的是,害虫识别模型在 CPU 上推理一次平均要 1 到 3 秒,如果让识别逻辑和用户登录、图片上传这几个接口跑在同一个进程里,一个高耗时推理接口就会把 Tomcat 线程池占满,其他普通接口全部跟着超时。这就是典型的故障放大效应。
再加上团队分工也不允许单体。算法同学用 Python 训练模型,Web 后端用 Java 写业务,前端用 Vue 做界面,三拨人对部署环境、发布节奏、资源消耗的要求都不一样。假如强行揉在一个应用里,每次模型升级都要重新构建整个 Java 工程,算法同学改个预处理逻辑还得找后端发版,效率和风险都不可控。所以从第一轮方案评审开始,我们就没纠结过单体架构,核心问题只在于微服务要拆多细。
1.2 服务边界怎么划分才合理
微服务拆分最怕拍脑袋。我们的原则很简单:按业务领域划分,不按技术组件划分。比如“用户管理”是一个完整领域,不是一个“登录 controller”;“图片上传”虽然底层涉及文件存储、压缩、校验,但对外就是一个图像服务。基于这个原则,最终拆出来五个服务,边界如下表:
| 服务名称 | 核心职责 | 数据归属 | 关键技术点 |
|---|---|---|---|
| 网关服务 | 统一入口、路由转发、鉴权过滤、限流 | 无状态 | Spring Cloud Gateway |
| 用户服务 | 注册、登录、用户档案管理 | user 库 | Spring Security + JWT |
| 图像服务 | 图片上传、格式校验、压缩、对象存储 | image 库、对象存储 | 异步处理 + Redis 缓存 |
| 识别服务 | 接收识别任务、调用模型、保存识别结果 | recog 库 | OpenFeign + 消息队列 |
| 告警服务 | 根据识别结果生成防治提醒与预警记录 | alert 库 | RabbitMQ 消费者 |
拆完以后,每个服务可以独立部署、独立扩容。比如识别服务是 CPU 密集型的,推广期可以横向加到 5 个实例,而用户服务保持两个实例就够了。图像服务如果对象存储带宽吃紧,也只需要单独调这个服务的资源,不会影响其他功能。
我特别想强调一个点:这个系统里的告警服务一开始并不在拆分清单里。是因为识别结果出现后要推送防治建议,而防治建议的模板规则变化很快,跟识别服务放一起会导致识别服务频繁发版,才单独拎出来。这说明服务边界不是做完就固定,是会根据发布频率和团队职责动态调整的。
1.3 端到端的核心调用链路
整个系统最关键的链路就是“拍图识别”这条主线。前端把图片传给图像服务,图像服务完成校验、压缩、存储后返回一个 fileId;随后调用识别服务的提交接口,识别服务生成一个 taskNo 并丢进 RabbitMQ;模型推理容器消费队列,执行目标检测和分类,回写识别结果;前端通过轮询 taskNo 拿最终结果。这里有一个很重要的取舍:识别任务没有做成同步 HTTP 长连接,而是采用异步任务状态轮询。
原因是推理时间不稳定。模型在 CPU 上快的时候 1 秒,慢的时候能到 8 秒,如果让前端一直挂着一个 HTTP 请求等待,网关、代理、Tomcat 的连接都会被长时间占用。分布式系统里长连接是最容易出问题的环节,超时一多就容易出现雪崩。改成异步后,上传接口一秒内返回,识别服务慢慢消费,前端每两秒轮询一次任务状态,用户体验虽然多了一点等待感,但整个系统的稳定性和吞吐量完全不一样。
2. 技术选型与核心原理:这套组合到底好在哪里
2.1 Spring Cloud 各组件在系统里的实际分工
微服务不是把服务拆开就完事,拆完以后服务怎么互相发现、配置怎么统一管理、流量怎么治理才是真正的工作量。我们用 Spring Cloud 这套体系,各组件分工非常明确:
| 组件 | 实际用途 | 为什么必须用 |
|---|---|---|
| Nacos | 服务注册中心 + 配置中心 | 同时解决服务发现和配置动态刷新,少维护一个组件 |
| Spring Cloud Gateway | 统一网关 | 所有外部请求只走一个入口,鉴权和限流逻辑不做在业务服务里 |
| OpenFeign | 服务间声明式 HTTP 调用 | 代码里像调本地接口一样调远程服务,传参和超时配置都集中在注解里 |
| Sentinel | 限流、熔断、降级 | 防止某个服务抖动拖垮整条链路,支撑可用性兜底 |
| RabbitMQ | 异步事件与任务分发 | 解耦识别耗时操作,实现削峰填谷 |
选择 Nacos 而不是 Eureka,现实原因有两个:一是 Nacos 同时提供配置中心功能,省掉了一整套 Spring Cloud Config 的部署和维护成本;二是中文社区资料多、坑基本都被踩平了。配置管理这一点在后文会详细讲,这里先记住一个原则:配置文件里凡是不同环境会变的参数,都不写死在本地,统一挪到 Nacos 上。
2.2 Vue 前端的技术栈与组织方式
前端选 Vue 3 + Vite + Element Plus + Pinia。选 Vue 不是因为它最流行,而是因为这个系统的界面主要是表单、列表、上传这类中后台交互,Vue 的单文件组件写起来更直接,Element Plus 的表格和表单组件开箱即用,团队上手成本低。Vite 做开发服务器启动很快,日常调试体验比 Webpack 时代舒服得多。
前端目录结构参考如下:
src/ api/ # 按服务拆分接口请求 user.js image.js pest.js views/ recognize/ # 拍照识别主页面 history/ # 识别历史记录 manage/ # 后台管理 store/ # pinia 状态管理 utils/ # 请求实例、token 处理、图片压缩请求部分统一封装 axios 实例,拦截器里自动附带 JWT token,响应拦截器统一处理错误码。这个系统跨微服务调用比较多,前端并不会感知到后端有几个服务,因为网关把所有路径统一成了/api/user/**、/api/image/**、/api/pest/**,前端只管调业务路径,服务拆分对前端是透明的。
2.3 识别模型为什么独立成容器,不融进 SpringBoot
算法团队最初给的是 PyTorch 训练好的模型文件,如果在 Java 服务里直接用 DJL 或者 ONNX Runtime 加载模型,也不是不行,但我们最终没有这么做。模型推理服务单独做了一个 Python 容器,对外只暴露一个 HTTP 推理接口,SpringBoot 识别服务通过 HTTP 调用它。
这是三个层面的考虑。首先是资源隔离:模型加载到内存后动辄几百 MB,推理时 CPU 占用也很高,如果和业务服务混在一起,一次 JVM GC 都可能影响模型服务端的响应;反过来,模型服务内存泄漏也不会直接干掉业务服务。第二是开发解耦,算法同学迭代模型完全不需要碰 Java 代码,只用保证推理接口的输入输出格式不变。第三是部署弹性:模型服务可以单独申请带 GPU 的节点,或者按 GPU 资源横向扩展,业务服务完全不用跟着扩。
推理容器内部结构也很简单:接收图片 URL 或者 base64,经过预处理后送入模型,输出 top5 的类别和置信度,返回结构类似{"code":0,"data":{"items":[{"label":"棉铃虫","confidence":0.9321},...]}}。整个交互就这一个接口,约定清楚以后两边并行开发完全不受影响。
3. 核心模块设计与实现细节
3.1 登录鉴权与服务间调用凭证
多服务下的登录鉴权和单体完全不同。单体里登录状态用 Session 共享很容易,微服务里用户请求先经过网关,再由网关转发到不同服务,如果每个服务都去解析一遍 Session,逻辑会非常割裂。这里采用了网关统一解析 JWT 的方式:
- 用户登录成功后,用户服务签发 JWT 返回给前端,JWT 里只放 userId、userName、过期时间,不塞业务数据。
- 前端每次请求在 Header 里带
Authorization: Bearer <token>。 - 网关过滤器校验 JWT,校验通过后把 userId 解析出来,放到请求头
X-User-Id里再转发给下游服务。 - 业务服务不再重复校验 JWT,只要看到
X-User-Id头就认为用户身份可信,直接从请求头取 userId。
这里有个细节要特别注意:下游服务彼此之间也会通过 OpenFeign 发起调用,这时候请求头里没有 JWT,网关也不会经过,所以需要在 Feign 请求拦截器里做两件事,一是内部调用凭证,二是透明传递用户身份。内部凭证设置一个X-Internal-Token头,每次请求带上,服务端写一个拦截器校验这个 token,防止测试时绕过网关直接打到服务端口。用户身份的透明传递则通过 RequestContextHolder 完成,关键代码示意如下:
@Bean public RequestInterceptor internalTokenRequestInterceptor() { return template -> { template.header("X-Internal-Token", internalApiToken); ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (attrs != null) { String userId = attrs.getRequest().getHeader("X-User-Id"); if (userId != null) { template.header("X-User-Id", userId); } } }; }这段代码是很多微服务项目容易漏掉的细节。没有它,识别服务在异步拿结果时往往丢掉了用户身份,后面做历史记录关联就要重新查询一次,既增加数据库压力,也增加排查问题的时间。
3.2 图片上传链路的完整实现
上传链路是整个系统最容易被人忽视却又最影响体验的一环。农户手里的手机拍照精度很高,一张照片经常超过 5MB,如果不加控制地直接上传,带宽压力大不说,推理服务处理大图也会明显变慢。我们对上传链路做了三道处理:
第一道是格式和大小预检。前端在上传前用 JS 读取文件类型和大小,超过 10MB 直接拦截,并用 canvas 做一次等比压缩;后端不能只信前端,同样校验文件头 magic number,防止有人伪造扩展名上传脚本文件。
第二道是服务端压缩和格式转换。图片如果宽高超过 1600px,等比缩放到 1600px 以内,并转成 JPEG 格式,质量参数设为 0.85。压缩后的图片存储到对象存储服务,路径按年、月、userId 分目录,避免单目录文件过多影响访问速度。
第三道是文件访问 URL 的有效期控制。对象存储生成的临时访问 URL 设置 30 分钟过期,供识别服务和前端预览使用,避免长期暴露原图地址,也在一定程度上减少图片被爬走的风险。
后端接口定义如下:
@PostMapping("/upload") public Result<UploadVO> upload(@RequestPart("file") MultipartFile file, @RequestHeader("X-User-Id") Long userId) { // 1. magic number 校验 // 2. 图片压缩转码 // 3. 上传对象存储,获取临时 URL // 4. 写 image 记录,返回 fileId 和缩略图 URL }前端用 Element Plus 的 el-upload 组件,接入自定义请求实现,上传时显示进度条,上传完成后前端立刻拿到 fileId,再调用识别服务的提交接口。这一串动作如果全部走完可能要两三秒,所以前端要保持清晰的阶段提示,否则用户会误以为自己没点成功。
3.3 识别任务表与异步状态流转
异步识别的核心不是 RabbitMQ 本身,而是任务表的设计。我用一张pest_task表记录每个识别任务的全生命周期,表结构大概如下:
CREATE TABLE pest_task ( id BIGINT PRIMARY KEY AUTO_INCREMENT, task_no VARCHAR(32) NOT NULL UNIQUE COMMENT '业务任务号', user_id BIGINT NOT NULL COMMENT '提交用户', image_url VARCHAR(255) NOT NULL COMMENT '待识别图片URL', status TINYINT NOT NULL DEFAULT 0 COMMENT '0等待 1识别中 2成功 3失败', result_json MEDIUMTEXT COMMENT '识别结果JSON', confidence DECIMAL(5,4) COMMENT '最高置信度', created_at DATETIME NOT NULL, finished_at DATETIME NULL, KEY idx_user_created (user_id, created_at) ) COMMENT '害虫识别任务表';所有状态变更都围绕这张表展开。前端提交识别请求后,识别服务生成唯一 taskNo,插入一条等待中的记录,把任务消息发给 MQ,然后立刻返回 taskNo。识别服务的消费者收到消息后把状态改成识别中,调用模型服务拿到结果后更新为成功并写入 result_json。前端轮询/pest/task/{taskNo}接口,通过状态字段判断流程结束没有。
为什么不用同步触发而是走消息队列,前面已经解释过超时问题。这里补充一个幂等设计:任务号是唯一的,RabbitMQ 消费端自然要做好重复消费处理,我们在消费逻辑里先查任务表,如果状态已经是成功就跳过,保证消息重投不会产生重复更新。这是异步系统里最容易踩的坑,后文排查章节还会提到。
4. 参数配置与分布式实践:哪些参数值得认真调
4.1 Nacos 配置中心与多环境隔离
微服务最直接的痛点就是配置文件管理。五个服务,每个服务又有 dev、test、prod 三套配置,如果还停留在本地 application.yml 手动改,发布时候很容易漏改或者改错。Nacos 作为配置中心统一接管后,整个系统只保留一个bootstrap.yml,其余配置全部按 DataId 动态获取。
我们的组织方式是这样的:每个服务一个专属配置文件pest-recognition.yml,公共配置抽成common-db.yml、common-mq.yml等,通过shared-configs引用。环境隔离用 namespace 实现,dev、test、prod 各一个命名空间,配置内容完全隔离,彻底避免测试环境的配置干扰生产。
spring: application: name: pest-recognition cloud: nacos: server-addr: ${NACOS_ADDR:127.0.0.1:8848} namespace: ${NACOS_NAMESPACE:dev} group: AGRICULTURE_GROUP file-extension: yml shared-configs: - dataId: common-db.yml refresh: true - dataId: common-mq.yml refresh: true这里要踩过坑才有体会:group 一定要统一,否则 A 服务引不到 B 服务的配置。我们用AGRICULTURE_GROUP作为全局 group,只有特殊场景才临时切换。配置中心的参数改动要注意refresh: true才能动态生效,比如数据库连接池大小、超时时间这些参数,线上调整时不用重启服务,非常实用。
4.2 网关路由、超时与限流规则
网关是整个系统的门面,路由配置是否正确直接影响所有请求。核心路由片段:
spring: cloud: gateway: routes: - id: user-route uri: lb://pest-user predicates: - Path=/api/user/** filters: - StripPrefix=1 - id: image-route uri: lb://pest-image predicates: - Path=/api/image/** filters: - StripPrefix=1 - id: pest-route uri: lb://pest-recognition predicates: - Path=/api/pest/** filters: - StripPrefix=1路由配置里lb://前缀表示从 Nacos 服务发现里拿实例地址,这样服务扩容增加实例以后网关不需要改配置,会自动负载均衡。这个机制让水平扩容变得非常简单,也是微服务比单体更容易弹性的关键。
网关还需要配两层超时。第一层是 HTTP Client 超时,控制网关与下游服务之间的连接建立和响应等待;第二层是全局路由的超时时间。我们同步接口的网关超时设置为 10 秒,异步任务提交接口也是 10 秒,但模型推理因为走异步链路,不会占用网关连接。
限流方面用过 Sentinel 做接口级控制,重点限了两个接口:图片上传接口按 IP 限制每分钟 60 次,识别任务提交接口按用户限制每分钟 30 次。规则配置在 Sentinel Dashboard 里,不用改代码,线上突然有异常流量时可以快速收紧,是非常重要的一道保护。
4.3 Redis 缓存与线程池参数调优
识别结果有一个很明显的局部性特征:同一片农田区域的同一类害虫,在相近时间段内拍出来的照片往往相似,甚至有人会反复上传同一张图片。针对这个特征,我们用 Redis 做了一级结果缓存。
缓存 key 设计是pest:recognize:{图片md5}:{模型版本号},value 存的是识别结果的 JSON 字符串,TTL 设置为 30 分钟。也就是说,同一张图片在 30 分钟内再次发起识别,就不用再走一次模型推理,直接从缓存返回。这个优化效果非常明显,首次识别接口 P95 大约在 680ms 左右,缓存命中后 P95 直接降到 35ms,几乎是秒开。
线程池参数上,业务服务的 Tomcat 线程池我们设置为max-threads=200、accept-count=500。这个值不是越大越好,线程太多反而会增加上下文切换和内存占用。因为核心的模型推理已经异步化,业务服务处理的都是轻量操作,200 个线程足够支撑试点阶段每秒钟几百次的接口调用。
Feign 调用超时参数也单独调过。同一个识别服务里去调用模型推理容器,读超时设置成 60 秒,而用户服务调用识别服务的基础接口,读超时只需要 5 秒。超时参数不能一刀切,要根据具体调用的耗时特征分别配置,否则要不就是设短了频繁误报,要不就是设长了把故障掩盖住,等到用户反馈才发现问题。
5. 实战记录:典型问题与排查思路
5.1 服务间同步调用导致线程池耗尽
这个坑发生在系统联调后期。一开始识别提交接口还是同步阻塞式调用,前端上传完图片后,请求会在识别服务里等待模型推理完成,一次请求要占一个 Tomcat 线程好几秒。当时联调只有十来个人用,感觉不出来,后来做了一轮简单压测,并发 50 个识别请求,业务服务其他接口全部超时,数据库连接池也报警了。
排查时先看了监控,发现服务 CPU、内存都不高,但 Tomcat 线程数一路涨到满。说明瓶颈不在资源消耗,而在线程被长任务阻塞。后来把识别流程彻底改成异步:提交接口只负责落库和发消息,立刻返回;真正的模型调用放到 MQ 消费者线程里。改完之后同步压测同样的并发量,提交接口 P95 稳定在 200ms 以内,其他接口完全不受影响。
这个问题的本质是“慢接口”和“快接口”混在同一个线程池里,慢接口占用了线程,快接口只能排队。解决办法要么是隔离线程池,要么是把慢操作彻底异步化。对于业务系统,我推荐优先异步化,因为模型推理这种任务天然适合做消息队列,没必要为了等一个结果把连接长时间挂在那。
5.2 跨域、网关转发与 Nginx 导致的各种玄学
前端联调阶段最常见的报错不是代码逻辑问题,而是跨域和请求头丢失。我们最开始在图省事,直接在 Spring Boot 里加了@CrossOrigin,结果联调确实顺了,但生产环境部署后发现部分浏览器请求还是报错。后来排查才发现,生产环境是 Nginx 转发到网关,网关再转发到服务,跨域配置在网关层和服务层互相叠加,反而把请求头里的 token 给弄丢了。
正确的做法是区分环境。开发环境下,前端通过 Vite 的 devServer 代理/api到网关地址,规避浏览器跨域限制;生产环境下,Nginx 统一转发/api到网关,同时处理 CORS 响应头,后端代码里不要随意加@CrossOrigin。这个原则确定了以后,跨域问题再没出现过。
还有一个细节容易被忽略:网关转发后 Host 头会变成内部服务的地址,如果内部服务方法里生成了重定向 URL,返回给前端的地址就是错的。我们加了一个过滤器,把 Nginx 传来的原始 Host 头透传到下游,保证生成的 URL 域名正确。
5.3 消息重复消费与数据不一致
试运行期间发现一个奇怪现象:个别识别任务在界面上出现了两条告警记录。查日志发现 RabbitMQ 在某些情况下会把消息重新投递,消费者处理完任务但还没来得及提交消息确认,消息就再次进入了队列。由于没有做幂等处理,同一个 taskNo 就被消费了两次。
解决方案不复杂,核心是给每条消息一个稳定的业务键,消费端做去重。告警表加了message_id唯一索引,保存前先INSERT ... ON DUPLICATE KEY UPDATE,保证了重复消费不会产生重复数据。识别任务表的幂等逻辑同样处理。这里我想额外说一句,很多人喜欢引入分布式事务框架解决这类问题,但实际上这种场景用“本地消息 + 唯一索引 + 重试”就能解决,而且性能更好、维护成本低得多。分布式事务不是不能用,而是要在真正跨服务强一致的场景下才值得引入,像告警这种最终一致就够用的业务,没必要背负那么重的框架负担。
5.4 前端历史记录加载慢的体验优化
识别历史记录页面刚开始是直接把原图 URL 拉出来展示,接口每页 20 条,图片动辄几 MB,页面滚动起来卡得不行,加载也要好几秒。优化方案很直接:列表页只展示缩略图,缩略图由图像服务在图片压缩阶段同时生成,URL 规则是原图路径加_thumb后缀;前端再用懒加载,图片进入视口时才加载。调整后历史记录接口 P95 从 3.2 秒降到 450ms,用户体感可以说是天壤之别。
顺带还调了展示逻辑:识别结果不再把 top5 全展示出来,默认只展示 top3,置信度用颜色分级,超过 0.85 显示为绿色,0.6 到 0.85 显示为黄色,低于 0.6 显示为红色。这个设计不是为了好看,是为了让农户对“系统到底多确定”有个直观判断,避免误以为系统报什么就一定是什么。
6. 部署、扩容与压测心得
6.1 服务容器化与基础资源配置
整套系统最终用 Docker Compose 编排部署。每个服务一个容器,MySQL、Redis、RabbitMQ、Nacos 各自独立,前端用 Nginx 镜像承载。Dockerfile 里有一个很容易踩的坑:Java 应用的堆内存设置如果不显式指定,JVM 默认会按容器内存比例自动分配,出现容器内存上限 512MB 但进程总内存占用超出导致被 OOMKilled。稳妥做法是显式设置堆大小,并且留出元空间和直接内存的余量。
FROM eclipse-temurin:17-jre ENV JAVA_OPTS="-Xms512m -Xmx512m -XX:MaxMetaspaceSize=128m" COPY target/*.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "-Djava.security.egd=file:/dev/./urandom", "-Dspring.profiles.active=prod", "/app.jar"]容器编排时建议增加健康检查,用 Spring Boot Actuator 的/actuator/health接口作为探针,服务启动完才算就绪。没加健康检查之前,部署更新经常出现网关把流量转发到还在启动中的实例,导致一批 503 错误,加了探针之后这种问题基本消失了。
6.2 压测数据与容量评估
试点阶段我们做了一轮相对完整的压测,机器配置是业务服务 8 核 16G,基础中间件单独部署。压测结果大致如下:业务侧用户服务和图像服务整体能到每秒 2600 次请求,响应时间稳定;识别服务因为模型推理耗时较长,单实例在 CPU 上大约 4 核 8G 的情况下,平均推理耗时 1.8 秒左右,并发 6 个推理任务时开始排队。也就是说,识别服务才是整个系统的容量瓶颈,需要重点观察。
推广期判断容量的逻辑是:如果预测高峰期每分钟有 200 人同时提交识别,单张图片推理耗时按 2 秒算,粗略估算需要200 / 60 * 2 = 6.7个并发推理槽位,那么部署两个识别服务实例留出余量就是合理的。这种估算方式虽然粗糙,但比“感觉再多加两个节点”靠谱得多。
数据库连接池我们也做了收敛。HikariCP 的 maximum-pool-size 一开始按默认值 10 配置,压测时发现数据库连接成了瓶颈,调整到 20 之后问题解决。之后没有继续放大,因为连接池过大反而会增加数据库负载。
6.3 如果再重做一遍,我会在这几处调整
系统上线以后回看,有几个决定如果一开始就做,能省下大量排查问题的时间。
第一是接入链路追踪。压测和联调期间,我们经常要为了一个“到底是哪一步慢”翻好几个服务的日志,非常痛苦。SkyWalking 或者 OpenTelemetry 这类工具应该从第一天就接入,服务调用链一目了然,排查效率能快好几倍。
第二是内部接口契约先定好。识别服务和图像服务之间的接口我们前后调整过三次字段,因为两边开发并行,接口约定不严谨。如果一开始就引入 OpenAPI 规范,定义好请求响应模型,后端和算法两边同时按契约开发,就不会出现联调时才发现字段对不上的局面。
第三是服务拆分数量要克制。五个服务对这个系统来说是合适的,但告警服务其实可以和识别服务合并,因为它的业务逻辑量太小,独立部署反而增加了运维成本。拆分粒度应该由“团队维护边界”和“独立部署需求”共同决定,而不是为了微服务而微服务。
第四是监控告警要前置。我们是在线上出现过一次接口异常半小时没人发现之后,才补上了完整的监控体系。其实一个健康的微服务系统,服务存活、接口 RT、队列堆积、数据库连接池水位这几个指标都应该第一时间接入告警。
这套系统走到现在,我最大的体会是:微服务架构真正考验人的不是框架配置,而是对业务场景的理解和对边界条件的把握。识别模型再准,如果上传链路不稳、异步任务丢消息、接口超时无兜底,农户端用起来依然会说是“不好用”。反过来,把工程底座做扎实,哪怕初期模型准确率还有提升空间,整个系统也依然是可演进、可迭代的。如果后面要继续扩展,我会优先考虑在告警服务里接入更多农事数据源,让“识别出害虫”真正走到“告诉农户该怎么办”这一步。