Python:性能优化入门,定位慢代码、减少无效对象和处理 I/O 瓶颈

Python 说到性能优化,最容易出现的误区不是“不会优化”,而是优化顺序一开始就错了。

最常见的情况通常是:

  • 脚本一慢,就开始盲目换写法
  • 一看到循环,就怀疑是不是 for 太慢
  • 一看到 Python,就默认问题一定在语言本身

但实际项目里,性能问题通常没那么抽象。更常见的是:

  • 文件读写次数太多
  • 同样的数据反复构造对象
  • 不必要的字符串拼接太频繁
  • 本该流式处理的数据一次性全读进内存
  • 真正慢的是 I/O,不是那几行 Python 运算

这一篇不按“优化技巧大全”来写,而是直接围绕一个实际脚本展开:做一个日志汇总慢查询脚本

这个脚本要完成这些事:

  1. 读取一批日志文件
  2. 提取每个接口的耗时
  3. 汇总慢请求数量
  4. 输出一份按接口分组的统计报告

用这个场景把性能优化入门最核心的三件事串起来:

  1. 先定位慢代码在哪里
  2. 再减少无效对象和重复工作
  3. 最后看 I/O 瓶颈怎么处理

一、这篇文章要解决什么问题

读完这一篇,应该能独立完成这些动作:

  • 用最小手段定位一段 Python 脚本慢在哪
  • 分清 CPU 计算慢和 I/O 读写慢
  • 识别几类典型低效写法,例如重复解析、无意义中间列表、大量字符串累加
  • 选择更合适的处理方式,例如流式读取、一次遍历、多用生成器

如果这些动作能独立做出来,性能优化就不会再是“看见慢就乱改”,而是开始有判断顺序。

二、先看这篇文章要完成的实际功能

假设现在有一批访问日志文件,每一行像这样:

1
2
3
4
5
GET /orders 120
GET /orders 980
POST /login 80
GET /orders 1500
GET /health 10

现在要做一个脚本,输出类似这样:

1
2
3
4
5
6
7
总日志数: 5
慢请求数: 2

接口统计:
GET /orders -> 3
POST /login -> 1
GET /health -> 1

并且把慢请求结果写进 slow_report.txt

这个需求不复杂,但已经足够把定位、对象分配、I/O 三块问题串起来。

三、先写一个最容易变慢的版本

很多脚本第一版通常会长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
from pathlib import Path


def build_report(log_dir):
all_lines = []
for file_path in Path(log_dir).glob("*.log"):
content = file_path.read_text(encoding="utf-8")
lines = content.splitlines()
for line in lines:
all_lines.append(line)

route_count = {}
slow_lines = []

for line in all_lines:
parts = line.split()
route = parts[0] + " " + parts[1]
cost = int(parts[2])

route_count[route] = route_count.get(route, 0) + 1

if cost >= 500:
slow_lines.append(line)

with open("slow_report.txt", "w", encoding="utf-8") as f:
for item in slow_lines:
f.write(item + "\n")

print("总日志数:", len(all_lines))
print("慢请求数:", len(slow_lines))
print(route_count)

这个版本当然能跑,但已经有几个很明显的性能风险:

  • 先把所有日志都堆进 all_lines
  • 每一行都做多次中间对象创建
  • 输出时逐条写文件

这些问题在小数据下不明显,一旦日志量上来就会开始放大。

四、性能优化第一步不是改代码,而是先定位

1. 先用最小时间统计确认哪段慢

先别上复杂 profiler,先给主要步骤加最小计时。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import time
from pathlib import Path


start = time.time()
all_lines = []
for file_path in Path("logs").glob("*.log"):
content = file_path.read_text(encoding="utf-8")
lines = content.splitlines()
for line in lines:
all_lines.append(line)
print("读取阶段耗时:", time.time() - start)

start = time.time()
route_count = {}
slow_lines = []
for line in all_lines:
parts = line.split()
route = parts[0] + " " + parts[1]
cost = int(parts[2])
route_count[route] = route_count.get(route, 0) + 1
if cost >= 500:
slow_lines.append(line)
print("统计阶段耗时:", time.time() - start)

输出可能类似这样:

1
2
读取阶段耗时: 1.82
统计阶段耗时: 0.37

这一步的意义非常大,因为它先回答一个最基本的问题:

  • 到底是读取慢
  • 还是处理逻辑慢

2. 一个实际错误:没定位就先改循环写法

第一步很容易先改成:

  • 列表推导式
  • map
  • join

但如果真正慢的是文件读写,这些改动通常没太大价值。

性能优化最怕的不是不会技巧,而是优化了错误的地方。

五、第二步看无效对象和重复工作

1. 先看看这段代码里有哪些重复对象创建

这段代码里至少有这些额外成本:

  • content.splitlines() 会先把整个文件拆成列表
  • all_lines.append() 又把所有行再存一次
  • line.split() 每次循环都创建新列表
  • parts[0] + " " + parts[1] 每次都创建新字符串

单看一条都不大,但量大时就会变成明显成本。

2. 实际优化方向:一边读,一边处理

更直接的写法通常是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
from pathlib import Path


def build_report(log_dir):
route_count = {}
slow_lines = []
total_lines = 0

