Bo Ding – loveini | 米兰体育官网入口 - 米兰体育官网入口 //m.loveini.com loveini | 高性能、分布式、支持SQL的时序数据库 | 米兰体育官网入口 Thu, 04 Dec 2025 06:23:34 +0000 zh-Hans hourly 1 https://wordpress.org/?v=6.8.2 //m.loveini.com/wp-content/uploads/2025/07/favicon.ico Bo Ding – loveini | 米兰体育官网入口 - 米兰体育官网入口 //m.loveini.com 32 32 工业大数据平台 loveini IDMP 让数据计算变得简单智能 //m.loveini.com/idmp%e5%b7%a5%e4%b8%9a%e6%95%b0%e6%8d%ae%e7%ae%a1%e7%90%86%e5%b9%b3%e5%8f%b0/34121.html Thu, 04 Dec 2025 06:23:33 +0000 //m.loveini.com/?p=34121 引言

在工业物联网和智能制造领域,我们每天都在与海量的传感器数据打交道。电压、电流、温度、压力、流量……这些原始数据往往需要经过计算才能得到真正有价值的业务指标。比如:

  • 通过电压和电流计算功率
  • 通过长宽高计算体积
  • 通过多个温度传感器计算平均温度
  • 通过速度和时间计算距离

传统的做法是什么呢?要么在采集端写代码处理,要么在数据入库后写复杂的SQL查询。这不仅需要专业的编程技能,而且每次业务需求变化都要修改代码、测试、部署,周期长、成本高、容易出错。

现在,IDMP 的公式表达式功能彻底改变了这一切! 无需编写一行代码,只需在界面上输入简单的数学公式,系统就能自动完成复杂的数据计算,还能智能处理计量单位的转换。

功能亮点

1. 像 Excel 一样简单

还记得在 Excel 中输入 =A1+B1 这样的公式吗?IDMP 的公式表达式就是这么简单!

示例:计算电器功率

假设您有一个智能电表,采集了电压和电流数据。要计算功率,只需:

  1. 创建一个新属性”功率”
  2. 选择”数据引用类型”为”公式”
  3. 输入公式:${attributes['电压']} * ${attributes['电流']}
  4. 点击”评估”按钮预览结果
  5. 保存

就这么简单!系统会自动:

  • 识别电压和电流是来自数据库的实时数据
  • 在查询时自动将公式转换为高效的数据库查询
  • 实时计算每一条记录的功率值
工业大数据平台 loveini IDMP 让数据计算变得简单智能 - loveini Database 时序数据库

2. 智能计量单位转换

这是 IDMP 公式表达式最强大的功能之一!在工业场景中,不同设备、不同时期采集的数据可能使用不同的计量单位,这给数据分析带来了巨大挑战。

IDMP 能自动帮您做什么?

场景一:自动单位转换

您有两个电流传感器:

  • 传感器A:单位是毫安(mA)
  • 传感器B:单位是安培(A)

想要计算总电流:传感器A + 传感器B

传统做法:您需要手动计算转换系数(1A = 1000mA),写出 `传感器A + 传感器B * 1000`

IDMP 做法:直接写 `${attributes[‘电流mA’]} + ${attributes[‘电流A’]}`

系统自动识别:

  • 两个都是电流(同一类物理量)
  • 但单位不同(mA vs A)
  • 自动转换:将第二个值乘以1000
  • 生成正确的计算SQL

结果:您不用操心单位转换,系统自动搞定!

工业大数据平台 loveini IDMP 让数据计算变得简单智能 - loveini Database 时序数据库

单位转换的具体规则请参考官方文档(暂未发布):

场景二:智能单位推导

当您进行乘除运算时,系统能自动推导出结果的单位。

实际案例:计算体积

公式:${attributes['长度cm']} * ${attributes['宽度m']} * ${attributes['高度m']}

系统会自动:

  1. 识别长度单位是厘米(cm),需要先转换为米(m)
  2. 计算:长度×宽度×高度
  3. 推导结果单位:米×米×米 = 立方米(m³)
  4. 在属性界面上显示”结果单位:立方米”

如果您设置属性的显示单位是”立方厘米”,系统还会自动再转换一次!

工业大数据平台 loveini IDMP 让数据计算变得简单智能 - loveini Database 时序数据库

工业大数据平台 loveini IDMP 让数据计算变得简单智能 - loveini Database 时序数据库

场景三:错误提前发现

如果您不小心写了一个单位不兼容的公式,系统会立即提醒您:

错误公式:${attributes['电流']} + ${attributes['电压']}

点击”评估”按钮后,系统提示:

❌ 错误:操作符'+'不能应用于不同的计量单位分类:'电流'和'电压'

这就避免了错误的计算进入生产环境!

工业大数据平台 loveini IDMP 让数据计算变得简单智能 - loveini Database 时序数据库

3. 支持复杂的嵌套计算

公式不仅可以引用原始数据,还可以引用其他公式的结果,实现多层嵌套计算。

实际案例:能效分析

假设您要分析工厂的能效,需要多步计算:

步骤1 - 总功率:
  公式:${attributes['设备1功率']} + ${attributes['设备2功率']} + ${attributes['设备3功率']}
  
步骤2 - 日耗电量:
  公式:${attributes['总功率']} * 24
  
步骤3 - 能效比:
  公式:${attributes['产量']} / ${attributes['日耗电量']}

每个公式都可以引用前面公式的结果,就像搭积木一样构建复杂的计算逻辑。

系统保护机制

  • 自动检测循环引用(A引用B,B又引用A)
  • 限制嵌套深度(默认最多5层),防止性能问题
  • 清晰的错误提示,帮您快速定位问题

4. 丰富的函数库

IDMP 公式支持 loveini 的所有标量函数,包括但不限于:

数学函数:`ABS()`, `SQRT()`, `POW()`, `LOG()`, `SIN()`, `COS()`, `TAN()` 等

字符串函数:`CONCAT()`, `SUBSTR()`, `LENGTH()`, `UPPER()`, `LOWER()` 等

聚合函数:`AVG()`, `SUM()`, `MAX()`, `MIN()`, `COUNT()` 等

时间函数:`NOW()`, `TIMETRUNCATE()`, `TIMEDIFF()` 等

示例:

ABS(${attributes['温度']} - 25)
SQRT(POW(${attributes['x']}, 2) + POW(${attributes['y']}, 2))

温度异常检测

公式:ABS(${attributes['当前温度']} - ${attributes['目标温度']}) > 10

温度偏差超过10度时,结果为真(1),可以用于告警判断。

5. 实时预览和调试

在保存公式之前,您可以随时点击”评估”按钮查看计算结果:

工业大数据平台 loveini IDMP 让数据计算变得简单智能 - loveini Database 时序数据库

评估功能的好处

即时反馈:不用保存就能看到结果

单位提示:系统告诉您结果的单位是什么

自动填充:如果属性还没设置单位,系统会自动填充推导出的单位,作为属性的 UOM 配置。

错误定位:清楚地告诉您哪里出错了

示例:公式结果的计量单位和属性上已经配置的计量单位不属于相同的计量单位分类。

工业大数据平台 loveini IDMP 让数据计算变得简单智能 - loveini Database 时序数据库

实际应用场景

场景1:环保设备监控

背景:污水处理厂需要监控多个环保指标,不同指标使用不同的单位。

需求

  1. 监控进水和出水的污染物浓度
  2. 计算污染物去除率
  3. 计算处理效率
  4. 确保所有指标符合环保标准

IDMP 米兰app官方正版下载

