☰
Scala+PostgreSQL实现交通拥堵预测数据库课设全流程
2026/10/8 5:14:39 网站建设 项目流程

简介:这是一份基于Scala的交通拥堵预测源码包,源自作者大三数据库课程设计,经导师指导与评审获得高分。资源面向计算机相关专业的教师与学生,尤其适合正在完成课程设计、期末大作业或需要项目实战演练的读者。项目代码完整,功能经过验证,可稳定运行。源码内部按数据消费、数据生产、拥堵预测、模型构建等环节进行了模块划分,并附带项目说明、说明文档及源码备份目录,结构清晰,便于理解整体流程,也支持在此基础上进行二次开发扩展。压缩包共50个文件,其中包含18个Scala源文件,另有XML配置、IDEA模块文件、Markdown说明文档、properties属性文件、文本及HTML页面等辅助内容,整体仅73KB,轻量易用。目前已有107人学习下载,适合具备一定Scala基础、希望获得可运行课程设计参考的读者深入学习和借鉴。

1. 交通拥堵预测课设:为什么Scala+数据库这条路线值得做

交通拥堵预测是数据库课程设计里少见的"既能撑起技术深度、又不需要真实业务数据"的题目。它天然要求你处理时序数据、空间数据和多表关联,刚好覆盖数据库设计、数据清洗、特征提取、模型训练这一整条链路。而用Scala来做,不是炫技——Scala在类型安全和集合处理上的表现,配合JVM生态里成熟的JDBC和机器学习库,会让你的课设代码比Python版本多一层"工程化"的说服力。这套源码适合两类人:一是正在选课设题目、想拿高分的学生,二是想练手Scala写数据应用的在职开发。下面按我做这类项目的习惯,从数据库设计一路讲到模型验证和答辩准备,把能直接抄的代码和踩过的坑都放出来。

2. 数据表设计:把交通数据存成能预测的库

交通拥堵预测的第一步不是写模型,而是先把数据组织好。数据库课程设计评分的重点往往在ER设计、范式、索引和SQL书写质量上,模型只是加分项。所以这一章花大力气把表结构讲透,后面所有特征和预测都要依赖这套设计。

2.1 ER设计:路段、卡口、流量、天气四张表够不够

我见过不少课设把交通数据塞进一张大宽表,字段十几个,冗余严重,第三范式根本谈不上。做预测项目,四张核心表就够了:路段表、卡口表、流量记录表、天气表。

路段表存道路静态属性,比如路段编号、道路等级、车道数、限速。卡口表存监测点位置,关联到路段,包含经纬度、方向、所属区域。流量记录表是核心事实表,每条记录是一个卡口在一个统计周期内通过的车辆数、平均速度、占有率。天气表按小时存温度、降水、能见度、风力等级,因为拥堵和天气强相关,做特征时要用它做时间对齐。

这四张表的关联关系是:路段1对多卡口,卡口1对多流量记录,天气和流量之间按时间戳关联。ER图上画清楚这三个关系,数据库设计分数基本稳了。另外加一张区域表也不是不行,但课设规模下四张表已经能讲清楚"事实表+维度表"的星型模型概念。

2.2 建表SQL:时序字段、分区键和索引

流量记录表的时间字段必须用TIMESTAMP类型,不要用VARCHAR存"2024-05-20 08:30:00"这种字符串。用字符串存时间,查询时无法走索引范围扫描,排序也要做字符串比较,性能上不去,答辩时老师一问就露馅。

CREATE TABLE traffic_flow ( id BIGSERIAL PRIMARY KEY, checkpoint_id INTEGER NOT NULL REFERENCES checkpoint(id), record_time TIMESTAMP NOT NULL, vehicle_count INTEGER NOT NULL, avg_speed NUMERIC(5,2) NOT NULL, occupancy_rate NUMERIC(5,2), road_state SMALLINT DEFAULT 0 ); CREATE INDEX idx_flow_time ON traffic_flow (record_time); CREATE INDEX idx_flow_checkpoint_time ON traffic_flow (checkpoint_id, record_time);

大表按时间做范围分区是常见做法,PostgreSQL 从 10 版本开始支持声明式分区。课程设计数据量一般在十万到百万级,不分区也能跑,但写上分区方案能体现你的工程意识。BIGSERIAL主键在并发插入时有性能问题,但课设并发量低,用它换实现简单是划算的。

索引设计上有两个点要注意:一是idx_flow_checkpoint_time这种复合索引要遵循最左前缀原则,查询条件里先出现checkpoint_id再用record_time过滤,这个索引才能命中;二是对于avg_speed这种经常做范围查询的数值字段,如果数据量大可以考虑BRIN索引,但百万级数据没必要。