for file_path in Path(log_dir).glob("*.log"):
with file_path.open("r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue

total_lines += 1
method, path, cost_text = line.split()
route = f"{method} {path}"
cost = int(cost_text)

route_count[route] = route_count.get(route, 0) + 1

if cost >= 500:
slow_lines.append(line)

print("总日志数:", total_lines)
print("慢请求数:", len(slow_lines))
print(route_count)

这次优化不是“技巧更高级”,而是减少了几件实际浪费:

  • 不再存整批 all_lines
  • 不再多做一次完整遍历
  • 不再提前把整个文件拆成列表

3. 一个实际错误:为了省内存把可复用结果也流掉了

有时候会把所有东西都写成生成器,结果后面还要重复消费,反而更乱。

所以优化不是“对象越少越好”,而是:

  • 该流式处理的流式处理
  • 该保留结果的保留结果

slow_lines 这种后面还要输出到文件的结果,保留列表就合理。

六、第三步再看 I/O 瓶颈

1. 逐文件整体读取和逐行读取,差别在哪

这两种写法看起来都能跑:

1
2
3
content = file_path.read_text(encoding="utf-8")
for line in content.splitlines():
...

和:

1
2
3
with file_path.open("r", encoding="utf-8") as f:
for line in f:
...

第二种的优势在于:

  • 不需要一次把整个文件都读进内存
  • 更适合大文件

2. 输出也有 I/O 成本

错误写法:

1
2
3
with open("slow_report.txt", "w", encoding="utf-8") as f:
for item in slow_lines:
f.write(item + "\n")

这当然可以,但如果数据很多,频繁写也会有额外成本。

更直接的写法通常是:

1
2
3
with open("slow_report.txt", "w", encoding="utf-8") as f:
f.write("\n".join(slow_lines))
f.write("\n")

3. 一个实际错误:把所有性能问题都归因到 Python 本身

如果日志文件本身就在机械盘、网络盘、远程挂载目录里,I/O 慢是很常见的。

这时候再怎么折腾 forlist,收益也可能有限。

所以性能优化里一个很基本的判断是:

  • 问题在代码结构
  • 还是在外部读写环境

七、把定位、对象优化和 I/O 优化真正串起来

现在把前面的内容整理成一个更稳的版本。

slow_report.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import time
from pathlib import Path


def build_report(log_dir, output_file):
start = time.time()

route_count = {}
slow_lines = []
total_lines = 0

for file_path in Path(log_dir).glob("*.log"):
with file_path.open("r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue

total_lines += 1
method, path, cost_text = line.split()
route = f"{method} {path}"
cost = int(cost_text)

route_count[route] = route_count.get(route, 0) + 1

if cost >= 500:
slow_lines.append(line)

with open(output_file, "w", encoding="utf-8") as f:
if slow_lines:
f.write("\n".join(slow_lines))
f.write("\n")

cost_time = time.time() - start

print("总日志数:", total_lines)
print("慢请求数:", len(slow_lines))
print("总耗时:", round(cost_time, 4))
print("\n接口统计:")
for route, count in route_count.items():
print(f"{route} -> {count}")

执行:

1
python slow_report.py

输出可能类似这样:

1
2
3
4
5
6
7
8
总日志数: 5
慢请求数: 2
总耗时: 0.0043

接口统计:
GET /orders -> 3
POST /login -> 1
GET /health -> 1

八、再补一层:什么时候才值得上 profiler

如果最小时间统计已经告诉你:

  • 某段逻辑明显慢
  • 而且脚本也开始变大

这时候再考虑 cProfile 就更合适。

例如:

1
python -m cProfile slow_report.py

这个命令的价值不是让你一上来就读懂所有统计表,而是:

  • 当简单计时已经不够细时
  • 再进一步定位函数级别热点

一个实际错误

有时一上来就直接跑 profiler,但连主流程分几步都没拆开。

这样就算拿到数据,也很难解释。

所以更顺的顺序通常是:

  1. 先拆阶段
  2. 再做最小计时
  3. 最后才上 profiler

九、一个实际排错场景

这类脚本里一个非常常见的实际问题是:数据量一大,脚本不是单纯变慢,而是内存明显上涨。

这时排查顺序通常很直接:

  1. 先看是不是把整批文件内容都读进了列表
  2. 再看是不是构造了多层中间列表
  3. 再看结果里哪些数据其实不需要长期保留

如果最后发现类似:

1
2
3
4
5
6
all_lines = []
for file_path in ...:
content = file_path.read_text(...)
lines = content.splitlines()
for line in lines:
all_lines.append(line)

那根因通常就不是某一行 if 慢,而是数据保留方式本身太重了。

十、一个实际练习

可以直接把这一篇变成一个完整练习。

练习目标:做一个“接口日志慢请求分析脚本”。

要求:

  1. 读取多个日志文件
  2. 统计总请求数和慢请求数
  3. 输出每个接口的请求次数
  4. 把慢请求写入报告文件
  5. 至少比较一次“整批读入”和“逐行读取”的差异
  6. 至少补一个实际错误场景,例如整批读入导致内存涨得明显

如果这个练习能独立做完,说明性能优化入门这一层已经开始真正掌握。

十一、这篇文章学完以后,下一步应该补什么

如果这一篇已经能跟着做完,下一步最适合继续补的是:

  1. 数据结构与算法入门:列表、链表、栈、队列、哈希到底怎么理解
  2. 常见算法题的工程价值:双指针、滑动窗口、递归、回溯、动态规划怎么入门
  3. 更系统的测试和性能基准怎么继续接进项目

因为到这一步,已经开始不只是写脚本,而是在判断“这段代码为什么慢,应该先改哪一层”。

十二、结语

Python 性能优化最值得先建立的,不是技巧清单,而是判断顺序。

只要先按这条线走:

  • 先定位
  • 再减少无效对象和重复工作
  • 最后判断是不是 I/O 瓶颈

很多问题都会比盲目改写法更容易收敛。