瓶颈在哪里

ETL 性能优化最忌讳的事就是一上来到处调参数。没有找到真正的瓶颈之前,所有的优化都是瞎蒙。

先搞清楚一个基本事实:ETL 管道中最慢的环节决定了整体速度。这个瓶颈可能出现在抽取阶段(网络带宽限制)、转换阶段(CPU 算力不足)、或者加载阶段(数据库写入太慢)。

定位瓶颈的基本方法是用监控工具观察每个阶段的耗时和资源消耗。一个简单的做法是在管道中埋点记录时间:

import time
from contextlib import contextmanager


@contextmanager
def measure_stage(stage_name, logger=None):
    """记录每个阶段的耗时"""
    start = time.time()
    start_mem = get_memory_usage()
    try:
        yield
    finally:
        elapsed = time.time() - start
        end_mem = get_memory_usage()
        print(f"[{stage_name}] 耗时: {elapsed:.2f}s, "
              f"内存: {start_mem:.0f}MB → {end_mem:.0f}MB "
              f"(增量: {end_mem - start_mem:.0f}MB)")

在实际案例中,我曾见过一个团队花了两周优化 SQL 查询,把转换阶段从 2 小时降到 20 分钟,但整体 ETL 仍然跑了 3 小时——因为他们没发现瓶颈其实在加载阶段,数据库写入速度跟不上。

并行处理

并行是加速 ETL 最直接的手段。可以把一个大任务拆分成多个小任务同时执行。

数据分区并行

把数据按某个维度分成多个分区,每个分区独立处理。分区方式包括:

  • 按时间分区:每天的数据一个分区,多天的数据可以并行处理
  • 按范围分区:按 ID 范围拆分,比如 1-100 万、100 万-200 万
  • 按业务维度:按地区、产品类别等业务字段拆分
import concurrent.futures
import pandas as pd
from math import ceil


def process_partition(partition_id, start_id, end_id, source_config):
    """处理单个数据分区"""
    sql = f"""
    SELECT * FROM orders
    WHERE id BETWEEN {start_id} AND {end_id}
    """
    df = read_from_source(sql, source_config)
    df = transform(df)
    write_to_target(df, partition_id)
    return len(df)


def parallel_etl(total_records, partition_size=100000, max_workers=4):
    """并行 ETL 处理"""
    num_partitions = ceil(total_records / partition_size)
    
    with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
        futures = []
        for i in range(num_partitions):
            start_id = i * partition_size + 1
            end_id = min((i + 1) * partition_size, total_records)
            
            future = executor.submit(
                process_partition, i, start_id, end_id, source_config
            )
            futures.append(future)
        
        total_processed = 0
        for future in concurrent.futures.as_completed(futures):
            count = future.result()
            total_processed += count
            print(f"分区完成,已处理 {total_processed} 条")
    
    return total_processed

进程级并行

Python 的多线程由于 GIL 的限制,在 CPU 密集型任务上效果不好。对于数据转换这类计算密集型任务,应该使用多进程。

import multiprocessing as mp


def transform_chunk(chunk_df):
    """转换数据块(在子进程中执行)"""
    # 执行复杂计算
    chunk_df["total_with_tax"] = chunk_df["amount"] * 1.13
    chunk_df["category_group"] = chunk_df["category"].apply(categorize)
    chunk_df["delivery_days"] = (
        chunk_df["delivered_at"] - chunk_df["shipped_at"]
    ).dt.days
    return chunk_df


def parallel_transform(df, num_workers=None):
    """使用进程池进行并行转换"""
    if num_workers is None:
        num_workers = mp.cpu_count()
    
    # 分割 DataFrame
    chunks = np.array_split(df, num_workers * 2)
    
    with mp.Pool(num_workers) as pool:
        results = pool.map(transform_chunk, chunks)
    
    return pd.concat(results, ignore_index=True)