2.3 数据导入:用Scala把CSV灌进PostgreSQL

很多下载到的数据源是CSV文件,需要先写导入程序。Scala里最直接的方式是用JDBC的COPY命令配合PGConnection的CopyManager,比逐条INSERT快一个数量级。

import org.postgresql.copy.CopyManager import org.postgresql.core.BaseConnection import java.io.FileReader val conn = DriverManager.getConnection(url, user, password) val copyManager = new CopyManager(conn.asInstanceOf[BaseConnection]) val fileReader = new FileReader("/data/traffic_flow.csv") val sql = "COPY traffic_flow(checkpoint_id, record_time, vehicle_count, avg_speed, occupancy_rate) FROM STDIN WITH CSV HEADER" copyManager.copyIn(sql, fileReader)

这里有两个关键参数:WITH CSV HEADER让第一行字段名跳过,FROM STDIN表示从客户端读入文件流。如果CSV里有空值,需要在SQL里加NULL 'null'指定空值字符串,否则默认只认空串。导入前先确认CSV字段顺序和表结构对应,顺序不一致时在COPY后的字段列表里显式声明即可。

2.4 为什么不用MySQL:窗口函数与扩展类型

课程设计里用MySQL完全可以,但交通拥堵预测这个题目用PostgreSQL更顺手。核心原因是两个:一是PostgreSQL的窗口函数实现成熟,做"过去30分钟平均车速"这种滑动窗口聚合,语法干净且性能可控;二是PostgreSQL有丰富的扩展类型,比如PostGIS可以做空间关联,虽然课设不一定要用到,但答辩时提一句"如果接入实时GPS数据,可以扩展空间索引"会显得你有全局视野。

另外,如果你后续想用Scala和机器学习库做特征,PostgreSQL可以直接对查询结果做ORDER BY和窗口函数处理,减少在应用层搬运数据的量。MySQL 8.0也支持窗口函数了,但PostgreSQL的DATE_TRUNC按分钟/小时对齐时间戳这类函数更顺手,比如DATE_TRUNC('hour', record_time)直接得到整点时间。

3. Scala数据库访问层:从JDBC到连接池

数据库表建好后,要用Scala程序读写它。这一章解决"用Scala操作PostgreSQL的正确姿势"——直接写JDBC能跑,但连接管理和并发处理会让程序不可控。从连接池开始讲,因为这是最容易翻车的地方。

3.1 直接写JDBC还是用Slick

课设里我建议直接用JDBC,不引入Slick等ORM框架。原因有三:一是JDBC是Scala和数据库交互的底层基础,原理透明,答辩时老师问起来你能答得上;二是Slick的DBIO抽象在学习曲线上要额外花两天,课设时间宝贵;三是JDBC配合连接池之后,代码量没比ORM多多少。

下面是Scala里连接PostgreSQL最朴素的一段代码,关键是驱动要引入对版本。PostgreSQL的JDBC驱动在Maven坐标是org.postgresql:postgresql:42.x.x,Scala 2.13用这个没问题,Java 8以上环境注意驱动版本和JVM版本兼容性。如果出现Method org.postgresql.jdbc.PgConnection.createClob() is not yet implemented这类报错,多半是驱动版本不对,换新版驱动就好。

import java.sql.{Connection, DriverManager, PreparedStatement} val url = "jdbc:postgresql://localhost:5432/traffic_db" val props = new java.util.Properties() props.setProperty("user", "traffic_user") props.setProperty("password", "your_password") props.setProperty("currentSchema", "public") val conn: Connection = DriverManager.getConnection(url, props) val query = "SELECT checkpoint_id, record_time, vehicle_count FROM traffic_flow WHERE record_time BETWEEN ? AND ?" val stmt: PreparedStatement = conn.prepareStatement(query) stmt.setTimestamp(1, java.sql.Timestamp.valueOf("2024-05-20 07:00:00")) stmt.setTimestamp(2, java.sql.Timestamp.valueOf("2024-05-20 09:00:00")) val rs = stmt.executeQuery() while (rs.next()) { println(rs.getInt("checkpoint_id") + ": " + rs.getInt("vehicle_count")) }

PreparedStatement里用setTimestamp传参,而不是把时间拼进字符串再执行,这样才能避免SQL注入,同时让数据库能复用查询计划。上面的代码只是演示,实际课设里Connection和Statement都要关闭,最稳妥的写法是放在try-with-resources或者Scala的Loan Pattern里——用try块包住,finally里close()。

