聚合
分组、投影和聚合函数
聚合是一种处理搜索查询结果的方式。聚合允许您对结果数据进行分组、排序和转换,并从中提取分析见解。与其他数据库和搜索引擎中的聚合查询类似,它们可用于创建分析报告或执行多面搜索样式的查询。
例如,索引 Web 服务器的日志,您可以创建按小时、国家/地区或任何其他细分来显示唯一用户的报告。或者,您可以为错误、警告等创建不同的报告。
核心概念
聚合查询的基本思想如下
- 执行搜索查询,筛选您希望处理的记录。
- 构建一个操作管道,通过零个或多个序列来转换结果
- 分组和归约:按结果中的字段进行分组,并将归约函数应用于每个组。
- 排序:根据一个或多个字段对结果进行排序。
- 应用转换:在管道中的字段上应用数学和字符串函数,可以选择创建新字段或替换现有字段。
- 限制:无论结果如何排序,都要限制结果。
- 筛选:根据与其值相关的谓词筛选结果(查询后)。
管道是动态的且可重复进入的,每个操作都可以重复。例如,您可以按属性 X 分组,按组大小对前 100 个结果进行排序,然后按属性 Y 分组并按其他属性对结果进行排序,然后对输出应用转换。
图 1:聚合管道示例
聚合请求格式
聚合请求的语法定义如下
FT.AGGREGATE
{index_name:string}
{query_string:string}
[VERBATIM]
[LOAD {nargs:integer} {property:string} ...]
[GROUPBY
{nargs:integer} {property:string} ...
REDUCE
{FUNC:string}
{nargs:integer} {arg:string} ...
[AS {name:string}]
...
] ...
[SORTBY
{nargs:integer} {string} ...
[MAX {num:integer}] ...
] ...
[APPLY
{EXPR:string}
AS {name:string}
] ...
[FILTER {EXPR:string}] ...
[LIMIT {offset:integer} {num:integer} ] ...
[PARAMS {nargs} {name} {value} ... ]
参数详细信息
可以接受可变数量参数的参数以param {nargs} {property_1... property_N}
的形式表示。参数的第一个参数是参数后面的参数数量。这允许 Redis Stack 避免在您的参数之一具有另一个参数的名称时出现解析歧义。例如,要按姓氏、名字和国家/地区进行排序,您将指定SORTBY 6 firstName ASC lastName DESC country ASC
。
-
index_name:执行查询的索引。
-
query_string:检索文档的基本筛选查询。它遵循与搜索查询完全相同的语法,包括筛选器、联合、非、可选等。
-
LOAD {nargs} {property} ... :从文档 HASH 对象加载文档字段。一般来说,应该避免这种情况。用于聚合的字段应存储为 SORTABLE(可选地为 UNF 以避免任何规范化),这样它们就可以以极低的延迟提供给聚合管道。LOAD 会大大降低聚合查询的性能,因为每个处理的记录都需要执行相当于对 Redis 密钥的 HMGET,当在数百万个密钥上执行时,会导致非常高的处理时间。可以使用
@__key
加载文档 ID。 -
GROUPBY {nargs} {property} ... :根据一个或多个属性对管道中的结果进行分组。每个组应该至少有一个归约器(见下文),一个处理组条目的函数,要么对它们进行计数,要么执行多个聚合操作(见下文)。
-
REDUCE {func} {nargs} {arg} ... [AS {name}]:使用归约函数将每个组中匹配的结果归约为单条记录。例如,COUNT 将计算组中的记录数量。有关可用归约器的更多详细信息,请参见下面的归约器部分。
归约器可以使用
AS {name}
可选参数拥有自己的属性名称。如果没有给出名称,结果名称将是归约函数的名称和组属性。例如,如果没有给出属性@foo
的 COUNT_DISTINCT 的名称,结果名称将是count_distinct(@foo)
。 -
SORTBY {nargs} {property} {ASC|DESC} [MAX {num}]:使用属性列表对管道排序,直到 SORTBY 点。默认情况下,排序是升序,但可以为每个属性添加
ASC
或DESC
。nargs
是排序参数的数量,包括 ASC 和 DESC。例如:SORTBY 4 @foo ASC @bar DESC
。MAX
用于优化排序,仅对 n 个最大元素进行排序。虽然它与LIMIT
无关,但您通常只需要SORTBY … MAX
用于常见查询。 -
APPLY {expr} AS {name}:对一个或多个属性应用一对一转换,并将结果存储为管道中的新属性,或使用此转换替换任何属性。
expr
是一个表达式,可用于对数字属性执行算术运算,或可根据其类型应用于属性的函数(见下文),或其任意组合。例如:APPLY "sqrt(@foo)/log(@bar) + 5" AS baz
将为管道中的每个记录动态评估此表达式,并将结果存储为名为 baz 的新属性,该属性可以由管道中进一步的 APPLY/SORTBY/GROUPBY/REDUCE 操作引用。 -
LIMIT {offset} {num}。将结果数量限制为仅返回从索引
offset
(基于零)开始的num
个结果。如上所述,如果您只对限制排序输出感兴趣,则使用SORTBY … MAX
效率更高。但是,limit 可以用于在不排序的情况下限制结果,或者用于分页由
SORTBY MAX
确定的 n 个最大结果。例如,获取前 100 个结果中的结果 50-100,最有效地表示为SORTBY 1 @foo MAX 100 LIMIT 50 50
。从 SORTBY 中删除 MAX 将导致管道对所有记录进行排序,然后对结果 50-100 进行分页。 -
FILTER {expr}。使用与每个结果中的值相关的谓词表达式筛选结果。这些表达式在查询后应用,并与管道的当前状态相关。有关完整详细信息,请参见下面的 FILTER 表达式。
-
PARAMS {nargs} {name} {value}。定义一个或多个值参数。每个参数都有一个名称和一个值。参数可以在查询字符串中通过
$
引用,后跟参数名称,例如:$user
,并且搜索查询中对参数名称的每个此类引用都将由相应的参数值替换。例如,使用参数定义PARAMS 4 lon 29.69465 lat 34.95126
,表达式@loc:[$lon $lat 10 km]
将被评估为@loc:[29.69465 34.95126 10 km]
。参数不能在查询字符串中引用不允许使用具体值的地方,例如字段名称,例如:@loc
示例
访问网站的日志可能如下所示,其中每条记录都具有以下字段/属性
- url(文本,可排序)
- timestamp(数字,可排序) - 访问条目时间戳。
- country(标签,可排序)
- user_id(文本,可排序,未索引)
示例 1:按小时显示唯一用户,按时间顺序排列。
第一步是确定索引名称和筛选查询。*
的筛选查询表示“获取所有记录”。
FT.AGGREGATE myIndex "*"
接下来,按小时对结果进行分组。数据包含以秒为分辨率的 Unix 时间戳的访问时间,因此您需要从时间戳中提取小时分量。为此,请添加一个 APPLY 步骤,该步骤从时间戳中删除小时以下的信息,并将其存储为新属性hour
FT.AGGREGATE myIndex "*"
APPLY "@timestamp - (@timestamp % 3600)" AS hour
接下来,按小时对结果进行分组,并计算每个小时内不同的用户 ID。这是通过 GROUPBY/REDUCE 步骤完成的
FT.AGGREGATE myIndex "*"
APPLY "@timestamp - (@timestamp % 3600)" AS hour
GROUPBY 1 @hour
REDUCE COUNT_DISTINCT 1 @user_id AS num_users
接下来,按小时对结果进行排序,升序排列
FT.AGGREGATE myIndex "*"
APPLY "@timestamp - (@timestamp % 3600)" AS hour
GROUPBY 1 @hour
REDUCE COUNT_DISTINCT 1 @user_id AS num_users
SORTBY 2 @hour ASC
最后一步,将小时格式化为人类可读的时间戳。这是通过调用转换函数timefmt
完成的,该函数格式化 Unix 时间戳。您可以指定要传递给系统strftime
函数的格式(请参阅文档),但未指定任何格式等同于将%FT%TZ
指定给strftime
。
FT.AGGREGATE myIndex "*"
APPLY "@timestamp - (@timestamp % 3600)" AS hour
GROUPBY 1 @hour
REDUCE COUNT_DISTINCT 1 @user_id AS num_users
SORTBY 2 @hour ASC
APPLY timefmt(@hour) AS hour
示例 2:按天和国家/地区对特定 URL 的访问进行排序
下一个示例按 url 筛选,将时间戳转换为其日期部分,并按日期和国家/地区分组,统计每个组的访问次数,按日期升序和国家/地区降序排序。
FT.AGGREGATE myIndex "@url:\"about.html\""
APPLY "@timestamp - (@timestamp % 86400)" AS day
GROUPBY 2 @day @country
REDUCE count 0 AS num_visits
SORTBY 4 @day ASC @country DESC
GROUPBY 归约器
GROUPBY
的工作方式类似于 SQL GROUP BY
子句,并根据每条记录中的一个或多个属性创建结果组。对于每个组,Redis 返回组键,或组中所有记录共有的值,以及零个或多个REDUCE
子句的结果。
管道中的每个GROUPBY
步骤都可以伴随零个或多个REDUCE
子句。归约器将累积函数应用于组中的每条记录,并将它们归约为表示该组的单条记录。处理完成后,GROUPBY
步骤上游的所有记录都会发出它们的归约记录。
例如,最简单的归约器是 COUNT,它只计算每个组中的记录数量。
如果一个GROUPBY
步骤存在多个REDUCE
子句,则每个归约器都会独立地对每个结果进行操作,并在完成后写入其最终输出。每个归约器都可以使用AS
可选参数确定自己的别名。如果未指定AS
,则别名是归约函数及其参数,例如:count_distinct(foo,bar)
。
支持的 GROUPBY 归约器
COUNT
格式
REDUCE COUNT 0
描述
计算每个组中的记录数量
COUNT_DISTINCT
格式
REDUCE COUNT_DISTINCT 1 {property}
描述
计算property
的不同值的数量。
COUNT_DISTINCTISH
格式
REDUCE COUNT_DISTINCTISH 1 {property}
描述
与 COUNT_DISTINCT 相同,提供近似值而不是精确计数,这对于大型组而言会消耗更少的内存和 CPU。
SUM
格式
REDUCE SUM 1 {property}
描述
返回组中给定属性的所有数字值的总和。组中的非数字值被计为 0。
MIN
格式
REDUCE MIN 1 {property}
描述
返回属性的最小值,无论是字符串、数字还是 NULL。
MAX
格式
REDUCE MAX 1 {property}
描述
返回属性的最大值,无论是字符串、数字还是 NULL。
AVG
格式
REDUCE AVG 1 {property}
描述
返回数字属性的平均值。这等效于按总和和计数进行归约,然后将它们的比率作为 APPLY 步骤应用。
STDDEV
格式
REDUCE STDDEV 1 {property}
描述
返回组中数字属性的标准差。
QUANTILE
格式
REDUCE QUANTILE 2 {property} {quantile}
描述
返回结果中给定分位数处数字属性的值。分位数表示为 0 到 1 之间的数字。例如,中位数可以表示为 0.5 处的分位数,例如:REDUCE QUANTILE 2 @foo 0.5 AS median
。
如果需要多个分位数,只需为每个分位数重复 QUANTILE 归约器即可。例如:REDUCE QUANTILE 2 @foo 0.5 AS median REDUCE QUANTILE 2 @foo 0.99 AS p99
。
TOLIST
格式
REDUCE TOLIST 1 {property}
描述
将给定属性的所有不同值合并到单个数组中。
FIRST_VALUE
格式
REDUCE FIRST_VALUE {nargs} {property} [BY {property} [ASC|DESC]]
描述
返回组中给定属性的第一个或顶部的值,可以选择通过将其与另一个属性进行比较。例如,您可以提取组中最老用户的姓名
REDUCE FIRST_VALUE 4 @name BY @age DESC
如果未指定BY
,则返回在组中遇到的第一个值。
如果您希望获取按相同值排序的组中的顶部或底部值,最好使用MIN/MAX
归约器,但通过执行REDUCE FIRST_VALUE 4 @foo BY @foo DESC
可以实现相同的效果。
RANDOM_SAMPLE
格式
REDUCE RANDOM_SAMPLE {nargs} {property} {sample_size}
描述
对组元素进行给定大小的蓄水池采样,并返回一个包含采样项目的数组,这些项目具有均匀分布。
APPLY 表达式
APPLY
对每个记录中一个或多个属性执行一对一转换。它将结果存储为管道中的新属性,或者使用此转换替换任何属性。
转换表示为算术表达式和内置函数的组合。函数和表达式的求值是递归嵌套的,并且可以无限地组合。例如:sqrt(log(foo) * floor(@bar/baz)) + (3^@qaz % 6)
或简化为 @foo/@bar
。
如果表达式或函数应用于与预期类型不匹配的值,则不会发出错误,结果将设置为 NULL 值。
APPLY 步骤必须具有由 AS
参数确定的显式别名。
表达式中的字面量
- 数字表示为整数或浮点数,例如
2
、3.141
和-34
。inf
和-inf
也是可以接受的。 - 字符串用单引号或双引号括起来。单引号在用双引号括起来的字符串中是可接受的,反之亦然。标点符号可以使用反斜杠进行转义。例如
"foo's bar"
、'foo\'s bar'
、"foo \"bar\""
。 - 任何字面量或子表达式都可以用括号括起来以解决运算符优先级歧义。
算术运算
对于数字表达式和属性,支持加法 (+
)、减法 (-
)、乘法 (*
)、除法 (/
)、模运算 (%
) 和乘方 (^
)。不支持按位逻辑运算符。
请注意,这些运算符仅适用于数字值和数字子表达式。例如,尝试将字符串乘以数字将导致 NULL 输出。
字段 APPLY 函数列表
函数 | 描述 | 示例 |
---|---|---|
exists(s) | 检查文档中是否存在字段。 | exists(@field) |
数字 APPLY 函数列表
函数 | 描述 | 示例 |
---|---|---|
log(x) | 返回数字、属性或子表达式的对数 | log(@foo) |
abs(x) | 返回数字表达式的绝对值 | abs(@foo-@bar) |
ceil(x) | 四舍五入到不小于 x 的最小值 | ceil(@foo/3.14) |
floor(x) | 四舍五入到不大于 x 的最大值 | floor(@foo/3.14) |
log2(x) | 返回以 2 为底的 x 的对数 | log2(2^@foo) |
exp(x) | 返回 x 的指数,例如 e^x |
exp(@foo) |
sqrt(x) | 返回 x 的平方根 | sqrt(@foo) |
字符串 APPLY 函数列表
函数 | ||
---|---|---|
upper(s) | 返回 s 的大写转换 | upper('hello world') |
lower(s) | 返回 s 的小写转换 | lower("HELLO WORLD") |
startswith(s1,s2) | 如果 s2 是 s1 的前缀,则返回 1 ,否则返回 0 。 |
startswith(@field, "company") |
contains(s1,s2) | 返回 s2 在 s1 中出现的次数,否则返回 0 。如果 s2 是空字符串,则返回 length(s1) + 1 。 |
contains(@field, "pa") |
strlen(s) | 返回 s 的长度 | strlen(@t) |
substr(s, offset, count) | 返回 s 的子字符串,从 offset 开始,包含 count 个字符。 如果 offset 为负数,则它表示与字符串结尾的距离。 如果 count 为 -1,则表示“从 offset 开始的字符串的剩余部分”。 |
substr("hello", 0, 3) substr("hello", -2, -1) |
format( fmt, ...) | 使用 fmt 之后的参数格式化字符串。目前支持的唯一格式参数是 %s ,它适用于所有类型的参数。 |
format("Hello, %s, you are %s years old", @name, @age) |
matched_terms([max_terms=100]) | 返回与每个记录匹配的查询词语(最多 100 个),以列表形式。如果指定了限制,Redis 将返回根据查询顺序找到的前 N 个匹配项。 | matched_terms() |
split(s, [sep=","], [strip=" "]) | 通过字符串 sep 中的任何字符分割字符串,并剥离字符串 strip 中的任何字符。如果只指定了 s,则它将被逗号分割,并且空格将被剥离。输出是一个数组。 | split("foo,bar") |
日期/时间 APPLY 函数列表
函数 | 描述 |
---|---|
timefmt(x, [fmt]) | 根据数字时间戳值 x 返回格式化的时间字符串。 有关格式选项,请参阅 strftime。 不指定 fmt 等同于 %FT%TZ 。 |
parsetime(timesharing, [fmt]) | timefmt() 的反向操作 - 使用给定的格式字符串解析时间格式 |
day(timestamp) | 将 Unix 时间戳四舍五入到当前日期的午夜 (00:00) 开始。 |
hour(timestamp) | 将 Unix 时间戳四舍五入到当前小时的开始。 |
minute(timestamp) | 将 Unix 时间戳四舍五入到当前分钟的开始。 |
month(timestamp) | 将 Unix 时间戳四舍五入到当前月的开始。 |
dayofweek(timestamp) | 将 Unix 时间戳转换为星期几编号(星期日 = 0)。 |
dayofmonth(timestamp) | 将 Unix 时间戳转换为月份中的日期编号(1 .. 31)。 |
dayofyear(timestamp) | 将 Unix 时间戳转换为一年中的日期编号(0 .. 365)。 |
year(timestamp) | 将 Unix 时间戳转换为当前年份(例如 2018)。 |
monthofyear(timestamp) | 将 Unix 时间戳转换为当前月份(0 .. 11)。 |
地理 APPLY 函数列表
函数 | 描述 | 示例 |
---|---|---|
geodistance(field,field) | 返回距离,单位为米。 | geodistance(@field1,@field2) |
geodistance(field,"lon,lat") | 返回距离,单位为米。 | geodistance(@field,"1.2,-3.4") |
geodistance(field,lon,lat) | 返回距离,单位为米。 | geodistance(@field,1.2,-3.4) |
geodistance("lon,lat",field) | 返回距离,单位为米。 | geodistance("1.2,-3.4",@field) |
geodistance("lon,lat","lon,lat") | 返回距离,单位为米。 | geodistance("1.2,-3.4","5.6,-7.8") |
geodistance("lon,lat",lon,lat) | 返回距离,单位为米。 | geodistance("1.2,-3.4",5.6,-7.8) |
geodistance(lon,lat,field) | 返回距离,单位为米。 | geodistance(1.2,-3.4,@field) |
geodistance(lon,lat,"lon,lat") | 返回距离,单位为米。 | geodistance(1.2,-3.4,"5.6,-7.8") |
geodistance(lon,lat,lon,lat) | 返回距离,单位为米。 | geodistance(1.2,-3.4,5.6,-7.8) |
FT.AGGREGATE myIdx "*" LOAD 1 location APPLY "geodistance(@location,\"-1.1,2.2\")" AS dist
要检索距离
FT.AGGREGATE myIdx "*" LOAD 1 location APPLY "geodistance(@location,\"-1.1,2.2\")" AS dist
注意:地理字段必须使用 LOAD
预加载。
结果也可以按距离排序
FT.AGGREGATE idx "*" LOAD 1 @location FILTER "exists(@location)" APPLY "geodistance(@location,-117.824722,33.68590)" AS dist SORTBY 2 @dist DESC
注意:确保没有位置丢失,否则 SORTBY 不会返回任何结果。使用 FILTER 确保您对所有有效位置进行排序。
FILTER 表达式
FILTER 表达式使用与结果集中的值相关的谓词过滤结果。
FILTER 表达式在查询后求值,并与管道的当前状态相关。因此,它们可用于根据组计算修剪结果。请注意,过滤器没有被索引,也不会加速处理。
过滤器表达式遵循 APPLY 表达式的语法,添加了条件 ==
、!=
、<
、<=
、>
、>=
。两个或多个谓词可以用逻辑 AND (&&
) 和 OR (||
) 组合。单个谓词可以用 NOT 前缀 (!
) 取反。
例如,过滤所有用户名为“foo”且年龄小于 20 的结果,表示为
FT.AGGREGATE
...
FILTER "@name=='foo' && @age < 20"
...
可以添加多个过滤器步骤,尽管在管道中的相同阶段,将多个谓词组合到单个过滤器步骤中更有效。
游标 API
FT.AGGREGATE ... WITHCURSOR [COUNT {read size} MAXIDLE {idle timeout}]
FT.CURSOR READ {idx} {cid} [COUNT {read size}]
FT.CURSOR DEL {idx} {cid}
您可以将游标与 FT.AGGREGATE
一起使用,使用 WITHCURSOR
关键字。游标允许您仅使用响应的一部分,允许您根据需要获取更多结果。这比使用带偏移量的 LIMIT
快得多,因为查询只执行一次,并且其状态存储在服务器上。
要使用游标,请在 FT.AGGREGATE
中指定 WITHCURSOR
关键字。例如
FT.AGGREGATE idx * WITHCURSOR
这将返回一个包含两个元素的数组的响应。第一个元素是实际的(部分)结果,第二个是游标 ID。然后可以将游标 ID 反复馈送到 FT.CURSOR READ
,直到游标 ID 为 0,此时所有结果都已返回。
要从现有游标读取,请使用 FT.CURSOR READ
。例如
FT.CURSOR READ idx 342459320
假设 342459320
是从 FT.AGGREGATE
请求返回的游标 ID,下面是一个伪代码示例
response, cursor = FT.AGGREGATE "idx" "redis" "WITHCURSOR";
while (1) {
processResponse(response)
if (!cursor) {
break;
}
response, cursor = FT.CURSOR read "idx" cursor
}
请注意,即使游标为 0,也可能仍然返回部分结果。
游标设置
读取大小
您可以使用 COUNT
参数控制每次游标获取读取的行数。此参数可以在 FT.AGGREGATE
(紧接在 WITHCURSOR
之后)或 FT.CURSOR READ
中指定。
以下示例将一次读取 10 行
FT.AGGREGATE idx query WITHCURSOR COUNT 10
您也可以在 CURSOR READ
中指定 COUNT
来覆盖此设置。以下示例将最多返回 50 个结果
FT.CURSOR READ idx 342459320 COUNT 50
默认读取大小为 1000。
超时和限制
由于游标是占用服务器内存的有状态资源,因此它们的使用寿命有限。为了防止出现孤立/过时的游标,游标具有空闲超时值。如果在空闲超时之前没有在游标上发生任何活动,则该游标将被删除。每当使用 CURSOR READ
从游标读取时,空闲计时器都会重置为 0。
默认空闲超时为 300000 毫秒(或 300 秒)。您可以在创建游标时使用 MAXIDLE
关键字修改空闲超时。请注意,该值不能超过默认的 300 秒。
例如,要设置十秒的限制
FT.AGGREGATE idx query WITHCURSOR MAXIDLE 10000
其他游标命令
可以使用 CURSOR DEL
命令显式删除游标。例如
FT.CURSOR DEL idx 342459320
请注意,如果所有结果都已返回,或者如果游标已超时,则游标会自动删除。
可以使用 FT.CURSOR GC idx 0
命令同时强制清除所有空闲游标。默认情况下,Redis Stack 使用延迟节流式垃圾回收方法,每 500 次操作或每秒(以较晚者为准)回收一次空闲游标。