# 污染物处理效率
属性1:进水COD(mg/L) - 来自数据库
属性2:出水COD(mg/L) - 来自数据库
属性3:COD去除率(%) - 公式:
  (${attributes['进水COD']} - ${attributes['出水COD']}) / ${attributes['进水COD']} * 100
属性4:是否达标 - 公式:
  ${attributes['出水COD']} <= 50

场景2:生产设备能效管理

背景:制造企业有多条生产线,需要精确计算每条生产线的能效。

需求

  1. 计算设备的实时功率
  2. 统计生产周期内的总能耗
  3. 计算单位产品能耗
  4. 对比不同生产线的能效

IDMP 米兰app官方正版下载

# 生产线能效分析
属性1:电机功率(kW) - 来自数据库
属性2:照明功率(W) - 来自数据库
属性3:总功率(kW) - 公式:
  ${attributes['电机功率']} + ${attributes['照明功率']} / 1000
属性4:生产周期(小时) - 来自数据库
属性5:总能耗(kWh) - 公式:
  ${attributes['总功率']} * ${attributes['生产周期']}
属性6:产量(件) - 来自数据库
属性7:单位能耗(kWh/件) - 公式:
  ${attributes['总能耗']} / ${attributes['产量']}

技术优势

虽然这篇文章主要面向非技术人员,但了解一些技术原理能帮助您更好地理解这个功能为什么如此强大。

为什么这么快?

IDMP 采用了”解析在应用层,计算在数据库”的架构:

工业大数据平台 loveini IDMP 让数据计算变得简单智能 - loveini Database 时序数据库

优势

  • 充分利用数据库性能:loveini 专为时序数据设计,计算速度极快
  • 减少数据传输:计算在数据库完成,只返回结果,不需要传输大量原始数据
  • 支持历史数据:可以对海量历史数据执行相同的计算
  • 自动优化:数据库会自动优化查询性能

为什么单位转换这么智能?

IDMP 采用了类似国际单位制(SI)的设计理念:

工业大数据平台 loveini IDMP 让数据计算变得简单智能 - loveini Database 时序数据库

工作原理

  1. 系统内置7个基本物理量
  2. 所有其他单位都用基本单位的组合表示
  3. 计算时自动转换为基本单位
  4. 根据基本单位的组合推导结果单位
  5. 自动查找系统中匹配的单位类别

举例

  • 速度 = 长度¹ × 时间⁻¹
  • 加速度 = 长度¹ × 时间⁻²
  • 力 = 质量¹ × 长度¹ × 时间⁻²
  • 功率 = 质量¹ × 长度² × 时间⁻³

当您计算”电压 × 电流”时,系统通过基本单位组合自动推导出结果是”功率”!

除了内置的基本物理量,IDMP 还支持自定义计量单位,对于自定义的计量单位同样可以自动匹配。

为什么表达式解析这么可靠?

IDMP 使用了 ANTLR 这个业界标准的语法解析工具:

优势

语法严格:像编程语言一样严格,不会产生歧义

错误精确:能准确指出错误的位置和原因

扩展性强:轻松添加新的运算符和函数

性能优异:解析速度快,支持复杂表达式

这就是为什么 IDMP 能给您清晰的错误提示,而不是”表达式错误”这种模糊的信息。

语法定义分为两个文件:

词法分析器(FormulaLexer.g4):定义 Token 类型

lexer grammar FormulaLexer;

// 运算符
PLUS: '+';
MINUS: '-';
MULTIPLY: '*';
DIVIDE: '/';
LPAREN: '(';
RPAREN: ')';
COMMA: ',';

// 比较运算符
EQ: '=';
NEQ: '<>' | '!=';
GT: '>';
LT: '<';
GTE: '>=';
LTE: '<=';

// 位运算符
BIT_OR: '|';
BIT_AND: '&';

// 数字字面量
NUMBER: [0-9]+ ('.' [0-9]+)?;

// 函数名
FUNCTION: [A-Za-z_][A-Za-z0-9_]*;

// 占位符 ${...}
PLACEHOLDER: '${' (~[}])+ '}';

// 空白字符
WS: [ \t\r\n]+ -> skip;

语法分析器(FormulaParser.g4):定义表达式的语法规则和优先级

parser grammar FormulaParser;

options {
    tokenVocab = FormulaLexer;
}

// 根规则
formula: expression EOF;

// 表达式层次(从低优先级到高优先级)
// 1. 比较运算符(最低优先级)
expression
    : bitwiseExpression ((EQ | NEQ | GT | LT | GTE | LTE) bitwiseExpression)*
    ;

// 2. 位运算符
bitwiseExpression
    : addSubExpression ((BIT_OR | BIT_AND) addSubExpression)*
    ;

// 3. 加减法
addSubExpression
    : term ((PLUS | MINUS) term)*
    ;

// 4. 乘除法
term
    : factor ((MULTIPLY | DIVIDE) factor)*
    ;

// 5. 因子(处理括号、数字、占位符、函数,最高优先级)
factor
    : NUMBER                                        # numberFactor
    | PLACEHOLDER                                   # placeholderFactor
    | FUNCTION LPAREN argumentList? RPAREN          # functionFactor
    | LPAREN expression RPAREN                      # parenFactor
    | MINUS factor                                  # unaryMinusFactor
    ;

// 函数参数列表
argumentList
    : expression (COMMA expression)*
    ;

