Python技术迷

又双叒叕踩坑了!还在用 Python 等号 (=)

凌晨补数脚本又把我拽起来了。 日志看着挺邪门:重试队列明明只剩 23 条,落盘备份文件里也跟着变成 23 条了。备份本来是为了保留“原始失败数据”的,结果原始数据跟着业务处理一起变了。这种现象我第一眼就不太信接口,先看本地对象是不是被串了。

最后真不是接口,也不是序列化,锅就出在一个最普通的 =。

很多人写 Python,看到这行代码不会皱一下眉头:

defprepare_retry_payload(task):
    raw_items = task["items"]
    backup_items = raw_items
    retry_items = raw_items

    retry_items[:] = [x for x in retry_items if x["retry"] < 3]
    retry_items.sort(key=lambda x: x["retry"])

return backup_items, retry_items

这段代码表面意思很朴素:backup_items 备份一份,retry_items 处理一份。 但实际上根本没备份,三个名字指向的是同一份列表。你后面一过滤、一排序,backup_items 也一起变了。

线上最坑人的是,它不一定立刻炸。它会先“看起来能跑”,过几轮重试以后,审计数据、补偿数据、原始快照全搅一锅。到那时候你再翻日志,已经很烦了。

这种事最简单的确认方式,不是猜,是直接打 id():

defdebug_ref(task):
    raw_items = task["items"]
    backup_items = raw_items
    retry_items = raw_items

    print("raw   :", id(raw_items))
    print("backup:", id(backup_items))
    print("retry :", id(retry_items))

如果三个 id 一样,就别自欺欺人了,压根不是“三份数据”,只是“三个变量名”。

Python 里的 =,很多时候不是“复制值”,而是“把名字贴到对象上”。 这句话平时看着像废话,一上 list、dict、set 这种可变对象,马上就变事故。

我见过最典型的一种写法,是批量清洗数据时顺手这么来一下:

record = {"order_no": "A1024", "tags": ["new", "vip"]}

audit_record = record
send_record = record

send_record["tags"].append("retry")
send_record["status"] = "PROCESSING"

print(audit_record)
# {'order_no': 'A1024', 'tags': ['new', 'vip', 'retry'], 'status': 'PROCESSING'}

很多人看到这里才反应过来: “我没改 audit_record 啊,它怎么脏了?”

因为你改的是对象,不是变量名。

那怎么改?别一上来背八股,先看你改到哪一层。

如果只是想把最外层容器拆开,用浅拷贝就够了:

defprepare_retry_payload(task):
    raw_items = task["items"]
    backup_items = raw_items.copy()
    retry_items = raw_items.copy()

    retry_items = [x for x in retry_items if x["retry"] < 3]
    retry_items.sort(key=lambda x: x["retry"])

return backup_items, retry_items

但这地方我一般还会多留个心眼。 如果列表里面放的是字典,或者字典里面还套列表,copy() 只断开最外层,里面那层还是共用的。你改深一点,照样串。

像这种结构:

import copy

task = {
"batch_no": "B20260418",
"items": [
        {"order_no": "A1", "ext": {"retry": 1, "trace": ["first"]}}
    ]
}

safe_task = copy.deepcopy(task)
safe_task["items"][0]["ext"]["trace"].append("second")

print(task["items"][0]["ext"]["trace"])      # ['first']
print(safe_task["items"][0]["ext"]["trace"]) # ['first', 'second']

涉及嵌套对象,我更愿意直接 deepcopy,别在那赌“应该只改外层”。线上排障的时候,“应该”这两个字最不值钱。

不过我现在其实更偏向第三种写法: 少做“先赋值再修改”,直接构造新对象。这样看代码的人也不费劲。

defbuild_retry_task(task):
return {
"batch_no": task["batch_no"],
"items": [
            {
"order_no": item["order_no"],
"retry": item["retry"] + 1,
"trace_id": item["trace_id"],
            }
for item in task["items"]
if item["retry"] < 3
        ]
    }

这类写法有个好处,边界很清楚。 谁是输入,谁是输出,哪些字段保留,哪些字段重算,一眼就能看明白。后面真出问题,也更容易顺着日志往回捋。

所以这个坑的重点不是“记住 = 不是复制”这么一句口诀。 真正要记住的是:你手里这份数据,后面到底还会不会被别人拿去用。

会,就别偷懒。 该浅拷贝浅拷贝,该深拷贝深拷贝。 再不行就老老实实造新对象。

很多 Python 代码表面写得很短,很优雅,真到线上未必扛揍。= 这种最基础的东西,反而最容易让人放松警惕。等你看到“备份数据怎么也变了”“日志里的入参和落盘文件对不上”这种现象时,先别急着怀疑框架,先看看是不是自己把同一个对象用了三遍。