← 中文资源

简体中文版本

Python — 类重构后反序列化失败

解决重命名类或移动模块后 pickle 报 AttributeError Can't get attribute 与 __reduce__ 错误,并用别名保持旧 pickle 可加载。

阅读英文原文

症状

  这个错误是把“路径查找失败”包装成“数据问题”:AttributeError: Can’t get attribute ‘Invoice’ on <module ‘app.billing’>。pickle 根本不存类定义,只存模块字符串、类名与实例状态。用 pickletools 反汇编旧工件,能看到它内嵌的每一个引用——GLOBAL 行列出模块与类名,那就是重构需要桥接的完整清单。故障总是跟随部署出现、从不跟随数据变化出现,这一条把它与数据损坏区分开。

诊断思路

  先读报错里的旧路径(模块加类名),映射到新位置即得所需别名;再用 pickletools.dis 枚举工件内嵌的全部引用,形成别名清单;最后查重构历史确认工件早于哪些重命名与移动。dataclass 字段增删会让旧 pickle 构造出状态与 init 不再匹配的对象,是第二类症状;模块改懒加载后名字在反序列化时刻不存在,是第三类。

根因

  pickle 按导入路径序列化对象:模块字符串加类名,再加上实例状态。重构重命名类、移动模块或改字段后,反序列化解析旧路径找不到目标即抛 AttributeError——字节完好无损,指向的命名空间却已消失。

修复与验证

  紧急桥接用模块级别名或重映射 Unpickler:受控面优先子类化 Unpickler 的 find_class,按 (module, name) 映射旧名字到新类,并对 app. 前缀之外的模块直接拒绝——反序列化不可信数据等于执行任意代码,自定义 Unpickler 必须带严格白名单。字段漂移用 setstate 显式兼容:setdefault 补新增字段、pop 掉已删除字段。桥接稳定后规划退出:读取时重写为 JSON/msgpack,不再为任何活得比部署久的对象产出新 pickle。验证时取重构前后的代表性工件过同一个加载器,断言字段级相等;再喂一个恶意 pickle(伪造不可信模块路径)确认加载器拒绝它。

代码示例

AttributeError: Can't get attribute 'Invoice' on
<module 'app.billing' from '/app/app/billing.py'>
python -m pickletools old.pkl | grep -A2 GLOBAL
#    0: \x8c GLOBAL      'app.billing' 'Invoice'
import sys, pickle
from app.models import Invoice  # new location

sys.modules['app.billing'] = type(sys)('app.billing')
sys.modules['app.billing'].Invoice = Invoice

obj = pickle.loads(blob)  # old path resolves to new class
class MigratingUnpickler(pickle.Unpickler):
    ALIASES = {("app.billing", "Invoice"): Invoice}

    def find_class(self, module, name):
        if (module, name) in self.ALIASES:
            return self.ALIASES[(module, name)]
        if module.startswith("app."):
            raise pickle.UnpicklingError(f"untrusted module: {module}")
        return super().find_class(module, name)
def __setstate__(self, state):
    state.setdefault("currency", "USD")   # added field
    state.pop("legacy_vat", None)        # removed field
    self.__dict__.update(state)