一块行情数据的"时间旅行"
凌晨 3:17,某量化基金的因子系统触发了一次罕见查询:回溯苹果公司 2015 年以来的全部日线数据,叠加 2016 年几次财报的分钟级快照,交叉比对波动率因子的失效时点。
这个查询涉及的数据点超过 2.3 亿行,跨 8 年时间窗口。系统返回结果的时间:1.4 秒。
这个数字背后不是运气,是一套经过精细设计的存储架构在支撑。本文不聊玄学,从原理层面拆解 TickDB 的历史行情数据存储体系:时序数据的底层模型如何设计、冷热分离怎么做、查询路径上有哪些被反复打磨的优化点。
为什么行情数据是特殊的时序数据
在说存储架构之前,先要理解行情数据的特殊性。
通用时序数据库(TSDB)处理的是传感器读数、监控指标、日志事件,核心访问模式是"最新 N 条"或"最近一个时间窗口"。而行情数据有完全不同的访问特征:
访问模式的双峰分布。实盘策略只关心最近几分钟到几小时的 K 线——这部分是热数据,访问频率极高。但当你做因子回测或事后分析时,往往要从 2014 年的数据开始跑——这部分是冷数据,单次查询的数据量巨大,却同样要求秒级响应。
查询条件的组合复杂度。一条历史 K 线查询往往伴随多个过滤条件:特定时间范围、特定交易品种、特定的 K 线周期(1m/5m/1h/1d),以及组合上述条件的嵌套查询。简单的范围扫描无法满足需求。
数据对齐的严格性。两份不同数据源的行情数据,必须在时间轴上精确对齐,否则因子计算结果会系统性偏差。2014 年某天的 bar 是从 09:30:00 开始还是从 09:30:01 开始,会直接影响配对交易的收益率统计。
这三重特性决定了 TickDB 不能简单套用通用时序数据库的默认配置,必须在存储层做针对性设计。
整体架构:从请求到数据的完整路径
在展开每个模块之前,先给出 TickDB 存储架构的全景:
┌─────────────────────────────────────────────────────────────┐
│ 查询请求 │
│ GET /v1/market/kline?symbol=AAPL.US&... │
└─────────────────────────┬───────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ API Gateway Layer │
│ 认证鉴权 → 请求路由 → 限频控制 (code:3001) │
└─────────────────────────┬───────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Query Optimization Layer │
│ 查询计划生成 → 冷热路径分流 → 并行执行 → 结果归并排序 │
└──────────┬────────────────────────────┬────────────────────┘
│ 热数据路径 │ 冷数据路径
▼ ▼
┌──────────────────────┐ ┌────────────────────────────────┐
│ Redis 缓存集群 │ │ 分层存储引擎 │
│ (最近 48h 分钟线) │ │ Hot SSD Tier + Warm HDD Tier │
│ 毫秒级响应 │ │ + Cold S3 Tier │
└──────────────────────┘ │ 列式存储 + 块级压缩 │
└────────────────────────────────┘
│
▼
┌─────────────────┐
│ 索引加速层 │
│ (symbol + time) │
│ 布隆过滤器预筛 │
└─────────────────┘
核心设计原则:冷热分离 + 列式存储 + 分区索引。这三个决策点覆盖了几乎所有高并发时序查询系统的核心挑战。
分区策略:时间维度与标的维度的交叉设计
时间窗口分区
TickDB 的数据在存储层按时间窗口做粗粒度分区。分区大小的选择是一个经典工程权衡:分区太大,单次小范围查询会扫描过多无关数据;分区太小,元数据管理开销急剧上升。
TickDB 采用了混合分区策略:
| 时间窗口 | 分区粒度 | 存储介质 | 典型场景 |
|---|---|---|---|
| 最近 48 小时 | 分钟级分区 | Redis SSD | 实盘策略、超短期因子 |
| 3 天 - 90 天 | 小时级分区 | NVMe SSD | 日内回测、事件研究 |
| 90 天 - 3 年 | 天级分区 | HDD/SSD 混合 | 中期策略回测 |
| 3 年以上 | 月级分区 | 对象存储 (S3) | 长周期研究、样本外验证 |
这个分层的依据是访问热力图的统计分布。根据 TickDB 内部的访问日志,90% 以上的查询发生在最近 30 天内,99% 发生在最近 3 年内。分层策略的本质是:用更高的存储成本服务高频访问,用更低的单位存储成本覆盖低频但数据量巨大的长周期查询。
标的维度分区
在同一时间分区内,数据按交易标的(symbol)做二级分区。这是避免查询时跨标的扫描的关键设计。
# 分区键设计示意(逻辑层,不对应真实内部实现)
partition_key = f"{symbol_bucket}/{year}/{month}"
# 示例
# APPL.US/2024/03/ → 该月全部 AAPL.US 分钟线
# NVDA.US/2024/03/ → 该月全部 NVDA.US 分钟线
这里有一个容易被忽视的细节:symbol 的分组策略。如果简单按股票代码字母顺序分区,相关标的(同一行业的股票、同一次事件的受益链)会散落在不同分区,跨标的查询(如板块轮动因子)会产生大量跨分区扫描。
合理的做法是按流动性分组:将日均成交额相近的标的放在同一组,确保每个分区的数据量相对均衡,查询时的并行度也更容易优化。
列式存储:压缩率与查询效率的平衡
为什么是列式而非行式
行式存储(如 MySQL InnoDB)是按行连续写入,单行数据的所有字段物理上相邻。这种布局对"查一条记录的多个字段"的场景非常友好(OLTP 场景),但对"查一个时间范围内的某一字段"是灾难——你需要扫描所有行才能取到目标列。
列式存储(如 Parquet、ORC)则相反:同一列的所有值物理上连续存储。对于 K 线查询,这个优势是决定性的:
# 场景:查询 AAPL.US 从 2020-01-01 到 2024-12-31 的收盘价
# 时间范围:约 1260 个交易日 × 390 分钟 = 约 49 万行
# 行式存储读取逻辑(示意)
for each row in all_rows:
if row.symbol == "AAPL.US" and row.time in range:
result.append(row.close) # 每次磁盘 IO 只取 1 个值
# 列式存储读取逻辑(示意)
close_column = read_column("close", symbol="AAPL.US", time_range)
result = close_column.filter_by_time_range(start, end) # 顺序读取,连续命中缓存
列式存储的另一个核心优势是列内压缩。同一列的值往往有高度相似性:close 价格在相邻行之间差异很小,volume 在大部分时间接近均值。使用列式压缩编码(如 Delta Encoding、Run-Length Encoding),可以将存储空间压缩到行式的 1/5 到 1/10,同时读取时解压开销极低。
Parquet 格式的实际收益
TickDB 内部在冷数据层使用了 Parquet 格式(以及自研的变体格式)。以日线数据为例,同样的 10 年数据:
| 指标 | 行式存储 (JSON) | 行式存储 (Protobuf) | 列式存储 (Parquet) |
|---|---|---|---|
| 单标的 10 年日线存储大小 | ~85 MB | ~18 MB | ~4 MB |
| 全量查询耗时 (symbol + time filter) | ~2400 ms | ~680 ms | ~85 ms |
| 磁盘占用 | 基准 | -78% | -95% |
压缩率的提升不只是节省存储成本,更重要的是减少了磁盘 IO 量。在机械硬盘时代,IO 是主要瓶颈;在 SSD 时代,虽然 IOPS 大幅提升,但 NVMe 直连的吞吐量仍有物理上限——压缩后每次 IO 能带回更多有效数据字节,等效于扩大了带宽。
索引体系:两级过滤的查询加速
第一级:分区裁剪(Partition Pruning)
查询请求进入后,第一步是分区裁剪——根据时间范围和标的过滤条件,把完全不可能包含数据的分区全部跳过。
# 查询优化器中的分区裁剪逻辑(示意)
def prune_partitions(query_time_range, query_symbols, all_partitions):
candidate_partitions = []
for partition in all_partitions:
# 时间范围完全不重叠 → 跳过
if partition.end_time < query_time_range.start:
continue
if partition.start_time > query_time_range.end:
continue
# 标的完全不匹配 → 跳过
if not partition.symbols.intersects(query_symbols):
continue
candidate_partitions.append(partition)
return candidate_partitions
这个步骤的优化效果是惊人的:一次 10 年日线查询,可能涉及 120 个月级分区,但如果标的只有 AAPL.US,优化器会直接确认 AAPL.US 的数据只存在于特定 symbol bucket 下的分区,将候选分区从 120 个缩减到 1-2 个。
第二级:块级布隆过滤器
在每个分区内,数据不是连续存储的,而是被分割为多个数据块(通常一个块包含数千行)。每个块附带一个布隆过滤器(Bloom Filter):
# 布隆过滤器工作示意
class BlockBloomFilter:
def __init__(self, size, hash_count):
self.bit_array = [False] * size
self.hash_count = hash_count
def add(self, item):
for seed in range(self.hash_count):
index = hash(item + str(seed)) % self.size
self.bit_array[index] = True
def might_contain(self, item) -> bool:
"""返回 True → 块内可能包含 item(需进一步验证)
返回 False → 块内一定不包含 item(安全跳过)"""
for seed in range(self.hash_count):
index = hash(item + str(seed)) % self.size
if not self.bit_array[index]:
return False
return True
布隆过滤器的核心特性是:假阳性(False Positive)不可避免,但假阴性(False Negative)绝不存在。这意味着:如果布隆过滤器说"这个块没有你要的数据",你就放心跳过;如果它说"可能有",你再打开块做精确比对。
实际应用中,布隆过滤器可以将无谓的块读取减少 60%-80%,对查询延迟的改善是数量级的。
第三级:列式块内索引
对于命中的数据块,列式格式的块内索引进一步加速了列级过滤。以 Parquet 为例,每个数据块内部维护了:
- 列的统计信息:min、max、null_count。查询时先检查目标值是否在
[min, max]范围内,如果不在,整个块可以跳过。 - 字典编码(Dictionary Encoding):低基数字段(如 symbol 代码)的唯一值被编码为整数,查询时直接用整数比较替代字符串比较,效率提升约 10 倍。
# 块内统计信息加速查询示意
class DataBlock:
close_min: float # 该块收盘价的最小值
close_max: float # 该块收盘价的最大值
time_min: int # 该块时间戳的最小值(epoch ms)
time_max: int # 该块时间戳的最大值
def might_contain(self, time_range, symbol) -> bool:
if self.time_max < time_range.start:
return False
if self.time_min > time_range.end:
return False
# ... symbol 过滤逻辑
return True
三层索引叠加后的效果:一次跨 10 年的查询,实际读取的磁盘数据量可能不到原始数据的 0.1%。
查询优化实战:避开那些拖慢 10 倍的陷阱
光有底层架构还不够,应用层的查询写法也会显著影响响应时间。以下是 TickDB 技术团队在内部监控和用户反馈中反复遇到的问题模式。
问题一:时间范围边界模糊导致全表扫描
# 危险写法:时间边界未指定
response = requests.get(
"https://api.tickdb.ai/v1/market/kline",
headers=headers,
params={"symbol": "AAPL.US", "interval": "1d"},
timeout=(3.05, 10)
)
# 问题:没有指定 limit 或 start_time/end_time
# API 可能返回全量数据(10 年 × 252 交易日 ≈ 2520 行)
# 看似"一次搞定",但数据量超出应用层预期时会 OOM
# 正确写法:分页查询 + 时间范围限制
response = requests.get(
"https://api.tickdb.ai/v1/market/kline",
headers=headers,
params={
"symbol": "AAPL.US",
"interval": "1d",
"start_time": int(datetime(2020, 1, 1).timestamp()),
"end_time": int(datetime(2024, 12, 31).timestamp()),
"limit": 500 # 每页上限,超出时分页
},
timeout=(3.05, 10)
)
问题二:循环内请求(最常见的 N+1 问题)
# 危险写法:串行请求 50 只股票的日线数据
symbols = ["AAPL.US", "MSFT.US", "NVDA.US", ...] # 50 只
for symbol in symbols: # 串行执行,每个请求 200ms,总耗时 10 秒
response = requests.get(
"https://api.tickdb.ai/v1/market/kline",
headers=headers,
params={"symbol": symbol, "interval": "1d", "limit": 100}
)
# 正确做法一:并发请求(注意限频处理)
import concurrent.futures
def fetch_kline(symbol):
try:
response = requests.get(
"https://api.tickdb.ai/v1/market/kline",
headers=headers,
params={"symbol": symbol, "interval": "1d", "limit": 100},
timeout=(3.05, 10)
)
return response.json()
except Exception as e:
return None
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
# max_workers 控制并发度,避免出发限频
results = list(executor.map(fetch_kline, symbols))
# 正确做法二:使用 batch 端点(如果有的话)
# 询问 TickDB 是否支持批量查询接口,一次获取多标的数据
问题三:错误的接口选择
这是最容易踩但又最隐蔽的坑:
# 危险写法:用 /kline/latest 做回测
# /kline/latest 返回的是当前正在形成的 K 线(未关闭)
# 用于回测会导致"未来函数"——你看到了收盘后才生成的数据
response = requests.get(
"https://api.tickdb.ai/v1/market/kline/latest",
headers=headers,
params={"symbol": "AAPL.US", "interval": "1d"}
)
# ⚠️ 这个接口设计用于实盘监控,不适合历史回测
# 正确写法:用 /kline 并指定已结束的时间窗口
response = requests.get(
"https://api.tickdb.ai/v1/market/kline",
headers=headers,
params={
"symbol": "AAPL.US",
"interval": "1d",
"start_time": start_ts,
"end_time": end_ts, # 明确的结束时间,对应已关闭的 K 线
"limit": 1000
}
)
写入链路:从数据源到存储引擎
说完查询优化,再看写入侧——没有稳定高效的写入链路,查询优化就是无本之木。
TickDB 的数据写入链路设计了一个多级校验与重试机制,确保 10 年尺度的数据在持续写入过程中不出现空洞或错误对齐:
数据源 (交易所/API)
│
▼
┌─────────────────────────────────────────┐
│ 实时摄取层 (Ingestion Layer) │
│ - 数据完整性校验 (字段数、时间单调性) │
│ - 时间戳对齐 (交易所时间 → UTC → epoch) │
│ - 符号代码标准化 (AAPL → AAPL.US) │
└────────────────────┬────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ 写入缓冲层 (Write Buffer) │
│ - 内存缓冲 (批量累积,减少 IO 次数) │
│ - 刷盘阈值 (时间触发 + 大小触发) │
└────────────────────┬────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ 分层写入 │
│ - 热数据 → SSD (实时可查) │
│ - 冷数据 → S3 (异步归档) │
└─────────────────────────────────────────┘
关键设计点:时间单调性校验。行情数据有一个硬性约束:同一标的的 K 线时间戳必须严格单调递增。如果检测到乱序数据(通常由交易所重传或网络抖动导致),系统会触发告警并拒绝写入,而不是静默丢弃或覆盖已有数据。这个机制保证了回测数据的时间连续性——这是因子有效性的根基。
价值对比:TickDB 存储架构与常见方案的横向评估
对于需要自建行情存储的团队,以下是 TickDB 与常见方案的真实对比:
| 维度 | 自建 ClickHouse | 自建 InfluxDB | 自建 PostgreSQL | TickDB |
|---|---|---|---|---|
| 10 年日线存储 | 需手动分表、生命周期管理 | 需配置 TSM 引擎和 RP 策略 | 需分区表 + 压缩扩展 | 开箱即用,自动分层 |
| 跨周期查询(1m→1d 聚合) | SQL 聚合函数,需额外索引 | Continuous Query,需预计算 | Window Function,性能一般 | 查询引擎自动处理 |
| 订单簿深度 | 需额外建模 | 不支持原生 depth | 需 JSON 列或关联表 | 原生 depth 频道支持 |
| 冷热数据切换 | 需自行实现 S3 归档策略 | 需配置 downsampling | 需 pg_partman + 定时迁移 | 自动透明切换 |
| 接口复杂度 | SQL,需连接池管理 | InfluxQL / Flux | SQL | REST API,标准鉴权 |
| 数据对齐保障 | 需业务层处理 | 不保证 | 依赖时区配置 | 标准化处理 |
| 运维负担 | 高(集群管理、备份、调参) | 中(单节点简单,集群复杂) | 中(需持续 VACUUM/分区维护) | 零运维,纯 API 消费 |
| 适合团队规模 | 5 人以上基础设施团队 | 2-3 人,有一定运维经验 | 任意规模(但上限有限) | 个人到机构,按需扩展 |
这个对比的核心结论是:自建方案不是做不了,而是成本结构完全不同。自建方案的显性成本是云资源,隐性成本是持续的人力运维和数据正确性保障。对于核心业务不是基础设施建设的量化团队,这些隐性成本往往被低估。
部署方案:不同规模的接入建议
接入 TickDB 的存储能力,取决于你的实际使用场景:
| 场景 | 推荐方案 | 关键配置 |
|---|---|---|
| 个人策略验证 | REST API + Python 脚本 | limit 限制单次返回量,分页拉取;缓存最近 30 天数据到本地 SQLite |
| 日内策略(高频回测) | REST API + Redis 本地缓存层 | 热数据(48h 分钟线)缓存到 Redis,TickDB 作为冷数据源;并发控制 max_workers=5 |
| 多因子研究平台 | REST API + 异步批量拉取 | 后台任务预先拉取研究窗口数据到本地时序库(如 TimescaleDB),在线查询走本地 |
| 机构级因子工厂 | TickDB Enterprise API + 专线 | 批量数据导出 + WebSocket 订阅组合;定制 SLA 和数据溯源支持 |
结语
回到开篇那个场景:2.3 亿行数据,1.4 秒返回。
这个数字不是某一项技术的功劳,而是分层存储 + 列式压缩 + 三级索引 + 冷热分流四层优化叠加的结果。每层解决不同层次的问题:
- 分层存储解决了"热数据快、冷数据省"的空间与成本问题
- 列式存储解决了"大量数据怎么放"的磁盘效率问题
- 三级索引解决了"怎么快速定位目标数据"的搜索问题
- 冷热分流解决了"不同访问模式如何共存"的架构问题
如果你正在评估行情数据的基础设施选型,核心问题不是"哪个数据库最强",而是"我的访问模式是什么"——高频实盘与长周期回测对存储架构的要求截然不同。理解这一点,比记住任何具体产品的参数都更有价值。
下一步行动
如果你需要历史 K 线数据做因子回测:
- 访问 tickdb.ai 注册(免费,无需信用卡)
- 在控制台申请 API Key,配置环境变量
TICKDB_API_KEY - 复制本文代码,将
symbol和interval替换为你的目标标的,即可运行
如果你关心 TickDB 的数据边界:目前 TickDB 提供 10 年级别的美股历史 K 线数据(清洗对齐),分钟/小时/日/周多周期支持。trades 逐笔接口目前不支持美股和 A 股,港股和数字货币支持 depth 频道 10 档深度。
如果你习惯用 AI 辅助开发:在 AI 助手中搜索安装 tickdb-market-data SKILL,可以直接在对话中调用 TickDB 数据接口做策略验证。
免责声明:本文不构成任何投资建议。TickDB 为技术文章提供数据引用,不对任何策略的实盘收益负责。市场有风险,投资需谨慎。