3.2 连接池配置:连接数与并发线程的关系

不搞连接池直接写JDBC的典型症状是:并发请求一多,程序报Connection is not available, request timed out,或者数据库端报too many connections。因为DriverManager每次请求都新建物理连接,TCP握手开销大,而且数据库默认最大连接数是100左右,超出就拒绝。

用HikariCP做连接池是JVM生态的标配。Scala里配置一个连接池很简单。HikariCP的关键参数是maximumPoolSize、minimumIdle和connectionTimeout,很多人不懂它们和线程池并发数的关系。如果线程池有50个线程,连接池只有10个连接,那40个线程排队等连接;反之线程池10个线程,连接池50个连接,连接就白白空闲。正确做法是把两个数设置成一致,或者maximumPoolSize略小于线程数。

import com.zaxxer.hikari.HikariConfig import com.zaxxer.hikari.HikariDataSource val hikariConfig = new HikariConfig() hikariConfig.setJdbcUrl("jdbc:postgresql://localhost:5432/traffic_db") hikariConfig.setUsername("traffic_user") hikariConfig.setPassword("your_password") hikariConfig.setMaximumPoolSize(20) hikariConfig.setMinimumIdle(5) hikariConfig.setConnectionTimeout(30000) hikariConfig.setIdleTimeout(600000) val dataSource = new HikariDataSource(hikariConfig)

HikariCP还有两个容易被忽略的点:一个是setConnectionTimeout,默认30秒,如果查询本身就要跑很久,连接会被判定超时回收,服务端还在跑的话,下次拿到连接的请求就可能收到异常结果,所以复杂查询要单独调大这个值;另一个是连接泄漏检测,配setLeakDetectionThreshold(10000),连接借出超过10秒未归还会在日志里警告,这个在开发阶段排查问题极有用。

3.3 数据访问层读写分离:预测服务不拖垮入库

课程设计里如果做了可视化展示页面,通常是一个端口同时提供数据入库和预测查询。写操作(导入历史CSV)和读操作(前端按时间范围查流量曲线)混在同一个连接池里,可能出现读写互相阻塞——批量写把连接占满,读请求全部排队。

我一般会把读写操作拆到两个数据源:写库连接池配5个连接,读库连接池配15个连接。Scala里用两个HikariDataSource实例指向同一个库也可以,或者用@Transactional(readOnly = true)这类标记区分。读多写少的场景下,读写分离不是为了水平扩展,而是避免偶发的批量写把在线查询拖死。

这一段在课设文档里写出来,老师会觉得你考虑到了生产环境的运维问题。实际操作上,Scala里定义一个DataSourceProvider对象,内部持有读写两个连接池实例,对外分别暴露readConnection()和writeConnection()方法。

4. 特征工程与拥堵预测:Scala怎么算特征

数据库只是存数据,预测模型才是"交通拥堵预测"的核心。但课设里的模型不必追求SOTA,更多是展示"从数据到结论"的完整链路。这一章从特征清单讲起,然后给SQL和Scala代码,最后落到模型选型和训练。

4.1 特征清单:时间、空间、历史三大类

交通拥堵预测的特征可以归为三类:时间特征、空间特征、历史状态特征。时间特征包括是否为工作日、是否节假日、小时、是否早晚高峰、最近15分钟/30分钟/60分钟的流量变化趋势。空间特征包括路段等级、上下游卡口流量差、卡口所属区域的拥堵水平。历史状态特征则是过去几个统计周期的平均车速和占有率,这个最容易被忽略但往往对预测效果贡献最大。

我见过最好的一个做法是把"过去30分钟同路段平均车速"作为特征写入预测表,而不是每次预测时现算。因为现算意味着要实时执行一遍窗口查询,延迟高;预计算则在数据入库时同步更新特征表,预测服务只做单表查询。

4.2 窗口特征:用SQL窗口函数整列生成

PostgreSQL窗口函数可以一条SQL把窗口特征全部算出来,不需要在Scala里写循环。下面这段SQL计算每个卡口过去30分钟、60分钟的平均车速和车辆总数,并把结果写入特征表。

INSERT INTO traffic_feature (checkpoint_id, record_time, avg_speed_30m, avg_speed_60m, count_30m) SELECT checkpoint_id, record_time, AVG(avg_speed) OVER w AS avg_speed_30m, AVG(avg_speed) OVER (PARTITION BY checkpoint_id ORDER BY record_time ROWS BETWEEN 59 PRECEDING AND CURRENT ROW) AS avg_speed_60m, SUM(vehicle_count) OVER w AS count_30m FROM traffic_flow WINDOW w AS (PARTITION BY checkpoint_id ORDER BY record_time ROWS BETWEEN 29 PRECEDING AND CURRENT ROW);