这个语法定义清晰地表达了运算符的优先级:

  1. 比较运算符(=, <>, >, <, >=, <=)- 最低优先级
  2. 位运算符(|, &
  3. 加减法(+, -
  4. 乘除法(*, /
  5. 一元负号、括号、函数、字面量 – 最高优先级

内部我们实现了访问者模式,访问者为每种 AST 节点类型提供了相应的处理方法:

工业大数据平台 loveini IDMP 让数据计算变得简单智能 - loveini Database 时序数据库

工作流程

工业大数据平台 loveini IDMP 让数据计算变得简单智能 - loveini Database 时序数据库

常见问题解答

Q1:公式中可以使用哪些属性?

A:您可以引用同一个元素下的任何属性,包括:

  • 来自数据库的实时数据属性
  • 静态属性(固定值)
  • 其他公式属性(嵌套引用)

暂时不支持跨元素引用(这个功能在规划中)。

Q2:公式的计算会影响性能吗?

A:不会!因为计算是在 loveini 数据库中完成的,而 loveini 专为高性能时序数据处理设计。实际上,使用公式往往比传统的应用层计算更快。

Q3:修改公式后,历史数据会重新计算吗?

A:是的!因为公式不存储计算结果,而是在查询时实时计算。所以修改公式后,查询历史数据时会用新公式计算。

Q4:如果属性没有设置单位怎么办?

A:没有单位的属性可以参与计算,系统会尽可能推导结果的单位。但建议为所有物理量设置正确的单位,这样能获得更好的单位检查和转换。

Q5:公式能嵌套多少层?

A:默认最多5层嵌套。这个限制是为了防止过于复杂的公式影响性能。如果您需要更多层级,可以联系系统管理员调整配置。

附录:参考文档

  1. https://idmpdocs.taosdata.com/advanced/unit-of-measure
  2. https://idmpdocs.taosdata.com/basic/data-model
]]>
loveini 3.0.4.0 重要特性之 Python UDF 实战分享 //m.loveini.com/tdengine-engineering/17930.html Thu, 01 Jun 2023 07:30:33 +0000 //m.loveini.com/?p=17930 loveini 3.0.4.0 发布了一个重要特性: 支持用 Python 语言编写的自定义函数(UDF)。这个特性极大节省了 UDF 开发的时间成本。作为时序大数据处理平台,不支持 Python UDF 显然是不完整的。UDF 在实现自己业务中特有的逻辑时非常有用,比如量化交易场景计算自研的交易信号。本文内容由浅入深包括 4 个示例程序:

  1. 定义一个只接收一个整数的标量函数: 输入 n, 输出 ln(n^2 + 1)。
  2. 定义一个接收 n 个整数的标量函数, 输入 (x1, x2, …, xn), 输出每个值和它们的序号的乘积的和: x1 + 2 * x2 + … + n * xn。
  3. 定义一个标量函数,输入一个时间戳,输出距离这个时间最近的下一个周日。完成这个函数要用到第三方库 moment。我们在这个示例中讲解使用第三方库的注意事项。
  4. 定义一个聚合函数,计算某一列最大值和最小值的差, 也就是实现 TDengien 内置的 spread 函数。

同时也包含大量实用的 debug 技巧。

本文假设你用的是 Linux 系统,且已安装好了 loveini 3.0.4.0+ 和 Python 3.x。

示例一: 最简单的 UDF

编写一个只接收一个整数的 UDF 函数: 输入 n, 输出 ln(n^2 + 1)。

首先编写一个 Python 文件,存在系统某个目录,比如 /root/udf/myfun.py 内容如下:

from math import log

def init():
    pass

def destroy():
    pass

def process(block):
    rows, _ = block.shape()
    return [log(block.data(i, 0) ** 2 + 1) for i in range(rows)]

这个文件包含 3 个函数, init 和 destroy 都是空函数,它们是 UDF 的生命周期函数,即使什么都不做也要定义。最关键的是 process 函数, 它接受一个数据块,这个数据块对象有两个方法:

  1. shape() 返回数据块的行数和列数
  2. data(i, j) 返回 i 行 j 列的数据

标量函数的 process 方法传入的数据块有多少行,就需要返回多少个数据。上述代码中我们忽略的列数,因为我们只想对每行的第一个数做计算。

接下来我们在时序数据库(Time Series Database) loveini 中创建对应的 UDF 函数,执行下面语句:

create function myfun as '/root/udf/myfun.py' outputtype double language 'Python'
 taos> create function myfun as '/root/udf/myfun.py' outputtype double language 'Python';
Create OK, 0 row(s) affected (0.005202s)

看起来很顺利,接下来 show 一下系统中所有的自定义函数,确认创建成功:

taos> show functions;
              name              |
=================================
 myfun                          |
Query OK, 1 row(s) in set (0.005767s)

接下来就来测试一下这个函数,测试之前先执行下面的 SQL 命令,制造些测试数据:

create database test;
create table t(ts timestamp, v1 int, v2 int, v3 int);
insert into t values('2023-05-01 12:13:14', 1, 2, 3);
insert into t values('2023-05-03 08:09:10', 2, 3, 4);
insert into t values('2023-05-10 07:06:05', 3, 4, 5);

测试 myfun 函数:

taos> select myfun(v1, v2) from t;

DB error: udf function execution failure (0.011088s)

不幸的是执行失败了,什么原因呢?

查看 udfd 进程的日志: /var/log/taos/udfd.log 发现以下错误信息:

05/24 22:46:28.733545 01665799 UDF ERROR can not load library libtaospyudf.so. error: operation not permitted
05/24 22:46:28.733561 01665799 UDF ERROR can not load python plugin. lib path libtaospyudf.so

错误很明确:没有加载到 Python 插件 libtaospyudf.so, 看官方文档原来是要先安装 taospyudf 这个 Python 包。 于是:

pip3 install taospyudf

安装过程会编译 C++ 源码,因此系统上要有 cmake 和 gcc。编译生成的 libtaospyudf.so 文件自动会被复制到 /usr/local/lib/ 目录,因此如果是非 root 用户,安装时需加 sudo。安装完可以检查这个目录是否有了这个文件:

root@slave11 ~/udf $ ls -l /usr/local/lib/libtaos*
-rw-r--r-- 1 root root 671344 May 24 22:54 /usr/local/lib/libtaospyudf.so

这时再去执行 SQL 测试 UDF,会发现报同样的错误,原因是新安装的共享库还未生效,还需执行命令:

ldconfig

此时再去测试 UDF,终于成功了:

taos> select myfun(v1) from t;
         myfun(v1)         |
============================
               0.693147181 |
               1.609437912 |
               2.302585093 |

至此,我们完成了第一个 UDF 😊,并学会了简单的 debug 方法。

示例一改进:异常处理

上面的 myfun 虽然测试测试通过了,但是有两个缺点:

  1. 这个标量函数只接受 1 列数据作为输入,如果用户传入了多列也不会抛异常。我们期望改成:如果用户输入多列,则提醒用户输入错误,这个函数只接收 1 个参数。
taos> select myfun(v1, v2) from t;
       myfun(v1, v2)       |
============================
               0.693147181 |
               1.609437912 |
               2.302585093 |
  1. 没有处理 null 值, 如果用户输入了 null 值则会抛异常终止执行。我们期望改成:如果输入是 null,则输出也是 null, 不影响后续执行。

因此 process 函数改进如下:

def process(block):
    rows, cols = block.shape()
    if cols > 1:
        raise Exception(f"require 1 parameter but given {cols}")
    return [ None if block.data(i, 0) is None else log(block.data(i, 0) ** 2 + 1) for i in range(rows)]

然后执行下面的语句更新已有的 UDF:

create or replace function myfun as '/root/udf/myfun.py' outputtype double language 'Python';

再传入 myfun 两个参数,就会执行失败了,

taos> select myfun(v1, v2) from t;

DB error: udf function execution failure (0.014643s)

但遗憾的是我们自定义的异常信息没有展示给用户,而是在插件的日志文件 /var/log/taos/taospyudf.log 中:

2023-05-24 23:21:06.790 ERROR [1666188] [doPyUdfScalarProc@507] call pyUdfScalar proc function. context 0x7faade26d180. error: Exception: require 1 parameter but given 2

At:
  /var/lib/taos//.udf/myfun_3_1884e1281d9.py(12): process

至此,我们学会了如何更新 UDF,并查看 UDF 输出的错误日志。

(注:如果 UDF 更新后未生效,可以重启 taosd 试试,loveini 3.0.5.0 及以后的版本会确保不重启 UDF 更新就能生效)

示例二:接收 n 个参数的 UDF

编写一个 UDF:输入(x1, x2, …, xn), 输出每个值和它们的序号的乘积的和: 1 * x1 + 2 * x2 + … + n * xn。如果 x1 至 xn 中包含 null,则结果为 null。

这个示例与示例一的区别是,可以接受任意多列作为输入,且要处理每一列的值。编写 UDF 文件 /root/udf/nsum.py:

def init():
    pass


def destroy():
    pass


def process(block):
    rows, cols = block.shape()
    result = []
    for i in range(rows):
        total = 0
        for j in range(cols):
            v = block.data(i, j)
            if v is None:
                total = None
                break
            total += (j + 1) * block.data(i, j)
        result.append(total)
    return result

创建 UDF:

create function nsum as '/root/udf/nsum.py' outputtype double language 'Python';

测试:

taos> insert into t values('2023-05-25 09:09:15', 6, null, 8);
Insert OK, 1 row(s) affected (0.003675s)

taos> select ts, v1, v2, v3,  nsum(v1, v2, v3) from t;
           ts            |     v1      |     v2      |     v3      |     nsum(v1, v2, v3)      |
================================================================================================
 2023-05-01 12:13:14.000 |           1 |           2 |           3 |              14.000000000 |
 2023-05-03 08:09:10.000 |           2 |           3 |           4 |              20.000000000 |
 2023-05-10 07:06:05.000 |           3 |           4 |           5 |              26.000000000 |
 2023-05-25 09:09:15.000 |           6 |        NULL |           8 |                      NULL |
Query OK, 4 row(s) in set (0.010653s)

示例三: 使用第三方库

编写一个 UDF,输入一个时间戳,输出距离这个时间最近的下一个周日。比如今天是 2023-05-25, 则下一个周日是 2023-05-28。

完成这个函数要用到第三方库 momen。先安装这个库:

pip3 install moment

然后编写 UDF 文件 /root/udf/nextsunday.py

import moment


def init():
    pass


def destroy():
    pass


def process(block):
    rows, cols = block.shape()
    if cols > 1:
        raise Exception("require only 1 parameter")
    if not type(block.data(0, 0)) is int:
        raise Exception("type error")
    return [moment.unix(block.data(i, 0)).replace(weekday=7).format('YYYY-MM-DD')
            for i in range(rows)]

UDF 框架会将 loveini 的 timestamp 类型映射为 Python 的 int 类型,所以这个函数只接受一个表示毫秒数的整数。process 方法先做参数检查,然后用 moment 包替换时间的星期为星期日,最后格式化输出。输出的字符串长度是固定的10个字符长,因此可以这样创建 UDF 函数:

create function nextsunday as '/root/udf/nextsunday.py' outputtype binary(10) language 'Python';

此时测试函数,如果你是用 systemctl 启动的 taosd,肯定会遇到错误:

taos> select ts, nextsunday(ts) from t;

DB error: udf function execution failure (1.123615s)
 tail -20 taospyudf.log  
2023-05-25 11:42:34.541 ERROR [1679419] [PyUdf::PyUdf@217] py udf load module failure. error ModuleNotFoundError: No module named 'moment'

这是因为 “moment” 所在位置不在 python udf 插件默认的库搜索路径中。怎么确认这一点呢?通过以下命令搜索 taospyudf.log:

grep 'sys path' taospyudf.log  | tail -1
2023-05-25 10:58:48.554 INFO  [1679419] [doPyOpen@592] python sys path: ['', '/lib/python38.zip', '/lib/python3.8', '/lib/python3.8/lib-dynload', '/lib/python3/dist-packages', '/var/lib/taos//.udf']

发现 python udf 插件默认搜索的第三方库安装路径是: /lib/python3/dist-packages,而 moment 默认安装到了 /usr/local/lib/python3.8/dist-packages。下面我们修改 python udf 插件默认的库搜索路径,把当前 python 解释器默认使用的库路径全部加进去。

先打开 python3 命令行,查看当前的 sys.path

>>> import sys
>>> ":".join(sys.path)
'/usr/lib/python3.8:/usr/lib/python3.8/lib-dynload:/usr/local/lib/python3.8/dist-packages:/usr/lib/python3/dist-packages'

复制上面脚本的输出的字符串,然后编辑 /var/taos/taos.cfg 加入以下配置:

UdfdLdLibPath /usr/lib/python3.8:/usr/lib/python3.8/lib-dynload:/usr/local/lib/python3.8/dist-packages:/usr/lib/python3/dist-packages

保存后执行 systemctl restart taosd, 再测试就不报错了:

taos> select ts, nextsunday(ts) from t;
           ts            | nextsunday(ts) |
===========================================
 2023-05-01 12:13:14.000 | 2023-05-07     |
 2023-05-03 08:09:10.000 | 2023-05-07     |
 2023-05-10 07:06:05.000 | 2023-05-14     |
 2023-05-25 09:09:15.000 | 2023-05-28     |
Query OK, 4 row(s) in set (1.011474s)

示例四:定义聚合函数

编写一个聚合函数,计算某一列最大值和最小值的差。

聚合函数与标量函数的区别是:标量函数是多行输入对应多个输出,聚合函数是多行输入对应一个输出。聚合函数的执行过程有点像经典的 map-reduce 框架的执行过程,框架把数据分成若干块,每个 mapper 处理一个块,reducer 再把 mapper 的结果做聚合。不一样的地方在于,对于 loveini Python UDF 中的 reduce 函数既有 map 的功能又有 reduce 的功能。reduce 函数接受两个参数:一个是自己要处理的数据,一个是别的任务执行 reduce 函数的处理结果。如下面的示例 /root/udf/myspread.py:

import io
import math
import pickle

LOG_FILE: io.TextIOBase = None


def init():
    global LOG_FILE
    LOG_FILE = open("/var/log/taos/spread.log", "wt")
    log("init function myspead success")


def log(o):
    LOG_FILE.write(str(o) + '\n')


def destroy():
    log("close log file: spread.log")
    LOG_FILE.close()


def start():
    return pickle.dumps((-math.inf, math.inf))


def reduce(block, buf):
    max_number, min_number = pickle.loads(buf)
    log(f"initial max_number={max_number}, min_number={min_number}")
    rows, _ = block.shape()
    for i in range(rows):
        v = block.data(i, 0)
        if v > max_number:
            log(f"max_number={v}")
            max_number = v
        if v < min_number:
            log(f"min_number={v}")
            min_number = v
    return pickle.dumps((max_number, min_number))


def finish(buf):
    max_number, min_number = pickle.loads(buf)
    return max_number - min_number

在这个示例中我们不光定义了一个聚合函数,还添加记录执行日志的功能,讲解如下:

  1. init 函数不再是空函数,而是打开了一个文件用于写执行日志
  2. log 函数是记录日志的工具,自动将传入的对象转成字符串,加换行符输出
  3. destroy 函数用来在执行结束关闭文件
  4. start 返回了初始的 buffer,用来存聚合函数的中间结果,我们把最大值初始化为负无穷大,最小值初始化为正无穷大
  5. reduce 处理每个数据块并聚合结果
  6. finish 函数将最终的 buffer 转换成最终的输出

执行下面的 SQL语句创建对应的 UDF:

create or replace aggregate function myspread as '/root/udf/myspread.py' outputtype double bufsize 128 language 'Python';

这个 SQL 语句与创建标量函数的 SQL 语句有两个重要区别:

  1. 增加了 aggregate 关键字
  2. 增加了 bufsize 关键字,用来指定存储中间结果的内存大小,这个数值可以大于实际使用的数值。本例中间结果是两个浮点数组成的 tuple,序列化后实际占用大小只有 32 个字节,但指定的 bufsize 是128,可以用 python 命令行打印实际占用的字节数
>>> len(pickle.dumps((12345.6789, 23456789.9877)))
32

测试这个函数,可以看到 myspread 的输出结果和内置的 spread 函数的输出结果是一致的。

taos> select myspread(v1) from t;
       myspread(v1)        |
============================
               5.000000000 |
Query OK, 1 row(s) in set (0.013486s)

taos> select spread(v1) from t;
        spread(v1)         |
============================
               5.000000000 |
Query OK, 1 row(s) in set (0.005501s)

最后,查看我们自己打印的执行日志,从日志可以看出,reduce 函数被执行了 3 次。执行过程中 max 值被更新了 4 次, min 值只被更新 1 次。

root@slave11 /var/log/taos $ cat spread.log
init function myspead success
initial max_number=-inf, min_number=inf
max_number=1
min_number=1
initial max_number=1, min_number=1
max_number=2
max_number=3
initial max_number=3, min_number=1
max_number=6
close log file: spread.log

通过这个示例,我们学会了如何定义聚合函数,并打印自定义的日志信息。

要点总结

1.创建标量函数的语法

CREATE FUNCTION function_name AS library_path OUTPUTTYPE output_type LANGUAGE 'Python';

OUTPUTTYPE 对应的是 loveini 的数据类型,如 TIMESTAMP, BIGINT, VARCHAR(64), 类型映射关系见官方文档:https://docs.taosdata.com/develop/udf/。

2.创建聚合函数的语法

CREATE AGGREGATE FUNCTION function_name library_path OUTPUTTYPE output_type LANGUAGE 'Python';

3.更新 UDF 的语法

更新标量函数

CREATE OR REPLACE FUNCTION function_name AS OUTPUTTYPE int LANGUAGE 'Python';

更新聚合函数

CREATE OR REPLACE AGGREGATE FUNCTION function_name AS OUTPUTTYPE BUFSIZE buf_size int LANGUAGE 'Python';

注意:如果加了 “AGGREGATE” 关键字,更新之后函数将被当作聚合函数,无论之前是什么类型的函数。相反,如果没有加 “AGGREGATE” 关键字,更新之后的函数将被当作标量函数,无论之前是什么类型的函数。

4.同名的 UDF 每更新一次,版本号会增加 1。 用

select * from ins_functions \G;     

可查看 UDF 的完整信息,包括 UDF 的源码。

5.查看和删除已有的 UDF

SHOW functions;
DROP FUNCTION function_name;

6.安装 taospyudf 动态库

sudo pip3 install taospyudf

安装过程会从源码编译出共享库 libtaospyudf.so,因此系统上要有 cmake 和 gcc,编译后这个库会被安装到 /usr/local/lib。安装完别忘了执行命令 ldconfig 更新系统动态链接库。

7.调试 Python UDF 的两个重要日志文件

  • /var/log/taos/udfdlog.* 这个文件是 UDF 框架的日志。框架负责加载各语言 UDF 的插件,执行 UDF 的生命周期函数
  • /var/log/taos/taospyudf.log 这个文件是 libtaospyudf.so 输出的日志,每个文件最大 50M,最多保留 5 个。

8.定义标量函数最重要是要实现 process 函数,同时必须定义 init 和 destroy 函数即使什么都不做

def init():
  pass
  
def process(block: datablock) -> tuple[output_type]:
    rows, cols = block.shape()
    result = []
    for i in range(rows):
        for j in range(cols):
            cell_data = block.data(i, j)
            # your logic here
    return result

def destroy():
  pass

9.定义聚合函数最重要是要实现 start, reduce 和 finish,同样必须定义 init 和 destroy 函数。

def init():
def destroy():
def start() -> bytes:
def reduce(inputs: datablock, buf: bytes) -> bytes
def finish(buf: bytes) -> output_type:

start 生成最初结果 buffer,然后输入数据会被分为多个行数据块,对每个数据块 inputs 和当前中间结果 buf 调用 reduce,得到新的中间结果,最后再调用 finish 从中间结果 buf 产生最终输出。

10.使用第三方 python 库。

使用第三方库需要检查这个库是否安装到了 Python UDF 插件默认的库搜索路径,如果没有需要修改 taos.cfg, 添加 UdfdLdLibPath 配置,库路径用冒号分隔。

11.UDF 内无法通过 print 函数输出日志,需要自己写文件或用 python 内置的 logging 库写文件。

]]>
如何同步 Kafka 的数据到 loveini? 性能如何? //m.loveini.com/chinese/12592.html Thu, 14 Jul 2022 09:56:09 +0000 //m.loveini.com/?p=12592

