一个 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