在这里插入图片描述
在这里插入图片描述

实例:搜索历史记录(SearchHistory)|技术:历史词表、唯一约束、SearchHistoryDao

一、业务需求分析:搜索历史的三个「怪癖」

搜索历史是每个 App 都有的小功能,看起来简单,但它的数据行为有三个「怪癖」,恰恰是数据库设计的考题:

  1. 去重:同一个词搜三次,历史里只显示一条,但可以记录搜索次数——用户搜「HarmonyOS」反复搜,说明这个词对他重要;
  2. 有上限:历史不能无限累积,一般保留最近 10~20 条,超出要淘汰最久未搜的——这是 LIMIT 的运用场景;
  3. 时间排序:最近搜过的排前面,超时自动上浮——用户扫一眼历史就能点回刚才搜的词。

这三个需求组合起来,就是一个精妙的数据库题:如何用一张表 + 唯一约束 + LIMIT 删除,实现「去重插入 + 超限淘汰 + 时间排序」。本实例就是这道题的完整解答。

核心业务需求清单:

需求 数据层实现 页面呈现
记录一次搜索 插入/更新时间戳 + 次数 标签流 + 时间线
去重 keyword 唯一约束 同词只显示一条
超限淘汰 LIMIT 删除最旧 保留 10 条
一键清空 DELETE 全表 清空按钮
单条删除 DELETE by id 长按删除

二、字段设计:一张极简的历史表

搜索历史表 search_history

字段名 类型 约束 说明
id INTEGER PRIMARY KEY AUTOINCREMENT 自增主键
keyword TEXT NOT NULL UNIQUE 搜索词,唯一约束(去重核心)
search_time INTEGER NOT NULL 最近搜索时间戳(毫秒)
count INTEGER NOT NULL DEFAULT 1 累计搜索次数

设计要点拆解

1. keyword UNIQUE 是去重的数据库级保证。表结构层面直接声明 keyword TEXT NOT NULL UNIQUE——同一个词只能存在一行,第二次搜索不会新增记录,而是 UPDATE 这行的 search_time 和 count。去重逻辑交给数据库约束而非前端判断,这是本实例的核心设计思想。

2. search_time 每次搜索刷新。它记录「最近一次搜索的时间」,排序用 ORDER BY search_time DESC——最近搜的排最前。注意它不记录「第一次搜索时间」——历史列表只需要「最近」语义。

3. count 累计搜索次数。同词反复搜索,count 递增(count + 1)。页面可以用它显示「HarmonyOS ×8」这种热度徽标,暗示用户的搜索偏好。这个字段是可选的锦上添花,但实现成本极低(一次 UPDATE)。

4. 三字段极简设计。为什么没有「搜索类型」「来源页面」等扩展字段?因为搜索历史的本质就是「词 + 时间 + 次数」,加字段违反「单表职责单一」——复杂需求(如按来源分组)应拆表。能用 3 个字段表达的模型,绝不用 6 个

三、建表 SQL:唯一约束的两种写法

CREATE TABLE IF NOT EXISTS search_history (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  keyword TEXT NOT NULL UNIQUE,
  search_time INTEGER NOT NULL,
  count INTEGER NOT NULL DEFAULT 1
);

UNIQUE 约束的两种声明方式

-- 方式一:列级约束(本实例采用)
keyword TEXT NOT NULL UNIQUE
-- 方式二:表级约束(适合复合唯一)
UNIQUE (keyword)

单列唯一用列级即可;复合唯一(如打卡实例的 (habit_id, date))必须用表级。SQLite 的 UNIQUE 约束自动创建唯一索引——插入重复值会抛 UNIQUE constraint failed 异常,这正是我们「先查后改」逻辑(见下节)的兜底防线。

四、SearchHistoryDao 封装:去重插入的核心方法

数据层核心 SearchHistoryDao。最关键的方法是 addKeyword——它实现了「去重插入 + 时间刷新 + 次数累计 + 超限淘汰」四合一:

static async addKeyword(context: common.Context, keyword: string): Promise<void> {
  const kw = keyword.trim();
  if (!kw) {
    return;
  }
  const store = await SearchHistoryDao.getStore(context);
  // 1. 查重:按词查是否已存在
  const pred = new relationalStore.RdbPredicates(SearchHistoryDao.TABLE);
  pred.equalTo('keyword', kw);
  const result = await store.query(pred);
  if (result.goToNextRow()) {
    // 已存在 → 更新时间 + 次数 +1
    const id = result.getLong(result.getColumnIndex('id'));
    const count = result.getLong(result.getColumnIndex('count'));
    result.close();
    const values: relationalStore.ValuesBucket = {
      search_time: Date.now(),
      count: count + 1,
    };
    const updatePred = new relationalStore.RdbPredicates(SearchHistoryDao.TABLE);
    updatePred.equalTo('id', id);
    await store.update(values, updatePred);
  } else {
    result.close();
    // 不存在 → 插入新记录
    const values: relationalStore.ValuesBucket = {
      keyword: kw, search_time: Date.now(), count: 1,
    };
    await store.insert(SearchHistoryDao.TABLE, values);
  }
  // 2. 超限淘汰:保留最近 MAX_KEEP 条
  await SearchHistoryDao.trim(context);
}

逻辑拆解

第 1 步:查重分支equalTo('keyword', kw) 查询——查到(重复词)走 UPDATE 分支(刷新 search_time、count+1),查不到走 INSERT 分支(新建 count=1)。这就是「去重插入」的完整语义:不是简单地 INSERT,而是「存在则更新、不存在则插入」(UPSERT 语义)。RDB 没有直接的 UPSERT 语法,用「先查后改」实现,逻辑清晰。

第 2 步:trim 超限淘汰MAX_KEEP = 10 条上限,超出的最旧记录被删除(下节详解)。

关于 result.close() 的位置:查重分支里,UPDATE 前必须 close 结果集——虽然 RDB 通常允许查询后再写,但规范做法是先释放游标再做写操作,避免潜在的锁竞争。

五、超限淘汰:LIMIT 删除的 SQL 艺术

trim 方法实现「保留最近 MAX_KEEP 条,删除更旧的」:

static async trim(context: common.Context): Promise<void> {
  const store = await SearchHistoryDao.getStore(context);
  // 1. 统计总数
  const countResult = await store.querySql(`SELECT COUNT(*) AS c FROM ${SearchHistoryDao.TABLE}`);
  let total = 0;
  if (countResult.goToNextRow()) {
    total = countResult.getLong(countResult.getColumnIndex('c'));
  }
  countResult.close();
  if (total <= MAX_KEEP) {
    return;  // 未超限,无需淘汰
  }
  // 2. 计算超出的条数
  const overflow = total - MAX_KEEP;
  // 3. 删除最旧的 overflow 条
  await store.executeSql(
    `DELETE FROM ${SearchHistoryDao.TABLE}
     WHERE id IN (
       SELECT id FROM ${SearchHistoryDao.TABLE}
       ORDER BY search_time ASC
       LIMIT ${overflow}
     )`
  );
}

这条 SQL 的精妙之处

子查询选最旧ORDER BY search_time ASC LIMIT ${overflow}——按搜索时间升序(最旧的在前)取前 overflow 条,就是「最久未搜的 overflow 个词」。

外层按 id 删除DELETE ... WHERE id IN (子查询)——不能用 DELETE ... ORDER BY ... LIMIT(SQLite 的 DELETE 不支持 LIMIT),所以用子查询把「要删的 id 集合」算出来,再按 id 删。这是 SQLite 删除 TOP-N 记录的标准写法。

为什么不在 SQL 里一次算:可以先查「总数 - MAX_KEEP」再决定是否删除——总数 <= 上限时直接 return,避免无谓的子查询。先判断再操作减少不必要的 SQL 执行。

