Python 大型 CSV 排序:用 SQLite 按整数列稳定排序
文章目录
文件越来越大,sorted(list(csv.reader(...))) 开始占用大量内存;直接按文本排序,又可能把 10 放到 2 前面,甚至拆坏带引号换行的字段。
这篇教程面向初学者到中级读者:逐条读取一个未排序 CSV,存进临时 SQLite 数据库,按整数列排序后生成新文件。已有的大型 CSV 流式处理教程适合计数等逐条任务;这里补上必须看到全部数据才能完成的全局排序。
1. 方法:整数键排序,相同键保留原顺序
- 用
csv.reader解析记录,不按物理行拆分 CSV。 - 将排序键转换为整数,另存输入序号
seq与完整字段列表。 - 执行
ORDER BY sort_key, seq,逐条写出结果,不调用fetchall()。 - 先完成临时输出,再发布为一个不存在的新路径;任何已有输出都保留。
SQLite 的 ORDER BY 文档说明,多项排序表达式会依次打破平局。第二个键 seq 明确保证相同整数键按输入顺序排列,而不是依赖数据库碰巧返回的顺序。
输入约定:UTF-8、逗号分隔、双引号引用,允许文件开头带 BOM。表头必须非空、列名非空且不重复;每条记录字段数必须与表头一致。排序列按列名精确匹配,只接受带可选正负号的 ASCII 整数,范围为 -2**63 到 2**63-1,不接受空白、小数或缺失值。02 与 2 的排序键相同,原始文本仍分别保留。
2. 准备环境与测试数据
使用 Linux、本地可写文件系统、Python 3.9 或更新版本,并确认能导入 sqlite3。无需 pip 包或独立数据库服务;本文在 Python 3.11.4、SQLite 3.41.2 上测试。下面命令适用于 bash 或 zsh,全部在同一个新目录运行;重复实验时换一个目录名。
python3 --version
python3 -c 'import sqlite3; print(sqlite3.sqlite_version)'
mkdir csv-sort-demo
cd csv-sort-demo
mkdir scratch
cat > jobs.csv <<'CSV'
id,priority,note
A,10,"needs, review"
B,2,"line one
line two"
C,2,ready
D,-1,first
CSV
将下一节保存为 sort_csv.py,也可下载完整排序脚本。使用已经写完、运行期间不会变化的普通输入文件;输出父目录和 --work-dir 指定的目录必须已存在。
3. 完整实现:逐条入库与逐条导出
"""Sort UTF-8 CSV by one signed 64-bit integer column. Python 3.9+."""
import argparse
from contextlib import closing
import csv
import json
import os
from pathlib import Path
import re
import sqlite3
import sys
import tempfile
def records(reader, key_index, width):
for seq, row in enumerate(reader, 1):
if len(row) != width:
raise ValueError(f"record {seq}: expected {width} fields, got {len(row)}")
text = row[key_index]
if not re.fullmatch(r"[+-]?[0-9]+", text):
raise ValueError(f"record {seq}: key must be an integer, got {text!r}")
number = int(text)
if not -(2**63) <= number < 2**63:
raise ValueError(f"record {seq}: key outside signed 64-bit range")
yield seq, number, json.dumps(row, ensure_ascii=True)
def sort_csv(source, output, key, work_dir):
if source.resolve() == output.resolve():
raise ValueError("input and output must differ")
if os.path.lexists(output):
raise FileExistsError(f"output already exists: {output}")
staged = None
try:
with tempfile.TemporaryDirectory(prefix="csv-sort-", dir=work_dir) as tmp:
with closing(sqlite3.connect(str(Path(tmp) / "rows.sqlite"))) as con:
con.execute("PRAGMA temp_store=FILE")
con.execute("PRAGMA cache_size=-8192")
con.execute("CREATE TABLE items (seq INTEGER PRIMARY KEY, "
"sort_key INTEGER NOT NULL, payload TEXT NOT NULL)")
with source.open(encoding="utf-8-sig", newline="") as file:
reader = csv.reader(file, strict=True)
header = next(reader, None)
if not header or any(not name.strip() for name in header):
raise ValueError("a nonempty header is required")
if len(set(header)) != len(header):
raise ValueError("duplicate header names")
if key not in header:
raise ValueError(f"missing key column: {key!r}")
with con:
con.executemany(
"INSERT INTO items VALUES (?, ?, ?)",
records(reader, header.index(key), len(header)),
)
count = con.execute("SELECT count(*) FROM items").fetchone()[0]
with tempfile.NamedTemporaryFile(
mode="w", encoding="utf-8", newline="", delete=False,
prefix=".csv-sort-", suffix=".tmp", dir=output.parent,
) as file:
staged = Path(file.name)
writer = csv.writer(file, lineterminator="\n")
writer.writerow(header)
for (payload,) in con.execute(
"SELECT payload FROM items ORDER BY sort_key, seq"
):
writer.writerow(json.loads(payload))
# Same-directory hard link: publish a complete file without overwriting.
os.link(staged, output)
return count
finally:
if staged is not None:
staged.unlink(missing_ok=True)
def main():
parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
parser.add_argument("input", type=Path)
parser.add_argument("output", type=Path, help="new file in an existing directory")
parser.add_argument("--key", required=True, help="exact integer column name")
parser.add_argument("--work-dir", type=Path, help="existing SQLite workspace parent")
args = parser.parse_args()
csv.field_size_limit(1024 * 1024)
try:
count = sort_csv(args.input, args.output, args.key, args.work_dir)
except (OSError, ValueError, csv.Error, sqlite3.Error) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
print(f"OK: rows={count} key={args.key} output={args.output}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Python sqlite3 文档介绍了磁盘数据库、参数占位符和游标迭代。代码使用固定 SQL 与参数,不把用户提供的列名拼接进 SQL;列名仅用于找到 CSV 中的字段位置。
Python CSV 文档建议打开 CSV 文件时使用 newline=""。脚本通过解析器保留字段内的逗号、引号和换行,但会重新生成 CSV 引用和记录分隔符;保留的是字段值,不是原文件字节。每个字段上限设为 1 MiB 字符,表头也受这个限制。
临时数据库由 TemporaryDirectory 管理,连接显式关闭后才清理目录。临时输出放在目标同目录,写完关闭后使用 os.link()创建最终文件名;目标已存在时失败,不替换旧文件。此方式需要支持硬链接的本地文件系统,不用于保证断电后的持久性。正常返回和普通异常会清理临时文件;强制杀进程或断电后可能需要人工检查残留。
4. 执行与验证
- 查看帮助,再运行排序:
python3 sort_csv.py --help
python3 sort_csv.py jobs.csv sorted.csv --key priority --work-dir scratch
预期输出:
OK: rows=4 key=priority output=sorted.csv
- 用 CSV 解析器检查顺序和字段,不用
wc -l当作记录数:
python3 - <<'PY_CHECK'
import csv
from pathlib import Path
with open("sorted.csv", encoding="utf-8", newline="") as file:
rows = list(csv.DictReader(file))
assert [row["id"] for row in rows] == ["D", "B", "C", "A"]
assert rows[1]["note"] == "line one\nline two"
assert rows[-1]["note"] == "needs, review"
assert not list(Path("scratch").iterdir())
assert not list(Path(".").glob(".csv-sort-*.tmp"))
print("OK: numeric order, stable ties, intact CSV, cleaned workspace")
PY_CHECK
应打印 OK: numeric order, stable ties, intact CSV, cleaned workspace。顺序是 D、B、C、A,不是按字符串得到的 D、A、B、C;B 和 C 的键同为 2,因此保持原顺序。这里的小测试可以用列表,实际排序脚本不保存全部记录到 Python 列表。
5. 验证失败分支
在上一节的同一目录运行,保留已经生成的 sorted.csv:
printf 'id,priority\nX,not-a-number\n' > bad.csv
if python3 sort_csv.py bad.csv rejected.csv --key priority --work-dir scratch; then
printf 'UNEXPECTED SUCCESS\n'
else
printf 'STOP: invalid key\n'
fi
test ! -e rejected.csv && printf 'OK: no output published\n'
if python3 sort_csv.py jobs.csv sorted.csv --key priority --work-dir scratch; then
printf 'UNEXPECTED OVERWRITE\n'
else
printf 'OK: existing output kept\n'
fi
坏键检查在 stderr 提示 record 1: key must be an integer,脚本退出码为 1,且不会创建 rejected.csv。第二次排序提示 output already exists,旧的 sorted.csv 保持不变。成功返回 0;命令行参数错误返回 2。
自动化任务应仅在脚本返回 0 时消费输出。运行验证覆盖了负数、正号、前导零、64 位边界、相同键、中文、BOM、CRLF、带换行字段、空文件、只有表头、重复列名、缺失列、字段数错误、非法 UTF-8、损坏引号、已有输出与工作目录清理。另用 100,000 条逆序记录检查了完整排序与记录数。
6. 磁盘与常见问题
| 现象 | 具体处理 |
|---|---|
key must be an integer |
根据提示的记录序号修复原始键值;不要把空值静默转换成 0。记录序号从表头后的第一条数据开始,不是物理行号。 |
key outside signed 64-bit range |
当前实现只支持 64 位整数;超大整数或精确小数需要另外定义存储与排序策略,不要直接改成浮点数。 |
missing key column |
检查 --key 的大小写与表头空白;脚本不自动修剪列名。 |
output already exists |
换用新的输出名;先检查旧结果,不要为重跑而盲目删除。 |
database or disk is full |
检查数据库工作目录、输出目录和系统临时目录所在磁盘,换到空间充足的目录。 |
field larger than field limit |
检查输入分隔符与引号;确认字段确实很大后,再按内存预算调整代码中的 csv.field_size_limit()。 |
Operation not supported 或链接权限错误 |
检查输出所在文件系统是否支持创建硬链接;本示例选择支持硬链接的本地目录。 |
磁盘数据库不等于固定总内存。 temp_store=FILE 请求使用文件存储 SQLite 临时数据,但 SQLite 仍使用缓存;cache_size=-8192 是页缓存建议值,不是进程 8 MiB 内存上限。SQLite 临时文件文档说明排序可能使用额外临时存储,PRAGMA 文档还列出了编译选项的影响。内存同时受最大记录大小、解析器和排序器开销影响。
--work-dir 仅选择主数据库所在临时目录,不保证改变 SQLite 内部所有临时文件的位置。需要为输入、数据库、排序临时文件和新输出一起预算空间,没有适用于所有 CSV 的固定倍率;运行前可参考Python 磁盘空间预检。需要检测重复 ID 而不是排序时,使用CSV 重复 ID 检查教程。
总结
逐条解析 CSV,把全局排序交给 SQLite,再用整数键与输入序号获得稳定结果。先用小样本验证字段和失败退出,再处理真实文件;给临时数据留足磁盘空间,并保留原始输入。这个方法减少 Python 端保存全部记录的需要,但不承诺恒定内存或磁盘用量。
- 原文作者:yanzhi
- 原文链接:https://www.bobobk.com/python-sort-large-csv-sqlite.html
- 版权声明:本作品采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可。非商业转载请注明作者与原文链接,商业转载请联系作者获得授权。