# 对比:串行 vs 并行
if __name__ == "__main__":
    import time
    
    # 生成模拟数据
    df = pd.DataFrame({"amount": range(1000000)})
    
    # 串行处理
    start = time.time()
    serial_result = transform_chunk(df)
    print(f"串行耗时: {time.time() - start:.2f}s")
    
    # 并行处理
    start = time.time()
    parallel_result = parallel_transform(df)
    print(f"并行耗时: {time.time() - start:.2f}s")

批量大小调优

批量大小(Batch Size)是 ETL 优化中最容易被忽视的参数。批量大小太小,网络往返次数太多,吞吐量上不去。批量大小太大,单批处理时间过长,内存压力大,失败后重试成本高。

批量大小不存在一个放之四海皆准的数值。需要通过实验找到最佳值。

import time
import matplotlib.pyplot as plt


def benchmark_batch_sizes(etl_func, batch_sizes, test_data):
    """测试不同批量大小的性能"""
    results = []
    
    for batch_size in batch_sizes:
        start = time.time()
        etl_func(test_data, batch_size)
        elapsed = time.time() - start
        
        throughput = len(test_data) / elapsed
        results.append({
            "batch_size": batch_size,
            "elapsed": elapsed,
            "throughput": throughput
        })
        print(f"批量大小 {batch_size}: {elapsed:.2f}s, 吞吐量 {throughput:.0f} 条/秒")
    
    return results


# 示例:数据库批量写入性能测试
def batch_insert(records, batch_size, db_config):
    """批量写入数据库"""
    conn = create_connection(db_config)
    for i in range(0, len(records), batch_size):
        batch = records[i:i + batch_size]
        sql = build_batch_insert_sql(batch)
        conn.execute(sql)
    conn.commit()


# 批量大小调优结果通常呈现倒 U 形曲线:
# 太小 → 网络开销大 → 慢
# 适中 → 吞吐量最高
# 太大 → 内存压力大 → 单笔事务时间长 → 慢

经验参考值(在大多数场景下):

  • 数据库写入:每批 500-5000 行
  • API 调用:每批 100-1000 条
  • 文件读写:每批 1 万-10 万行
  • 消息队列:取决于消息大小,从几百到几千条

数据库读写优化

批量化写入

不要逐行 INSERT,使用批量 INSERT 或多行 VALUES:

-- ❌ 不推荐:逐行插入
INSERT INTO orders VALUES (1, 'A', 100);
INSERT INTO orders VALUES (2, 'B', 200);
INSERT INTO orders VALUES (3, 'C', 300);

-- ✅ 推荐:批量插入
INSERT INTO orders (id, name, amount) VALUES
(1, 'A', 100),
(2, 'B', 200),
(3, 'C', 300);

合理使用索引

对于 ETL 中的临时表或中间表,不建议建太多索引——写入数据时维护索引本身也是一种开销。

策略是这样的:

  • 抽取阶段:源表的索引应该是已经存在的,不要为 ETL 额外加太多索引
  • 转换阶段:中间表尽量少用索引,尤其是频繁写入的表
  • 加载阶段:目标表在大批量加载前,可以先禁用索引,加载完再重建
-- 大批量加载前禁用索引
ALTER INDEX idx_orders_date DISABLE;

-- 执行批量插入
INSERT INTO dw.orders SELECT * FROM staging.orders;

-- 加载完成后重建索引
ALTER INDEX idx_orders_date REBUILD;

避免 N+1 查询

在转换阶段访问参考数据时,很容易写出 N+1 查询:

# ❌ N+1 查询:每次循环都查询一次数据库
for order in orders:
    user = db.query(f"SELECT * FROM users WHERE id = {order['user_id']}")
    order["user_name"] = user["name"]

# ✅ 批量查询:一次查出所有用户
user_ids = list(set(o["user_id"] for o in orders))
users = db.query(f"SELECT * FROM users WHERE id IN ({','.join(user_ids)})")
user_map = {u["id"]: u["name"] for u in users}

