4. I/O优化:文件读写优化、网络请求优化、数据库查询优化

聊到自动化脚本的性能,I/O这块绝对是绕不开的坎儿。我见过太多脚本,CPU利用率不到10%,但跑起来慢得像蜗牛。为什么?因为大部分时间都花在等I/O上了。说白了,你的脚本不是在「算」,而是在「等」。

我个人习惯把I/O优化分成三大块:文件读写、网络请求、数据库查询。咱们一个一个来拆解。

4.1 文件读写优化

文件读写,听起来简单吧?但坑是真不少。我记得有一次帮团队优化一个日志分析脚本,原本要跑40分钟,优化后只要3分钟。你猜怎么着?就改了一行代码。

4.1.1 使用缓冲区

很多人写文件喜欢一行一行写:

# 不推荐:逐行写入,频繁I/O
with open('output.txt', 'w') as f:
    for item in data:
        f.write(item + '\n')

这样写,每写一行就要做一次系统调用。数据量大了,性能直接崩掉。

我建议改成批量写入:

# 推荐:批量写入,减少系统调用
with open('output.txt', 'w', buffering=65536) as f:  # 64KB缓冲区
    f.write('\n'.join(data))

这里的关键是 buffering 参数。默认是8KB,你可以根据文件大小调整。我一般设到64KB或128KB,效果不错。

小技巧: 如果你在写CSV或JSON,直接用 csv.writerjson.dump,它们内部已经做了缓冲优化。别自己造轮子。

4.1.2 大文件分块读取

处理大文件时,千万别一次性读到内存里。我曾经见过一个同事,用 readlines() 读一个5GB的日志文件,结果服务器直接OOM了。

正确的做法是分块读取:

# 推荐:分块读取,控制内存
def process_large_file(filepath, chunk_size=8192):
    with open(filepath, 'r') as f:
        while True:
            chunk = f.read(chunk_size)
            if not chunk:
                break
            # 处理chunk
            process(chunk)

或者用迭代器方式:

# 更Pythonic的方式
with open('large.log', 'r') as f:
    for line in f:  # 内部自动缓冲
        process(line)

嗯,这里要注意:for line in f 这种方式虽然方便,但如果你需要随机访问文件的不同位置,还是用 seek() 配合分块读取更灵活。

4.2 网络请求优化

网络I/O是另一个大头。自动化脚本经常要调API、拉数据,如果处理不好,一个请求等几秒,几百个请求下来,时间全浪费了。

4.2.1 连接池

你想想看,每次发HTTP请求都新建一个TCP连接,三次握手、四次挥手,光这个开销就够受的。连接池就是解决这个问题的。

我用 requests.Session 配合 urllib3 的连接池:

import requests
from requests.adapters import HTTPAdapter

session = requests.Session()
adapter = HTTPAdapter(
    pool_connections=20,    # 连接池大小
    pool_maxsize=100,       # 最大连接数
    max_retries=3,          # 重试次数
    pool_block=False        # 池满时是否阻塞
)
session.mount('http://', adapter)
session.mount('https://', adapter)

# 复用session发送请求
for url in url_list:
    response = session.get(url, timeout=5)
    # 处理响应
核心要点: 连接池不是越大越好。我一般根据目标服务器的并发能力来设置。比如对方限流100QPS,你设200个连接也没用,反而会触发限流导致重试。

4.2.2 批量操作

单个请求效率低,那就批量发。但批量不是简单地把请求堆在一起,要考虑并发控制。

我常用的方案是用 concurrent.futures 配合信号量:

from concurrent.futures import ThreadPoolExecutor, as_completed
import threading

semaphore = threading.Semaphore(10)  # 控制并发数

def fetch_url(url):
    with semaphore:
        return session.get(url, timeout=5)

with ThreadPoolExecutor(max_workers=20) as executor:
    futures = [executor.submit(fetch_url, url) for url in url_list]
    for future in as_completed(futures):
        result = future.result()
        # 处理结果

