假设这是一个客服界面。客服人员正在搜索与退款有关的事件,他不想查看冗长的日志,而是希望看到一个简短摘要:每个用户一行、匹配总数,以及最近的 五个 事件。如果需要详细信息,应用可以再根据 ID 加载它们。
两个显而易见的方案都不能完全满足需求:
- 普通的
GROUP_CONCAT()会把分组中的所有值拼接在一起, - 而
GROUP N BY会为每个用户返回多行。
从 Manticore Search 28.6.6
起,可以在 GROUP_CONCAT() 内部对值排序,并只保留所需数量:
GROUP_CONCAT(id ORDER BY event_ts DESC, id DESC LIMIT 5)
下面看看它是如何工作的。我们将使用 SphinxQL,并显式写出 GROUP BY —— 没有它,新形式的 GROUP_CONCAT() 无法工作。
我们将使用的事件
创建 activity 表,其中每个文档代表一个独立事件。事件文本存储在 body 中,用户 ID 存储在 user_id 中,时间存储在 event_ts 中。
CREATE TABLE activity (
body text,
user_id int,
event_ts bigint,
event_type string
);
INSERT INTO activity (id, body, user_id, event_ts, event_type) VALUES
(1001, 'refund requested for order 501', 101, 1770000010, 'requested'),
(1002, 'user logged in', 101, 1770000020, 'login'),
(1003, 'refund approved for order 501', 101, 1770000030, 'approved'),
(1004, 'refund email sent for order 501', 101, 1770000040, 'email'),
(1005, 'refund status checked for order 501', 101, 1770000050, 'checked'),
(1006, 'refund payout queued for order 501', 101, 1770000060, 'queued'),
(1007, 'refund webhook retried for order 501', 101, 1770000060, 'retried'),
(2001, 'refund requested for order 601', 202, 1770000015, 'requested'),
(2002, 'refund approved for order 601', 202, 1770000025, 'approved'),
(2003, 'shipping address changed', 202, 1770000035, 'shipping'),
(2004, 'refund payout queued for order 601', 202, 1770000045, 'queued'),
(2005, 'refund completed for order 601', 202, 1770000055, 'completed'),
(3001, 'refund requested for order 701', 303, 1770000012, 'requested'),
(3002, 'invoice downloaded', 303, 1770000022, 'invoice'),
(3003, 'refund rejected for order 701', 303, 1770000032, 'rejected');
搜索 refund 会找到用户 101 的六个事件、用户 202 的四个事件,以及用户 303 的两个事件。我们特意让事件 1006 和 1007 使用相同时间:后面会看到为什么排序时还需要 id。
旧方案的问题
先从普通的 GROUP_CONCAT() 开始。返回格式符合我们的需求 —— 每个用户一行:
SELECT
user_id,
COUNT(*) AS matched_events,
GROUP_CONCAT(id) AS all_event_ids
FROM activity
WHERE MATCH('refund')
GROUP BY user_id
ORDER BY matched_events DESC, user_id ASC;
但用户 101 的这一行会包含全部六个 ID,例如 1001,1003,1004,1005,1006,1007,而我们只需要最近的五个。此外,如果不在内部排序,值的顺序并不保证。
+---------+----------------+-------------------------------+
| user_id | matched_events | all_event_ids |
+---------+----------------+-------------------------------+
| 101 | 6 | 1001,1003,1004,1005,1006,1007 |
| 202 | 4 | 2001,2002,2004,2005 |
| 303 | 2 | 3001,3003 |
+---------+----------------+-------------------------------+
也可以换一种方式,让 GROUP N BY 从每个分组中选出最近的五个文档:
SELECT
id,
user_id,
event_ts
FROM activity
WHERE MATCH('refund')
GROUP 5 BY user_id
WITHIN GROUP ORDER BY event_ts DESC, id DESC
ORDER BY user_id ASC;
用户 101 最旧的事件会消失,但其余每个文档都会单独占一行。最终我们得到的不是三行,而是十一行:5 + 4 + 2。当客户端需要文档本身时,这很方便,但不适合我们的紧凑摘要。
+------+---------+------------+
| id | user_id | event_ts |
+------+---------+------------+
| 1007 | 101 | 1770000060 |
| 1006 | 101 | 1770000060 |
| 1005 | 101 | 1770000050 |
| 1004 | 101 | 1770000040 |
| 1003 | 101 | 1770000030 |
| 2005 | 202 | 1770000055 |
| 2004 | 202 | 1770000045 |
| 2002 | 202 | 1770000025 |
| 2001 | 202 | 1770000015 |
| 3003 | 303 | 1770000032 |
| 3001 | 303 | 1770000012 |
+------+---------+------------+
只把最近五个 ID 拼在一起
现在把这两个操作合在一起:直接在 GROUP_CONCAT() 内部对文档排序,并在那里把列表限制为五个值。
SELECT
user_id,
COUNT(*) AS matched_events,
GROUP_CONCAT(
id
ORDER BY event_ts DESC, id DESC
LIMIT 5
) AS recent_event_ids
FROM activity
WHERE MATCH('refund')
GROUP BY user_id
ORDER BY matched_events DESC, user_id ASC;
+---------+----------------+--------------------------+
| user_id | matched_events | recent_event_ids |
+---------+----------------+--------------------------+
| 101 | 6 | 1007,1006,1005,1004,1003 |
| 202 | 4 | 2005,2004,2002,2001 |
| 303 | 2 | 3003,3001 |
+---------+----------------+--------------------------+
首先,MATCH('refund') 筛选出退款相关事件,然后 GROUP BY user_id 按用户对它们分组。COUNT(*) 统计所有找到的事件,而 GROUP_CONCAT() 只取每个分组排序后的前五个。
这里第二个排序键 id DESC 尤其重要。事件 1006 和 1007 的 event_ts 相同,因此没有它时,两者之间的顺序将是不确定的。按 ID 排序后,事件 1007 总是排在前面。
注意,查询中有两个 ORDER BY。GROUP_CONCAT() 内部的那个决定字符串中 ID 的顺序。最后一个 ORDER BY 则对最终结果行排序:先按匹配数量,再按 user_id。
内部的 LIMIT 不会改变 COUNT(*),也不会影响整个结果集的分页。因此,用户 101 仍然有六个匹配项,尽管旁边只显示五个 ID。这正是我们的摘要所需要的。
还有一点:GROUP_CONCAT() 始终返回字符串,即使其中包含的是数字 ID。如果 API 需要返回数字数组或包含多个字段的对象,就必须在客户端解析这个字符串,或使用其他返回格式。
对于分布式表,查询的工作方式相同。Manticore 会从所有本地和远程表收集候选项,然后为每个分组选出全局 top-N。
如果逗号不合适
默认情况下,值之间使用逗号分隔。有时更直观的字符串会更方便 —— 例如,把事件类型和它的 ID 放在一起显示。可以通过 SEPARATOR 指定自己的分隔符:
SELECT
user_id,
GROUP_CONCAT(
CONCAT(event_type, ':', TO_STRING(id))
ORDER BY event_ts DESC, id DESC
SEPARATOR ' / '
LIMIT 3
) AS recent_events
FROM activity
WHERE MATCH('refund')
GROUP BY user_id
ORDER BY user_id ASC;
+---------+----------------------------------------------+
| user_id | recent_events |
+---------+----------------------------------------------+
| 101 | retried:1007 / queued:1006 / checked:1005 |
| 202 | completed:2005 / queued:2004 / approved:2002 |
| 303 | rejected:3003 / requested:3001 |
+---------+----------------------------------------------+
在这个语法中,SEPARATOR 要放在 LIMIT 之前。Manticore 不会转义任何内容,也不会添加引号:结果只是普通字符串,而不是 JSON 数组。
对于 event_type,这种方式很合适,因为这些值由应用本身定义。对于任意文本则要更谨慎:如果分隔符本身出现在数据中,就无法可靠地解析结果。这种情况下,最好返回单独的行,或使用结构化格式。
新方法不适用的场景
这种形式的 GROUP_CONCAT() 有一些限制。它只适用于显式使用 GROUP BY 的 SQL 查询,并且不支持:
DISTINCT、OFFSET,以及一次组合多个表达式;JOIN、FACET、外层SELECT和表函数;- KNN 和 hybrid 查询,以及 scroll;
- 隐式分组,以及 JSON API 中类似的聚合语法。
它的别名不能在 HAVING 或最终的 ORDER BY 中使用。不过,分组本身仍然可以按分组键和普通聚合结果排序 —— 例如像上面的查询一样按 user_id 和 COUNT(*) 排序。
还需要考虑内存消耗。对于每个这样的表达式,Manticore 都会为结果中保留下来的每个分组维护一个独立的 top-N。分组越多、N 越大、这类表达式越多,所需内存就越多。值和排序键的大小也会影响内存占用,因此不要为了“以防万一”把限制设得过高。
新功能的完整文档请见这里 。
其他示例
客服事件只是众多使用场景之一。只要文档中已经有分组键、需要返回的值和排序字段,这种新模式就可能派上用场:
- 商品图片: 对每个
product_id收集前几个 ID 或路径,并按display_order排序。 - 高优先级任务: 对每个
assignee_id返回最多六个任务 ID,并按预先计算好的priority排序。 - 服务器错误: 显示每台服务器最近的 N 个错误,同时单独保留错误总数。
如果需要一个简短的 ID、名称或路径列表,GROUP_CONCAT(... ORDER BY ... LIMIT N) 现在可以通过一个查询直接得到。希望这能对你有所帮助。
