场景与现象

一个日志导入程序按固定字节数读取 UTF-8 文件,每块调用 decode("utf-8")。英文测试一直正常,上线处理中文时却偶发 UnicodeDecodeError;改成 errors="ignore" 后程序不再报错,客户名称里却少了字。

本文处理的是同一条连续 UTF-8 字节流的分块读取,适用于文件导入或自己维护的字节流适配器。传输块边界不保证与字符边界重合。若上游实际使用 GB18030,必须先按协议确定编码,增量解码不能修复编码选择错误。

先复现,不急着放宽错误策略

raw = "甲乙".encode("utf-8")
chunks = [raw[:4], raw[4:]]
print("每块字节数:", [len(chunk) for chunk in chunks])
try:
    print("".join(chunk.decode("utf-8") for chunk in chunks))
except UnicodeDecodeError as exc:
    print("严格解码失败:", exc.reason)
print("忽略错误后的结果:", "".join(
    chunk.decode("utf-8", errors="ignore") for chunk in chunks
))

输出中块长度为 [4, 2],严格解码失败,忽略错误只剩“甲”。“乙”的三个字节被拆成一字节和两字节,两次独立解码都无法还原它。读取块大小改成 4096 只能改变故障概率;UTF-8 字符长度可变,不能靠固定块长保证边界。

排查时检查完整原始字节在严格模式下是否可解码,再比较不同分块方式。只在测试夹具中输出字节内容;生产日志记录 encoding、chunk_index、bytes_read 和错误原因即可,不记录原始敏感文本。异常位置通常相对于本次解码输入,增量模式还可能包含缓存字节,不要未经换算就当成文件绝对偏移。

修复:每条流保留一个解码器

把下面代码保存为 stream_decode.py。仅依赖标准库,适用于 Python 3.10 及以上。

import codecs
from collections.abc import Iterable, Iterator


def iter_utf8_text(chunks: Iterable[bytes]) -> Iterator[str]:
    """在同一条字节流内保留跨块字符,并在流结束时检查残留字节。"""
    decoder = codecs.getincrementaldecoder("utf-8")(errors="strict")
    for chunk in chunks:
        if not isinstance(chunk, bytes):
            raise TypeError("字节流的每个分块必须是 bytes")
        text = decoder.decode(chunk, final=False)
        if text:
            yield text
    tail = decoder.decode(b"", final=True)
    if tail:
        yield tail

解码器在循环外创建,不完整字符会留在其内部,后续字节到达再继续解码。空块合法,不代表这条流结束;只有迭代器正常耗尽,才执行 final=True。结束时若还有不完整字符,严格模式抛出异常,防止把截断误认作成功。

这些行为依据 Python 官方 codecs 文档。标准库也提供 codecs.iterdecode(chunks, "utf-8", errors="strict");这里显式写出生命周期,便于说明结束检查和输入合同。

普通文件无需自行维护分块解码,通常直接使用 open(path, encoding="utf-8", errors="strict") 的文本接口。只有调用方已经拿到字节块时,才需要上述适配器。解码器不能跨文件共享,也不能让多个线程或任务交错喂入不同流的数据。

回归测试:不能只测完整中文块

将以下内容保存为同目录的 test_stream_decode.py,执行 python -m unittest -v test_stream_decode。

import unittest

from stream_decode import iter_utf8_text


class StreamDecodeTests(unittest.TestCase):
    """检查块边界、输入合同和流结束的错误传播。"""

    def test_every_split(self) -> None:
        text = "A甲乙\U0001f680\r\n尾"
        raw = text.encode("utf-8")
        for split in range(len(raw) + 1):
            with self.subTest(split=split):
                self.assertEqual(
                    "".join(iter_utf8_text([raw[:split], raw[split:]])), text
                )

    def test_one_byte_chunks(self) -> None:
        text = "中文\U0001f680"
        chunks = (bytes([value]) for value in text.encode("utf-8"))
        self.assertEqual("".join(iter_utf8_text(chunks)), text)

    def test_empty_input(self) -> None:
        self.assertEqual(list(iter_utf8_text([])), [])
        self.assertEqual(list(iter_utf8_text([b"", b""])), [])

    def test_truncated_end(self) -> None:
        with self.assertRaises(UnicodeDecodeError):
            list(iter_utf8_text(["甲".encode("utf-8")[:2]]))

    def test_invalid_bytes(self) -> None:
        with self.assertRaises(UnicodeDecodeError):
            list(iter_utf8_text([b"ok", b"\xff"]))

    def test_invalid_chunk_type(self) -> None:
        with self.assertRaisesRegex(TypeError, "bytes"):
            list(iter_utf8_text(["中文"]))

    def test_streams_are_independent(self) -> None:
        with self.assertRaises(UnicodeDecodeError):
            list(iter_utf8_text([b"\xe7"]))
        self.assertEqual("".join(iter_utf8_text([b"ok"])), "ok")


if __name__ == "__main__":
    unittest.main()

第一项遍历每个双块切分位置,覆盖二、三、四字节字符以及换行。单字节块用来防止实现偶然依赖较大块长。最后一项验证失败流的状态不会污染下一次调用。

解码正确不等于导入原子提交

这个函数是惰性迭代器,前面合法文本可能已经产出,后面才遇到非法字节或末尾截断。如果消费方提前退出,也不会运行结束检查。必须完整消费且未报错,才能把整条流标记为解码成功。

因此,导入任务不要每收到一个文本片段就宣布成功。需要整批原子性时,先写入受限的暂存区,完整消费并校验业务记录后再提交;失败则清理暂存。不要为验证完整性把超大文件全部拼进内存。上游读取异常应向外传播,异常中断不能冒充正常 EOF。

解码器输出的片段也不保证是一行、一个 JSON 对象或一条业务记录。分行与协议解析应放在解码之后,并对记录长度设置独立限制。本文没有实现记录解析、事务暂存、压缩解包或断点续传;有 BOM 时也应由文件合同明确是否接受,不能无条件删除文本首字符。

发布前验证与落地检查

本文示例在 Python 3.14.6 中执行,七项 unittest 全部通过。将增量解码故意退回逐块独立解码后,跨块用例出现 10 个预期的解码执行错误;删除结束刷新后,两项测试因未抛出预期异常而失败。恢复正确实现后七项重新通过。编码、代码语法和围栏检查通过;本地未安装 Ruff,未执行其格式和静态检查,也未安装额外依赖。本地检查不能代替真实文件源、网络断流、解压链路和生产吞吐量验证。

上线前保留包含中文、四字节字符、尾部截断和非法字节的匿名夹具,对照原始字节合同验收。观察解码失败数和导入失败状态,避免用 ignore 或 replace 掩盖数据损坏。回滚可切回旧版本并暂停异常批次,保留受控原始文件供重放,不能把已经丢失字符的结果继续作为正确数据传播。