资讯详情

资讯详情

KDB时序数据库实战:列式存储与q语言快速上手

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 为什么金融场景偏爱KDBKDB被大规模使用在金融行业核心原因是行情数据几乎就是典型的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语言里的原子类型也就是单个值并不复杂日常用到的基本不超过十种类型字面量示例说明boolean1b布尔值1b是真0b是假short10h16位整数后缀hint10i32位整数后缀ilong1064位整数无后缀是longfloat10.564位浮点数chara单个字符symbolabc符号原子本质是内部化字符串date2024.01.01日期timestamp2024.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列表ABC是一个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语言中字典用!构造左边是键右边是值例如abc ! 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: A001A001B002; 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查询返回的仍然是表。如果你只想要某个列的值、而不是一张一张的表需要改用execexec 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永远排在最前面这点在数据清洗时要留意。如果你需要按多列排序可以把排序列放进一个列表symdate 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[symtime; 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都要求右表按连接键排序如果你不清洗好顺序就硬joinKDB要么抛出排序错误要么返回错乱结果。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: AAAAAAAAAAAAAAAAAA; 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分钟桶的平均收盘价可以用mavgselect 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语言。这个过渡路径比一上来就啃完整文档要舒服得多。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →