做气象实时采集系统这个项目,最早是因为自己想监控一片小菜园的微气候条件,温度、湿度、气压这些数据对种植影响还挺大的。后来发现这不只是个传感器问题,把数据采集、存储、展示、告警串起来才是完整闭环。于是就用最熟悉的 Python + Flask 搭了一套完整的气象实时采集系统。从数据源接入、定时任务调度,到 Flask 提供接口、前端实时刷新,再到阈值告警和 Docker 部署,整个链路都跑通了。这篇文章就把这套系统的完整实现思路、代码细节和踩过的坑从头到尾讲一遍,适合正在学 Flask、想做一个真实可运行项目的朋友参考。
1. 整体设计思路与关键技术选型
1.1 先拆需求:这到底是个什么系统
做任何项目之前,我都习惯先把自己当用户,想想这个系统到底要解决什么问题。这套气象实时采集系统,核心诉求其实就四件事:定时拿到气象数据、把数据存下来、把数据展示出来、在异常天气来临时能通知我。
拆开看就是四个模块,采集模块负责从气象数据源拉取数据,存储模块把数据落库,服务模块通过 Flask 提供 HTTP 接口给前端,展示模块把数据变成图表和指标卡片。除了这四个核心模块,还要考虑配置管理、日志记录、异常重试、部署方式这些工程化问题。
数据源的选择很关键。我当时考虑过自己搭传感器,买一套温湿度传感器加上树莓派,成本大概两三百块,数据完全私有,但有硬件维护成本,而且只有本地一个点,没法拿到城市级别的天气趋势。后来换成公共气象 API,注册个 Key 就能用,能拿到当前天气、24小时预报、空气质量、生活指数这些丰富的结构化数据,对大多数真实场景来说够用了。当然也可以两条腿走路,本地传感器数据通过另一个采集器写入同一套系统,后面扩展再说。
这套系统适合谁来用?个人开发者想落地一个 Python Web 项目、运维同学要做定时采集和告警、或者单纯想监控一个地方天气情况的朋友,都可以参考这套代码。我没有用太重度的框架组件,Flask + SQLAlchemy + APScheduler + 原生 JavaScript 就是全部家当,代码量不大,结构清晰,方便改造成你自己需要的样子。
1.2 为什么是 Flask 而不是 Django 或 FastAPI
很多朋友问过我,Python 的 Web 框架这么多,为什么选 Flask?我的回答很简单:这套系统的服务端职责很轻,主要就是提供几个查询接口、渲染一个页面,Flask 的轻量正好匹配。
Django 适合大型应用,自带 Admin 后台、ORM、认证体系,但这也意味着你要接受它的全套约定,项目结构很重,对一个小型气象采集项目来说属于杀鸡用牛刀。FastAPI 性能确实好,自动生成 OpenAPI 文档,异步支持出色,但它的异步生态对初学者来说上手成本略高,而且文档和中文社区资料相对 Flask 还是少一些。Flask 则提供了一个非常干净的核心,路由、模板、请求上下文都很直观,再加上庞大的插件生态(Flask-SQLAlchemy、Flask-Migrate、Flask-CORS 等),任何功能都能按需添加。
另外还有一个实际的考虑:Flask 的资料在中文互联网上极其丰富,遇到问题一搜就有答案,对新手极其友好。我做这个项目的时候,有个需求是给前端提供 JSON 接口,Flask 原生支持,直接把字典返回就自动转成 JSON,省了很多事。如果换 FastAPI,还得考虑 Pydantic 模型定义,对简单场景反而多了一些概念负担。
1.3 工程目录设计:小项目也要有清晰结构
Flask 项目最忌讳的就是把所有代码塞进一个 app.py 文件里。最开始几十行还可以,等到加上定时任务、数据库模型、多个路由、告警逻辑,一个文件变得上千行,改一处就担心影响别的地方,那体验太差了。我现在养成习惯,哪怕是小项目,也按功能分目录。
这套项目的目录结构长这样:
text复制weather_system/
├── app.py # 应用入口,创建 Flask app,注册蓝图
├── config.py # 配置文件,读取环境变量,加载 API Key、数据库地址等
├── requirements.txt # 依赖清单
├── Dockerfile # Docker 镜像构建文件
├── docker-compose.yml # 容器编排,简化部署
├── models/
│ └── models.py # SQLAlchemy 数据模型
├── services/
│ ├── collector.py # 气象数据采集逻辑
│ ├── scheduler.py # 定时任务调度
│ └── alerter.py # 阈值告警逻辑
├── routes/
│ ├── weather_routes.py # 天气相关的路由接口
│ └── page_routes.py # 页面渲染路由
├── static/
│ ├── css/
│ └── js/
├── templates/
│ └── index.html # 前端页面
└── logs/
└── weather.log # 运行日志
routes 里用蓝图把接口和页面分开,services 里把采集、调度、告警独立成模块,让业务逻辑和 Web 层解耦。这样分工带来的好处很明显,后面加新的数据源只需要改 collector,加新的告警渠道只需要改 alerter,完全不用动 Web 层代码。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据采集层实现:把气象数据稳定抓进本地
2.1 数据源选型与接入流程
数据源我选的是和风天气的 API,也有朋友用 OpenWeatherMap,两者各有优劣。和风天气对国内城市支持得更好,天气现象的中文描述很友好,接入代码也不用处理语言切换。OpenWeatherMap 数据面更广,但对国内访问速度和数据详情的本地化描述稍差。
接入流程其实都差不多。先去平台注册账号,创建一个应用,拿到 API Key。和风天气的 API 可以按城市 ID 查询,也可以按经纬度查询,我建议用经纬度,精度更高,而且以后想扩展监控任意位置都不用改代码。需要注意 API Key 是敏感信息,一定不要硬编码在代码里,我放在 config.py 里,用环境变量的方式加载。
采集请求的核心代码我用了 requests 库,设置超时时间为 10 秒,防止某个请求卡住拖垮整个调度。看一段实际代码:
python复制# services/collector.py
import requests
import logging
from datetime import datetime
logger = logging.getLogger(__name__)
class WeatherCollector:
def __init__(self, api_key, location, base_url="https://devapi.qweather.com/v7/weather/now"):
self.api_key = api_key
self.location = location
self.base_url = base_url
def fetch_realtime_weather(self):
params = {
"location": self.location,
"key": self.api_key
}
try:
resp = requests.get(self.base_url, params=params, timeout=10)
resp.raise_for_status()
data = resp.json()
if data.get("code") != "200":
logger.error("API return error code: %s", data.get("code"))
return None
return data.get("now", {})
except requests.RequestException as e:
logger.error("Fetch weather failed: %s", str(e))
return None
这里需要注意一个细节,如果 API 返回的业务错误码不是 200,网络请求其实是成功的,requests 不会主动抛异常,所以必须自己检查 code 字段。我一开始忽略了这个,结果 API Key 过期后系统日志里一片空白,页面还显示上一次的数据,排查了很久才想到是业务层的错误码没处理。
2.2 定时调度策略:选 APScheduler 而不是自己写循环
采集任务不可能手动执行,必须定时自动跑。初学者最容易想到的方案是 while True 加 sleep,然后开一个子线程运行。这种方式在小场景下能用,但它有几个问题:线程无法管理,程序崩溃后不会自动恢复;没有任务持久化,重启后调度定义丢失;没有错过任务的处理策略,比如执行到一半机器休眠,醒来后任务会累积执行。
APScheduler 就是为了解决这些问题来的。它是 Python 社区非常成熟的调度库,支持 cron 表达式、固定间隔、日期定时等多种触发方式,还支持持久化任务存储。我在项目里用的是 BackgroundScheduler 加上简单内存存储,每 5 分钟执行一次采集。为什么是 5 分钟?和风天气的当前天气数据更新频率差不多也就是 3 到 10 分钟一个批次,采集太频繁只是浪费 API 配额,太慢又失去“实时”的意义。
定时任务的实现长这样:
python复制# services/scheduler.py
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.interval import IntervalTrigger
import logging
logger = logging.getLogger(__name__)
def start_scheduler(collect_job):
scheduler = BackgroundScheduler()
scheduler.add_job(
collect_job,
trigger=IntervalTrigger(minutes=5),
id="weather_collect",
replace_existing=True,
max_instances=1,
coalesce=True,
misfire_grace_time=30
)
scheduler.start()
logger.info("Weather collector scheduler started")
return scheduler
这里有两个参数特别想强调一下。max_instances=1 意味着同一个任务不会并发执行,如果上一次采集还没结束,下一次触发就直接跳过,避免重复写入。coalesce=True 表示当任务错过了多次执行计划后(比如电脑休眠了),只补执行最近一次,而不是把之前所有错过的都跑一遍,这对节约 API 配额很有用。misfire_grace_time=30 代表任务延迟 30 秒内仍然执行,超过就放弃。这几个参数一开始没配置,导致我有一次挂机过夜,早上起来发现日志里 API 调用次数暴涨,明显是任务堆积了。
2.3 数据清洗:把不规整的数据变成规整的记录
API 返回的原始 JSON 字段名比较简短,比如温度是 temp,湿度是 humidity,体感温度是 feelsLike,这些字段要转换成数据库表里更语义化的列名。另外还要做类型转换,API 返回的数值都是字符串,比如"23.5",直接存字符串也行,但后续做数值比较和图表展示时会很别扭,我在清洗阶段统一转成 float。
数据清洗的逻辑我放在 collector 里,拿到原始数据后组装成一个字典:
python复制def clean_weather_data(raw_data):
return {
"city": raw_data.get("city", "unknown"),
"temperature": float(raw_data.get("temp", 0)),
"feels_like": float(raw_data.get("feelsLike", 0)),
"humidity": float(raw_data.get("humidity", 0)),
"pressure": float(raw_data.get("pressure", 0)),
"wind_dir": raw_data.get("windDir", ""),
"wind_scale": raw_data.get("windScale", ""),
"weather": raw_data.get("text", ""),
"update_time": datetime.now()
}
清洗这一步看起来简单,但很容易出问题。比如某些字段偶尔会缺失,直接用 get 取不到值就返回默认值 0,如果后续根据温度判断是否需要告警,这个 0 值就会造成误报。所以我在清洗完之后还会加一个数据完整度校验,关键字段缺失就整条丢弃并记录日志,宁可缺一条数据也不要污染数据集。
3. 后端服务与数据存储设计
3.1 数据模型设计:怎么建表才合理
气象数据的特点是写多读少、按时间排序、需要频繁做最近 N 条查询。针对这个特点,表设计不需要太复杂,但两个细节要注意:时间字段要带索引,数据要防止重复插入。
我用 SQLAlchemy 定义模型,选用 SQLite 作为开发和单机部署的存储。为什么不用 MySQL?因为单机场景下 SQLite 完全够用,零配置、文件型数据库,备份就是复制一个文件,很适合个人项目。如果以后数据量大了或者多人并发访问,迁到 PostgreSQL 也就改一行数据库连接串的事,SQLAlchemy 统一了差异。
模型定义:
python复制# models/models.py
from datetime import datetime
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
class WeatherRecord(db.Model):
__tablename__ = "weather_records"
id = db.Column(db.Integer, primary_key=True)
city = db.Column(db.String(50), nullable=False, default="")
temperature = db.Column(db.Float, nullable=False)
feels_like = db.Column(db.Float, nullable=False)
humidity = db.Column(db.Float, nullable=False)
pressure = db.Column(db.Float, nullable=False)
wind_dir = db.Column(db.String(20), default="")
wind_scale = db.Column(db.String(20), default="")
weather = db.Column(db.String(50), default="")
update_time = db.Column(db.DateTime, default=datetime.now, index=True)
__table_args__ = (
db.UniqueConstraint("update_time", "city", name="uq_update_city"),
)
为什么加唯一约束?因为采集任务可能因为网络抖动重试,重试就可能导致同一分钟的数据写进去两条,图表上会出现重复点。加上 update_time 和 city 的联合唯一约束后,重复插入直接抛异常,在写入前先查询去重也可以,但唯一约束是最后一道保险。
3.2 Flask 接口设计:把数据安全地暴露出去
安装依赖这一步太关键了。我虚拟环境里用的 Python 版本是 3.10,Flask 是 2.3.x。如果直接用 pip install flask,在有些国内网络环境下会下载很慢,有朋友配了镜像源之后就快很多。我习惯在项目目录下建虚拟环境:
bash复制python3 -m venv venv
source venv/bin/activate
pip install flask flask-sqlalchemy apscheduler requests gunicorn
热词里很多朋友经常遇到 flask 和 pycharm 相关的问题,比如“pycharm社区版不能使用flask”,其实社区版是支持Flask开发的,只是没有专业版的模板一键生成,手动建项目目录完全不受影响。还有“vscode创建flask项目”,VSCode 比 PyCharm 更轻量,装好 Python 插件后,新建虚拟环境、选择解释器,跑一个 hello world 一点问题都没有。这些工具问题不用纠结,能用编辑器写代码就行。
我把代码服务设计成 Flask 应用工厂模式。完整的 app.py 启动了 Flask 实例,注册了配置、数据库、蓝图,并挂载了定时任务和告警模块:
python复制# app.py
from flask import Flask
from config import Config
from models.models import db
from routes.page_routes import page_bp
from routes.weather_routes import weather_bp
from services.scheduler import start_scheduler
from services.collector import collect_and_store
import logging
def create_app():
app = Flask(__name__)
app.config.from_object(Config)
db.init_app(app)
# 注册蓝图
app.register_blueprint(page_bp)
app.register_blueprint(weather_bp, url_prefix="/api/weather")
# 确保表存在
with app.app_context():
db.create_all()
# 启动定时采集
app.scheduler = start_scheduler(collect_and_store)
# 配置日志
logging.basicConfig(
filename="logs/weather.log",
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s"
)
return app
if __name__ == "__main__":
app = create_app()
app.run(host="0.0.0.0", port=5000, debug=False)
Flask 接口方面,我设计了下面几个接口:
GET /-> 渲染仪表盘页面GET /api/weather/current-> 返回最近一条采集记录GET /api/weather/history?hours=24-> 返回最近 N 小时的采集记录列表GET /api/weather/alerts-> 返回当前生效中的告警
实现当前天气接口:
python复制# routes/weather_routes.py
from flask import Blueprint, jsonify, request
from models.models import WeatherRecord
from datetime import datetime, timedelta
weather_bp = Blueprint("weather", __name__)
@weather_bp.route("/current", methods=["GET"])
def current_weather():
record = WeatherRecord.query.order_by(WeatherRecord.update_time.desc()).first()
if record is None:
return jsonify({"code": 404, "message": "no weather data"}), 404
return jsonify({
"code": 200,
"data": {
"city": record.city,
"temperature": record.temperature,
"humidity": record.humidity,
"pressure": record.pressure,
"weather": record.weather,
"wind_dir": record.wind_dir,
"wind_scale": record.wind_scale,
"update_time": record.update_time.strftime("%Y-%m-%d %H:%M:%S")
}
})
@weather_bp.route("/history", methods=["GET"])
def history_weather():
hours = request.args.get("hours", default=24, type=int)
since = datetime.now() - timedelta(hours=hours)
records = WeatherRecord.query.filter(
WeatherRecord.update_time >= since
).order_by(WeatherRecord.update_time.asc()).all()
data = [{
"time": r.update_time.strftime("%m-%d %H:%M"),
"temperature": r.temperature,
"humidity": r.humidity,
"pressure": r.pressure
} for r in records]
return jsonify({"code": 200, "data": data})
接口返回统一格式的 JSON,对前端非常友好,同时也方便以后接小程序或者其他客户端。有一点值得提醒:查询历史数据时一定要限制时间范围,不然数据量大了之后 SQLite 查询会越来越慢,我在接口里用 hours 参数做了默认 24 小时的限制。
3.3 配置管理:密钥不该出现在代码里
配置管理是很多新手容易忽略的点。API Key、数据库连接串、数据源位置、请求间隔这些都应该放到配置文件里,并用环境变量覆盖默认值。我建了一个 config.py:
python复制# config.py
import os
class Config:
QWEATHER_API_KEY = os.getenv("QWEATHER_API_KEY", "")
LOCATION = os.getenv("LOCATION", "101010100")
SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_URL", "sqlite:///weather.db")
SQLALCHEMY_TRACK_MODIFICATIONS = False
SCHEDULER_INTERVAL_MINUTES = int(os.getenv("SCHEDULER_INTERVAL_MINUTES", "5"))
这样部署到服务器时,只要在环境变量里设置 QWEATHER_API_KEY 等值,不用改任何代码。代码提交到 Git 仓库时也绝对不会泄露密钥。我见过不少开源项目截图里直接把 API Key 亮出来,然后被人在几分钟内刷爆余额的案例,一定要引以为戒。
4. 前端实时展示与告警联动
4.1 页面骨架与实时数据刷新机制
后端接口写好了,前端页面就是锦上添花的事。我用 Flask 自带模板引擎渲染一个仪表盘页面,不搞前后端分离,减少工程复杂度。页面结构包含四个指标卡片(温度、湿度、气压、天气现象),一个折线图展示最近 24 小时温度变化,再配一个粒子消息区域展示告警信息。
实时性怎么保证?最简单的做法是前端 JavaScript 定时轮询接口,每 30 秒拉一次当前天气和最近 24 小时历史数据,然后更新 DOM 和图表。这种方式实现简单、通用性强,30 秒的间隔对气象展示场景完全够用。如果想更实时,可以用 SSE 或 WebSocket 由服务端推送,Flask 里可以集成 Flask-SSE 或 Flask-SocketIO,但对我这个场景来说,轮询已经足够稳定。
轮询的核心代码:
javascript复制// static/js/dashboard.js
async function fetchCurrentWeather() {
try {
const resp = await fetch('/api/weather/current');
const json = await resp.json();
if (json.code === 200) {
const d = json.data;
document.getElementById('temperature').textContent = d.temperature + ' °C';
document.getElementById('humidity').textContent = d.humidity + ' %';
document.getElementById('pressure').textContent = d.pressure + ' hPa';
document.getElementById('weather').textContent = d.weather;
document.getElementById('update-time').textContent = '更新 ' + d.update_time;
}
} catch (e) {
console.error('fetch current weather failed', e);
}
}
async function fetchHistory() {
try {
const resp = await fetch('/api/weather/history?hours=24');
const json = await resp.json();
if (json.code === 200) {
renderChart(json.data);
}
} catch (e) {
console.error('fetch history failed', e);
}
}
// 页面加载后立即执行一次,然后每30秒循环
fetchCurrentWeather();
fetchHistory();
setInterval(() => {
fetchCurrentWeather();
fetchHistory();
}, 30000);
注意我把 fetchCurrentWeather 和 fetchHistory 分开定义,这样即使一个请求失败,也不影响另一个。如果放在同一个 async 函数里有任何一个 await 抛异常,后面的逻辑都会被中断。这个细节看似小,在弱网环境下体验差别很明显。
4.2 图表可视化:选 Chart.js 就够了
折线图我用了 Chart.js,一个轻量的前端图表库。为什么不用 ECharts?ECharts 功能更全,地图、仪表盘、3D 效果都能做,但打包体积大,对浏览器内存占用高。Chart.js 更轻、更易上手,折线图、柱状图常用功能完全够用,而且中文资料也不少。
图表渲染函数的写法:
javascript复制let chartInstance = null;
function renderChart(data) {
const ctx = document.getElementById('tempChart').getContext('2d');
if (chartInstance) {
chartInstance.destroy();
}
chartInstance = new Chart(ctx, {
type: 'line',
data: {
labels: data.map(item => item.time),
datasets: [{
label: '温度(°C)',
data: data.map(item => item.temperature),
borderColor: 'rgb(255, 99, 132)',
tension: 0.3,
fill: false
}]
},
options: {
responsive: true,
scales: {
y: {
beginAtZero: false
}
}
}
});
}
这里有一个 Chart.js 的常见坑:如果直接在同一个 canvas 上反复 new Chart,图表会重叠甚至报错。必须先判断实例是否存在,存在就先 destroy 再重建。我见过很多朋友的第一个图表项目都栽在这个问题上。
4.3 阈值告警:从只看到主动通知
光有数据展示还不够,系统的价值更在于异常时能主动告诉你。我实现了一个阈值告警模块,温度高于 35 度、低于 -5 度,或者湿度高于 90%,都会触发告警。告警方式先做了日志记录和控制台输出,后面可以扩展邮件、钉钉 Webhook、企业微信机器人,核心逻辑都一样。
告警去重是个关键点。如果温度连续 10 次采集都高于 35 度,每次都发告警通知,那完全是骚扰。我在 alerter 里维护了一个状态字典,记录每个城市每个告警类型最近一次触发时间,只有当距离上次触发超过一定时间(比如 30 分钟)才再次触发。这样既保证不漏报,又避免轰炸。
告警模块核心逻辑:
python复制# services/alerter.py
from datetime import datetime, timedelta
class ThresholdAlerter:
def __init__(self, cooldown_minutes=30):
self.cooldown_minutes = cooldown_minutes
self.last_trigger = {}
def check(self, record):
alerts = []
now = datetime.now()
rules = [
(record.temperature > 35, "高温预警: 温度 %.1f°C" % record.temperature),
(record.temperature < -5, "低温预警: 温度 %.1f°C" % record.temperature),
(record.humidity > 90, "高湿预警: 湿度 %.1f%%" % record.humidity)
]
for triggered, message in rules:
if triggered:
key = message
last = self.last_trigger.get(key)
if last is None or (now - last) > timedelta(minutes=self.cooldown_minutes):
alerts.append(message)
self.last_trigger[key] = now
return alerts
这套告警逻辑在气象监控里还可以继续扩展,比如根据气压骤降判断可能暴雨、根据风速判断大风预警。规则可配置化之后,你完全可以把它改成一个通用的事件告警引擎,不限于气象数据。
5. 部署运行与常见问题排查
5.1 本地跑通与 Docker 部署
本地调试阶段直接用 Flask 自带的 dev server 就够了,但真正长期运行到服务器上,建议用 gunicorn 或者 waitress 作为 WSGI 服务器。Flask 自带的服务器是单进程同步模式,并发能力很差,只能用在开发环境。我用 gunicorn 部署时指定 2 个 worker,加上 gunicorn 的 timeout 参数,避免某个 worker 卡死导致请求超时。
更省心的方式是直接 Docker 部署。我把系统打包成一个镜像,定义好 Dockerfile 和 docker-compose 文件,在云服务器上一条命令就能启动。Dockerfile 长这样:
dockerfile复制FROM python:3.10-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
COPY . .
EXPOSE 5000
CMD ["gunicorn", "-w", "2", "-b", "0.0.0.0:5000", "app:create_app()"]
然后把 api key 通过环境变量注入,docker-compose.yml:
yaml复制version: "3"
services:
weather:
build: .
container_name: weather_system
ports:
- "5000:5000"
environment:
- QWEATHER_API_KEY=${QWEATHER_API_KEY}
- LOCATION=${LOCATION}
volumes:
- ./data:/app/data
- ./logs:/app/logs
- ./weather.db:/app/weather.db
restart: unless-stopped
这里有个容易踩的坑:容器默认是隔离的文件系统,容器重启后 SQLite 文件如果没挂载到宿主机卷上,数据就直接丢了。我用 volumes 把 SQLite 数据库和日志目录都映射了出来,容器怎么重建数据都还在。
5.2 高频踩坑:时区、编码、任务重复
这套系统从开发到稳定运行,我踩了不少坑,挑几个最典型的分享。
第一个是时区问题。Python 的 datetime.now() 返回的是本地时区时间,如果服务器时区设置成了 UTC,而数据库里存的时间是 UTC 时间,前端展示时用户看到的是少了 8 小时的时间。我统一在配置里设置服务器时区为 Asia/Shanghai,并且数据库写入时用 datetime.now() 结合本地 timezone。如果在多时区服务器上运行,更规范的做法是统一存储 UTC 时间,展示时再转本地时间。我在 Docker 环境里直接通过 TZ 环境变量设置时区,避免代码里到处转换。
第二个是编码问题。requests 获取响应后,resp.json() 一般不会出编码问题,但 resp.text 就有可能,尤其当接口返回头没声明 charset 时。解决办法是在请求头显式声明编码:
python复制resp.encoding = 'utf-8'
如果不设置,某些接口返回的中文天气现象会变成乱码,存到数据库里也是乱码,图表上显示一堆问号,排查起来非常抓狂。
第三个是定时任务重复执行。APScheduler 在 Flask 应用工厂模式下,如果开发服务器开了 reload 模式,子进程会重新导入 app,定时任务就被创建两次,导致重复采集。解决方法是开发环境关闭 reload,或者用环境变量区分是否启动调度器。我最终选择在 create_app 里加一个参数控制:
python复制app = create_app(start_scheduler=os.getenv("ENABLE_SCHEDULER", "true") == "true")
本地测试调度逻辑时单独跑 scheduler,部署时才启用调度器。
第四个是 SQLite 并发写入锁的问题。Flask 的多个请求同时写数据库,SQLite 可能会出现 database is locked 的报错。这是因为 SQLite 同一时间只允许一个写事务。解决办法是可以把 SQLite 的 journal mode 设置为 WAL,读写并发能力会好很多。我在数据库初始化时执行一条 SQL:
python复制db.session.execute("PRAGMA journal_mode=WAL;")
这个改动之后,任务采集写入和前端的查询基本没再发生过锁冲突。
5.3 性能优化与扩展方向
这套系统跑起来之后,稳定性还不错,但我也想过它还能怎么升级。
如果采集点从 1 个城市扩展到 50 个城市,采集任务需要并发执行,而不是串行一个一个拉。APScheduler 本身不负责并发,我会引入 ThreadPoolExecutor,在采集任务里加一个线程池,每个城市一个线程去抓数据,限制最大 10 个并发,避免被接口限流。这个改动对采集速度的提升是数量级的。
存储方面,如果数据量达到每天几万条,SQLite 依然能扛,但查询历史数据时建议按时间分表或者加 Redis 缓存最近数据。我更推荐把冷热数据分离,热数据放 Redis,冷数据落 SQLite 或 PostgreSQL,这样实时页面可以从毫秒级缓存里拿数据,不会因为历史数据量变大变慢。
前端实时性方面,轮询改 WebSocket 也是一个自然演进方向。Flask-SocketIO 可以比较轻松地实现服务端推送,数据采集完成后立即推送到所有在线客户端,浏览器端响应会更及时,也减少无用轮询请求。不过这是一个可选项,如果只是个人监控,30 秒轮询完全够了。
写在最后的实践心得
这套基于 Python 和 Flask 的气象实时采集系统,从最初只有一个小脚本定时打印天气,慢慢迭代成有数据存储、接口、展示、告警、容器化部署的完整项目,这个过程本身就是最有价值的部分。我自己最大的体会是,做工程项目不要追求一步到位,先把最小的闭环跑通,采集到数据、能查出来、能显示出来,就已经是一个能用的系统,后面再逐步加告警、加优化、加部署。这样每一步都有可验证的成果,排查问题时也知道该往哪一层看。
最后再分享一个小技巧:运行过程中遇到奇怪的 bug,先去看日志,不要上来就改代码。我在这套项目里专门埋了完善的日志记录,采集、写入、告警、调度都有对应的日志。某次系统看起来什么都正常就是页面没新数据,最后查日志发现是 API Key 过期了,请求一直在报业务错误,如果没有日志,这个问题可能得排查好几个小时。写日志的时间永远不会白费。
