news 2026/10/2 23:27:39

定时任务显示「成功」,数据却是旧的:一条 20 平台采集管线的静默失败排查(附自检脚本)

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
定时任务显示「成功」,数据却是旧的:一条 20 平台采集管线的静默失败排查(附自检脚本)

我有一条每天凌晨 4:30 跑的采集管线:抓 20 个平台的热榜,翻译英文标题,构建静态站点,推上去自动部署。今天早上那份运行报告的结尾是这样:

== fetch 04:30:19 == OK zhihu: 30 items OK bili: 30 items OK v2ex: 10 items OK hupu: 30 items OK maoyan: 30 items OK hn: 30 items KEEP lobsters.json: fetch failed, keeping previous data OK github: 30 items OK reddit: 25 items OK yt: 30 items OK kr36: 30 items …(中间 9 个平台同理) done: 19/20 platforms collected (2026-09-30) == summarize 04:31:26 == DONE in 74s

任务状态:成功。线上页面正常打开,20 个平台都在,条数也都满。其中 Lobsters 那一栏,展示的是昨天那份数据。

这篇文章只写这一件事:跑成功,但数据不是新的。它不报错、不变红、页面上也看不出来,你只有专门去比日期才会发现。

翻了一遍过去 50 份运行报告:有 14 份里至少有一个平台走了「保留旧数据」这个分支,一共 24 次 —— reddit 12 次、tc 6 次、kr36 2 次、cnbeta 2 次、arxiv 1 次、lobsters 1 次(就是今天)。其中 reddit 那 12 次正好是 9 月 1 日到 9 月 12 日连续 12 天,榜单上那一栏一直显示 8 月 31 日的内容,我自己过了一个多星期才发现。

下面按「怎么发现、怎么分类、怎么修」写,第五节的检查脚本可以直接抄走,代码都是这条管线里真实在跑的,路径做了替换。

一、先看结果:一个脚本当场否掉了那份「成功」的报告

我做了个独立脚本,只做两件事:读产物、比日期。它不碰采集逻辑,所以采集代码怎么改它都不会失效。

数据新鲜度自检 2026-09-30 19:10:21 共 20 个平台 OK ars 20 条 collectedAt=2026-09-30T04:30:19+08:00 OK arxiv 30 条 collectedAt=2026-09-30T04:30:19+08:00 OK bili 30 条 collectedAt=2026-09-30T04:30:19+08:00 OK cnbeta 30 条 collectedAt=2026-09-30T04:30:19+08:00 OK github 30 条 collectedAt=2026-09-30T04:30:19+08:00 OK hn 30 条 collectedAt=2026-09-30T04:30:19+08:00 OK hupu 30 条 collectedAt=2026-09-30T04:30:19+08:00 OK ifanr 20 条 collectedAt=2026-09-30T04:30:19+08:00 OK ithome 30 条 collectedAt=2026-09-30T04:30:19+08:00 OK kr36 30 条 collectedAt=2026-09-30T04:30:19+08:00 OLD lobsters 25 条 collectedAt=2026-09-29T04:30:19+08:00(1 天前) OK maoyan 30 条 collectedAt=2026-09-30T04:30:19+08:00 OK reddit 25 条 collectedAt=2026-09-30T04:30:19+08:00 OK solidot 20 条 collectedAt=2026-09-30T04:30:19+08:00 OK sspai 10 条 collectedAt=2026-09-30T04:30:19+08:00 OK tc 20 条 collectedAt=2026-09-30T04:30:19+08:00 OK v2ex 10 条 collectedAt=2026-09-30T04:30:19+08:00 OK verge 10 条 collectedAt=2026-09-30T04:30:19+08:00 OK yt 30 条 collectedAt=2026-09-30T04:30:19+08:00 OK zhihu 30 条 collectedAt=2026-09-30T04:30:19+08:00 FAIL: 19/20 个平台的数据是今天的

OLD lobsters那一行就是今天那份报告里的漏洞:任务成功,数据是昨天的。

