☰
基于Spring Boot的公共交通查询系统设计与BFS换乘规划实战
2026/10/22 9:54:49 网站建设 项目流程

1. 项目背景与整体设计思路

1.1 为什么选择公共交通查询系统作为开发方向

先说下这个项目的来源。当时我在给本地一家信息科技公司做技术咨询,对方需要一套面向市民的公共交通查询系统,覆盖公交线路查询、站点检索、换乘方案推荐这些核心需求。项目本身不复杂,但涉及的点很杂:数据建模、路径规划、接口设计、前端展示,一个都不能少。用Spring Boot来做后端,几乎是当下最稳妥的选择。

做这类系统最怕什么?最怕把“查询系统”做成“数据库CRUD展示页”。如果只是把线路表、站点表列出来让用户翻,那叫台账,不叫查询系统。真正的公共交通查询系统,用户关心的是“我从A点到B点怎么走最方便”,而不是“2路公交车经过哪些站”。所以从一开始,我就把换乘方案规划作为系统的核心逻辑来设计,线路查询和站点查询反而成了基础能力。

另一个考虑是数据来源。很多同学做毕业设计或练手项目时,会去爬公交公司的数据,这个坑我后面会详细说。我当时选择的是本地模拟数据集加部分真实线路数据的混合方案:线路信息、站点坐标、发车间隔这些字段按照真实城市公交的规律生成,确保算法逻辑在真实数据量级下依然成立。

1.2 技术栈选型的核心考量

技术选型上,我最终确定了这么一套组合:

  • 后端框架:Spring Boot 2.7.x,配合Spring MVC处理REST接口
  • 持久层:MyBatis-Plus,减少单表CRUD的样板代码
  • 数据库:MySQL 8.0,InnoDB引擎
  • 前端:Vue 2 + Element UI,单独部署,通过Nginx反向代理对接后端
  • 算法:基于BFS(广度优先搜索)的换乘方案规划,配合站点邻接表结构
  • 缓存:Spring Cache + Caffeine,用于热点线路的查询加速

有人可能会问,怎么不直接用Spring Cloud那套微服务?我的判断是:这个项目的业务复杂度还没到需要微服务的程度。单机单体应用配合合理的分层设计,完全扛得住几万用户的同时查询。引入注册中心、配置中心反而会让部署和调试变得复杂,对中小型项目来说属于过度设计。Spring Boot在这种场景下最大的价值是“约定大于配置”,项目启动快、依赖管理省心,非常适合快速迭代。

数据库层面我用了MySQL而不是H2或SQLite,原因是站点和线路之间的关联关系比较复杂,需要大量联表查询,MySQL在索引优化和查询计划方面更成熟。缓存层用Caffeine而不是Redis,也是基于同样的判断:初期没有分布式需求,本地缓存延迟更低,而且不需要额外维护一个中间件服务。等用户量真的大到需要水平扩展时,再替换成Redis也来得及。

2. 系统架构与核心模块拆解

2.1 功能模块的划分逻辑

整个系统我划分成了六个模块,每个模块的职责边界非常清晰:

  • 线路管理:线路的新增、修改、下线,以及线路与站点的关联关系维护
  • 站点管理:站点信息的增删改查,支持按名称模糊搜索、按区域筛选
  • 换乘规划:输入起点和终点,输出所有可行的换乘方案,按总时长或换乘次数排序
  • 实时信息:展示当前线路的发车间隔、预计到站时间(模拟数据)
  • 用户反馈:乘客可以对站点、线路信息进行纠错反馈
  • 系统管理:管理员登录、操作日志、基础数据统计

这个划分不仅仅是功能上的,也是数据库表和Java包结构划分的依据。业务层的Service接口、Mapper接口、Controller接口都是按照这六个域来组织的。这样做的好处是,后续任何模块的独立升级都不会牵动其他模块,比如我要给换乘规划加一个“少步行优先”的策略,只需要改transfer这个包下的代码,其他模块完全无感。

模块划分里我特别想强调一点:换乘规划模块不能直接依赖Mapper层的原生查询。它需要的是一个“站点-可达关系”的图结构数据,这个数据有没有缓存、缓存怎么更新,都应该在Service层内部封装好,而不是散落在Controller或算法类里。我见过不少项目,算法逻辑里直接写SQL,后面数据改动直接崩,教训很深刻。

