
本文字数:6400;估计阅读时间:17分钟
作者:Lionel Palacin

编者按: 本文译自 ClickHouse 原博客(链接点文末-查看原文)。 原文围绕「用 ClickHouse 承载高频 tick 数据的实时写入、存储与查询。」展开。文章从报价与成交的基本概念切入,逐步搭建数据链路,对实时分析场景的选型有参考价值。技术栈组合清晰,适合作为同类应用的落地范例。
实时 tick 数据应用是实时分析的典型场景。追踪 Web 应用中的用户行为、监控物联网设备的指标,都属于这一类:面对高频事件流,需要以低延迟完成写入、存储和查询。
放到金融市场,区别就在于紧迫性。哪怕只延迟几秒,一笔本来盈利的交易也可能变成亏损。每一笔成交、每一次报价更新都会产生一个新的 tick,多个标的加在一起,每秒可能多达数千个。
ClickHouse 很适合这类负载。它能处理高频写入、基于时间的查询和低延迟查询。内置压缩能降低存储开销,哪怕每个标的有数十亿行也不成问题。物化视图可以在数据写入时就完成预聚合或重新组织,从而优化查询性能,无需额外增加处理层。
下面一步步演示如何构建一个实时 tick 数据应用:用 Massive(原 Polygon.io)获取市场数据,用 ClickHouse 实时存储和查询 tick,后端用 Node.js,实时可视化用 React。下面正式开始。
什么是 tick?
开始之前,有必要先弄清楚报价(quote)和成交(trade)分别是什么。报价代表市场参与者当前愿意买入或卖出某只证券的价格,具体包含最优买价(best bid,即有人愿意支付的最高价)和最优卖价(best ask,即有人愿意接受的最低卖出价)。随着新订单进入或退出市场,报价会不断更新。
| sym | bx | bp | bs | ax | ap | as | c | i | t | q | z | inserted_at |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| SPY | 12 | 602.73 | 2 | 11 | 602.74 | 6 | 1 | 1,93 | 1749583478206 | 63152225 | NYSE | 1749583479396 |
成交则是买卖双方之间真实发生的交易。当有人接受当前的卖价或买价、订单被撮合成交时,就产生了一笔成交。成交记录包含成交价、成交量和时间戳。
| sym | i | x | p | s | c | t | q | z | trfi | trft | inserted_at |
|---|---|---|---|---|---|---|---|---|---|---|---|
| SPY | 52983525034825 | 11 | 607.26 | 1 | 12,37 | 1750842255972 | 22126 | NYSE | 0 | 0 | 1750842257842 |
逐笔数据(tick data)通常来自两类数据流。一类是报价更新,另一类是成交记录。两者对理解市场行为都不可或缺,但在分析和策略开发中各有各的用处。
获取实时市场数据
清楚了要采集的数据类型,接下来看怎么获取。首先要找到并订阅一个股票市场 API。可选的服务商很多,本次演示选用的是 Massive。所选套餐必须明确包含通过 WebSocket 提供的股票 成交与报价。只有 REST API 密钥,或者只含聚合数据的套餐,都不够用。订阅前先确认当前的频道权限。
WebSockets 对行情数据的流式传输至关重要,因为它免去了轮询 REST API 的延迟与开销。每次请求数据都新建连接,还可能漏掉两次调用之间的逐笔更新;WebSocket 则维持一条长连接,数据一就绪就推送过来。对于毫秒必争的高频行情数据,这一点尤为关键。
用 Massive API 开始采集数据并不复杂:与 /stocks 端点建立连接,用你的 Massive API 密钥完成认证,然后开始处理消息。
下面这个独立的 Node.js 诊断脚本会先完成认证,认证成功后再订阅,并记录收到的事件。装好依赖、配置好 .env 之后,在示例目录下运行它。启动完整应用之前要先把它停掉;账户的连接数上限会导致多个客户端无法共用同一路数据。
require("dotenv").config();
const WebSocket = require("ws");
if (!process.env.MASSIVE_API_KEY) throw new Error("Set MASSIVE_API_KEY in .env");
const symbols = (process.env.MASSIVE_SYMBOLS || "AAPL,MSFT,NVDA")
.split(",").map((symbol) => symbol.trim()).filter(Boolean);
const ws = new WebSocket("wss://socket.massive.com/stocks");
ws.on("open", () => {
ws.send(JSON.stringify({ action: "auth", params: process.env.MASSIVE_API_KEY }));
});
ws.on("message", (data) => {
const payload = JSON.parse(data.toString());
for (const row of payload) {
if (row.ev === "status") {
console.log(row.status, row.message);
if (row.status === "auth_success") {
ws.send(JSON.stringify({
action: "subscribe",
params: symbols.flatMap((symbol) => [`T.${symbol}`, `Q.${symbol}`]).join(","),
}));
} else if (row.status === "auth_failed") {
ws.close();
}
} else if (row.ev === "T" || row.ev === "Q") {
console.log(row);
}
}
});
ws.on("error", (error) => console.error(error.message));
process.on("SIGINT", () => ws.close());
将数据写入 ClickHouse
在 ClickHouse 中为逐笔数据建模
Tick 数据的建模相对直接:它只有两类事件,一类是 trade,一类是 quote,每类事件的字段都不多,而且大多是数值型。下面是创建两张独立表的 DDL。t 是 SIP 时间戳,单位为毫秒;q 是每个标的的序列号。condition 和 indicator 数组用 UInt32,因为其取值可能超过 255。trade 表结构还保留了可选的 ds(小数数量,文本形式)和 pt(参与者时间戳)字段。可选字段缺失时使用默认值。当前 quote 的 size 以股数表示。示例中价格用 Float64,并对整数 trade 数量 s 求和;如果你的应用有需要,应改用合适的 decimal 类型,并对小数成交量单独处理。
CREATE TABLE IF NOT EXISTS quotes
(
`sym` LowCardinality(String),
`bx` UInt8,
`bp` Float64,
`bs` UInt64,
`ax` UInt8,
`ap` Float64,
`as` UInt64,
`c` UInt8,
`i` Array(UInt32),
`t` UInt64,
`q` UInt64,
`z` Enum8('NYSE' = 1, 'AMEX' = 2, 'Nasdaq' = 3),
`inserted_at` UInt64 DEFAULT toUnixTimestamp64Milli(now64())
)
ENGINE = MergeTree
ORDER BY (sym, t - (t % 60000));
CREATE TABLE IF NOT EXISTS trades
(
`sym` LowCardinality(String),
`i` String,
`x` UInt8,
`p` Float64,
`s` UInt64,
`ds` String DEFAULT '',
`pt` UInt64 DEFAULT 0,
`c` Array(UInt32),
`t` UInt64,
`q` UInt64,
`z` Enum8('NYSE' = 1, 'AMEX' = 2, 'Nasdaq' = 3),
`trfi` UInt64,
`trft` UInt64,
`inserted_at` UInt64 DEFAULT toUnixTimestamp64Milli(now64())
)
ENGINE = MergeTree
ORDER BY (sym, t - (t % 60000));
数据量会增长得很快。
开发阶段先订阅少量标的,测好吞吐量之后,再订阅整个市场。选择合适的 order key 对性能至关重要。在本例中,行首先按 sym(股票代码)排序,把同一标的的所有事件归到一起。在每个标的分组内,行按 t - (t % 60000) 排序,从而形成 1 分钟的时间桶。这种做法在这里很合适,因为我们是按标的聚合数据来生成可视化的。分钟桶把时间相近的事件聚在一起,而查询仍然用原始时间戳和序列号来确定事件顺序。排序键和过滤条件要结合实际负载做基准测试;单靠时间桶并不能保证每个查询都能高效裁剪。
数据写入策略
为这类应用设计数据写入管道有多种方式,比如借助 Kafka 这样的消息队列。不过,为了尽量降低延迟,通常更好的做法是保持系统简单:只要条件允许,就直接把数据从 WebSocket 连接推入 ClickHouse。
这套方案就绪之后,下一步是选择合适的写入方式。ClickHouse 同时支持同步插入和异步插入。
对于同步写入,数据先在客户端攒批,再发送出去。批大小(batch size)要在内存占用、延迟和系统开销之间取得平衡。批次越大,插入请求越少,吞吐越高,但内存占用会增加,单条记录的延迟也会变大。批次越小,内存压力越小,但会产生过多小数据分片,反而给 ClickHouse 带来更多负载。
异步写入时,数据持续发往 ClickHouse,攒批由内部完成。到达的记录先写入内存缓冲区,再按可配置的阈值刷写到存储。当客户端不方便攒批时------比如数据来自大量小客户端------这种方式很合适。
在这个场景里,同步写入更合适。数据只由一个客户端从 WebSocket API 推送过来,批处理可以放在客户端做,从而更好地控制性能和资源占用。
下面这个精简的攒批类展示了写入路径。创建一个 TickBatcher,把收到的 WebSocket 帧连同上面的认证处理程序一起传给它的 handleMessage 方法。在 await close() 之前先停止接收消息。完整示例另外加上了有界并发插入、重连处理、关闭超时和指标。两者都用同步插入,因此成功就意味着 ClickHouse 已确认写入。
const { createClient } = require("@clickhouse/client");
class TickBatcher {
constructor() {
this.client = createClient({
url: process.env.CLICKHOUSE_URL || "http://localhost:8123",
username: process.env.CLICKHOUSE_USERNAME || "default",
password: process.env.CLICKHOUSE_PASSWORD || "",
database: process.env.CLICKHOUSE_DATABASE || "default",
compression: { request: true, response: true },
clickhouse_settings: { async_insert: 0 },
});
this.batches = { trades: [], quotes: [] };
this.flushing = { trades: false, quotes: false };
this.failedRecords = 0;
this.droppedRecords = 0;
this.timer = setInterval(() => {
void this.flushBatch("trades");
void this.flushBatch("quotes");
}, 2000);
}
handleMessage(data) {
const payload = JSON.parse(data.toString());
for (const { ev, ...fields } of payload) {
const table = ev === "T" ? "trades" : ev === "Q" ? "quotes" : null;
if (!table) continue;
if (this.batches[table].length >= 10000) {
this.droppedRecords++;
continue;
}
this.batches[table].push(fields);
if (this.batches[table].length >= 1000) void this.flushBatch(table);
}
}
async flushBatch(table) {
if (this.flushing[table]) return;
this.flushing[table] = true;
try {
while (this.batches[table].length) {
const values = this.batches[table].splice(0, 1000);
try {
await this.client.insert({ table, values, format: "JSONEachRow" });
} catch (error) {
this.failedRecords += values.length;
console.error(`Insert failed for ${table}:`, error.message);
}
}
} finally {
this.flushing[table] = false;
}
}
async close() {
clearInterval(this.timer);
while (Object.values(this.flushing).some(Boolean)) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
await Promise.all([this.flushBatch("trades"), this.flushBatch("quotes")]);
await this.client.close();
}
}
这个精简类把每张待写入表的缓冲区限制在 10,000 条记录,并统计被丢弃或失败的记录;失败的写入不会重试。要做到不丢数据,还需要持久化缓冲、重放和去重。完整示例在 1,000 行或每两秒触发一次刷写,最多同时执行四个插入。
可视化实时市场数据
数据存进 ClickHouse 之后,搭建可视化层就很直接了。真正的难点在于写出正确的 SQL 查询。下面看看具体怎么做。我们聚焦支撑两个关键可视化所需的查询。第一个是实时表格,持续更新,展示某只股票的最新交易数据。