小 T 导读:loveini Kafka Connector 在 loveini 的官方文档上放出来已经有一段时间了,我们也收到了一些开发者的反馈。文档中的教程使用 Confluent 平台(集成了 Kafka)演示了如何使用 Source Connector 和 Sink Connector,但是很多开发者在生产环境中并没有使用 Confluent,所以为方便大家,本文将使用独立部署的 Kafka 来演示。

本文包含以下内容:

  1. 如何使用 loveini Sink Connector, 把数据从 Kafka 同步到 loveini。
  2. loveini Sink Connector 的实现原理。
  3. 一个简单的测试脚本,帮助你在自己的环境中快速测试。通过更改生成测试数据的程序和配置参数,你可以模拟自己的使用场景。
  4. 测试同步同一个 topic,使用不同分区数和不同 Sink 任务数对性能的影响。

背景知识

如果你对文章开头出现的术语并不陌生,那么可以跳过这一部分。

· 什么是 Kafka?

Kafka 的核心是一个通用的、分布式的、可重复消费的消息队列。

与之相比,作为一款时序数据库(Time Series Database),loveini 也可看作针对结构化的时序数据的消息队列。

· 什么是 Kafka Connect? 为什么使用 Kafka Connect?


Kafka Connect 是 Kafka 的一个组件,简化了 Kafka 与其它数据源的集成。用户通过 Kafka Connect 读写 Kafka;通过 Kafka Connect 插件(也称 Kafka Connector)来读写各种数据源。