窗口函数里有个关键参数:ROWS BETWEEN 29 PRECEDING AND CURRENT ROW表示取当前行和之前29行的数据参与计算。这个"29行"对应30分钟还是30条记录,取决于数据的统计周期——如果每5分钟一条记录,29条就是约145分钟,所以要按实际时间间隔换算。更保险的方式是RANGE BETWEEN INTERVAL '30 minutes' PRECEDING AND CURRENT ROW,直接按时间窗口计算,但要注意RANGE要求ORDER BY字段唯一,否则会报错。实际课设里,能用ROWS就用ROWS,性能和语义都可控。

4.3 模型选型:从线性回归到随机森林的落地路线

交通拥堵预测的模型选择,常见做法是两阶段:先用线性回归或岭回归做baseline,验证特征有效性;再用随机森林或梯度提升树模型提升精度。课程设计到随机森林这档已经足够拿高分,不需要上深度学习。

但这里有个Scala带来的选择问题:Spark MLlib的随机森林适合大数据量,单机课设用它是杀鸡用牛刀,而且启动Spark上下文就要几秒,交互体验差。更轻量的方案是用smile库(纯JVM机器学习库),随机森林和回归都直接支持,集成简单。

如果环境允许,走Spark MLlib路线也不是不行,但要注意Scala版本要和Spark匹配,比如Spark 3.x要求Scala 2.12/2.13,引入依赖时把版本对齐,否则一启动就报NoClassDefFoundError。课设规模的数据量,Spark分布式计算的优势根本体现不出来,所以我不建议为了用Spark而用Spark。

4.4 训练与预测的Scala代码骨架

下面给一个smile库做随机森林回归的最小代码骨架。输入的DataFrame从PostgreSQL查询出来,转换为smile的DataFrame格式,然后按时间列拆分训练集和测试集——交通数据必须按时间切分,不能随机洗牌,否则会造成数据泄漏。

import smile.data.`type`.StructType import smile.data.`type`.DataTypes import smile.regression.RandomForest import smile.data.DataFrame // 从数据库读取特征 val sql = """ SELECT avg_speed_30m, avg_speed_60m, count_30m, EXTRACT(HOUR FROM record_time) AS hour, EXTRACT(DOW FROM record_time) AS dow, vehicle_count AS label FROM traffic_feature WHERE record_time >= '2024-04-01' AND record_time < '2024-05-01' """ val rs = stmt.executeQuery(sql) val schema = new StructType( Array( DataTypes.DoubleType("avg_speed_30m"), DataTypes.DoubleType("avg_speed_60m"), DataTypes.IntegerType("count_30m"), DataTypes.DoubleType("hour"), DataTypes.DoubleType("dow"), DataTypes.DoubleType("label") ) ) val data = DataFrame.of(rs, schema) // 按时间排序后切分:前80%训练,后20%测试 val trainingSize = (data.nrows() * 0.8).toInt val trainData = data(0, trainingSize) val testData = data(trainingSize, data.nrows()) val model = RandomForest.fit( trainData.drop("label"), trainData.column("label").toDoubleArray, nTrees = 100, maxDepth = 10 ) val predictions = model.predict(testData.drop("label"))

代码里两个参数值得说:nTrees设100足够课设数据量,再大只是训练变慢,精度提升有限。maxDepth设10是为了防止过拟合,树太深会在小数据集上把训练样本背下来,测试集上反而变差。这是我试过多组参数后的经验值,不是理论最优值。

DataFrame.sql的EXTRACT(HOUR FROM record_time)得到的是数值类型,在Scala里要转成Double才能进smile。DOW是星期几,0是周日,6是周六,这个特征区分工作日和周末拥堵模式非常有效——早高峰和工作日的强相关性,在模型里会很明显。

5. 课设避坑:4条血泪经验

做交通拥堵预测课设,代码层面的坑集中在环境、连接、数据泄漏和时间特征处理上。下面按"现象→原因→解决"写四条,都是我实际遇到过的问题。

5.1 现象:Spark任务比单线程跑得还慢

有同学非要用Spark MLlib做随机森林,本地起了4个线程,训练反而比单机慢了好几倍。原因是课设数据量只有几万行,Spark的调度开销、任务序列化和Shuffle占了大部分时间。另一个常见诱因是IntelliJ里跑Spark任务没有设置spark.master,默认是local模式但资源配了local[4],Executor的GC停顿就吃掉了并行收益。

