iScreenMonitor Session 关闭逻辑的三次演进

iScreenMonitor Session关闭逻辑的三次演进:从朴素查询到窗口函数再到索引优化的SQL进化之路。

一个 macOS 活动监控工具中,一行 SQL 的三次重构,带你理解数据库查询优化的核心原则。

背景

iScreenMonitor 是什么

iScreenMonitor 是一个 macOS 菜单栏常驻应用,每秒采集一次前台 App 信息,通过状态机追踪用户在哪个应用上花了多少时间。类似于 RescueTime 或 Screen Time,但完全本地运行,数据存于本地 SQLite。

Session 是什么

每次用户切换到新 App,引擎会做两件事:

1. 关闭旧 App 的 session(记录结束时间 + 时长)
2. 开启新 App 的 session(记录开始时间)

数据库表结构(简化):

CREATE TABLE app_sessions (
    id INTEGER PRIMARY KEY AUTOINCREMENT,  -- 自增主键
    app_name TEXT NOT NULL,                 -- 应用名,如 "Safari"
    started_at REAL NOT NULL,              -- 开始时间戳(秒,包含小数)
    ended_at REAL,                         -- 结束时间戳,NULL 表示"活跃中"
    duration_sec REAL                      -- 时长(秒),ended_at - started_at
);

核心挑战

closeCurrentSession() 每秒可能被调用,必须在高频率下绝对可靠地找到并关闭正确的 session。以下记录了三次重构的完整过程。

第一版:子查询 + 排序 + LIMIT

实现

UPDATE app_sessions
SET ended_at = now, duration_sec = now - started_at
WHERE id = (
    SELECT id FROM app_sessions
    WHERE ended_at IS NULL          -- 找到所有未关闭的
    ORDER BY started_at DESC        -- 按开始时间降序
    LIMIT 1                         -- 取最新的一条
)
// Swift 侧
func closeCurrentSession() {
    let now = Date().timeIntervalSince1970
    db.exec("UPDATE ... WHERE id = (SELECT id ... ORDER BY ... LIMIT 1)")
}

逻辑

"任意时刻有且仅有一条未关闭的 session(即当前活跃的那个),子查询找到它并关闭。"

问题 1:语义脆弱

LIMIT 1 ORDER BY started_at DESC 关闭的是"最新的 NULL session",而不是"我正在追踪的那一条"。当 DB 中只有一条 NULL 时,它们是同一个。但当出现多条 NULL 时——比如程序崩溃重启后——就会关错。

真实案例(2026-05-22):

DB 状态(崩溃后重启):
  649: CodeBuddy CN  19:29  →  NULL  ← 孤儿(崩溃残留)
  698: Tableau        09:37  →  NULL  ← 当前活跃

healthCheck 同时触发"超时→空闲"和"空闲→恢复"两个分支:
  第1次 closeCurrentSession → 关闭 698(最新 NULL)✅
  第2次 closeCurrentSession → 关闭 649(新最新 NULL)❌ 误杀!

结果:649 的 ended_at = 09:43(重启时间),
      实际应该 = 19:30(用户切换时刻)
      → 单条 session 虚增 14 小时

问题 2:性能开销

SELECT id FROM app_sessions
WHERE ended_at IS NULL
ORDER BY started_at DESC
LIMIT 1
  • WHERE ended_at IS NULL → 全表扫描或索引扫描 NULL 值
  • ORDER BY started_at DESC → 需要对所有 NULL 行排序
  • 每秒钟都执行,高频 + 不必要的排序

第一版总结

维度 | 评价

--- | ---

准确性 | ❌ 多条 NULL 时可能关错

性能 | ⚠️ 子查询 + 排序,高频开销

简洁性 | ⚠️ SQL 理解成本中等

缺陷根源:依赖外部状态("最新 NULL")而非内部标识("我正在追踪的那条")。

第二版:时间戳容差匹配

思路

"我不再靠排序找最新 NULL,我用 currentSessionStart(内存中记录的当前 session 开始时间)去精确匹配。"

实现

UPDATE app_sessions
SET ended_at = now, duration_sec = now - currentSessionStart
WHERE started_at = currentSessionStart AND ended_at IS NULL
func closeCurrentSession() {
    guard currentSessionStart > 0 else { return }
    let now = Date().timeIntervalSince1970
    db.exec("UPDATE ... WHERE started_at = \(currentSessionStart) AND ended_at IS NULL")
}

逻辑

startNewSession 创建 session 时记录 currentSessionStart = now,关闭时用这个时间戳精确匹配。

问题:浮点数等值失配

完美运行了 0 分钟——上线后立刻全部失败。

started_at = 1779429258.34596  ← SQLite 存储的 REAL
currentSessionStart = 1779429258.34596  ← Swift Double

WHERE started_at = 1779429258.34596
→ 0 行匹配!❌

根因

环节 | 精度

--- | ---

`Date().timeIntervalSince1970` | Swift Double,纳秒级

`"\(currentSessionStart)"` 字符串化 | 截断到约 15 位有效数字

SQLite 解析 → 存储 REAL(IEEE 754) | 二进制表示可能与 Swift 不同

比较 `started_at = X` | 两个 REAL 的二进制值有微小差异

