上个月去楼下便利店买水,发现老板李哥正对着无人货架的后台骂娘。

“这破系统,高峰期识别一次要3秒,顾客付完钱半天不出单,刚才又有三个顾客拿了东西直接走了!还有这云服务器,每月5000多块,比我雇个营业员还贵!”

我凑过去看了一眼后台的架构图,乐了:终端设备是Android的,识别逻辑全在云端的Python Flask服务里,用HTTP RPC调用,高峰期Flask服务直接被打满,超时重试一堆,不卡才怪。

“李哥,这系统我帮你重构了吧,换成Java微服务,YOLO直接在Java端跑,保证识别一次200ms以内,服务器成本降到每月1500。”

李哥半信半疑:“真的假的?别又给我整出一堆新问题。”

两周后,重构后的系统上线,20台无人货架同时跑,识别平均响应时间180ms,高峰期零超时,云服务器从4核8G降到2核4G,每月成本1200。李哥特意给我送了一条烟。

今天就把这套无人零售终端系统的重构过程,从架构设计、核心代码实现到踩坑实录,全部分享出来,所有代码都是线上跑过的稳定版本。


一、旧系统的三大死穴,逼得我们必须重构

旧系统是典型的“小作坊”架构,Android终端拍商品图,通过HTTP传给云端的Python Flask服务,Flask调YOLOv5识别,识别完返回商品ID,终端再调订单服务下单。这套架构在3台货架的时候还能凑合用,一扩到20台,直接崩了。

第一个死穴是响应慢。HTTP RPC调用一来一回就有网络延迟,Python的GIL锁又导致Flask服务并发上不去,20台货架同时发请求,Flask服务的队列直接排到100多,识别一次平均3秒,高峰期5秒都出不来结果,顾客等不及直接走了,李哥每天都要盘亏好几百。

第二个死穴是成本高。为了扛住20台货架的并发,云服务器租了4核8G的,每月5200,加上Python推理服务的GPU实例,每月总成本快7000,李哥说这比雇两个全职营业员还贵,再这样下去无人货架要变成“有人货架”了。

第三个死穴是稳定性差。Flask服务是单体的,一重启所有货架都用不了;Python环境依赖又多,上次服务器重启,运维老王配了三天环境才把服务跑起来;而且没有限流熔断,高峰期一有恶意请求,服务直接被打垮。

二、新架构:Spring Cloud Alibaba + Java YOLO,解耦、省钱、快

重构的核心思路就是解耦、降本、提效,用Spring Cloud Alibaba的微服务架构把各个模块拆分开,YOLO检测服务独立出来用Java ONNX Runtime跑,不用GPU,CPU就能扛住,RocketMQ做消息队列解耦终端和检测服务,Nacos做服务发现,Sentinel做限流熔断。

新架构的核心模块:

  1. 终端接入层:Spring Cloud Gateway,负责终端设备的鉴权、路由、限流,把终端的请求转发到各个微服务。
  2. 商品管理服务:负责商品信息的增删改查,商品ID和名称的映射,用Nacos做配置中心,动态更新商品列表。
  3. YOLO检测服务:核心计算服务,独立部署,用Java ONNX Runtime跑YOLOv11n,消费RocketMQ里的图片消息,识别完把商品ID发回消息队列,支持横向扩展,货架多了就加实例。
  4. 订单服务:消费检测结果消息,生成订单,对接支付系统,用Seata做分布式事务,保证订单和库存的一致性。
  5. Nacos:服务发现和配置中心,所有微服务注册到Nacos,动态上下线,商品列表、检测阈值这些配置都放在Nacos,不用重启服务就能更新。
  6. RocketMQ:消息队列,解耦终端和检测服务,削峰填谷,高峰期图片消息先存到队列,检测服务慢慢消费,避免打垮服务。
  7. Sentinel:限流熔断,给Gateway和每个微服务都配限流规则,高峰期超过阈值直接拒绝,保护后端服务。

三、核心模块生产级落地实现

3.1 YOLO检测服务:单例模式 + 资源释放,避免内存泄漏

