用途
这个技能条目针对一个具体的数据统计问题:统计“某时间段内有首次出现(first-ever)行为的实体数量”。它指出,这个问题不能通过筛选该时间段内的行来回答,而要对每个实体的完整历史单独提问一次,并对每个实体取 MIN(timestamp),再判断该最小值是否落在窗口内。
主要能力与核心区别
条目给出了正确写法的 Spring Data JPA 示例,时间窗口条件放在 HAVING 中,子查询扫描全部历史:
@Query("SELECT COUNT(*) FROM (SELECT entityId FROM event_entity GROUP BY entityId" +
" HAVING MIN(timestamp) BETWEEN :startTimestamp AND :endTimestamp)")
suspend fun getNewEntityCountInRange(startTimestamp: LocalDateTime, endTimestamp: LocalDateTime): Int
以及大家最容易先写出的错误写法,它实际上统计的是“窗口内的不同实体数”:
SELECT COUNT(DISTINCT entityId) FROM event_entity
WHERE timestamp BETWEEN :start AND :end
两者都会返回一个看似合理的整数,上限相同,并随活跃度一起变化。
使用方式与要点
WHERE 过滤行,HAVING 过滤分组;只有 HAVING 能“看到”窗口之外的行。把谓词移到 HAVING,才能先由实体的全部行构成分组再提问。同一列在两处出现谓词,并不是可以清理的冗余,WHERE 版本会改变答案。
聚合必须嵌套:无法在同一层同时使用 COUNT(DISTINCT …) 和 HAVING MIN(…),分组查询必须作为子查询,由外层语句计数。部分引擎还要求派生表带别名。
该查询有意扫描历史,所以它需要的索引形状是 (entityKey, timestamp),与按时间窗口过滤的查询所需的索引(composite 以 timestamp 开头)顺序相反。仅有键的单列索引能完成分组,但仍需回表取最小值。这是该系列中唯一一个成本随总历史增长、而不是随窗口增长的查询。
数据保留清理(retention prune)会悄悄重新定义“ever”:
DELETE FROM activity_event WHERE timestamp < :cutoffTimestamp
旧事件被删除后,MIN(timestamp) 变成剩余数据的最小值,早期行已老化的实体会重新变成“新”的,长期休眠的收藏也会被计为发现。若首次出现时间重要,应保存在清理触及不到的地方(例如实体上的 first-seen 列,只写一次),而不是从专为裁剪设计的表中重新推导。至少应在查询旁注明保留窗口。
不要用这个查询推导 distinct 计数,也不要反过来。它们是针对不同行集的不同问题,计算比率需要两者。两个查询各自便宜、各自显式。
“First-ever”不依赖窗口长度,因此本期与上期数字可比;而 first-in-window 随窗口长度缩放,会让 7 天对 7 天的差值变成真实数字,而粒度变化带来的差值变成噪声。
其镜像问题是“lapsed”(自窗口以来未出现的实体),失败方式相同:应对完整历史取 MAX(timestamp),同样在 HAVING 中过滤;在窗口内计算会退化为“窗口内出现过的实体”,与问题相反却仍返回一个可信数字。任何 first / last / only / ever 形式的问题都是对未分组历史的 HAVING,而错误版本总是把窗口放在 WHERE 中。
验证方法
查找所有 first-seen 或 “new” 查询,检查窗口在哪个子句中(页面给出 5 处命中):
grep -rn --include='*.kt' -E "MIN\(timestamp\)|HAVING|COUNT\(DISTINCT" . | grep -v '/build/'
每个 MIN(...) 命中都应位于 HAVING 中;只有 WHERE 窗口谓词的 “new” 查询,就是换了个名字的 distinct 计数。
检查清理触及的范围,因为这决定了 “ever” 的含义:
grep -rn --include='*.kt' -E "DELETE FROM [a-z_]*event" . | grep -v '/build/'
这里的任何内容都会限制 MIN 能看到的历史,包括仅通过级联到达该表的删除。
在真实数据上运行区分性测试。选择一个确定包含长期收藏的窗口,运行两种写法:
SELECT COUNT(*) FROM (SELECT entityId FROM event_entity GROUP BY entityId
HAVING MIN(timestamp) BETWEEN :start AND :end);
SELECT COUNT(DISTINCT entityId) FROM event_entity WHERE timestamp BETWEEN :start AND :end;
在任何有历史的账号上,第一个结果必须严格更小。数字相等意味着要么是全新数据集,要么是生产环境跑错了查询;将同样两条语句在最早窗口上运行,即可区分这两种情况。
适用场景与限制
适用于使用 SQL / JPA 统计“新实体”“流失实体”等基于完整历史的问题,以及相关查询的代码审查与测试验证。页面聚焦此单一 SQL 陷阱,未提供安装命令、定价、平台支持范围或更广泛的功能说明。