资讯详情 Django+MySQL实现PM2.5可视化:从数据导入到ECharts图表完整实战
📅 2026/10/7 10:19:19
简介这是一份基于DjangoMySQL实现的城市PM2.5空气质量数据可视化分析项目源码适合Python初学者、Web开发学习者及数据分析爱好者参考使用。项目整合了宏观空气质量数据的采集入库、查询统计与可视化展示全流程可帮助理解Django框架的MTV架构、ORM数据库操作以及前后端数据交互方式。压缩包共66个文件约12.38MB包含17个Python源码文件、24个CSV数据文件、4个XML配置、3个HTML模板、SQL数据库脚本及部署文档等。CSV数据覆盖北上广成沈等多座城市六年间PM2.5浓度变化以及PM2.5与温度、湿度、大气压、风向、露点等环境因素的关系数据SQL脚本与Django配置可直接还原数据库表结构。代码按项目目录、数据目录、模板目录组织结构清晰。资源当前已有145人学习下载。随包附部署文档、requirements依赖清单及README替换本地数据即可直接运行。通过源码可掌握数据预处理、MySQL读写、图表可视化等关键环节适合毕业设计或课程项目二次开发。1. 这套 DjangoMySQL 的 PM2.5 可视化项目到底能拿来做什么如果你手里有一份城市空气质量监测的 CSV 数据想把它变成能在浏览器里交互查看的趋势图、小时对比图、污染排行最直接的路径就是 Django 做后端、MySQL 存数据、ECharts 出图表。这个方向的项目在 Python 求职作品集里出现频率极高核心价值在于它覆盖了从数据清洗、关系建模到 Web 接口和前端渲染的完整链路不是那种只调个库就出图的玩具。适合的人群很明确刚学完 Django 基础但不知道怎么写第一个像样项目的人想给简历补一个有数据库、有部署文档的完整案例的转行者以及需要快速搭建内部空气质量看板的运维或环境相关从业者。本文按实际落地顺序把模型设计、数据导入、聚合查询、图表对接和部署验证逐个拆开讲你会看到每个环节的参数设置和翻车点。2. 把数据落进 MySQLDjango 模型设计与环境准备2.1 先定表结构监测记录、监测站点、污染物维度拿到 PM2.5 数据后第一件事不是写视图而是想清楚表和表之间的关系。大多数公开数据集长这样每一行是一条监测记录包含站点名、时间、PM2.5 浓度、PM10、SO2、NO2、CO、O3。如果把全部字段塞进一张表后续按站点、按天做统计时会反复出现冗余和慢查询。常见做法是拆成三张表监测站点表存站点名称和经纬度污染物维度表存污染物代码和名称监测事实表存时间和各污染物数值。Django 里对应的模型可以这样写from django.db import models class Station(models.Model): name models.CharField(max_length64, uniqueTrue, verbose_name站点名称) longitude models.FloatField(verbose_name经度) latitude models.FloatField(verbose_name纬度) city models.CharField(max_length32, verbose_name所属城市) class Meta: db_table station class Pollutant(models.Model): code models.CharField(max_length16, uniqueTrue, verbose_name污染物代码) name models.CharField(max_length32, verbose_name污染物名称) class Meta: db_table pollutant class AirQualityRecord(models.Model): station models.ForeignKey(Station, on_deletemodels.CASCADE, verbose_name站点) pollutant models.ForeignKey(Pollutant, on_deletemodels.CASCADE, verbose_name污染物) record_time models.DateTimeField(verbose_name监测时间) value models.FloatField(verbose_name浓度值) unit models.CharField(max_length16, defaultug/m3, verbose_name单位) class Meta: db_table air_quality_record indexes [ models.Index(fields[record_time, station], nameidx_time_station), ]这里最关键的决定是把每种污染物拆成独立的记录行而不是把 PM2.5、PM10 作为字段并列。原因有两个一是数据导入时 CSV 里列的顺序经常变化拆成行就不会被表结构绑死二是后续做某一天各污染物对比只需要按时间和站点过滤SQL 写起来清爽。如果你拿到的是已经清洗好的一行一条记录的数据那直接用这个模型。表名通过db_table指定避免 Django 默认生成的appname_modelname式名字在部署迁移时引起混淆。idx_time_station联合索引是为后面高频的某一时间范围 某个站点查询准备的实测在百万行数据量下不加这个索引查询耗时能从秒级降到毫秒级。2.2 数据导入脚本用 bulk_create 而不是循环 insertCSV 数据导库是第一个分水岭。很多新手用for row in reader: Model.objects.create(...)数据量几千行时还能忍几万行时直接卡到怀疑人生。正确做法是把解析和入库分开用bulk_create批量写入。import csv from datetime import datetime from django.utils.timezone import make_aware from .models import Station, Pollutant, AirQualityRecord def import_csv(file_path): station_cache {} pollutant_cache {} with open(file_path, encodingutf-8-sig) as f: reader csv.DictReader(f) batch [] batch_size 2000 for row in reader: station_name row[station] if station_name not in station_cache: station, _ Station.objects.get_or_create( namestation_name, defaults{longitude: float(row[lon]), latitude: float(row[lat]), city: row.get(city, )} ) station_cache[station_name] station for code in [pm25, pm10, so2, no2, co, o3]: val row.get(code) if val in (None, , NA): continue if code not in pollutant_cache: pollutant, _ Pollutant.objects.get_or_create(codecode) pollutant_cache[code] pollutant record_time make_aware(datetime.strptime(row[time], %Y-%m-%d %H:%M:%S)) batch.append(AirQualityRecord( stationstation_cache[station_name], pollutantpollutant_cache[code], record_timerecord_time, valuefloat(val), )) if len(batch) batch_size: AirQualityRecord.objects.bulk_create(batch, batch_sizebatch_size) batch.clear() if batch: AirQualityRecord.objects.bulk_create(batch, batch_sizebatch_size)这段脚本里有两个容易被忽略的点encodingutf-8-sig是因为很多 CSV 文件从 Excel 导出来时带 BOM 头用普通utf-8读会把\ufeff带进第一个列名导致解析失败get_or_create配合本地缓存是为了避免每行都查一次数据库站点表和污染物表的记录数是有限的查一次存进 dict 后面直接用就行。make_aware是把 naive 的 datetime 变成带时区的对象Django 的USE_TZTrue时会强制要求 aware datetime否则写入报错。实测下来10 万行原始记录约 60 万条事实记录用bulk_create写入 MySQL 大约需要 20 到 40 秒如果你仍然逐条 create同样的数据可能要跑十几分钟而且中间任何一条失败都得重来。2.3 settings 里 MySQL 的 4 个必配参数与编码坑Django 默认连 SQLite切换到 MySQL 需要装驱动并在 settings 里改一段配置。常见驱动有mysqlclient和pymysql我的建议是直接用pymysql因为它安装不需要编译本地 C 扩展Windows 和 Linux 上都不容易踩坑。import pymysql pymysql.install_as_MySQLdb() DATABASES { default: { ENGINE: django.db.backends.mysql, NAME: air_quality_db, USER: root, PASSWORD: your_password, HOST: 127.0.0.1, PORT: 3306, CONN_MAX_AGE: 60, OPTIONS: { charset: utf8mb4, init_command: SET sql_modeSTRICT_TRANS_TABLES, }, } }四个必配参数CONN_MAX_AGE设为 60表示一个连接在一分钟内可复用避免每次请求都重新握手charset必须配utf8mb4不是utf8因为utf8在 MySQL 里最多支持 3 字节编码存不了特殊字符和生僻字init_command里的sql_mode设置让 MySQL 在写入超长字段时抛异常而不是静默截断这个直接影响数据完整性HOST写127.0.0.1而不是localhost有些系统上 localhost 会被解析成 socket 文件Django 连不上。另外创建数据库时就要指定字符集这一步很多人忘记导致后面迁移建表全是 latin1 编码中文乱码满屏飞。命令行里这样建库mysql -u root -p -e CREATE DATABASE air_quality_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;然后执行python manage.py makemigrations和python manage.py migrate。如果迁移时报django.db.utils.OperationalError: (1130, Host ... is not allowed to connect)说明 MySQL 没开远程权限在 MySQL 里执行GRANT ALL PRIVILEGES ON air_quality_db.* TO root% IDENTIFIED BY 密码; FLUSH PRIVILEGES;就能解决。运行起来后确认一下数据是否真的进去了用SELECT COUNT(*) FROM air_quality_record;对数量然后抽查一条记录看record_time和value是否为预期值。数据入库这步做得越扎实后面可视化接口翻车的概率越低。3. 可视化接口背后的查询逻辑聚合、分组与时间粒度3.1 按小时/天/月聚合的 ORM 写法与对应 SQL前端图表要展示的通常不是原始记录而是聚合后的趋势线。按天算日均 PM2.5、按月算月均、按小时看污染突变这些需求都对应 SQL 里的GROUP BY加时间函数。Django ORM 里做这件事用annotate但时间的截断需要借助Trunc系列表达式。from django.db.models import Avg, F from django.db.models.functions import TruncDay, TruncHour from .models import AirQualityRecord def daily_average(station_id, start_date, end_date): rows ( AirQualityRecord.objects .filter( station_idstation_id, pollutant__codepm25, record_time__date__gtestart_date, record_time__date__lteend_date, ) .annotate(dayTruncDay(record_time)) .values(day) .annotate(avg_valueAvg(value)) .order_by(day) ) return list(rows)这段代码生成的 SQL 大致是SELECT DATE(record_time) AS day, AVG(value) AS avg_value FROM air_quality_record WHERE station_id 1 AND pollutant_id 2 AND DATE(record_time) BETWEEN 2025-01-01 AND 2025-01-31 GROUP BY DATE(record_time) ORDER BY day;TruncDay(record_time)的作用是把 datetime 截断到天相当于 MySQL 里的DATE()函数换成TruncHour就是按小时分组。这里有一个性能细节record_time__date__gte会隐式地对record_time调用DATE()函数导致索引失效数据量大时全表扫描。正确写法是直接用record_time__gtedatetime(2025,1,1)配合record_time__ltdatetime(2025,2,1)让范围过滤直接命中前面建的联合索引。这个差别在百万行数据上是 0.1 秒和 3 秒的区别。values(day)和annotate(avg_valueAvg(value))的顺序是有讲究的先values指定分组字段再annotate聚合Django 才会按day分组如果反过来聚合结果就不是你想要的分组粒度。这个顺序错了不会报错但返回的数据会让人摸不着头脑是常见的隐蔽 bug。3.2 城市对比与均值计算annotate values 的组合除了单站点趋势PM2.5 项目里最常见的还有城市维度对比比如同一天北京、上海、广州的 PM2.5 均值分别是多少做成柱状图拍出污染排行。这需要跨站点点按城市分组如果你的站点表里已经存了city字段Django 的 ORM 可以顺着外键直接分组。from django.db.models import Avg from django.db.models.functions import TruncDay from .models import AirQualityRecord def city_daily_average(date): rows ( AirQualityRecord.objects .filter(record_time__datedate, pollutant__codepm25) .values(station__city, record_time__date) # 先分组再聚合 .annotate(city_avgAvg(value)) .order_by(-city_avg) ) return rows注意这里values(station__city, record_time__date)里用了跨表字段station__cityDjango 会自动做 JOIN。如果Pollutant表里的 code 有唯一索引这里过滤pollutant__codepm25会比先查出污染物 ID 再过滤更快。实际执行时可以用connection.queries查看 ORM 生成的 SQL 来验证如果看到 JOIN 了两张不相关的表说明模型关系的related_name设置有问题。有时候现实需求是对比一组城市某时间段的达标率而不是均值这在 SQL 里就是AVG(CASE WHEN value 75 THEN 1 ELSE 0 END)ORM 中用Avg(Case(When(value__lt75, then1.0), default0.0))实现。PM2.5 的 24 小时均值限值是 75 微克/立方米达标率数据在可视化看板里比绝对值更有说服力。3.3 查询性能索引设计与大表分页数据量上了几十万行后接口响应变慢几乎都是索引问题。Django 的模型定义里已经加了record_time station的联合索引但如果你频繁按污染物和时间过滤这个索引的字段顺序需要重新考量。MySQL 索引遵循最左前缀原则查询条件里如果只给pollutant_id和record_time联合索引(record_time, station)就用不上。常见做法是把索引拆成两个(pollutant_id, record_time)和(station_id, record_time)分别应对某污染物全站点时序和某站点全污染物时序两类高频查询。class Meta: db_table air_quality_record indexes [ models.Index(fields[pollutant, record_time], nameidx_pollutant_time), models.Index(fields[station, record_time], nameidx_station_time), ]加了索引后别忘了跑一下EXPLAIN验证EXPLAIN SELECT station_id, AVG(value) FROM air_quality_record WHERE pollutant_id 2 AND record_time BETWEEN 2025-01-01 AND 2025-01-31 GROUP BY station_id;看到type是range、key是idx_pollutant_time就说明索引生效了。如果还是ALL全表扫描检查字段类型是否不一致——station_id是 int 而查询里传了字符串MySQL 会做隐式类型转换然后放弃索引。另一个坑是图表接口返回的数据量。如果你做的是整年逐小时曲线一年有 8760 个点一次性全返回会让前端渲染卡死。常见的解法不是分页图表不适合页码翻动而是后端限制采样点数量。我的做法是超过 2000 个点时自动把时间粒度从小时升到按天聚合这个逻辑放在视图层判断def trend_data(request, station_id): start request.GET.get(start) end request.GET.get(end) points AirQualityRecord.objects.filter( station_idstation_id, record_time__range(start, end) ).count() if points 2000: granularity day else: granularity hour # 按 granularity 走不同聚合分支接口层做一次粗粒度计数通常很快用COUNT(*)配合索引命中几乎无感。用户看到的图表永远有数据只是缩放范围大时显示日线、小时范围时显示小时线体验上比转圈圈等加载好很多。4. 把查询结果渲染成图表ECharts 的 JSON 对接方案4.1 返回 JSON 的视图函数时间序列格式是关键Django 后端和 ECharts 前端的数据交接痛点从来不是怎么渲染图形而是 JSON 格式的约定。ECharts 的折线图需要 x 轴数据和 y 轴数据热力图需要三维数组仪表盘只需要一个数值。后端视图的核心职责就是把聚合结果整理成 ECharts 能直接消费的结构。import json from django.http import JsonResponse from django.views.decorators.http import require_GET from django.utils.dateparse import parse_datetime from .query import daily_average require_GET def api_daily_trend(request): station_id request.GET.get(station_id) start parse_datetime(request.GET.get(start)) end parse_datetime(request.GET.get(end)) if not all([station_id, start, end]): return JsonResponse({code: 400, msg: 缺少 station_id/start/end 参数}, status400) rows daily_average(station_id, start, end) result { code: 0, data: { dates: [r[day].strftime(%Y-%m-%d) for r in rows], values: [round(r[avg_value], 1) for r in rows], unit: ug/m3, } } return JsonResponse(result)这个接口把日期格式化成了%Y-%m-%d字符串再放进 JSON。不要直接把 datetime 对象交给json.dumpsDjango 的JsonResponse遇到 datetime 会报TypeError: Object of type datetime is not JSON serializable到时候你还得返工加 default 参数不如一开始就格式化。require_GET装饰器限制了这个接口不接受 POST因为查询类接口本质上没有副作用。前端如果拿不到数据第一件事是看浏览器 Network 面板确认请求是否真的发出去了以及响应的code是不是 0。状态码 200 但业务码 400 的情况在这类项目里很常见后端和前端对异常的定义一致是顺利联调的前提。4.2 折线图、热力图、仪表盘的 ECharts 配置参数拿到 JSON 数据后ECharts 的配置就是照着官方示例改。折线图展示日均 PM2.5 趋势xAxis的type建议用category配合字符串日期不要用time类型再传时间戳因为 category 在数据点不多时缩放更顺滑也不用管时区。div idchart stylewidth: 100%; height: 420px;/div script fetch(/api/daily_trend?station_id1start2025-01-01end2025-01-31) .then(res res.json()) .then(res { if (res.code ! 0) return; const chart echarts.init(document.getElementById(chart)); chart.setOption({ title: { text: PM2.5 日均浓度 }, tooltip: { trigger: axis }, grid: { left: 60, right: 30, top: 40, bottom: 40 }, xAxis: { type: category, data: res.data.dates }, yAxis: { type: value, name: res.data.unit, splitLine: { lineStyle: { type: dashed } } }, series: [{ type: line, data: res.data.values, smooth: true, areaStyle: { opacity: 0.2 }, markLine: { silent: true, data: [{ yAxis: 75 }] } }] }); window.addEventListener(resize, () chart.resize()); }); /scriptmarkLine在 y 轴 75 处画一条警戒线这是 PM2.5 日均浓度国家二级标准的限值有这条线用户一眼就能看出哪些日期超标了。areaStyle的透明度调到 0.2 会让曲线下方有淡色填充视觉上更直观。别忘了window.addEventListener(resize, () chart.resize())否则刷新浏览器窗口时图表会溢出容器。如果需要做小时级热力图横轴是 24 小时纵轴是日期颜色深度代表浓度ECharts 用visualMap分段颜色option { tooltip: {}, grid: { left: 80 }, xAxis: { type: category, data: hours }, // [00:00, 01:00, ...] yAxis: { type: category, data: days }, // [2025-01-01, ...] visualMap: { min: 0, max: 200, calculable: true, inRange: { color: [#dcebff, #8ec8ff, #ffd666, #ff7875] } }, series: [{ type: heatmap, data: data3d, // [[dateIndex, hourIndex, value], ...] label: { show: false } }] };热力图的数据格式是三维数组[dayIndex, hourIndex, value]不是平铺的数值列表后端接口要按索引组装。这种图适合展示某站点一周里每个小时污染浓度分布能看出早晚高峰的规律性波动。4.3 定时刷新与后端缓存让页面不卡死PM2.5 数据的更新频率通常是小时级但用户不会手动刷新页面前端做个 30 分钟自动请求是常规操作。定时拉取的实现简单但要注意两个问题一是用户切换了站点或时间范围之后要重置计时器二是如果每次拉取都打全量接口MySQL 撑不住高并发访问。解决方案是给查询接口加一层文件缓存或内存缓存。数据的时间跨度决定了它的语义——对于已经过去的日期聚合结果是不会变的可以放心缓存只有今天的数据会随着新的监测记录写入而变化from django.core.cache import cache def api_daily_trend(request): station_id request.GET.get(station_id) start request.GET.get(start) end request.GET.get(end) cache_key fdaily_trend_{station_id}_{start}_{end} result cache.get(cache_key) if result is None: rows daily_average(station_id, parse_datetime(start), parse_datetime(end)) # ... 组装 result cache.set(cache_key, result, timeout1800) # 半小时过期 return JsonResponse(result)cache.set的 timeout 设成 1800 秒正好和前端 30 分钟定时刷新同步缓存还是热的时候前端请求会被直接命中MySQL 这层基本没有压力。如果你用的是 Django 默认的 LocMemCache 缓存要注意它是进程内缓存多进程部署时各进程各存一份命中率会打折但开发和小规模使用完全够用。生产环境可以考虑换成 Redis配置差异只在 settings 里增加一段 CACHES 配置代码不用改。前端定时刷新的完整写法是let timer null; function loadChart(params) { fetch(/api/daily_trend? new URLSearchParams(params)) .then(res res.json()) .then(res { if (res.code ! 0) return; chart.setOption({ xAxis: { data: res.data.dates }, series: [{ data: res.data.values }] }); }); } function startAutoRefresh(params) { if (timer) clearInterval(timer); timer setInterval(() loadChart(params), 30 * 60 * 1000); }注意setOption第二次调用时只需要传变化的xAxis.data和series.dataECharts 会做 diff 合并不需要把完整配置重写一遍。这个细节能让图表在刷新时保持缩放的视图状态用户不会觉得页面在闪。5. DjangoMySQL 可视化项目避坑手册5 个高频翻车点5.1 坑位一MySQL 驱动装不上Django 一直报 Did you install mysqlclient?现象python manage.py migrate时报错说找不到 MySQL 驱动或者pip install mysqlclient编译报错提示缺少mysql_config。原因mysqlclient依赖本地 MySQL C 库的 header 文件Windows 上最常见的问题是缺少 Visual C 编译器Linux 上则需要libmysqlclient-dev系统包。解决直接换pymysql。在项目__init__.py里写两行代码就能让 Django 认出 pymysqlimport pymysql pymysql.install_as_MySQLdb()这个操作是把 pymysql 伪装成 MySQLdbDjango 的 MySQL 后端在 import 时会自动调用它。相比下载预编译的 mysqlclient wheel 包pymysql 是纯 Python 实现安装就是pip install pymysql一条命令没有任何编译依赖。5.2 坑位二中文显示乱码curl 接口返回 UTF-8 字符变成问号现象前端图表里的站点名、城市名变成???或者写进 MySQL 的中文读出来是一串乱码。原因数据库建的字符集不是 utf8mb4。MySQL 5.7 及以下版本默认字符集是 latin1Django 表结构里的vachar字段跟着继承了这个字符集中文字符被截断存成了问号。解决建库语句显式指定字符集写入时在连接里也指定ALTER DATABASE air_quality_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; ALTER TABLE station CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;ALTER TABLE ... CONVERT TO CHARACTER SET会重写表里已有的数据如果数据已经是乱码重写完还是乱码需要重新导库。所以最稳妥的做法是在导入数据之前就把库、表、连接三处字符集统一用第 2 章里的建库语句不要等到看见乱码再补救。5.3 坑位三数据库表已经有数据想加索引却卡到超时现象往百万行数据的表上加索引命令执行半天没完开发环境的 MySQL 直接报 lock wait timeout。原因MySQL 5.6 之前的版本加索引会锁表即使在 5.7 上用ALTER TABLE ADD INDEX也是在线 DDL但如果你用的 MySQL 版本较老或者有长事务在执行加索引还是会阻塞读写。解决用pt-online-schema-change工具做无锁变更或者分两步处理先创建一张新表带上索引再INSERT ... SELECT拷数据最后RENAME TABLE。对于数据量在几十万级别的场景最简单的办法是确认没有别的连接在跑长事务后直接把lock_wait_timeout调大SET SESSION lock_wait_timeout 3600; ALTER TABLE air_quality_record ADD INDEX idx_pollutant_time (pollutant_id, record_time);如果索引加不上去影响到了整个接口的响应时间这时候可行的路线是先把数据导入脚本跑完再一次性加上所有索引避免反复 ALTER。5.4 坑位四Django 的 DateTimeField 写入报 ValueError: time data ... does not match format现象用datetime.strptime解析 CSV 里的一行时间字段报格式不匹配但肉眼看着数据格式明明一样。原因CSV 里的时间精度可能不统一比如大部分是2025-01-01 00:00:00但偶尔有几行是2025-01-01 0:00:00或2025/01/01 00:00:00strptime 的格式串只要对不上就抛异常。解决不要用死格式解析用dateutil.parser.parse做模糊解析from dateutil.parser import parse raw_time row[time] try: parsed_time parse(raw_time) except (ValueError, OverflowError): print(f无法解析的时间: {raw_time}) continuedateutil能识别多种常见格式省去你写一堆try/except的逻辑。但要注意它解析2025/01/01时会按美式日期处理如果你有中文格式的2025年1月1日最好在导入前统一预处理成标准格式。5.5 坑位五接口偶发 500日志显示 MySQL server has gone away现象图表页面刚打开时正常点了几次切换条件之后突然报错重启服务之后又恢复。原因MySQL 的wait_timeout默认是 8 小时Django 这边CONN_MAX_AGE设为 60 后连接在 8 小时内空闲会被 MySQL 服务端主动断开Django 复用这条已失效的连接时就报 gone away。这个坑在你开发调试时几乎不会出现部署上线后流量低峰期最容易遇到。解决在 settings 里把CONN_MAX_AGE调小一些或者加一段连接校验的重试逻辑from django.db import close_old_connections # 在视图入口处调用放弃已断开的连接 close_old_connections()常见做法是每处理完一个请求就调用一次close_old_connectionsDjango 的 signal 机制会自动做这件事但如果你用了多线程或异步任务就需要手动加。另一个思路是改 MySQL 的wait_timeout为 28800 以上看能不能避开但治标不治本还是建议在代码层容忍失效连接。6. 部署后第一天就该做的验证数据一致性、告警线与压测技巧6.1 一致性校验图表数据对不上原始 CSV 的问题排查部署完成、页面能出图之后先别急着收工花十分钟做一致性校验。抽查一个站点、一个时间范围的聚合结果用 SQL 手算一遍对比前端展示的数值是否一致SELECT station_id, DATE(record_time) AS day, ROUND(AVG(value), 1) AS avg_pm25 FROM air_quality_record WHERE pollutant_id (SELECT id FROM pollutant WHERE codepm25) AND record_time BETWEEN 2025-01-01 AND 2025-01-07 GROUP BY station_id, DAY(record_time);把 SQL 结果和图表上显示的数字对一下如果差得多先从数据导入环节查。有几个容易忽略的点CSV 里有的行是NA或者空值导入脚本里continue跳过之后日均值的分母会变导致浓度整体偏高或偏低同一时间同一站点有重复记录导致聚合时被算了两遍。用SELECT COUNT(*) FROM air_quality_record WHERE station_id1 AND record_time2025-01-01 00:00:00;可以快速验证有没有重复有的话在导入脚本里加get_or_create或去重逻辑。我一般会在导入时额外输出一份统计日志包括总行数、跳过行数、时间范围起止、站点数量这样将来数据对不上时能直接对照日志排查而不是等用户发现图表异常再回头查。6.2 loadtest 压测确认你的可视化接口撑得住多看板同时在线部署完成后用django-extension的runserver_plus或者直接用abApache Bench压一下接口底数。测试脚本只打最核心的聚合接口命令很简单ab -n 100 -c 10 http://127.0.0.1:8000/api/daily_trend?station_id1start2025-01-01end2025-01-31-n 100表示总共发 100 个请求-c 10表示同时 10 个并发。看两个值Time per request和Failed requests。如果平均响应时间超过 2 秒先检查视图里有没有count()做粗粒度判断、有没有缓存命中如果 Failed 不为 0往 MySQL 慢查询日志看一眼有没有全表扫描出现。压测结束后把 Django 的DEBUG设为False再测一遍DEBUG 模式下 Django 会为每个 SQL 查询记录执行计划开销很大很多人上线后忘了关接口莫名变慢。6.3 一个小习惯把看板的数据刷新频率和缓存过期时间写进配置我吃过大意亏之后养成了写配置注释的习惯。项目的settings.py里会有这样一段# 数据更新频率外部数据源每 30 分钟更新一次 DATA_REFRESH_INTERVAL_SECONDS 30 * 60 # 缓存时间略大于数据更新间隔避免边缘请求打穿缓存 CACHE_TIMEOUT DATA_REFRESH_INTERVAL_SECONDS 60所有和数据新鲜度相关的值集中在一个文件中定版后不分散在各个视图里。这样做的好处是将来数据源的更新频率变了只改一个常量前端定时刷新的间隔也可以动态暴露成接口字段不用每次改完再发一次前端代码。可视化项目看着不难上线后真正消耗精力的永远是数据对不上和慢查询把这两个问题在部署头一天解决掉后面就是省心的看板了。这个方向项目还有一个容易被忽略的优势它天然适合你在求职时讲性能优化的故事——从慢查询到加索引、从逐条插入到批量写入、从无缓存到带超时的缓存层每一个点都是能展开讲十分钟的真实经历。希望你在自己动手跑通数据链路的过程中也能收获同样的底气和判断力。希望帮到你。本文还有配套的精品资源点击获取