要做这个可视化,一个查询就够了,数据可以用 ClickHouse 强大的 SQL 查询语言和自定义函数来格式化。
with
{syms: Array(String)} as symbols,
toDate(now('America/New_York')) as curr_day,
trades_info as (
select
sym,
argMax(p, tuple(t, q)) as last_price,
round(((last_price - (argMin(p, tuple(t, q)))) / nullIf(argMin(p, tuple(t, q)), 0)) * 100, 2) as change_pct,
sum(s) as total_volume,
max(t) as latest_t
from
trades
where
toDate(fromUnixTimestamp64Milli(toInt64(t), 'America/New_York')) = curr_day
and sym in symbols
group by
sym
order by
sym asc
),
quotes_info as (
select
sym,
argMax(bp, tuple(t, q)) as bid,
argMax(ap, tuple(t, q)) as ask,
max(t) as latest_t
from
quotes
where
toDate(fromUnixTimestamp64Milli(toInt64(t), 'America/New_York')) = curr_day
and sym in symbols
group by
sym
order by
sym asc
)
select
t.sym as ticker,
t.last_price as last,
q.bid as bid,
q.ask as ask,
t.change_pct as change,
t.total_volume as volume,
toUnixTimestamp64Milli(now64()) - toInt64(greatest(t.latest_t, q.latest_t)) as latency
from
trades_info as t
left join quotes_info as q on t.sym = q.sym;
下面拆解一下这个查询做了什么。
首先定义两个变量:symbols 保存待分析的股票代码列表,curr_day 记录纽约时区的当前日期。
查询随后获取交易数据,包括:
• last_price :最新成交价,使用 argMax(p, tuple(t, q)) 在毫秒时间戳相同时按序列号打破平局
• change_pct :相对于当前纽约日历日第一笔已摄取交易的百分比变化,带有零分母保护。这不是官方开盘价或前收盘价。
• total_volume :该日历日已摄取的总整数股成交量
它还获取报价数据:
• bid :最新买价,使用 argMax(bp, tuple(t, q))
• ask :最新卖价,使用 argMax(ap, tuple(t, q))
最后,将交易和报价结果合并。额外的 latency 列报告最新收到事件距今的毫秒数,而不是只测量数据库插入时间。下列行是示例历史值。该演示不筛选交易条件,不处理取消/更正,也不重建官方交易所的 OHLCV 序列。
| ticker | last | bid | ask | change | volume |
|---|---|---|---|---|---|
| NVDA | 151.2099 | 151.2 | 151.21 | 2.17 | 65269276 |
我们要分析的第二个可视化是蜡烛图,展示某只股票的价格走势和成交量。

