简介:这是一套基于Java实现的公交车实时监控系统设计源码,面向具备Java基础、希望学习智能交通与实时监控项目开发的学生和开发者。项目以Spring Boot微服务架构组织后端模块,通过RESTful API与笑园实时公交API对接,获取车辆位置、运行状态与轨迹数据,并借助XML、YAML完成参数配置、SQL实现数据持久化,适合作为课程设计、毕业设计或企业级练手参考。资源包共43个文件,以37个Java源码为核心,辅以2个XML配置、1个YAML、1个SQL脚本及说明文档,压缩包约61KB,结构紧凑便于快速导入IDE阅读。目前已有279人学习下载。读者可从中掌握实时数据流处理、微服务拆分、接口标准化与配置管理等关键思路,理解监控系统从数据接入到持久化的完整链路,并据此扩展更多数据源或前端展示功能。
1. 公交车实时监控系统:从 GPS 漂移到到站预测,Java 这套源码到底能跑通什么
早高峰的公交调度室里,调度员盯着屏幕上一辆辆缓慢移动的图标,最怕的不是车堵在路上,而是图标突然跳到隔壁街道——GPS 漂移让整条线路的到站预测集体失真。基于 Java 实现的公交车实时监控系统设计源码,要解决的就是这类问题:把车载终端上报的经纬度、速度、方向角,经过清洗、地图匹配、轨迹存储,最终变成调度大屏上一条可信的轨迹和可用的到站时间。这套源码适合做 Java 课程设计的学生、需要快速搭出车辆监控原型的后端工程师,以及想理解「实时数据管道 + 地理计算」怎么在 Java 里落地的人。它不追求高并发百万终端,但把单车数据从接入到展示的完整链路讲清楚了,照着改就能接自己的设备协议。热搜里常出现的 Java 基础、MyBatis 源码、Java 怎么保证数据一致性,在这套系统里都能找到对应的真实落点——不是背八股,而是数据进来之后你到底怎么存、怎么算、怎么保证不丢。
2. 实时监控系统的骨架:Java 技术选型与数据流拆解
2.1 为什么是 Spring Boot + Netty + PostGIS 这套组合
公交监控的本质是「持续写入 + 实时查询 + 空间计算」。车载终端每 5 到 15 秒发一次位置,1000 辆车就是每秒 60 到 200 个点位,这个量级用 Spring Boot 内置的 Tomcat 扛 HTTP 短连接完全没问题,但终端侧往往用 TCP 长连接上报,所以常见做法是 Netty 做接入层,解析完的定位数据通过内部队列交给 Spring Boot 的业务层。数据库选型上,MySQL 存车辆档案、线路、站点这些关系数据,PostgreSQL 加 PostGIS 存轨迹点并做空间索引,因为「查询某条线路附近 500 米内的车辆」这种需求,用 MySQL 的经纬度双字段查询会全表扫描,而 PostGIS 的ST_DWithin能走 GiST 索引,响应从秒级降到毫秒级。
有读者会问,能不能全用 MySQL?可以,但你要自己实现地理网格编码,比如 Geohash 前缀查询,维护成本高且边界情况多。这套源码选择 PostGIS 不是炫技,是把空间查询的复杂度交给成熟的扩展。Java 侧用 MyBatis-Plus 做常规 CRUD,轨迹批量写入用 JdbcTemplate 的batchUpdate,因为 MyBatis 的逐条插入在每秒几百个点位时会产生大量小事务,拖慢整体吞吐。
2.2 从终端上报到调度大屏:一条定位数据的完整旅程
一条定位数据从车载终端发出到出现在调度大屏上,要经过五个环节。第一步,Netty 解码:终端发来的可能是 JT/T 808 协议或自定义二进制帧,解码后得到车辆 ID、时间戳、经纬度、速度、方向。第二步,数据清洗:过滤掉经纬度为 0 或超出中国范围的点,速度超过 120 km/h 的标记为异常但保留,因为可能是高速路段。第三步,地图匹配:把漂移点吸附到最近的道路上,常用做法是取最近三个点做加权平均,或者用 PostGIS 的ST_ClosestPoint找最近路段。第四步,轨迹存储:按车辆 ID 和时间分区写入轨迹表,同时更新 Redis 里该车辆的「最新位置」缓存,供大屏轮询。第五步,到站预测:根据当前车辆位置、线路站点顺序、历史路段速度,估算到达下一站的时间。
这个链路里最容易翻车的是第三步。很多课程设计源码直接跳过地图匹配,把原始 GPS 点画在地图上,结果车辆图标在建筑物之间跳跃。如果你只是做演示,可以接受;如果要给调度员用,地图匹配必须做,哪怕只是简单的「取最近站点方向投影」。
2.3 最小可运行环境:建表、配置、启动
先建轨迹表,用 PostGIS 的 geography 类型存点,并建 GiST 索引:
-- 车辆轨迹表,按车辆 ID 和时间范围查询 CREATE TABLE bus_track ( id BIGSERIAL PRIMARY KEY, bus_id VARCHAR(32) NOT NULL, line_id VARCHAR(32) NOT NULL, location geography(POINT, 4326) NOT NULL, speed REAL, direction REAL, report_time TIMESTAMP NOT NULL, created_at TIMESTAMP DEFAULT NOW() ); -- 空间索引,加速「附近车辆」查询 CREATE INDEX idx_bus_track_location ON bus_track USING GIST (location); -- 按车辆和时间查询的复合索引 CREATE INDEX idx_bus_track_bus_time ON bus_track (bus_id, report_time DESC);geography(POINT, 4326)表示 WGS84 坐标系下的点,4326 是 GPS 原始坐标系。用 geography 而不是 geometry 的好处是距离计算单位是米,不用自己换算。GiST 索引是 PostGIS 空间查询的核心,没有它ST_DWithin会退化成全表扫描。
接着配置 Spring Boot 的数据源和 Netty 端口:
# application.yml spring: datasource: url: jdbc:postgresql://localhost:5432/bus_monitor username: postgres password: your_password hikari: maximum-pool-size: 20 minimum-idle: 5 redis: host: localhost port: 6379 netty: port: 9000 boss-threads: 1 worker-threads: 8maximum-pool-size设 20 是因为轨迹批量写入会占用连接,但不要超过数据库最大连接数的 80%。worker-threads设 8 对应 CPU 核心数,Netty 的 worker 线程负责 IO 读写,不需要太多。
启动类里初始化 Netty 服务端:
@Component public class TcpServer { @Value("${netty.port}") private int port; @PostConstruct public void start() throws InterruptedException { EventLoopGroup boss = new NioEventLoopGroup(1); EventLoopGroup worker = new NioEventLoopGroup(8); try { ServerBootstrap b = new ServerBootstrap(); b.group(boss, worker) .channel(NioServerSocketChannel.class) .childHandler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) { // 解码器:处理粘包,按固定长度或分隔符切分 ch.pipeline().addLast(new LengthFieldBasedFrameDecoder(1024, 0, 4, 0, 4)); ch.pipeline().addLast(new BusMessageDecoder()); ch.pipeline().addLast(new BusMessageHandler()); } }); ChannelFuture f = b.bind(port).sync(); f.channel().closeFuture().sync(); } finally { boss.shutdownGracefully(); worker.shutdownGracefully(); } } }LengthFieldBasedFrameDecoder解决 TCP 粘包问题,参数含义是:最大帧长 1024 字节,长度字段偏移 0,长度字段占 4 字节,长度字段之后跳过 0 字节,帧头去掉 4 字节。如果你的终端协议用分隔符,换成DelimiterBasedFrameDecoder。BusMessageDecoder把字节流转成 Java 对象,BusMessageHandler做业务处理,比如写 Redis 和发到内部队列。
提示:Netty 的
@PostConstruct启动会阻塞主线程,实际部署时建议用CommandLineRunner或单独线程启动,避免影响 Spring 容器初始化。
2.4 轨迹写入与最新位置缓存的双写一致性
车辆位置既要落库供历史查询,又要写 Redis 供大屏实时读取。双写最容易出的问题是:数据库写成功但 Redis 写失败,大屏显示旧位置;或者 Redis 写成功但数据库失败,历史轨迹缺一个点。常见做法是「先写数据库,再删 Redis 缓存」,让下次读取时从数据库加载并回填。但公交监控场景下,大屏每秒都在读,删缓存会导致大量请求打到数据库。
更务实的方案是:先写 Redis,再异步批量写数据库。Redis 里存最新位置,数据库里存轨迹。如果数据库写入失败,记录到本地文件或重试队列,不影响大屏展示。代价是极端情况下历史轨迹会缺几个点,但对调度来说,实时性比完整性更重要。这套源码里用BlockingQueue做缓冲,每 500 毫秒或攒够 200 条批量插入一次:
@Component public class TrackWriter { private final BlockingQueue<TrackPoint> queue = new LinkedBlockingQueue<>(10000); private final JdbcTemplate jdbcTemplate; public TrackWriter(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; startBatchWriter(); } public void offer(TrackPoint point) { if (!queue.offer(point)) { // 队列满,丢弃最旧的点,保证新点能进来 queue.poll(); queue.offer(point); } } private void startBatchWriter() { Executors.newSingleThreadExecutor().submit(() -> { List<TrackPoint> batch = new ArrayList<>(200); while (true) { try { TrackPoint first = queue.poll(500, TimeUnit.MILLISECONDS); if (first != null) { batch.add(first); queue.drainTo(batch, 199); } if (!batch.isEmpty()) { batchInsert(batch); batch.clear(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } }); } private void batchInsert(List<TrackPoint> batch) { String sql = "INSERT INTO bus_track (bus_id, line_id, location, speed, direction, report_time) " + "VALUES (?, ?, ST_SetSRID(ST_MakePoint(?, ?), 4326)::geography, ?, ?, ?)"; jdbcTemplate.batchUpdate(sql, batch, batch.size(), (ps, point) -> { ps.setString(1, point.getBusId()); ps.setString(2, point.getLineId()); ps.setDouble(3, point.getLng()); ps.setDouble(4, point.getLat()); ps.setFloat(5, point.getSpeed()); ps.setFloat(6, point.getDirection()); ps.setTimestamp(7, Timestamp.valueOf(point.getReportTime())); }); } }LinkedBlockingQueue容量 10000,按每秒 200 个点算能缓冲 50 秒,足够应对数据库短暂抖动。drainTo一次最多取 199 条,加上poll取的第一条正好 200 条。batchUpdate的第三个参数是批量大小,设成batch.size()表示一次提交。SQL 里ST_MakePoint创建点,ST_SetSRID设置坐标系,::geography转成地理类型。
注意:
queue.poll()在队列满时丢弃最旧的点,这是有损策略。如果你的场景要求轨迹完整,改成阻塞等待或扩大队列,但要注意内存。
3. 到站预测与地图匹配:让轨迹从「能看」变成「能用」
3.1 到站预测的三种算法与参数调优
到站预测的输入是车辆当前位置、线路站点列表、历史行驶速度。最简单的算法是「剩余距离除以平均速度」,但早高峰和晚高峰的平均速度差一倍,固定值会导致预测忽快忽慢。改进方案是用最近 30 分钟的同线路车辆通过该路段的平均速度,如果数据不足,回退到历史同时段速度。
具体实现:把线路切成相邻站点之间的路段,每个路段维护一个速度缓存,键是lineId:fromStationId:toStationId,值是最近 N 次通过时间的滑动平均。车辆上报位置后,计算它到下一站的距离,除以该路段当前速度,得到预计到达时间。如果车辆已经过了下一站,就顺延到再下一站。
public class ArrivalPredictor { // 路段速度缓存,key: lineId:fromStation:toStation, value: 平均速度 m/s private final Map<String, Double> segmentSpeedCache = new ConcurrentHashMap<>(); // 滑动窗口,保存最近 10 次通过时间 private final Map<String, Deque<Double>> speedHistory = new ConcurrentHashMap<>(); public LocalDateTime predictArrival(BusPosition current, Station nextStation, String lineId) { double distance = GeoUtils.distanceInMeters( current.getLng(), current.getLat(), nextStation.getLng(), nextStation.getLat() ); String segmentKey = lineId + ":" + current.getLastStationId() + ":" + nextStation.getId(); double speed = segmentSpeedCache.getOrDefault(segmentKey, 8.0); // 默认 8 m/s,约 28.8 km/h if (speed < 1.0) speed = 1.0; // 防止除零和龟速导致预测时间爆炸 long seconds = (long) (distance / speed); return LocalDateTime.now().plusSeconds(seconds); } public void updateSpeed(String segmentKey, double distance, long seconds) { double speed = distance / Math.max(seconds, 1); Deque<Double> history = speedHistory.computeIfAbsent(segmentKey, k -> new ArrayDeque<>()); history.addLast(speed); if (history.size() > 10) history.removeFirst(); double avg = history.stream().mapToDouble(Double::doubleValue).average().orElse(8.0); segmentSpeedCache.put(segmentKey, avg); } }segmentSpeedCache用ConcurrentHashMap是因为多个 Netty 线程会同时更新。默认速度 8 m/s 是城市公交的常见巡航速度,低于 1 m/s 时强制设为 1,避免堵车时预测时间变成几小时。滑动窗口大小 10 是经验值,太小对突发拥堵不敏感,太大对路况变化反应慢。
3.2 地图匹配:把漂移点拉回道路的两种低成本做法
地图匹配不需要上隐马尔可夫模型,课程设计和中小项目用两种低成本做法就够。第一种是「最近站点投影」:如果车辆在线路附近,把它的位置投影到最近两个站点之间的连线上。这种方法计算量小,适合线路固定的公交。第二种是「最近道路吸附」:用 PostGIS 查询最近的道路段,把点投影到道路段上。需要道路数据,但精度更高。
最近站点投影的实现:
public class MapMatcher { public Point matchToLine(Point raw, List<Station> stations) { double minDist = Double.MAX_VALUE; Point best = raw; for (int i = 0; i < stations.size() - 1; i++) { Station a = stations.get(i); Station b = stations.get(i + 1); Point projected = projectPointToSegment(raw, a, b); double dist = GeoUtils.distanceInMeters(raw, projected); if (dist < minDist) { minDist = dist; best = projected; } } // 如果最近距离超过 200 米,认为车辆偏离线路,返回原始点 return minDist > 200 ? raw : best; } private Point projectPointToSegment(Point p, Station a, Station b) { double ax = a.getLng(), ay = a.getLat(); double bx = b.getLng(), by = b.getLat(); double px = p.getLng(), py = p.getLat(); double dx = bx - ax, dy = by - ay; double t = ((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy); t = Math.max(0, Math.min(1, t)); return new Point(ax + t * dx, ay + t * dy); } }projectPointToSegment是标准的点到线段投影,t截断在 0 到 1 之间保证投影点在线段上。200 米的阈值是经验值,城市道路间距一般小于这个数,超过说明车辆可能绕行或 GPS 严重漂移。返回原始点而不是强行吸附,避免把绕行的车拉回线路造成误判。
3.3 用 PostGIS 查询附近车辆与线路
调度大屏常需要「显示某条线路所有车辆」或「显示某个区域内的车辆」。用 PostGIS 的ST_DWithin做半径查询:
-- 查询线路 1 路上,距离人民广场 2 公里内的车辆最新位置 SELECT DISTINCT ON (bus_id) bus_id, line_id, ST_AsText(location) AS location, speed, report_time FROM bus_track WHERE line_id = '1' AND report_time > NOW() - INTERVAL '5 minutes' AND ST_DWithin( location, ST_SetSRID(ST_MakePoint(121.4737, 31.2304), 4326)::geography, 2000 ) ORDER BY bus_id, report_time DESC;DISTINCT ON (bus_id)是 PostgreSQL 的语法,取每个车辆最新的一条记录。ST_DWithin的第三个参数单位是米,因为 location 是 geography 类型。report_time > NOW() - INTERVAL '5 minutes'过滤掉过期数据,避免把已经熄火的车显示出来。这个查询在 GiST 索引下,100 万条轨迹数据里能在 50 毫秒内返回。
提示:如果数据量超过 1000 万条,按月份分区表,查询时带上时间范围能触发分区裁剪,否则索引再快也扛不住全表扫描。
4. 避坑与排查:公交监控系统上线前必须处理的五个问题
4.1 车辆图标在屏幕上「瞬移」
现象:调度大屏上,车辆图标每隔几秒跳一次,有时跳到几百米外再跳回来。原因:GPS 冷启动或隧道出口定位漂移,原始点没有做过滤和地图匹配。解决:在 Netty 解码后加一道过滤,速度超过 120 km/h 或与上一个点距离超过 1 公里且时间间隔小于 10 秒的,标记为可疑点,不更新大屏位置,只存库供分析。同时开启地图匹配,把点吸附到线路附近。
4.2 轨迹表写入越来越慢
现象:系统跑了一周后,轨迹插入从每秒 200 条降到 50 条。原因:bus_track表没有分区,数据量到千万级后,B-tree 索引和 GiST 索引维护成本急剧上升。解决:按report_time做月度分区,或者按line_id做哈希分区。分区后每个子表数据量可控,插入和查询都稳定。如果不想改表结构,至少定期归档 3 个月前的数据到历史表。
4.3 Redis 里最新位置和数据库不一致
现象:大屏显示车辆在 A 站,但查历史轨迹发现最后一个点在 B 站。原因:先写 Redis 后写数据库,数据库批量写入失败时没有补偿。解决:批量写入失败时,把失败的批次写入本地重试队列,同时记录日志。更简单的做法是,大屏读取时以 Redis 为准,但提供一个「校准」接口,从数据库查最新点回填 Redis。不要试图用分布式事务,代价太高,公交监控允许秒级不一致。
4.4 到站预测在终点站附近乱跳
现象:车辆快到终点站时,预测时间从 2 分钟突然变成 30 分钟。原因:终点站没有下一站,代码取不到nextStation,回退到默认速度或异常值。解决:在预测逻辑里判断,如果当前站是终点站,返回「已到终点」而不是计算时间。同时,线路数据里给每个站点标记sequence,预测时严格按顺序取下一站,不要用距离排序,避免环形线路出错。
4.5 Netty 连接数上不去
现象:压测时 500 个终端连接后,新连接被拒绝。原因:Linux 默认文件描述符限制是 1024,Netty 的worker-threads设太小,或者SO_BACKLOG默认值不够。解决:ulimit -n 65535提高文件描述符限制,worker-threads设为 CPU 核心数乘以 2,ServerBootstrap的option(ChannelOption.SO_BACKLOG, 1024)调大等待队列。另外检查有没有在channelRead里做阻塞操作,比如同步查数据库,这会卡住 EventLoop 线程。
5. 让这套源码真正可用的三个进阶技巧
第一个技巧是给轨迹数据加「行程分割」。原始轨迹是一条长长的点序列,但调度员关心的是「这一趟车从起点到终点跑了多久」。做法是:当车辆距离起点站小于 100 米且方向朝向起点,标记为行程开始;距离终点站小于 100 米且方向朝向终点,标记为行程结束。两个标记之间的点属于同一趟。这样就能算出每趟的准点率,而不是一堆散点。
public class TripSegmenter { public void segment(BusPosition pos, Line line) { Station first = line.getStations().get(0); Station last = line.getStations().get(line.getStations().size() - 1); double distToFirst = GeoUtils.distanceInMeters(pos, first); double distToLast = GeoUtils.distanceInMeters(pos, last); if (distToFirst < 100 && !pos.isInTrip()) { pos.setInTrip(true); pos.setTripStartTime(LocalDateTime.now()); } else if (distToLast < 100 && pos.isInTrip()) { pos.setInTrip(false); saveTrip(pos.getBusId(), pos.getTripStartTime(), LocalDateTime.now()); } } }第二个技巧是用 Redis 的 Geo 结构做附近车辆查询。PostGIS 适合持久化查询,但大屏每秒刷新时,每次都查数据库压力大。Redis 的GEOADD和GEORADIUS能在内存里做半径查询,延迟低于 1 毫秒。做法是:车辆位置更新时,同时写 PostGIS 和 Redis Geo;大屏查询走 Redis,历史查询走 PostGIS。两者通过车辆 ID 关联,不需要强一致。
# 添加车辆位置到 Redis Geo GEOADD bus:online 121.4737 31.2304 "bus:1001" # 查询人民广场 2 公里内的车辆 GEORADIUS bus:online 121.4737 31.2304 2 km WITHDIST WITHCOORD第三个技巧是给到站预测加「置信区间」。不要只返回一个时间点,而是返回「最快 3 分钟,最慢 8 分钟」。做法是用滑动窗口里的速度标准差,乘以剩余距离,得到时间波动范围。调度员看到区间比看到单点更有判断力。这个改动很小,但让系统从「玩具」变成「工具」。
我自己的习惯是,每次改完预测算法,先拿过去一周的历史轨迹回放一遍,对比预测时间和实际到站时间的误差。如果平均误差超过 2 分钟,说明参数需要调,或者路段划分太粗。这套源码的价值不在于代码多复杂,而在于它把公交监控的完整链路跑通了,你可以在此基础上换协议、换数据库、换算法,但骨架不用动。希望帮到你。
本文还有配套的精品资源,点击获取