前言

最近在玩PT的时候发现磁盘io对pt速度影响巨大,并且qbittorrent对读写比较特殊,有些远程储存方案虽然延迟和大文件读写速度都很可观,但是拿来下PT速度只有3-5mb/s。所以在这里记录一下针对qbit特征的磁盘io测试。

qBittorrent 的磁盘读写活动特征(运行期间)

qBittorrent 是目前最常用的 BT 客户端之一,高性能、稳定、界面好,但它对磁盘 I/O 的压力远超很多人的想象。特别是在 Docker 环境、虚拟机、远程存储(SMB/WebDAV/SFTP/NFS)挂载的情况下,磁盘性能往往成为下载速度的真正瓶颈。

与“顺序写大文件”的常规下载器不同,qBittorrent 的数据流非常复杂,主要表现为:

1. 小块(block)写入

  • 一个 piece 通常是 4MB 或 16MB

  • 但写入磁盘时都是 32KB / 64KB 小块 block

  • 实际磁盘上看到的是大量 16K/32K/64K 的写入

这对 HDD、SMB、WebDAV、网络盘非常痛苦。

2. 随机写入(Random Write)

BitTorrent 协议的数据来自全球各个 peer,顺序不可控:

  • 先来了 piece #800

  • 再来了 piece #12

  • 再来了 piece #1200

磁盘写入位置也是乱跳的。

对于远程文件系统,这意味着:

无法合并写入 → 碎片极重 → IOPS 崩溃

3. 高频读写交替

很多人误以为 BT 下载只会“写入”。实际上还包括:

  • 写完 block → 读回 piece 校验 hash(SHA1/SHA256)

  • 下载恢复 → 读取已存在文件

  • 断点续传 → 读取 piece 状态

  • 校验不通过时 → 重写数据

因此 qBittorrent 的 I/O 模式本质上是:

写 → 读 → CPU(hash)→ 写 → 读 → 写 → …

4. 数据落盘频率高(频繁 flush / fsync)

qBittorrent 会周期性清空内置 disk cache,让数据实际写入磁盘,避免内存丢失风险。

表现为:

  • 频繁 fsync

  • 高频元数据刷新

  • 多线程同时写入同一目录

SMB、WebDAV 对这种行为支持极差,会导致:

  • 速度突然下降到 KB/s

  • UI 提示“文件错误”或“文件无法写入”

综上所述,qbit的运行模式对远程储存方案非常不友好。SMB、WebDAV、SFTP 的共同弱点是:

  • 对小块随机写效率极差

  • metadata 操作消耗大

  • 无法处理高并发 I/O

  • 无法对乱序 block 进行预读或合并写

  • 延迟(哪怕 1ms)会被放大成灾难

模拟检测脚本

这里模拟qbittorrent的行为编写了一个对应的磁盘io测试脚本。测试运行环境为debian13,可以指定运行目录。测试完成后会自动删除测试文件,不会占用额外的磁盘空间。

测试主文件:qbittorrent_disk_test.py

#!/usr/bin/env python3
"""
qbittorrent-like disk I/O benchmark

特点:
- 模拟 qBittorrent 对单个大文件的随机小块写 + 读校验行为
- 可指定测试目录
- 结束后自动删除测试文件,不占用额外磁盘空间
- 适用于 Linux / Debian(含 Debian 13)

用法示例:
    ./qbittorrent_disk_test.py --dir /path/to/mount --size 10G --duration 120

参数说明:
    --dir       测试目录(必须)
    --size      模拟的“种子文件”大小,支持 K/M/G,默认 10G
    --duration  运行时间(秒),默认 120
    --piece-size   piece 大小(默认 4M)
    --block-size   每次写入 block 大小(默认 64K)
    --preallocate  预分配整个文件(更接近开启“预分配”的行为,但对远程盘很重)
"""

import argparse
import os
import random
import time
import hashlib
import sys

def parse_size(s: str) -> int:
    s = s.strip().upper()
    if not s:
        raise ValueError("size 为空")
    if s[-1] in ("G", "M", "K"):
        num = float(s[:-1])
        if s[-1] == "G":
            return int(num * 1024 ** 3)
        if s[-1] == "M":
            return int(num * 1024 ** 2)
        if s[-1] == "K":
            return int(num * 1024)
    return int(s)

def human_size(n: float) -> str:
    for unit in ["B", "K", "M", "G", "T"]:
        if n < 1024 or unit == "T":
            return f"{n:.2f}{unit}"
        n /= 1024