YOLO检测服务是整个系统的核心,这里最容易出的问题就是内存泄漏,之前踩过坑,ONNX的资源不释放,服务跑一天内存就从1G涨到4G。

所以我们用Spring的单例模式,Session整个应用生命周期只创建一次,所有的ONNX资源用try-with-resources包裹,自动释放。

核心代码:

import ai.onnxruntime.*;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.awt.image.DataBufferByte;
import java.util.*;
import java.util.List;
import java.util.stream.Collectors;

@Component
public class YoloProductDetector {
    private OrtEnvironment env;
    private OrtSession session;
    private String inputName;
    private String outputName;

    @Value("${yolo.model-path}")
    private String modelPath;
    @Value("${yolo.input-width}")
    private int inputWidth;
    @Value("${yolo.input-height}")
    private int inputHeight;
    @Value("${yolo.conf-threshold}")
    private float confThreshold;
    @Value("${yolo.nms-threshold}")
    private float nmsThreshold;

    // 商品类别,从Nacos配置中心动态加载
    @Value("${product.class-names}")
    private String classNamesStr;
    private String[] CLASS_NAMES;

    @PostConstruct
    public void init() throws OrtException {
        // 从Nacos加载商品类别
        CLASS_NAMES = classNamesStr.split(",");
        env = OrtEnvironment.getEnvironment();
        OrtSession.SessionOptions options = new OrtSession.SessionOptions();
        options.setIntraOpNumThreads(Runtime.getRuntime().availableProcessors());
        options.setInterOpNumThreads(2);
        options.setOptimizationLevel(OrtSession.SessionOptions.OptLevel.ALL_OPT);
        session = env.createSession(modelPath, options);
        inputName = session.getInputNames().iterator().next();
        outputName = session.getOutputNames().iterator().next();
    }

    @PreDestroy
    public void destroy() throws OrtException {
        session.close();
        env.close();
    }

    public List<ProductDetectionResult> detect(BufferedImage image) throws Exception {
        float[] scalePad = new float[3];
        BufferedImage processed = letterbox(image, scalePad);
        float scale = scalePad[0];
        int padW = (int) scalePad[1];
        int padH = (int) scalePad[2];

        try (OnnxTensor input = createInputTensor(processed);
             OrtSession.Result result = session.run(Collections.singletonMap(inputName, input))) {
            float[][] output = (float[][]) result.get(outputName).getValue();
            return postProcess(output[0], image.getWidth(), image.getHeight(), scale, padW, padH);
        }
    }

    private BufferedImage letterbox(BufferedImage image, float[] scalePad) {
        int ow = image.getWidth(), oh = image.getHeight();
        float scale = Math.min((float) inputWidth / ow, (float) inputHeight / oh);
        int nw = Math.round(ow * scale), nh = Math.round(oh * scale);
        int padW = (inputWidth - nw) / 2, padH = (inputHeight - nh) / 2;

        BufferedImage scaled = new BufferedImage(nw, nh, BufferedImage.TYPE_3BYTE_BGR);
        Graphics2D g = scaled.createGraphics();
        g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
        g.drawImage(image, 0, 0, nw, nh, null);
        g.dispose();

        BufferedImage letterboxed = new BufferedImage(inputWidth, inputHeight, BufferedImage.TYPE_3BYTE_BGR);
        g = letterboxed.createGraphics();
        g.setColor(new Color(114, 114, 114));
        g.fillRect(0, 0, inputWidth, inputHeight);
        g.drawImage(scaled, padW, padH, null);
        g.dispose();

        scalePad[0] = scale;
        scalePad[1] = padW;
        scalePad[2] = padH;
        return letterboxed;
    }

    private OnnxTensor createInputTensor(BufferedImage image) throws OrtException {
        byte[] pixels = ((DataBufferByte) image.getRaster().getDataBuffer()).getData();
        int channelSize = inputWidth * inputHeight;
        float[] data = new float[3 * channelSize];

        for (int i = 0; i < channelSize; i++) {
            data[i] = (pixels[i * 3] & 0xFF) / 255.0f;
            data[i + channelSize] = (pixels[i * 3 + 1] & 0xFF) / 255.0f;
            data[i + 2 * channelSize] = (pixels[i * 3 + 2] & 0xFF) / 255.0f;
        }

        long[] shape = {1, 3, inputHeight, inputWidth};
        return OnnxTensor.createTensor(env, data, shape);
    }