解决:数据量低于百万级,直接用smile或其它JVM库单机训练。如果一定要用Spark,设置SparkSession.builder().master("local[*]")并把日志级别调成WARN,别让INFO日志刷屏——它会显著拖慢控制台输出,看上去像是任务卡死。

5.2 现象:连接池一满,预测接口直接卡死

现象是前端页面进入预测查询页时,整个服务响应要十几秒,后台日志报Connection is not available, request timed out after 30000ms。根源在于批量导入CSV用了同一个连接池的绝大部分连接,且每条COPY操作要占用连接直到整个文件导入完成。导入一个大文件几分钟,这期间所有预测请求全部排队。

解决:把数据导入和预测服务的连接池彻底分开。具体操作是建两个HikariDataSource,导入进程用3个连接,在线服务用15个连接,各自独立配置。批量导入前先检查dataSource.getConnection().isValid(5),这一步能提前发现数据库连接被防火墙断开的情况,避免导入跑到一半抛Connection closed异常。

5.3 现象:时间特征一加入模型就飘

做了DOW特征后,测试集误差反而比不带这个特征时更大。原因可能是把DOW当作连续数值特征用了——DOW是从0到6的数字,线性回归会认为5和6比0和1"更相近",但星期几是循环变量,周日(0)和周六(6)的拥堵模式更接近。很多模型不会自动理解这种循环语义。

解决:把DOW转成哑变量(OneHot),或者构造更高阶特征,比如sin(2 * π * dow / 7)和cos(2 * π * dow / 7)。对于小时字段同样处理,早晚高峰在0点-6点区间是连续的,直接用数值反而破坏了这个连续性。我一般在SQL里就把这些转换计算好,Scala端只做读取,避免在内存里重复处理。

5.4 现象:答辩时说不出数据从哪来

不少同学从网上下载了dataset,答辩时老师问"你的数据来源是什么,采集频率是多少",答不上来。数据来源说不清楚,数据库设计的合理性就无法证明。比如你说路段的卡口数据2分钟一条,但你的表结构里没有体现,或者你用的是某公开数据集但没在文档里注明出处。

解决:下载数据后,先检查CSV里有没有采集时间字段,如果数据集是聚合到5分钟周期的交通流数据,就按这个粒度建表,并在表注释里写明数据来源于哪个城市的开放数据平台。如果找不到合适数据集,可以人工按真实拥堵模式生成模拟数据——规律是工作日早晚高峰车流激增、周末午间和傍晚有次高峰、雨雪天气平均车速会下降。做模拟数据不怕简单,但要把生成逻辑写进数据库设计文档里,答辩时反而能展示你的业务理解。

6. 从源码到高分:验证、可视化与答辩准备的最后一公里

模型训练完不代表课设结束。这一章把最后这段路补完——怎么验证预测效果、怎么可视化、答辩时怎么应对提问。这三件事做扎实,课程设计分数会明显高一档。

6.1 时间序列交叉验证比随机切分更可靠

前面写了按时间8:2切分训练集和测试集,这是时序预测的基本要求。但如果你想在课设里体现方法论,可以加一个时间序列交叉验证:把数据按时间分成5段,先用第1段训练、第2段测试,再用前2段训练、第3段测试,依次滚动。不用像K折交叉验证那样让测试集出现在训练集之前——那是给独立同分布数据用的,时序数据这么做是数据泄漏。

6.2 把预测结果画出来:JFreeChart还是导出ECharts

课程设计要求可视化,最简单的方式是Scala计算预测结果后导出CSV,用ECharts在前端展示。比在Scala里画图灵活得多,配色和交互也好看。如果想要纯后端方案,JFreeChart是JVM里比较成熟的图表库,调ChartUtilities.saveChartAsPNG导出图片嵌入报告。我一般导出CSV到前端渲染,因为答辩时浏览器演示比翻截图更直观。

6.3 答辩评委常问的3个问题

第一个问题是"为什么用PostgreSQL不用MySQL",答窗口函数和扩展类型;第二个是"模型效果怎么评价",答清楚你用MAE还是RMSE,并说明为什么RMSE对极端拥堵更敏感;第三个是"如果数据量变成每天百万条,你的架构哪里需要改",答分区表、连接池扩容、预测服务与数据入库分离。这几个问题基本围绕数据库设计和工程取舍,把本文前面几个章节的内容理顺就能应对。

课程设计做完我最大的教训是:一开始别急着训练模型,先用SQL把特征表和窗口查询跑通,再写Scala代码——SQL能解决的,不要在Scala里用循环硬算。这个习惯帮我省了至少两天的调试时间。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询