在工业物联网和智能制造领域,我们每天都在与海量的传感器数据打交道。电压、电流、温度、压力、流量……这些原始数据往往需要经过计算才能得到真正有价值的业务指标。比如:
传统的做法是什么呢?要么在采集端写代码处理,要么在数据入库后写复杂的SQL查询。这不仅需要专业的编程技能,而且每次业务需求变化都要修改代码、测试、部署,周期长、成本高、容易出错。
现在,IDMP 的公式表达式功能彻底改变了这一切! 无需编写一行代码,只需在界面上输入简单的数学公式,系统就能自动完成复杂的数据计算,还能智能处理计量单位的转换。
还记得在 Excel 中输入 =A1+B1 这样的公式吗?IDMP 的公式表达式就是这么简单!
示例:计算电器功率
假设您有一个智能电表,采集了电压和电流数据。要计算功率,只需:
${attributes['电压']} * ${attributes['电流']}就这么简单!系统会自动:

这是 IDMP 公式表达式最强大的功能之一!在工业场景中,不同设备、不同时期采集的数据可能使用不同的计量单位,这给数据分析带来了巨大挑战。
IDMP 能自动帮您做什么?
您有两个电流传感器:
想要计算总电流:传感器A + 传感器B
传统做法:您需要手动计算转换系数(1A = 1000mA),写出 `传感器A + 传感器B * 1000`
IDMP 做法:直接写 `${attributes[‘电流mA’]} + ${attributes[‘电流A’]}`
系统自动识别:
结果:您不用操心单位转换,系统自动搞定!

单位转换的具体规则请参考官方文档(暂未发布):
当您进行乘除运算时,系统能自动推导出结果的单位。
实际案例:计算体积
公式:${attributes['长度cm']} * ${attributes['宽度m']} * ${attributes['高度m']}
系统会自动:
如果您设置属性的显示单位是”立方厘米”,系统还会自动再转换一次!


如果您不小心写了一个单位不兼容的公式,系统会立即提醒您:
错误公式:${attributes['电流']} + ${attributes['电压']}
点击”评估”按钮后,系统提示:
❌ 错误:操作符'+'不能应用于不同的计量单位分类:'电流'和'电压'
这就避免了错误的计算进入生产环境!

公式不仅可以引用原始数据,还可以引用其他公式的结果,实现多层嵌套计算。
实际案例:能效分析
假设您要分析工厂的能效,需要多步计算:
步骤1 - 总功率:
公式:${attributes['设备1功率']} + ${attributes['设备2功率']} + ${attributes['设备3功率']}
步骤2 - 日耗电量:
公式:${attributes['总功率']} * 24
步骤3 - 能效比:
公式:${attributes['产量']} / ${attributes['日耗电量']}
每个公式都可以引用前面公式的结果,就像搭积木一样构建复杂的计算逻辑。
系统保护机制:
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),可以用于告警判断。
在保存公式之前,您可以随时点击”评估”按钮查看计算结果:

评估功能的好处:
– 即时反馈:不用保存就能看到结果
– 单位提示:系统告诉您结果的单位是什么
– 自动填充:如果属性还没设置单位,系统会自动填充推导出的单位,作为属性的 UOM 配置。
– 错误定位:清楚地告诉您哪里出错了
示例:公式结果的计量单位和属性上已经配置的计量单位不属于相同的计量单位分类。

背景:污水处理厂需要监控多个环保指标,不同指标使用不同的单位。
需求:
IDMP 米兰app官方正版下载:
# 污染物处理效率
属性1:进水COD(mg/L) - 来自数据库
属性2:出水COD(mg/L) - 来自数据库
属性3:COD去除率(%) - 公式:
(${attributes['进水COD']} - ${attributes['出水COD']}) / ${attributes['进水COD']} * 100
属性4:是否达标 - 公式:
${attributes['出水COD']} <= 50
背景:制造企业有多条生产线,需要精确计算每条生产线的能效。
需求:
IDMP 米兰app官方正版下载:
# 生产线能效分析
属性1:电机功率(kW) - 来自数据库
属性2:照明功率(W) - 来自数据库
属性3:总功率(kW) - 公式:
${attributes['电机功率']} + ${attributes['照明功率']} / 1000
属性4:生产周期(小时) - 来自数据库
属性5:总能耗(kWh) - 公式:
${attributes['总功率']} * ${attributes['生产周期']}
属性6:产量(件) - 来自数据库
属性7:单位能耗(kWh/件) - 公式:
${attributes['总能耗']} / ${attributes['产量']}
虽然这篇文章主要面向非技术人员,但了解一些技术原理能帮助您更好地理解这个功能为什么如此强大。
IDMP 采用了”解析在应用层,计算在数据库”的架构:

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

