
简介一套基于Java、Redis、MySQL与MongoDB实现的无人机飞行管控平台源码面向Java全栈开发者、物联网及无人机方向学习者。项目包含前端fly-ui、后端Fly与无人机客户端client三大模块采用前后端分离架构集成关系型与非关系型数据库覆盖设备管理、飞行任务、实时状态存储等典型场景。资源共701个文件以299个Java后端逻辑、109个Vue页面组件为主另含SQL初始化脚本、YAML/Properties配置及部署说明文档压缩包仅8.35MB目录清晰便于按需检索。目前已有90人学习。随包附有环境使用手册与项目说明文档修改MySQL、Redis、MongoDB连接配置即可启动可帮助理解多数据库协同设计、权限框架集成及客户端通信机制适合作为毕业设计或企业级管控系统二次开发的参考。1. 无人机飞行管控平台为什么非要四种存储一张表拆出三个库凌晨两点值班手机被告警短信震醒一架正在执行巡检任务的无人机越过了电子围栏平台需要同时在屏幕上给出它的实时位置、过去两小时的航迹回放、飞手联系方式以及这个架次还剩多少电量。这四个问题落到存储层就是四种完全不同脾气的数据档案要强一致轨迹只追加不改位置要毫秒级响应告警要扛住并发写入。只靠一台 MySQL 硬撑航迹表很快膨胀到几千万行查询越来越慢最后连告警都跟着卡顿。这个标题的组合不是技术堆砌而是把一份管控业务按数据生命周期拆给了最合适的存储Java 负责把链路串起来MySQL 管飞手和飞机档案MongoDB 存海量航迹Redis 扛实时状态。这套架构适合正在做毕业设计、课程设计或者想给中小型管控系统做技术选型的工程师。接下来我把数据模型、项目骨架、核心链路和踩坑记录一次讲透照着复现能少走三个月的弯路。2. 先把数据模型立住MySQL 管事务、MongoDB 管轨迹、Redis 管状态2.1 四类数据的不同生命周期是选型的唯一依据无人机管控平台里数据大致能分成四类第一类是档案数据包括无人机编号、型号、飞手身份、飞行计划、电子围栏定义这类数据要改、要删、要和其他表做关联必须支持事务放进 MySQL 最稳。第二类是航迹数据每三秒上报一个点一架飞机飞一小时就是 1200 条记录只增不改查询基本是按时间和飞机 ID 扫描一段范围典型的海量追加型数据用 MongoDB 的文档模型存储和分片能力最合适。第三类是实时状态比如飞行中、悬停、告警中、电量百分比、当前经纬度这类数据读写频率极高但单条价值很低用 Redis 的 String 和 Hash 存能扛住高并发。第四类是衍生数据比如告警记录、统计报表本质上可以从上述数据聚合出来按需落地。评判选型是否合理就看数据模型落地时是不是在硬扛。如果你发现某个场景需要给 MySQL 表加 JSON 字段才能存下数据或者要让 MongoDB 做大量联表查询或者要用 Redis 存需要持久化的核心业务数据那大概率是选型或建模出了问题。我在项目里见过的最典型误用就是把飞手的完整档案塞进 Redis——一旦 Redis 重启业务直接断掉这是没分清缓存和数据的关系。2.2 MySQL 五张核心表从无人机档案到告警记录MySQL 侧我一般拆五张表无人机档案表、飞手表、飞行计划表、电子围栏表、告警记录表。无人机和飞手是多对一的关系一个飞手可以操作多台无人机飞行计划关联无人机和飞手一个计划对应一条航线电子围栏是独立的地理区域定义告警记录关联计划记录越界、低电量、失联等事件。建表脚本长这样CREATE TABLE tp_unmanned ( id BIGINT AUTO_INCREMENT PRIMARY KEY, drone_code VARCHAR(32) NOT NULL UNIQUE COMMENT 无人机编号, drone_type VARCHAR(64) COMMENT 机型, pilot_id BIGINT NOT NULL COMMENT 所属飞手ID, status TINYINT DEFAULT 0 COMMENT 0-停机 1-飞行中 2-异常, battery INT DEFAULT 100 COMMENT 电量百分比, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_pilot (pilot_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT无人机档案表; CREATE TABLE tp_pilot ( id BIGINT AUTO_INCREMENT PRIMARY KEY, pilot_code VARCHAR(32) NOT NULL UNIQUE, real_name VARCHAR(64) NOT NULL, cert_level VARCHAR(16), phone VARCHAR(20), created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT飞手信息表; CREATE TABLE tp_flight_plan ( id BIGINT AUTO_INCREMENT PRIMARY KEY, plan_code VARCHAR(64) NOT NULL UNIQUE, drone_id BIGINT NOT NULL, pilot_id BIGINT NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, status TINYINT DEFAULT 0 COMMENT 0-待审批 1-已批准 2-执行中 3-已完成 4-取消, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_drone (drone_id), KEY idx_pilot (pilot_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT飞行计划表; CREATE TABLE tp_geo_fence ( id BIGINT AUTO_INCREMENT PRIMARY KEY, fence_name VARCHAR(64) NOT NULL, fence_type TINYINT DEFAULT 1 COMMENT 1-圆形 2-多边形, center_lng DECIMAL(10,6), center_lat DECIMAL(10,6), radius INT COMMENT 半径单位米, polygon TEXT COMMENT 多边形顶点JSON数组, enabled TINYINT DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT电子围栏表; CREATE TABLE tp_alarm ( id BIGINT AUTO_INCREMENT PRIMARY KEY, plan_id BIGINT NOT NULL, drone_code VARCHAR(32), alarm_type TINYINT COMMENT 1-越界 2-低电量 3-失联 4-超速, alarm_content VARCHAR(256), lng DECIMAL(10,6), lat DECIMAL(10,6), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_plan_time (plan_id, created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT告警记录表;五张表的关联逻辑很简单飞行计划的执行过程中产生告警告警里冗余了 drone_code 和经纬度是为了在列表页不联表就能展示。三个索引覆盖了最常用的查询路径按飞手查无人机、按无人机查计划、按计划查告警。需要注意两个实践点。第一无人机表里冗余了 pilot_id 并建了索引如果你以为把飞手和无人机的关系单独建一张关联表更规范在管控场景里只会让查询多一次 JOIN没必要。第二battery 这个字段放在无人机表里每三秒更新一次这种高频小更新在 MySQL 里压力不大但如果并发过千就得考虑挪到 Redis我在第四部分讲实时链路时会展开。2.3 MongoDB 的航迹集合按计划分片还是按日期分片MongoDB 侧核心是一个航迹集合flight_track外加一个可选的飞行日志集合。航迹文档不建议一条记录存一个点那样一个架次要产生上千条文档查询要把整个计划的数据捞出来排序效率一般。更常见的做法是一分钟一条文档内部用数组存这一分钟内的坐标点这样既保留了轨迹细节又能控制文档数量。{ plan_id: 1001, drone_code: UAV-001, date: 2025-01-15, start_time: 2025-01-15T10:00:00Z, points: [ { t: 2025-01-15T10:00:00Z, lng: 121.473701, lat: 31.230416, alt: 120, speed: 15.2 }, { t: 2025-01-15T10:00:03Z, lng: 121.473801, lat: 31.230516, alt: 121, speed: 15.1 }, { t: 2025-01-15T10:00:06Z, lng: 121.473902, lat: 31.230617, alt: 122, speed: 15.3 } ], created_at: 2025-01-15T10:00:06Z }索引设计是最容易翻车的部分。按 plan_id start_time 建复合索引覆盖查某个计划某段时间的轨迹这条主路径按 date 建索引是为了方便做按天清理的定时任务。如果你要做地理查询给 points.lng 和 points.lat 建 2dsphere 索引但要注意 2dsphere 只能用在 GeoJSON 字段上普通数组里的一对数字是建不了的想用必须把位置改成 GeoJSON Point 结构。分片键的选择我推荐用 plan_id date 的组合思路小规模单机部署不用分片直接用索引就够真到了需要分片的时候按 date 做范围分片最省事因为查询永远带时间范围。见过有人硬按 drone_code 做哈希分片结果查询都要广播到所有分片性能反而更差。2.4 Redis 的 key 设计与数据类型选择String 不只是缓存Redis 在这个平台里承担三个职责设备实时状态缓存、分布式锁、轻量级计数器。数据类型上String 存 JSON 序列化后的设备状态快照Hash 存设备属性的字段级读写Set/List 偶尔用于存储在线设备 ID 集合ZSet 可以存按时间排序的心跳记录。drone:status:{droneCode} - StringJSON{status, lng, lat, battery, lastHeartbeat} drone:online - Set元素为 droneCode表示当前在线设备 drone:heartbeat:{droneCode} - String存最近一次心跳时间戳 alarm:count:{planId}:{date} - String存当天告警次数 lock:cmd:{droneCode} - StringSETNX 实现指令防重带过期时间这里的关键认知是Redis 的数据类型选择不是按看起来像什么决定的而是按要做什么操作决定的。需要判断一个无人机是否在线就用 Set 的 SISMEMBERO(1) 完成需要给设备状态加一个字段用 Hash 的 HSET 只改一个字段避免 String 反序列化整个 JSON。我把 String 用成快照把 Set 用成集合判定这个区分在管控平台上意义很大它决定了一个查在线状态的接口是 1 毫秒还是 5 毫秒。3. 从零搭起管控后端Spring Boot 项目分层与四库联调的骨架3.1 项目骨架Maven 多模块怎么切才不臃肿拿到源码之后我习惯先把项目结构捋一遍。常见做法是 Maven 多模块拆成 common、dal、service、web 四个模块。common 放通用工具和常量dal 放 MyBatis 的 Mapper 和 MongoDB 的 Repositoryservice 放业务逻辑web 放 Controller 和启动类。每个模块职责单一四库联调时的依赖关系也不会乱。uav-control-platform/ ├── pom.xml ├── uav-common/src/main/java/... 通用工具、常量、统一返回体 ├── uav-dal/src/main/java/... MySQL Mapper MongoDB Repository ├── uav-service/src/main/java/... 业务逻辑事务边界在这里控制 └── uav-web/src/main/java/... Controller Application 启动类pom.xml 里的依赖版本最容易踩坑尤其是 MongoDB Driver 和 MySQL Connector 的版本冲突。我一般用 Spring Boot 2.7.x 的 BOM 统一管版本额外引入 mongodb-driver-sync 和 mysql-connector-j不手动指定版本号。parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-mongodb/artifactId /dependency /dependencies骨架搭建完成后第一件要确认的事是 Spring Boot 版本和驱动版本是否兼容。常见的翻车现场是 Spring Boot 2.3 配了新版 MySQL Connector/J 8.0.33启动时直接报 ClassNotFoundException因为驱动类名从 com.mysql.jdbc.Driver 换成了 com.mysql.cj.jdbc.Driver。这类问题靠调整版本号能解决但也提示我们拿到源码第一步不要急着写业务先把依赖树跑一遍 mvn dependency:tree看看有没有红色冲突。3.2 一套 application.yml 管四个数据源连接池参数一次调对四存储的联调核心在配置。DataSource 管 MySQLMongoClient 管 MongoDBRedisTemplate 管 Redis三个客户端各自独立初始化互不干扰。spring: datasource: url: jdbc:mysql://localhost:3306/uav_control?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: root123 driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 max-lifetime: 1800000 data: mongodb: uri: mongodb://admin:admin123localhost:27017/uav_control?authSourceadmin auto-index-creation: true redis: host: localhost port: 6379 password: redis123 database: 0 lettuce: pool: max-active: 50 max-idle: 20 min-idle: 5 max-wait: 3000ms三个配置各有一个参数需要特别关心。MySQL 的 HikariCP 里 maximum-pool-size 不是越大越好默认 10 足够撑起这个平台的并发设到 50 反而会让数据库端连接数超限MongoDB 的 URI 里 authSourceadmin 是指定认证库很多人因为漏了这个参数导致连不上Redis 的 lettuce 连接池 max-active 设 50是因为航迹上报接口要大量读设备状态池子小会排队。这套配置跑通后验证方式很简单启动项目如果在日志里看到 HikariPool 启动、MongoDB cluster 描述输出、Redis 连接成功三条信息说明四库联调通了。我一般会在启动类里加一个 ApplicationRunner启动后自动执行一次 ping 三库的操作状态不对就快速失败。3.3 核心 Service 代码写入 MySQL 后如何保证 MongoDB 不丢数据航迹上报是平台里最核心的写入链路。无人机每隔三秒上报经纬度这个数据既要做实时展示走 Redis又要落库存档走 MongoDB还要在越界时产生告警写 MySQL。链路长任何一个环节失败都不能让主流程卡死。我用的方案是异步削峰加本地状态标记。无人机上报接口先写 Redis 更新实时状态再把原始报文丢进一个内存队列后台线程批量写入 MongoDB。MySQL 侧的告警记录只在越界时写通过一个独立的状态字段保证不重复。Service public class TrackReportService { Autowired private StringRedisTemplate redisTemplate; Autowired private MongoTemplate mongoTemplate; private static final int BATCH_SIZE 200; private ListFlightTrackPoint buffer new ArrayList(); public void report(TrackPoint point) { // 第一步更新 Redis 实时状态这是响应最快的路径 String key drone:status: point.getDroneCode(); redisTemplate.opsForValue().set(key, JSON.toJSONString(point), Duration.ofSeconds(30)); // 第二步加入批量缓冲区由调度线程落 MongoDB synchronized (buffer) { buffer.add(point); if (buffer.size() BATCH_SIZE) { ListFlightTrackPoint batch new ArrayList(buffer); buffer.clear(); mongoTemplate.insert(batch, flight_track); } } } }逻辑说明先写 Redis 是为了让实时位置立刻可见TTL 设 30 秒如果无人机掉线这个 key 会自动过期在线状态自然失效。批量缓冲区用的是同步锁加 ArrayList简单场景足够没必要上 Disruptor 之类的重型方案。批量写入 MongoDB 是一次 insert 多条比一条一条 insert 快一个数量级。这个实现最需要注意的是批量缓冲区是纯内存的如果 JVM 崩溃缓冲里未落库的点会丢。对航迹数据来说丢几秒的可接受如果你在做的业务不允许丢就需要引入本地文件持久化或者直接同步写 MongoDB代价是上报接口的 RT 会从 2 毫秒涨到 15 毫秒左右。3.4 给 JSON 字段定义 POJO避免 MongoDB 文档和 Java 对象对不上MongoDB 的文档是无模式的但 Java 代码不是。写 Repository 之前先定义好 POJO把文档结构固定下来。最常见的坑是 ObjectId 字段映射和 GeoJSON 字段的解析。Data public class FlightTrackDoc { Id private String id; private Long planId; private String droneCode; private String date; private ListTrackPoint points; private Date createdAt; Data public static class TrackPoint { private String t; private Double lng; private Double lat; private Double alt; private Double speed; } }注意事项第一id 字段用 String 接收 ObjectId不要用 Long否则 MongoDB 驱动反序列化时直接报 CastException。第二TrackPoint 里的 t 字段对应文档里的时间字符串如果你在文档里存的是 Date 类型这里就要用 Date 而不要用 String我用 String 是为了和上报报文的原始格式保持一致省去转换。第三如果文档里多出 POJO 里没有的字段默认会被忽略反过来 POJO 里有而文档里没有的字段Jackson 会填 null。这种现象在迭代中很容易发生建议给新增字段加 Field(field_name) 注解保证字段名不会因为 Java 命名规范被改掉。4. 飞行管控核心链路实时位置上报、电子围栏告警、指令下发回执4.1 设备上报链路HTTP 推还是 WebSocket 拉无人机端的通信链路常见的做法有 HTTP 轮询和长连接推送。HTTP 轮询的优点是实现简单、无状态、适合调试缺点是实时性差三秒一次的上报改成轮询的话带宽浪费严重。长连接方案里Netty 或者 Spring WebSocket 都能做Netty 性能更好但需要自己处理协议编解码WebSocket 和 Spring MVC 集成更顺。我一般用 WebSocket JSON 报文原因有两点一是管控平台的设备接入量撑死几百台并发WebSocket 完全够用二是 Spring 的 WebSocketHandler 可以直接注册成 Bean和业务代码整合零成本。如果是几万台的规模再上 Netty那是另一个量级的故事。Component public class UavWebSocketHandler extends TextWebSocketHandler { Autowired private TrackReportService trackReportService; private static final MapString, WebSocketSession SESSION_MAP new ConcurrentHashMap(); Override public void afterConnectionEstablished(WebSocketSession session) { String droneCode session.getAttributes().get(droneCode).toString(); SESSION_MAP.put(droneCode, session); } Override protected void handleTextMessage(WebSocketSession session, TextMessage message) { TrackPoint point JSON.parseObject(message.getPayload(), TrackPoint.class); // 校验报文合法性后走上报服务 trackReportService.report(point); } }这里有一个显眼的坑SESSION_MAP 只存了 WebSocketSession如果设备断线重连旧的 Session 不会自动清理。需要在 afterConnectionClosed 里移除否则 Map 越来越大内存泄漏。另外设备的心跳保活不能依赖 WebSocket 自带的心跳要在业务层设计设备 10 秒发一个心跳报文平台侧 30 秒没收到就判定失联触发告警。4.2 电子围栏用 Redis GEO 快速预筛用 MongoDB 精确判定电子围栏判定是管控平台最容易出现性能瓶颈的功能。每一台飞行中的无人机每三秒就要和几十个围栏做比对如果直接查 MySQL 里的多边形顶点再用射线法计算包含关系光这层计算就能把数据库拖垮。我的方案是两级判定。第一级用 Redis 的 GEO 类型存围栏中心点用 GEORADIUS 快速筛出离设备 5 公里内的候选围栏。第二级对候选中真正落在多边形内的精确计算包含关系。一二级配合既能保证实时性又能处理不规则多边形。public boolean isInFence(TrackPoint point, GeoFence fence) { // 第一级Redis GEO 粗筛判断是否靠近围栏中心 if (fence.getFenceType() 1) { // 圆形围栏直接计算距离 double distance distance(point.getLat(), point.getLng(), fence.getCenterLat(), fence.getCenterLng()); return distance fence.getRadius(); } // 第二级多边形围栏射线法精确判定 return isPointInPolygon(point.getLng(), point.getLat(), parsePolygon(fence.getPolygon())); }代码很短但包含三个容易出错的点。第一Redis GEO 只支持以某个点为中心查半径它不能直接做点在多边形内的判断所以只用于预筛。第二射线法判断点在多边形内的算法网上抄的代码很多在边界的处理上有问题点在多边形边上的情况要单独处理否则无人机擦着围栏飞会反复进出告警。第三围栏表数据量小的话可以全量加载到 JVM 内存里做判定飞机会更多但不要用 MySQL 的 ST_Within 函数逐台设备查询那会把 MySQL 的 CPU 打满。4.3 指令下发Redis 分布式锁怎么防重复指令管控平台的指令下发有四个类别起飞、返航、悬停、降落。指令必须精确到达设备不能重复执行。在单机部署里用 synchronized 就够了但在多实例部署时要用 Redis 分布式锁保证同一时刻只有一个实例在处理同一台设备的指令。public boolean sendCommand(String droneCode, String command) { String lockKey lock:cmd: droneCode; String lockValue UUID.randomUUID().toString(); Boolean locked redisTemplate.opsForValue().setIfAbsent(lockKey, lockValue, Duration.ofSeconds(5)); if (!Boolean.TRUE.equals(locked)) { // 拿不到锁说明已有指令在途 return false; } try { // 指令持久化到 MySQL状态为已下发 CommandLog cmdLog new CommandLog(); cmdLog.setDroneCode(droneCode); cmdLog.setCommand(command); cmdLog.setStatus(0); commandLogMapper.insert(cmdLog); // 通过 WebSocket 推送给设备 WebSocketSession session UavWebSocketHandler.getSession(droneCode); if (session null || !session.isOpen()) { throw new IllegalStateException(设备离线); } session.sendMessage(new TextMessage(command)); return true; } finally { // 用 Lua 脚本保证删除锁的原子性防止误删别人的锁 String luaScript if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; redisTemplate.execute(new DefaultRedisScript(luaScript, Long.class), Collections.singletonList(lockKey), lockValue); } }这里的思想是SETNX 加锁UUID 作为锁的持有者标识释放锁时用 Lua 脚本比对值再删除防止因为业务处理超时导致锁被自动释放后另一个线程进来误删。指令下发前先写 MySQL 记录是为了后续排查时留审计日志。如果 WebSocket 推送失败指令状态要能从 0 改成 2并且在设备重连后做重推这个重推机制我不展开但它是指令可靠性里最容易漏的一块。4.4 告警风暴Redis 滑动窗口限流越界告警是管控平台的刚需功能但也是最容易被打爆的功能。一台无人机在围栏边缘盘旋时每三秒就触发一次越界判定一分钟产生 20 条告警十条线同时这样告警表就炸了值班手机一晚上响个不停。加一个 Redis 滑动窗口限流就能解决。以每台设备每分钟最多 3 条告警为例用 Redis 的 INCR 加过期时间实现简化版滑动窗口public boolean allowAlarm(String droneCode) { String key alarm:limit: droneCode : System.currentTimeMillis() / 1000 / 60; Long count redisTemplate.opsForValue().increment(key); if (count 1) { redisTemplate.expire(key, Duration.ofSeconds(65)); } return count 3; }这段代码的逻辑是key 里带上当前分钟的时间戳每来一次告警 INCR 一次递增到第 4 次就拒绝。过期时间设 65 秒而不是 60 秒是防止并发下 key 刚过期又马上被创建导致计数从 1 重新开始。这就是典型的少 5 秒就出问题的边界真正的生产环境我通常改用 Redis 的滑动窗口脚本或者直接用一个 60 秒的固定窗口告警场景里每隔 20 秒允许一条体验完全可接受。5. Java Redis MySQL MongoDB 四存储组合的避坑记录5.1 现象航迹写入 MongoDB 成功但 MySQL 里的飞行计划状态没更新原因MySQL 和 MongoDB 没有事务。Spring 的 Transactional 只能管 MySQL 一个库MongoDB 的写入不在事务控制范围内。很多人第一次接触多存储时默认一个事务全搞定结果就是业务代码抛异常回滚了 MySQL但 MongoDB 里已经插入了一半数据两边对不上。解决不要试图跨库做分布式事务。把链路拆成主数据 从数据的模式MySQL 里的 flight_plan 状态是主数据更新失败就重试直到成功MongoDB 里的航迹是从数据写入失败先留待补偿任务慢慢补。补偿任务的实现可以是一条定时扫描的 SQL把近十分钟内状态仍为执行中但航迹表里没有数据的计划捞出来重新生成一条离线记录。这个思路叫最终一致管控平台对航迹的实时性要求没那么高完全够用。5.2 现象Redis 连接池被打满所有接口响应变慢原因拿到源码后我习惯先做压测结果发现到 500 并发时 Redis 连接池直接打满报出 RedisConnectionFailureException。排查时用 Redis Desktop Manager 连上去一看连接数飙到了 80 多远超配置的 max-active50。原来是因为代码里有个地方每次都 new 一个 RedisTemplate 实例Spring 容器里的那个反而没用上。解决全局只注入一个 RedisTemplate 或 StringRedisTemplate不要手动 new。这个坑很容易被忽略因为本地开发流量小不会触发一上压测就原形毕露。另一个原因是 Redis 命令本身的耗时某个接口在循环里执行了几十次 Redis 操作每次哪怕只有 1 毫秒串行下来也要几十毫秒压测时线程全部卡在 Redis 上。解决办法是改用 pipeline 批量执行。5.3 现象MongoDB 嵌套数组查询查不到数据原因文档里 points 是数组数组的元素是对象用 {points.lng: 121.47} 去查可能什么也查不到。这是因为 MongoDB 对嵌套数组的匹配是至少一个元素满足全部条件如果你同时查 lng 和 lat默认的匹配逻辑是同一个数组元素必须同时满足两个条件。但如果你把一个点拆成两个文档再查结果又不一样。解决先确认查询条件是否指向同一个数组元素。要查某个坐标点是否在轨迹里应写成 {points: {$elemMatch: {lng: 121.47, lat: 31.23}}}。用 $elemMatch 保证 lng 和 lat 落在同一个点元素上。这个坑在 MongoDB 的 Java 驱动里尤其隐蔽因为 Criteria.where(points.lng).is(...) 这种链式写法不会报错只是查不到结果。5.4 现象MongoDB 里存的时间比 MySQL 多了 8 小时原因MongoDB 默认以 UTC 存储日期MySQL 连接串里配了 serverTimezoneAsia/Shanghai存进去的就是东八区。两边一对比同一个时间在 MongoDB 里显示的是 UTCMySQL 里显示的是本地时间中间差 8 小时。更麻烦的是代码里从 MongoDB 读出来的 Date 直接序列化成 JSON 传给前端前端再格式化显示时间又不对了。解决统一约定。MongoDB 里存时间戳long或者 ISO 字符串不要存 Date 对象MySQL 里继续用 DATETIME 加 serverTimezone。这样读取端只做一次格式化统一用东八区。如果你一定要在 MongoDB 里存 Date那么查询时用 $expr 配合时区偏移做转换每次写查询都要算 8 小时非常痛苦。5.5 现象2dsphere 索引建了地理查询还是慢原因索引建错了字段。我给 points 数组建索引时的命令是 db.flight_track.createIndex({points: 2dsphere})但 points 数组里存的是普通经纬度数字对不是 GeoJSON 格式。2dsphere 索引要求字段是 GeoJSON Point 或者 GeoJSON Polygon普通数组根本不走索引查询退化成全表扫描。解决要么把 points 里的点改成 GeoJSON Point 结构要么改用 2d 索引仅适用于普通经纬度数组。考虑到航迹查询基本是按计划扫时间区间很少直接做地理范围查询我最后的处理是不在轨迹点上建 2dsphere而是在告警表上建真正的 GeoJSON 字段只让告警地点参与空间计算轨迹还是走时间索引。5.6 现象MySQL 连接串没加 rewriteBatchedStatements批量插入慢得离谱原因MyBatis 的 batch 模式如果连接串没加 rewriteBatchedStatementstrueJDBC 驱动会把批量插入拆成一条一条执行和逐条插入没有任何区别。无人机航迹批量入库的速度直接从每秒几千条掉到每秒几百条。解决连接串加上 rewriteBatchedStatementstrue 之后MySQL 会把多条 INSERT 语句重写成一条多 VALUES 的语句插入速度翻五倍以上。这个参数在 MySQL 8.0 和 5.7 都支持。我见过不少人踩了这个坑还以为是 MongoDB 写入慢其实瓶颈在 MySQL 的批处理配置上。6. 把源码跑起来之后做的第一件事用三千条模拟航迹验证管控平台源码部署好后不建议直接连真实无人机先写一个数据生成器把平台撑起来。我一般会生成三台无人机、每台绕一个矩形航线飞 20 分钟、三秒一个点大约 1200 条记录三千条规模够暴露大部分问题了。public ListTrackPoint generateTrack(String droneCode, String planId) { ListTrackPoint points new ArrayList(); double lng 121.473701, lat 31.230416; long baseTime System.currentTimeMillis(); for (int i 0; i 400; i) { // 绕一个 500 米见方的矩形 if (i 100) lng 0.0001; else if (i 200) lat 0.0001; else if (i 300) lng - 0.0001; else lat - 0.0001; TrackPoint point new TrackPoint(); point.setDroneCode(droneCode); point.setPlanId(Long.parseLong(planId)); point.setLng(lng); point.setLat(lat); point.setAlt(120 (i % 10)); point.setSpeed(15.0); point.setT(String.valueOf(baseTime i * 3000L)); points.add(point); trackReportService.report(point); } return points; }跑完生成器后验证清单按顺序查三个地方。第一Redis Desktop Manager 里看 drone:status 的 key 是否在 30 秒内自然过期过期后再次查询是否正确返回离线状态第二MongoDB Compass 里查 flight_track 集合确认文档数量和分钟级聚合是否正确第三模拟一个越界点看 MySQL 的 tp_alarm 表是否只产生一条不重复的告警。这三条链路都通平台的核心功能就算立住了。参数调整上三个数需要现场改Redis 状态的 TTL 从 30 秒改到 60 秒可以降低误判离线率但失联告警的响应速度也会翻倍批量写入缓冲区的大小从 200 改到 500 能减少 MongoDB 的写入次数但 JVM 内存占用会升高告警限流的窗口从每分钟 3 条改到 5 条要结合值班电话的忍受度来定。没有一组参数是通用的必须用小规模模拟数据压一遍再决定。最后说一个我自己的验证习惯跑通之后故意关掉 Redis 试一次。你会发现告警服务整体不可用因为核心的限流依赖 Redis。这时候需要判断是 Redis 单点的问题还是设计上就应该允许降级。我的选择是告警可以断航迹不能断——MongoDB 的写入链路不依赖 Redis所以 Redis 挂了航迹数据仍然完整。这种关键路径不依赖非关键组件的边界才是这套四存储架构真正值钱的地方。把这些边界摸清了你再去扩展飞行动态展示、历史轨迹回放、多站点接入都会顺很多。希望帮到你。本文还有配套的精品资源点击获取