for order in orders:
    order["user_name"] = user_map.get(order["user_id"])

内存管理

Python 的 Pandas 在处理大数据时,内存不足是最常见的问题之一。

分块读取

不要一次性把全部数据加载到内存。

import pandas as pd


def process_in_chunks(file_path, chunk_size=50000):
    """分块处理大文件"""
    results = []
    
    for chunk in pd.read_csv(file_path, chunksize=chunk_size):
        # 处理每个数据块
        processed = transform_chunk(chunk)
        results.append(processed)
        # 及时释放内存
        del chunk, processed
    
    return pd.concat(results, ignore_index=True)

选择合适的数据类型

Pandas 默认的数据类型往往比实际需要的类型占用更多内存。手动优化类型可以大幅降低内存使用。

def optimize_dtypes(df):
    """优化 DataFrame 数据类型以减少内存"""
    start_mem = df.memory_usage(deep=True).sum() / 1024 ** 2
    
    for col in df.columns:
        col_type = df[col].dtype
        
        if col_type != "object":
            c_min = df[col].min()
            c_max = df[col].max()
            
            if str(col_type)[:3] == "int":
                # 整数类型优化
                if c_min >= 0:
                    if c_max <= 255:
                        df[col] = df[col].astype("uint8")
                    elif c_max <= 65535:
                        df[col] = df[col].astype("uint16")
                    elif c_max <= 4294967295:
                        df[col] = df[col].astype("uint32")
                else:
                    if c_min >= -128 and c_max <= 127:
                        df[col] = df[col].astype("int8")
                    elif c_min >= -32768 and c_max <= 32767:
                        df[col] = df[col].astype("int16")
            else:
                # 浮点类型优化
                df[col] = pd.to_numeric(df[col], downcast="float")
        else:
            # 分类数据优化
            if df[col].nunique() / len(df) < 0.5:
                df[col] = df[col].astype("category")
    
    end_mem = df.memory_usage(deep=True).sum() / 1024 ** 2
    print(f"内存占用: {start_mem:.2f} MB → {end_mem:.2f} MB (减少 {100 * (start_mem - end_mem) / start_mem:.1f}%)")
    
    return df

及时清理中间结果

ETL 管道中会产生大量中间数据,如果不及时清理,内存会被慢慢吃光。

def etl_with_memory_discipline():
    """有内存纪律的 ETL"""
    # 1. 抽取 - 只保留需要的列
    raw = read_source(["order_id", "amount", "status"])
    
    # 2. 完成一个阶段后,立即释放不再需要的变量
    extracted = raw[raw["status"].notna()].copy()
    del raw  # 原始数据不再需要
    
    # 3. 转换后释放中间结果
    transformed = expensive_transform(extracted)
    del extracted
    
    # 4. 最终写入后释放全部
    write_to_target(transformed)
    del transformed
    
    # 5. 强制垃圾回收
    import gc
    gc.collect()

列式文件格式

在数据读取和传输阶段,文件格式的选择对性能影响巨大。列式存储格式(Parquet、ORC)比行式格式(CSV、JSON)在分析场景下有明显的性能优势。

特性 CSV JSON Parquet
压缩比 高(通常压缩 3-5 倍)
读取速度 慢(文本解析) 慢(JSON 解析) 快(列式裁剪)
Schema 隐式 显式
支持复杂类型
适用场景 小数据、人类可读 API 数据交换 分析型工作负载
# 读取 Parquet 比读取 CSV 快很多
import pandas as pd

# CSV:需要解析每一行,确定列类型,占用大量 IO
df_csv = pd.read_csv("orders.csv")  # 100M 文件 → 15 秒

# Parquet:列式存储+压缩,只读取需要的列
df_parquet = pd.read_parquet("orders.parquet")  # 20M 文件 → 2 秒

# 只读取需要的列进一步加速
df_filtered = pd.read_parquet(
    "orders.parquet",
    columns=["order_id", "amount", "status"]
)