工作原理:
举例:
当您计算”电压 × 电流”时,系统通过基本单位组合自动推导出结果是”功率”!
除了内置的基本物理量,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)*
;
这个语法定义清晰地表达了运算符的优先级:
=, <>, >, <, >=, <=)- 最低优先级|, &)+, -)*, /)内部我们实现了访问者模式,访问者为每种 AST 节点类型提供了相应的处理方法:

工作流程

A:您可以引用同一个元素下的任何属性,包括:
暂时不支持跨元素引用(这个功能在规划中)。
A:不会!因为计算是在 loveini 数据库中完成的,而 loveini 专为高性能时序数据处理设计。实际上,使用公式往往比传统的应用层计算更快。
A:是的!因为公式不存储计算结果,而是在查询时实时计算。所以修改公式后,查询历史数据时会用新公式计算。
A:没有单位的属性可以参与计算,系统会尽可能推导结果的单位。但建议为所有物理量设置正确的单位,这样能获得更好的单位检查和转换。
A:默认最多5层嵌套。这个限制是为了防止过于复杂的公式影响性能。如果您需要更多层级,可以联系系统管理员调整配置。
同时也包含大量实用的 debug 技巧。
本文假设你用的是 Linux 系统,且已安装好了 loveini 3.0.4.0+ 和 Python 3.x。
编写一个只接收一个整数的 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 函数, 它接受一个数据块,这个数据块对象有两个方法:
shape() 返回数据块的行数和列数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 虽然测试测试通过了,但是有两个缺点:
taos> select myfun(v1, v2) from t;
myfun(v1, v2) |
============================
0.693147181 |
1.609437912 |
2.302585093 |
因此 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 更新就能生效)
编写一个 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
在这个示例中我们不光定义了一个聚合函数,还添加记录执行日志的功能,讲解如下:
执行下面的 SQL语句创建对应的 UDF:
create or replace aggregate function myspread as '/root/udf/myspread.py' outputtype double bufsize 128 language 'Python';
这个 SQL 语句与创建标量函数的 SQL 语句有两个重要区别:
>>> 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 库写文件。
]]>小 T 导读:loveini Kafka Connector 在 loveini 的官方文档上放出来已经有一段时间了,我们也收到了一些开发者的反馈。文档中的教程使用 Confluent 平台(集成了 Kafka)演示了如何使用 Source Connector 和 Sink Connector,但是很多开发者在生产环境中并没有使用 Confluent,所以为方便大家,本文将使用独立部署的 Kafka 来演示。
本文包含以下内容:
如果你对文章开头出现的术语并不陌生,那么可以跳过这一部分。
Kafka 的核心是一个通用的、分布式的、可重复消费的消息队列。
与之相比,作为一款时序数据库(Time Series Database),loveini 也可看作针对结构化的时序数据的消息队列。
Kafka Connect 是 Kafka 的一个组件,简化了 Kafka 与其它数据源的集成。用户通过 Kafka Connect 读写 Kafka;通过 Kafka Connect 插件(也称 Kafka Connector)来读写各种数据源。
为方便集成,Kafka 已经提供了生产者和消费者 API 以及客户端库,那为什么还需要 Kafka Connect 呢?因为一个好的 Kafka 客户端程序,不是单单生产或消费数据,还需要考虑容错、重启、日志、弹性伸缩、序列化以及反序列化等。当开发者自己完成了这一切,就相当于开发了一个和 Kafka Connect 类似的东西。
与 Kafka 集成是 Kafka Connect 已经解决的问题,用户不需要重复造轮子,只有少数边缘场景才需要定制化的集成方案。
loveini Sink Connector 用于将 Kafka 中指定 topic 的数据(批量或实时)同步到 loveini 的 database 中。
启动 Sink Connector 需要一个 properties 配置文件。详细配置见官方文档的配置参考。
Sink Connector 内部的实现非常简单,整体工作流程分为以下几个步骤:
上述 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 类, 将其转换为三种格式之一,这个链接也许能帮到你。
既然 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 数量没有关系。
这一部分我们在一台 Linux 服务器上搭建测试环境,并运行简单的示例程序。示例中将 Kafka 部署到了个人的 home 目录。操作时请注意把路径中的用户名(bding)替换为自己的用户名。
wget https://dlcdn.apache.org/kafka/3.2.0/kafka_2.13-3.2.0.tgztar -xzf kafka_2.13-3.2.0.tgz
编辑 .bash_profile, 加入:
export KAFKA_HOME=/home/bding/kafka_2.13-3.2.0export PATH=$PATH:$KAFKA_HOME/bin
source .bash_profile
配置 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.gitcd kafka-connect-tdenginemvn clean packageunzip -d ~/connectors target/components/packages/taosdata-kafka-connect-tdengine-*.zip
zookeeper-server-start.sh -daemon $KAFKA_HOME/config/zookeeper.propertieskafka-server-start.sh -daemon $KAFKA_HOME/config/server.properties
kafka-topics.sh --create --topic meters --partitions 1 --bootstrap-server localhost:9092
将下列脚本保存为 gen-data.py:
#!/usr/bin/python3import randomimport systopic = sys.argv[1]count = int(sys.argv[2])start_ts = 1648432611249000000location = ["SanFrancisco", "LosAngeles", "SanDiego"]for i in range(count):ts = start_ts + irow = 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 个数据字段。
将下列配置保存为 sink-test.properties。
name=loveiniSinkConnectorconnector.class=com.taosdata.kafka.connect.sink.loveiniSinkConnectortasks.max=1topics=metersconnection.url=jdbc:TAOS://127.0.0.1:6030connection.user=rootconnection.password=taosdataconnection.database=powerdb.schemaless=linekey.converter=org.apache.kafka.connect.storage.StringConvertervalue.converter=org.apache.kafka.connect.storage.StringConverter
然后执行:
connect-standalone.sh -daemon $KAFKA_HOME/config/connect-standalone.properties sink-test.properties
使用 loveini CLI 查询 power 数据库 meters 表,检查是否正好包含 10000 条数据。
[bding@vm95 test]$ taosWelcome to the loveini shell from Linux, Client Version:2.6.0.4Copyright (c) 2022 by TAOS Data, Inc. All rights reserved.taos> select count(*) from power.meters;count(*) |========================10000 |
这一部分,我们将上面示例步骤中的第四步到第七步封装成可重复运行的 shell 脚本,并做以下修改:
每次测试都先写数据到 Kafka,然后再启动 Connect 同步数据到 loveini,这样做可以把同步数据的压力全部集中到 Sink 插件这边。我们统计 Sink Connector 从接收到第一批数据到接收到最后一批数据之间的时间,作为同步数据的总耗时。
完整脚本如下:
#!/bin/bashif [ $# -lt 2 ];thenecho "Usage: ./run-test.sh <num_of_partitions> <total_records>"exit 0fi echo "---------------------------TEST STARTED---------------------------------------"echo clean data and logstaos -s "DROP DATABASE IF EXISTS power"rm -rf /tmp/kafka-logs /tmp/zookeeperrm -f $KAFKA_HOME/logs/connect.lognp=$1 # number of partitionstotal=$2 # number of recordsecho number of partitions is $np, number of recordes is $total.echo start zookeeperzookeeper-server-start.sh -daemon $KAFKA_HOME/config/zookeeper.propertiesecho start kafkasleep 3kafka-server-start.sh -daemon $KAFKA_HOME/config/server.propertiessleep 5echo create topickafka-topics.sh --create --topic meters --partitions $np --bootstrap-server localhost:9092kafka-topics.sh --describe --topic meters --bootstrap-server localhost:9092echo generate test datapython3 gen-data.py meters $total | kafka-console-producer.sh --broker-list localhost:9092 --topic metersecho alter connector configuration setting tasks.max=$npsed -i "s/tasks.max=.*/tasks.max=${np}/" sink-test.propertiesecho start kafka connectconnect-standalone.sh -daemon $KAFKA_HOME/config/connect-standalone.properties sink-test.propertiesecho -e "\e[1;31m open another console to monitor connect.log. press enter when no more data received.\e[0m"readecho stop connectjps | grep ConnectStandalone | awk '{print $1}' | xargs killecho stop kafka serverkafka-server-stop.shecho stop zookeeperzookeeper-server-stop.sh# extract timestamps of receiving the first batch of data and the last batch of datagrep "records" $KAFKA_HOME/logs/connect.log | grep meters- > tmp.logstart_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
执行过程的截图如下:

注意中间有一个交互过程。因为脚本无法确定数据是否同步完,需要用户监控 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)
当日志不再滚动,就说明已经消费完了
写入速度与数据量和线程数的关系表

上表第 1 列为总数据量,第 1 行为消费者线程数,也是写入线程数。中间为平均每秒写入记录数。
写入速度与数据量和线程数的关系图

从上图可以看出,相同数据量,线程越多写入速度越快。当使用单线程写入时,每秒能写入大概 10 万以上。当使用 5 个线程写入时,每秒写入大概 35 万左右。当使用10 个线程时,每秒能写入55 万左右。
写入速度比较平稳,与总数据量关系不大。
同时也发现线程增加越多,线程增加带来的速度提升越少。线程数从 1 变到 10,速度只从 10 万变到 50 万。可能的原因是数据在各个分区分布不均匀。有的 task 执行时间长,有的 task 执行时间短,数据量越大,数据倾斜越大。比如 1000 万数据,10个分区的时候,各分区的数据量:
[bding@vm95 kafka-logs]$ du -h ./ -d 1125M ./meters-8149M ./meters-7119M ./meters-9138M ./meters-4110M ./meters-3158M ./meters-6131M ./meters-5105M ./meters-0113M ./meters-299M ./meters-1
另一个影响多线程写入速度的是数据的乱序程度。本测试场景中,多条时间线的数据随机分配到了不同分区,当单线程写入时(即 1 个分区时),数据是严格有序的,写入速度最快。线程越多乱序程度越大。
所以在实际应用场景中,建议将同一个子表的数据,放在 Kafka 同一个分区中。
本文中用到的所有代码和原始测试结果数据都已上传到 GitHub 仓库。