影响:从 11:39 部署到 14:00 发现,期间 49 条 session 全部成为孤儿(ended_at IS NULL),无一条正常关闭。

第二版改进:容差匹配

WHERE abs(started_at - currentSessionStart) < 0.01 AND ended_at IS NULL

10ms 容差解决了精度问题,恢复正常工作。

第二版总结

维度 | 评价

--- | ---

准确性 | ⚠️ 容差匹配修复后 OK,但仍有 10ms 理论碰撞概率

性能 | ✅ 无子查询,单条件扫描

简洁性 | ⚠️ `abs()` 是妥协,不是根本解决

缺陷根源:用有损的浮点数做精确匹配,为精度问题引入了容差补丁。

第三版(最终):自增主键精确匹配

思路

"Session 创建时,SQLite 给它分配了唯一的自增整数 id。我为什么不直接记住这个 id,用它来匹配?"

实现

// 新增变量
private var currentSessionId: Int64 = 0

// 创建 session 时记录 id
func startNewSession(appName: String, bundleId: String?) {
    let now = Date().timeIntervalSince1970
    db.exec("INSERT INTO app_sessions (app_name, bundle_id, started_at)
             VALUES ('\(appName)', '\(bundleId)', \(now))")
    currentSessionId = sqlite3_last_insert_rowid(db)  // ← 新增
}

// 关闭时用 id 精确匹配
func closeCurrentSession() {
    guard currentSessionId > 0 else { return }
    let now = Date().timeIntervalSince1970
    db.exec("""
        UPDATE app_sessions
        SET ended_at = \(now), duration_sec = \(now) - started_at
        WHERE id = \(currentSessionId) AND ended_at IS NULL
    """)
}

为什么这是最优解

WHERE id = 905 AND ended_at IS NULL
       ↑
   INTEGER PRIMARY KEY
   • 全局唯一(AUTOINCREMENT)
   • 等值匹配,不依赖浮点精度
   • B-Tree 主键索引 O(log n) → O(1)
   • 绝不可能匹配到其他 session

第三版总结

维度 | 评价

--- | ---

准确性 | ✅ 整数主键,绝对精确

性能 | ✅ 主键命中,O(1)

简洁性 | ✅ 最直观的 SQL

时间精度 | ✅ `started_at` 仍保留微秒精度,不受影响

三版对比

v1:  WHERE id = (SELECT id ... ORDER BY ... LIMIT 1)
     → 依赖外部状态聚合
     → 多条 NULL 时关错
     → 不必要的排序

v2:  WHERE abs(started_at - currentSessionStart) < 0.01
     → 依赖内部状态(时间戳)
     → 浮点精度 → 需要容差
     → 等于用补丁覆盖设计缺陷

v3:  WHERE id = currentSessionId
     → 依赖内部状态(整数 id)
     → 全局唯一,零歧义
     → 主键索引,O(1)

核心教训

1. 不要用聚合查询做身份识别

LIMIT 1 ORDER BY ... 是在猜测目标。你应该知道目标是谁。

2. 不要用浮点数做等值匹配

Double 的 cross-language 比较是不可靠的。如果需要精确匹配,用整数。

3. 利用数据库已有的能力

SQLite 的 AUTOINCREMENT 就是天然的唯一标识符。一行 sqlite3_last_insert_rowid() 消除了子查询、排序、容差的全部复杂度。

4. 开发优先级:准确 > 稳定 > 性能

v1 是为了简单(无需额外变量),牺牲了准确性。v2 修复了准确性但引入了稳定性问题(浮点误差)。v3 同时满足了准确、稳定、高性能三项,代价只是多存一个 Int64 变量。

5. 发现问题 ≠ 第一时间修

v1 运行了很久,"偶尔"关错 session,但没被发现。直到我们做了 Session 明细页面,能可视化每条记录,才暴露了系统性 bug。先建设可观测性,再优化

附加:孤儿 Session 处理

正常关闭靠 closeCurrentSession,异常情况(崩溃/强杀)遗留的孤儿靠启动清理

UPDATE app_sessions
SET ended_at = (
    SELECT MIN(s2.started_at) FROM app_sessions s2
    WHERE s2.started_at > app_sessions.started_at
),
duration_sec = ...
WHERE ended_at IS NULL
  AND EXISTS (SELECT 1 FROM app_sessions s2
              WHERE s2.started_at > app_sessions.started_at)

下一条 session 的开始时间作为结束时间,比用"当前时间"准确得多。

注意:最后一条 NULL session(当前活跃)的 EXISTS 条件不满足(没有"下一条"),自动保留,不被误关。

2026-05-22 · iScreenMonitor v0.9.1

作者:xilejun · v1.0 · 2026-05-22

📖 相关文章
百分位排序方法全解析:从百行数据到亿级并发的技术选型
Tableau 自定义 SQL 参数化 + Apache Doris 倒排索引:亿级大表的毫秒级实时点查实践
8.1  计算的演进及分类:从Excel、SQL到Tableau
Tableau性能优化:逻辑计算位置对筛选效率的影响
SQL发展史简述:从关系模型到现代数据库标准演进
——————————————————————————————

No comments yet