news 2026/10/16 8:23:47

Spring Boot公交查询系统毕设实战:数据库设计到BFS换乘算法

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring Boot公交查询系统毕设实战:数据库设计到BFS换乘算法

每年做毕业设计,找我聊选题的同学特别多。大多数人上来就说要做"XX管理系统",无非是用户管理、订单管理、增删改查,页面堆一堆,等答辩的时候老师问一句"你系统的难点是什么",整个人就僵住了。但如果是公共交通查询系统,情况会完全不一样——这是一个大家每天都能感知到的场景,天然具备真实的数据结构和算法深度,既不冷门,也不至于烂大街。

公共交通查询系统的核心价值是:给用户输入起点站和终点站,系统返回可选的公交线路、经过站点、换乘方案;输入线路号,能看到这条线路的完整走向;输入站点名,能知道有哪些线路经过这里。这些事情看起来简单,但落到数据库设计和后端实现上,涉及多对多关系建模、图论搜索算法、数据清洗、接口性能优化,是一道非常完整的全栈考题。

这篇文章是我做这个项目的全记录,从选题理由、技术选型、数据库设计,到线路查询、站点查询、换乘算法、性能优化,再到实际开发中遇到的坑和答辩时被追问的问题,一次性讲清楚。无论你是准备做类似毕设的在校生,还是想练手Spring Boot的开发者,这篇文章都能给你一套可以直接落地的方案。

1. 项目全景:为什么公共交通查询系统是个"聪明"的选题

1.1 毕设选题的第一原则:场景真实、边界清晰

技术类毕业设计最怕两件事:一是题目太虚,需求全靠编;二是题目太窄,根本没有发挥空间。公共交通查询系统恰好两头都占不到,它来源于城市生活中再普通不过的需求——出门怎么坐车,因此需求描述不需要任何想象力,导师一听就明白,答辩时也不会在"业务背景"上纠缠太久。

更重要的是,它的数据模型是天然的多对多关系。一条线路包含几十个站点,一个站点可能同时被十几条线路覆盖,这种关系和图书管理系统里"用户-订单"那种一对多关系完全不是一个量级。你在设计表结构、写查询语句、处理数据冗余时,会真实感受到"关系型数据库建模"是怎么一回事。

换乘查询这个功能又给项目带来了算法深度。你不可能用简单的SQL查出"从A站到B站换乘最少"的方案,它本质上是图论问题,需要把公交网络抽象成一张图,再用广度优先搜索(BFS)来求解。这一下子就把项目从"CRUD管理系统"拉高到了"有算法的系统",答辩时的讨论空间完全不一样。

1.2 从用户需求反推核心功能

做系统之前,我习惯先站在用户角度把所有功能列出来,标出优先级和实现难度,再做取舍。公共交通查询系统的典型用户无非想知道四类事情:

功能用户意图实现难度备注
线路查询输入2路,看它从哪到哪、经过哪些站低核心功能,必做
站点查询输入"人民广场",看有哪些线路经过中核心功能,必做
换乘方案从"火车站"到"机场",给出换乘路线高亮点功能,必做
周边站点给我经纬度,找500米内的公交站中加分项,可选
历史记录/收藏记住常用线路和站点低可选,视时间定

我最后只做了前三个核心功能加周边站点查询。历史记录和收藏因为涉及用户体系,会增加不少工作量,对核心展示没有决定性的帮助,可以先砍掉。项目做到这一步,功能已经足够完整,答辩时也可以说"后续可以扩展",留有余地。

2. 技术选型与系统架构:不盲目追新

2.1 为什么是Spring Boot而不是其他方案

选题定下来之后,技术栈的选择其实没有太多悬念。Spring Boot在Java Web领域已经是事实上的标准,内置Tomcat、自动配置、Starter机制,把十年前SSH那套繁琐的XML配置全部干掉,开发效率高一大截。对毕设来说,最重要的是生态成熟,遇到问题在搜索引擎里一搜就有答案,不会卡在某个冷门配置上出不来。

有人会问,为什么不用Python的Flask或Django?不是说它们不好,而是对计算机专业的学生来说,Java的强类型和面向对象建模能力更适合这种多实体关联的业务场景。站点、线路、线路站点关联,天然可以抽象成Entity、Repository、Service、Controller分层结构,写起来思路清晰,答辩讲架构也方便。而且Spring Boot在Java就业市场中的占比很高,做这个项目顺带把主流框架的套路练了一遍,一举两得。