MAX_KEEP 常量const MAX_KEEP = 10 定义在 DAO 顶部,是「保留条数」的单一事实来源。调整上限只改一处,页面无需改动——这是常量提取的意义。

六、基础查询与删除

全部历史(最近搜索在前)

static async queryAll(context: common.Context): Promise<SearchHistory[]> {
  const store = await SearchHistoryDao.getStore(context);
  const predicates = new relationalStore.RdbPredicates(SearchHistoryDao.TABLE);
  predicates.orderByDesc('search_time');
  const result = await store.query(predicates);
  return SearchHistoryDao.collect(result);
}

orderByDesc('search_time')——最近搜的排最前,这是历史列表的基本顺序。

删除单条与一键清空

static async deleteOne(context: common.Context, id: number): Promise<number> {
  const store = await SearchHistoryDao.getStore(context);
  const predicates = new relationalStore.RdbPredicates(SearchHistoryDao.TABLE);
  predicates.equalTo('id', id);
  return await store.delete(predicates);
}

static async clearAll(context: common.Context): Promise<number> {
  const store = await SearchHistoryDao.getStore(context);
  const predicates = new relationalStore.RdbPredicates(SearchHistoryDao.TABLE);
  return await store.delete(predicates);
}

deleteOne 删单条(页面长按标签触发),clearAll 删全表(页面「清空」按钮触发)——注意 store.delete 的 predicates 不带条件时删除全部行,返回受影响行数。

七、技术要点对照表

技术点 实现方式 生产价值
去重 keyword UNIQUE 约束 数据库级防重复
去重插入 先查后改(存在 UPDATE / 不存在 INSERT) UPSERT 语义
次数累计 count + 1 热度徽标数据源
超限淘汰 DELETE … WHERE id IN (SELECT … LIMIT) TOP-N 删除标准写法
时间排序 orderByDesc(‘search_time’) 最近优先
一键清空 predicates 无条件 delete 全表清除

八、文章小结

搜索历史的数据层核心是**「唯一约束 + 先查后改 + LIMIT 淘汰」**三件套:UNIQUE 保证去重、先查后改实现 UPSERT、DELETE ... IN (SELECT ... LIMIT) 实现超限淘汰。这是「有上限的键值记录」这类场景的标准建模——搜索历史、最近浏览、推荐记录都是同一模式。极简的 3 字段设计让模型一目了然。

下一篇(7-2)展示极简标签流 UI——搜索框 + 历史标签 + 一键清空,像热搜榜一样清爽。

九、字段设计详解:UNIQUE 去重、搜索次数与热度排序

前文的三字段极简设计,在实战中有三个细节值得深挖:

1. keyword UNIQUE 去重策略的边界。UNIQUE 约束区分大小写、也忽略不了空格——「HarmonyOS」与「harmonyos」是两条记录,「 HarmonyOS」(带空格)与「HarmonyOS」也是两条。所以 addKeyword 第一步 keyword.trim() 不是可有可无:输入层先归一化,数据库约束才能发挥「同词只留一行」的威力。若还想大小写不敏感去重,可在插入前统一 toLowerCase()(本实例保留原样,尊重用户输入)。

2. count 是「搜索次数」的唯一事实来源。它由「存在则 count + 1」维护,永不手工赋值——避免并发写导致次数失真。页面徽标(HarmonyOS 开发 ×8)读的就是这个字段。

3. 热度排序:count 的另一半价值。时间排序(search_time DESC)回答「最近搜了什么」,热度排序(count DESC)回答「我搜得最多的是什么」,两种排序对应两种产品语义:

排序维度 SQL 排序 产品场景
时间排序 ORDER BY search_time DESC 历史时间线(默认)
热度排序 ORDER BY count DESC 我的热搜榜、Top 10
// 按热度取 Top 10:搜得最多的词排最前
static async queryHot(context: common.Context, limit: number = 10): Promise<SearchHistory[]> {
  const store = await SearchHistoryDao.getStore(context);
  const predicates = new relationalStore.RdbPredicates(SearchHistoryDao.TABLE);
  predicates.orderByDesc('count');
  predicates.limitAs(limit);
  const result = await store.query(predicates);
  return SearchHistoryDao.collect(result);
}

limitAs(limit) 与 trim 里的 LIMIT ${overflow} 殊途同归——一个是查询侧取前 N,一个是删除侧删前 N,LIMIT 是 SQLite 控制「数量」的统一武器

十、热门搜索统计的 GROUP BY 思路

细心的读者会发现:search_history 表因为有 keyword UNIQUE 约束,天然就是「每个词一行」——统计热门搜索根本不需要 GROUP BY,直接 count 降序即可。那 GROUP BY 什么时候用得上?

答案是:当「每次搜索」单独记日志时。假设升级需求「记录每个词每天被搜几次」,就要拆一张日志表 search_log:

-- 每次搜索记一行,不合并
CREATE TABLE IF NOT EXISTS search_log (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  keyword TEXT NOT NULL,
  search_time INTEGER NOT NULL
);

热门统计就变成经典的聚合查询:

SELECT keyword, COUNT(*) AS cnt, MAX(search_time) AS last_time
FROM search_log
WHERE search_time >= ${startTime}
GROUP BY keyword
ORDER BY cnt DESC
LIMIT 10;

GROUP BY keyword 把同一词的多次搜索聚合成一行,COUNT(*) 算次数、MAX(search_time) 取最近时间。这就是单表 UNIQUE 换掉的复杂度——有唯一约束时 GROUP BY 是冗余的,没有约束时才需要它。两套方案的选择标准:历史只需要「最新状态」→ 单表 UNIQUE(本实例);历史需要「完整轨迹」→ 日志表 + GROUP BY。

十一、清空历史的 DELETE 操作细节

clearAll 一行 store.delete(predicates) 就把全表删光,但生产环境有三个细节值得推敲:

1. DELETE 不重置自增主键。删光后再次插入,id 会从 11 继续而不是从 1 开始。多数场景无感(id 只做内部标识),若产品要求「清空后重新编号」,需手动重置:

DELETE FROM sqlite_sequence WHERE name = 'search_history';

2. 清空建议走事务clearAll 内部可包一层 store.beginTransaction(),配合「清空 + 灌种子数据」的连招(7-4 的 initSeedData 就是先清后灌),保证要么全成功要么全回滚,避免半清空状态。

3. 页面侧双重确认。清空是不可逆操作,页面应弹 AlertDialog 确认,防止误触——数据层的 DELETE 只管执行,要不要删、删前是否确认,是产品与 UI 层的责任。这条分层原则与「去重逻辑交给数据库」一脉相承:数据层只提供能力,不替业务做决策。

十二、FAQ:搜索历史表的高频疑问

问题 解答
为什么用 UNIQUE 不用前端去重? 数据库约束是唯一可靠防线,前端去重在多入口(搜索框、热词点击)时会漏
INSERT OR REPLACE 能替代先查后改吗? 能去重,但会删除旧行重建新行,count 无法累计、id 会变化,本场景不合适
trim 为什么不用 DELETE … LIMIT? SQLite 的 DELETE 不支持 LIMIT,必须用 WHERE id IN (子查询) 迂回
时间戳为什么用毫秒? Date.now() 直接可得,与 RDB 的 INTEGER 兼容,排序比较无精度损失
搜索词需要单独建索引吗? UNIQUE 约束自动创建唯一索引,equalTo(‘keyword’) 查重已走索引,无需重复建
MAX_KEEP 改 20 会影响性能吗? 不会,trim 只删超出的几条,表数据量本身有上限,性能恒定
Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