基于 Flask 的「自动任务控制台」设计与踩坑实录
------ Python 常驻调度 + IIS/wfastcgi 部署 + 云端 MySQL 的一体化实践
本文是对一个已稳定运行的生产级项目 webconsole(自动任务控制台) 的完整复盘:
一个 Flask 单体应用,同时承担 Web 控制台 与 5 个常驻采集/自动化任务 的调度中心,
部署在 Windows IIS + wfastcgi 上,数据落地云端 MySQL。
文章包含完整的设计思路、模块拆解,以及从开发到上线踩过的 12 个真实的坑(含排查过程与修复方案),希望对做同类系统的朋友有所帮助。

一、项目概述
1.1 这个系统做什么
自动任务控制台是一个「Web 界面 + 后台任务调度」二合一的单体应用,管理 5 个互不相关的自动化任务:
| # | 任务 | 功能 | 调度模式 |
|---|---|---|---|
| 1 | 程序员客栈 · 自动接单 | 轮询外包平台待办邀请,按策略(预算/关键词/周期)自动接单 | 7×24 常驻(默认 180s 巡检) |
| 2 | 雪球 · 调仓数据采集 | 抓取自选/大V组合持仓快照,对比生成调仓记录,异动邮件提醒 | 交易日盘中轮询 + 收盘全量 |
| 3 | 涨停数据 · 采集 | 开盘啦涨停板池 + 涨停原因,经 C# WebAPI 落云端 | 每交易日 15:30 一次 |
| 4 | 财经舆情 · 多源采集 | 8 路财经资讯源采集 → SimHash 雷同检测 + 股票命中 → 邮件预警 | 7×24 常驻(30s 轮询) |
| 5 | 中标线索库 · 定期采集 | 政采网站点信息化项目中标/成交公告采集入库 | 每 3 天 09:00 |
每个任务都有对应的可视化页面:实时状态卡、数据浏览、常用参数在线调整、手动触发。
1.2 技术选型
| 层 | 选型 | 理由 |
|---|---|---|
| Web 框架 | Flask + Jinja2 | 轻量单体,模板服务端渲染,无前端构建链 |
| 调度 | 自研常驻线程(非 APScheduler/Celery) | 任务本质是「轮询 + 幂等」,常驻线程最简单可靠,见 §4.1 |
| 数据库 | MySQL 8.0(云端) | 与既有 C# 股票系统共用同一个 stock 库,实现跨语言数据共享 |
| 部署 | Windows IIS + wfastcgi | 服务器上已有多个 IIS 站点,统一运维 |
| 采集 | requests + BeautifulSoup + Playwright | 静态页/API 用 requests,需登录态的用本机浏览器 CDP |
| 分析 | jieba + TF-IDF + KMeans + SimHash | 资讯话题聚类与雷同检测 |
一个值得强调的决策:不用 Celery/Redis,不用 APScheduler 。
5 个任务的调度逻辑差异很大(7×24 巡检 / 交易日时点 / 每 N 天),但共同点是「到点检查 → 幂等执行 → 写日志」。
每个模块一个 daemon 线程 +
sys_config开关,约 200 行代码搞定全部调度,且天然与 Flask 同进程,页面能直接读线程内部状态。
二、总体架构
┌────────────────────────────────────────────┐
│ Windows IIS (8015) │
│ 站点 webconsole → 物理路径(Junction) │
│ 应用池 news (wfastcgi, maxInstances=1) │
└──────────────────┬─────────────────────────┘
│ FastCGI
┌──────────────────▼─────────────────────────┐
│ Flask app (app.py) │
│ ┌─────────────┐ ┌──────────────────────┐ │
│ │ 11 个页面 │ │ 30+ REST API │ │
│ │ /jobs /news... │ │ /api/news/* /api/... │ │
│ └─────────────┘ └──────────────────────┘ │
│ ┌──────────────────────────────────────┐ │
│ │ jobs_registry.build_scheduler() │ │
│ │ (模块级挂载!见踩坑 #1) │ │
│ └──────┬──────┬──────┬──────┬─────────┘ │
│ ┌──────▼─┐ ┌──▼───┐ ┌▼─────┐ ┌▼──────┐┌──▼──────┐
│ │cxy-mon │ │xq-mon│ │zt-mon│ │news-mon││bid-mon │
│ └────┬───┘ └──┬───┘ └┬─────┘ └┬──────┘└──┬──────┘
└────────┼────────┼──────┼────────┼──────────┼─────┘
▼ ▼ ▼ ▼ ▼
┌───────────────────────────────────────────────────┐
│ services/ 层:storage(统一DB) · news_analyzer │
│ (SimHash/命中/邮件) · xueqiu_cookie_capture │
└──────┬──────────────┬──────────────┬──────────────┘
▼ ▼ ▼
云端 MySQL stock 库 C# WebAPI SMTP(163)
(共用原系统表) (涨停数据落库) (预警邮件)
关键设计原则:
- 单进程单调度 :FastCGI
maxInstances=1,保证调度线程全应用只有一份(双实例会重复采集); - 页面读线程、不抢任务 :页面只读各模块
state_view()(内存态)+job_run_log(库态),手动触发走 API 队列; - 存储统一收口 :所有 DB 访问经
services/storage.py,线程本地连接,禁止各模块自建连接; - 敏感信息零落码 :SMTP 授权码/DB 密码走环境变量或本机密钥文件,
config.py只放非敏感默认值。
三、目录结构
webconsole/
├── app.py # Flask 入口:11 页面 + 30+ API + 调度器模块级挂载
├── jobs_registry.py # 任务注册表:JOBS 定义 / job_view 视图模型 / build_scheduler
├── config.py # 全部默认配置(无密钥):CXY/XUEQIU/ZT/NEWS/BID + 设置页表单定义
├── smoke_test.py # 冒烟测试(WEBCONSOLE_NO_NETWORK=1 全链路离线)
├── web.config # IIS wfastcgi 配置
├── services/
│ ├── storage.py # 唯一 DB 层:线程本地连接 / 建表 / 幂等 upsert / TTL 缓存 / 清理
│ ├── cxy_agent.py # 任务1:客栈自动接单(巡检→策略→接单)
│ ├── xueqiu_agent.py # 任务2:雪球调仓(盘中轮询+收盘全量+异动邮件)
│ ├── xueqiu_cookie_capture.py # Playwright 浏览器登录采集 Cookie(CDP)
│ ├── zt_agent.py # 任务3:涨停采集(开盘啦 API → WebAPI 保存)
│ ├── news_collector.py # 任务4a:8 路资讯采集 + 常驻调度
│ ├── news_analyzer.py # 任务4b:SimHash 雷同 + 股票命中 + 邮件预警
│ └── bid_leads_agent.py # 任务5:中标线索(4 类站点解析器)
└── templates/ # base + 12 个业务页面 + 404 + 公共参数卡片
services 层的模块规范(每个任务 agent 都遵守):
python
SERVICE_NAME = "xxx" # 与 JOBS 条目的 service 字段严格一致(见踩坑 #3)
JOB_ID = "xxx" # job_run_log 的任务标识
def start_monitor(): ... # 幂等启动 daemon 线程(已在跑则直接返回)
def monitor_alive() -> bool: # 线程存活探测
def state_view() -> dict: # 页面读取的内存态快照(含 monitor_alive/last_heartbeat)
def run_once(): ... # 手动触发入口(页面按钮调用)
这套约定让 jobs_registry 可以用纯字符串约定把注册表、页面状态、手动触发三者串起来,新增任务只需「写一个 agent + JOBS 加一条」。
四、核心设计
4.1 调度中心 jobs_registry.py
这是全系统最核心的一个文件,三个职责:
(1)JOBS 注册表------任务元数据的唯一事实来源:
python
JOBS = [
{
"id": "zt_collect", # 任务标识(job_run_log 外键语义)
"order": 3, # 页面排序
"name": "涨停数据 · 采集",
"url": "/zt", # 模块页地址
"schedule_mode": SCHEDULE_TRADING, # 24x7 / trading / every_n_days
"cron_text": "30 15 * * 1-5", # 展示用 cron 文案
"service": "services.zt_agent", # → agent 模块(字符串约定!)
"enabled": True,
...
},
...
]
(2)job_view 视图模型------把静态注册条目渲染成带实时状态的完整视图:
python
def job_view(job, now=None):
v = dict(job)
if job["service"] == "services.zt_agent":
# 导入对应 agent,读取:DB 指标(metrics_of)、启停开关(get_config)、
# 线程存活(state_view()["monitor_alive"])、最近运行(last_run_of)、
# 实时生效参数(effective_collect_time) → 动态改写 schedule_text / next_run
...
# 状态机:enabled=False→paused;真实任务 state 跟随 monitor_alive
if job["service"] in (...) and v.get("enabled"):
v["state"] = "running" if v.get("monitor_alive") else "idle"
这样页面上的「运行中 / 待触发 / 已暂停 / 下次执行 xx:xx」全部是真实状态,而非静态文案。
(3)build_scheduler()------调度挂载点,带三重守卫:
python
def build_scheduler():
try:
# 守卫1:冒烟测试离线模式不拉线程
if os.environ.get("WEBCONSOLE_NO_NETWORK") == "1":
return None
# 守卫2:Werkzeug 自动重载时父进程不启动(避免双份线程)
if os.environ.get("WEB_RELOAD") == "1" and os.environ.get("WERKZEUG_RUN_MAIN") != "true":
return None
# 守卫3:受 sys_config 启停开关控制
if storage.get_config("zt_enabled", "1") == "1":
zt_agent.start_monitor()
...
except Exception:
return None
(4)调度算法 :不用 cron 库,每种模式一个 next_run_of 计算:
24x7:now + interval(下次轮询时刻);trading:下一个交易日的 HH:MM(先调交易日服务接口,失败回退「排除周末」);every_n_days:上次运行日 + N 天的 run_time,sys_config记录上次运行日防重。
4.2 数据库层 services/storage.py
python
MYSQL_CONFIG = { "host": "<云端IP>", "port": 3306, "database": "stock", ... }
_CONN = threading.local() # 线程本地连接:每个线程独享,避免 MySQL 协议线程竞争
_CFG_CACHE: dict | None = None # sys_config 3 秒 TTL 缓存
_CFG_LOADED_AT = 0.0
_CFG_LOCK = threading.Lock()
三个关键设计:
(1)线程本地连接 。Flask 请求线程和 5 个调度线程并发访问 DB,MySQL 连接不是线程安全的。用 threading.local() 让每线程第一次访问时建连、之后复用,ping(reconnect=True) 处理断线。
(2)sys_config 读取带 3 秒 TTL 缓存(写穿透) 。远程 MySQL 单次往返实测 40~60ms,而 get_config 在调度循环里每轮都要调(启停开关、间隔参数......)。设计为:
python
def get_config(key, default=""):
global _CFG_CACHE, _CFG_LOADED_AT
# 3 秒内直接读内存;过期则整表拉回内存(一张小表,一次往返换 N 次查询)
# 查无此键 → 用哨兵 _CFG_MISS 记入缓存,避免"每次都 miss 都打 DB"
def set_config(key, value):
# 写库 + 同步更新内存缓存(写穿透),保证本进程立即可见
界面保存参数后下一轮调度即生效、无需重启进程,就是靠这张表 + 这套缓存。
(3)幂等写入 。采集任务的黄金法则是「重复执行不能产生脏数据」,各表分别用:
| 表 | 幂等机制 |
|---|---|
| bid_announcements | url 唯一索引 + INSERT IGNORE(天然防重) |
| xq_rebalancings | uid(雪球原始调仓ID)唯一索引 + upsert |
| zt_pool(WebAPI 端) | 同日重复提交,C# 端按 code 跳过已存在 |
| news_articles | (source, title_hash) 唯一键,插入返回 (inserted, skipped) |
| cxy_orders | order_no 判重后插入 |
4.3 配置热更新体系(三层)
config.py(代码默认值,含全部字段的取值范围/hint)
│ 覆盖
▼
sys_config 表(界面「常用参数」保存,各模块 effective_xxx() 读取)
│ 约束
▼
agent.effective_xxx() # 统一入口:sys_config 优先 → 非法值回退 config 默认 → min/max 夹紧
config.py 里的 SETTINGS_SECTIONS / MODULE_SETTINGS 用声明式表单定义 (key/label/type/min/max/hint),系统设置页和各模块页的参数卡片直接由这份数据渲染,保存统一走 POST /api/settings 写 sys_config。新增一个可调参数 = 配置里加一个 dict,前端零改动。
4.4 邮件预警共用通道
所有邮件(雪球仓位异动、资讯雷同、股票命中、中标新线索)走同一个通道:
- 开关与收件人在「系统设置 → 通知与预警」维护(
sys_config.mail_enabled/mail_recipients); - SMTP 授权码从环境变量
SMTP_AUTH_CODE读取,回退本机smtp_auth_code.txt,绝不进代码、不进界面; - 各任务只管调用
news_analyzer.send_alert(...),发送结果按约定记job_run_log(成功=INFO / 因开关关闭跳过=WARNING / 失败=ERROR)。
4.5 财经舆情分析管线(最复杂的一条数据流)
8 路采集源(央视API/tushare三路/新浪/同花顺/金融街/东财7×24)
│ 每 poll_seconds 醒一次,按各源 interval 判断到期,只采到期源
▼
news_articles((source,title_hash) 去重,SimHash(64bit) + 标题MD5 落列)
│ 每轮末尾自动分析
├─► 雷同检测:24h 窗口内 SimHash 海明距离 ≤3 聚类 → 命中来源 ≥3 家 → 邮件预警
│ (已预警分组打 alerted 标记,不重复提醒)
├─► 标题命中:央视栏目文本 × 全A股 stock_basic / 基金 fund_basic 精确匹配 → 预警
└─► 热门话题:近 3 天资讯 jieba 分词 + TF-IDF + KMeans(k=3) 聚类(页面展示)
性能关键 :话题聚类不能在页面请求线程里做(实测 jieba+KMeans 一跑 2~7 秒,页面直接卡死,见踩坑 #6),采用「缓存过期立即返回旧值 + 后台线程重算」的最终一致模型。
4.6 数据清理
CLEANUP_SCOPE 声明清范围(表 → 中文名 → 时间判定列),界面预览条数 → 确认后真实 DELETE,范围外的表(如 WebAPI 端的涨停池)不碰。清理动作记 WARNING 级任务日志,可审计。
五、Web 层设计
5.1 页面与 API
| 页面 | 内容 | 主要数据来源 |
|---|---|---|
/ 总览 |
五任务状态卡 + 各模块统计 | all_job_views() + 各模块 stats |
/jobs 任务调度 |
五任务运行态/下次执行/手动触发/启停 | jobs_registry |
/cxy /xueqiu /zt /news /bids |
各模块数据浏览 + 常用参数卡 | agent + storage |
/xq-records |
雪球调仓记录(客户查看页,含共振分析) | storage 分页查询 |
/logs /settings |
运行日志 / 系统设置 | job_run_log / sys_config |
API 约定:页面数据接口 GET /api/<module>/* 返回 JSON,前端 5s 轮询(间隔可在设置页调);动作接口 POST(手动运行、启停、保存配置、清理执行)。
5.2 读路径优化
远程库往返 40~60ms 的现实下,几条硬性规矩:
- 统计用条件聚合合并 :一个页面要 5 个 COUNT 时,合并成一条
SUM(CASE WHEN ...); - get_config 走 TTL 缓存(见 §4.3);
- 列表查询强制 LIMIT,默认 page_size=50 可调;
- 重计算放后台(话题聚类模型,见 §4.5)。
六、部署方案(Windows IIS + wfastcgi)
安装:Python 3.11 (venv) → pip install wfastcgi → wfastcgi-enable
站点:物理路径指向 d:\产品\MIS\weike\webconsole
(D:\wwwroot\webconsole 做成 Junction 指向工作目录,两边同一份文件)
应用池:无托管代码 + FastCGI maxInstances=1(单调度进程的关键!)
web.config:
handler:*.py → wfastcgi.py
环境变量:WSGI_HANDLER=app.app / PYTHONPATH=<站点根> / WSGI_LOG=<日志文件>
webconsole 的 web.config 核心内容:
xml
<configuration>
<system.webServer>
<handlers>
<add name="Python FastCGI"
path="*" verb="*"
modules="FastCgiModule"
scriptProcessor="<python路径>\wfastcgi.py"
resourceType="Unspecified" requireAccess="Script" />
</handlers>
<httpErrors existingResponse="PassThrough" /> <!-- 保留 Flask 错误正文 -->
</system.webServer>
</configuration>
FastCGI 的 maxInstances 必须设为 1 :IIS 默认按负载拉多个 python 进程,每个进程都会执行 from app import app → 每个进程都挂一套调度线程 → 重复采集。单实例后,Flask 请求与 5 个调度线程同进程,页面读状态零开销。
七、踩坑心得(重点)
以下 12 个坑全部来自真实排查,按「现象 → 根因 → 修复 → 启示」组织。
坑 #1:调度器在开发机上好好的,部署到 IIS 一个任务都不跑
-
现象 :本地
py -3 app.py五个任务全部正常;部署到 IIS 后页面能开,但所有任务「尚未运行」,采集数据一条不进。 -
根因 :调度器挂载写在
if __name__ == "__main__":里。开发模式直接运行会执行;而 IIS/wfastcgi 是from app import app模块导入,__main__块永远不执行。 -
修复 :挂载提升到模块级,
from app import app必然执行:pythonSCHEDULER = jobs_registry.build_scheduler() # 模块级,__main__ 之外 -
启示 :「在我机器上能跑」的最大来源之一就是
__main__里的初始化逻辑。凡是 wsgi/gunicorn/uvicorn 导入式部署,初始化必须放模块级(或应用工厂)。
坑 #2:四个任务线程明明活着,页面却显示「待触发」------UnboundLocalError 被静默吞掉
这是排查成本最高的一个坑,值得一整节。
-
现象 :修复 #1 后 DB 日志证明线程在持续采集,但
/jobs页面仍显示 3 个任务「待触发/尚未运行」;job_view里的异常插桩写日志,DB 里却一条 WARNING 都没有。 -
排查 :做了个临时调试路由,把 worker 进程内部的模块身份、线程清单、甚至
job_view.__code__.co_consts(已加载代码对象的字符串常量,用于排除「进程加载了旧版代码」)全部吐出来。最后把 except 的插桩从「写 DB 日志」改成「直接把异常写进返回值」,异常现形:UnboundLocalError: cannot access local variable 'storage' where it is not associated with a value -
根因 :
job_view函数里 cxy/xueqiu 两个分支写了局部import services.storage as storage。Python 的作用域规则:函数内任何位置对名字赋值(import 也算赋值),该名字就是整个函数的局部变量 。于是模块顶部的import services.storage as storage全局名被屏蔽;当请求进入 zt/news/bid 分支(不执行 cxy 分支的 import)时,局部storage未赋值 → 直接UnboundLocalError。而 except 里的兜底逻辑storage.add_log(...)用的还是这个 storage → 兜底也炸 → 双重静默。 -
修复:删掉分支内的局部 import,统一用模块顶部全局 import。
-
启示:
- 函数内不要重复 import 顶部已有的同名模块------这是 Python 版的「变量提升陷阱」;
- except 里写日志的兜底代码,本身也可能抛异常 。关键路径的调试插桩应该把异常写进不依赖外部资源的地方(比如直接塞进返回值),否则「插了桩也没有日志」会把你引向完全错误的方向。
坑 #3:注册表字符串与分支判断不一致,雪球任务状态永远是「待触发」
- 现象:雪球条目配置齐全、线程正常,页面状态就是不对。
- 根因 :JOBS 条目写的是
"service": "services.xueqiu_collector",而 job_view 分支判断的是services.xueqiu_agent。字符串约定类架构的典型故障------约定靠人肉维护,改文件名/重构时必然漂移。 - 修复 :统一为
services.xueqiu_agent。 - 启示 :用「字符串 → 模块」映射时,最好加一条启动期自检(如
assert importlib.import_module(service)),把约定破裂从运行期静默失败提前到启动期报错。
坑 #4:新建的专用应用池一挂站点就 500,进程秒退
- 现象 :为隔离性新建应用池「webconsole」,切过去后站点 500、响应空 body。FRT(失败请求跟踪)拿到
FASTCGI_UNEXPECTED_EXIT------python 进程启动即退,无任何 stderr。 - 排查:排除了池配置差异、ACL 权限、AppLocker/IFEO、WAS 重启、池重建、loadUserProfile......均无效;最终回退到既有的「news」池立即恢复。
- 启示 :
- wfastcgi 的
stderrMode=ReturnStdErrIn500下,500 空 body 基本等于「python 根本没启动起来」,先看 FRT 再猜; - IIS 匿名账号(虚拟账号)+ 特殊目录(Junction)组合会有玄学问题,生产上优先复用已验证的应用池,别为洁癖新建;
- 事件查看器 → WER 报告能发现底层运行库问题 (本机曾查到 194 条
msvcp140.dll崩溃记录,运行库版本过老)。
- wfastcgi 的
坑 #5:改了模板不生效------Jinja 缓存
- 现象 :修改
.html模板刷新页面无变化,本地开发却即时生效。 - 根因 :wfastcgi 下
app.debug=False,Jinja2 模板缓存不会自动失效 。代码文件.py变更 wfastcgi 会自动重启进程,但模板变更不会。 - 修复 :改模板后
Restart-WebAppPool <池名>。 - 启示 :把「改模板 → 重启池」写进部署习惯,或者开发期开
TEMPLATES_AUTO_RELOAD。
坑 #6:/news 页面动辄 5 秒------重计算跑在了请求线程里
- 现象:资讯页首屏极慢,CPU 尖峰。
- 根因:页面请求线程内同步执行 jieba 分词 + TF-IDF + KMeans 话题聚类,实测 2~7 秒。
- 修复 :
hot_topics改为最终一致模型------缓存过期时立即返回上次结果,同时后台线程重算,请求线程永远不做重计算。 - 启示:Web 请求线程里只允许 O(数据库索引查询) 级别的工作;任何秒级计算都该后台化 + 返回旧值。
坑 #7:远程 MySQL 每条 SQL 都是 50ms------读路径的往返预算
- 现象:页面统计卡一堆数字,加载明显慢。
- 根因 :远程库单次往返 40~60ms,页面读路径每多一条 SQL 就多 50ms,5 个 COUNT 就是 250ms。
- 修复 :统计合并为条件聚合一条 SQL;
get_config3 秒 TTL 缓存(写穿透);列表强制 LIMIT。 - 启示 :远程数据库场景下,性能预算的单位是「往返次数」而不是「SQL 复杂度」。一条复杂的条件聚合,快于五条简单 COUNT。
坑 #8:自建表导致与老系统数据隔离
- 根因 :最初为雪球模块新建了
wc_前缀的独立表,结果老 C# 系统看不到新数据,两边各玩各的。 - 修复 :直接使用老系统既有表 (
xq_portfolios/xq_rebalancings),按原表结构派生缺失列(如rebalanced_at=created_at),写路径满足老表的外键约束(组合不存在先按 Symbol 建组合)。 - 启示 :多系统共享库时,表的所有权约定优先于工程洁癖。新写方要适配老表结构 + 用唯一索引 upsert 保证不污染老数据。
坑 #9:雪球 Cookie 的三个判定陷阱
登录态判定看似简单,实际连踩三坑:
- 匿名访客也带 token :未登录浏览器同样会拿到
xq_a_token/xq_id_token(JWT 里 uid=-1),只有 Cookie 中xq_is_login=1才是真实登录标记; - 同名 Cookie 双份 :Cookie 串里同名键可能出现两次,逐条判定时「任一条
xq_is_login==1即登录」,避免被重复键遮蔽漏检; - 未验证不落盘 :手工粘贴的 Cookie 可能是复制的旧请求、或粘贴后账号又在别处登录导致作废------必须先在线
verify_cookie()复核通过,才允许覆盖sys_config里已有的值。
另附 Cookie 采集的实现经验:用 Playwright channel="msedge" (直接驱动本机 Edge,无需下载浏览器),后台线程起 CDP 连接;无控制台环境(IIS)下 Playwright 子进程会调 sys.stderr.fileno() 而抛 io.UnsupportedOperation,需在启动前把 stdout/stderr(含 __stdout__)替换为 devnull 真实句柄;单次 CDP 抖动只记 WARNING,连续 5 次失败才判定浏览器被关------单次异常就降级会让采集功能脆得不可用。
坑 #10:政府采购网站点的四类风控/脏数据
政采类站点是采集工程的重灾区,四个教训:
- 导航链接混入列表 :校园门户「统一身份认证平台」指向内网
/user/login,每页误抓 1 条 → 3 次重试 × 25s 超时,一轮采集被拖爆。修复:_is_junk_url()按login|sso|\.css|\.js正则直接丢弃; - 风控限流伪装成故障 :中国政府采购网搜索接口对云/机房 IP 直接回 HTTP 500 或「频繁访问」限流页------这是环境问题不是代码问题 ,排查时先看
请求重试耗尽的 WARNING 日志,别急着改代码;界面提供「启用数据源」开关,把不稳定源临时摘除; - SPA 站点没有直链正文:湖南省政采网列表是 JSON 接口(POST 分页),正文在 SPA 壳页里,入库 URL 需要拼「跳转页地址」(articleId 整体 URL 编码后 base64),否则点开是空壳;
- 正文缺字段时的兜底 :采购公告/意向类正文常没有「地址」「中标商」,
is_local(本地/外地打标)必须加标题兜底(标题命中本地地名关键词即置位),否则整县线索全被误标为外地。
坑 #11:时间显示的「历史日志误读」问题
- 现象 :状态卡只显示
09:55:53,隔天看日志会把昨天的记录当成今天的。 - 修复 :展示时间非当天自动补日期前缀 (
09-16 09:55:53),当天才只显示时分秒。 - 启示 :时间展示的完整律:「凡是可能被隔天回看的界面,一律带日期」。
坑 #12:演示模式残留的兜底分支
- 现象 :早期为原型写了
DEMO_MODE的 mock 数据兜底,真实化之后个别页面仍会偶发落到演示数据,且极难复现。 - 修复 :彻底退出演示模式后,删掉所有 mock 兜底分支而不是留着「以防万一」。
- 启示 :死代码不可怕,可怕的「偶发生效的兜底」------它让真实的异常被假数据掩盖。
八、总结:可复用的经验清单
架构层面
- 中小规模「Web 控制台 + 定时任务」用单体 Flask + daemon 线程完全够用,别上分布式;
- 任务模块遵守统一接口约定(
start_monitor / monitor_alive / state_view / run_once),注册表即可驱动页面与调度; - 多系统共用库时直接写老系统既有表,靠唯一索引 + upsert 保证幂等,比新建隔离表再同步划算得多。
可靠性层面
- 调度初始化必须放模块级 (导入式部署不执行
__main__); - FastCGI/IIS 多进程会复制调度线程,maxInstances=1 或改用进程内单例锁;
- 关键路径的 except 兜底要「不依赖外部资源」------写日志本身可能就是抛异常的那行;
- 登录态/Cookie 类凭证:在线验证通过才落库 ,判定标记用业务语义字段(如
is_login)而非 token 存在性。
性能层面
- 远程数据库的性能单位是往返次数:条件聚合合并 COUNT、小表 TTL 缓存(写穿透)、强制 LIMIT;
- 秒级计算(分词/聚类/大循环)一律后台线程 + 「返回旧值 + 后台重算」的最终一致模型。
运维层面
- wfastcgi 三件套要烂熟:
__main__不执行、模板改后重启池、500 空 body = python 没起来(看 FRT); - 采集工程的异常十有八九是环境风控而非代码 bug------先把「重试耗尽」这类关键 WARNING 落到日志页,再谈排查;
- 界面时间一律带日期;演示/mock 兜底代码上线前删除干净。
本文基于真实项目复盘撰写,涉及的服务器地址、账号、密钥均已脱敏。欢迎在评论区交流你的定时任务系统设计经验。