别再拿for循环硬扛了:这段Python代码我只改一行,接口耗时从1.8秒掉到400毫秒
上周三晚上十点,监控群里弹出一条告警。订单列表接口平均耗时1800毫秒,超过阈值三倍。值班同事说用户已经在客服后台骂了半小时。我打开日志,找到一个循环。那个循环处理几千条订单数据,每条数据拼一个字符串。代码写得很普通,for循环加等号,一行一行往后加。测试环境数据少,跑起来没问题。线上流量一上来,这个循环就成了瓶颈。
我盯着那段代码看了五分钟。循环里做的是字符串拼接。Python的字符串不可变。每次加等号都会创建一个新字符串,再把旧内容拷贝过去。几千次循环下来,内存分配和拷贝的次数是平方级。数据量越大,耗时越长。1800毫秒里,大部分时间花在多余的拷贝上。业务逻辑本身不复杂,复杂的是写法。
改法很简单。把循环里的字符串加等号,改成先用列表收集每一段,循环结束后用join一次性拼接。只改一行。原来的循环体里那行累加删掉,换成往列表里追加。循环外面加一行join。严格说改了两行。核心思路就是一行。把逐次拼接换成一次拼接。 循环本身不慢,循环里反复创建对象才慢。 这句话我写在了团队文档里。
上线后我盯着监控看。耗时从1800毫秒掉到400毫秒。第一次看到数字的时候我以为看错了。刷新页面,还是400毫秒左右。测试同学跑过来问我是不是偷偷加了机器。我说没有,就改了一行代码。他不太信。我把改动截图发给他,他看了半天说这也行。行。Python的内置方法用好了,比手动循环快很多。
这件事之后我养成了一个习惯。每次看到接口慢,先不看数据库慢查询。先看代码里有没有for循环。循环里有没有重复计算,重复查询,重复创建对象。很多慢接口不是数据库扛不住,是代码在浪费CPU。 把循环里能移到外面的东西移出去,把逐条处理改成批量处理。 这两句话比加机器管用。加机器要钱,改代码几分钟就能搞定。
说回for循环。for循环不是坏东西。它直观,好读,适合处理简单逻辑。问题出在把重活塞进循环。字符串拼接,列表追加,数据库查询,这些操作放在循环里,数据量小的时候看不出毛病。数据量一上来,问题就暴露。线上环境和测试环境最大的差别不是机器配置,是数据量。测试环境跑十次没问题,线上跑一万次就出问题。
我后来把这个案例分享给组里新人。新人问我为什么不一开始就用join。我说写代码的时候只想着把功能跑通。功能跑通和性能达标是两件事。很多性能问题不是技术难题,是习惯问题。习惯用for循环硬扛,习惯等出了问题再改。改一行代码能解决的事,不要等到扩容那一天。 先让代码跑对,再让代码跑快。 顺序不能反。跑对之后要多看一眼循环。
现在这个接口已经稳定跑了两个月。耗时一直在400毫秒上下。用户不再抱怨加载慢。我也没再加机器。有时候我会想。那天晚上我没打开日志,直接申请扩容,问题也能解决。扩容之后呢。数据量再涨一倍,接口还会慢。改代码是一劳永逸的事。找到循环里那个重复动作,把它拿掉。就这么简单。
你也碰到过类似的事。不妨翻翻自己项目里的慢接口。看看日志里耗时最长的那个方法。看看里面有没有一个for循环。看看循环里有没有可以批量处理的操作。改一行,测一下。也许下一个从1800毫秒掉到400毫秒的接口,就是你改的。