版本上我选的是Spring Boot 2.7.x + JDK 8,没有用最新的Spring Boot 3.x。原因很简单:JDK 8在很多学校机房和面试场景里还是主力,2.7.x的资料也多,遇到问题好查。如果你本机已经装了JDK 17,用3.x也不是不行,唯一要注意的是3.x里javax包换成了jakarta包,很多老教程的import语句直接复制会报错,新手容易被这个坑绊一下。

2.2 前后端分离还是服务端渲染

这个选择要看你做毕设的定位。如果只是为了交差,服务端渲染是成本最低的方案,用Thymeleaf模板引擎直接渲染HTML,后端一个工程搞定,部署也简单,不用考虑跨域问题。但如果你想让项目在简历上更有分量,我建议做前后端分离:后端用Spring Boot提供纯JSON接口,前端用一个Vue项目。

我实际选择的是前后端分离。原因是公共交通查询系统本身是一个"查询密集型"应用,接口语义清晰,非常适合用JSON来交互。前端用一个单页应用展示线路详情、换乘方案的地图感界面,会比传统模板页面灵活得多。代价是要多维护一个前端工程,部署时也要多一步,但对完整项目经验的积累很有帮助。答辩时也可以说"我考虑了前后端分离架构",这比单纯的CRUD页面要有说服力。

2.3 项目目录与分层思路

包结构我按经典分层来做,这样写起来清爽,答辩时也好讲:

  • entity:数据库实体类,比如Station、Route、RouteStation
  • repository:数据访问层,使用Spring Data JPA
  • service:业务逻辑层,处理线路查询、换乘搜索等
  • controller:接口层,只做参数接收和结果返回
  • dto:接口出入参对象,避免直接把实体返回给前端
  • config:配置类,比如跨域、缓存配置
  • common:统一返回包装、异常处理

有一个容易忽略的点:接口返回值不要直接吐实体对象。实体里可能有关联关系、懒加载代理,直接序列化容易出循环引用或者多余字段。我统一用一个Result包装类返回code、message、data三个字段,前端只用判断code就能知道请求是否成功,这个习惯在真实项目中也是通用的。

3. 数据库建模与数据准备:查询系统的地基

3.1 三张核心表的设计思路

公共交通查询系统最核心的表就三张:站点表、线路表、线路站点关联表。我第一次设计时想复杂了,给线路表里加了很多运营属性,结果发现大部分根本用不上。兜兜转转,最终保留了最精简有用的字段。

站点表station的核心字段是站点名称、经度、纬度。经纬度非常重要,后面做周边站点查询和换乘方案排序都要用到。注意站点名称可能会重复,例如"人民广场"在不同城区可能都有,所以站点不能只按名称区分,要有独立主键。

线路表route的核心字段是线路编号、线路名称、起点站、终点站、运营开始时间和结束时间。一条线路往往有上下行两个方向,比如"1路"从A站到B站,返回时从B站到A站,走的站点序列不完全相同。如果图省事把两个方向合并成一条记录,换乘算法会算出不存在的路径。所以我在route表里加了direction字段,上下行分两条记录存,一条线路编号对应两条route记录,这是非常关键的设计决策。

线路站点关联表route_station是连接线路和站点的桥梁。它至少要包含三个字段:线路ID、站点ID、站点序号seq_no。站点序号表示该站是这条线路的第几站,换乘算法按这个排序才能还原出完整的行驶路径。如果还要展示站间距,可以加一个distance_from_start字段表示距离起点站的距离。

建表SQL可以参考这样的结构:

CREATE TABLE station ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, lng DECIMAL(10, 6), lat DECIMAL(10, 6), district VARCHAR(50), address VARCHAR(200), UNIQUE KEY uk_station_name_lnglat (name, lng, lat) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; CREATE TABLE route ( id BIGINT PRIMARY KEY AUTO_INCREMENT, route_no VARCHAR(20) NOT NULL, route_name VARCHAR(100), start_station VARCHAR(100), end_station VARCHAR(100), direction TINYINT COMMENT '0-上行 1-下行', start_time VARCHAR(10), end_time VARCHAR(10), UNIQUE KEY uk_route_no_direction (route_no, direction) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; CREATE TABLE route_station ( id BIGINT PRIMARY KEY AUTO_INCREMENT, route_id BIGINT NOT NULL, station_id BIGINT NOT NULL, seq_no INT NOT NULL COMMENT '站点在线路中的顺序', distance_from_start DECIMAL(10, 2) COMMENT '距起点距离,单位公里', KEY idx_route_id (route_id), KEY idx_station_id (station_id) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

这里我把route_no和direction做成联合唯一键,防止同一条线路的上下行重复录入。站点表也做了名称和经纬度的唯一约束,避免同一位置出现多个"幽灵站"。这类细节在答辩时主动讲出来,老师会觉得你真的思考过数据质量问题。

3.2 数据从哪里来:模拟数据与真实数据的平衡

数据是做这个项目最容易卡住的地方。真实公交数据涉及版权和接口授权,个人项目跑起来比较麻烦;纯手工造几百条数据,又累又容易出逻辑漏洞。我的做法是写一个Python脚本,参考某地图开放平台的公开公交线路格式,生成了某市一套模拟数据:一共20条线路、约800个站点、3000多条线路站点关联记录。规模虽然不大,但足够支撑功能和算法验证。

生成数据时有两个地方要特别注意。一是站点名称要有合理的重复,比如"火车站"这种换乘大站,必须出现在多条线路上,否则换乘查询根本没法测。二是线路要有环路和断头路,不能所有线路都简单地首尾相连,否则图结构太规整,算法测试不出边界情况。

数据清洗上,最大的坑是编码问题。线路名和站点名里可能有生僻字、繁体字,MySQL必须设置utf8mb4字符集,Java连接串里也要加上characterEncoding=utf8参数,否则中文存进去再查出来就是问号。我一开始没用utf8mb4,结果"人民广场"存成了"??" 修了一晚上。

3.3 为换乘查询做数据预热

换乘查询如果每次实时去数据库里多次join,性能会很难看。我的做法是应用启动完成后,把站点和线路的关系加载到内存里,构建一个以站点ID为key、以经过该站点的线路列表为value的Map。

@Component public class TransferDataLoader implements ApplicationRunner { @Resource private RouteStationRepository routeStationRepository; @Resource private StationRepository stationRepository; private Map<Long, List<RouteStation>> stationToRoutes; private Map<Long, Station> stationMap; @Override public void run(ApplicationArguments args) { List<Station> stations = stationRepository.findAll(); stationMap = stations.stream().collect(Collectors.toMap(Station::getId, s -> s)); List<RouteStation> relationList = routeStationRepository.findAll(); stationToRoutes = relationList.stream().collect( Collectors.groupingBy(RouteStation::getStationId) ); } }

这里的关键是groupingBy按站点ID把经过它的所有关联记录分组。换乘BFS搜索时,从一个站点出发,能坐哪些线路,直接从这个Map里取,O(1)时间复杂度,非常快。启动预热虽然占用一点内存,但换乘查询的响应时间能控制在几十毫秒以内,这笔交易很划算。

4. 核心功能模块实现:线路查询与站点查询

4.1 线路查询接口的完整实现

线路查询的业务逻辑很直接:根据前端传来的线路编号(比如"2路"或者"2"),匹配到所有相关线路,然后按方向列出经过的站点。这里面最容易写错的地方是:一条线路有上下行,查询时要把两个方向都返回,并且每个方向内的站点顺序必须严格按照seq_no升序排列。

Controller层我只做参数校验和结果封装:

@RestController @RequestMapping("/api/route") public class RouteController { @Resource private RouteService routeService; @GetMapping("/{routeNo}") public Result<List<RouteDetailDTO>> getRouteDetail(@PathVariable String routeNo) { if (routeNo == null || routeNo.trim().isEmpty()) { return Result.error("线路号不能为空"); } return Result.success(routeService.getRouteDetail(routeNo.trim())); } }

Service层是业务核心,先查线路,再查线路站点关联,最后组装站点明细。JPA里如果直接用实体关联查询,很容易出现N+1问题,也就是查了一条线路之后,每查一个关联站点都发一条SQL。避免的办法是使用JOIN FETCH一次性把关联数据查出来:

@Service public class RouteServiceImpl implements RouteService { @Resource private RouteRepository routeRepository; @Resource private RouteStationRepository routeStationRepository; @Resource private StationRepository stationRepository; @Override public List<RouteDetailDTO> getRouteDetail(String routeNo) { List<Route> routes = routeRepository.findByRouteNoOrderByDirectionAsc(routeNo); if (routes.isEmpty()) { throw new BusinessException("未找到该线路"); } List<RouteDetailDTO> result = new ArrayList<>(); for (Route route : routes) { List<RouteStation> rsList = routeStationRepository .findByRouteIdOrderBySeqNoAsc(route.getId()); List<StationInfoDTO> stations = rsList.stream() .map(rs -> { Station station = stationRepository.findById(rs.getStationId()).orElse(null); if (station == null) { return null; } StationInfoDTO dto = new StationInfoDTO(); dto.setStationId(station.getId()); dto.setStationName(station.getName()); dto.setSeqNo(rs.getSeqNo()); return dto; }) .filter(Objects::nonNull) .collect(Collectors.toList()); RouteDetailDTO dto = new RouteDetailDTO(); dto.setRouteId(route.getId()); dto.setRouteNo(route.getRouteNo()); dto.setStartStation(route.getStartStation()); dto.setEndStation(route.getEndStation()); dto.setDirection(route.getDirection()); dto.setStartTime(route.getStartTime()); dto.setEndTime(route.getEndTime()); dto.setStations(stations); result.add(dto); } return result; } }

这里我特意没有在Route实体里用@OneToMany直接关联RouteStation,而是单独用Repository按条件查询。这样做牺牲了一点ORM的便利,但换来的是SQL可控、不会莫名触发懒加载异常。对于毕业设计这种规模的项目,清晰比花哨重要。

4.2 站点查询与关键字的模糊搜索

站点查询的输入是关键字,输出是匹配到的站点以及经过这些站点的线路。最朴素的SQL是WHERE name LIKE '%关键字%',但这里有个性能陷阱:前导通配符会让普通索引失效。如果站点表数据量小,问题不大,我测试时800个站点查询也在毫秒级,可以接受。

如果想要更专业一点,可以给站点名称加一个全文索引。MySQL的全文索引对中文支持不算特别好,需要ngram分词器。在真实项目中很多系统会引入搜索引擎,但一个毕业设计做成这样已经足够,我在答辩时的说法是"当前数据量下LIKE查询满足需求,若扩展到全量数据可引入全文索引或搜索引擎",这个回答既诚实又体现你有扩展思维。

站点重名问题也必须处理。比如"人民广场"在多个区域都有,用户输入这个名字,查询结果应该把所有同名站点都返回,并且每个站点下列出经过它的线路。前端可以在展示时加上区域或经纬度来区分。Service层返回结构可以这样设计:

public class StationSearchDTO { private Long stationId; private String stationName; private String district; private BigDecimal lng; private BigDecimal lat; private List<String> routeNos; // 经过该站点的线路编号列表 }

4.3 周边站点查询:一个优雅的加分项

周边站点查询实现起来不算复杂,但对用户体验提升非常明显。用户打开系统,输入自己当前的经纬度,系统返回500米或1公里内的公交站点。这里用到的核心是球面距离公式,也就是Haversine公式。MySQL 5.7以上直接提供了ST_Distance_Sphere函数,可以按经纬度算球面距离:

@Query(value = "SELECT s.*, " + "ST_Distance_Sphere(point(s.lng, s.lat), point(?1, ?2)) AS distance " + "FROM station s " + "HAVING distance < ?3 " + "ORDER BY distance " + "LIMIT 10", nativeQuery = true) List<StationDistanceDTO> findNearbyStations(double lng, double lat, double radius);

注意ST_Distance_Sphere返回单位是米,所以传入的radius就是500或1000。用原生SQL时,DTO里的distance字段是一个别名的映射,我在实际开发中发现如果不加HAVING distance < ?3,MySQL会直接报错,因为别名不能在WHERE里引用。这个细节花了一点时间才排查清楚。

5. 换乘方案查询:从BFS到工程化实现

5.1 把公交网络抽象成图

换乘查询是公交查询系统最核心的算法,也是整个项目里最有含金量的部分。我先讲清楚图是怎么建的:把每个站点看成图的顶点,每一条线路看成一组连接关系。但这里有个容易理解错的地方,换乘最少的问题不能简单地把"相邻两个站点"都当成一条边来做BFS,那样算出来的最短路径很可能是步行距离最短而非换乘最少。

换乘最少问题的正确打开方式是:从站点A出发,选择一条经过该站点的线路L,沿着L可以一路坐到这条线上的任意站点都不算换乘;如果中途下车换到另一条线路M,这才算一次换乘。所以BFS的"步数"应该是换乘次数,每个状态要记录当前坐的是哪条线路。

用生活化的方式理解:你站在站台上,面前停了10路、20路、30路,你选择坐上20路的那一刻,就是一次"决策";20路经过10个站,只要你没下车,这10个站之间都不需要再做决策。只有当你想换到30路的时候,才需要再花一次"决策成本"。BFS按决策次数逐层扩展,第一次到达终点站的方案,就是换乘最少的方案。

5.2 BFS换乘算法的代码实现

我定义了一个内部类来表示搜索状态:

public class TransferNode { private Long stationId; // 当前站点 private Long routeId; // 当前乘坐的线路 private int transferCount; // 已经换乘的次数 private List<Long> stationPath; // 经过的站点路径 private List<Long> routePath; // 经过的线路路径 public TransferNode(Long stationId, Long routeId, int transferCount, List<Long> stationPath, List<Long> routePath) { this.stationId = stationId; this.routeId = routeId; this.transferCount = transferCount; this.stationPath = new ArrayList<>(stationPath); this.routePath = new ArrayList<>(routePath); } }

搜索的时候,用一个队列做BFS,再用一个Map记录每个站点在"不同线路"下已经出现的换乘次数,用于剪枝:

public TransferResult findTransfer(Long startStationId, Long endStationId, int maxTransfer) { Queue<TransferNode> queue = new LinkedList<>(); Map<String, Integer> visited = new HashMap<>(); List<RouteStation> startRsList = stationToRoutes.get(startStationId); if (startRsList == null) { return TransferResult.empty("起点站不存在"); } for (RouteStation rs : startRsList) { Route route = routeMap.get(rs.getRouteId()); List<Long> stationPath = new ArrayList<>(); stationPath.add(startStationId); List<Long> routePath = new ArrayList<>(); routePath.add(rs.getRouteId()); TransferNode node = new TransferNode(startStationId, rs.getRouteId(), 0, stationPath, routePath); queue.offer(node); visited.put(key(startStationId, rs.getRouteId()), 0); } while (!queue.isEmpty()) { TransferNode node = queue.poll(); if (node.getTransferCount() > maxTransfer) { continue; } if (node.getStationId().equals(endStationId)) { return buildResult(node); } List<RouteStation> currentLineStations = routeStationMap.get(node.getRouteId()); for (RouteStation rs : currentLineStations) { Long nextStationId = rs.getStationId(); if (node.getStationPath().contains(nextStationId)) { continue; } // 沿当前线路继续坐,不增加换乘次数 List<Long> nextStationPath = new ArrayList<>(node.getStationPath()); nextStationPath.add(nextStationId); TransferNode nextNode = new TransferNode(nextStationId, node.getRouteId(), node.getTransferCount(), nextStationPath, node.getRoutePath()); queue.offer(nextNode); } // 在当前站点换乘其他线路 List<RouteStation> crossLineList = stationToRoutes.get(node.getStationId()); for (RouteStation rs : crossLineList) { Long nextRouteId = rs.getRouteId(); if (nextRouteId.equals(node.getRouteId())) { continue; } String visitKey = key(node.getStationId(), nextRouteId); int nextTransferCount = node.getTransferCount() + 1; if (nextTransferCount > maxTransfer) { continue; } Integer prevCount = visited.get(visitKey); if (prevCount != null && prevCount <= nextTransferCount) { continue; } visited.put(visitKey, nextTransferCount); List<Long> nextRoutePath = new ArrayList<>(node.getRoutePath()); nextRoutePath.add(nextRouteId); TransferNode nextNode = new TransferNode(node.getStationId(), nextRouteId, nextTransferCount, node.getStationPath(), nextRoutePath); queue.offer(nextNode); } } return TransferResult.empty("未找到合适的换乘方案"); }

这段代码有一个关键的剪枝逻辑:同一个站点同一条线路,如果之前已经用更少的换乘次数访问过,那就没必要再入队。如果不做这个剪枝,环路线路上会无限循环,站点一多直接就内存溢出了。我最初版本漏了这个,测试时一个很小的图就卡死,排查了半天才发现问题。

换乘次数上限我设置成了3次,也就是最多允许4条线路接驳。真实城市公交出行中,超过3次换乘的路径几乎没有实际意义,同时限制深度可以防止算法在稀疏图上空转。

5.3 换乘结果的排序与返回格式

BFS第一次找到终点时,换乘次数一定是最少的,但实际使用中,同样是换乘1次,可能是"走2站然后换乘"也可能是"走20站再换乘",后者体验明显更差。所以我的策略是:继续BFS,把所有小于等于最大换乘次数的方案都收集起来,然后按换乘次数优先、总经过站数其次的规则排序,取前3条返回。

前端拿到换乘方案之后,可以分段渲染,每一段显示"乘坐X路从A站到B站,共N站",这样界面展示非常直观。接口返回格式大致是:

{ "code": 200, "data": [ { "transferCount": 1, "totalStations": 12, "segments": [ { "routeNo": "10路", "startStation": "火车站", "endStation": "广场东", "stationCount": 5 }, { "routeNo": "20路", "startStation": "广场东", "endStation": "机场", "stationCount": 7 } ] } ] }

答辩时老师很喜欢问为什么不用Dijkstra。我的理解是:Dijkstra解决的是带权最短路径,如果权重是"时间"或者"距离",当然可以用;但"最少换乘次数"的权重是等值的,每次换乘代价为1,BFS天然按层扩展,每层就是一次换乘,复杂度更低也更贴合问题本质。如果要坐车时间最短,可以把站间距离或时间作为边权切换到Dijkstra,我当时在系统里也预留了策略接口,只是没有完整实现,这个话术在答辩现场很加分。

6. 实操过程记录:从初始化到联调通过

6.1 环境准备与项目初始化

环境的坑往往比代码坑更难处理。我用的组合是JDK 8 + Maven 3.8 + Spring Boot 2.7.18 + MySQL 8.0。打开Spring Initializr,依赖选了Spring Web、Spring Data JPA、MySQL Driver、Lombok、Validation。如果你的前端做前后端分离,还要记得在Config里写一个跨域过滤器,否则前端访问接口会被浏览器拦下来。

application.yml的基本配置长这样:

server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/transit?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver jpa: hibernate: ddl-auto: update show-sql: true properties: hibernate: format_sql: true cache: type: caffeine

ddl-auto: update对于毕设项目非常省事,实体类改完字段,重启自动帮你同步表结构。但注意,这只能用于开发环境,如果换字段类型或删字段,自动更新是不可靠的,正式环境一定要用Flyway这类迁移工具。答辩时可以说"开发阶段用update验证,生产环境应当使用版本化迁移",体现工程质量意识。

6.2 核心接口的开发顺序

我建议的开发顺序是:实体类 -> Repository -> Service -> Controller -> 前端联调。这看起来像废话,但真有很多人先写Controller再补Service,最后实体还没建,调试时到处报空指针。

实际开发时我踩过的最大的坑是JPA的懒加载。查线路列表没问题,但查线路详情时,因为实体关系用了@ManyToOne,当Jackson序列化Route对象时,可能触发对Station的懒加载,而Session已经关闭,直接抛出LazyInitializationException。解决方法是DTO层兜底:绝不在实体上直接序列化,而是手动把需要的数据复制到DTO里。一旦你这么做了,会发现懒加载的问题瞬间消失。

至于接口调试,我用的是常见的接口调试工具,比如Postman这类。先把线路查询调通,再调站点查询,再调周边站点,最后集中火力处理换乘。每调通一个接口就提交一次代码,别等到最后统一提交,否则出了Bug都不知道是哪次改动引起的。

6.3 缓存优化与SQL调优

查询系统的性能优化点在"读"。线路详情和换乘结果都是高频查询,但数据本身变化很慢,非常适合加缓存。我用Spring Cache加Caffeine做了两级本地缓存:

@Service public class RouteServiceImpl implements RouteService { @Cacheable(value = "routeDetail", key = "#routeNo") @Override public List<RouteDetailDTO> getRouteDetail(String routeNo) { // 去数据库查 } }

实测效果非常明显,第一次查询可能需要50毫秒,加了缓存之后稳定在个位数毫秒。因为数据是启动时加载的内存数据,甚至可以不查数据库直接返回,但用Spring Cache的好处是代码侵入小,逻辑清晰。

SQL调优方面,我重点看route_station表的两条查询路径:按routeId查、按stationId查,这两个字段都建了索引。用EXPLAIN看执行计划,确认是走索引的,没有出现全表扫描。如果想再压一下性能,可以给(route_id, seq_no)建联合索引,因为线路详情的查询永远是按线路ID加序号排序的,联合索引能直接覆盖查询。

7. 常见问题与排查技巧实录

7.1 高频报错与解决方案速查表

我把自己开发中遇到的高频问题整理成了一张表,遇到类似报错可以直接对照处理:

报错信息可能原因解决方案
Access denied for user 'root'@'localhost'数据库账号密码错误检查application.yml配置,确认密码无误
Loading class com.mysql.jdbc.DriverMySQL驱动类名写错新版MySQL驱动类名改为com.mysql.cj.jdbc.Driver
LazyInitializationException实体懒加载跨Session访问改用DTO组装数据,不要直接返回实体
Cannot deserialize value...前端参数类型和后端不一致检查Controller入参类型,JSON字段名是否匹配
中文乱码数据库连接串和目标表字符集不对URL加characterEncoding=utf8,表建为utf8mb4
端口被占用上次服务没有正常关闭杀掉占用8080端口的进程,或换端口
换乘查询内存溢出BFS剪枝不完善给每个站点线路组合做好visited记录

其中LazyInitializationException是最容易让新手崩溃的,因为它报错的位置在Jackson序列化阶段,看起来完全没有逻辑,其实只要坚持"接口一定返回DTO"这个原则,这类问题就能从根上避免。

7.2 换乘查询返回空结果的排查思路

换乘接口做完第一次测试时,我连续试了好几组起终点都返回"未找到合适换乘方案"。我没有急着改代码,而是先做了三件事:第一,打印BFS搜索的起点线路集合,确认起点站确实有线路经过;第二,检查终点站是否在可达集合里,做一个简单的连通性检测;第三,逐步减少maxTransfer上限,看看是不是搜索深度不够。

最终发现的问题很有意思:我的数据生成脚本里有一条环线,它从某站出发绕一圈回到同一站,BFS在沿这条线路扩展时,遇到的站点都已经被其他节点访问过,剪枝策略把很多其实没被"合理访问"的节点活生生剪掉了。也就是说,visited记录只考虑了站点+线路的组合,却没有把换乘次数纳入判断的最优维度,导致部分换乘次数更少的可能性被错误剪掉。修正办法是在visited里保存当前最优换乘次数,只有新的换乘次数更小才允许重新访问。

这种问题在书本上很难看到,只有实际构造出环线数据、跑出错误结果之后,才会真正理解BFS剪枝的边界条件。我把这个Bug原原本本地写进了项目文档里,答辩时当作"分析问题和解决问题能力"的例子来讲,比任何包装都有说服力。

7.3 答辩环节的高频追问与应对思路

答辩时老师问我的几个问题,基本是所有做这个题目都会被问到的,提前准备一下很有必要。

第一个问题:"你的数据是真实数据吗?" 我如实回答是模拟数据,但数据结构参考了公开的公交数据格式,同时说明数据量对验证功能足够,如果需要接入真实数据,只需要写一个数据导入适配器。这个回答既诚实又表明你考虑过数据来源。

第二个问题:"换乘算法为什么不直接查数据库?" 我的解释是换乘本质是图搜索,数据库的SQL不适合表达"最少换乘"这种跨线路的路径搜索,所以用内存图加BFS。同时说明为什么数据量不大时可以内存加载,如果站点数量达到百万级,可以改用图数据库或分片缓存。这个扩展思路会让答辩老师觉得你有全局视野。

第三个问题:"系统还有什么可以改进的地方?" 我列了三点:引入实时到站数据、加入发车间隔推算总耗时、把换乘策略改成多目标优化(最少时间、最少步行、最少换乘可切换)。这些不是空话,而是结合公共交通领域的真实痛点提出来的方向,老师一听就知道你对这个领域有过思考。

做这个项目的一点个人心得

整个项目从选题到最后文档完稿,我大概花了三周时间。回头复盘,最花时间的不是写代码,而是设计数据结构和换乘算法那两天。有一个很深的体会:毕业设计不是"完成度竞赛",而是"思考深度展示"。同样一个换乘函数,有人直接网上抄一段Dijkstra改改,有人能讲清楚为什么在"最少换乘"场景下BFS比Dijkstra更合适,后者的分数一定高得多,因为老师想看到的是你能独立建模、独立决策。

最后再分享一个小技巧:开发之前先花半天时间,用一张纸上把所有站点的线路关系画出来,哪怕只是简化版。这张图不仅是你的设计草图,更是答辩时能亮出来的素材。我答辩时把这张手绘图扫描进PPT里,配合BFS的层次扩展过程讲解换乘逻辑,效果比放一堆代码截图好太多了。

如果你也想做类似的系统,我的建议是先定数据,再定算法,最后写接口。顺序搞反了,后续返工的成本远比你想象的高。现在这套"站点-线路-关联表"的建模思路,换个马甲就能用在地铁查询、园区摆渡车、校内班车等场景里,复用价值极高。做一次,不亏。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 8:22:22

数值分析上机编程全攻略:题型拆解、算法选型与Python实现

数值分析上机编程题&#xff0c;大概是每个工科学生都绕不过去的坎。期末要交作业、考试前突击练习、保研复试上机考核&#xff0c;翻来覆去就是那几个经典题目&#xff1a;插值、数值积分、非线性方程求根、线性方程组迭代解、常微分方程数值解。题目看着简单&#xff0c;但真…

作者头像 李华
网站建设 2026/10/16 8:22:10

从零搭建历史资料汇编AI Agent编排架构:六轮迭代实战复盘

1. 为什么我要从零搭一套资料汇编的编排架构历史专题资料汇编这件事&#xff0c;做过的人都知道&#xff0c;它跟普通的文档整理完全是两码事。普通文档整理&#xff0c;你把文件按目录分好、改个名、写个索引就完事了。但历史专题资料汇编的核心难点在于&#xff1a;同一件事在…

作者头像 李华
网站建设 2026/10/16 8:22:03

安卓手机投屏电脑全指南:无线、App推流与有线方案对比

安卓手机投屏电脑这个需求&#xff0c;这两年问的人越来越多。有人是为了开会时把手机里的资料直接放上大屏幕&#xff0c;有人是想把视频App里攒的剧投到显示器上慢慢看&#xff0c;也有朋友纯粹是不想再传文件&#xff0c;直接镜像手机画面省事。问题是安卓机型太多&#xff…

作者头像 李华
网站建设 2026/10/16 8:22:00

PyTorch手写VGG16:从结构原理到训练调优的完整实践

1. 为什么VGG16至今仍是PyTorch入门必练的“教科书级”模型你打开任何一份PyTorch深度学习教程&#xff0c;几乎都会在“经典CNN架构实战”章节里撞见VGG16。它不像ResNet那样靠残差连接刷榜&#xff0c;也不像Vision Transformer那样用注意力机制掀起浪潮&#xff0c;但它稳稳…

作者头像 李华
网站建设 2026/10/16 8:21:58

前端转型Agent开发:不训模型,如何用工程能力跑通第一个Agent

1. 被“训练模型”四个字劝退的前端&#xff0c;到底错过了什么去年年底&#xff0c;团队里一个做了五年 React 的同事跟我说&#xff0c;他想转 Agent 开发&#xff0c;但看了三天资料就放弃了。我问他卡在哪&#xff0c;他说&#xff1a;“满屏都是微调、LoRA、RLHF、损失函数…

作者头像 李华
网站建设 2026/10/16 8:20:45

Python列表元组集合转换全指南:从基础语法到实战避坑

写Python这些年&#xff0c;被问得最多的不是那些花哨的框架&#xff0c;反而是list、tuple、set之间怎么转来转去。其实列表集合元组的转换这一课&#xff0c;看起来就是三个内置函数的事&#xff0c;但真要落地到项目里&#xff0c;里面藏着一堆顺序、可变性、哈希性的坑。这…

作者头像 李华