你写了个批量脚本,要扫描几千个文件、从中检索关键词。前几百个跑得好好的,突然进程整个崩掉,报一个 UnicodeDecodeError。等你去查,发现「肇事」的只是其中一个编码不规范的小文件。为了这一个文件,整轮任务白跑了。
现象
UnicodeDecodeError: 'utf-8' codec can't decode byte 0x8b in position 1024:
invalid start byte
或者:
UnicodeDecodeError: 'utf-8' codec can't decode bytes in position 0-1:
invalid continuation byte
特点很鲜明:
- 报错是 DecodeError,方向是「往里读」;
- 崩的是整个进程,不是跳过那个文件继续;
- 如果脚本已经处理了一半,可能留下半成品输出;
- 换个数据集,崩在别的文件上,因为「哪个文件编码不规范」是随机的。
根因
根因其实很简单——读文件时没给容错选项。
Python 的 open() 默认行为是:指定了 encoding='utf-8' 后,一旦遇到不符合 UTF-8 规则的字节序列,就抛 UnicodeDecodeError 并中止。这是「严格模式」(errors='strict'),也是默认模式。
问题是,你处理的这批文件「来源不可控」:可能是别人给的、从各种旧系统导出的、甚至是从网页上抓下来的。其中难免有一两个不是 UTF-8——可能是 GBK、可能是拉丁编码、也可能是文件损坏。只要有一个读不过去,默认行为就是整个脚本倒下。
从脚本的角度看,这是「一颗老鼠屎坏了一锅汤」;从 Python 的角度看,它只是忠实执行了「遇到非法数据就报错」的默认约定。
解决
加上容错参数 errors='replace'(或 errors='ignore'),让读取在遇到坏字节时不中止:
with open(path, 'r', encoding='utf-8', errors='replace') as f:
text = f.read()
两个选项的区别:
errors='replace':把无法解码的字节替换成�(U+FFFD)。保留长度信息,你知道这里「有个解不出来的字符」。errors='ignore':直接丢弃坏字节。文本更干净,但你不知道丢了什么。
做批量检索时,推荐 errors='replace'——你要的是「别崩、尽量多读到内容」,而不是「绝对不能有坏字符」。检索关键词时,� 不会干扰匹配。
如果批量处理里更想要「跳过坏文件、继续下一个」的语义,可以包一层:
for path in files:
try:
with open(path, 'r', encoding='utf-8', errors='replace') as f:
text = f.read()
except OSError as e:
print(f'[SKIP] {path}: {e}')
continue
process(path, text)
更讲究一点的做法是先探测编码再读,用 chardet 之类的库猜出真实编码,但那是「尽量读对」的策略;errors='replace' 是「保证不崩」的策略。批量任务的第一优先级是不崩。
延伸与预防
这条坑的教训可以推广成一句通用原则:
批量处理「来源不可控的输入」时,容错参数不是可选项,是必选项。
不只是编码——同一类问题到处都是:
- 解析 JSON 时某个文件格式不对,要不要 catch;
- 读 CSV 时某行列数不对,要不要跳过;
- 解析日期时某个值格式奇怪,要不要给默认值。
共同点是:你的任务是「处理一大批」,不是「处理完美的那一批」。 一个坏输入导致整批失败,是设计上的错误,而不是数据的问题。
设计批量任务时,一个很有用的自检问题是:
如果我处理的这批数据里,有 1% 是坏的,会发生什么?
如果答案是「整个任务崩掉」,那就该在关键环节加容错,并把坏输入记录下来(而不是静默丢弃),跑完之后再统一看哪几个文件出了问题、要不要单独处理。让流程跑完,同时让问题可见——这才是一个健壮的批处理应有的样子。