最省事的版本其实只有一行。每个平台的数据文件里都写了采集时间collectedAt,它就是判据:

# 有哪几个平台的 collectedAt 不是今天grep-L'"collectedAt": "2026-09-30'public/data/*.json

一秒出结果,比任何任务状态都准。脚本版在第五节。

二、为什么任务状态抓不到这类问题:失败有三种

抓数据的失败不是一种。按「会不会让你知道」分,是三类:

  1. 抛异常。脚本非零退出,cron 直接告诉你任务失败了。这类最好办,也最容易修。
  2. 空结果。HTTP 200,正常返回,就是 0 条。代码不抛错,于是要么把好数据覆盖成空的,要么走「保留旧数据」分支。表现是静默降级。
  3. 内容不对。200、条数也够,但内容是错的:只抓了三个分类里的一个、参数没生效、语种不对。这一类的健康指标全部正常,最难发现。

前两类的共同点是:任务状态回答的是「脚本有没有跑完」,不回答「数据是不是今天的」。

这句话是整件事里唯一重要的结论。我原来的监控判据是任务状态(成功/失败),换成数据日期之后,这类问题才有办法被抓出来。

三、报告里能看到「失败了」,看不到「为什么」

第一层问题是任务成功,第二层问题是:即使报告里写了失败,也没有原因。

今天这份报告里那行KEEP lobsters.json: fetch failed, keeping previous data,是我自己代码里打的,里面没有异常信息。真正的错误原文走的是 stderr:

exceptExceptionasexc:print(f'ERR{name}:{exc}',file=sys.stderr)platforms[name]=[]

而 cron 收走的报告只有 stdout。所以报告里能看到「它失败了」,看不到「它为什么失败」。

我为了搞清楚今天 Lobsters 那次,手动把采集函数单独调了一次:

python3-c"import sys; sys.path.insert(0,'scripts'); import fetch_rankings as fr; print(len(fr.fetch_lobsters()))"25

现在跑是好的,25 条。也就是说 04:30 那次是瞬时失败,而它当时的原因,报告里没留下。这种事重复几次你就会习惯性忽略那行 KEEP,直到某一栏的数据烂了两个星期。

改法很小:把错误行同时打到 stdout,或者跑脚本的时候把 stderr 合并进去(2>&1)。代价为零,收益是失败当场就有原因可查。

四、三个真实案例

案例一:Reddit 断了 12 天

现象就是上面那 12 份报告,每天一行KEEP reddit.json: fetch failed, keeping previous data,任务天天成功。

原因在采集路径上。这条路径我一开始依赖一个谷歌翻译的代理页面去读 Reddit 热门,9 月初那个入口改成了 302 跳转,链接结构全变了,解析器拿不到帖子块,抛的是:

reddit: no thing blocks found (proxy changed?)

报错原文其实写得很清楚,问题是它写在 stderr 里,从来没进过报告。

修的时候换成了官方 Atom RSS,有两个坑记一下:

  • 地址必须带geo_filter=GLOBAL。不带的话它会按你出口 IP 的国家给你本地化内容,我的出口在德国,不带这个参数返回来的就是整页德文。
  • 必须走代理,直连拿不到。

代码里现在是这样,三条路由按顺序试,并且带总预算:

REDDIT_BUDGET=300# 退避 sleep 不许把整条 pipeline 拖住deadline=time.monotonic()+REDDIT_BUDGET routes=[(REDDIT_RSS,PROXY,3),# 主路径:GLOBAL + 代理(REDDIT_RSS,'direct',1),# 真·直连(绕开 env http_proxy)(REDDIT_RSS_PLAIN,PROXY,2)]# geo 参数失效时的兜底forurl,proxy,attemptsinroutes:iftime.monotonic()>=deadline:print(' [reddit] time budget exhausted — stop trying routes',file=sys.stderr)breakforattemptinrange(attempts):try:html_text=http_get(url,timeout=45,proxy=...,direct=...)breakexcepturllib.error.HTTPErrorasexc:wait=30*(attempt+1)# 429/403/503 才退避iftime.monotonic()+wait>=deadline:breaktime.sleep(wait)

