← 返回博客列表

【Python量化实战 #17】数据存哪里不卡?Python股票存储方案CSV-SQLite-MySQL对比与选型

2026年08月05日 09:25 · 内容龙虾

本文是「Python量化实战」系列第 17 篇(工程化模块),上一篇我们用并发把全市场 5000 只股票拉了下来——但拉下来塞哪儿?扔进内存下次启动就没了,写成 CSV 文件又慢又占盘。这篇把量化项目最常见的三种本地存储方案放在同一台机器、同一份数据上跑了一次真实基准:写 10 只股票 2.5 年的日 K(6010 行),CSV 写入 113ms、SQLite 78ms;按单只过滤查询,CSV 22ms、SQLite 5.9ms——差距 3.7 倍。看完数字你就能根据自己的场景挑对方案。

本文你将得到什么

  1. 三种存储方案在同一台机器同一份数据下的写入 / 全量读 / 条件查询耗时对比
  2. CSV / SQLite / MySQL 选型决策树(10 万行以下?百万行?千万行?多人协作?)
  3. SQLite 的一行代码迁移到 MySQL 的最小 DDL 模板(含主键/唯一索引建议)
  4. 增量更新写法:用 UNIQUE(code, trade_dt) 避免重复写
  5. 三种方案的 5 个常见坑(编码、并发、列类型、磁盘 IO、备份策略)

一、在线体验

想先在线试试接口效果?打开 API Playground 即可直接调用测试: https://mairuiapi.com/playground

本文用到 stock_liststock_history 接口。拉下来的数据怎么存,是每个量化项目都要做的第一个"地基"决定。

二、环境准备

本文代码使用 mairui SDK 获取股票数据,安装方法如下:

pip install mairui

SDK 的完整接口文档与使用说明请查阅 GitHub 仓库:https://github.com/MaiRuiApi/mairui

接口的详细参数说明请查阅官网 API 文档:https://mairuiapi.com/hsdata

运行环境: - Python 3.9+ - mairui SDK 1.0.0 - pandas 2.2+ - SQLite 3.x(标准库自带,无需安装) - MySQL(可选,需自行部署;本文 DDL 部分给参考,未连库实测)

import os
import mairui

# 证书从环境变量读取(官网注册后获取),不要写死在代码里
api = mairui.Client(os.environ["MAIRUI_LICENCE"])

本文数据截至 2026-07-30,10 只样本股 2024-01-01~2026-06-30 共 6010 行日 K 数据。

三、三种方案的真实基准

先别急着选方案,拿数据说话。我准备了 10 只大盘股 2.5 年的日 K(共 6010 行),分别用 CSV、SQLite 写入磁盘,再分别测"全量读回内存"和"按 code 过滤"两个高频操作的耗时。

import os
import time
import sqlite3
import pandas as pd
import mairui

api = mairui.Client(os.environ["MAIRUI_LICENCE"])
SAMPLE_SIZE = 10
ST, ET = "20240101", "20260630"

# 1) 拉样本(最小化代码,无重试——存储演示用)
stock_list = api.stock_list()[:SAMPLE_SIZE]
frames = [
    pd.DataFrame(api.stock_history(s["dm"].split(".")[0], "d", "n", st=ST, et=ET))
        .assign(code=s["dm"], name=s["mc"])
    for s in stock_list
]
merged = pd.concat(frames, ignore_index=True)
print(f"样本: {merged.shape[0]} 行 x {merged.shape[1]} 列")

真实输出:

样本: 6010 行 x 11 列
内存占用: 1684.6 KB

3.1 写入耗时

# CSV 写入
def save_csv(df, path):
    t0 = time.time()
    df.to_csv(path, index=False, encoding="utf-8-sig")
    return time.time() - t0

# SQLite 写入
def save_sqlite(df, path):
    t0 = time.time()
    conn = sqlite3.connect(path)
    try:
        df.to_sql("kline", conn, if_exists="replace", index=False)
    finally:
        conn.close()
    return time.time() - t0

真实输出(2026-07-30 验证):

  CSV 写入:    113.1 ms   文件 458.1 KB
  SQLite 写入: 78.5 ms   文件 564.0 KB

SQLite 写入比 CSV 快约 31%——别小看这点,全市场 5000 只每天写一次就是几分钟 vs 十几分钟的区别。SQLite 文件略大是因为附带索引结构。

3.2 全量读回内存

def load_csv(path):
    t0 = time.time()
    df = pd.read_csv(path)
    return df, time.time() - t0

def load_sqlite(path):
    t0 = time.time()
    conn = sqlite3.connect(path)
    try:
        df = pd.read_sql("SELECT * FROM kline", conn)
    finally:
        conn.close()
    return df, time.time() - t0

真实输出:

  CSV 全量读:    53.5 ms
  SQLite 全量读: 44.3 ms

