简介:这是一份基于Django+MySQL实现的城市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. 这套 Django+MySQL 的 PM2.5 可视化项目,到底能拿来做什么
如果你手里有一份城市空气质量监测的 CSV 数据,想把它变成能在浏览器里交互查看的趋势图、小时对比图、污染排行,最直接的路径就是 Django 做后端、MySQL 存数据、ECharts 出图表。这个方向的项目在 Python 求职作品集里出现频率极高,核心价值在于它覆盖了从数据清洗、关系建模到 Web 接口和前端渲染的完整链路,不是那种只调个库就出图的玩具。适合的人群很明确:刚学完 Django 基础但不知道怎么写第一个像样项目的人,想给简历补一个"有数据库、有部署文档"的完整案例的转行者,以及需要快速搭建内部空气质量看板的运维或环境相关从业者。本文按实际落地顺序,把模型设计、数据导入、聚合查询、图表对接和部署验证逐个拆开讲,你会看到每个环节的参数设置和翻车点。
2. 把数据落进 MySQL:Django 模型设计与环境准备
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_length=64, unique=True, verbose_name="站点名称") longitude = models.FloatField(verbose_name="经度") latitude = models.FloatField(verbose_name="纬度") city = models.CharField(max_length=32, verbose_name="所属城市") class Meta: db_table = "station" class Pollutant(models.Model): code = models.CharField(max_length=16, unique=True, verbose_name="污染物代码") name = models.CharField(max_length=32, verbose_name="污染物名称") class Meta: db_table = "pollutant" class AirQualityRecord(models.Model): station = models.ForeignKey(Station, on_delete=models.CASCADE, verbose_name="站点") pollutant = models.ForeignKey(Pollutant, on_delete=models.CASCADE, verbose_name="污染物") record_time = models.DateTimeField(verbose_name="监测时间") value = models.FloatField(verbose_name="浓度值") unit = models.CharField(max_length=16, default="ug/m3", verbose_name="单位") class Meta: db_table = "air_quality_record" indexes = [ models.Index(fields=["record_time", "station"], name="idx_time_station"), ]这里最关键的决定是把每种污染物拆成独立的记录行,而不是把 PM2.5、PM10 作为字段并列。原因有两个:一是数据导入时 CSV 里列的顺序经常变化,拆成行就不会被表结构绑死;二是后续做"某一天各污染物对比"只需要按时间和站点过滤,SQL 写起来清爽。如果你拿到的是已经清洗好的一行一条记录的数据,那直接用这个模型。
表名通过db_table指定,避免 Django 默认生成的appname_modelname式名字在部署迁移时引起混淆。idx_time_station联合索引是为后面高频的"某一时间范围 + 某个站点"查询准备的,实测在百万行数据量下,不加这个索引查询耗时能从秒级降到毫秒级。
2.2 数据导入脚本:用 bulk_create 而不是循环 insert
CSV 数据导库是第一个分水岭。很多新手用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, encoding="utf-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( name=station_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(code=code) pollutant_cache[code] = pollutant record_time = make_aware(datetime.strptime(row["time"], "%Y-%m-%d %H:%M:%S")) batch.append(AirQualityRecord( station=station_cache[station_name], pollutant=pollutant_cache[code], record_time=record_time, value=float(val), )) if len(batch) >= batch_size: AirQualityRecord.objects.bulk_create(batch, batch_size=batch_size) batch.clear() if batch: AirQualityRecord.objects.bulk_create(batch, batch_size=batch_size)这段脚本里有两个容易被忽略的点:encoding="utf-8-sig"是因为很多 CSV 文件从 Excel 导出来时带 BOM 头,用普通utf-8读会把\ufeff带进第一个列名导致解析失败;get_or_create配合本地缓存是为了避免每行都查一次数据库,站点表和污染物表的记录数是有限的,查一次存进 dict 后面直接用就行。make_aware是把 naive 的 datetime 变成带时区的对象,Django 的USE_TZ=True时会强制要求 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_mode='STRICT_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_id=station_id, pollutant__code="pm25", record_time__date__gte=start_date, record_time__date__lte=end_date, ) .annotate(day=TruncDay("record_time")) .values("day") .annotate(avg_value=Avg("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__gte=datetime(2025,1,1)配合record_time__lt=datetime(2025,2,1),让范围过滤直接命中前面建的联合索引。这个差别在百万行数据上是 0.1 秒和 3 秒的区别。
values("day")和annotate(avg_value=Avg("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__date=date, pollutant__code="pm25") .values("station__city", "record_time__date") # 先分组再聚合 .annotate(city_avg=Avg("value")) .order_by("-city_avg") ) return rows注意这里values("station__city", "record_time__date")里用了跨表字段station__city,Django 会自动做 JOIN。如果Pollutant表里的 code 有唯一索引,这里过滤pollutant__code="pm25"会比先查出污染物 ID 再过滤更快。实际执行时可以用connection.queries查看 ORM 生成的 SQL 来验证,如果看到 JOIN 了两张不相关的表,说明模型关系的related_name设置有问题。
有时候现实需求是"对比一组城市某时间段的达标率"而不是均值,这在 SQL 里就是AVG(CASE WHEN value < 75 THEN 1 ELSE 0 END),ORM 中用Avg(Case(When(value__lt=75, then=1.0), default=0.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"], name="idx_pollutant_time"), models.Index(fields=["station", "record_time"], name="idx_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_id=station_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 参数"}, status=400) 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.dumps,Django 的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 id="chart" style="width: 100%; height: 420px;"></div> <script> fetch('/api/daily_trend?station_id=1&start=2025-01-01&end=2025-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()); }); </script>markLine在 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 = f"daily_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, timeout=1800) # 半小时过期 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.data,ECharts 会做 diff 合并,不需要把完整配置重写一遍。这个细节能让图表在刷新时保持缩放的视图状态,用户不会觉得页面在闪。
5. Django+MySQL 可视化项目避坑手册: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 认出 pymysql:
import pymysql pymysql.install_as_MySQLdb()这个操作是把 pymysql 伪装成 MySQLdb,Django 的 MySQL 后端在 import 时会自动调用它。相比下载预编译的 mysqlclient wheel 包,pymysql 是纯 Python 实现,安装就是pip install pymysql一条命令,没有任何编译依赖。
5.2 坑位二:中文显示乱码,curl 接口返回 UTF-8 字符变成问号
现象:前端图表里的站点名、城市名变成???,或者写进 MySQL 的中文读出来是一串乱码。
原因:数据库建的字符集不是 utf8mb4。MySQL 5.7 及以下版本默认字符集是 latin1,Django 表结构里的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:00,strptime 的格式串只要对不上就抛异常。
解决:不要用死格式解析,用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_connections,Django 的 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 code='pm25') 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_id=1 AND record_time='2025-01-01 00:00:00';可以快速验证有没有重复,有的话在导入脚本里加get_or_create或去重逻辑。
我一般会在导入时额外输出一份统计日志,包括总行数、跳过行数、时间范围起止、站点数量,这样将来数据对不上时能直接对照日志排查,而不是等用户发现图表异常再回头查。
6.2 loadtest 压测:确认你的可视化接口撑得住多看板同时在线
部署完成后用django-extension的runserver_plus或者直接用ab(Apache Bench)压一下接口底数。测试脚本只打最核心的聚合接口,命令很简单:
ab -n 100 -c 10 "http://127.0.0.1:8000/api/daily_trend?station_id=1&start=2025-01-01&end=2025-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所有和"数据新鲜度"相关的值集中在一个文件中,定版后不分散在各个视图里。这样做的好处是将来数据源的更新频率变了,只改一个常量,前端定时刷新的间隔也可以动态暴露成接口字段,不用每次改完再发一次前端代码。可视化项目看着不难,上线后真正消耗精力的永远是"数据对不上"和"慢查询",把这两个问题在部署头一天解决掉,后面就是省心的看板了。
这个方向项目还有一个容易被忽略的优势:它天然适合你在求职时讲"性能优化"的故事——从慢查询到加索引、从逐条插入到批量写入、从无缓存到带超时的缓存层,每一个点都是能展开讲十分钟的真实经历。希望你在自己动手跑通数据链路的过程中,也能收获同样的底气和判断力。希望帮到你。
本文还有配套的精品资源,点击获取