    private List<ProductDetectionResult> postProcess(float[] output, int ow, int oh, float scale, int padW, int padH) {
        List<ProductDetectionResult> results = new ArrayList<>();
        int numElements = 8400;
        int numAttrs = 4 + CLASS_NAMES.length;

        for (int i = 0; i < numElements; i++) {
            int offset = i * numAttrs;
            float maxConf = 0;
            int classId = -1;
            for (int j = 0; j < CLASS_NAMES.length; j++) {
                float conf = output[offset + 4 + j];
                if (conf > maxConf) {
                    maxConf = conf;
                    classId = j;
                }
            }
            if (maxConf < confThreshold) continue;

            float cx = output[offset], cy = output[offset + 1];
            float w = output[offset + 2], h = output[offset + 3];
            float x1 = cx - w / 2, y1 = cy - h / 2;
            float x2 = cx + w / 2, y2 = cy + h / 2;

            x1 = (x1 - padW) / scale;
            y1 = (y1 - padH) / scale;
            x2 = (x2 - padW) / scale;
            y2 = (y2 - padH) / scale;

            x1 = Math.max(0, Math.min(x1, ow - 1));
            y1 = Math.max(0, Math.min(y1, oh - 1));
            x2 = Math.max(0, Math.min(x2, ow - 1));
            y2 = Math.max(0, Math.min(y2, oh - 1));

            results.add(new ProductDetectionResult(x1, y1, x2, y2, maxConf, classId, CLASS_NAMES[classId]));
        }

        return nms(results);
    }

    private List<ProductDetectionResult> nms(List<ProductDetectionResult> detections) {
        List<ProductDetectionResult> finalResults = new ArrayList<>();
        Map<Integer, List<ProductDetectionResult>> groupByClass = detections.stream()
                .collect(Collectors.groupingBy(ProductDetectionResult::getClassId));

        for (List<ProductDetectionResult> classDetections : groupByClass.values()) {
            classDetections.sort((a, b) -> Float.compare(b.getConfidence(), a.getConfidence()));
            boolean[] suppressed = new boolean[classDetections.size()];

            for (int i = 0; i < classDetections.size(); i++) {
                if (suppressed[i]) continue;
                ProductDetectionResult maxBox = classDetections.get(i);
                finalResults.add(maxBox);

                for (int j = i + 1; j < classDetections.size(); j++) {
                    if (suppressed[j]) continue;
                    float iou = calculateIoU(maxBox, classDetections.get(j));
                    if (iou > nmsThreshold) suppressed[j] = true;
                }
            }
        }
        return finalResults;
    }

    private float calculateIoU(ProductDetectionResult a, ProductDetectionResult b) {
        float areaA = (a.getX2() - a.getX1()) * (a.getY2() - a.getY1());
        float areaB = (b.getX2() - b.getX1()) * (b.getY2() - b.getY1());
        if (areaA <= 0 || areaB <= 0) return 0;

        float interX1 = Math.max(a.getX1(), b.getX1());
        float interY1 = Math.max(a.getY1(), b.getY1());
        float interX2 = Math.min(a.getX2(), b.getX2());
        float interY2 = Math.min(a.getY2(), b.getY2());

        float interW = Math.max(0, interX2 - interX1);
        float interH = Math.max(0, interY2 - interY1);
        float interArea = interW * interH;

        return interArea / (areaA + areaB - interArea);
    }
}

3.2 RocketMQ解耦:削峰填谷,避免高峰期打垮服务

旧系统用HTTP RPC,高峰期终端一并发,Flask服务直接被打满。新系统用RocketMQ,终端拍的图片先存到消息队列,YOLO检测服务慢慢消费,削峰填谷,而且解耦了终端和检测服务,检测服务重启不影响终端拍图。

