1. 接到这个项目时,我首先想清楚的三件事
去年年初,朋友介绍了一个公交公司的项目——做一套城市公交调度系统。初次沟通时,调度员给我演示了他们的日常:一本厚厚的发车记录本,一部对讲机,一块写满线路和车次的白板。车辆走到哪、下一班什么时候发、某个站点客流是否积压,全靠调度员的经验判断。这套模式在小县城、单一线路还能勉强运转,一旦线路超过十条、车辆超过五十台,人工调度的压力就非常明显。
客户的需求其实很朴素:要把车辆位置实时看到,要把发车计划自动排出来,要能查每天的营运数据。但"朴素的需求"落到系统设计上并不简单,因为公交调度牵扯的不仅是技术问题,还有业务流程、人员习惯、数据口径等一连串实际问题。基于SpringBoot来做这套系统,技术层面没有太大悬念,真正的难点在于把调度员的脑内经验转译成可计算、可落地的逻辑。
项目刚开始,我先做了一件事:和调度员一起跟车跑了三天。这三天对我的帮助远大于任何需求文档。公交调度的真实流程是这样的——早上发车前,车辆在首末站停车场等待,调度员根据当天的排班表安排车辆上线;车辆运营中,司机会在某些重要站点通过对讲机汇报路况和客流,比如"XX站人很多,需要加车""前方堵车,晚点十分钟";高峰结束,调度员再把多余的车辆从线路上抽回来。这个"上线-运营-抽车"的循环,是所有调度功能的核心骨架。
如果一开始就扎进代码里,很容易把系统做成"一堆列表的增删改查",而忽略了调度员真正需要的东西。我在项目启动会上立了三条设计原则,这几条原则贯穿了整个开发周期:
- 系统要能回答"车在哪、几时到、跑了几趟"这三个问题;
- 排班模块必须支持手动干预,算法只是辅助,不能替代调度员的决策权;
- 所有的操作都要留痕,因为公交公司有营运台账和审计要求。
这三条原则直接决定了后面的模块划分、数据库设计和接口方案。比如"操作留痕"这一条,导致我在所有核心表上都加了操作日志字段,并且单独建了一张操作流水表。当时看起来是多写了几个接口,上线三个月后就体现出价值了——某次早晚高峰发车记录对不上,就是靠流水表还原了当时的操作顺序,找出了原因。
1.1 调度系统里的角色与人机边界
公交调度系统里其实有四种角色,很多人容易搞混:系统管理员、调度员、司机、乘客(或对外查询端)。我见过不少同类项目,一上来就给司机和调度员分同样的权限,结果调度台能看到不该看的维修数据,司机端又被塞了一堆排班管理功能,整个系统臃肿难用。
合理的划分是这样的:系统管理员管基础数据,包括线路、站点、车辆档案、司机档案;调度员看的是当班线路的实时状态,核心操作是发车确认、临时加车、抽车、调整发车间隔;司机通过车载终端或手机端接收调度指令,上报异常情况,查询自己的班次计划;乘客端(如果有的话)只需要一个线路查询和实时到站预估,不需要登录就能用。
这个系统我最终实现了管理端和调度端两个Web入口,司机用的是一个轻量化的H5页面。没有做App,因为公交公司的车载终端系统比较老旧,短期内统一部署App不现实,H5可以直接嵌到现有的车载平板里用,成本最低。
1.2 先梳理业务流程,再设计软件功能
公交调度的业务流程可以用一条时间线来理解。前一天晚上,调度员根据客流历史数据、节假日安排、天气预测,制定第二天的排班计划;当天发车前,司机签到领车,调度员确认车辆上线;运营过程中,GPS设备每10秒上报一次位置,系统根据位置计算车辆是否准点,是否出现连续两车间隔过大的情况;高峰时段,调度员根据实时客流临时调整发车密度;收车后,系统自动汇总当天的运营数据,生成日报表。
这个流程梳理清楚之后,功能模块基本上就自己浮现出来了:基础数据管理、排班管理、实时监控、调度指令、统计分析。而我花时间最多的,是排班管理里的"如何定义一辆车准点"——听起来很简单,但实际上牵涉到首站发车时间、中间站到站时间偏差、末站收车时间三套口径。低估这个粒度,后面做准点率统计时会返工到怀疑人生。
提示:公交调度系统最忌讳一上来就写代码。先把"线路-车辆-班次-站点"这几个实体的关系画清楚,再谈技术方案。业务关系没理清,后面每个模块都会互相打架。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型:SpringBoot生态里我为什么最终选这几样
选型这件事,我一向主张"够用就好,但要给未来留余量"。这套系统并发量不会像电商那样动辄每秒上万,但有一个特殊性:实时位置数据的写入是持续且高频的,如果五十台车每10秒上报一个坐标,一天下来的数据量就是几十万条,而且这些数据要保留至少三个月供轨迹回放。这个特性决定了技术方案不能全凭个人喜好来定。
2.1 SpringBoot版本与自动装配机制的取舍
SpringBoot版本我选了2.7.x。为什么不直接上3.x?原因有三个:第一,3.x基于Jakarta EE,很多老的依赖需要跟着升级,而公交公司这边有存量系统需要对接,比如某厂商的GPS设备SDK,翻了下它的依赖还是javax开头,直接升3.x就得改SDK或者做一层适配,风险不划算;第二,团队里几个人对SpringBoot 2.x的自动装配机制已经很熟,排错效率高;第三,2.7.x还在社区维护期内,安全更新跟得上。
这里顺带说一下SpringBoot自动装配的原理,因为后面排错时真的用到了。spring-boot-autoconfigure包里维护了一堆XXXAutoConfiguration类,SpringBoot启动时通过@EnableAutoConfiguration注解配合spring.factories(2.7.x)或AutoConfiguration.imports文件,把候选配置类加载进来,再配合@ConditionalOnClass、@ConditionalOnProperty、@ConditionalOnMissingBean这些条件注解,决定哪些配置类生效。理解了这个机制,你就能解释很多"奇怪"的现象,比如为什么引入了Redis依赖就自动配好了RedisTemplate,为什么自己的DataSource会覆盖默认的HikariCP。
实际排错案例是这样的:系统上线第二周,运维反馈"偶尔登录变慢,过一会又正常"。刚开始怀疑数据库连接池不够,查了一圈没什么问题,最后发现是Redis连接配置里漏了lettuce的线程池参数,导致高并发时连接获取阻塞,而SpringBoot自动配置的RedisTemplate恰恰对这种情况没有兜底——因为你一旦自定义了RedisTemplate Bean,自动配置里的条件注解就失效了,相关的默认参数也不会再生效。这个坑让我后来养成了一个习惯:凡是自定义覆盖自动配置的地方,必须把原配置类的参数完整看一遍,不能只改自己想改的那一个方法。
2.2 MyBatis-Plus + 分页插件:查询开发效率的关键
ORM方面我用了MyBatis-Plus而不是原生MyBatis。调度系统里有大量条件查询场景:按线路查车辆、按时间查排班、按状态查异常记录,如果用原生MyBatis,每个查询都要手写XML和ResultMap,开发量会翻倍。MyBatis-Plus的BaseMapper提供了通用的CRUD,再加上LambdaQueryWrapper,日常查询基本不用写SQL,代码可读性也好。
分页插件PaginationInnerInterceptor是MyBatis-Plus里最有价值的一个组件,它本质上是一个MyBatis的Interceptor,会在Executor执行SQL之前拦截下来,先把原SQL改写成带LIMIT的查询,再执行一次COUNT查询统计总数。这个机制听起来很简单,但有几个细节很容易踩:
- 分页插件只对Mapper方法生效,如果自定义了SQL且在里面用到了JOIN、GROUP BY,COUNT自动生成的SQL经常不准,出现总数和实际数据不一致的情况。解决方式是直接提供一个自定义的countSql,比如用"SELECT COUNT(*) FROM (原SQL) total"这种包装方式,或者干脆手动指定count方法。
- 插件顺序很重要,如果有多个自定义Interceptor,PaginationInnerInterceptor要尽量排在前面,因为分页要先改写SQL,后续的逻辑才拿得到处理过的语句。
- 分页插件在5.x版本里是单独的一个模块,需要额外引入pagehelper还是mybatis-plus-jsqlparser要分清。我用的MyBatis-Plus 3.5.x,分页插件在mybatis-plus-extension包里,还有jsqlparser依赖支撑SQL解析。
数据量上来之后,我还做了一个针对性优化:轨迹查询表按月分表。实时位置写入时按月份路由到对应的表,查询历史轨迹时先根据时间范围定位表,再走索引。这样即使运营了一年,轨迹查询的响应时间也能控制在几百毫秒内,不用一上来就上分布式方案,给项目省了很大一笔复杂度。
2.3 Redis的定位:缓存与实时状态的一鱼两吃
Redis在这套系统里的角色挺重的,不只是做缓存。实时监控页面需要展示所有在线车辆的实时位置、当前线路、当前车速、是否晚点等信息,如果用MySQL去扛这个高频查询,五十台车每10秒刷新一次页面,数据库压力不小。我的方案是把每辆车的实时状态直接存到Redis里,用车辆ID作为Key,Hash结构存坐标、速度、方向、当前班次ID等字段,页面查询直接走Redis,单次查询毫秒级返回。
排班计划、线路站点这些低频变更的数据,也放了一份在Redis里做缓存,缓存失效策略用的是简单的定时过期加主动刷新。实际运行下来,Redis的内存占用非常有限,几十辆车加十几条线路的数据量,连200MB都不到,一台2核4G的云服务器就能很舒服地跑起来。
顺便说一句,SpringBoot整合Redis时,我推荐手动配置RedisTemplate的序列化器,不要直接用默认的JDK序列化。默认的JdkSerializationRedisSerializer会把对象序列化成二进制,Redis里看起来全是乱码,而且跨语言读取很麻烦。我用的是GenericJackson2JsonRedisSerializer配合StringRedisSerializer的Key序列化方案,这样Redis里的数据可读,以后运维排查也方便。
2.4 WebSocket推送和Vue前端的分工
实时监控页面需要自动刷新,有两种方案:前端定时轮询后端接口,或者后端通过WebSocket主动推送。轮询简单,但延迟高、浪费请求;WebSocket能实现真正的实时,但会引入连接管理的复杂度。这套系统我最终用了WebSocket,因为调度场景对实时性要求确实高——调度员盯着监控大屏,车上的异常状态如果不能秒级推送,等轮询发现的时候可能已经错过了最佳的调度窗口。
前端选的Vue 3 + Element Plus,前后端完全分离,所有接口走RESTful风格加统一返回结构。SpringBoot只负责提供API和WebSocket端点,前端静态资源单独部署到Nginx。这个架构的好处是,开发阶段可以并行推进,前端Mock数据先跑起来,后端接口好了直接联调;生产环境也能各自扩容,互不干扰。
2.5 统一异常处理与接口返回规范
SpringBoot里做统一异常处理很简单,用@RestControllerAdvice配合@ExceptionHandler就能捕捉全局异常。但要注意一点:异常处理不是只返回一个错误码就完了,还要考虑日志的完整性和调用方的可读性。我的做法是定义了一个Result类,包含code、message、data三个字段,code为0表示成功,非0表示业务异常。业务异常(比如"该车辆已被排班")和系统异常(比如空指针、数据库连接失败)分开处理,业务异常返回具体提示,系统异常记录完整堆栈并返回通用错误。
这个设计在后端联调时帮了大忙,前端不用猜接口为什么报错,统一看code和message就能定位问题方向。我还在异常处理里加了一个小技巧:把HTTP状态码和业务code分开,HTTP 200不代表业务成功,业务失败也固定用HTTP 200返回,只有在网络层或参数格式错误时才用4xx/5xx。很多团队纠结HTTP状态码和业务码怎么对应,我的经验是业务层面统一走200+业务码,简单直接,前端处理逻辑也清爽。
3. 模块拆解和数据库建模:这是后面所有功能的地基
功能模块根据业务流程拆成了五个:基础数据管理、排班管理、实时监控与调度指令、轨迹回放、统计分析。每个模块都有对应的一张或几张核心表,设计阶段我花了整整一周来梳理表结构和字段关系,因为表结构一旦定下来,后面改动的成本会指数级上升。
3.1 核心表结构的设计思路
先说说最重要的几张表。
线路表(line):字段有线路编号、线路名称、首末班时间、发车间隔(高峰期和平峰期分开)、里程、票价等。注意这里有个容易忽略的地方:一条线路可能有多个方向(上行/下行),所以要设计direction字段来区分,否则后面的站点排序和发车排班会乱。
站点表(station):字段有站点名称、所属线路、站点序号、经度、纬度、是否可换乘等。站点和线路是多对多关系,所以中间还需要一张线路站点关联表(line_station),存储每个站点在某条线路上的排列顺序。这个"顺序"字段很重要,我踩过坑——最开始用站点ID列表存进线路的一个聚合字段里,后来发现增删站点时维护顺序非常痛苦,果断改成关系表加sort字段。
车辆表(vehicle):字段有车牌号、车辆编号、座位数、车辆状态(运营/维修/停运)、所属车队、GPS设备编号等。车辆的实际状态要跟排班状态联动,比如一辆车正在维修,排班模块里就不允许给它安排班次。
排班表(shift):这是整个系统的核心表,字段有线路ID、车辆ID、司机ID、发车日期、发车时间、预计到达终点时间、实际发车时间、实际到达时间、班次状态等。排班表每天会生成大量记录,一条线路一天往返几十趟,十条线路一个月的排班数据就是上万条。查询时要建好联合索引,通常以线路ID和发车日期为查查询条件。
实时位置表(gps_record):字段有车辆ID、经度、纬度、速度、方向、当前线路ID、当前班次ID、GPS时间、上报时间。这张表的数据量最大,我按月份做了分区,每个月的记录单独存在一个子表里,查询时指定时间范围就能命中对应分区,避免全表扫描。
调度指令表(dispatch_command):字段有指令类型(加车、抽车、调整发车间隔、临时改道等)、目标车辆ID、指令内容、发送时间、状态(待确认、已确认、已执行)、创建人、确认人。这张表实现了"调度操作留痕"这条设计原则,每一次调度动作都有完整的审计信息。
3.2 数据库设计的几个实操经验
第一,所有业务表都带上create_time、update_time、deleted这三个字段。逻辑删除比物理删除好,因为公交公司经常要追溯历史数据,物理删了再想恢复就没辙了。查询时统一过滤deleted=0,字段冗余一点,但安全性和可追溯性高很多。
第二,时间字段统一用datetime,不要用timestamptz混着用。项目里有司机从手机端录入数据,也有系统自动生成时间,如果来源不统一,时区转换很容易出错。我还加了一个约定:接口传输时间全部用字符串格式"yyyy-MM-dd HH:mm:ss",不传时间戳。这是因为前端拿到时间戳还要做本地时区转换,容易出偏差,直接用格式化字符串最省事。
第三,调度指令表要记录一个snapshot字段。意思是,当调度员发出"调整发车间隔"指令时,不仅要记录指令内容,还要把调整前的间隔和调整后的间隔都存下来。如果只存"调整为10分钟",后面复盘时根本不知道原来是多少。加了这个快照字段后,追溯问题就简单多了,这算是我做了多年业务系统的一个心得——凡是业务流程类操作,状态变更前后都要留痕。
4. 实时定位推送和自动排班的核心实现细节
4.1 车辆GPS上报接口的设计与并发控制
车辆端的上报接口是整套系统里调用频率最高的接口,设计上要特别小心。我定义了一个POST接口,车辆每10秒上报一次,参数是车辆编号、经度、纬度、速度、方向、GPS时间。接收流程分三步:
- 校验车辆编号是否合法、车辆是否处于运营状态;
- 把数据写入Redis实时位置,Key为"vehicle:loc:车辆ID",Hash存储坐标和速度等字段;
- 异步把数据落库到gps_record表,走的是线程池,不阻塞主流程。
这里有个并发控制的细节:异步落库时,车辆上报的频率是每10秒一次,如果一辆车正常运营8小时,会产生2880条记录,50辆车就是14万条。直接插入数据库没有太大性能问题,但在高峰期如果同时有大量车辆上报,线程池的队列要设置合理,否则会出现积压。我用的线程池核心线程数设为CPU核数+1,队列容量500,拒绝策略用CallerRunsPolicy——实在处理不过来时让调用方线程自己执行,保证数据不丢。
实时位置的存储用Redis而不是直接查MySQL,还有个额外的好处:调度页面做"车辆在线状态"判断非常快。判断规则是"最近60秒内有GPS上报即视为在线",Redis里存了last_report_time字段,查询时直接比较当前时间,刷个全量车辆在线状态也就是毫秒级的事。
4.2 WebSocket推送:从连接管理到消息路由
WebSocket推送部分,我用的方案是SpringBoot的WebSocket + STOMP协议,后端通过SimpMessagingTemplate广播消息。这个方案跟直接用WebSocketSession管理相比,最大的优势是内置了消息路由和订阅管理,比如前端可以订阅"/topic/line/5"来只接收5号线路的实时消息,后端推送时指定destination就行,不用手动维护session集合。
前端连接时通过URL参数带上线路ID和调度台ID,后端在握手拦截器里解析参数并绑定会话属性。每次车辆位置刷新时,后端把车辆的实时状态包装成一条消息,推送给订阅了对应线路的调度台。消息体用了轻量的JSON格式:车辆ID、车牌号、线路ID、经度、纬度、速度、当前状态(准点、晚点、提前)、当前班次ID。
关于心跳机制,我额外做了两件很多教程不会提的事:
- 服务端设置空闲超时时间,超过一定时间没收到任何消息就关闭连接,防止僵尸连接占资源;
- 前端做心跳重连,监测WebSocket断开后自动重连,并且重连成功后重新订阅之前的所有频道。
实际运营中,WebSocket断连最常见的场景不是服务端问题,而是网络切换——比如调度员开会时把电脑从有线切到无线,或者公司网络做了NAT超时。自研的心跳重连机制上线后,监控大屏"掉线"的问题基本绝迹了。
4.3 自动排班的最小可行算法
排班是客户最看重的功能,但也是"看起来高大上、实际不能太复杂"的功能。公交排班本质上是在给定线路、车辆、司机、发车时间范围、发车间隔条件下,生成一张满足约束的班次计划表。真正的智能优化模型(比如考虑客流预测、充电时间、司机工时等)可以做很复杂,但作为一个内部管理系统,客户真正需要的是一套规则引擎式的排班工具。
我的实现思路是这样的:排班员先配置好线路的基础参数,包括首班时间、末班时间、高峰期时段、高峰期发车间隔、平峰期发车间隔。系统根据这些参数自动生成一整天的理论班次时间序列。举个例子,某线路首班6:00,末班22:00,高峰期(7:00-9:00)间隔5分钟,平峰期间隔10分钟,那么系统会自动计算出所有可能的发车时间点,生成一个备选班次表。
然后是车辆分配。系统按"车辆编号顺序轮询"的方式分配车辆,但有几个约束条件要满足:车辆状态必须是"可运营";车辆在分配时点不能已有冲突班次;司机的连续工作时间不能超过4小时,否则要插入休息时间。这些约束用规则引擎写起来很繁琐,但其实用循环加条件判断就能搞定。核心算法用一个列表存储当天所有可用的班次时间点,再按车辆依次填充,每填充一个班次就更新该车辆下一趟可发车的最早时间。
这里我特意做了一个人工干预的口子:自动排班生成后,调度员可以手动调整任意班次的发车时间、车辆和司机,调整过的班次打上"手动"标记。下次再执行自动排班时,被手动调整过的班次不会被覆盖。这个设计对业务极其重要——算法只是辅助,调度员才是最终决策者。如果系统强制覆盖调度员的手动调整,他们用几次就会放弃这个功能。
4.4 统一异常处理在调度场景中的实战价值
调度场景里有些异常必须及时暴露给调度员,比如GPS设备离线超过5分钟、某辆车连续两次上报位置都在同一坐标(可能设备坏了)、某班次晚点超过10分钟。我定义了一个异常事件表(alert_event),后端后台定时任务扫描这些异常条件,一旦触发就写入告警记录,并通过WebSocket推送到监控大屏。
定时任务用的SpringBoot的@Scheduled注解,扫描间隔30秒。一开始踩过一个坑:定时任务默认是单线程的,多个任务串行执行,如果某个任务耗时过长,后面的任务都会延迟。后来加了@Async注解配合自定义线程池,把不同业务的定时任务隔离开来。
告警事件除了实时推送,还要有历史查询和确认功能。调度员处理完告警后要点击"确认",确认操作写进操作日志,这个闭环让客户很满意——他们可以按月统计每条线路的告警数量和处理响应时间,作为调度员绩效考核的参考数据之一。
5. 从开发到上线的完整踩坑记录
把踩坑过程单独拿出来写,是因为这里面的经验价值远超我前面写的所有代码。项目从开发到上线,坑一个接一个,有些是技术问题,有些是业务问题,但每一个都实打实地影响上线进度。
5.1 SpringBoot版本适配带来的连环坑
开发初期,团队里有人习惯用IDEA的Spring Initializr创建项目,默认选的SpringBoot 3.1,结果跟GPS设备厂商的SDK打了一整天架——SDK内部的HttpClient用的是javax.*包,在Jakarta命名空间下直接编译不过。后来整个项目回退到2.7.18,所有依赖重新梳理了一遍。
这个坑说大不大,但很有代表性。现在SpringBoot 3.x已经很成熟了,但你的项目中如果有老设备SDK、老内部组件,一定先确认它们的依赖兼容性再定版本。与其在版本兼容上蹉跎时间,不如选一个稳健的版本先把业务做出来。
回退2.7.18后还有一个小坑:spring-boot-starter-validation的校验注解,在2.7.18里用的是javax.validation,这个没问题,但要注意如果之前用了3.x里新增的校验特性,就得降级写法。我这边没有涉及,但提醒大家版本切换后一定要全局搜索一下import javax和import jakarta。
5.2 MyBatis-Plus分页插件遇上复杂查询
分页插件的问题出现在班组统计报表上。报表要按天统计每个线路的班次数、总运营里程、总客运量(人工输入的数据),SQL需要关联排班表、车辆表、线路表,还用到了GROUP BY和SUM聚合。分页插件自动生成的COUNT SQL在这种场景下算出的总数总是偏大。
排查过程是这样的:先打日志看实际执行的SQL,发现分页插件生成的COUNT语句把GROUP BY展开成一个个分组然后计数,结果不是行数而是分组数。解决方案是自定义count方法,直接写一个针对原SQL的COUNT包装查询。这个解决方案不复杂,但如果不深入理解分页插件的执行原理,碰到这种问题就只能绕道或者硬编码。
这个坑我给同行的建议是:凡是涉及聚合查询的表格报表,不要用分页插件自带的count,自己写countSql或者干脆禁止count,手动返回分页数据。分页插件用得好是效率神器,用不好就是隐藏的定时炸弹。
5.3 WebSocket连接在Nginx代理下的Session粘滞
项目生产环境用Nginx做了反向代理,后端服务部署了两台服务器做负载均衡。上线第一天监控大屏报了一个诡异的现象:部分调度台刷新页面后,车辆位置更新时有时无,有时重置页面重连又恢复正常了。
这个问题的根因是WebSocket连接自Nginx默认的轮询策略下,每次重连可能分配到不同的后端服务器。如果连接是长连接,理论上Nginx会把一个连接始终代理到同一台后端,但WebSocket握手升级时的连接迁移,加上服务端设置了空闲超时,导致连接断开后重连到另一台服务器,而另一台服务器上根本没有这台调度台的订阅状态,消息自然就发不过去。
解决方案有两个层面:Nginx配置里给WebSocket的URL设置ip_hash负载均衡策略,保证同一IP的请求固定分配到同一台后端;同时后端这边做了一层用户订阅信息的Redis共享,订阅信息不存本地内存,而是存到Redis。这样即使请求落到不同的服务器,也能从Redis里恢复订阅关系。我两个都做了,双保险,效果非常稳。
这里多说一句,做WebSocket生产环境架构时,最好一开始就把订阅关系的存储设计成可共享的,不要贪图方便存在内存里。集群化部署是早晚的事,到时候再改造,牵涉的代码量就大了。
5.4 时区和时间格式引发的数据错乱
这个坑很小,但影响面很大。排班表里存的是发车时间,比如"06:00:00",但某一天运营月报出来后,所有班次的时间都差了8小时。排查了一圈,问题出在司机端H5页面提交数据时,前端代码把时间转成了ISO格式的时间戳,而ISO字符串里带时区信息"2023-05-01T06:00:00.000Z",后端用LocalDateTime解析时把Z当成了普通字符串,存进MySQL时实际存的是14:00:00。这个8小时的偏差正好是时区偏移量。
这个坑完全是前后端联调时对"时间格式"约定不明确造成的。我后来在技术文档里明确规定:接口请求和响应中所有时间字段一律用"yyyy-MM-dd HH:mm:ss"字符串格式,除非特别说明,否则不允许传时间戳或ISO格式。定好这个约定之后,时间错乱的问题就再没出现过。
5.5 Docker部署时的资源限制与JVM调参
后端服务我用Docker部署,写Dockerfile时第一版只写了基础镜像和启动命令,其他参数都没管。结果跑起来后,频繁出现服务间调用超时,查看日志才发现JVM默认堆内存是根据物理内存自动设置的,宿主机64G内存,JVM默认直接去申请了宿主机内存的四分之一(16G),一台机器上跑了三个容器,直接把宿主机内存吃满了。
后来我写Dockerfile时固定了JVM启动参数:-Xms512m -Xmx512m -XX:MaxMetaspaceSize=256m。这个参数对这套系统来说非常充足,因为主要的性能瓶颈在Redis和数据库,业务服务本身是无状态的,内存需求并不高。部署脚本里我还加了一个约定:Docker容器的memory limit必须显式声明,不声明就不允许启动,防止再出现内存超分。
Docker部署的另一个细节是健康检查。SpringBoot的Actuator里health端点用起来非常方便,Dockerfile的HEALTHCHECK指令可以定期遍历health端点,容器异常时自动重启。这个配置在上线初期确实救过我们一次——某个深夜Redis重启导致业务服务大面积报错,健康检查触发容器重启后自动恢复了,第二天早上客户都没察觉到异常。
我的几点实际体会
整套系统从需求调研到上线运行,前后差不多四个月。最后再分享几个我的个人经验,希望对正在做类似系统的人有帮助。
第一,业务系统的复杂度通常不在技术,而在业务规则本身。公交调度这个领域,你很难单纯靠翻一本《算法导论》就设计出排班模块,而是要靠跟调度员聊天、跟车跑、翻台账来沉淀业务规则。技术上的"最优解",在业务上往往不是"最可用"的方案,比如自动排班算法再先进,也得给调度员留手动调整的入口。
第二,SpringBoot开发效率高,但高不代表可以省略设计。统一异常处理、统一的返回结构、数据库时间字段规范、前后端接口约定,这些看似琐碎的规范,决定了项目到后期是不是一团乱麻。我见过太多半路烂尾的SpringBoot项目,无一例外都是前期太随意,后期改不动。
第三,调试和排错能力才是这个项目的核心竞争力。项目里遇到的最难问题,比如分页插件count不准、WebSocket集群下的订阅丢失,没有一个是通过百度搜"SpringBoot XX报错"能直接解决的。真正解决问题靠的是理解SpringBoot自动装配的来龙去脉,理解Nginx代理WebSocket的握手细节,理解MyBatis插件机制的拦截时机。框架能帮你提高效率,但理解框架的原理,才能真正让你在踩坑时能自己爬出来。
如果让我重新选一次技术栈,我大概率还是会用SpringBoot加这一套组合。它未必是最前沿的方案,但在解决"城市公交调度系统"这种务实业务场景时,它交付稳定、排查容易、生态成熟——这些特质,才是这类系统最需要的。