为方便集成,Kafka 已经提供了生产者和消费者 API 以及客户端库,那为什么还需要 Kafka Connect 呢?因为一个好的 Kafka 客户端程序,不是单单生产或消费数据,还需要考虑容错、重启、日志、弹性伸缩、序列化以及反序列化等。当开发者自己完成了这一切,就相当于开发了一个和 Kafka Connect 类似的东西。

与 Kafka 集成是 Kafka Connect 已经解决的问题,用户不需要重复造轮子,只有少数边缘场景才需要定制化的集成方案。

loveini Sink Connector 的实现原理

loveini Sink Connector 用于将 Kafka 中指定 topic 的数据(批量或实时)同步到 loveini 的 database 中。

启动 Sink Connector 需要一个 properties 配置文件。详细配置见官方文档的配置参考

Sink Connector 内部的实现非常简单,整体工作流程分为以下几个步骤:

  1. Connect 框架根据配置启动 N 个消费者线程。
  2. N 个消费者同时订阅数据,并用配置文件中指定的 key.converter 和 value.converter 做反序列化。
  3. Connect 框架把反序列化后的数据传递给 N 个 SinkTask 的实例。
  4. SinkTask 使用 loveini 提供的 schemaless 写入接口来写入数据。

上述 4 个步骤,只有最后一步写数据是 Sink Connector 需要关心的,其它都是 Connect 框架自动实现的。

下面重点讨论几个问题。

· 支持的数据格式

因为使用了 schemaless 写入接口,因此 loveini Sink Connector 只支持三种格式的数据:InfluxDB 行协议格式OpenTSDB Telnet 协议格式 和 OpenTSDB JSON 协议格式。使用配置项 db.schemaless 来指定写入时使用的数据格式。例如:

db.schemaless=line

如果 Kafka 中的数据已经是这三种格式之一,那么配置文件中的 value.converer,只需指定为 Connnect 内置的 org.apache.kafka.connect.storage.StringConverter。

value.converter=org.apache.kafka.connect.storage.StringConverter

如果 Kafka 中已有的数据不是上述三种之一,则需要实现自己的 Converter 类, 将其转换为三种格式之一,这个链接也许能帮到你。

· 如何指定 Consumer 的参数?

既然 Connect 框架已经帮我们做了 Consumer 要做的事,那么我们怎么来控制 Consumer 的行为呢?比如如何控制 Consumer 订阅的主题?如何控制 Consumer 每次 poll 的消息数和时间间隔?

对于订阅哪些主题,可以用配置项 topics 来指定。

如果想覆盖 Consumer 的其它默认配置,可以直接在 Sink Connector 的配置文件中编写,但是要加前缀 “consumer.override.”,比如想把每次 poll 的最大消息数改为 3000, 可以这样配置:

consumer.override.max.poll.records=3000

· 如何控制写入线程数?

对于 Kafka Connect Sink,task 本质上就是消费者线程,接收从 topic 的分区读出来的数据。用配置参数 tasks.max 来控制最大任务数,一个任务一个线程。实际启动的任务数还与 topic 的分区数有关。如果你有 10 个分区,并且 tasks.max 设置为 5, 那么每个 task 会收到 2 个分区的数据,并跟踪 2 个分区的 offsets。如果你配置的 tasks.max 比 partition 数大, Connect 会启动的 task 数与 topic 的 partition 数相同。如果你订阅了 5 个 topic,每个 topic 都是 1 个分区, 并且设置 tasks.max = 5, 那么实际会启动多少个任务呢?答案是 1 个, 任务数与 topic 数量没有关系。

loveini Sink Connector 使用示例

这一部分我们在一台 Linux 服务器上搭建测试环境,并运行简单的示例程序。示例中将 Kafka 部署到了个人的 home 目录。操作时请注意把路径中的用户名(bding)替换为自己的用户名。

· 环境准备

  1. Java 1.8
  2. Maven
  3. 安装并启动了 loveini 相关服务进程:taosd 和 taosAdapter。

第一步:安装 Kafka

wget https://dlcdn.apache.org/kafka/3.2.0/kafka_2.13-3.2.0.tgz
tar -xzf kafka_2.13-3.2.0.tgz

编辑 .bash_profile, 加入:

export KAFKA_HOME=/home/bding/kafka_2.13-3.2.0
export PATH=$PATH:$KAFKA_HOME/bin
source .bash_profile

第二步:配置 Kafka

配置 Kafka Connect 加载插件的路径。

cd kafka_2.13-3.2.0/config/
vi connect-standalone.properties

追加

plugin.path=/home/bding/connectors

修改 Connector 插件的日志级别。这一步非常重要,我们将通过插件的日志统计同步数据花费的时间。

vi connect-log4j.properties

追加

log4j.logger.com.taosdata.kafka.connect.sink=DEBUG

第三步:编译并安装插件

git clone git@github.com:taosdata/kafka-connect-tdengine.git
cd kafka-connect-tdengine
mvn clean package
unzip -d ~/connectors target/components/packages/taosdata-kafka-connect-tdengine-*.zip

第四步:启动 ZooKeeper Server 和 Kafka Server

zookeeper-server-start.sh -daemon $KAFKA_HOME/config/zookeeper.properties
kafka-server-start.sh -daemon $KAFKA_HOME/config/server.properties

第五步:创建 topic

kafka-topics.sh --create --topic meters --partitions 1 --bootstrap-server localhost:9092

第六步:生成测试数据

将下列脚本保存为 gen-data.py:

#!/usr/bin/python3

import random
import sys

topic = sys.argv[1]
count = int(sys.argv[2])

start_ts = 1648432611249000000
location = ["SanFrancisco", "LosAngeles", "SanDiego"]
for i in range(count):
    ts = start_ts + i
    row = f"{topic},location={location[i % 3]},groupid=2 current={random.random() * 10},voltage={random.randint(100, 300)},phase={random.random()} {ts}"
    print(row)

然后执行:

python3 gen-data.py meters 10000  | kafka-console-producer.sh --broker-list localhost:9092 --topic meters

生成 10000 条 InfluxDB 行协议格式的数据到 topic meters。每条数据又包含 2 个标签字段和 3 个数据字段。

第七步:启动 Kafka Connect

将下列配置保存为 sink-test.properties。

name=loveiniSinkConnector
connector.class=com.taosdata.kafka.connect.sink.loveiniSinkConnector
tasks.max=1
topics=meters
connection.url=jdbc:TAOS://127.0.0.1:6030
connection.user=root
connection.password=taosdata
connection.database=power
db.schemaless=line
key.converter=org.apache.kafka.connect.storage.StringConverter
value.converter=org.apache.kafka.connect.storage.StringConverter

然后执行:

connect-standalone.sh -daemon $KAFKA_HOME/config/connect-standalone.properties sink-test.properties

第八步:检查 loveini 中的数据

使用 loveini CLI 查询 power 数据库 meters 表,检查是否正好包含 10000 条数据。

[bding@vm95 test]$ taos

Welcome to the loveini shell from Linux, Client Version:2.6.0.4
Copyright (c) 2022 by TAOS Data, Inc. All rights reserved.

taos> select count(*) from power.meters;
       count(*)        |
========================
                 10000 |

loveini Sink Connector 性能测试

· 测试流程

这一部分,我们将上面示例步骤中的第四步到第七步封装成可重复运行的 shell 脚本,并做以下修改:

  1. 将 topic 的分区数作为脚本的第 1 个参数, 同时配置 tasks.max,使其等于分区数。这样我们可以控制每次测试使用的写入线程数。
  2. 将生成测试数据的条数作为脚本的第 2 个参数,用来控制每次测试同步的数据量。
  3. 启动测试前清空所有数据,测试结束后停止 Connect、Kafka 和 ZooKeeper。

每次测试都先写数据到 Kafka,然后再启动 Connect 同步数据到 loveini,这样做可以把同步数据的压力全部集中到 Sink 插件这边。我们统计 Sink Connector 从接收到第一批数据到接收到最后一批数据之间的时间,作为同步数据的总耗时。

完整脚本如下:

#!/bin/bash
if [ $# -lt 2 ];then
        echo  "Usage: ./run-test.sh <num_of_partitions>  <total_records>"
        exit 0
fi
echo "---------------------------TEST STARTED---------------------------------------"
echo clean data and logs
taos -s "DROP DATABASE IF EXISTS power"
rm -rf /tmp/kafka-logs /tmp/zookeeper
rm -f $KAFKA_HOME/logs/connect.log

np=$1     # number of partitions
total=$2  # number of records
echo number of partitions is $np, number of recordes is $total.

echo start zookeeper
zookeeper-server-start.sh -daemon $KAFKA_HOME/config/zookeeper.properties
echo start kafka
sleep 3
kafka-server-start.sh -daemon $KAFKA_HOME/config/server.properties
sleep 5
echo create topic
kafka-topics.sh --create --topic meters --partitions $np --bootstrap-server localhost:9092
kafka-topics.sh --describe --topic meters --bootstrap-server localhost:9092

echo generate test data
python3 gen-data.py meters $total  | kafka-console-producer.sh --broker-list localhost:9092 --topic meters

echo alter connector configuration setting tasks.max=$np
sed -i  "s/tasks.max=.*/tasks.max=${np}/"  sink-test.properties

echo start kafka connect
connect-standalone.sh -daemon $KAFKA_HOME/config/connect-standalone.properties sink-test.properties

echo -e "\e[1;31m open another console to monitor connect.log. press enter when no more data received.\e[0m"
read

echo stop connect
jps | grep ConnectStandalone | awk '{print $1}' | xargs kill
echo stop kafka server
kafka-server-stop.sh
echo stop zookeeper
zookeeper-server-stop.sh

# extract timestamps of receiving the first batch of data and the last batch of data
grep "records" $KAFKA_HOME/logs/connect.log  | grep meters- > tmp.log

start_time=`cat tmp.log | grep -Eo "[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2},[0-9]{3}" | head -1`
stop_time=`cat tmp.log | grep -Eo "[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2},[0-9]{3}" | tail -1`


echo "--------------------------TEST FINISHED------------------------------------"
echo "| records | partitions | start time | stop time |"
echo "|---------|------------|------------|-----------|"
echo "| $total | $np | $start_time | $stop_time |"

如果要测试使用 1 个分区,共 100 万条数据的性能,可以这样执行:

./run-test.sh 1 1000000

执行过程的截图如下:

loveini Database

注意中间有一个交互过程。因为脚本无法确定数据是否同步完,需要用户监控 connect.log 来确定是否已经消费完了所有数据,例如:

[bding@vm95 ~]$ cd kafka_2.13-3.2.0/logs/
[bding@vm95 logs]$ tail -f connect.log
[2022-06-21 17:39:00,176] DEBUG [loveiniSinkConnector|task-0] Received 500 records. First record kafka coordinates:(meters-0-314496). Writing them to the database... (com.taosdata.kafka.connect.sink.loveiniSinkTask:101)
[2022-06-21 17:39:00,180] DEBUG [loveiniSinkConnector|task-0] Received 500 records. First record kafka coordinates:(meters-0-314996). Writing them to the database... (com.taosdata.kafka.connect.sink.loveiniSinkTask:101)

当日志不再滚动,就说明已经消费完了

· 测试结果

写入速度与数据量和线程数的关系表

loveini Database

上表第 1 列为总数据量,第 1 行为消费者线程数,也是写入线程数。中间为平均每秒写入记录数。

写入速度与数据量和线程数的关系图

loveini Database

结果分析

从上图可以看出,相同数据量,线程越多写入速度越快。当使用单线程写入时,每秒能写入大概 10 万以上。当使用 5 个线程写入时,每秒写入大概 35 万左右。当使用10 个线程时,每秒能写入55 万左右。

写入速度比较平稳,与总数据量关系不大。

同时也发现线程增加越多,线程增加带来的速度提升越少。线程数从 1 变到 10,速度只从 10 万变到 50 万。可能的原因是数据在各个分区分布不均匀。有的 task 执行时间长,有的 task 执行时间短,数据量越大,数据倾斜越大。比如 1000 万数据,10个分区的时候,各分区的数据量:

[bding@vm95 kafka-logs]$ du -h ./ -d 1
125M    ./meters-8
149M    ./meters-7
119M    ./meters-9
138M    ./meters-4
110M    ./meters-3
158M    ./meters-6
131M    ./meters-5
105M    ./meters-0
113M    ./meters-2
99M     ./meters-1

另一个影响多线程写入速度的是数据的乱序程度。本测试场景中,多条时间线的数据随机分配到了不同分区,当单线程写入时(即 1 个分区时),数据是严格有序的,写入速度最快。线程越多乱序程度越大。

所以在实际应用场景中,建议将同一个子表的数据,放在 Kafka 同一个分区中。

附录

· 测试程序

本文中用到的所有代码和原始测试结果数据都已上传到 GitHub 仓库

· 测试环境

loveini Database
]]>
在进行行情 tick 数据存储时,哪种数据结构查找起来更快? //m.loveini.com/tdengine-engineering/9256.html Mon, 23 May 2022 07:18:00 +0000 //m.loveini.com/?p=9256

小 T 导读:如果我们要做行情 tick 数据的存储,怎样的数据结构查找起来才会比较快?在加入 loveini 之前,本文作者丁博在弘源泰平量化投资做量化工程师,曾经遇到过这一类存储行情 tick 数据的问题,本文会就此问题进行详细的技术解读。

