
有这样一个场景:上周三晚上11点,我正准备关电脑睡觉,手机突然炸了——群里连环@,线上接口大面积超时,用户投诉堆了上百条。
排查了40分钟,最后定位到的原因让所有人沉默了:
# 上线前
result = cache.get(key)
# 某人"优化"后
result = cache.get(key) orquery_db(key)就这一行。or 替换了一个 if 判断,缓存直接被击穿。流量一上来,数据库连接池瞬间打满,整个服务雪崩。
事后复盘,大家面面相觑。这种坑,说起来谁都知道,但到了写代码的时候,手比脑子快。
今天就来聊聊Python生产环境里那些看着不起眼、分分钟能搞垮系统的小细节。都是真金白银买来的教训。
or 和 if,一行代码的缓存击穿上面那段代码就是真事。同事当时的想法很自然:缓存没拿到就查库,一行搞定,多简洁。
但 or 的语义不是“如果为None”,而是“如果为假”。
# 看起来等价,实际天差地别
result = cache.get(key) orquery_db(key)
# 这才是正确的
result = cache.get(key)
ifresultisNone:
result = query_db(key)Python里falsy值可不止 None——0、""、[]、False,统统被 or 当作“没取到”。而这些都是完全合法的缓存值。用户余额为0?缓存击穿。查询结果为空列表?缓存击穿。一个QPS上万的接口,哪怕只有1%的请求命中这些falsy值,每秒也有上百次穿透到数据库。
正确的做法要么用 is None 精确判断,要么用Redis的 exists 先判断key是否存在:
# 方案1:is None
result = cache.get(key)
ifresultisNone:
result = query_db(key)
# 方案2:exists判断
ifnotcache.exists(key):
result = query_db(key)
cache.set(key, result, ttl=3600)
else:
result = cache.get(key)这点我后来在团队里反复强调:缓存判断一律用 is None,or 图省事是要付出代价的。
另一个坑,也是Python老生常谈了,但年年有人踩。
有同事写了这么个函数:
defformat_log(message, trace_ids=[]):
trace_ids.append(get_current_trace_id())
returnf"[{'|'.join(trace_ids)}] {message}"第一次调用,[abc123] user login,没问题。第二次,[abc123|def456] user logout,怎么回事?到了第十次,一条日志里塞了十个trace_id,直接变面条了。
原因很简单:Python的默认参数在函数定义时就创建了,不会每次调用重新创建。所以这个 [] 是所有调用共享的同一个列表对象——你往里append,它就一直攒着。
改法也不复杂,用 None 做默认值就行:
defformat_log(message, trace_ids=None):
iftrace_idsisNone:
trace_ids = []
trace_ids.append(get_current_trace_id())
returnf"[{'|'.join(trace_ids)}] {message}"说实话这个坑我第一次学Python的时候就见过,但真到了赶进度写代码的时候,肌肉记忆一上来,trace_ids=[] 手就打出去了。后来我们team的code review第一条规矩就是:扫函数签名,凡是看到可变默认参数的,一律打回。
except: pass——最安静的炸弹这个事故我印象最深。
一个消费Kafka的服务,线上偶尔“吞消息”——消费了但没处理,也不报错。运营那边说数据对不上,查了两天日志,啥线索都没有。最后扒代码才发现:
try:
data = json.loads(message)
except:
pass # "先不管,后面再说"一个光秃秃的 except: pass。消息格式不对?吞了。编码错误?吞了。网络抖动?也吞了。Kafka那边消费offset正常推进,业务那边什么也没执行。更绝的是,因为没有任何日志,出了问题你连排查方向都没有。
而且 except: 不带类型,连 KeyboardInterrupt 和 SystemExit 都能捕获,Ctrl+C都按不出去。
老老实实写:
importjson
importlogging
logger = logging.getLogger(__name__)
try:
data = json.loads(message)
exceptjson.JSONDecodeErrorase:
logger.error(f"消息格式异常, raw={message[:200]}, error={e}")
dead_letter_queue.send(message) # 进死信队列,别丢了
return
exceptExceptionase:
logger.exception(f"消息处理未知异常: {e}")
raise # 让它重试,别吞except: pass 是生产环境的头号反模式,没有之一。吞异常就是埋地雷,你不知道它什么时候炸,只知道它一定会炸。
这个bug藏得很深。
有个全局配置字典,多个线程同时读写。线上跑了两个月,突然有用户投诉看到了别人的数据。不是所有用户,只是偶发。本地怎么都复现不了。
# 全局配置,所有请求共享
config = {}
defhandle_request(request):
config["user_id"] = request.user_id
config["timeout"] = request.timeout
# ... 中间还有别的逻辑 ...
result = call_downstream(config["user_id"], timeout=config["timeout"])
returnresult问题在于:线程A刚把 user_id 设成自己的,还没走到 call_downstream,线程B就把它改了。A拿到的就是B的user_id。
有人可能会说Python有GIL啊——GIL只保证字节码级别的原子性,不保证你这几行业务逻辑的原子性。两行赋值之间,随时可能切线程。
最简单的解法:别用全局变量,直接传参。
defhandle_request(request):
result = call_downstream(
user_id=request.user_id,
timeout=request.timeout
)
returnresult如果实在需要线程间隔离的状态,用 threading.local():
importthreading
local_config = threading.local()
defhandle_request(request):
local_config.user_id = request.user_id
local_config.timeout = request.timeout
result = call_downstream(local_config.user_id, timeout=local_config.timeout)
returnresult全局可变状态加上并发,出bug只是时间问题。能传参就传参,实在不行就 threading.local() 或者加锁。
datetime.now() 的8小时时差这个坑属于“本地跑得好好的,一上线就翻车”的典型。
一个订单系统的日终结算,本地和测试环境都没问题,上生产后结算日期老是差一天。查了半天,发现服务器部署在海外,系统时区是UTC:
fromdatetimeimportdatetime
now = datetime.now() # 拿到的是UTC时间
today = now.date() # UTC的"今天"比北京时间少了8小时datetime.now() 返回的是naive datetime——没有时区信息,值完全取决于运行机器的时区设置。开发机是北京时间,测试机也是北京时间,生产机是UTC。8小时的时差,让凌晨0点到8点之间的结算全算到了“昨天”。
改起来不难,但要养成习惯:
fromdatetimeimportdatetime, timezone, timedelta
# 统一用UTC,需要显示时再转换
now = datetime.now(timezone.utc)
# 转北京时间
UTC_8 = timezone(timedelta(hours=8))
beijing_now = now.astimezone(UTC_8)Python 3.9以上还可以用 zoneinfo,更规范:
fromdatetimeimportdatetime
fromzoneinfoimportZoneInfo
beijing_now = datetime.now(ZoneInfo("Asia/Shanghai"))一句话:涉及时间的代码,一律带时区。datetime.now() 在生产环境里就是裸奔。
这个事故发生在日志分析脚本上。脚本处理一批日志文件,跑了几个小时后直接被OOM Killer干掉了。
forfilenameinlog_files:
f = open(filename, 'r')
content = f.read()
process(content)
# 忘了 f.close()短脚本跑完进程结束,文件描述符自然释放,问题不大。但长驻服务里,文件描述符和内存只增不减,最终触发OOM。
更糟的是大文件 read() 一次性加载到内存——如果同时处理多个文件,每个都hold住全部内容,内存直接起飞。
# 用 with,自动关闭
forfilenameinlog_files:
withopen(filename, 'r') asf:
content = f.read()
process(content) # 文件已关闭,内存可回收
# 大文件逐行读,别一次全加载
forfilenameinlog_files:
withopen(filename, 'r') asf:
forlineinf:
process(line)with open() 应该是肌肉记忆,不管文件多小、脚本多短,一律用 with。这不是风格问题,是生存问题。
踩完这些坑之后,我在团队里立了几条规矩,没什么高深的东西,就是防止同样的坑再踩一遍:
is None,别用 or。or 会把0、空串、空列表都当缓存未命中,高并发下就是击穿。None。def foo(x=[]) 这种写法,review直接打回。except: pass。至少记个日志,能精确捕获就精确捕获,能不吞就不吞。threading.local() 或者加锁。with。datetime.now(timezone.utc) 和 with open() 应该是默认写法,不是“想起来才加”。说到底,上面这些每一个坑,代码改动都是“合理的”——更简洁、更优雅、更省事。但生产环境不是写demo,你的代码跑在真实的流量、真实的并发、真实的异常之下。“能跑”和“跑得稳”之间,隔着无数个凌晨的告警短信。
这些坑不是Python的bug,是语言特性在特定场景下的反噬。了解它们、记住它们,比学任何框架都有用。
毕竟每一行看似无害的代码背后,都可能藏着一个凌晨3点被叫醒的人。
希望那个人不是你。
“无他,惟手熟尔”!有需要的用起来!
本文分享自 Nicholas与Pypi 微信公众号,前往查看
如有侵权,请联系 cloudcommunity@tencent.com 删除。
本文参与 腾讯云自媒体同步曝光计划 ,欢迎热爱写作的你一起参与!