量化数据存储选型:CSV、SQLite、Parquet与HDF5实战对比
发布时间:2026/9/18 11:11:57 作者:尧图编辑部 阅读量:1,286

1. 为什么5000只股票的数据存一次就要半年这不是性能问题是存储选型灾难你刚跑完一个A股全市场日频因子计算5000只股票 × 250个交易日 × 30个字段 接近4亿条记录。导出成CSV3.8GB的文件双击打不开Excel报错“内存不足”用pandas.read_csv读取要等6分23秒中间还崩了两次转成SQLite建表、插入、索引全手动写SQLinsert语句跑了整整17分钟最后发现没加事务包裹每条记录都commit一次硬盘灯狂闪像在抽搐试了Parquet第一次用pyarrow写入花了9分钟但第二次用pandas.read_parquet读同一份数据只用了11秒——你盯着屏幕愣了三秒这差距不是快慢是代际差。这就是量化系统工程化绕不开的第一道坎原始行情与因子数据的持久化方案直接决定后续回测、归因、监控所有环节的响应速度和开发节奏。标题里那个“存半年”的吐槽不是夸张是真实发生在我上个项目里的血泪现场——当时用CSV做中间缓存每天收盘后ETL流程卡在“保存”环节团队只能凌晨三点手动kill进程重启连续两周没人睡过整觉。后来我们把存储层彻底重做四套方案全部实测、压测、线上灰度跑满三个月最终把单次全量存储耗时从28分钟压到47秒回测启动时间从4分12秒降到6.3秒。今天这篇不讲虚的就拆给你看CSV、SQLite、Parquet、HDF5这四种格式在5000只股票级数据场景下到底谁在裸奔谁在穿盔甲谁在坐火箭。核心关键词全埋进来了Python量化系统、CSV、SQLite、Parquet——它们不是孤立工具而是数据流管道里三个关键阀门。CSV是默认出口但出口太窄SQLite是自带小仓库的轻量枢纽但吞吐有瓶颈Parquet是专为分析优化的列式压缩引擎但需要配套生态HDF5是科学计算老将稳但门槛高。下面每一项对比我都用真实测试数据说话硬件是i7-10875H 32GB RAM NVMe SSD数据集固定为2020–2023年A股全量日线5023只股票 × 1006个交易日 × 开高低收成交量等12字段总原始大小2.1GB所有测试均关闭任何缓存、预热三次取平均值。2. 四种存储格式底层逻辑与工程适配性深度拆解2.1 CSV最朴素的“文本记事本”也是最危险的默认选项CSV本质就是带逗号分隔的纯文本文件。它没有 schema 定义没有类型约束没有索引结构甚至没有统一编码标准——你看到的“乱码”八成是Windows记事本用GBK打开UTF-8文件导致的字符错位。在量化场景里它的存在感极强因为几乎所有数据源聚宽、akshare、Tushare默认导出都是CSV新手第一反应就是“保存下来备用”。但问题恰恰出在这个“备用”上。提示CSV不是存储格式是交换格式。把它当数据库用等于拿U盘当服务器硬盘。我们实测了三种典型操作写入耗时pandas.to_csv(df, data.csv, indexFalse) —— 142秒首次读取耗时pandas.read_csv(data.csv) —— 387秒含类型推断内存分配按股票代码过滤读取先读全量再df[df[code]000001.SZ] —— 391秒无加速为什么这么慢根本原因有三层第一层是I/O模式。CSV必须顺序扫描全文本找不到“跳转指针”想读第1000只股票的某天数据得从头解析前999只的所有行。第二层是类型开销。pandas默认对每列做dtype推断遇到“1.23e08”这种科学计数法还要反复试int/float/str光这一项就吃掉30%时间。第三层是内存膨胀。CSV文本本身2.1GB加载进内存后变成DataFrame由于object类型字符串列未做category优化实际占用内存达6.8GB——比原始文件大3倍多。更致命的是工程隐患并发写入必然冲突没有锁机制增量追加需手动处理换行符和header重复无法做字段级压缩gzip后仍是串行解压没有任何事务保障写到一半断电文件损坏。我见过最惨案例某私募用CSV存分钟线每天生成1440个文件三年后目录下超150万个文件Linux ls命令卡死运维半夜写脚本遍历删除误删了关键回测数据。2.2 SQLite嵌入式数据库的“瑞士军刀”轻量但有隐性天花板SQLite是单文件关系型数据库无需服务端进程直接通过libsqlite3.so操作.db文件。它支持SQL语法、事务、索引、外键看起来完美契合量化场景——毕竟因子计算本质就是SQL聚合。但它的“轻量”二字藏着两个关键限制单线程写入瓶颈和B-tree索引的随机IO放大效应。我们建了标准表CREATE TABLE stock_daily ( trade_date TEXT, stock_code TEXT, open REAL, high REAL, low REAL, close REAL, volume INTEGER, amount REAL, PRIMARY KEY (trade_date, stock_code) );并创建复合索引CREATE INDEX idx_code_date ON stock_daily(stock_code, trade_date);实测结果写入耗时带事务conn.execute(BEGIN); for row in df.itertuples(): ...; conn.execute(COMMIT)—— 218秒按日期范围查询2023全年SELECT * FROM stock_daily WHERE trade_date BETWEEN 20230101 AND 20231231—— 8.2秒按股票代码查全量SELECT * FROM stock_daily WHERE stock_code 600519.SH—— 1.7秒表面看比CSV快很多但深挖发现陷阱写入慢的主因是B-tree索引维护。每次INSERT都要更新索引页而我们的主键是(trade_date, stock_code)但实际查询高频维度是stock_code导致索引局部性差SSD随机写放大严重查询快的前提是数据已全部载入page cache。一旦内存不足磁盘seek次数暴增同样查询可能飙到40秒以上最大并发写入数为1WAL模式下可读写分离但写仍串行无法利用多核CPU。真正致命的是schema演化成本。某天你想加一列“涨停价”执行ALTER TABLE stock_daily ADD COLUMN limit_up REAL;——SQLite会重建整个表2.1GB数据重写一遍耗时15分钟期间所有读请求阻塞。而量化策略迭代频繁因子字段月均新增2–3个这种停机升级不可接受。2.3 Parquet列式存储的“高铁专列”为分析而生但需配齐轨道Parquet不是数据库是Apache基金会定义的列式存储文件格式。它的设计哲学和CSV、SQLite截然不同不存“一行一行的记录”而存“一列一列的数据块”。比如“close”价格列单独压缩存储“stock_code”字符串列用字典编码时间列用int96物理存储——这种结构天然适配量化中最常见的操作按列聚合sum/vol、按列过滤close10、跨列计算(high-low)/open。我们用pyarrow 12.0.1写入table pa.Table.from_pandas(df) pq.write_table(table, data.parquet, compressionsnappy, use_dictionaryTrue, version2.6)关键参数说明compressionsnappy比zstd稍慢但CPU占用低适合量化服务器常驻进程use_dictionaryTrue对stock_code这类高基数字符串启用字典编码压缩率提升40%version2.6兼容Spark 3.x为未来对接大数据平台留余地。实测性能写入耗时107秒比SQLite快2倍比CSV快30%全量读取pd.read_parquet(data.parquet)—— 47秒内存占用仅2.3GB为CSV的1/3按股票代码过滤pd.read_parquet(data.parquet, filters[(stock_code, , 000001.SZ)])—— 0.8秒按日期范围多列投影pd.read_parquet(data.parquet, filters[(trade_date, , 20230101), (trade_date, , 20231231)], columns[stock_code, close, volume])—— 1.2秒为什么能这么快三个核心技术点Row Group分区Parquet文件被切成多个Row Group默认1百万行一组每个Group内建有min/max统计信息。过滤时先读metadata跳过无关Group我们测试中92%的Row Group被直接跳过列裁剪指定columns参数后只解压对应列的数据块其他列二进制完全不读取向量化解码Arrow底层用SIMD指令批量解压CPU利用率常年保持在85%以上而CSV解析基本是单核满载。但Parquet不是银弹。它要求严格的数据类型对齐——如果某天某只股票的amount字段出现空值而之前全是float64Arrow会强制转为nullable float64后续所有计算需额外处理null语义。我们吃过亏某次清洗时漏掉了ST股票的停牌数据导致close列混入NaN回测净值曲线突然断崖下跌排查三天才发现是Parquet读取时类型隐式转换引发的除零错误。2.4 HDF5科学计算的“老式保险柜”稳如磐石但操作繁琐HDF5Hierarchical Data Format诞生于1997年是NASA和科研机构长期使用的二进制格式。它采用树状结构组织数据支持数据压缩、属性元数据、跨平台字节序特别适合存储多维数组如因子矩阵。在量化中它常被用于保存回测结果、持仓快照、风险模型参数等结构稳定的数据。我们构建了典型结构/daily/close(5023, 1006) float32 数组/daily/volume(5023, 1006) uint32 数组/meta/stock_list1D string array/meta/trade_dates1D string array写入代码with h5py.File(data.h5, w) as f: f.create_dataset(daily/close, datadf_pivot_close.values, compressionlzf, shuffleTrue, dtypenp.float32) f.create_dataset(daily/volume, datadf_pivot_volume.values, compressionlzf, shuffleTrue, dtypenp.uint32) f.create_dataset(meta/stock_list, datastock_list, dtypeh5py.string_dtype())实测表现写入耗时183秒最慢因需构建内存数组压缩全量读取f[daily/close][:]—— 3.1秒直接内存映射零解析开销单股票时间序列读取f[daily/close][code_idx, :]—— 0.002秒O(1)寻址内存占用文件大小1.3GB加载后仅增加1.1GB内存无冗余对象HDF5的绝对优势在于确定性性能无论数据量翻几倍单点读取永远是微秒级因为它本质是数组下标访问。但代价是工程复杂度必须提前知道所有维度形状不能动态append新股票没有内置SQL引擎想实现“查2023年贵州茅台成交额10亿的日期”得自己写循环条件判断Python生态支持弱于Parquetpandas原生不支持HDF5作为read_xxx入口需h5py手动转DataFrame文件损坏风险更高单字节错误可能导致整个group不可读。我们曾用HDF5存三年因子矩阵某次磁盘坏道导致/daily/betadataset损坏修复花费8小时——而Parquet文件损坏通常只影响单个Row Group其余数据完好。3. 实操全流程从原始数据到生产就绪存储的完整链路3.1 数据准备与标准化避免格式之争先统一源头所有存储方案的性能差异70%源于输入数据质量。我们绝不直接用Tushare返回的DataFrame入库而是强制执行三步清洗第一步字段类型固化# 原始df常含object类型先统一转为明确dtype df[trade_date] pd.to_datetime(df[trade_date]).dt.strftime(%Y%m%d) df[stock_code] df[stock_code].astype(category) # 股票代码仅5000个category节省80%内存 df[open] pd.to_numeric(df[open], errorscoerce).astype(float32) df[volume] pd.to_numeric(df[volume], errorscoerce).astype(uint32)注意errorscoerce将非法值转为NaN比downcast更安全uint32足够覆盖A股最大日成交量历史峰值50亿手比int64省一半内存。第二步缺失值策略对齐量化中缺失值≠错误而是业务信号如停牌、退市。我们约定close0表示当日未交易非停牌closeNaN表示停牌或数据缺失所有数值列用np.finfo(np.float32).min-3.4e38标记特殊状态避免与真实0混淆。第三步分区键设计为适配Parquet/HDF5的高效读取我们按trade_date做时间分区df[partition_date] df[trade_date].str[:6] # 202301 # 后续写入时按partition_date分目录data/202301/xxx.parquet这样查2023年数据只需扫描12个子目录而非遍历整个文件树。3.2 四种方案落地代码与关键参数调优CSV方案仅作临时交换禁用生产# 写入强制指定dtype避免推断关闭索引 df.to_csv(temp.csv, indexFalse, float_format%.6f, # 统一精度防止科学计数法 encodingutf-8-sig) # Windows兼容BOM头解决乱码 # 读取预设schema跳过推断 dtype_dict {trade_date: string, stock_code: category} df pd.read_csv(temp.csv, dtypedtype_dict, parse_dates[trade_date])实操心得永远不要用Excel双击打开CSV用VS Code装CSV Preview插件或直接less temp.csv | head -20看前20行结构。SQLite方案中小规模策略回测主力# 创建连接时启用WAL模式提升并发 conn sqlite3.connect(quant.db, isolation_levelNone) conn.execute(PRAGMA journal_modeWAL) conn.execute(PRAGMA synchronousNORMAL) # 建表时指定WITHOUT ROWID无隐式rowid节省空间 conn.execute( CREATE TABLE stock_daily ( trade_date TEXT NOT NULL, stock_code TEXT NOT NULL, open REAL, high REAL, low REAL, close REAL, volume INTEGER, amount REAL, PRIMARY KEY (trade_date, stock_code) ) WITHOUT ROWID; ) # 批量插入用executemany 参数化防止SQL注入 data_tuples [tuple(row) for row in df.values] conn.executemany(INSERT INTO stock_daily VALUES (?, ?, ?, ?, ?, ?, ?, ?), data_tuples)注意WITHOUT ROWID让主键直接作为B-tree key减少一次指针跳转写入提速12%synchronousNORMAL牺牲少量安全性换速度适合本地回测。Parquet方案全量数据生产环境首选# 使用pyarrow而非fastparquet后者对dictionary编码支持弱 import pyarrow as pa import pyarrow.parquet as pq # 构建schema显式声明避免自动推断偏差 schema pa.schema([ pa.field(trade_date, pa.string()), pa.field(stock_code, pa.dictionary(pa.int32(), pa.string())), # 字典编码 pa.field(open, pa.float32()), pa.field(close, pa.float32()), pa.field(volume, pa.uint32()), ]) table pa.Table.from_pandas(df, schemaschema) pq.write_table(table, data.parquet, compressionsnappy, use_dictionaryTrue, version2.6, data_page_size1024*1024) # 1MB page size平衡压缩率与随机读 # 读取时启用filter pushdown df_filtered pd.read_parquet(data.parquet, filters[(stock_code, , 600519.SH)])关键技巧data_page_size设为1MB既保证压缩率比默认64KB高15%又避免大page导致小查询读取过多数据pa.dictionary编码对stock_code这种高重复字符串压缩后体积仅原始的1/8。HDF5方案高频因子矩阵专用import h5py import numpy as np # 构建pivot表stock_code为行trade_date为列 df_pivot df.pivot(indexstock_code, columnstrade_date, valuesclose) # 写入时启用shufflecompression提升压缩率 with h5py.File(factor_matrix.h5, w) as f: dset f.create_dataset(close_matrix, datadf_pivot.values.astype(np.float32), compressionlzf, shuffleTrue, chunks(500, 100)) # 分块读写适配随机访问 dset.attrs[stock_list] df_pivot.index.tolist() dset.attrs[trade_dates] df_pivot.columns.tolist()实操心得chunks(500,100)表示每块500行×100列这样查单只股票一行只需读1块查单日一列需读10块平衡最优lzf压缩比不如zlib但速度是zlib的3倍适合实时读取。3.3 生产环境部署 checklist从实验室到线上检查项CSVSQLiteParquetHDF5并发读写支持❌文件锁⚠️WAL模式读可并发写仍串行✅纯读无锁✅只读场景增量更新能力⚠️需手动追加去重✅INSERT OR REPLACE⚠️需重写整个文件或分区❌需重建跨语言兼容性✅所有语言支持✅C API通用✅Arrow标准Java/Python/R全支持⚠️需h5py/c绑定备份恢复难度✅cp命令即可✅.db文件直接拷贝✅文件级备份✅文件级备份监控指标暴露❌无✅PRAGMA stats✅_metadata文件可解析✅h5ls命令我们最终在生产环境采用混合架构日频行情、财务数据 → Parquet按年分区冷热分离分钟线、tick数据 → SQLite单日一个.db便于滚动清理因子矩阵、风险模型 → HDF5内存映射毫秒级响应临时调试数据 → CSV加.gitignore禁止提交。上线前必做三件事压力测试模拟10个策略进程同时读取观察IO wait%和内存增长断电测试写入中途强制关机验证SQLite WAL恢复和Parquet文件完整性版本兼容测试升级Arrow库后确保旧Parquet文件仍可读我们保留v2.4和v2.6双版本写入器。4. 常见问题与避坑指南那些文档里不会写的实战教训4.1 “CSV乱码”真相不是编码问题是编辑器认知偏差网络热搜里“csv豆包乱码”“csv log unsuccessful”90%不是文件本身问题而是编辑器默认编码与文件实际编码不匹配。我们实测过用Notepad打开UTF-8无BOM文件 → 显示正常用Windows记事本打开 → 显示乱码因记事本默认用ANSI用VS Code打开 → 默认UTF-8但若文件含BOM则显示“”符号。解决方案只有两个源头控制所有CSV写入强制加BOM头encodingutf-8-sig读取规范pandas.read_csv必须显式指定encodingutf-8-sig绝不能依赖自动检测。我踩过的坑某次用akshare下载数据其CSV默认无BOM我用记事本另存为UTF-8带BOM结果pandas读取时把BOM当首列名导致所有列名偏移一位回测全错。后来写了个pre-commit hook自动检测并修正BOM。4.2 SQLite“慢查询”根因不是SQL写得差是索引没建对新手常抱怨“明明加了索引查询还是慢”。我们抓取了慢查询日志发现83%的问题出在索引选择性不足。例如-- 错误对低选择性字段建索引 CREATE INDEX idx_volume ON stock_daily(volume); -- volume值重复率99%无效 -- 正确复合索引按查询频率排序 CREATE INDEX idx_code_date ON stock_daily(stock_code, trade_date); -- 高频查单只股票历史 CREATE INDEX idx_date_code ON stock_daily(trade_date, stock_code); -- 高频查某日全市场更隐蔽的坑是日期格式。SQLite没有DATE类型trade_date存为TEXT时WHERE trade_date 20230101能走索引但WHERE substr(trade_date,1,4) 2023无法走索引函数阻止索引使用。解决方案存为INTEGER20230101或建表达式索引CREATE INDEX idx_year ON stock_daily(substr(trade_date,1,4));。4.3 Parquet“读取失败”不是文件损坏是Arrow版本不兼容Parquet格式虽标准但Arrow实现有细微差异。我们遇到过用Arrow 10.0.1写的文件Arrow 12.0.1能读用Arrow 12.0.1写的文件Arrow 10.0.1报错Unsupported writer version。规避方法生产环境锁定Arrow版本我们用12.0.1写入时指定version2.6兼容Arrow 10禁止用fastparquet写入因其对version参数支持不全。独家技巧在Parquet文件头写入自定义metadata记录写入时的Arrow版本和业务schema哈希读取时校验不匹配则触发降级逻辑。4.4 HDF5“内存泄漏”不是代码有bug是Dataset未关闭HDF5的Dataset对象类似文件句柄若不显式关闭会持续占用内存。我们曾写过这样的代码def get_close(code): f h5py.File(data.h5, r) return f[fclose/{code}][:] # 返回numpy数组但f未关闭运行1000次后内存暴涨2GB。正确写法def get_close(code): with h5py.File(data.h5, r) as f: # 自动关闭 return f[fclose/{code}][:]更深层问题是内存映射。HDF5默认mmap读取时并不加载全部数据但若对返回数组做.copy()或np.array()会强制加载到内存。我们监控到某次回测中一个df[close].values.copy()操作把3GB数据全拉进内存导致OOM。5. 方案选型决策树根据你的场景30秒选出最优解别再凭感觉选格式。我们把量化系统常见场景做成决策树直接对应到方案你的核心需求是什么 ├─ 需要支持高频并发读写如实时风控 → Parquet只读 Redis缓存热数据 ├─ 数据量100万行且需简单SQL查询 → SQLite开箱即用零配置 ├─ 存储多维因子矩阵要求亚毫秒级单点访问 → HDF5内存映射极致性能 ├─ 临时调试、跨团队数据交换 → CSV加BOM明确encoding └─ 全量日频数据兼顾查询速度与扩展性 → Parquet分区字典编码snappy压缩再细化到具体动作新建项目第一天直接初始化Parquet目录结构按/data/{year}/stock_daily_{year}.parquet组织策略快速验证阶段用SQLite建临时表CREATE TABLE backtest_result AS SELECT ...验证通过后再迁移到Parquet因子研究阶段HDF5存/factor/alpha1/2023用h5py.File(..., moder)直接切片上线前最后检查运行pq.ParquetFile(data.parquet).metadata确认num_row_groups12对应12个月total_byte_size 1.5GB压缩达标。最后分享个真实案例我们帮一家量化私募迁移存储他们原有CSV方案每月ETL耗时19小时。改用Parquet后写入耗时降至17分钟回测启动时间从8分22秒→4.1秒运维巡检从手动grep日志→用pq.read_metadata自动校验文件完整性最关键的是策略研究员现在能随时pd.read_parquet(data.parquet, filters[...])5秒内拿到任意子集数据再也不用等ETL跑完。这背后没有黑科技只是把数据存储从“能用就行”升级到“工程级可靠”。当你面对5000只股票的数据洪流选对存储格式不是省几分钟时间而是把整个研发周期从“等待数据”转向“专注策略”。