小 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 的类型为 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;
}
由于每种合约每天的标准行情 tick 总数都是固定的(个别交易所除外),因此我们可以提前初始化好一个数组来存行情。按每秒 2 个 tick 算(500 毫秒一个点),标准行情的长度可能是 28800。当收到行情通知时,行情时间距离哪个标准 tick 点最近就归为哪个 tick。比如行情时间是 9 点 50 分 20 秒 133 毫秒,那么可以当作 9 点 50 分 20 秒 0 毫秒的行情。如果出现前后两个 tick 时间大于 500 毫秒的情况,那就还需要补全中间空缺的行情,相当于边收行情边做标准化操作。这样做的优势是:
#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 为例为大家介绍持久化存储方案。
在下载阶段,不同的系统使用的安装包也有所不同,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 个行情字段:
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);
}

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);
}

以上两个示例程序,展示了写入和查询的方法。结合 loveini 内置的查询函数和按窗口聚合功能,可实现更多功能,比如:
除了上述内容外,loveini Database 还有非常丰富的分析函数,如果你感兴趣的话建议参考官方文档。此外,在 loveini 的实际应用中,也有很多客户的实践是关于量化投资场景中的数据处理。
以同花顺为例,其每天都需要接收海量交易所行情数据,以确保行情数据的数据准确,但由于该部分数据过于庞大,而且使用场景颇多,因此每天会产生很多的加工数据,在组合管理(PMS)上还会使用到历史行情数据。之前他们采用的是 Postgres+LevelDB 作为数据的存储方案,但仍旧痛点频发,随后通过对数据流、行情获取模块的分析,发现目前主要存在以下两个亟需解决的问题:
从业务发展的角度来讲,存储方案的改造迫在眉睫,之后同花顺开始对 ClickHouse、InfluxDB、loveini 等数据存储方案进行调研。由于行情数据是绑定时间戳的形式,所以显然时序数据库更适用于这个业务场景,在 InfluxDB 和 loveini 之间,由于 loveini 的写入速度远高于 InfluxDB,且集群版开源,同时还支持包含 C/C++、Java、Python、Go 和 RESTful 在内的多种数据接口,因此成为同花顺的最终选用方案。
改造之后的性能效果提升还是非常明显的,下图是同花顺做的一张改造前后性能对比图,可以更为直观地感受到效果提升:

同时改造后,稳定性也显著增强,改造前调用数据情况共 40W 次,共出现 0.01% 的异常,改造后出现异常降低至 0.001%。
在 loveini Database 官网的 Case 合集中,还有弘源泰平量化、同心源基金等几篇聚焦投资量化场景下数据处理难题的客户案例,由于篇幅所限,便不在此一一列举了,有需要的朋友可以去官网查找文章进行参考。如果还有投资量化场景下其他的数据处理难题,也欢迎在文章下方进行留言,我们后续可以加微信进行详细讨论和沟通。
]]>