差距不大——10 只股票的数据量(1.6 MB)太小,磁盘 IO 主导。量级到了百万行,SQLite 的优势才会显著(见第四节的扩展建议)。

3.3 条件查询(最常被忽视的差距)

# CSV 方案:只能把全表读进内存再用 pandas 过滤
df = pd.read_csv("kline.csv", dtype={"code": str})
hits = df[df["code"] == "000001.SZ"]   # 内存过滤

# SQLite 方案:库内过滤,只返回匹配行
hits = pd.read_sql("SELECT * FROM kline WHERE code=?", conn, params=("000001.SZ",))

真实输出:

  CSV 读全表再过滤:  22.1 ms(含 IO,内存过滤)
  SQLite 索引过滤:   5.9 ms(库内过滤)

差距 3.7 倍——这才是工程化项目里 SQLite 的核心价值:磁盘-内存的 IO 边界由数据库帮你优化,不用把整张表都塞进内存。

四、选型决策树:你的项目该选谁

场景 推荐 理由
临时分析 / 单次回测(<1 万行) CSV 零依赖、Excel 可直接打开、人工检查方便
单机小项目(10 万 ~ 100 万行) SQLite 单文件、零配置、并发安全、查询快
多人协作 / 数据量 > 1 亿行 MySQL / PostgreSQL 真正的服务进程,多用户权限与备份机制
多端同步 / Web 后端读取 MySQL 跨进程访问,SQLite 写锁会成瓶颈
回测历史归档 CSV + Parquet Parquet 列存压缩,1 亿行可压到 1 GB 以内

经验阈值: - 数据量 < 1 GB → SQLite 完全够用 - 数据量 1~50 GB → MySQL 单机能扛 - 数据量 > 50 GB → MySQL 分库分表 + Parquet 归档

五、SQLite 的 4 个工程化技巧

SQLite 是单文件 DB,看起来"玩具",但合理使用完全可以扛住个人量化项目。几个关键技巧:

5.1 启用索引

CREATE INDEX idx_code ON kline(code);
CREATE UNIQUE INDEX uniq_code_dt ON kline(code, trade_dt);

UNIQUE(code, trade_dt)增量更新的基础——同一天不会写两行。

5.2 增量更新(避免每天重写整张表)

def upsert_sqlite(df_new, path):
    conn = sqlite3.connect(path)
    try:
        # INSERT OR IGNORE:唯一索引冲突就跳过
        df_new.to_sql("kline", conn, if_exists="append", index=False)
        # 真正去重(to_sql 不会触发 OR IGNORE,需要 SQL 化)
        conn.execute("""
            DELETE FROM kline
            WHERE rowid NOT IN (
                SELECT MIN(rowid) FROM kline GROUP BY code, trade_dt
            )
        """)
        conn.commit()
    finally:
        conn.close()

to_sql 默认是 INSERT,不是 INSERT OR IGNORE——所以两步走:先 append,再 SQL 去重。

5.3 列类型选择

SQLite 是弱类型——你写 DECIMAL(10,3) 它也照样存文本。但如果你想迁到 MySQL,列类型一开始就写对

字段 Python 类型 SQLite 类型 MySQL 类型
code str TEXT VARCHAR(16)
trade_dt str DATE DATE
open/close/high/low float REAL DECIMAL(10,3)
vol/amount int INTEGER BIGINT

5.4 备份就是 cp

# SQLite 备份只需拷贝单文件
cp kline.db kline_20260730.db

写入中直接 cp 可能拷贝到不一致状态——更稳的姿势:

import shutil
# 用 SQLite 自带的备份 API
src = sqlite3.connect("kline.db")
dst = sqlite3.connect("kline_backup.db")
with dst:
    src.backup(dst)
dst.close()
src.close()

六、MySQL 方案参考

SQLite 是单文件,单机能扛;但多进程同时写就会触发 SQLITE_BUSY。一旦项目里有"Web 后端拉数据 + 策略脚本读数据"两个进程同时访问,就必须上 MySQL/PostgreSQL。

最小可用 DDL(连库代码用 pymysqlSQLAlchemy,本文不展开):

