一块行情数据的"时间旅行"

凌晨 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 线数据做因子回测

  1. 访问 tickdb.ai 注册(免费,无需信用卡)
  2. 在控制台申请 API Key,配置环境变量 TICKDB_API_KEY
  3. 复制本文代码,将 symbolinterval 替换为你的目标标的,即可运行

如果你关心 TickDB 的数据边界:目前 TickDB 提供 10 年级别的美股历史 K 线数据(清洗对齐),分钟/小时/日/周多周期支持。trades 逐笔接口目前不支持美股和 A 股,港股和数字货币支持 depth 频道 10 档深度。

如果你习惯用 AI 辅助开发:在 AI 助手中搜索安装 tickdb-market-data SKILL,可以直接在对话中调用 TickDB 数据接口做策略验证。


免责声明:本文不构成任何投资建议。TickDB 为技术文章提供数据引用,不对任何策略的实盘收益负责。市场有风险,投资需谨慎。