资讯动态

数据提取异常排查全攻略:从网络请求到解析入库的稳定性实践

发布时间:2026/10/20 1:21:46 来源:尧图企业网站定制
做了这么多年的数据提取相关开发我越来越觉得一个项目能不能稳定跑下去看的不是功能有多花哨而是面对异常的时候能不能扛得住。很多刚上手的朋友看到报错第一反应是——怎么又出错了但实际上绝大多数异常不是随机出现的背后都有一个相对明确的触发链路。这个常见异常及原因---提取的整理过去半年我一直在做把线上任务里反复出现的异常按阶段归类、逐条反推根因效果非常明显。这篇文章就是把整理出来的东西挑重点展开讲一遍从网络请求、内容解析、运行环境再到整条排查链路希望能帮你少走我踩过的那些弯路。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, default0): 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 exceptionKeyError fieldpriceInfo sourcespu_1000231 raw_fragment{productName:某商品,saleInfo:{price:299.00}}这比只记录KeyError有用得多。别人接手这个日志时即使没有完整的请求上下文也能大概判断问题出在哪个环节、数据长什么样。注意不要直接把完整的响应内容打到日志里大日志会拖垮系统也会带来敏感信息泄露的风险截取片段或者做哈希摘要就够了。6.2 重试不是无脑重来要有分级退避策略提取任务对网络请求做重试是常规操作但重试策略不能拍脑袋。我习惯把异常分成两类暂时性异常和确定性异常。暂时性异常包括超时、连接重置、偶尔的 5xx 状态码这类异常重试有意义但要配套退避策略——先等 1 秒重试不行再等 3 秒逐步拉长间隔避免在服务端还没恢复时集中重试把对方打挂。确定性异常包括 4xx 状态码、字段缺失、格式错误、编码错误这类异常重试多少次都一样重试反而浪费时间、放大日志噪音。正确处理方式是直接跳过并记录或者进入告警通道。下面是我用的一套简单重试参数供参考第一轮1 秒后重试第二轮3 秒后重试第三轮7 秒后重试三轮之后放弃记录失败详情并通知6.3 配置化的兜底规则和人工确认通道无论日志和重试做得多好总会有超出预期的异常。所以必须留一道人工兜底通道。我在线上项目里会把异常数据写入一个单独的待确认队列表字段包括原始数据来源、异常类型、出错阶段、完整现场数据、时间戳。每天定时巡检这个表判断哪些是可以自动修复的、哪些需要联系上游。系统跑一段时间后能沉淀出大量异常模式这些模式又能反哺前面的结构校验层和重试策略形成闭环。这里我想分享一个自己的体会不要试图消灭所有异常那不现实。更实际的目标是——让异常在可预期的地方暴露、可控制的范围爆炸、可追溯的方式留存。数据提取项目做得久了你就会发现稳定运行不是没有异常而是每种异常都有对应的处理路径。我做完这套整理和改造之后最直观的感受是线上告警少了但每次告警的信息量都大了异常依然存在但不再需要半夜爬起来从零排查。如果你的项目里同样堆了大量来路不明的报错建议也按这个思路重新整理一遍——从链路上手按阶段归类最后落到日志、重试和兜底上。你可能会发现那些反复折磨你的灵异错误其实每一个都有非常具体的根因。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