CREATE DATABASE IF NOT EXISTS stock_data DEFAULT CHARACTER SET utf8mb4;
USE stock_data;
CREATE TABLE IF NOT EXISTS kline (
    id        BIGINT AUTO_INCREMENT PRIMARY KEY,
    code      VARCHAR(16)  NOT NULL,
    name      VARCHAR(32)  NOT NULL,
    trade_dt  DATE         NOT NULL,
    open      DECIMAL(10,3),
    close     DECIMAL(10,3),
    high      DECIMAL(10,3),
    low       DECIMAL(10,3),
    vol       BIGINT,
    amount    BIGINT,
    UNIQUE KEY uniq_code_dt (code, trade_dt),  -- 增量更新防重复
    INDEX idx_trade_dt (trade_dt)              -- 按日期范围查询
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

两个索引是关键: - UNIQUE KEY (code, trade_dt):让 INSERT ... ON DUPLICATE KEY UPDATE 实现 upsert - INDEX (trade_dt):让"最近 N 天"扫描走索引而不是全表

七、完整可运行示例

把上面拼起来约 80 行的端到端脚本:

# -*- coding: utf-8 -*-
"""股票数据存储方案对比:CSV / SQLite 真实基准。"""
import os
import time
import sqlite3
import pandas as pd
import mairui

SAMPLE_SIZE = 10
ST, ET = "20240101", "20260630"
OUT = os.path.dirname(os.path.abspath(__file__))

def fetch_one(api, code, name):
    kline = api.stock_history(code.split(".")[0], "d", "n", st=ST, et=ET)
    df = pd.DataFrame(kline)
    df.insert(0, "code", code)
    df.insert(1, "name", name)
    return df

def main():
    api = mairui.Client(os.environ["MAIRUI_LICENCE"])
    stock_list = api.stock_list()[:SAMPLE_SIZE]
    frames = [fetch_one(api, s["dm"], s["mc"]) for s in stock_list]
    merged = pd.concat(frames, ignore_index=True)

    csv_path = os.path.join(OUT, "kline.csv")
    sqlite_path = os.path.join(OUT, "kline.db")

    t0 = time.time()
    merged.to_csv(csv_path, index=False, encoding="utf-8-sig")
    print(f"CSV 写入: {(time.time() - t0) * 1000:.1f} ms")

    t0 = time.time()
    conn = sqlite3.connect(sqlite_path)
    merged.to_sql("kline", conn, if_exists="replace", index=False)
    conn.execute("CREATE INDEX idx_code ON kline(code)")
    conn.execute("CREATE UNIQUE INDEX uniq_code_dt ON kline(code, t)")
    conn.close()
    print(f"SQLite 写入+建索引: {(time.time() - t0) * 1000:.1f} ms")

    # 按 code 过滤
    t0 = time.time()
    pd.read_csv(csv_path, dtype={"code": str})
    df = pd.read_csv(csv_path, dtype={"code": str})
    df[df["code"] == stock_list[0]["dm"]]
    print(f"CSV 过滤: {(time.time() - t0) * 1000:.1f} ms")

    t0 = time.time()
    conn = sqlite3.connect(sqlite_path)
    pd.read_sql("SELECT * FROM kline WHERE code=?",
                conn, params=(stock_list[0]["dm"],))
    conn.close()
    print(f"SQLite 过滤: {(time.time() - t0) * 1000:.1f} ms")

if __name__ == "__main__":
    main()

八、避坑与进阶

  • 坑 1:CSV 用 GBK 编码。跨平台直接乱码,永远用 UTF-8 with BOMencoding="utf-8-sig"),Excel 打开不乱码、pandas 读也兼容。
  • 坑 2:SQLite 多人同时写。SQLite 是文件锁,多进程写必触发 SQLITE_BUSY——多端访问直接上 MySQL。
  • 坑 3:MySQL 字段类型用 FLOATFLOAT 精度有限(7 位有效数字),股票价格用 DECIMAL(10,3) 才不会漂。
  • 坑 4:忘记建索引。MySQL/SQLite 没建索引的 WHERE 会全表扫描——10 万行以内感觉不到,100 万行后慢到崩溃。
  • 坑 5:用 SELECT * 拉全表。列越多 IO 越重,只 SELECT 你需要的列(特别是 BLOB/TEXT)。
  • 进阶方向:亿级数据归档用 Parquet 列存格式(pyarrow),比 CSV 压缩率高 5~10 倍;用 pyarrow.parquet.write_table() 写出,pandas 直接 read_parquet() 读回。

九、总结与延伸

存储方案没有"最好的",只有"最适合的"。本文真实基准结论:

  • 小项目 / 单人 → SQLite 是最优解(快、稳、零依赖、随项目走)
  • 多人协作 / Web 后端 → MySQL,DDL 直接复用本文模板
  • 临时分析 → CSV,Excel 可读就行
  • 归档 → Parquet,省盘

工程化模块继续推进:下一篇把存下来的数据画成 K 线图——pyecharts 交互式看板 + matplotlib 静态图,让回测结果有"看得见"的形态。

延伸阅读: - 在线体验更多接口:https://mairuiapi.com/playground - 查看完整 API 文档:https://mairuiapi.com/hsdata - SDK 文档与源码:https://github.com/MaiRuiApi/mairui - 关注公众号获取本系列更新


本文为技术演示,所有数据来自公开股票接口,文中存储方案对比为本地实测,不构成投资建议。

QQ 客服 3826425416 咨询时请提供证书号或订单号,便于快速处理
咨询