做了这么多年的数据提取相关开发,我越来越觉得一个项目能不能稳定跑下去,看的不是功能有多花哨,而是面对异常的时候能不能扛得住。很多刚上手的朋友看到报错第一反应是——怎么又出错了?但实际上,绝大多数异常不是随机出现的,背后都有一个相对明确的触发链路。这个"常见异常及原因---提取"的整理,过去半年我一直在做,把线上任务里反复出现的异常按阶段归类、逐条反推根因,效果非常明显。这篇文章就是把整理出来的东西挑重点展开讲一遍,从网络请求、内容解析、运行环境再到整条排查链路,希望能帮你少走我踩过的那些弯路。
1. 异常不是孤立冒出来的:先给"提取"画个链路图
很多人排查异常时喜欢盯着报错的那一行看,这当然没错,但如果你对整个提取链路没有一个全局认知,很容易被表面现象带偏。比如同样是JSONDecodeError,可能是响应内容真的坏了,也可能是编码识别错误导致的乱码,还可能是目标接口临时返回了一段 HTML 错误页。三种情况的处理方式完全不同,只靠报错信息根本分不出来。
1.1 一次完整的提取任务分哪几个阶段
我把提取任务大致切成五个阶段:请求、响应、解析、清洗、入库。每个阶段都有自己典型的异常群,而且阶段越靠后,异常越容易被误判成"数据问题"。
请求阶段主要涉及网络连接、超时控制、访问限制;响应阶段要处理状态码、响应头、编码格式;解析阶段处理的是把原始内容转成结构化数据,这里最容易爆出格式错误和字段缺失;清洗阶段一般做去重、类型转换、空值处理,很多TypeError和ValueError都在这里冒出来;入库阶段则跟数据库连接、字段长度、主键冲突有关。
大多数异常在哪个阶段产生,通常都是有规律可循的。比如超时类异常很少出现在解析阶段,而UnicodeDecodeError基本只会在编码转换时出现。如果你能快速定位异常属于哪个阶段,排查范围就能缩小一大半。
1.2 为什么那么多项目死于"未知原因"
我在看别人代码的时候,发现一个高频问题:每个函数都包着一层巨大的try...except,捕获到异常之后只写一句print("error"),或者直接pass吞掉。这种代码跑生产环境,等于把自己的眼睛蒙上。
等到异常真的发生时,你手头只有一句"第 83 行报错",没有请求 URL、没有响应内容片段、没有当时传的参数、没有上下文数据。想排查都得从头复现,效率非常低。
所以我在整理异常原因时,第一件事不是列异常类型,而是给项目立了一条规矩:异常处理代码至少要保留三个东西——异常类型、触发位置、现场数据的关键片段。这样后面每一条异常才有的聊、有的查。后面几个章节里我会反复强调"还原现场"这个思路。
2. 网络请求阶段的异常:超时、连接重置、访问限制
这个阶段是提取任务的第一道关卡,也是异常种类最杂的地方。很多新手以为网络请求就是requests.get(url)一行代码的事,但实际跑起来,各种问题一个接一个。
2.1 超时和连接重置:最常见的两个网络异常
先看超时。requests在不设置timeout参数时,会一直等下去,直到系统层面的默认超时生效。这在生产环境里非常危险,一个失联的接口就能拖死整个线程池。所以一定要分别设置连接超时和读取超时:
import requests try: resp = requests.get( "https://example.com/api/data", timeout=(3.05, 10) # 连接超时3.05秒,读取超时10秒 ) except requests.exceptions.ConnectTimeout: # 说明目标服务不可达,可能网络不通、DNS解析失败或服务宕机 pass except requests.exceptions.ReadTimeout: # 说明连接建立了,但服务端迟迟没有返回完整数据 pass连接超时和读取超时的成因差别很大。连接超时通常是目标服务器 IP 不可达、防火墙丢包、DNS 解析异常;读取超时更可能是目标服务逻辑慢、响应体过大、或者服务端自己有超时断开逻辑。定位时可以用curl -v或ping做基础探测,但如果只能靠代码排查,就同时记下耗时和重试次数,观察是不是时间规律性的超时。
连接重置(ConnectionResetError)往往和"服务端主动断开"有关。我遇到的情况里,有一半源于目标服务对单 IP 高频访问做了限流,另一半是反向代理或负载均衡设备因为 keep-alive 空闲时间过长断开了连接。处理思路一般是减小请求频率,或者每次请求都复用但严格控制连接池的空闲回收策略。
2.2 访问限制和状态码背后的信号
当请求高频或行为特征明显时,目标服务通常会返回 403、429 这类状态码。403 多是无权限或被拦截,429 则是访问频率超过限制。很多人一看到 403 就想着换 IP,这个思路只解决表面问题。更可靠的方案是把请求频率主动降下来,同时把身份特征做得更像普通用户:设置合理的User-Agent、带上必要的请求头、保持 cookie 的连续性。这些都是常规做法,但每一条都能实实在在地减少被限流概率。
这里我的建议是:不要等被限流了才去优化请求策略。在写爬虫或数据提取脚本的初期,就把频率控制、UA 伪装、重试退避这些做进去,后面会省非常多事。我见过很多项目一开始裸奔跑得欢,等被限制之后才手忙脚乱地加规则,结果数据已经断了好几天。
3. 响应内容解析阶段的异常:格式、编码和字段缺一不可
请求就算拿到 200 状态码,也不代表万事大吉。真正的"重灾区"在解析阶段,这里的异常隐蔽性更强,一个看似正常的响应里可能藏着各种坑。
3.1 编码与乱码:容易被误判为"数据损坏"
UnicodeDecodeError和乱码问题是解析阶段最常见的现象之一。根因几乎总是:目标服务返回的编码和你的代码假设的编码不一致。
比如某些服务返回的是GBK编码的 HTML 或 JSON,但你用 UTF-8 去解码,结果不是报错就是满屏乱码;有些服务返回的响应头里没有charset,只能靠内容中的 meta 标签或字节特征去猜。
我通常的处理方式是:拿响应头里的Content-Type和实际字节内容做双重判断,不硬编码解码方式。对于 HTML 页面,解析前先检测<meta charset>,对于接口数据,优先用resp.encoding或resp.apparent_encoding来兜底。这里提个性能注意点:apparent_encoding需要全量读取内容做分析,数据量大时会对性能有一点损耗,建议只在自动检测失败时使用。
3.2 JSON 和 XML 解析失败的真正根因
拿到接口数据后很多人直接json.loads(resp.text),报JSONDecodeError时第一反应是"接口坏了"。但你抓一下原始响应内容会发现,很多情况根本不是数据坏了,而是发生了以下情况:
- 接口返回了空串或只有空白字符;
- 接口返回的是 JSONP 格式,外面包了一层
callback({...}); - 接口在异常时返回了纯文本错误描述;
- 返回内容里混了 BOM 头或不可见字符。
处理方式很简单:解析之前先做一次内容清洗,比如去掉 BOM、去掉前后空白,判断空串;对于 JSONP,可以用正则匹配提取{...}部分再做解析。我习惯写一个小函数统一处理:
import json import re def safe_loads(text: str) -> dict: if not text or not text.strip(): raise ValueError("empty response") # 去掉可能的BOM if text.startswith("\ufeff"): text = text[1:] text = text.strip() # JSONP兼容 if not text.startswith("{"): match = re.search(r"\{.*\}", text, re.S) if match: text = match.group(0) return json.loads(text)XML 解析失败的原因也很类似,常见的是非法字符、未闭合标签、多个根节点。用lxml解析时,遇到这类问题可以考虑开启 recover 模式,但要注意 recover 模式可能掩埋真正的结构性问题,建议只对可信来源开启。
3.3 字段缺失、类型不符这类"半残数据"
这类问题的定位难度比格式报错高得多。因为程序不一定报错,只有当代码访问缺失字段或做类型运算时才会爆出KeyError、TypeError。比如某个接口文档说data.list一定有值,但实际某些条件下它是null;或者某个字段平时是字符串,某天突然变成了数字。
我在处理这类数据时,会做一个结构校验层:用 JSON Schema 的思路对关键字段做类型和必填检查,不满足条件的数据直接落入"待人工确认"的分流区,而不是让业务代码被一条脏数据卡死。这个方法放到生产环境里很实用,既不影响正常数据的提取,又能把异常暴露出来。
下面这张表是我整理的解析阶段常见异常与根因对照,排查时可以直接对着找方向:
| 异常现象 | 常见根因 | 定位手段 |
|---|---|---|
UnicodeDecodeError | 编码判断错误、响应头缺失 charset | 查看响应头、打印字节序列 |
JSONDecodeError | 空响应、JSONP、错误页内容 | 打印原始文本前200字符 |
XMLSyntaxError | 非法字符、未闭合标签 | 用 lxml recover 模式查看警告 |
KeyError | 字段缺失、数据结构变化 | 打印整个节点再定位 |
TypeError | 字段类型突变 | 检查实际类型与预期类型 |
4. 代码与运行环境制造的"人工异常"
前面几类异常,锅大多在外部。但还有一批异常,纯粹是自己代码里的逻辑问题或环境问题造成的。这类异常最尴尬,因为它完全可避免,但在工程里反而出现得特别频繁。
4.1 类型转换和空值处理:最常见的代码层根因
做提取任务时,经常要把字符串转成数字、把时间字符串标准化。一旦上游数据里混入None、空字符串、或者带逗号的数字文本,转换函数直接爆ValueError或TypeError。
这里我想强调一个习惯:不要假设上游数据格式永远不变。你写转换逻辑时,要下意识考虑"如果这里来的是None会怎样"、"如果多了一个空格会怎样"。更稳妥的做法是写一个不抛异常的转换函数,转换失败时返回默认值,并记录日志:
def safe_int(value, default=0): if value is None: return default try: return int(str(value).strip()) except (ValueError, TypeError): return default这种函数看着不起眼,但它能把异常从"打断整条流水线"降级成"记录一条告警继续跑"。在数据提取这种海量处理场景里,单条数据失败往往是正常的,批量任务整体失败才是不正常的。
4.2 并发和资源消耗导致的隐性异常
提取任务一旦上了并发,很多单线程下不会出现的问题就全跑出来了。常见的是线程池或进程池连接数太多,打爆目标服务;还有内存被响应内容占满,导致进程被系统杀掉;再有就是数据库连接池耗尽,报TooManyConnections。
这类异常的坑在于:它们不是必然发生的,与环境参数有关,同一个脚本配置不同结果完全不同。所以我做并发改造时会遵循一个原则:并发量从低到高逐步调整,同时监控目标服务响应时间和本机资源占用。不要一上来就开 50 个线程,先从 5 个起步,看响应时间是否稳定、有无超时和失败率上升,再逐步往上加。
数据库连接异常也一样,不是代码业务逻辑的问题,而是连接数配置不合理。用连接池并显式设置最大连接数,每次用完释放,比反复建立和断开连接靠谱得多。
4.3 变量作用域和状态残留:隐蔽的"现场破坏者"
还有一类异常更难排查,就是状态残留导致的结果错乱。比如在循环里复用了同一个可变对象,上次循环的数据没清空,被带到了下一次;或者在多线程环境里用了模块级变量,不同线程之间互相覆盖。
这类问题报错不规律,有时候跑得通,有时候跑不通,而且报错信息跟实际根因往往毫无关系。我排查这类异常时,会重点检查代码里有没有在循环外面定义、但在循环内被修改的变量;有没有被多个线程共用的全局变量;有没有从上一个任务带过来的缓存状态。把这些点捋一遍,大部分"灵异事件"都能水落石出。
5. 一次线上提取任务的完整排查复盘
只看分类不够,我把一个真实项目的排查过程完整拆开讲,这样能更直观地理解"异常——根因——修复"这条链路是怎么走的。为方便表述,我把这个项目记为"某电商平台的商品信息提取任务"。
5.1 现象:凌晨批处理大面积失败
任务每天凌晨跑一次,从目标平台提取商品列表和详情。某天早上看到群里的告警,失败率从平时的不到 1% 飙到了 67%,而且集中报KeyError: 'priceInfo'。
我第一反应是:数据源结构是不是变了?但当时并没有收到任何上游变更通知,团队也没人动过代码,所以判断这大概率不是本地改动引起的,而是一个外部变化。
5.2 排查:日志、现场数据和复现三步走
第一步先翻日志。我查了失败任务的现场数据,发现失败的商品节点里确实普遍缺少priceInfo字段,但当时时间比较早,数据量很大,来不及一条条人工核对。
第二步抓现场数据。从日志里随机抽了几条失败样本,打印出完整的 JSON 节点,发现一个规律:凡是来源渠道为"自营"的商品都缺priceInfo,来源为"第三方"的商品则正常。这说明问题不是随机出现,而是和某个维度强相关。
第三步做复现。我用测试账号按正常流程跑了一遍商品详情接口,发现接口返回里priceInfo字段确实存在,但顶层节点的priceInfo变成了一个空数组,而真正的价格信息挪到了另一个叫saleInfo的节点下。这意味着上游接口做了一次字段结构迁移,旧字段只是暂时保留空值,没有直接删除。
5.3 根因:上游结构在特定条件下"静默变化"
最终定位的根因是:目标平台针对"自营"类商品发布了新的数据结构版本,priceInfo迁移到了saleInfo下面,而"第三方"商品仍走旧结构。我们的提取代码里写死了data["priceInfo"],遇到新结构自然就报KeyError。
这个案例最有价值的点在于:异常本身并不难解,难的是判断"这是偶发脏数据还是系统性变化"。如果当初直接写一条规则跳过缺失priceInfo的商品,任务确实能保住当天不挂,但这个上游结构变化会被长期忽略,后面所有的价格类数据都会是空的,等真正要用数据时才发现问题。排查时保持敏感度,判断是"可容忍异常"还是"预警信号",比快速修完 bug 重要得多。
5.4 改进:结构校验和字段版本管理
修好这次问题之后,我做了两个改造:
第一,给关键字段增加了结构校验逻辑。解析后先校验priceInfo、saleInfo等必要字段是否存在,不存在就告警并分流,而不是直接让KeyError打崩任务。第二,建立字段版本映射表。记录各字段在不同数据结构版本下的对应关系,代码里做兼容层,新老结构都能解析。
这套改造下来,后续再遇到类似上游调整,系统能自动兼容一部分,实在兼容不了的也会留下完整现场数据给人工确认,不会再用 67% 失败率这种方式提醒我们。
6. 把异常处理变成工程能力:日志、告警和兜底
单个异常修掉只是治标,真正重要的事情是把异常处理变成一套可持续运转的机制。这套机制在我看来有三个核心:能还原现场的日志、能分级退避的重试、能兜底的人工介入通道。
6.1 日志要能"还原现场",而不是记录"出错了"
很多项目的日志就是一句话:except Exception as e: log.error(str(e))。这种日志除了告诉你"出错了"之外毫无价值。真正有用的日志至少要包含三个信息:异常类型、诱发异常的输入数据摘要、异常发生时的运行参数。
比如解析商品数据失败时,我会记录这样的日志:
[extract_item_detail] parse failed exception=KeyError field=priceInfo source=spu_1000231 raw_fragment={"productName":"某商品","saleInfo":{"price":299.00}}这比只记录KeyError有用得多。别人接手这个日志时,即使没有完整的请求上下文,也能大概判断问题出在哪个环节、数据长什么样。注意不要直接把完整的响应内容打到日志里,大日志会拖垮系统,也会带来敏感信息泄露的风险,截取片段或者做哈希摘要就够了。
6.2 重试不是无脑重来,要有分级退避策略
提取任务对网络请求做重试是常规操作,但重试策略不能拍脑袋。我习惯把异常分成两类:暂时性异常和确定性异常。
暂时性异常包括超时、连接重置、偶尔的 5xx 状态码,这类异常重试有意义,但要配套退避策略——先等 1 秒重试,不行再等 3 秒,逐步拉长间隔,避免在服务端还没恢复时集中重试把对方打挂。确定性异常包括 4xx 状态码、字段缺失、格式错误、编码错误,这类异常重试多少次都一样,重试反而浪费时间、放大日志噪音。正确处理方式是直接跳过并记录,或者进入告警通道。
下面是我用的一套简单重试参数,供参考:
- 第一轮:1 秒后重试
- 第二轮:3 秒后重试
- 第三轮:7 秒后重试
- 三轮之后放弃,记录失败详情并通知
6.3 配置化的兜底规则和人工确认通道
无论日志和重试做得多好,总会有超出预期的异常。所以必须留一道人工兜底通道。
我在线上项目里会把异常数据写入一个单独的"待确认队列"表,字段包括原始数据来源、异常类型、出错阶段、完整现场数据、时间戳。每天定时巡检这个表,判断哪些是可以自动修复的、哪些需要联系上游。系统跑一段时间后,能沉淀出大量"异常模式",这些模式又能反哺前面的结构校验层和重试策略,形成闭环。
这里我想分享一个自己的体会:不要试图消灭所有异常,那不现实。更实际的目标是——让异常在可预期的地方暴露、可控制的范围爆炸、可追溯的方式留存。数据提取项目做得久了你就会发现,稳定运行不是"没有异常",而是"每种异常都有对应的处理路径"。
我做完这套整理和改造之后,最直观的感受是:线上告警少了,但每次告警的信息量都大了;异常依然存在,但不再需要半夜爬起来从零排查。如果你的项目里同样堆了大量来路不明的报错,建议也按这个思路重新整理一遍——从链路上手,按阶段归类,最后落到日志、重试和兜底上。你可能会发现,那些反复折磨你的"灵异错误",其实每一个都有非常具体的根因。