一个让我背了黑锅的线上事故
先讲一件让我至今心有余悸的事。
前年我在维护一个订单处理系统,核心逻辑是给一批订单打标签。每个订单进来,系统会根据规则生成一个标签列表,然后存到数据库里。
代码大概长这样:
def apply_tags(order_id, tags=[]):
# 根据订单信息生成标签
if order_id.startswith("VIP"):
tags.append("VIP用户")
if order_id.startswith("大额"):
tags.append("大额订单")
# ... 更多规则
return tags
这个函数的设计很直接——你传一个订单号进来,它返回对应的标签列表。tags 参数是可选的,方便外部传入已有的标签列表进行追加。
看起来没毛病对吧?
但是有一天,运营反馈说:"为什么有些普通订单莫名其妙被打上了'VIP用户'标签?"
我查了数据库,发现确实是出现了标签错乱。但我的逻辑很严谨啊,只有订单号以 "VIP" 开头的才会打 VIP 标签,怎么普通订单也中招了?
查了半天日志,我发现一个诡异的现象:同一个 Python 进程处理的不同订单,标签列表会互相累积。 一个 VIP 订单处理完后,它产生的标签会出现在下一个普通订单的结果里。
我盯着代码看了整整一个小时,最后终于发现了问题所在:
def apply_tags(order_id, tags=[]): # 这个 [] 在函数定义时只创建一次!
Python 在定义函数的时候,tags=[] 这个空列表就已经被创建好了。之后每次调用这个函数,如果不传 tags 参数,用的都是 同一个列表对象。
所以当第一个 VIP 订单调用了 apply_tags("VIP001"),tags 列表里有了 ["VIP用户"]。第二个普通订单调用 apply_tags("普通001") 时,没有传 tags 参数,用的还是刚才那个列表,里面已经有一个元素了,于是变成了 ["VIP用户", "普通订单"]。
普通订单就此背上了"VIP用户"的标签。
那天我删掉了线上几万条错误标签,然后记住了这个教训:Python 函数的默认参数,在定义时只计算一次,然后永远复用。
默认参数的计算时机:只发生在 def 那一刻
这个行为是 Python 和其他语言很不一样的地方。
在很多语言里,函数的默认参数是在每次调用时重新计算的。但 Python 不是这样——默认参数的值在函数定义时就确定了,之后再也不变了。
看一个更直观的例子:
import datetime
def log_message(msg, timestamp=datetime.datetime.now()):
print(f"[{timestamp}] {msg}")
log_message("第一条日志") # 输出 [2024-01-01 10:00:00] 第一条日志
time.sleep(5)
log_message("第二条日志") # 输出 [2024-01-01 10:00:00] 第二条日志
两条日志的时间戳一模一样,因为 datetime.datetime.now() 只在函数定义时执行了一次。
如果你希望每次调用都用当前时间,必须这样写:
def log_message(msg, timestamp=None):
if timestamp is None:
timestamp = datetime.datetime.now()
print(f"[{timestamp}] {msg}")
这个 None 加判断的写法,是 Python 社区处理"每次调用重新计算默认值"的标准模式。
哪些类型是"可变对象",为什么它们这么危险?
要理解这个坑的严重性,先搞清楚什么是"可变对象"。
Python 里的数据分为两大类:
不可变对象:数字、字符串、元组、布尔值、None
- 修改它们会创建新对象,原对象不变
- 作为默认参数,你改不了它,所以安全
可变对象:列表、字典、集合、自定义类的实例
- 修改它们会改变原对象本身
- 作为默认参数,你改了它,下次调用还是改过的状态
# 安全:数字是不可变的
def add_one(x, increment=1):
return x + increment # increment 永远不会变
# 危险:列表是可变的
def add_item(item, items=[]):
items.append(item) # 每次调用都在修改同一个列表
return items
print(add_item("a")) # ['a']
print(add_item("b")) # ['a', 'b'] ← 出问题了
不可变对象作为默认值,不管你调用多少次,它都是原来的值。可变对象就不一样了——每次调用都有可能修改它,而修改会累积。
还有哪些地方藏着同样的坑?
其实不止函数参数,Python 里还有好几个地方也有同样的问题。
类属性里的可变对象:
class Order:
tags = [] # 这个列表属于类,不属于实例
def add_tag(self, tag):
self.tags.append(tag)
o1 = Order()
o2 = Order()
o1.add_tag("VIP")
print(o2.tags) # ['VIP'] ← o2 无辜中枪
类属性是所有实例共享的。如果你在类属性里放列表、字典这些可变对象,所有实例都会共享同一份数据。
正确的做法是放在 __init__ 里,每个实例独立创建:
class Order:
def __init__(self):
self.tags = [] # 每个实例都有自己的列表
闭包里捕获的变量:
funcs = []
for i in range(3):
funcs.append(lambda: i) # 捕获的是变量 i,不是值 i
for f in funcs:
print(f()) # 输出 2, 2, 2,不是 0, 1, 2
这个坑的机制不太一样,但本质类似——你以为是"当时的值",实际是"同一个变量"。
标准解决方案:None + 判断
Python 社区有一个约定俗成的写法,专门解决可变对象作默认值的问题:
# 错误写法
def add_item(item, items=[]):
items.append(item)
return items
# 正确写法
def add_item(item, items=None):
if items is None:
items = []
items.append(item)
return items
核心思路:默认参数永远用 None,在函数体里判断并创建可变对象。
这个模式适用于所有可变对象:
# 列表
def process(items=None):
if items is None:
items = []
# 字典
def process(data=None):
if data is None:
data = {}
# 集合
def process(ids=None):
if ids is None:
ids = set()
如果你觉得每次都写 if items is None 太啰嗦,可以这样写一行:
def add_item(item, items=None):
items = items or [] # 但要注意:如果 items 是空列表或者空字典,也会被替换
items.append(item)
return items
不过 items = items or [] 有个小问题——如果传入的是空列表 [],它会被当作"假值",然后被替换成一个新的空列表。大多数情况下这不算问题,但如果你确实需要区分"传入了空列表"和"没传参数",用 if items is None 更准确。
有没有例外?什么时候可以用可变默认值?
有的。
缓存场景是一个经典的例外。有时候你故意用可变默认值来做缓存:
def get_user(user_id, cache={}):
if user_id not in cache:
cache[user_id] = query_database(user_id)
return cache[user_id]
每次调用 get_user,如果同一个 user_id 再次出现,直接从缓存返回,不用重复查数据库。
但即使是这种场景,也要小心——如果缓存无限增长,会导致内存泄露。通常在这种场景下,用类或者专门的缓存库(比如 functools.lru_cache)更靠谱:
from functools import lru_cache
@lru_cache(maxsize=128)
def get_user(user_id):
return query_database(user_id)
lru_cache 不仅帮你做了缓存,还限制了缓存大小,更安全。
我怎么养成的习惯
那次线上事故之后,我给自己定了一个规则:所有默认参数,只要值是可变的,一律用 None。
这个规则写起来多几个字符,但能避免 100% 的"默认参数污染"问题。
而且我的 IDE(PyCharm)会直接给 def f(items=[]) 这种写法画黄线警告,提醒我"默认参数是可变的,有风险"。
现在我的手指已经形成肌肉记忆了——打完函数参数,如果是列表、字典、集合,自动写成 =None,然后在函数体里加一个判断。
回到那个订单标签的例子,正确的写法是:
def apply_tags(order_id, tags=None):
if tags is None:
tags = []
if order_id.startswith("VIP"):
tags.append("VIP用户")
if order_id.startswith("大额"):
tags.append("大额订单")
return tags
这样每次调用都是全新的列表,互不干扰。
写在最后
那个删除线上错误标签的下午,我一共删了将近五万条数据。每一行 DELETE 语句执行的时候,我都在心里骂自己一遍——为什么当初写代码的时候不多想一步?
Python 的这个设计,严格来说不算 Bug——它是为了性能做的优化。函数定义时只计算一次默认值,能节省每次调用时重新创建对象的时间开销。在 CPython 的实现里,默认参数是存储在函数对象上的,每次调用直接从函数对象里取,效率很高。
但"高性能"带来的副作用就是"容易被误用"。尤其对于从其他语言转过来的开发者,默认参数每次调用重新计算才是习惯的认知,Python 这个"定义时计算一次"完全是意料之外。
我现在每次写函数默认参数,都会问自己三遍:
- 这个默认值是可变的吗?
- 我是不是需要每次调用都新建一个?
- 用
None安全还是直接用值安全?
问完这三遍,再落笔。
希望你不用像我一样,靠删五万条数据来记住这个教训。