为什么用信号量?因为 ThreadPoolExecutormax_workers 控制的是线程数,但每个线程可能同时发多个请求(比如用了异步)。信号量能精确控制并发请求数。

避坑指南: 我曾经在优化一个爬虫脚本时,把并发数设到50,结果对方的WAF直接把我IP封了。后来我加了随机延时和重试机制,才稳定下来。记住:优化不是一味地快,要尊重别人的服务。

4.3 数据库查询优化

数据库I/O,说白了就是「查」和「写」的优化。我见过太多脚本,数据库查询慢不是因为数据量大,而是因为SQL写得烂。

4.3.1 索引优化

索引是数据库查询加速的利器。但索引不是越多越好,也不是随便建个索引就完事了。

我总结了几条经验:

  • 覆盖索引: 查询的字段都在索引里,避免回表查询
  • 复合索引: 把查询条件中最常用的字段放前面
  • 避免函数操作: 不要在索引字段上使用函数,比如 WHERE DATE(create_time) = '2024-01-01' 会让索引失效

举个例子:

-- 不推荐:索引失效
SELECT * FROM orders WHERE YEAR(create_time) = 2024;

-- 推荐:利用索引
SELECT * FROM orders 
WHERE create_time >= '2024-01-01' 
  AND create_time < '2025-01-01';

我记得有一次,一个报表脚本跑了一个小时。我一看,查询条件里用了 LIKE '%keyword%',全表扫描。改成全文索引后,10秒就出结果了。

4.3.2 批量提交

批量插入是数据库写入优化的核心。逐条插入不仅慢,还会产生大量事务日志。

我一般这样写:

import sqlite3

conn = sqlite3.connect('data.db')
cursor = conn.cursor()

# 批量插入
data = [(1, 'Alice'), (2, 'Bob'), (3, 'Charlie')]
cursor.executemany(
    'INSERT INTO users (id, name) VALUES (?, ?)',
    data
)
conn.commit()  # 一次性提交

对于MySQL,还可以用 LOAD DATA INFILE 或者批量INSERT语句:

# 批量INSERT
INSERT INTO users (id, name) VALUES 
(1, 'Alice'),
(2, 'Bob'),
(3, 'Charlie');
经验之谈: 批量提交的条数不是越多越好。我一般控制在500-1000条一次。太多会导致事务日志暴涨,太少又浪费网络往返。你可以根据实际情况测试一下,找到最优值。

4.3.3 连接池与事务管理

数据库连接池和HTTP连接池原理类似。我用 DBUtilsSQLAlchemy 的连接池:

from dbutils.pooled_db import PooledDB
import pymysql

pool = PooledDB(
    creator=pymysql,
    maxconnections=10,    # 最大连接数
    mincached=2,         # 最小空闲连接
    maxcached=5,         # 最大空闲连接
    blocking=True,       # 无连接时是否阻塞
    host='localhost',
    user='root',
    password='password',
    database='test'
)

# 从池中获取连接
conn = pool.connection()
cursor = conn.cursor()
cursor.execute('SELECT * FROM users')
conn.close()  # 不是真的关闭,而是归还到连接池

事务管理也很重要。我习惯用上下文管理器:

with conn.cursor() as cursor:
    conn.begin()  # 开启事务
    try:
        cursor.execute('UPDATE accounts SET balance = balance - 100 WHERE id = 1')
        cursor.execute('UPDATE accounts SET balance = balance + 100 WHERE id = 2')
        conn.commit()
    except Exception:
        conn.rollback()
        raise
总结一下: I/O优化的核心就四个字——「减少次数」。文件读写用缓冲,网络请求用连接池和批量,数据库查询用索引和批量提交。每次I/O操作都有固定开销,减少次数就是减少开销。

好了,这一章的内容就这些。下一章我们会聊「内存管理与垃圾回收」,到时候再细说Python的内存模型和优化技巧。