Pandas 操作优化

Pandas 是 Python ETL 中最常用的库,但默认写法不一定是最快的。

避免在循环中使用 apply

# ❌ 慢:使用 apply 逐行计算
df["total"] = df.apply(lambda row: row["price"] * row["quantity"], axis=1)

# ✅ 快:使用向量化操作
df["total"] = df["price"] * df["quantity"]

# ❌ 慢:字符串拼接用 apply
df["full_name"] = df.apply(lambda r: f"{r['first_name']} {r['last_name']}", axis=1)

# ✅ 快:向量化字符串操作
df["full_name"] = df["first_name"] + " " + df["last_name"]

使用 query 和 eval

对于过滤和运算操作,Pandas 的 queryeval 方法会利用底层 NumExpr 加速。

# 过滤
# ❌
df_filtered = df[(df["amount"] > 100) & (df["status"] == "PAID")]

# ✅ 使用 query(在大数据集上快 20-30%)
df_filtered = df.query("amount > 100 and status == 'PAID'")

# 计算
# ❌
df["ratio"] = (df["amount"] - df["tax"]) / df["amount"]

# ✅ 使用 eval
df["ratio"] = df.eval("(amount - tax) / amount")

避免链式赋值

# ❌ 链式赋值(可能产生 SettingWithCopyWarning)
df[df["status"] == "PENDING"]["priority"] = "high"

# ✅ 使用 loc
df.loc[df["status"] == "PENDING", "priority"] = "high"

缓存策略

在 ETL 中,有一些数据是不需要每次重新计算的。缓存这些数据可以节省大量时间。

维度表的缓存

业务维度表(如产品分类、区域信息)变更频率很低,可以缓存起来重复使用。

class DimensionCache:
    """维度表缓存(每天刷新一次)"""
    
    def __init__(self, db_config, cache_ttl_hours=24):
        self.db = create_connection(db_config)
        self.cache = {}
        self.cache_ttl = timedelta(hours=cache_ttl_hours)
        self.last_refresh = {}
    
    def get_dimension(self, table_name):
        """获取维度表数据(带缓存)"""
        now = datetime.now()
        last_refresh = self.last_refresh.get(table_name)
        
        if last_refresh and (now - last_refresh) < self.cache_ttl:
            return self.cache[table_name]
        
        print(f"刷新维度表缓存: {table_name}")
        df = pd.read_sql(f"SELECT * FROM {table_name}", self.db)
        self.cache[table_name] = df
        self.last_refresh[table_name] = now
        return df

中间结果缓存

对于多个下游需要使用的中间数据,可以把结果写入临时表,需要时直接读取。

def checkpoint_to_temp_table(df, checkpoint_name, conn):
    """将中间结果写入临时表作为检查点"""
    temp_table = f"tmp_etl_{checkpoint_name}"
    df.to_sql(temp_table, conn, if_exists="replace", index=False)
    print(f"检查点已保存: {temp_table} ({len(df)} 行)")
    return temp_table


def resume_from_checkpoint(checkpoint_name, conn):
    """从检查点恢复"""
    temp_table = f"tmp_etl_{checkpoint_name}"
    if table_exists(temp_table, conn):
        return pd.read_sql(f"SELECT * FROM {temp_table}", conn)
    return None

小结

ETL 性能优化是一个系统性的工程。首先要通过埋点和监控找到真正的瓶颈,然后对症下药。并行处理是加速最有效的手段之一,但要注意分区策略和进程/线程的选择。批量大小的调优往往能带来意想不到的收益。在内存管理上,分块读取和数据类型优化可以减少不必要的资源消耗。文件格式方面,列式存储(Parquet)在分析场景下远优于 CSV 和 JSON。最后,不要轻视缓存的作用——合理缓存维度表和中间结果可以避免大量重复计算。

Summary: ETL 性能优化的系统方法论,从瓶颈定位到并行、批量、内存和文件格式全方位优化。