那个deadline是后来补的。原来的退避最坏情况能睡足十分钟,而单次 socket 读超时管不到它,等于给整条管线埋了一个卡死点(下面第五节还有一次更严重的)。

顺带说一件我放弃的事:这个 RSS 里没有分数和评论数。我试过免登录的.json接口(直连、走代理、连 Chrome 的 TLS 指纹伪装,一律 403),也试过去建 OAuth 应用(Reddit 已经关闭了 legacy Data API 的建应用通道,表单能提交、应用不创建)。最后定案:Reddit 这一栏就是官方 RSS,没有分数,前端自动省略这两列。我把结论写进了项目文档,标的是「已定案,别再试」。

案例二:36 氪抓回 0 条,它不报错

36 氪的频道页是纯客户端渲染,拿不到 HTML,我走抓取服务去渲染。抓取服务的返回体有两种形态:

{"results":[{"url":"https://36kr.com/p/...","raw_content":"..."}],"failed_results":[]}{"results":[],"failed_results":[{"error":"Error fetching content"}]}

第二种是「渲染档位不够」。它 HTTP 200,结构合法,只是结果列表是空的。我原来的代码直接取第一个结果:

d=tavily_extract(url,depth='basic')content=d['results'][0]['raw_content']# 空列表 → IndexError

抛的是 IndexError,被外面的通用兜底接住,走「保留旧数据」分支。于是这个平台的表现和网络故障完全一样。修法有两处:判空,把失败原因显式抛出来。

d=tavily_extract(url,depth='advanced')# 36 氪必须 advancedifnotd.get('results'):reasons='; '.join(r.get('error','?')forrind.get('failed_results',[]))raiseRuntimeError(f'36kr: extract 无结果(depth 不够或额度问题):{reasons}')content=d['results'][0]['raw_content']

区别是:下次它再坏,报告里会有一句人话,而不是一行 KEEP。

案例三:arXiv 少了两个方向(最隐蔽的一个)

这个是后来做代码审计翻出来的,症状最难发现:条数满 30,任务成功,页面正常。

原因是我那个 RSS 采集函数写成这样:

def_arxiv_from_rss():items=[]forfeedinARXIV_RSS_FEEDS:# cs.AI / cs.LG / cs.CLxml=http_get(feed)foreinparse(xml):items.append(...)iflen(items)>=LIMIT:returnitems# ← 问题在这returnitems

return写在循环体里,第一个分类装满 30 条就整个函数返回了,后两个分类从来没被请求过。机器学习和大模型方向的论文,从榜单上整体消失。

修的时候还有第二个坑:三个 feed 是同一天发布的,单纯改成「全部收集再按日期排序取前 30」还是会被排在最前面的 cs.AI 占满名额。所以现在是按 feed 轮转交错,每个 feed 内部保持原顺序。

今天的实际分布:

30 条 {'cs.AI': 10, 'cs.LG': 10, 'cs.CL': 10}

五、让失败没法静默:五条护栏

1. 单个平台失败不阻断其他平台,但必须留痕。我的选择是一个平台挂掉不让另外 19 个白跑,代价就是失败会变安静。所以保留旧数据的时候一定要打标记,数据文件上直接写:

doc['stale']=Truedoc['staleAt']=now

这样「这份数据是旧的」这件事有地方可查,而不是只存在于当天那一份报告里。

2. 每一步都套 timeout,脚本开头exec </dev/null,Python 用-u。这条是拿一整天的数据换来的。9 月 15 日那天的报告全文就 249 个字节:

# Cron Job: daily-hub 每日更新(v3 采集+部署) **Job ID:** c441dae17593 **Run Time:** 2026-09-15 05:30:25 **Mode:** no_agent (script) **Status:** script failed Script timed out after 3600s: …/daily-hub-update.sh