def run_test(
    directory: str,
    file_size: int,
    duration: int,
    piece_size: int,
    block_size: int,
    preallocate: bool,
):
    os.makedirs(directory, exist_ok=True)
    test_path = os.path.join(directory, "qbittorrent_io_test.tmp")

    total_pieces = max(1, file_size // piece_size)
    blocks_per_piece = piece_size // block_size

    print(f"测试文件:{test_path}")
    print(f"模拟文件大小:{human_size(file_size)}")
    print(f"piece 大小:{human_size(piece_size)},每 piece {blocks_per_piece} 个 block")
    print(f"block 大小:{human_size(block_size)}")
    print(f"piece 总数:{total_pieces}")
    print(f"预分配:{'是' if preallocate else '否'}")
    print(f"计划运行时长:{duration} 秒")
    print("-" * 60)

    f = None
    bytes_written = 0
    bytes_read = 0
    pieces_done = 0
    start = time.time()
    last_report = start

    try:
        # 以读写方式创建文件
        f = open(test_path, "w+b")

        if preallocate:
            print("正在预分配文件(可能会比较慢,尤其是远程挂载)...")
            f.truncate(file_size)
            f.flush()
            os.fsync(f.fileno())
            print("预分配完成。")

        while True:
            now = time.time()
            if now - start >= duration:
                break

            # 随机选择一个 piece
            piece_index = random.randrange(total_pieces)
            piece_offset = piece_index * piece_size

            # 写入当前 piece 的所有 block(模拟下载这个 piece)
            for i in range(blocks_per_piece):
                offset = piece_offset + i * block_size
                if offset >= file_size:
                    break
                f.seek(offset)
                data = os.urandom(block_size)
                f.write(data)
                bytes_written += len(data)

            # 刷新到磁盘(qBittorrent 也是周期性 flush)
            f.flush()
            os.fsync(f.fileno())

            # 随机决定是否对这个 piece 做 hash 校验(模拟 piece 校验)
            if random.random() < 0.5:
                f.seek(piece_offset)
                # 只读一个 piece(或到文件结尾)
                to_read = min(piece_size, file_size - piece_offset)
                read_data = f.read(to_read)
                bytes_read += len(read_data)
                # 做一次哈希,纯 CPU,增加一点负载
                hashlib.sha1(read_data).digest()

            pieces_done += 1

            # 每隔几秒打印一次进度
            if now - last_report >= 5:
                elapsed = now - start
                w_speed = bytes_written / elapsed if elapsed > 0 else 0
                r_speed = bytes_read / elapsed if elapsed > 0 else 0
                print(
                    f"[{elapsed:6.1f}s] 完成 piece: {pieces_done:6d} | "
                    f"写入: {human_size(bytes_written)} ({human_size(w_speed)}/s) | "
                    f"读取: {human_size(bytes_read)} ({human_size(r_speed)}/s)"
                )
                last_report = now

    except KeyboardInterrupt:
        print("\n收到中断信号,正在结束测试...")
    finally:
        if f is not None:
            try:
                f.close()
            except Exception:
                pass
        # 删除测试文件
        try:
            if os.path.exists(test_path):
                os.remove(test_path)
                print(f"已删除测试文件:{test_path}")
        except Exception as e:
            print(f"删除测试文件失败:{e}", file=sys.stderr)

    elapsed = time.time() - start
    if elapsed <= 0:
        elapsed = 1
    print("-" * 60)
    print(f"实际运行时间:{elapsed:.1f} 秒")
    print(f"总写入:{human_size(bytes_written)},平均写入速度:{human_size(bytes_written / elapsed)}/s")
    print(f"总读取:{human_size(bytes_read)},平均读取速度:{human_size(bytes_read / elapsed)}/s")
    print("测试完成。")

def main():
    parser = argparse.ArgumentParser(
        description="模拟 qBittorrent 行为的磁盘 I/O 测试脚本(随机小块读写 + piece 校验)"
    )
    parser.add_argument(
        "--dir",
        required=True,
        help="测试目录(例如 /mnt/storagebox 或 /data)",
    )
    parser.add_argument(
        "--size",
        default="10G",
        help="模拟种子文件大小,支持 K/M/G(默认 10G)",
    )
    parser.add_argument(
        "--duration",
        type=int,
        default=120,
        help="测试时长(秒),默认 120",
    )
    parser.add_argument(
        "--piece-size",
        default="4M",
        help="piece 大小,默认 4M",
    )
    parser.add_argument(
        "--block-size",
        default="64K",
        help="每次读写的 block 大小,默认 64K",
    )
    parser.add_argument(
        "--preallocate",
        action="store_true",
        help="预分配整个文件(类似开启 qBittorrent 预分配选项)",
    )

    args = parser.parse_args()

    try:
        file_size = parse_size(args.size)
        piece_size = parse_size(args.piece_size)
        block_size = parse_size(args.block_size)
    except Exception as e:
        print(f"参数解析错误: {e}", file=sys.stderr)
        sys.exit(1)

    if piece_size <= 0 or block_size <= 0:
        print("piece-size 和 block-size 必须大于 0", file=sys.stderr)
        sys.exit(1)
    if piece_size % block_size != 0:
        print("piece-size 必须是 block-size 的整数倍", file=sys.stderr)
        sys.exit(1)
    if file_size <= 0:
        print("size 必须大于 0", file=sys.stderr)
        sys.exit(1)

    run_test(
        directory=args.dir,
        file_size=file_size,
        duration=args.duration,
        piece_size=piece_size,
        block_size=block_size,
        preallocate=args.preallocate,
    )

if __name__ == "__main__":
    main()

保存脚本并赋予执行权限

chmod +x qbittorrent_disk_test.py

指定测试目录进行测试(支持远程挂载目录)

# 模拟一个 10G 的大种子,运行 2 分钟
./qbittorrent_disk_test.py --dir /mnt/storagebox --size 10G --duration 120

常用参数例子:

  • 降低测试体量(比如只搞 2G、跑 60 秒):

./qbittorrent_disk_test.py --dir /mnt/storagebox --size 2G --duration 60

  • 调小 piece / block(比如模拟 2M piece、32K block):

./qbittorrent_disk_test.py \
  --dir /mnt/storagebox \
  --size 5G \
  --duration 90 \
  --piece-size 2M \
  --block-size 32K

  • 模拟“开启预分配”的情况(会对远程盘更狠一点):

./qbittorrent_disk_test.py \
  --dir /mnt/storagebox \
  --size 10G \
  --duration 120 \
  --preallocate