用途
该技能面向 Kotlin 应用中需要按“本地时间”对事件分组的统计场景,例如本地收听分析中的小时直方图、最忙的一天、每日计数和总量。它主张:本地时间分桶应在应用代码中完成,而不是在 SQL 里用引擎的本地时间修饰符完成。
主要能力
- 用一次范围查询返回原始行(时间戳与数值),而不是让 SQL 返回聚合数字。
- 在代码中一次遍历同时填充多个本地时间桶:小时直方图、每日计数、总量、最忙日期。
- 避免多次扫描之间因写入导致结果不一致。
- 避免依赖进程时区(如 SQLite 的 'localtime' 及类似修饰符),因为其解析依据是操作系统交给进程的时区,可能是 TZ 环境变量、服务账号时区或容器默认的 UTC,并不等于用户时区。
使用方式
资料给出的示例查询返回原始样本:
@Query("SELECT timestamp, listenedSecond FROM activity_event" +
" WHERE timestamp BETWEEN :startTimestamp AND :endTimestamp")
suspend fun getSamplesInRange(startTimestamp: LocalDateTime, endTimestamp: LocalDateTime): List<Sample>
随后在代码中一次遍历完成四个统计:
val hours = IntArray(24)
val perDay = mutableMapOf<LocalDate, Int>()
var listened = 0L
samples.forEach { s ->
hours[s.timestamp.hour]++
perDay[s.timestamp.date] = (perDay[s.timestamp.date] ?: 0) + 1
listened += s.listenedSecond
}
val busiest = perDay.maxByOrNull { it.value }
适用场景与边界
- 只有分组键是本地字段时才把分桶移到代码中。
- 去重计数、Top-N 列表、首次最小值、连接、占比计数等没有本地时间成分的聚合仍应留在 SQL 引擎中,因为它们按 id 分组或按同一尺度比较时间戳,返回一行结果而非每行数据。
- 原始扫描返回行数随窗口增长;直方图查询无论周期多长都只返回 24 个数字。应只投影推导真正需要的列,并在发布更宽窗口前了解最宽支持窗口的成本。若周期可能无界,这种形态不合适。
- 原始扫描也可作为空检查:先读取它,空周期可提前返回,避免发出后续七个聚合查询,把八次往返减少为一次。
- 样本类型必须让 ORM 转换器仍能运行。不要把时间戳字段声明为原始数字以“保持投影廉价”,否则返回的编码需要手工解释,而本地小时恰恰是解释错误时最容易出错的数字。应请求日期时间类型,小时和日期可直接读取,路径中无需任何时区运算。
- 不要把本地问题推入索引:对计算出的本地字段建索引会把某个时区固化进存储字节,对其他时区的用户静默错误,规则变更后对原用户也会过期。时区应放在边缘,在代码中应用一次。
- 每新增一个本地桶应是现有循环中的一行,而不是新查询。若分组键来自本地日期时间,就应写成 forEach 中的一行。
- 应在声明处说明原因:一个返回原始行的查询与十一个返回数字的查询并列时,容易被误认为未学会聚合。用一条注释说明原因(本地分组、一次扫描、与时区无关),可防止日后被“优化”成四个 GROUP BY。
验证方式
检查所有查询字符串中的本地时间函数,正确结果是唯一命中来自一条说明为何不使用的注释:
grep -rn --include='*.kt' -E "localtime|strftime\(" . | grep -v '/build/'
查看范围查询族的形态——多少返回行、多少返回数字:
grep -rn --include='*Dao*.kt' -E "suspend fun (get|query)[A-Za-z]*InRange" . | grep -v '/build/'
应恰好有一个返回原始样本列表,且它应是本地桶推导所依据的查询。同一族中出现第二个返回行的查询,通常意味着同一扫描被支付了两次。
最后,在同一数据上分别用代码和 SQL 的本地时间修饰符计算小时直方图并比较。两者仅在进程时区恰好等于用户时区时一致,因此可分别在 TZ=UTC 和 TZ=Asia/Ho_Chi_Minh 环境下运行:代码答案不应变化,SQL 答案会变化。