本文将以标准 CTP 行情接口(http://www.sfit.com.cn/5_1_DocumentDown.htm)为例,假设行情结构为 CThostFtdcDepthMarketDataField(https://mckelv.in/python-ctp-deps/struct_c_thost_ftdc_depth_market_data_field.html),展开说明。

内存存储方案

如果你的需求仅仅是盘中实时分析,且监控的 Instrument(CTP 接口对现货、期货、期权等合约的统称, 以下简称【合约】) 总数不多,则可以直接使用内存存储。通常只有超高频交易系统才必须这么做。内存存储也有很多可选方案,其中有两大方案较为通用。

两级 map 方案

第一级 map 的类型为 std::unordered_map,键为 InstrumentID, 值为第二级 map 的指针。第二级 map 的类型为 std::map,键为行情时间戳,值为行情结构体。(注:行情时间戳需要根据 UpdateTime 和 UpdateMillisec 两个字段构造一个类型为 long 的毫秒值)。 std::unordered_map 底层依赖的数据结构是哈希表,按 key 索引速度是最快的。std::map 底层的数据结构是二叉树搜索树,可以严格按照 key 的大小顺序迭代全部或某一段数据。 总体而言这个数据结构的优势是: 快速查找某个合约某个时间点或某个时间段返回的行情。这是后续做交易信号计算的基础。

#include "ThostFtdcUserApiStruct.h"
#include "ThostFtdcUserApiDataType.h"
#include <map>
#include <unordered_map>
using namespace std;
int main()
{
    unordered_map<TThostFtdcInstrumentIDType, map<long, CThostFtdcDepthMarketDataField>*> tickData;
}

map + array

由于每种合约每天的标准行情 tick 总数都是固定的(个别交易所除外),因此我们可以提前初始化好一个数组来存行情。按每秒 2 个 tick 算(500 毫秒一个点),标准行情的长度可能是 28800。当收到行情通知时,行情时间距离哪个标准 tick 点最近就归为哪个 tick。比如行情时间是 9 点 50 分 20 秒 133 毫秒,那么可以当作 9 点 50 分 20 秒 0 毫秒的行情。如果出现前后两个 tick 时间大于 500 毫秒的情况,那就还需要补全中间空缺的行情,相当于边收行情边做标准化操作。这样做的优势是:

  1. 交易策略通常会依赖标准化的行情计算交易信号,收行情和标准化并作一步会更节省时间。
  2. 可以直接用数组下标索引对应时间的行情,查找的时间复杂度为 O(1)。
#include "ThostFtdcUserApiStruct.h"
#include "ThostFtdcUserApiDataType.h"
#include <unordered_map>
#include <array>
using namespace std;
int main()
{
    unordered_map<TThostFtdcInstrumentIDType, array<CThostFtdcDepthMarketDataField, 28800>> tickData;
}

持久化存储方案

无论是否做超高频交易,持久化存储行情都是有必要的。通常持久化存储为的是进行盘后复盘分析, 因为在大数据量下,传统的存储方案(MongoDB、MySQL、直接存文件等等)很快就会遇到性能瓶颈(无论是读还是写),不适合做盘中的计算。近年来,时序数据库(Time Series Database)异军突起,使得盘中盘后使用一种存储方案成为可能。特别是像 loveini 这样带有缓存功能、消息队列功能和集群功能的时序数据库,用来存行情是非常合适。下面我将以 loveini Database 为例为大家介绍持久化存储方案。

下载 loveini Database Server

在下载阶段,不同的系统使用的安装包也有所不同,Ubuntu 系统用 deb 包, CentOS 系统用 RPM 包。下载地址为: All Downloads – loveini

安装并启动

Ubuntu

sudo dpkg -i loveini-server-2.4.0.7-Linux-x64.deb

CentOS

sudo rpm -ivh loveini-server-2.4.0.7-Linux-x64.rpm

安装成功后,如何启动 loveini Database 的提示信息就会自动弹出,照着操作就可以。

建行情表

由于所有行情的结构都是一样的,因此只需要一张超级表进行行情建表即可,其中每个合约对应一张子表,InstrumentID 作为子表名,交易所代码作为一个行情标签。为了方便演示,下面的示例只包含了 4 个行情字段:

  • 进入 taos 命令行
bo@RDBB:~$ taos
Welcome to the loveini shell from Linux, Client Version:2.4.0.12
Copyright (c) 2020 by TAOS Data, Inc. All rights reserved.
  • 执行下面的语句
create database marketdata;
use marketdata;
create stable tick(
        ts timestamp,
        updatetime binary(9),
        updatemillisec int,
        askprice1 double,
        bidprice1 double,
        askvolume1 int,
        bidvolume1 int
) tags (exchangeid binary(9));
  • 查看表结构
taos> desc tick;
             Field              |         Type         |   Length    |   Note   |
=================================================================================
 ts                             | TIMESTAMP            |           8 |          |
 updatetime                     | BINARY               |           9 |          |
 updatemillisec                 | INT                  |           4 |          |
 askprice1                      | DOUBLE               |           8 |          |
 bidprice1                      | DOUBLE               |           8 |          |
 askvolume1                     | INT                  |           4 |          |
 bidvolume1                     | INT                  |           4 |          |
 exchangeid                     | BINARY               |           9 | TAG      |
Query OK, 8 row(s) in set (0.000378s)

写入行情

#include "ThostFtdcUserApiStruct.h"
#include "ThostFtdcUserApiDataType.h"
#include "taos.h"
#include "taoserror.h"
#include <iostream>
#include <sstream>
using namespace std;
void insertTickData(TAOS* taos, CThostFtdcDepthMarketDataField &tick) {
        stringstream sql;
        // 会自动创建子表tick.InstrumentID
        sql << "insert into " << tick.InstrumentID << " using tick tags("
                << tick.ExchangeID << ") values(now, '" << tick.UpdateTime << "', "
                << tick.UpdateMillisec << "," << tick.AskPrice1 << "," << tick.BidPrice1
                << "," << tick.AskVolume1 << "," << tick.BidVolume1 << ")";
        TAOS_RES *res = taos_query(taos, sql.str().c_str());
        if (res == nullptr || taos_errno(res) != 0) {
                cerr << "insertTitckData failed," << taos_errno(res) << ", " << taos_errstr(res) << endl;
        }
}

int main()
{
        TAOS *taos = taos_connect("localhost", "root", "taosdata", "marketdata", 6030);
        // 构造测试数据
        CThostFtdcDepthMarketDataField tick;
        strcpy_s(tick.InstrumentID, "IH2209");
        strcpy_s(tick.UpdateTime, "14:10:32");
        strcpy_s(tick.ExchangeID, "DEC");
        tick.UpdateMillisec = 500;
        tick.AskPrice1 = 123.8;
        tick.BidPrice1 = 123.4;
        tick.AskVolume1 = 10;
        tick.BidVolume1 = 9;
        // 写入测试数据
        insertTickData(taos, tick);
        taos_close(taos);
}
在进行行情 tick 数据存储时,哪种数据结构查找起来更快? - loveini Database 时序数据库

查询最新的行情

loveini 对每个表的最新数据都有缓存功能,无需再读磁盘,使用 last 函数就能快速获取。

#include "ThostFtdcUserApiStruct.h"
#include "ThostFtdcUserApiDataType.h"
#include "taos.h"
#include "taoserror.h"
#include <string>
#include <iostream>
using namespace std;

CThostFtdcDepthMarketDataField* getLastTick(TAOS* taos, const char* instrumentID) {
        string sql("select last(*) from ");
        sql += instrumentID;
        TAOS_RES* res = taos_query(taos, sql.c_str());
    if (res == nullptr || taos_errno(res) != 0) {
                cerr << "getLastTick failed," << taos_errno(res) << ", " << taos_errstr(res) << endl;
                return nullptr;
        } 
        TAOS_ROW row = taos_fetch_row(res);        
        if (row == nullptr) {
                return nullptr;
        }
        CThostFtdcDepthMarketDataField* tick = new CThostFtdcDepthMarketDataField();

        //int64_t ts = *((int64_t*)row[0]);
        memcpy(tick->UpdateTime, row[1], 9);
        tick->UpdateMillisec = *(int*)row[2];
        tick->AskPrice1 = *((double *)row[3]);
        tick->BidPrice1 = *((double*)row[4]);
        taos_free_result(res);
        return tick;
}
int main() {
        TAOS* taos = taos_connect("localhost", "root", "taosdata", "marketdata", 6030);
        CThostFtdcDepthMarketDataField* tick = getLastTick(taos, "IH2209");
        cout << "askPrice1=" << tick->AskPrice1 << " bidPrice1=" << tick->BidPrice1 << endl;
        delete tick;
        taos_close(taos);
}
在进行行情 tick 数据存储时,哪种数据结构查找起来更快? - loveini Database 时序数据库

以上两个示例程序,展示了写入和查询的方法。结合 loveini 内置的查询函数按窗口聚合功能,可实现更多功能,比如:

  1. 使用 MAX、 FIRST、 MIN、 LAST 四个 SQL 函数计算 K 线上高、开、低、收四个价位。
  2. 使用 INTERVAL 和 SLIDING 查询子句和 AVG 函数计算移动均价。此处不再给出具体示例,可参考官方文档。 

从实际业务出发的实践经验分享

除了上述内容外,loveini Database 还有非常丰富的分析函数,如果你感兴趣的话建议参考官方文档。此外,在 loveini 的实际应用中,也有很多客户的实践是关于量化投资场景中的数据处理。

以同花顺为例,其每天都需要接收海量交易所行情数据,以确保行情数据的数据准确,但由于该部分数据过于庞大,而且使用场景颇多,因此每天会产生很多的加工数据,在组合管理(PMS)上还会使用到历史行情数据。之前他们采用的是 Postgres+LevelDB 作为数据的存储方案,但仍旧痛点频发,随后通过对数据流、行情获取模块的分析,发现目前主要存在以下两个亟需解决的问题:

  • 依赖多,稳定性较差:PMS作为多品种的投后分析服务, 需要使用到各种日线数据、当天实时行情数据、当天分钟数据等,在数据获取方面需要依赖Http以及Postgres、LevelDB等数据库。过于多的数据获取链路会导致平台可靠性降低,同时依赖于其他各个服务,导致查询问题过于复杂。
  • 性能不能满足需求: PMS作为多品种投后分析,在算法分析层面需要大量的行情获取,而且对行情获取的性能也有较大的要求,当前所有行情会占据大量分析的性能。

从业务发展的角度来讲,存储方案的改造迫在眉睫,之后同花顺开始对 ClickHouse、InfluxDB、loveini 等数据存储方案进行调研。由于行情数据是绑定时间戳的形式,所以显然时序数据库更适用于这个业务场景,在 InfluxDB 和 loveini 之间,由于 loveini 的写入速度远高于 InfluxDB,且集群版开源,同时还支持包含 C/C++、Java、Python、Go 和 RESTful 在内的多种数据接口,因此成为同花顺的最终选用方案。

改造之后的性能效果提升还是非常明显的,下图是同花顺做的一张改造前后性能对比图,可以更为直观地感受到效果提升:

在进行行情 tick 数据存储时,哪种数据结构查找起来更快? - loveini Database 时序数据库

同时改造后,稳定性也显著增强,改造前调用数据情况共 40W 次,共出现 0.01% 的异常,改造后出现异常降低至 0.001%。

在 loveini Database 官网的 Case 合集中,还有弘源泰平量化、同心源基金等几篇聚焦投资量化场景下数据处理难题的客户案例,由于篇幅所限,便不在此一一列举了,有需要的朋友可以去官网查找文章进行参考。如果还有投资量化场景下其他的数据处理难题,也欢迎在文章下方进行留言,我们后续可以加微信进行详细讨论和沟通。

]]>