数据库范式实战:从硬编码分类器到第三范式重构

以一个 macOS 时间追踪应用的真实重构为例,演示如何将 105 行硬编码的 app 分类规则逐步迁移为符合三范式的数据库驱动系统。每一步都有真实的 SQL 和 Swift 代码对照。

重构不是玄学。本文以我最近做的一个 macOS 应用——TimeMirror(时间镜子)的真实经历,演示数据库规范化如何让一个从 0NF 的「代码垃圾场」变成 3NF 的整洁数据模型。

📌 本文核心要点
从 0NF 到 3NF:用「TimeMirror」macOS 应用的真实重构案例,展示数据库规范化的落地方法
三步走方法论:抽取分类维度 → categories 表(2NF)→ 分离匹配规则 → app_categories 表(3NF)
范式不是教条:有意保留的反范式设计(duration_sec 冗余、单表事实)是权衡而非缺陷

一、背景:105 行硬编码规则的痛点

TimeMirror 需要将用户使用的每个 macOS 应用归类(开发工具、浏览器、通讯社交等),以便在 Habit 页面按分类展示时间分布。重构前的代码是一个巨大的 switch-case——

CategoryClassifier.swift:105 行,包含 9 个分类、约 80 条 bundle_id 前缀规则、约 40 条 app_name 关键词规则,全部硬编码在 Swift 代码里。

这种设计有 4 个致命问题

  1. 无法动态更新:新增一个分类(如「AI 工具」)需要改代码、重新编译、重新部署
  2. 用户不可定制:用户无法将某个 app 归入自己想要的分类
  3. 数据与逻辑耦合:规则散落在 Swift 代码中,无法用 SQL 查询「哪些 app 被归入了开发工具」
  4. 分类元数据无独立存储:categoryColors 字典与业务代码混在一起,改个颜色要翻代码
0NF到3NF的数据库范式演进过程

二、范式化三步走

2.1 现状分析:0NF(未规范化)

重构前,分类系统等价于一张「虚拟表」——每条规则重复存储 category_label 和 category_color:没有独立主键、插入异常、更新异常、删除异常、数据冗余,四项全中。这是因为我们还没有把「分类」当成一个独立的实体来建模。

2.2 第一步:抽取分类维度 → categories 表(达到 2NF)

设计思路很直接:分类的 key、label、color 是独立实体,应该有自己的表。每个非主属性完全函数依赖于主键

CREATE TABLE categories (
id INTEGER PRIMARY KEY,
key TEXT NOT NULL UNIQUE, -- 程序内部标识 'development'
label TEXT NOT NULL, -- 用户可见名称 '开发工具'
color TEXT NOT NULL -- 展示颜色 '#3B82F6'
);

定义 9 个分类的种子数据——开发工具、浏览器、通讯社交、数据分析、写作笔记、办公套件、系统工具、娱乐、未分类。使用 INSERT OR IGNORE 写 SQLite seed,实现幂等。

这一步的收益:分类元数据独立存储,修改颜色或标签只需 UPDATE 一行,不再需要改代码。所有分类查询都有了统一的 JOIN 入口

2.3 第二步:分离匹配规则 → app_categories 表(达到 3NF)

核心洞察:匹配规则(bundle_id 前缀 / app_name 关键词)不应该和分类元数据混在一起。它们有独立于分类的语义——同一种匹配方式可以对应不同分类,同一个分类可以有多种匹配规则。

CREATE TABLE app_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
match_key TEXT NOT NULL, -- 匹配字符串
match_type TEXT NOT NULL DEFAULT 'bundle_prefix',
category_id INTEGER NOT NULL, -- FK → categories.id
source TEXT NOT NULL DEFAULT 'system', -- system / user
updated_at REAL NOT NULL,
FOREIGN KEY (category_id) REFERENCES categories(id),
UNIQUE(match_key, match_type)
);

三种 match_type 设计

  • bundle_prefix:匹配 bundle_id 前缀(如 com.google.Chrome),最长前缀优先,约 80 条系统规则
  • app_name:关键词匹配(如「微信」),不区分大小写,约 40 条系统规则,作为 bundle_id 匹配失败后的降级策略
  • exact:用户精确覆盖,优先级最高,source='user',利用 UNIQUE 约束实现 upsert

source 字段的设计是关键——'system' 和 'user' 的区分让用户覆盖和系统默认优雅共存。删除用户规则即「恢复默认」,不需要维护额外的标记位。

重构后的数据库表结构 — categories 和 app_categories 的关系

三、分类引擎的优先级逻辑

将规则存入数据库后,分类逻辑从 Swift switch-case 变成了一个纯数据流

classify(bundleId, appName):
1. 查用户精确覆盖 (match_type='exact', source='user')
2. 查系统 bundle_prefix 最长前缀匹配
3. 查系统 app_name 关键词匹配(case-insensitive)
4. 兜底 → '未分类' (category_id=9)

每次 DataProvider 刷新数据时,先加载全部规则到内存(categories + app_categories 共约 130 行),然后在 Swift 层完成优先级匹配。规则缓存生命周期 = 一次刷新周期,保证了数据一致性。

四、看得见的收益

  • 可查询:SELECT * FROM app_categories WHERE category_id=1 立刻知道哪些 app 属于开发工具
  • 可热更新:新增分类或规则只需 INSERT/UPDATE,不需要重新编译
  • 用户可定制:提供 AppCategorySheet UI,用户可覆盖任意 app 的分类
  • 零耦合:分类数据与业务代码完全分离,CategoryClassifier.swift 被直接删除
  • 可扩展:未来可轻松扩展为多分类、多语言标签、分类图标等

五、范式不是教条

严格达到 3NF 并不意味着完美。我们的 app_sessions 表仍然保留了 duration_sec 冗余字段——它完全可以从 ended_at - started_at 计算出来。但这是有意为之的反范式设计:在按小时切分会话的递归 CTE 查询中,直接读 duration_sec 比每次重新计算快得多。

范式化是手段,不是目的。真正的判断标准只有两个:数据一致性是否可控查询性能是否可接受

六、完整代码与项目

本文涉及的完整实现已开源

  • CategoryStore.swift:分类存储引擎,含 seed 数据 + classify 优先级逻辑
  • MonitorEngine.swift:categories + app_categories 建表 + 首次 seed
  • AppCategorySheet.swift:用户自定义分类 UI,支持搜索、9 类选择、恢复默认
  • DataProvider.swift:每次刷新加载 RuleSet,为 AppUsage 注入 categoryLabel

七、总结

从 0NF 到 3NF 的重构不是一个学术练习。它每一步都解决了真实的生产力问题:新增分类不用改代码、用户有自主权、数据模型变得可查询。范式化的本质是让数据「说出」结构,而不是让代码「记住」结构

如果你也在维护一个逐渐膨胀的单表或硬编码分类器,希望这篇文章能给你一个可操作的参考——范式不是高深理论,它就是一串 DDL 和一点勇气

No comments yet