又双叒叕踩坑了!还在用 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 copytask = {
"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 代码表面写得很短,很优雅,真到线上未必扛揍。= 这种最基础的东西,反而最容易让人放松警惕。等你看到“备份数据怎么也变了”“日志里的入参和落盘文件对不上”这种现象时,先别急着怀疑框架,先看看是不是自己把同一个对象用了三遍。