没有报错、没有进度,连一句「开始采集了」都没有。输出走管道加块缓冲,进程被杀的时候缓冲区里的话全丢了。那天线上一条数据都没更新,我是第二天早上打开页面才发现的,因为页面显示的还是前一天的日期。

3. 错误原因要能被看到。见第三节。

4. 重试和翻译要有总预算。翻译一次最多 420 秒,翻译请求一次只送 15 条(一次送 30 条模型输出会被截断,JSON 解析失败,整批白翻)。重试的退避也纳入同一个预算。

5. 「过期」要在页面上显示出来。保留旧数据是合理的取舍,坏的是它长得和今天的数据一模一样。

现在这条管线的调度脚本大概是这样(真实内容,路径替换过):

#!/bin/bashexec</dev/null# 子进程读 stdin 立刻 EOF,不会无声挂死exportHOME=/home/yourname# cron 里 HOME 往往是 /rootcd/path/to/your/repo||{echo"REPO NOT FOUND";exit1;}STEPS_LOG="$HOME/.logs/steps.log";mkdir-p"$(dirname"$STEPS_LOG")"mark(){echo"==$1==";printf'%s %s\n'"$(TZ=Asia/Shanghaidate'+%F %T')""$1">>"$STEPS_LOG";}start=$(date+%s)mark"fetch$(date+%H:%M:%S)"timeout-k301200python3-uscripts/fetch_rankings.py||{rc=$?;echo"FETCH FAILED (rc=$rc; 124=单步超时)";exit1;}mark"summarize$(date+%H:%M:%S)"timeout-k30420python3-uscripts/summarize.py||echo"SUMMARIZE FAILED (non-fatal)"mark"build$(date+%H:%M:%S)"timeout-k30300npmrun build>"$HOME/.logs/build.log"2>&1||{rc=$?;echo"BUILD FAILED (rc=$rc)";tail-5"$HOME/.logs/build.log";exit1;}mark"git$(date+%H:%M:%S)"gitadd-Aifgitdiff--cached--quiet;thenecho"no changes to commit (data unchanged)"elsetimeout180gitcommit-m"daily update$(date+%F)"&&timeout180gitpush origin mainfiecho"DONE in$(($(date+%s)-start))s"

关键只有四处:第一行的exec </dev/null、每个timeout、python3 -u、还有那个mark函数(事后一眼看出卡在哪一步)。

六、自检脚本:不碰采集逻辑,只读产物

上面所有修复都指向同一个缺口:没有任何一步在回答「数据是不是今天的」。所以最后落成一个独立脚本,存成scripts/freshness_check.py:

#!/usr/bin/env python3"""数据新鲜度自检:任务成功 != 数据是新的。 python3 scripts/freshness_check.py [--json] 退出码: 0 = 全部新鲜; 1 = 至少一个平台有问题 """importjson,sysfromdatetimeimportdatetime,timedelta,timezonefrompathlibimportPath TZ=timezone(timedelta(hours=8))DATA=Path(__file__).resolve().parent.parent/'public'/'data'defday_of(value):"""collectedAt / 条目日期字段 → date,取不到返回 None。"""ifnotvalueornotisinstance(value,str):returnNonetry:returndatetime.fromisoformat(value.replace('Z','+00:00')).astimezone(TZ).date()exceptValueError:try:returndatetime.strptime(value[:10],'%Y-%m-%d').date()exceptValueError:returnNonedefcheck(path,today):doc=json.loads(path.read_text(encoding='utf-8'))if'items'notindoc:return'SKIP','不是平台数据文件',0# 同目录还有摘要等别的 jsonitems=doc.get('items')or[]n=len(items)ifn==0:return'EMPTY','items 为空(抓回 0 条,采集器不报错)',n collected=day_of(doc.get('collectedAt'))ifdoc.get('stale'):return'STALE',f"采集器标记 stale(文件日期{doc.get('date')})",nifcollected!=today:days=(today-collected).daysifcollectedelse-1return'OLD',f'collectedAt={doc.get("collectedAt")}({days}天前)',n dates=[dfordin(day_of(it.get('date')orit.get('published'))foritinitems)ifd]ifdatesand(today-max(dates)).days>1:return'OLD-DATA',f'最新的条目日期是{max(dates)},内容比文件旧',nreturn'OK',f'collectedAt={doc.get("collectedAt")}',ndefmain():today=datetime.now(TZ).date()rows,bad=[],0forpathinsorted(DATA.glob('*.json')):try:status,note,n=check(path,today)exceptExceptionasexc:status,note,n='BROKEN',f'读/解析失败:{exc}',0ifstatus=='SKIP':continueifstatus!='OK':bad+=1rows.append({'platform':path.stem,'status':status,'count':n,'note':note})if'--json'insys.argv:print(json.dumps({'total':len(rows),'bad':bad,'platforms':rows},ensure_ascii=False,indent=2))return1ifbadelse0forrinrows:flag='OK 'ifr['status']=='OK'elser['status'].ljust(5)print(f"{flag}{r['platform']:<8}{r['count']:>3}条{r['note']}")print(f"{'PASS'ifnotbadelse'FAIL'}:{len(rows)-bad}/{len(rows)}个平台的数据是今天的")return1ifbadelse0if__name__=='__main__':sys.exit(main())

四个状态各管一类坑:EMPTY是空结果型,OLD是整份文件上次留下的,STALE是采集器自己承认这次没抓到,OLD-DATA是文件今天写的但内容比文件还旧。

用一个真实例子看它怎么用:把上面这份输出接进告警,--json的结果带bad计数,退出码非零就是有问题,不需要人去页面上比日期。

七、回头看,值得记下来的几条

  • 判据盯着数据的日期,不要盯任务状态。任务状态只会告诉你脚本跑完了。
  • 「抓回 0 条」和「抓失败」要分开处理。前者不会抛异常,只会让你以为一切正常。
  • 错误原因不能只写在 stderr。报告里只有 stdout 的话,你拿到的是「失败了」,不是「为什么失败」。
  • 只返回一部分的成功也算失败。提前 return、参数没生效、只抓了第一个分类,都属于这类。
  • 保留旧数据要打标记,页面上要显示。过期数据伪装成今日数据,是这类事故能拖两个星期的真正原因。
  • 修完之后回头数一遍历史报告。我这次翻 50 份报告花了几分钟,得到的结论是「24 次 KEEP、reddit 占 12 次」,比任何单次排查都更能说明问题在哪。

如果你也在跑定时采集,可以对照着看一眼自己那份「成功」的报告:里面有没有KEEP、有没有keeping previous data,那些行的日期是哪天。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/2 23:26:57

数字IC手撕代码:valid/ready握手协议详解与Verilog实现

写这段文字的时候&#xff0c;我刚从一场线上技术交流里出来&#xff0c;话题又绕到了“数字IC手撕代码”。好几个朋友都在问同一个问题&#xff1a;面试官让现场写握手协议&#xff0c;到底要写到什么程度才算过关&#xff1f;实际上&#xff0c;握手协议&#xff08;valid/re…

作者头像 李华
网站建设 2026/10/2 23:26:54

OpenClaw本地使用完整教程:把settings改到TaoToken

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/2 23:19:12

AI Skills 完全解析:用 SKILL.md 把大模型能力模块化接入 TaoToken

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/2 23:18:37

基康G2采集仪与BGK4500U接入MQTT:破解大坝监测数据孤岛实战

今年开春&#xff0c;我跑去坝区处理一件拖了两个月的窝火事&#xff1a;监控室里那台老工控机上装着基康的采集软件&#xff0c;坝基渗压计的测值在里头一跳一跳的&#xff0c;看着一切正常。可分管领导要的是实时上云、大屏展示和手机报警&#xff0c;而G2采集仪的数据就是出…

作者头像 李华