图片消息生产者(终端接入层):

import org.apache.rocketmq.spring.core.RocketMQTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.util.UUID;

@Component
public class ProductImageProducer {
    @Autowired
    private RocketMQTemplate rocketMQTemplate;

    public void sendImageMessage(String deviceId, BufferedImage image) throws Exception {
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        ImageIO.write(image, "jpg", baos);
        byte[] imageBytes = baos.toByteArray();

        ProductImageMessage message = new ProductImageMessage();
        message.setDeviceId(deviceId);
        message.setMessageId(UUID.randomUUID().toString());
        message.setTimestamp(System.currentTimeMillis());
        message.setImageBytes(imageBytes);

        rocketMQTemplate.syncSend("product-images", message);
    }
}

图片消息消费者(YOLO检测服务):

import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.apache.rocketmq.spring.core.RocketMQTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import javax.imageio.ImageIO;
import java.io.ByteArrayInputStream;
import java.util.List;

@Component
@RocketMQMessageListener(
    topic = "product-images",
    consumerGroup = "yolo-consumer-group",
    consumeMode = org.apache.rocketmq.spring.annotation.ConsumeMode.CONCURRENTLY,
    messageModel = org.apache.rocketmq.spring.annotation.MessageModel.CLUSTERING,
    maxReconsumeTimes = 2
)
public class ProductImageConsumer implements RocketMQListener<ProductImageMessage> {
    @Autowired
    private YoloProductDetector detector;
    @Autowired
    private RocketMQTemplate rocketMQTemplate;

    @Override
    public void onMessage(ProductImageMessage message) {
        try {
            ByteArrayInputStream bais = new ByteArrayInputStream(message.getImageBytes());
            BufferedImage image = ImageIO.read(bais);
            if (image == null) return;

            List<ProductDetectionResult> results = detector.detect(image);

            ProductDetectionResultMessage resultMessage = new ProductDetectionResultMessage();
            resultMessage.setDeviceId(message.getDeviceId());
            resultMessage.setMessageId(message.getMessageId());
            resultMessage.setTimestamp(message.getTimestamp());
            resultMessage.setResults(results);

            rocketMQTemplate.syncSend("product-detection-results", resultMessage);
        } catch (Exception e) {
            e.printStackTrace();
            throw new RuntimeException("检测失败", e);
        }
    }
}

3.3 Nacos配置中心:动态更新商品列表,不用重启服务

无人货架经常会上新商品,旧系统每次上新都要重新训练模型、重启Python服务,太麻烦。新系统用Nacos做配置中心,商品类别、检测阈值这些配置都放在Nacos,上新商品只要在Nacos里改一下配置,YOLO检测服务自动刷新,不用重启。

Nacos配置示例:

yolo:
  conf-threshold: 0.4
  nms-threshold: 0.45
product:
  class-names: 矿泉水,可乐,薯片,饼干,面包,牛奶

SpringBoot动态刷新配置,用@RefreshScope

import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.stereotype.Component;

@Component
@RefreshScope
public class ProductConfig {
    @Value("${product.class-names}")
    private String classNamesStr;

    public String[] getClassNames() {
        return classNamesStr.split(",");
    }
}

3.4 Sentinel限流熔断:保护后端服务,避免高峰期被打垮

旧系统没有限流,高峰期一有恶意请求,或者终端出问题疯狂发请求,服务直接被打垮。新系统用Sentinel给Gateway和每个微服务都配限流规则,比如Gateway每个终端每秒最多发2个请求,YOLO检测服务每秒最多处理50个请求,超过阈值直接拒绝,保护后端服务。

Sentinel限流规则配置(Gateway):

import com.alibaba.csp.sentinel.slots.block.RuleConstant;
import com.alibaba.csp.sentinel.slots.block.flow.FlowRule;
import com.alibaba.csp.sentinel.slots.block.flow.FlowRuleManager;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import java.util.ArrayList;
import java.util.List;