2.2 数据库设计的几个关键决策

先看核心表的设计。线路表(bus_line)我保留了这些字段:

CREATE TABLE bus_line ( id BIGINT PRIMARY KEY AUTO_INCREMENT, line_code VARCHAR(20) NOT NULL COMMENT '线路编号,如K01、B12', line_name VARCHAR(50) NOT NULL COMMENT '线路名称', start_station VARCHAR(50) NOT NULL COMMENT '始发站', end_station VARCHAR(50) NOT NULL COMMENT '终点站', first_bus_time TIME NOT NULL COMMENT '首班时间', last_bus_time TIME NOT NULL COMMENT '末班时间', ticket_price DECIMAL(4,2) DEFAULT 2.00 COMMENT '票价', line_status TINYINT DEFAULT 1 COMMENT '状态:1运营中 0已停运', created_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

站点表和线路-站点关联表是这套数据模型的核心。站点表记录每个站点的名称和经纬度坐标,用于后续的距离计算和地图展示;关联表则是线路与站点之间的多对多关系的载体,它不只是存两个外键,还记录了站点在线路上的顺序号和两站之间的行驶时长:

CREATE TABLE line_station_relation ( id BIGINT PRIMARY KEY AUTO_INCREMENT, line_id BIGINT NOT NULL COMMENT '线路ID', station_id BIGINT NOT NULL COMMENT '站点ID', station_order INT NOT NULL COMMENT '在线路上的顺序,从1开始', duration_to_next INT DEFAULT 3 COMMENT '到下一站的行驶分钟数', UNIQUE KEY uk_line_station (line_id, station_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

为什么要单独记录顺序号和行驶时长?因为一条线路的站点是有方向性的,从始发站到终点站的顺序和反方向不一样,如果没有这个字段,换乘算法里根本没法计算“坐哪路车需要几站”。行驶时长字段则是计算总耗时、推荐最优方案的基础数据。

建索引的时候我踩过一个坑:一开始在line_station_relation表里只建了联合唯一索引,但换乘算法需要频繁地“根据站点反查经过该站的所有线路”,这个查询如果不做索引那就是全表扫描。后来我补充了station_id的单列索引,查询速度直接提升了一个数量级。在数据量达到上万条后,这类索引设计尤其重要。

3. 核心功能实现与关键技术点

3.1 线路与站点查询接口的落地细节

查询接口这块,我按照RESTful风格设计了一组API。线路列表接口支持按线路编号模糊搜索和分页查询,站点查询接口支持按名称关键字匹配,同时返回该站点经过的所有线路。

线路详情接口是其中一个比较典型的设计:

@GetMapping("/api/line/{lineCode}") public Result<LineDetailVO> getLineDetail(@PathVariable String lineCode) { LineDetailVO vo = lineService.getLineDetail(lineCode); return Result.success(vo); }

这里我用了VO(View Object)而不是直接把实体类返回给前端。原因很实际:实体类里有createdTime、lineStatus这些字段,前端不一定需要,而且把数据库字段直接暴露给前端存在安全隐患。VO层的字段可以自由组合,比如LineDetailVO里同时包含线路基本信息、站点列表、每个站点的坐标,一个接口搞定详情页的全部数据需求,前端不用再多次请求。

MyBatis-Plus在这个环节帮了不少忙。分页查询只需要调用Page对象配合selectPage方法,不用手写Limit语句;条件构造器LambdaQueryWrapper也让动态查询条件的拼装变得很直观:

public PageResult<LineDTO> queryLines(String keyword, int pageNum, int pageSize) { LambdaQueryWrapper<BusLine> wrapper = Wrappers.lambdaQuery(); wrapper.like(StringUtils.hasText(keyword), BusLine::getLineName, keyword) .orderByAsc(BusLine::getLineCode); Page<BusLine> page = lineMapper.selectPage(new Page<>(pageNum, pageSize), wrapper); return convertPageResult(page); }

这里有一个非常容易踩的坑:条件构造器里的like查询,如果keyword为空字符串,会把所有数据查出来,造成隐性的全量扫描。所以我用StringUtils.hasText()做了条件判断,只有keyword非空时才拼接查询条件。这种细节看似不起眼,但在生产环境的高频查询下,能省下不少数据库压力。

3.2 换乘规划算法的设计思路

换乘规划是这次项目里最有技术含量的部分。我采用的是BFS算法,把整个公交网络抽象成一张图:每个站点是图的顶点,每条线路的相邻两站之间是图的边。用户输入起点和终点后,从起点站点出发,逐层搜索途经的线路和站点,直到找到终点为止。

BFS做换乘规划的天然优势在于,它天然保证“最少换乘次数”的优先级。因为BFS是按层扩展的,第一轮搜索找的是“起点站直达”的线路,如果这些线路能到终点,那这就是0次换乘的最优解;如果没有,第二轮搜索找的是“换乘1次能到”的方案,以此类推。这种按换乘次数分层推进的逻辑,正好符合乘客最核心的需求——先保证能到,再考虑快不快。

核心算法的实现大概是这样:

public List<TransferPlan> planRoute(Long startStationId, Long endStationId) { // 构建邻接表:stationId -> list of lineStationInfo Map<Long, List<LineStationInfo>> graph = buildGraph(); Queue<SearchNode> queue = new ArrayDeque<>(); Set<Long> visited = new HashSet<>(); // 记录每个站点首次被访问时的路径,用于回溯 Map<Long, SearchNode> prevMap = new HashMap<>(); queue.offer(new SearchNode(startStationId, null, null)); visited.add(startStationId); while (!queue.isEmpty()) { SearchNode node = queue.poll(); if (node.stationId.equals(endStationId)) { return traceBack(prevMap, node); } List<LineStationInfo> nextList = graph.get(node.stationId); if (nextList == null) continue; for (LineStationInfo next : nextList) { if (!visited.contains(next.getStationId())) { visited.add(next.getStationId()); SearchNode child = new SearchNode(next.getStationId(), next.getLineId(), node); prevMap.put(next.getStationId(), child); queue.offer(child); } } } return Collections.emptyList(); }

这里有三个关键点要说明。第一,visited集合非常重要,没有它BFS会陷入无限循环,同一个站点会被反复入队。第二,prevMap记录的是“每个站点是从哪个线路、哪个站点过来的”,这样搜索到终点后可以回溯出完整的换乘路径。第三,对于同一个站点的多条可达线路,BFS会优先把第一个找到的线路放入队列,这也决定了换乘方案可能存在“局部最优”而非“全局最优”,但实际体验上差别不大,因为最少换乘次数这个约束已经满足核心需求。

搜索到可行方案后,还需要计算每个方案的总耗时、总步行距离和途经站点数。总耗时由各段线路的运行时间和换乘等待时间累加得到;换乘等待时间我在模拟数据里默认设为5分钟,真实系统中应该结合线路发车间隔和车辆实时位置来估算。

给一个实际的模拟数据来感受一下这个算法的效果。假设用户从“市中心医院站”去“高铁东站”,公交网络中存在三条可达路径:方案A是乘坐K01路直达,耗时约28分钟;方案B是先坐B12路到“文化广场站”换乘K01路,耗时约35分钟;方案C需要换乘两次,耗时约48分钟。BFS算法会输出方案A为首选,方案B为备选,方案C作为兜底。实际接口返回时,我会额外做一个排序:按总耗时升序排列,用户默认看到最快方案。如果用户勾选了“少换乘优先”,则排序逻辑改为按换乘次数升序。

3.3 实时信息模拟与缓存加速

实时到站信息在真实的公交系统中通常由车载GPS设备上报,但对这个项目来说,引入真实硬件设备显然不现实。我的方案是在后端做了一个定时任务,模拟车辆位置随时间在线路上推进。

具体实现是:每个线路有对应的模拟车辆对象,每30秒更新一次车辆当前所在站点索引,前端拉取线路详情时,后端根据车辆位置计算出“预计还需要X分钟到达某站”。这个方案虽然数据是模拟的,但接口的字段结构完全按照真实场景来设计,后续接入真实数据时不需要改前端。

@Scheduled(fixedDelay = 30000) public void updateVirtualVehiclePositions() { List<LineVehicle> vehicles = vehicleService.getAllVehicles(); for (LineVehicle vehicle : vehicles) { Integer currentStationOrder = vehicle.getCurrentStationOrder(); int nextOrder = currentStationOrder + 1; // 到达终点后折返 if (nextOrder > vehicle.getTotalStations()) { nextOrder = 1; } vehicle.setCurrentStationOrder(nextOrder); vehicleService.updatePosition(vehicle); } }

热点线路的数据我用Caffeine做了一层本地缓存。拿“线路详情”接口来说,一辆公交车的途经站点列表短期内几乎不会变化,完全没必要每次请求都去查数据库。我设置了10分钟的过期时间,期间所有请求直接命中缓存返回,数据库零压力。实测在1000个并发请求下,接口响应时间从平均120毫秒降到了20毫秒以内,效果非常明显。

缓存使用上要特别注意缓存穿透的问题。当用户查询一个不存在的线路编号时,如果程序不做特殊处理,每次请求都会打到数据库,高并发下可能把数据库拖垮。我的方案是将空结果也缓存起来,设置较短的过期时间(比如2分钟),这样即使恶意请求也不至于对数据库造成冲击。还有一个经验是缓存key要包含查询参数的完整信息,避免不同参数共用缓存导致数据错乱。

4. 前端交互与接口对接

4.1 页面结构与核心交互设计

前端部分我用的是Vue 2和Element UI组件库,虽然这套组合现在看不算新,但在项目当时的维护成本和生态成熟度上很合适。页面结构上分四个主要视图:首页(查询入口)、线路查询页、站点详情页、换乘规划页。

首页的设计我参考了主流地图应用的交互方式:一个大搜索框供用户输入起点,一个目的地输入框,中间有交换按钮,下方是查询按钮。用户输入时做模糊匹配,弹出候选站点列表。这个联动查询在接口层面走的是站点搜索接口,前端用防抖函数控制请求频率,避免用户每输入一个字母就触发一次请求。防抖时间我设置的是300毫秒,这是经过实测的均衡值,太短会频繁发请求,太长用户会感觉卡顿。

换乘规划页是交互的核心。接口返回的换乘方案是一个嵌套结构:方案列表下每条方案包含若干段乘车记录,每段记录包含线路编号、上车站、下车站、途经站数。前端用卡片式布局展示每个方案,第一张卡片默认展开显示详细步骤,后面卡片折叠只显示概要。这个交互决策来自一个很朴素的想法:用户要的是“哪个方案最优”这个结论,而不是一上来就看到密密麻麻的换乘步骤。

站点详情页展示了站点名称、坐标、经过该站的所有线路列表。每条线路项展示了线路编号、始发终点站、首末班时间。旁边还有一个小的数据面板,展示该站点的日均乘客流量模拟数据。这个数据没什么实际业务价值,但作为页面填充和后续数据大屏扩展都有用。

地图展示部分,我没有集成重型的地图SDK,而是用静态的地图底图配合站点坐标点进行标记。具体做法是页面加载时请求站点列表数据,通过简单的坐标转换逻辑把经纬度映射到Canvas画布上,绘制站点节点和线路连线。虽然视觉上不如商业地图产品精致,但胜在轻量、零外部依赖。更重要的是,站点坐标数据在后端是可配置的,未来接真实地图SDK时,前端只需要把Canvas渲染替换成地图marker即可,前后端接口完全不用动。

4.2 接口文档与前后端协作

这个项目在接口协作上踩了不少弯路。最初前端和后端是并行开发的,前端需要接口定义才能开始写页面逻辑。我们试过用文字描述接口,结果非常痛苦:字段命名不一致、嵌套层级理解偏差、响应格式不统一,联调阶段全暴露出来。

后来我强制要求项目里所有接口都使用统一的响应包装类和文档工具。响应包装类定义如下:

public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.code = 200; result.message = "success"; result.data = data; return result; } public static <T> Result<T> error(Integer code, String message) { Result<T> result = new Result<>(); result.code = code; result.message = message; return result; } }

这个包装类看起来很简单,但它解决了前后端沟通中最大的痛点:错误处理的一致性问题。接口报错时,前端只需要判断code是否为200,所有错误信息都从message字段取,不用关心HTTP状态码的复杂语义。这比直接返回HTTP 404、500加一个带错误的响应体要清晰很多。

接口路径设计上也有讲究。我遵循了资源导向的命名原则,比如/api/line/{code}表示线路资源,/api/station/{id}表示站点资源,/api/transfer/plan表示换乘方案的执行动作。这种风格的好处是接口数量一目了然,前端同学能根据资源名猜出URL的含义,几乎不用花时间查文档。

实际联调中我还推荐了一个小工具思路:让后端在启动时自动扫描所有Controller的RequestMapping注解,生成接口清单JSON文件。这样前端有个实时同步的接口总览,不用每次接口更新都靠微信发消息通知。实现起来就是一个ApplicationRunner监听器的事,代码不超过20行,但省去了大量沟通成本。

5. 实际开发中踩过的坑与问题排查

5.1 数据库连接池耗尽导致的服务假死

项目部署到测试环境后,有一段时间频繁出现整个服务无响应的情况。查看日志发现大量“Connection is not available, request timed out”的报错,这是典型的数据库连接池耗尽问题。排查发现在高并发时段,很多请求在查询数据库后没有正确释放连接。

根因有两个。一个是MyBatis-Plus的批量操作如果使用不当,会在循环里反复获取和释放连接,而C3P0连接池的默认最大连接数设置偏小,在高并发下很快被打满。另一个是部分Service方法里开启了事务后,如果事务内有耗时的外部调用(比如调用第三方天气API),事务不会提交,连接一直被占用。

我的解决方案分两部分。首先把所有事务型方法只保留必要的业务操作,外部接口调用移到事务外部,或者干脆去掉事务。其次把连接池参数调优:最大连接数从20调到50,空闲连接回收时间设为10分钟。调整后服务重新稳定运行,没有再出现假死。

这里有个经验要分享:排查此类问题不要只看报错堆栈,一定要结合连接池监控数据和慢查询日志一起分析。很多时候报错是表象,慢SQL才是真凶。我在项目里配置了Druid连接池的监控页面,通过它能看到每个接口的SQL执行时间、连接获取时间、活跃连接数等关键指标,定位问题快很多。

5.2 BFS算法在环线线路下的死循环问题

另一个印象很深刻的bug出现在环线公交线路的处理上。某条模拟线路是一条环形线路,始发站和终点站是同一个站点。在使用BFS搜索换乘方案时,算法沿着环线一圈一圈地遍历,虽然visited集合避免了站点重复入队,但环线导致搜索的层数远超预期,最坏情况下要把整张图全部遍历一遍才能结束。

这个问题暴露了一个算法设计上的缺陷:我最初只对站点做了访问去重,没有对“站点+线路”的组合做去重。同一个站点,用户从A线路到达后,没必要再从B线路到达一次,因为BFS的特性决定了第一次到达就是最短路径。但如果经过该站点的B线路在环线上,它的后继站点可能已经在visited集合里,搜索就会提前终止,找不到更优方案。

修复方案是引入“站点-线路”组合的访问记录。每个站点维护一个集合,记录已访问过该站点的线路ID,当算法准备从某个站点换乘到一条新线路时,先检查这条线路是否已经被访问过,如果访问过就跳过。这样既保证了搜索效率,又不会因为环线产生冗余搜索。

这个问题也让我意识到:算法测试不能只用简单的线性线路数据,必须覆盖环线、区间车、夜班车这些特殊线路形态。我把这些边界情况写成单元测试用例,每次改动算法后都跑一遍回归,确保不会引入新的问题。单元测试框架用的是JUnit 5,测试数据通过内存数据库初始化,测试速度非常快。

5.3 中文模糊查询的索引失效问题

系统上线后,站点名称的模糊搜索在低数据量时表现正常,但数据量增长到几万条后响应变慢。检查执行计划发现,虽然我在站点名称字段上建了索引,但like查询的模糊匹配规则是“%关键字%”,这种模式无法使用B+树索引,数据库只能做全表扫描。

针对这个问题,我尝试了两种方案。第一种是改用前缀匹配“关键字%”,但这会丢失“站名包含某关键字”的查询能力,比如用户输入“广场”,无法匹配“文化广场站”。第二种是引入全文索引,在MySQL 8.0中可以通过ngram分词器支持中文全文检索,本质上是用空间换时间,查询前把关键词分词,再走倒排索引加速。

最终我选了全文索引方案。在建表语句中给站点名称字段添加FULLTEXT索引,查询时使用MATCH AGAINST语法。实测在有5万条站点数据的情况下,模糊查询响应时间从800毫秒降到了30毫秒,效果非常明显。这里有个小细节:全文索引的维护会增加写入时的开销,但站点数据本身就很少更新,这个代价完全可以接受。

这类优化思路其实很像生活里的日常整理:东西不收拾的时候找什么都难,花点心思做好索引和分类,后面找起来就轻松了。数据量小的时候体会不到差别,数据量大了之后,建不建索引就是天壤之别。

6. 部署上线与后续扩展方向

6.1 实际部署过程中的经验

项目的部署我采用的是传统的单机部署方案:一台云服务器上运行Nginx、MySQL和Spring Boot应用。构建时使用Maven的package命令打出可执行jar包,通过systemd服务守护进程来管理应用生命周期。

说说为什么不用Docker。当时测试环境的服务器配置是2核4G,跑Docker加多个容器的资源开销比直接跑jar包大不少,而且对不熟悉容器技术的人来说,排查问题的工作量会翻倍。单机部署最大的风险是应用挂了需要人工处理,但我用systemd配置了服务失败自动重启,配合简单的健康检查脚本,稳定性已经完全够用。

部署过程中的一个关键操作是配置文件的分离。我没有把数据库连接信息直接写死在application.properties里,而是用Spring Boot的多环境配置机制:application-dev.yml用于本地开发,application-prod.yml用于线上环境。启动时通过--spring.profiles.active=prod参数指定当前环境。这样本地开发连本地数据库,线上连线上数据库,配置互不干扰,代码一致。

安全方面我做了几层措施:第一层是服务器安全组配置,只开放80和443端口,其他端口不对公网开放;第二层是数据库账号单独创建,不用root账号连接应用,且密码通过环境变量注入而不是写在配置文件中;第三层是Nginx层做请求限制,防止恶意刷接口。虽然这个系统的用户量还远未达到被攻击的规模,但这些安全习惯一旦养成,以后做任何项目都受益。

6.2 这套系统还能怎么扩展

如果后续要把这套查询系统做成一个真正面向公众的产品,我认为有四个方向值得探索。

第一个方向是接入真实公交数据。很多城市都有公交数据开放平台,提供静态线路数据和实时车辆位置数据。接入真实数据后,我的模拟定时任务就只需要替换成数据同步任务,核心算法和接口结构完全复用。

第二个方向是智能推荐与个性化。比如根据用户常去的地点推荐最优通勤线路,或者在突发状况(如线路临时调整)时主动推送替代方案。这些功能依赖用户行为数据的积累,需要前端埋点和后端用户画像模块的配合。

第三个方向是多端适配。目前是Web端为主,如果能做成微信小程序或App,对用户来说会方便很多,毕竟查询公交线路的需求大多数发生在移动场景。小程序的UI组件和接口调用方式与Web端差异不小,但后端接口基本可以直接复用,工作量主要在前端。

第四个方向是性能与架构升级。当用户量达到十万量级、数据库成为瓶颈时,可以考虑引入Redis作为二级缓存层,把热点数据从本地缓存升级为分布式缓存。换乘算法如果遇到大规模图数据,也可以考虑预计算所有站点对的最短路径,把计算结果存到缓存中,查询时直接读取,响应时间能做到10毫秒以内。当然这是比较远的规划,当前架构下优化空间还很大。

最后分享一个我在这类项目里反复感受到的小体会:好系统不是一次设计出来的,是在一次又一次的实际使用反馈中打磨出来的。公交查询系统看起来技术含量不算高,但真正把“查询快、结果准、体验顺”这三个要求做到位,需要思考的细节远比想象中多。初版上线后,朋友使用过程中反馈“搜索结果顺序不直观”“模糊搜索匹配太严格”,每次反馈背后都是一个可以优化的点。保持对细节的敏感,比掌握多高深的技术都重要。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询