我们来看看驱动这一可视化的 SQL 查询。
select
toUnixTimestamp64Milli(toDateTime64(toStartOfInterval(fromUnixTimestamp64Milli(toInt64(t)), interval 2 minute), 3)) as x,
argMin(p, tuple(t, q)) as o,
max(p) as h,
min(p) as l,
argMax(p, tuple(t, q)) as c,
sum(s) as v
from trades
where x > toUnixTimestamp64Milli(now64() - interval 1 hour)
and sym = {sym: String}
group by x order by x asc;
该查询将最近一小时按两分钟分桶,并计算开盘价、最高价、最低价、收盘价和成交量。开盘价和收盘价使用 (t, q) 来处理时间戳相同的交易。桶过滤器排除了窗口起始处部分重叠的桶。
浏览器调用一个命名查询的 Express 端点;只接受服务器定义、参数化的 SQL。ClickHouse 凭据保留在服务器上,绝不放入 NEXT_PUBLIC_ 变量。为了可视化查询结果,我们使用 click-ui 组件显示表格,使用 Chart.js 绘制蜡烛图。
扩展与实用技巧
在生产环境中处理高频市场数据,不仅需要快速数据库。以下技巧和方法有助于确保系统在数据量增长时保持高性能和可靠。
扩展数据摄取
处理多个交易标的的 tick 级数据时,持续吞吐量很容易超过每秒数万条记录。
要应对这种情况,可以从以下几个方面入手:
• 使用 客户端批量写入 ,批量大小按系统的内存和延迟限制调到最优。
• 使用 压缩 :压缩插入数据能缩小网络传输的载荷体积,降低带宽占用,加快传输速度。
• 监控 ClickHouse 中创建的 part 数量,避免合并过于频繁。这篇 博客 讲的是异步插入,但其中关于 part 创建的部分同样适用于同步写入。你也可以借助 高级仪表盘 来 监控 数据 part 的数量。
• Massive 也提供了处理高吞吐数据消费的 性能建议 。
监控写入延迟
前面提到,金融应用对数据的实时性要求很高,所以监控延迟很有必要。
你可以很方便地计算并跟踪事件时间戳(tick 发生的时刻)与写入时间戳(数据被存储的时刻)之间的差值。这个「事件到写入」的延迟涵盖行情源传输、客户端批量处理、网络耗时和数据库写入,并非单纯的数据库延迟。下面的查询统计最近一小时内每个交易标的最新成交的该延迟。使用有符号运算,可以避免时间戳出现意外差异时发生无符号下溢。
SELECT
sym,
count() AS trade_count,
argMax(toInt64(inserted_at) - toInt64(t), tuple(t, q)) AS ingest_latency_ms
FROM trades
WHERE t >= toUnixTimestamp64Milli(now64() - INTERVAL 1 HOUR)
GROUP BY sym
ORDER BY trade_count DESC
LIMIT 100;
把这个指标可视化到仪表盘上,有助于及早发现变慢的迹象。
善用物化视图
Materialized views 适合在数据到达时就预先聚合。对于依赖按时间汇总的特定查询模式,这能带来明显的优化。典型例子是按固定间隔(比如 1 分钟窗口)计算金融数据的 OHLCV(开盘价、最高价、最低价、收盘价、成交量)指标。在写入时生成这些聚合结果,查询就能快速返回,不必每次重新计算。
先创建一张目标表,用来存放 1 分钟的 OHLCV 聚合结果。用 AggregatingMergeTree 合并聚合状态。排序键要包含所有分组维度:这里是 symbol、tape 和 minute。开盘价和收盘价状态沿用与原始查询相同的 (t, q) 排序。
CREATE TABLE trades_1min_ohlcv
(
`sym` LowCardinality(String),
`z` Enum8('NYSE' = 1, 'AMEX' = 2, 'Nasdaq' = 3),
`minute_bucket_ms` UInt64,
`open_price_state` AggregateFunction(argMin, Float64, Tuple(UInt64, UInt64)),
`high_price_state` AggregateFunction(max, Float64),
`low_price_state` AggregateFunction(min, Float64),
`close_price_state` AggregateFunction(argMax, Float64, Tuple(UInt64, UInt64)),
`volume_state` AggregateFunction(sum, UInt64),
`trade_count_state` AggregateFunction(count)
)
ENGINE = AggregatingMergeTree
ORDER BY (sym, z, minute_bucket_ms);
下一步是创建物化视图。
CREATE MATERIALIZED VIEW trades_1min_ohlcv_mv TO trades_1min_ohlcv
AS SELECT
sym,
z,
intDiv(t, 60000) * 60000 AS minute_bucket_ms,
argMinState(p, tuple(t, q)) AS open_price_state,
maxState(p) AS high_price_state,
minState(p) AS low_price_state,
argMaxState(p, tuple(t, q)) AS close_price_state,
sumState(s) AS volume_state,
countState() AS trade_count_state
FROM trades
GROUP BY sym, z, minute_bucket_ms;
目标表和视图都要在开始写入前建好。视图只处理新插入的数据块,不会自动回填已有数据行。如果需要,可以用一条安排好时机的 INSERT ... SELECT 回填已有数据,注意别把重叠的实时数据重复计入。查询里用了 -Merge 系列函数,即使后台合并尚未完成也能合并状态。
要查看数据,执行下面这个查询。
-- Query the table
SELECT
sym,
z,
minute_bucket_ms,
fromUnixTimestamp64Milli(toInt64(minute_bucket_ms)) as minute_timestamp,
argMinMerge(open_price_state) AS open_price,
maxMerge(high_price_state) AS high_price,
minMerge(low_price_state) AS low_price,
argMaxMerge(close_price_state) AS close_price,
sumMerge(volume_state) AS volume,
countMerge(trade_count_state) AS trade_count
FROM trades_1min_ohlcv
GROUP BY sym, z, minute_bucket_ms
ORDER BY sym, z, minute_bucket_ms;
运行完整示例
示例源码 现在位于 blog-examples/stock-data-demo。需要 Node.js 22 或更高版本,以及一个已有的本地或 Cloud ClickHouse 数据库。
git clone https://github.com/ClickHouse/examples.git
cd examples/blog-examples/stock-data-demo
npm ci
cp env.example .env
# Set CLICKHOUSE_URL, CLICKHOUSE_USERNAME, CLICKHOUSE_PASSWORD,
# CLICKHOUSE_DATABASE, and MASSIVE_API_KEY in .env.
npm run setup
npm run build
npm start
打开 http://localhost:34567/stocks/ 查看图表,或打开 http://localhost:34567/admin 控制数据写入。默认订阅 AAPL、MSFT 和 NVDA。想换其他代码,修改 MASSIVE_SYMBOLS 即可;仪表盘上的自选列表不会改变上游订阅。服务只绑定回环地址,API 也没有鉴权,公开暴露之前请先加上访问控制。
如果你的密钥缺少所需的数据通道,管理页面会提示认证失败。周末和节假日市场也可能冷清或休市。想在没有行情权限的情况下试用仪表盘,可以另用一个 ClickHouse 数据库,把 API key 留空,并在 npm start 之前先运行 npm run seed。这会插入明确标注为合成的价格数据,时间戳落在最近三分钟内,不会获取真实行情。当这些时间戳滑出图表窗口后,重新运行 seed 即可。
README 涵盖了本地 ClickHouse 的搭建、开发模式、配置和验证方法。
结论
这篇文章里,我们用 Massive 提供市场数据、用 ClickHouse 做高速写入和查询,搭建了一个实时逐笔数据(tick data)应用。内容涉及逐笔数据的流式传输与结构设计、写入性能的管理,以及高效查询和可视化的实现。
在这个 GitHub 仓库 里,有一个可运行的示例,可视化层用 React 实现。你可以借助它了解写入和查询的常见模式,再针对生产应用补上可靠投递、回放、身份认证,以及按具体负载做的调优。
关于我们
ClickHouse(clickhouse.com) 是面向 AI 时代打造的高性能实时分析数据库,能够以极致性能处理海量数据分析任务。凭借高并发、低延迟和云原生架构,ClickHouse 广泛应用于可观测性、数据仓库、实时分析及 AI 数据基础设施等场景。我们致力于帮助企业在公有云平台上构建安全、弹性且高性价比的实时分析与 AI 数据平台,加速释放数据价值,推动智能化创新与数字化转型。目前,Trip.com、DiDi、Meta、Sony、Netflix、Deutsche Bank、Sierra、Cloudflare 等全球领先企业均在使用 ClickHouse 支撑其关键业务和数据分析平台。