MySQL日线行情数据入库:表结构设计与批量写入优化
1. 这个"日线数据入库"到底在解决什么问题
行情软件自带的本地数据文件,用起来方便,但一旦要做选股回测、因子计算、多标的横向对比,本地文件的短板就暴露得很明显:格式封闭、字段固定、跨周期聚合困难、并发读取容易锁文件。我最早的做法是直接读软件目录下的日线文件,一个标的一个文件,几千个标的意味着几千次文件打开操作,跑一遍全市场扫描要等很久,而且每次想改个统计口径都得重新解析一遍二进制结构,非常磨人。
把日线数据搬进 MySQL,本质上是把"文件系统"换成"关系型数据库":一次导入,后续所有查询都交给 SQL 和索引去处理。这个转换带来的好处是实打实的——多标的 JOIN、按日期区间切片、按涨跌幅排序、分组统计,全部变成几行语句的事;数据落地在一个标准接口后面,Python、Java、甚至表格工具都能接。代价也很明确:需要设计表结构、需要处理重复导入、需要控制写入效率,还要考虑数据量上来之后索引和分区怎么做。
这篇内容面向的是手上已经有一份日线数据源、想把它沉淀成可查询数据集的同学,无论你是刚接触数据库的新手,还是已经在做量化数据管道的老手,都能从下面的表结构设计、批量写入、增量更新和性能调优里找到能直接抄的部分。标题里虽然写的是"测试",但我会把它当成一次完整的工程实践来拆,因为真正把数据灌进去之后你会发现,测试阶段的表结构如果设计得随意,后面返工的成本高得离谱。
先说清楚一个前提:本文讨论的是日线级别的行情数据,也就是每个标的每个交易日一条记录,字段大致是开盘、最高、最低、收盘、成交量、成交额这几项。分钟线、tick 线不在讨论范围内,因为量级完全不同——全市场日线一年也就一百多万条,而分钟线是这个数字的几百倍,两者的建表思路和写入方式必须分开对待。
注意:文中涉及的数据获取环节只讨论"你已经拥有合规数据源"之后的事情,数据来源的合法性请自行确认,本文不涉及任何具体数据供应商的推荐。
2. 表结构设计:别急着建表,先把这几个坑想明白
2.1 主键怎么定,决定了后面所有写入逻辑
我见过太多人第一版建表是这么写的:一个自增 id 做主键,然后代码插入、日期、开高低收各一个字段,最后加个唯一索引了事。表能跑,但一旦开始做增量更新就会发现问题——你需要先查一遍"这条记录存不存在",再决定 insert 还是 update,一来一回多出一次查询,几百万条数据下来,光这部分开销就够呛。
更合理的做法是直接把业务主键做成联合主键:(代码, 交易日期)。理由很直接,日线数据的唯一性定义就是这个二元组,一个标的在一个交易日只可能有一条记录。这样带来两个好处:一是写入时可以直接用INSERT ... ON DUPLICATE KEY UPDATE或者REPLACE INTO,一条语句搞定"存在则更新、不存在则插入",省掉前置查询;二是这个联合主键本身就是最常用的查询条件(按标的查一段时间的行情),天然形成聚簇索引,范围扫描效率很高。
需要提醒的是,如果用的是 InnoDB,联合主键的顺序会影响索引结构。把代码放前面、交易日期放后面,是因为绝大多数查询是"先锁定一个标的,再看时间段",前缀匹配能吃到索引。反过来如果日期在前,按标的查询就没法用上前缀,只能走全索引扫描。这个细节在数据量小的时候完全感知不到,等表到了千万级,差别会非常明显。
2.2 字段类型的选择,直接关系到存储和精度
价格字段用什么类型是个经典争议点。用FLOAT看着省空间,但浮点数在十进制小数上的表示误差会让"两个价格是否相等"这类判断变得不可靠,做涨跌停判断、做价格区间边界统计时容易出岔子。用DOUBLE精度好一些,但依然不是精确十进制。我的建议是价格统一用DECIMAL(10,3)或者DECIMAL(12,4),具体位数看你的数据源精度,多数日线数据保留两位小数,留一位冗余就够了。
成交量用BIGINT,不要用INT。单个标的单日成交量以手为单位通常在几十万到几千万之间,INT的上限是 21 亿多,看着够用,但一旦你的数据源单位是"股"而不是"手",或者遇到极端活跃的标的,溢出风险是真实存在的。成交额同理,用DECIMAL(18,2)比较稳妥,因为成交额的量级可以达到百亿级别,用整型会丢精度。
日期字段建议用DATE类型而不是VARCHAR(10)。字符串存日期在查询时需要隐式转换,按时间段筛选会失去索引效果,而且排序是按字典序来的,虽然YYYY-MM-DD格式恰好也和字典序一致,但类型语义上就该是日期。字符集和排序规则记得统一,如果代码字段里存在字母,注意大小写敏感性的设置要和你的数据源保持一致。
2.3 一份可以直接用的建表语句
把上面的考虑落成 SQL,大致是这样:
CREATE TABLE stock_daily ( code VARCHAR(12) NOT NULL COMMENT '标的代码', trade_date DATE NOT NULL COMMENT '交易日期', open DECIMAL(12,4) NOT NULL DEFAULT 0 COMMENT '开盘价', high DECIMAL(12,4) NOT NULL DEFAULT 0 COMMENT '最高价', low DECIMAL(12,4) NOT NULL DEFAULT 0 COMMENT '最低价', close DECIMAL(12,4) NOT NULL DEFAULT 0 COMMENT '收盘价', volume BIGINT NOT NULL DEFAULT 0 COMMENT '成交量', amount DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '成交额', PRIMARY KEY (code, trade_date), KEY idx_trade_date (trade_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='日线行情表';这里额外加了一个idx_trade_date的普通索引。原因是我在实际使用中发现,"按某一天取全市场数据"是高频操作——比如算当日全市场涨跌分布、做横截面因子。联合主键的前缀是code,按日期单独查询用不上它,所以补一个日期索引是值得的。如果你的场景里跨标的按日期查询不多,这个索引可以省掉,能减少写入开销。
提示:字段名不要用
open这种和 SQL 关键字沾边的词吗?实际上open在 MySQL 里不是保留字,可以直接用。但为了跨数据库兼容,我习惯加个表前缀,比如t_open,或者直接写open_price。这个看团队规范,没有绝对对错。
3. 数据从哪来、怎么读进来:解析环节的实操细节
3.1 本地数据文件的读取路径选择
拿到日线数据通常有两条路:一是从行情软件的导出功能生成文本文件,二是直接解析软件目录下的二进制数据文件。前者简单但需要人工操作,且导出格式经常带表头、带单位、字段顺序不固定;后者可以自动化,但需要弄清文件结构,而且软件数据结构一旦升级就要跟着改解析逻辑。
我自己的选择是优先用导出文本这条路做首次全量导入,因为文本格式肉眼可验证,出问题容易定位;自动化更新则用二进制解析或接口拉取。这个组合的逻辑是:全量导入是一次性动作,可靠性优先;增量更新是每天都要跑的,自动化优先。两种方式的字段最终都会归一到同一套结构,落库逻辑可以复用。
文本解析时最容易踩的坑是编码。很多导出文件是 GBK 编码,直接按 UTF-8 读会得到乱码,代码字段里的字母还好,中文名称字段直接报废。稳妥做法是读文件时显式指定编码,或者先探测 BOM。另外要注意行尾符,Windows 下是\r\n,如果按\n切分,每行末尾会多一个\r,粘到最后一个字段上,导致数字解析失败——这个错误我排查过一次,报错信息指向类型转换,但真正的原因在行尾符。
3.2 用 pandas 做批量读取和清洗
一次性读入几十万行文本,用 Python 的 csv 模块逐行处理是可以的,但 pandas 更省事,尤其是后面的批量写库能直接对接。读的时候把日期列显式转成日期类型,数值列指定类型,避免 pandas 自己推断出奇怪的 dtype:
import pandas as pd df = pd.read_csv( "daily_export.txt", sep="\t", encoding="gbk", dtype={ "code": "string", "open": "float64", "high": "float64", "low": "float64", "close": "float64", "volume": "int64", "amount": "float64", }, parse_dates=["trade_date"], ) df = df.dropna(subset=["code", "trade_date"]) df = df[df["close"] > 0]这里做了两步清洗:丢掉关键字段为空的行,丢掉收盘价为 0 的行。第二条看起来多余,但实际数据里确实会出现停牌期间用 0 占位、或者数据源本身有脏记录的情况。把这类记录挡在入库之前,比入库之后再处理要省事得多,因为脏数据一旦进了表,后续所有统计都会带着误差跑。
清洗环节还有几个我常用的检查项:一是日期是否落在合理区间,比如出现 1990 年之前的记录基本可以判定为解析错误;二是高低价的关系是否成立,high >= max(open, close)且low <= min(open, close),不成立的行值得单独看一眼;三是成交量为 0 但价格有波动的记录,这类通常是数据源的问题。这些校验写成断言或者打印统计,跑一次全量就能对数据质量心里有数。
3.3 内存与分批的权衡
全市场几年的日线数据,行数大概在几百万这个量级。pandas 全读进内存完全没问题,但如果你要处理的是十几年的历史数据,或者同时要导入多个市场,内存占用就要考虑一下了。我的经验值是每百万行日线数据在 pandas 里占用的内存大概在几百 MB,具体取决于列数和类型,把价格列设成float64而不是 object 能省很多。
真要控内存,就用分块读取,chunksize设成 10 万到 50 万比较合适。块太小会导致写库次数变多、事务开销上升,块太大又失去分批的意义。这个参数的调整没有玄学,跑一次看耗时和内存曲线就能定下来。
4. 写库方式的选择:为什么批量远比逐条快
4.1 逐条插入为什么不可取
新手最常见的写法是遍历 DataFrame 的每一行,拼一条INSERT发出去。几万行数据,这么写可能要跑好几分钟,几十万行直接让人失去耐心。慢的原因有三层:每条语句都是一次网络往返,即使本机也要走协议栈;每条INSERT都是一个事务,MySQL 默认要写 redo log 并刷盘;语句解析、执行计划生成这些固定开销被重复付了无数遍。
我用一份十万行左右的测试数据对比过几种方式,耗时量级差别非常悬殊:逐条插入在两三分钟的级别,用executemany批量提交降到十几秒,用LOAD DATA或者多值INSERT能压到几秒。这个差距不是线性的,是数量级的。所以如果不是特殊场景,逐条插入这个选项可以直接划掉。
4.2 三种批量方式的对比与选型
| 写入方式 | 十万行耗时量级 | 优点 | 适用场景 |
|---|---|---|---|
| 逐条 INSERT | 百秒级 | 实现最简单,错误定位精确到行 | 调试、极小数据量 |
| executemany 批量 | 十秒级 | 代码改动小,能配合事务控制 | 日常增量更新 |
| 多值 INSERT / LOAD DATA | 秒级 | 吞吐最高 | 首次全量导入 |
executemany的原理是把多条语句的参数列表一次性交给驱动,驱动会做一定程度的合并,减少往返次数。它对代码的侵入很小,pandas 的to_sql底层在部分驱动下也走这条路。写法上要注意把chunksize设上,否则一次提交几十万行的事务,回滚段会撑得很大,出错时也难恢复。
LOAD DATA LOCAL INFILE是最快的,因为它是服务端直接解析文件,绕过了逐行协议传输。但它有几个前提:客户端和服务端都要开启local_infile,数据要先落成格式规整的文本文件,而且错误处理不如逐行插入友好——出问题时往往只告诉你哪行有问题,不会精确到字段。另外部分环境出于安全考虑默认禁用这个功能,需要提前确认。
我通常的做法是:首次全量导入用LOAD DATA,把 DataFrame 先写成临时 TSV 文件;日常增量用executemany配合ON DUPLICATE KEY UPDATE,因为增量数据量小,代码简洁更重要。
4.3 增量更新的幂等设计
日线数据的增量更新有个绕不开的问题:同一天的数据可能会重复拉取。比如你今天跑了一次,明天补数据时又把昨天的范围重拉了一遍,如果不做去重,表里就会出现主键冲突或者重复行。
解决方式就是前面提到的ON DUPLICATE KEY UPDATE。这条语句的语义是:尝试插入,如果主键冲突就执行更新。配合联合主键(code, trade_date),天然实现了幂等——同一份数据灌十次,结果和灌一次一样。
INSERT INTO stock_daily (code, trade_date, open, high, low, close, volume, amount) VALUES (%s, %s, %s, %s, %s, %s, %s, %s) ON DUPLICATE KEY UPDATE open = VALUES(open), high = VALUES(high), low = VALUES(low), close = VALUES(close), volume = VALUES(volume), amount = VALUES(amount);有一个性能细节值得注意:ON DUPLICATE KEY UPDATE在冲突时的开销比纯插入高,因为要定位已有行再更新。如果你的增量数据每天只有几千行,这点开销可以忽略;但如果重现历史、批量补数据,建议先删后插,或者干脆把这段日期范围的数据整体替换掉,比逐行判断冲突要快。
注意:MySQL 8.0.20 之后
VALUES()函数的写法被标记为不推荐,新版本建议用别名语法。如果你的环境版本较新,可以写成INSERT ... AS new ON DUPLICATE KEY UPDATE open = new.open这种形式,避免未来版本不兼容。
5. 一次完整的实操流程走查
5.1 环境准备与连接配置
本地测试环境用 Docker 起一个 MySQL 是最省事的,避免了版本混乱和端口占用:
docker run -d --name stock-mysql \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=yourpassword \ -e MYSQL_DATABASE=market \ mysql:8.0 \ --character-set-server=utf8mb4 \ --collation-server=utf8mb4_0900_ai_ci启动参数里显式指定字符集是有必要的,虽然 8.0 默认就是 utf8mb4,但显式写出来能避免镜像升级后默认值变化带来的意外。排序规则选utf8mb4_0900_ai_ci表示大小写不敏感,如果你的代码字段需要区分大小写(比如同时存在sh和SH表示不同标的),要换成_bin或_as_cs结尾的排序规则。
Python 侧用PyMySQL就够,纯 Python 实现无需编译,装起来没有门槛;对性能要求高的场景可以换mysqlclient或者pymysql配合cryptography处理新版认证插件。Python 3.9 之后连接 MySQL 8 的caching_sha2_password认证方式需要额外的加密库,如果连接时报"Authentication plugin cannot be loaded",装一下cryptography就能解决。
5.2 建表、导入、验证的完整脚本
把前面的思路串起来,一个可运行的流程大致如下。我把它拆成连接、建表、读取、批量写入、校验五段,每段都能单独调试:
import pymysql import pandas as pd from sqlalchemy import create_engine # 1. 建连接 engine = create_engine( "mysql+pymysql://root:yourpassword@127.0.0.1:3306/market?charset=utf8mb4" ) # 2. 建表(DDL 只跑一次) with engine.connect() as conn: conn.execute(""" CREATE TABLE IF NOT EXISTS stock_daily ( code VARCHAR(12) NOT NULL, trade_date DATE NOT NULL, open DECIMAL(12,4) NOT NULL DEFAULT 0, high DECIMAL(12,4) NOT NULL DEFAULT 0, low DECIMAL(12,4) NOT NULL DEFAULT 0, close DECIMAL(12,4) NOT NULL DEFAULT 0, volume BIGINT NOT NULL DEFAULT 0, amount DECIMAL(20,2) NOT NULL DEFAULT 0, PRIMARY KEY (code, trade_date), KEY idx_trade_date (trade_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 """) # 3. 读数据(假设已清洗) df = pd.read_csv("daily_clean.tsv", sep="\t", encoding="utf-8", parse_dates=["trade_date"]) # 4. 分批写入 batch = 50000 for start in range(0, len(df), batch): chunk = df.iloc[start:start + batch] rows = list(chunk.itertuples(index=False, name=None)) with engine.begin() as conn: sql = """ INSERT INTO stock_daily (code, trade_date, open, high, low, close, volume, amount) VALUES (%s, %s, %s, %s, %s, %s, %s, %s) ON DUPLICATE KEY UPDATE open=VALUES(open), high=VALUES(high), low=VALUES(low), close=VALUES(close), volume=VALUES(volume), amount=VALUES(amount) """ cursor = conn.connection.cursor() cursor.executemany(sql, rows) print(f"已写入 {min(start + batch, len(df))} / {len(df)}")这里有几个实操要点值得单独说。itertuples比iterrows快很多,因为后者会为每一行生成 Series 对象;name=None让它直接返回元组,省掉字段名结构。事务用engine.begin()包住每一批,保证一批要么全成功要么全失败,避免出现半批数据造成的状态不一致。
批大小 5 万是我的经验值,十万也可以,但要留意max_allowed_packet这个参数。多值插入拼出来的语句是整条发给服务端的,如果单条语句超过这个限制会直接报错。默认值在 8.0 里是 64MB,5 万行的多值语句远低于这个数,但如果你把批调到几十万,就要算一算语句长度了。粗略估算:每行约 60 字节的参数加 SQL 模板,5 万行大概 3MB 上下,很安全。
5.3 入库后的数据校验
写完之后一定要校验,不能相信"没有报错就是对的"。我常用的三条校验 SQL:
-- 1. 总行数与日期跨度 SELECT COUNT(*) AS total_rows, MIN(trade_date), MAX(trade_date) FROM stock_daily; -- 2. 是否存在价格关系异常 SELECT COUNT(*) FROM stock_daily WHERE high < low OR high < open OR low > close; -- 3. 每个标的的记录数分布,找出数据明显缺失的标的 SELECT code, COUNT(*) AS days FROM stock_daily GROUP BY code HAVING days < 100 ORDER BY days LIMIT 20;第一条对总数量,和源文件行数应该基本一致(去掉清洗掉的脏数据)。第二条查价格逻辑矛盾,正常情况下应该返回 0。第三条能快速发现"某些标的只有零星几条记录"的问题,这类通常是解析时字段错位导致的,比如把日期列读成了代码列。
再补一个精度校验:随机抽几个标的一天,把库里的开高低收和原始文本的值逐个比对。这个动作看起来笨,但 DECIMAL 字段如果位数设得不够,或者中间经过了浮点转换,舍入误差就是这么被发现出来的。
6. 性能调优与常见问题排查实录
6.1 批量写入慢的几个真实原因
先说一个反直觉的:全量导入时把索引全部建好再灌数据,往往比先删索引、灌完再建要慢。原因是每插入一行都要维护 B+ 树,随机插入的成本远高于批量排序后建索引。我的做法是首次导入时只保留主键(主键索引没法删),把idx_trade_date挪到导入完成后通过ALTER TABLE ... ADD INDEX加上,速度能提升一截。
第二个原因是autocommit或者"每行一个事务"。这一点前面说过,但值得再强调:InnoDB 每提交一次事务就要做一次日志刷盘,磁盘的 IOPS 是硬瓶颈。把几千行合成一个事务,刷盘次数从几千降到个位数,差别是数量级的。
第三个原因是服务端参数没调。测试环境里可以适度调大innodb_buffer_pool_size,让热数据尽量留在内存里;innodb_flush_log_at_trx_commit在导入阶段可以暂时设成 2(每秒刷盘而不是每次提交刷盘),导入完成后再改回 1。这些改动只在你能控制服务端配置时有效,如果用的是托管数据库就跳过这部分。
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
| 导入速度随行数线性变慢 | 索引维护 + 事务过多 | 分批提交、延后建二级索引 |
| 速度忽快忽慢 | 刷盘与检查点波动 | 调整刷盘策略、增大缓冲池 |
| 单条语句报包过大 | 批大小超过 max_allowed_packet | 调小批次或调大参数 |
| CPU 打满但写入慢 | 字符集转换、类型隐式转换 | 统一连接与表的字符集 |
6.2 编码问题引发的连锁故障
编码问题在我的排查记录里出现频率排前三。典型表现是插入时报Incorrect string value,或者中文乱码但英文正常。根因通常是三层编码不一致:数据文件编码、Python 读取时的解码、连接字符集。
排查顺序我固定为:先确认文件真实编码(用二进制方式打开看前几个字节,或者用文件工具检测),再确认 pandas 读取时的encoding参数,然后确认连接串里的charset,最后确认表和列的字符集。四层里任何一层不匹配都会出问题。最省事的配置是全部统一到 utf8mb4,包括文件的重新编码,别指望中间层去猜。
还有一个隐蔽的坑:utf8在 MySQL 里指的是utf8mb3,最多 3 字节,遇到某些字符会存不下。建表和连接都写utf8mb4就不会有这个问题。
6.3 重复数据与主键冲突的处理策略
日线数据重复的来源大概三种:重复跑同一批导入、数据源本身推送了重复记录、跨数据源合并时产生了重叠。第一种靠幂等语句解决;第二种要在入库前用drop_duplicates(subset=["code", "trade_date"], keep="last")去重,keep="last"的含义是保留最后一条,通常对应最新修正过的数据;第三种要定义优先级,比如以某个源为准,冲突时整体替换而不是逐字段合并。
需要警惕的是主键冲突报错本身。如果用普通INSERT而不是幂等写法,遇到冲突会直接抛异常,整个批次回滚。在调试阶段这是好事,能立刻发现问题;但在生产脚本里,我更倾向于用幂等语句加上日志统计"本次更新了多少行、新增了多少行",通过cursor.rowcount拿到受影响行数来监控。注意ON DUPLICATE KEY UPDATE时这个返回值有特殊语义:新增算 1,更新算 2,未变化算 0,统计时要留意。
6.4 查询端的索引使用与慢查询
数据进去之后,查询变慢的抱怨通常来自两个地方:一是没走索引的全表扫描,二是SELECT *拉回了用不到的列。前者用EXPLAIN看执行计划,重点看type是不是ALL、key是不是NULL。按标的查时间段应该命中主键,type显示为range;如果显示ALL,检查一下查询条件里的日期是不是用了函数包起来,比如DATE(trade_date) = '2024-01-02'会让索引失效,正确写法是直接比较。
后者是个习惯问题。日线表字段不多,SELECT *的代价不大,但如果后面加了复权因子、行业分类这些宽字段,拉全列就浪费了。养成只选需要的列的习惯,尤其是在批量计算场景里。
还有一个容易忽略的点:ORDER BY trade_date DESC LIMIT 10这种"取最近几天"的查询,如果按标的过滤再排序,走主键前缀后还是要排序。数据量大时可以考虑在标的维度上冗余一个自增序号,或者直接依赖日期索引加覆盖条件。具体选择取决于你的查询分布,拿EXPLAIN实测一次最快。
6.5 我踩过的几个具体坑
第一个坑是日期类型的隐式转换。早期我用字符串存日期,查询写WHERE trade_date BETWEEN '2024-01-01' AND '2024-12-31',看似正常,但在某些数据上返回结果少了几天,原因是字符串比较在月份位数不一致时出问题。改成DATE类型后用STR_TO_DATE或者直接传日期对象,问题消失。这个教训让我把所有时间字段全部换成原生日期类型。
第二个坑是成交量的单位不一致。不同数据源导出的成交量,有的是手,有的是股,混在一张表里做统计会得出荒谬的结论。解决办法是在表里加一列记录单位,或者在导入环节统一换算并写死在注释里。我选择后者,字段注释明确写"单位:手"。
第三个坑是批量更新时忘记包事务。有一次脚本跑到一半网络抖动,前面的批次已经提交,后面的失败退出,结果表里数据处于半新半旧的状态,重跑又因为幂等语句重复更新了一遍。后来我改成每一批一个事务,并且记录"已完成到哪个批次",失败后可以断点续传。这个改进看起来麻烦,但一次数据修复的成本远高于写这几十行代码。
第四个坑是关于DECIMAL精度的。一开始我把价格设成DECIMAL(8,2),想着两位小数足够,结果数据源里有些价格保留三位,导入时被静默四舍五入,虽然后来发现得早,但那次做的回测结果全部作废。现在我一律留两位冗余。
提示:所有 DDL 变更,尤其是字段类型收窄,一定要先在副本库里跑一遍全量数据,看有没有精度丢失或溢出警告。MySQL 在部分情况下会静默截断而不是报错,这是最危险的。
7. 数据量长大之后:分区、归档与后续扩展
7.1 什么规模该考虑分区
全市场日线一年大概一百多万行,五年五百万行,十年一千万行出头。这个量级对 InnoDB 来说完全在舒适区内,单表几千万行配好索引,常规查询毫无压力。所以我的观点是:别过早分区。分区带来的复杂度——分区键必须在主键里、跨分区查询性能不确定、DDL 操作受限——在数据量没到瓶颈之前都是纯负担。
真正需要分区的情况通常是数据范围扩展到分钟级,或者要把多个市场、多个品种全部塞进一张表,行数到了亿级。这时候按年份做RANGE分区是自然选择,因为查询天然带日期条件,分区裁剪能直接砍掉大部分数据。分区键必须包含在主键里,所以主键要改成(code, trade_date)已经满足条件,按trade_date的年份做范围分区即可。
7.2 历史数据的归档思路
日线数据的特点是"越老越少查"。回测通常看最近几年,老数据只在做长周期统计时才用。基于这个特点,可以按时间做冷热分离:最近三年的留在主表,更早的迁移到归档表或者导成压缩文件。查询时如果需要老数据再单独查归档。
这个策略的收益不在于省空间——一千万行日线的存储其实不大——而在于减少主表的索引高度和扫描范围,让热查询更快。实施上可以写个定时任务,每年年初把三年前的数据INSERT INTO ... SELECT到归档表再DELETE主表的对应范围,注意DELETE要分批做,避免大事务锁表。
7.3 往上下游延展的几个方向
数据入库只是起点,往下走有几个自然的方向。往上,是数据质量的持续监控:每天增量导入后自动跑一遍校验 SQL,把异常行数和异常标的记录下来,形成数据质量日报,这样能第一时间发现数据源的问题,而不是等回测结果离谱了才回头查。
往下,是查询层的封装。把常用查询(按标的取区间、按日期取全市场、计算区间涨跌幅)封装成视图或者函数,业务代码不用再手写 SQL。再进一步是做一层物化的汇总表,比如每个标的的月度涨跌幅、年度波动率,用空间换时间。
还有一个方向是和其他维度的数据做关联。日线表只有行情,如果加上行业分类、财务指标、指数成分这些维度表,用code和日期做 JOIN,就能支撑横截面选股和分组回测。这时候表设计的规范性就体现出价值了——主键清晰、类型统一、没有冗余字段的表,关联起来才不别扭。反过来说,如果一开始把各种信息都塞进一张宽表,后面每加一个维度都要改表结构,维护成本会滚雪球。
我个人在实际操作中的体会是,这个"把日线数据放进数据库"的动作,技术难度并不高,难的是把它做得可持续:表结构经得起后面加字段、写入逻辑扛得住重复跑、查询习惯撑得住数据量增长。第一次做的时候花两个小时建表灌数据很容易,难的是半年后回头看这张表,还愿意在里面加东西而不是推倒重来。所以建表那一步多花十分钟想清楚主键和类型,后面能省掉很多返工。
最后分享一个我一直在用的小技巧:每次导入完成后,把本次的行数、日期范围、耗时、异常计数写进一张import_log表。这张表不参与业务,但当你某天发现数据对不上时,能立刻定位到是哪一次导入出的问题、影响了哪个日期区间。这个习惯帮我省过好几次大事,成本只是多一张小表和几行插入。