KDB这个词,做量化和金融数据分析的人肯定不陌生。别急着把它和什么内核数据库弄混,量化圈子里说的KDB,指的是一套以列式存储为核心的高性能时序数据库,搭配它的查询语言q来用。我最早接触KDB,是为了处理某量化团队的行情回放需求,当时数据量几百G,在传统关系型数据库里跑一次全量聚合要等十几分钟,换成KDB之后,同样的逻辑缩到了秒级。这篇文章就把KDB的基本操作和核心概念从头捋一遍,包括建表、写入、查询、聚合、窗口计算这些最常用的动作,以及新手特别容易踩的坑。目标读者是想快速上手KDB的开发者、数据分析师或者量化爱好者,不需要你有很深的数据库功底,但最好懂一点Python或者SQL,因为很多概念可以类比着学。
1. 先搞清楚KDB到底在解决什么问题
1.1 一张表存储到底有什么不一样
KDB的核心是列式存储。传统关系型数据库如MySQL、PostgreSQL,数据按行存储,一行记录的所有字段在磁盘上都是挨着的。KDB则把同一列的数据放在连续空间里。这个差别看起来只是存储布局,实际影响非常深远。拿"计算某只股票一天的平均价格"来说,行式存储要扫描整张表的所有行、读出所有列再丢掉不需要的字段;列式存储可以直接只读取px这一列的数据段,IO量和内存用量都大幅下降。金融行情数据动辄几百G到几个T,这个差距直接决定了查询是秒回还是等日志。
KDB的长项不只是列式存储,它把内存计算和磁盘映射结合得非常好。你可以把整个表当作一个内存变量操作,底层用内存映射文件机制把磁盘数据按需映射到进程地址空间。换句话说,KDB在查询时不需要像传统数据库那样把数据加载到Buffer Pool再解析SQL执行计划,它直接对映射后的数据做向量运算,很多场景下数据读取速度和内存访问差不了太多。
提示:KDB目前主要有两种部署形态。一种是纯内存库,所有数据放在RAM里,速度快到没朋友,但对物理内存要求极高;另一种是历史数据库HDB,数据按日期分区持久化到磁盘,查询时用内存映射读取。实际系统通常两者配合使用。
1.2 为什么金融场景偏爱KDB
KDB被大规模使用在金融行业,核心原因是行情数据几乎就是典型的append-only加时间有序加海量写入。每一笔tick数据、每一笔委托、每一笔成交,本质上就是一个不断追加的记录流。这种场景之下,传统关系型数据库的索引维护成本极高,每插入一行都要更新B+树索引,写入吞吐上不去。而KDB表本身不建行级索引,靠列对齐和向量计算来加速,写入基本就是往连续内存块后面追加数据,批量插入吞吐量非常夸张。
再加上金融分析有大量"按时间窗口聚合"的需求:分钟K线、日收益率、波动率、回撤,这些本质上是"把同一时间桶内的多条记录压缩成一条统计值"。向量化编程模型配合内置的xbar、msum、mavg这类窗口函数,让KDB在这个领域几乎是碾压级的存在。当然世界没有银弹,KDB的缺点也明显:进程单机模型不太容易横向扩展、q语言学习曲线不短、排错手段不如传统SQL生态丰富。但它只要用对地方,性价比真的很高。
1.3 一台KDB服务到底是怎么跑起来的
很多人第一次启动KDB会有点懵,因为它的启动方式不像MySQL那样有守护进程,也不存在"数据库实例"这种概念。KDB就是一个q进程,你在服务器上用q命令把可执行文件拉起,它会监听一个端口,客户端可以通过TCP连接上来执行q代码,像一个常驻的计算引擎。实践中一个最小服务大概长这样:
q -p 8888跑起来之后,你就多了一个能执行q语言的环境。说是数据库,更准确的说法是一个"数据计算服务":你通过远程句柄向它发指令,它执行完再把结果返回给你。KDB的持久化更多靠的是"把表序列化到磁盘"或者"按日期分区落地到文件目录",而不是传统意义上的表空间。理解这个模型,后面所有操作都不会别扭。
2. Q语言最基础的三块砖:原子类型、列表和字典
很多资料一上来就堆QSQL,我建议先把q语言的数据结构搞明白。因为KDB的表本质上是字典和列表的组合,不懂列表和字典,你写出的代码可能能跑,但会绕远路,遇到性能问题也找不到根源。
2.1 原子类型先建立直觉
q语言里的原子类型,也就是单个值,并不复杂,日常用到的基本不超过十种:
| 类型 | 字面量示例 | 说明 |
|---|---|---|
| boolean | 1b | 布尔值,1b是真,0b是假 |
| short | 10h | 16位整数,后缀h |
| int | 10i | 32位整数,后缀i |
| long | 10 | 64位整数,无后缀是long |
| float | 10.5 | 64位浮点数 |
| char | "a" | 单个字符 |
| symbol | `abc | 符号原子,本质是内部化字符串 |
| date | 2024.01.01 | 日期 |
| timestamp | 2024.01.01D12:30:00.000000000 | 带纳秒的时间戳 |
这里最需要留意的是int和long的区分。在q里直接写整数默认是long,也就是64位,写10i才是32位int。表字段如果定义为int,插入一个大数字long可能会类型报错。另一个重点是symbol类型,它和普通字符串不一样,symbol是全局符号表里的唯一实例,比较速度极快,但大量无界symbol会导致符号表膨胀,这是后面会提到的坑。
2.2 列表:一维数组就是Q的操作单位
列表在q里用括号和分号构造,也可以直接用空格当分隔符。例如1 2 3是一个long列表,`A`B`C是一个symbol列表。列表最重要的特征是向量化:对列表整体做运算,q会自动把操作逐元素展开。这一点和Python的list不一样,和NumPy的array更像。
price: 100.5 101.2 99.8 102.3 price + 1这段代码执行后,price的每个元素都会加1,得到101.5、102.2、100.8、103.3。如果你再执行avg price,会得到100.95。这个例子想说明的是,q语言默认就把列表当作向量来算,不需要你手写循环。列表还能套列表形成矩阵或嵌套结构,但金融计算一般不会用太深的嵌套。熟练掌握一维列表的切片、拼接、反转就够了。列表的下标从0开始,price[0]取第一个值,price 1 3取出第二个和第四个值组成新列表。
2.3 字典:KDB表结构的底座
q语言中字典用!构造,左边是键,右边是值,例如`a`b`c ! 10 20 30。字典在KDB里非常重要,因为一张表本质上就是一个列名到列数据的字典,只不过所有列长度必须相等。你可以用dict[a]取出值,也能用key dict、value dict`分别取键和值。
从这部分开始,你会感受到q语言非常符号化,语法里到处都是符号操作符。这不是故意炫技,而是为了把代码写到极短,减少解析开销。刚开始不习惯没关系,多写几次手就熟了。字典概念还有个实际用途:你可以把一张表按sym列转成字典,再通过代码直接索引某只股票的全部数据,这在做单标的分析时非常方便。
2.4 函数:一切都可以是函数
q语言里函数用花括号定义,参数用x、y、z表示,多于三个参数需要显式声明。例如:
add: {x + y} add[1; 2]这个add函数会返回3。函数调用可以用f[参数],也可以用f . (参数列表)。这里有一个高频坑:函数如果返回值是列表,调用时可能被解析成"多个值"。所以当你写自定义函数想返回一张表时,务必用enlist把结果包一层,不然会把行撕裂。KDB的编程模型和SQL完全不同,它更接近于"处理数据的函数式脚本语言",你可以在函数里组装任意复杂逻辑。
3. 表和QSQL:日常增删改查的完整套路
3.1 创建一张表:两种常用方式
第一种,用列表构造,适合一次性准备小数据量验证逻辑:
t: ([] date: 2024.01.01 2024.01.02 2024.01.03; sym: `A001`A001`B002; px: 100.5 99.8 52.3; vol: 1000 1200 800)这里([])是创建一个表,列名和值一一对应。注意每个值的列表长度必须一致,否KDB会直接报错。第二种,先声明空表,再逐批追加,适合写生产代码:
t: ([] date: Date$(); sym: `$(); px: Float$(); vol: Long$()) insert[t; (2024.01.01; `A001; 100.5; 1000)] t insert (2024.01.02; `A001; 101.2; 900)第二种方式中,Date$()表示把空列表转换为date类型,得到一个空的date列。insert是q内置函数,第一参数是表句柄,第二参数是行记录,返回值是插入位置。实际生产里批量插入更常见,你完全可以把一整块二维数据一次性insert进去,KDB对批量追加的优化非常明显,建议不要一条一条insert。
注意:字段类型一旦定义,插入时必须严格匹配。尤其int列不能直接插long类型,symbol列只能存symbol不能存字符串。KDB对类型极其严格,报错信息多数是类型错误,这点和Python完全不一样。
3.2 查询:select与where条件
KDB的QSQL核心语法是非常自然地向SQL靠拢的。最基础的长这样:
select from t select date, sym, px from t select date, sym, px from t where sym = `A001, date >= 2024.01.02where子句里使用逗号分隔多个条件,相当于AND。条件既可以用=、<>、<、>这些比较符,也可以使用in,还有like、within这类区间判断函数。
和SQL有一个关键差别:select查询返回的仍然是表。如果你只想要某个列的值、而不是一张一张的表,需要改用exec:
exec px from t这个会返回一个列表,内容就是t表中px列的全部值。exec也可以带条件和聚合,返回标量或列表,这个功能在写脚本取某个指标时非常常用。比如exec max px from t where sym =A001`可以直接取到指定股票的区间最高价。
3.3 更新与删除:同样走QSQL
更新表数据可以用update关键字:
update px: px * 1.02 from t这里要注意,它不会修改原始表,而是返回一张新表。如果想让原表被更新,可以写成:
t: update px: px * 1.02 from tKDB把表当作不可变数据,几乎所有操作都是生成新表,这个思维很重要。删除行用delete,删除列用drop。但实战中更常见的做法是查询过滤,很少真的去修改底层历史表,因为行情数据基本只会追加,修改和删除都是低频操作。
3.4 分组聚合:by到底怎么理解
聚合的基础写法是:
select avg px, sum vol by sym from t这句的意思是:按sym分组,每组计算平均价和总成交量,返回一张新表。by后面可以跟多个列,也可以跟一个表达式,比如10 xbar vol这种分桶逻辑。如果你想要更多控制,可以写成avg px as avgPx形式:
select avg px as avgPx, sum vol as totalVol by sym from t这是KDB所有操作里价值最高的一句:按任意维度分组,配合任意聚合函数,一行搞定。q的聚合函数返回的本身就是向量,所以你可以直接在聚合里继续嵌套计算,比如avg px - first px这种动态差值,逻辑上非常顺滑。
3.5 排序和去重
排序用`sym xasc t或`date xdesc t。注意这里不是SQL风格的order by,而是把排序列作为参数传给xasc或xdesc函数。去重有distinct函数,也可以select distinct sym from t。KDB对排序的默认逻辑和SQL一致,null永远排在最前面,这点在数据清洗时要留意。如果你需要按多列排序,可以把排序列放进一个列表:`sym`date xasc t,它先按sym升序,同sym内再按date升序。
4. 高频实战:时间聚合、窗口计算与性能优化
4.1 按时间分桶:xbar是KDB的灵魂函数之一
行情数据最关键的操作是"把高频数据聚合成低频数据"。例如把逐笔成交变成分钟Bar、把tick数据聚合成每秒快照。KDB用xbar函数做分桶计算:
select first px, last px, max px, min px, sum vol by sym, 1 xbar date.minute from tick这里date.minute是把时间列截断到分钟维度,1 xbar 分钟值表示按1分钟粒度分桶。如果你要5分钟Bar,改成5 xbar date.minute即可。这个语法的精妙之处在于它是严格的向量化分桶,不需要group by key,而且能自动把没有交易发生的空时段跳过,不会产生无效行。实际业务里,"某个时间段没有交易"和"交易量为0"是两个概念,KDB默认不产出空桶,这点非常友好。
提示:xbar分桶会把时间点向前对齐到桶的起点。例如5分钟桶,12:03会归到12:00这个桶,12:06会归到12:05。如果你希望向后对齐,可以先对时间做偏移再xbar,比如
5 xbar (time + 00:05)。
4.2 滑动窗口:msum、mavg、mcount
除了固定分桶,还有一类需求是滑动窗口:过去3笔成交的平均价、过去5分钟累计成交量。KDB提供了msum、mavg、mcount、mdev、mmax、mmin等函数,第一参数是窗口长度,第二参数是序列。
select date, sym, px, mavg[3; px] as movAvg from tmavg[3; px]计算每行附近3个值的移动平均,前两行则根据当前已出现的值计算,不会返回null。窗口计算的速度非常快,这是KDB比用SQL做自连接方案高明的地方。如果要计算"过去N根K线的最高价"这种多列联动窗口,可以借助prev、next函数手动拼移位列,或者用fby进行分组窗口计算,例如:
select px, mavg[3; px] by sym from t注意这里是by sym而不是常规的分聚合,它会把mavg按sym分别计算,再拼回原表,这个语法非常经典。不加上by sym的话,mavg会跨代码计算,把不同symbol的数据混在一起,结果就是错的。
4.3 时点对齐:aj函数处理多表联动查询
金融数据最折磨人的一个问题就是两张表时间戳完全对不齐:行情快照表是每隔若干毫秒一条,而交易回报表是事件发生才有的。你要把每一笔成交对应到它发生之前最近的一个快照价格,这种"时间点向前对齐"的操作,在SQL里用子查询写起来极其痛苦。KDB的aj函数专门解决这个问题。
aj[`sym`time; trade; quote]这里的trade和quote是两张表,要求都有sym列和时间列,时间列必须是排序过的。aj会针对trade的每一行,找到quote中同一sym、时间不晚于trade记录的最近一条数据,把quote的字段拼接过来。它对大数据的性能非常好,因为底层是按列二分查找而不是逐行扫描。用aj前必须把quote表按sym和时间排序,否则结果毫无意义。
4.4 查询性能的三个小习惯
基于我的使用经验,KDB查得慢,十有八九不是KDB慢,是写法不对。有三个习惯很重要。
第一,尽量写整列向量计算,不要用each遍历每一行。很多新手会用{...} each t这种逐行处理方式,这种写法在小表上没错,数据量一大就明显吃亏。KDB的设计哲学就是一列一列算,遇到需要逐行处理的逻辑,优先考虑改成向量表达式。
第二,多表join前记得排序。aj和ij都要求右表按连接键排序,如果你不清洗好顺序就硬join,KDB要么抛出排序错误,要么返回错乱结果。Python、SQL里你可能习惯让数据库自动排序,KDB不惯着这个毛病。
第三,能聚合就聚合,不要先把明细拉到客户端。KDB最有价值的一点是计算发生在数据所在进程内部,如果你把几百万行明细通过IPC传回应用层再做统计,那等于放弃了KDB的全部优势。尽量让KDB返回最终统计量,而不是原始大表。
5. 新手容易踩的坑与排查思路
5.1 类型不匹配:KDB不是你想的那种"弱类型"
KDB的报错里最高频的一定是type。原因是q语言对类型极其严格,int列不能插入long值,symbol列不能插入字符串,float列不能insert一个整数列表。定位方法很简单:遇到报错后先用meta 表名看字段类型,再用type 某个值看值的类型,两者对不上就是问题所在。特别是在批量插入时,如果数据从Python通过其他途径传过来,Python的整数默认变成long,而表定义是int,这时需要提前做cast。
5.2 symbol类型不是字符串
symbol是q语言内部优化过的"字符串常量",但很多人会把`A001和"A001"混用。symbol列表做聚合非常快,但如果你把一个动态拼接出来的字符串当symbol用,先要转成symbol:`$"A001"。还有一种坑是symbol表膨胀:如果使用不规范的符号拼接生成大量新symbol,会让全局符号表越涨越大,最终内存吃不消。规范做法是用枚举列或者限制symbol基数。
5.3 表越查越慢:可能是每一次查询都新建了临时大表
KDB的不可变更新模型很容易让人写出"每做一步操作就复制一份中间表"的代码。比如先select一个子集,再update一列,再聚合,中间每一步都产生完整副本,堆内存很快爆掉。正确做法是尽可能一个QSQL表达式完成过滤、计算、聚合,或者用fby、update的原地语义来减少中间表。要养成一个大清单:能合并的操作不要拆开。
5.4 时间戳类型不一致导致的"静默错位"
KDB里date、datetime、timestamp、time都表示时间,但它们精度和内部表示完全不同。date只到日,精确值是整数;timestamp带纳秒,精度是毫微秒;time只到毫秒但无日期。如果你把date和timestamp混在一个表达式里,q会自动做类型提升,但结果可能完全不是你想要的。最典型的场景是:把两种不同时间类型的列放在同一个where条件里比较,语法不报错,但结果查询不到数据。遇到"明明有数据却查不出来"的情况,先检查两边的类型是否严格一致,可以用meta查看表结构,用type查看值类型。
5.5 IPC句柄泄漏
q进程之间通过句柄通信,每次连接都是资源。如果代码里频繁用hopen建连,用完又忘了hclose,日积月累句柄会耗尽,表现为连接越来越慢甚至连接不上。排查方法是在代码里检查打开句柄的数量,或者统一在函数内hopen完立刻hclose。KDB本身不提供连接池,所以这种资源管理一定要靠调用方自觉。如果你写的是一个常驻服务,务必把连接生命周期管理好。
5.6 数据排序错了导致as-of join结果错乱
aj这类函数需要右侧表按连接键排序,如果你的quote表在插入过程中没有保持时间有序,aj的结果就是错乱的。KDB不校验你的数据是否有序,它默认假定有序,一旦破坏,结果可能不报错但是错。排查技巧是:先用`time xasc quote排一次,再看aj结果和预期差异。这个坑非常隐蔽,因为它不产生任何异常信息。
6. 一个完整小案例:从零搭一个行情聚合任务
最后用一个完整的例子把前面内容串起来。假设我们现在有某只交易标的的逐笔成交记录,每行包含时间、代码、价格、成交量四个字段,需要计算每个交易日内、每个代码的5分钟K线,也就是开高低收,并输出成交量之和。
6.1 准备数据
trades: ([] time: 09:30:00.001 09:30:00.050 09:30:01.020 09:31:00.500 09:34:00.200 09:36:00.800; sym: `AAA`AAA`AAA`AAA`AAA`AAA; px: 10.00 10.01 10.02 10.05 10.04 10.10; vol: 100 200 150 300 250 400)这里time是time类型,后面那些时间字面量KDB会识别为time类型。注意我这里的原始数据是升序的,实际生产环境要先做排序,否则后续分桶和aj都会出问题。
6.2 按5分钟分桶聚合
select open: first px, high: max px, low: min px, close: last px, totalVol: sum vol by sym, 5 xbar time from trades这一步的输出是每个5分钟桶的K线数据。5 xbar time会把09:30:00.001和09:31:00.500归到09:30这个桶,09:34:00.200和09:36:00.800归到09:35这个桶。需要留意的是,by后面的表达式结果必须是可排序的类型,time类型能正常分桶。这个查询逻辑非常经典,你只需要换时间粒度和聚合函数,就能从tick数据一路生成分钟级、小时级、日级所有级别K线。
6.3 增加移动平均
如果还要看一下最近3个5分钟桶的平均收盘价,可以用mavg:
select open, close, mavg[3; close] as maClose by sym, 5 xbar time from trades这里的mavg[3; close]会对同一个sym分组内的close序列做滑动平均。实际上q的窗口函数默认会跨越不同sym,如果你需要每个sym独立计算,还是要用fby包一层,写法是mavg[3; close] fby sym。这个技巧很容易被忽略,初学时容易以为by已经做了分组,实际不是,这里要单独处理。
6.4 把结果写成一张新表并保存
计算完的数据想保存到磁盘,可以直接把表序列化成文件,但更规范的做法是写成分区目录。例如按日期保存:
`:hdb/2024.01.01/trades set trades这条语句会把trades表保存到hdb目录下的2024.01.01分区里。之后你只要以这个目录为HDB根目录启动q进程,表就会自动按分区映射到命名空间,像内存表一样直接查询。这种落盘方式正是HDB的核心用法。这个流程虽然简单,但它把建表、插入、时间分桶、聚合、分组窗口、落盘全都串起来了,照着走一遍,基础操作就算是真正上手了。
我在实际使用中最深的体会是:KDB的学习曲线不在语法,而在思维转换。你用惯了行式数据库,总想用遍历的方式去处理数据;但KDB更接近"对整列做批处理"。一旦你接受列式向量化这个设定,很多代码会变得非常短,而且快得离谱。建议所有刚入门的朋友,先别急着背函数名,把([])建表、select、update、exec、by、xbar这六个动作练熟,基本能解决80%的日常需求。剩下的窗口函数、aj连接、落地存储,都是在这些基础上的延伸。
最后再分享一个小技巧:如果初期实在不适应q的语法,可以把KDB当作一个高性能计算引擎来用,只把最终结果通过接口导出到Python或其他工具再可视化。但查询和聚合的SQL尽量留在KDB侧完成,不要让原始明细跨进程流动。这样既能用上KDB的速度,又不用强迫自己把整个生态都搬到q里。等后面越来越熟了,你自然会愿意把更多逻辑下沉到q语言。这个过渡路径,比一上来就啃完整文档要舒服得多。