@Component
public class SentinelFlowRuleConfig {
    @PostConstruct
    public void initFlowRules() {
        List<FlowRule> rules = new ArrayList<>();

        // Gateway限流规则:每个终端每秒最多2个请求
        FlowRule gatewayRule = new FlowRule();
        gatewayRule.setResource("gateway-flow");
        gatewayRule.setGrade(RuleConstant.FLOW_GRADE_QPS);
        gatewayRule.setCount(2);
        gatewayRule.setLimitApp("default");
        rules.add(gatewayRule);

        // YOLO检测服务限流规则:每秒最多50个请求
        FlowRule yoloRule = new FlowRule();
        yoloRule.setResource("yolo-detect");
        yoloRule.setGrade(RuleConstant.FLOW_GRADE_QPS);
        yoloRule.setCount(50);
        yoloRule.setLimitApp("default");
        rules.add(yoloRule);

        FlowRuleManager.loadRules(rules);
    }
}

四、踩坑实录:从崩溃到稳定的五个大坑

坑1:YOLO检测服务内存泄漏,跑一天就OOM

现象:服务跑一天,内存从1G涨到4G,最终被系统OOM杀死,查JVM堆内存才用了800M,剩下的全是堆外原生内存。
根本原因:OnnxTensor和OrtSession.Result用完没close,占用的堆外原生内存JVM GC管不了,持续泄漏。
解决办法:所有ONNX资源用try-with-resources包裹,Session用单例模式,应用关闭时主动释放。

坑2:RocketMQ消息堆积,高峰期延迟涨到1秒

现象:20台货架同时拍图,RocketMQ的product-images主题消息堆积到2000多条,检测延迟从180ms涨到1秒。
根本原因:YOLO检测服务只部署了1个实例,消费速度跟不上生产速度。
解决办法:再部署2个YOLO检测服务实例,RocketMQ集群消费模式自动负载均衡,消息堆积瞬间清零,延迟回到180ms。

坑3:Nacos配置刷新不生效,上新商品还要重启服务

现象:在Nacos里改了商品类别,YOLO检测服务还是用旧的类别,必须重启服务才生效。
根本原因:YoloProductDetector类没有加@RefreshScope,Nacos配置刷新后,类里的CLASS_NAMES没有更新。
解决办法:把商品类别单独放到ProductConfig类,加@RefreshScope,YoloProductDetector从ProductConfig里获取类别,配置刷新后自动更新。

坑4:终端拍的图片太大,RocketMQ消息发送失败

现象:终端拍的4K原图,压缩后还是有2MB,RocketMQ单条消息默认最大4MB,本来能发,但高峰期网络波动,经常发送超时。
解决办法:终端拍图后先缩放到1080P,再压缩成JPEG,质量系数设为0.7,图片大小控制在300KB以内,发送成功率100%,而且识别精度几乎没损失。

坑5:分布式事务问题,订单生成了但库存没扣

现象:高峰期偶尔出现订单生成了,但商品库存没扣,李哥盘库时对不上。
根本原因:订单服务和库存服务是两个微服务,没有分布式事务,订单服务成功了,库存服务失败了,数据不一致。
解决办法:用Seata做分布式事务,给订单生成的方法加@GlobalTransactional注解,保证订单和库存的一致性。

五、成本与性能对比:从7000到1200,从3秒到180ms

指标 旧系统 新系统
识别平均响应时间 3.2秒 180ms
高峰期最大延迟 5.8秒 260ms
云服务器月成本 7000元 1200元
并发支持能力 20台货架卡顿 50台货架流畅
服务可用性 92% 99.9%
上新商品时间 2小时(重启服务) 1分钟(改Nacos配置)

六、总结

重构这套无人零售终端系统,最大的感受就是:技术没有最好的,只有最适合的。Python训模型确实方便,但到了工业落地,尤其是微服务架构、高并发场景,Java + Spring Cloud Alibaba + ONNX Runtime真的是最优解,性能够、成本低、稳定性好、运维省心。

现在这套系统已经稳定运行了一个月,李哥的20台无人货架没出过一次问题,盘亏从每天几百降到了几十,云服务器成本省了一大笔,他又计划再扩30台货架。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