
有这样一个场景:上周三晚上11点,我正准备关电脑睡觉,手机突然炸了——群里连环@,线上接口大面积超时,用户投诉堆了上百条。
排查了40分钟,最后定位到的原因让所有人沉默了:
# 上线前result = cache.get(key)# 某人"优化"后result = cache.get(key) orquery_db(key)
就这一行。or 替换了一个 if 判断,缓存直接被击穿。流量一上来,数据库连接池瞬间打满,整个服务雪崩。
事后复盘,大家面面相觑。这种坑,说起来谁都知道,但到了写代码的时候,手比脑子快。
今天就来聊聊Python生产环境里那些看着不起眼、分分钟能搞垮系统的小细节。都是真金白银买来的教训。
往期阅读>>>
Python 自动化管理Jenkins的15个实用脚本,提升效率
App2Docker:如何无需编写Dockerfile也可以创建容器镜像
Python 自动化识别Nginx配置并导出为excel文件,提升Nginx管理效率
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 Noneresult = 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都按不出去。
老老实实写:
importjsonimportlogginglogger = logging.getLogger(__name__)try:data = json.loads(message)exceptjson.JSONDecodeErrorase:logger.error(f"消息格式异常, raw={message[:200]}, error={e}")dead_letter_queue.send(message) # 进死信队列,别丢了returnexceptExceptionase:logger.exception(f"消息处理未知异常: {e}")raise# 让它重试,别吞
except: pass 是生产环境的头号反模式,没有之一。吞异常就是埋地雷,你不知道它什么时候炸,只知道它一定会炸。
这个bug藏得很深。
有个全局配置字典,多个线程同时读写。线上跑了两个月,突然有用户投诉看到了别人的数据。不是所有用户,只是偶发。本地怎么都复现不了。
# 全局配置,所有请求共享config = {}defhandle_request(request):config["user_id"] = request.user_idconfig["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():
importthreadinglocal_config = threading.local()defhandle_request(request):local_config.user_id = request.user_idlocal_config.timeout = request.timeoutresult = call_downstream(local_config.user_id, timeout=local_config.timeout)returnresult
全局可变状态加上并发,出bug只是时间问题。能传参就传参,实在不行就 threading.local() 或者加锁。
datetime.now() 的8小时时差这个坑属于“本地跑得好好的,一上线就翻车”的典型。
一个订单系统的日终结算,本地和测试环境都没问题,上生产后结算日期老是差一天。查了半天,发现服务器部署在海外,系统时区是UTC:
fromdatetimeimportdatetimenow = 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,更规范:
fromdatetimeimportdatetimefromzoneinfoimportZoneInfobeijing_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点被叫醒的人。
希望那个人不是你。
https://ima.qq.com/wiki/?shareId=f2628818f0874da17b71ffa0e5e8408114e7dbad46f1745bbd1cc1365277631c
