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

实例:搜索历史记录(SearchHistory)|技术:去重插入、LIMIT 保留 10 条、时间排序

一、三个查询难题的回顾

搜索历史的数据层要解决三个问题(7-1 文章已建立认知),本篇文章深入实现细节:

  1. 去重插入:同词不新增记录,而是刷新时间 + 次数累计;
  2. 超限淘汰:超过 10 条时删除最久未搜的;
  3. 时间排序:最近搜的排最前。

这三个问题恰好覆盖了 SQL 的三种能力:条件分支(查重)、子查询 + LIMIT(淘汰)、ORDER BY(排序)。逐个拆解。

二、去重插入:UPSERT 语义的两种实现

「存在则更新、不存在则插入」在数据库术语里叫 UPSERT(UPDATE + INSERT)。RDB 没有直接的 UPSERT 语法,落地版用「先查后改」实现(7-1 已展示完整代码),这里对比另一种方案:INSERT OR REPLACE

方案 A:先查后改(落地版采用)

const pred = new relationalStore.RdbPredicates(SearchHistoryDao.TABLE);
pred.equalTo('keyword', kw);
const result = await store.query(pred);
if (result.goToNextRow()) {
  // 存在 → UPDATE search_time + count
  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);
}

优点:次数累计逻辑自然(count + 1 在代码里完成);缺点:两次数据库交互(查 + 写)。

方案 B:INSERT OR REPLACE

INSERT OR REPLACE INTO search_history (id, keyword, search_time, count)
VALUES (?, ?, ?, ?);

问题:REPLACE 的语义是「删除旧行、插入新行」——如果旧行存在,它的 id 会变化(新 id),且必须提供全部字段。我们想保留 count 累加值就得先查出来,等于还是要先查。更糟的是 id 变化会导致外键引用失效(本实例无外键,影响小)。

结论

先查后改是搜索历史的正确方案——因为「次数累计」需要读旧值,无论怎么优化都要先查。INSERT OR REPLACE 更适合「无状态覆盖」场景(如用户设置)。方案选择取决于业务是否需要读旧值

三、超限淘汰:LIMIT 子查询删除的三种写法

「保留最近 N 条」的删除,SQLite 有几种写法,逐一对比:

写法一:子查询取 id(落地版采用)

DELETE FROM search_history
WHERE id IN (
  SELECT id FROM search_history
  ORDER BY search_time ASC
  LIMIT ${overflow}
);

优点:语义清晰、通用(任何 SQLite 版本可用);缺点:两层级联查询。

写法二:ROWID 直接删(SQLite 特性)

DELETE FROM search_history
WHERE rowid IN (
  SELECT rowid FROM search_history
  ORDER BY search_time ASC
  LIMIT ${overflow}
);

用内置 rowid 替代 id,避免子查询里读 id 列——性能略优(rowid 是主键索引),语义相同。如果表有 INTEGER PRIMARY KEY,id 就是 rowid 的别名,两种写法等价

写法三:先查后删(两段式)

// 先查出最旧的 overflow 个 id
const result = await store.querySql(
  `SELECT id FROM ${SearchHistoryDao.TABLE} ORDER BY search_time ASC LIMIT ${overflow}`
);
// 再逐个删除
for (const id of ids) { await store.delete(...); }

缺点:代码多、多次数据库交互。不如写法一/二一条 SQL 干净。

结论

落地版用写法一(id IN 子查询),兼顾通用性与可读性。记住这个模式:SQLite 删除 TOP-N 记录 = DELETE … WHERE id IN (SELECT id … ORDER BY … LIMIT N),它是「保留最近 N 条」这类需求的标准答案。

四、时间排序与「上浮」行为

历史列表按 search_time DESC 排序,这里有一个值得展开的交互细节:重复搜索的词会「上浮」到最前

场景:用户先搜「SQLite」,再搜「ArkTS」,再搜「SQLite」——第二次搜 SQLite 时,它的 search_time 被刷新为最新,排序时排到第一位。历史列表的最终顺序:SQLite(最新)、ArkTS、其他。

这个「上浮」行为是怎么来的? 完全由「去重插入时刷新 search_time」驱动——不需要额外的排序逻辑,ORDER BY search_time DESC 自然呈现「最近用的词在上」。数据层的行为设计决定了交互体验:刷新时间戳 → 自动上浮,这是搜索历史的行业惯例(浏览器、App 都这样)。

五、完整的方法矩阵

SearchHistoryDao 的完整方法清单:

方法 作用 关键 SQL
getStore 单例 + 建表 CREATE TABLE IF NOT EXISTS
queryAll 全部历史 ORDER BY search_time DESC
addKeyword 去重插入 + 刷新 + 淘汰 查重 → UPDATE/INSERT → trim
trim 超限淘汰 DELETE … IN (SELECT … LIMIT)
deleteOne 删单条 DELETE WHERE id = ?
clearAll 一键清空 DELETE(无条件)
initSeedData 12 条种子 见 7-4 文章

每个方法职责单一、签名简洁(context + 业务参数),页面调用零心智负担。

六、技术要点对照表

技术点 实现方式 生产价值
UPSERT 先查后改(存在 UPDATE/不存在 INSERT) 需读旧值的场景必备
INSERT OR REPLACE 不适用的场景分析 无状态覆盖才用
TOP-N 删除 DELETE … WHERE id IN (SELECT … LIMIT) 保留最近 N 条的标准答案
上浮排序 刷新 search_time + ORDER BY DESC 最近使用的词排最前
唯一约束 keyword UNIQUE 去重的数据库级保证
次数累计 count + 1 热度徽标

七、常见问题 FAQ

Q1:为什么不用 keyword 做主键?
A:可以但不好。用 keyword TEXT PRIMARY KEY 也能去重,但主键是字符串索引,且一旦改词(如大小写统一)会牵动引用。用「id 自增主键 + keyword 唯一约束」分离「标识」与「业务唯一」,更灵活。

Q2:大小写敏感吗?搜「harmonyos」和「HarmonyOS」算重复吗?
A:默认算不重复(SQLite 的 UNIQUE 区分大小写)。如果要忽略大小写去重,需要 COLLATE NOCASE 约束(keyword TEXT UNIQUE COLLATE NOCASE)或插入前统一 toLowerCase()。本实例保持默认,中文搜索无此问题。

Q3:空格和空串怎么处理?
A:addKeyword 开头 kw.trim() 去首尾空格,空串直接 return 不记录。避免「 」这种纯空格关键词污染历史。输入卫生是数据层的第一道防线

Q4:MAX_KEEP 是 10,能改成 20 吗?
A:改 const MAX_KEEP = 10 为 20 即可,页面副标题「保留最近 N 条」自动跟随(绑定 histories.length)。常量单一来源让调整零风险。

Q5:清空历史后重新搜索会怎样?
A:正常重新记录——clearAll 删空表,再搜索时表为空,addKeyword 走 INSERT 分支新建。历史从零开始,符合预期。

Q6:trim 在每次 addKeyword 都执行,频繁吗?
A:trim 内部先判断 total <= MAX_KEEP 直接 return,只有超限才执行删除。未超限时只是一次 COUNT 查询,开销可忽略。带守卫的判断让高频调用的成本最低

Q7:并发场景(两个搜索同时进来)会怎样?
A:单用户 App 的搜索是串行事件(用户一次输入一个词),并发风险极低。即使并发,keyword 唯一约束兜底(第二次 INSERT 冲突报错,被异常捕获)。本实例未做并发控制,符合场景复杂度。

八、文章小结

本篇文章深入讲解了搜索历史数据层的三个核心实现:先查后改的 UPSERT(需读旧值场景)DELETE … IN (SELECT … LIMIT) 的 TOP-N 删除(SQLite 标准写法)刷新时间戳驱动的上浮排序。这三个模式组合起来,就是「有上限的最近记录」这类场景的完整数据层答案——搜索历史、最近播放、浏览足迹全部适用。

下一篇(7-4)展示 12 条热词种子数据,让标签流一开屏就「撑起榜单」。

九、增删查三件套:查重、热度自增、关键词搜索

前三节讲了「去重插入 + 超限淘汰 + 排序」三个设计难点,本节把增删查的每个原子操作补齐:插入前查重、热度自增 UPDATE、LIKE 关键词搜索

9.1 插入前查重:两种判定的等价写法

先查后改的第一步是查重。除了 7-1 的「query + goToNextRow」写法,也可以用 COUNT 判定:

-- 查重:统计同名记录数
SELECT COUNT(*) AS cnt FROM search_history WHERE keyword = ?;
const pred = new relationalStore.RdbPredicates(SearchHistoryDao.TABLE);
pred.equalTo('keyword', kw);
const result = await store.query(pred);
const has = result.goToNextRow(); // true = 已存在
result.close();

两种写法等价:goToNextRow 判断「有没有下一行」,COUNT 判断「行数是否大于 0」。落地版选前者——它顺便把 id、count 取出来,省一次查询。真正的双保险来自数据库层的 keyword UNIQUE 唯一约束:即使代码查重漏判(如极端并发),重复 INSERT 也会被约束拒绝,不会产生脏数据。

9.2 热度自增 UPDATE:count 在 SQL 里加还是代码里加

热度 = 搜索次数。加一次热度有两种写法:

-- 写法 A:SQL 层自增
UPDATE search_history SET count = count + 1, search_time = ? WHERE id = ?;

-- 写法 B:代码层自增(落地版)
-- count + 1 在 ArkTS 里完成,UPDATE 只写回结果
写法 读旧值 原子性 落地版选型
A:count = count + 1 SQL 内部完成 一条语句原子完成 查重用 COUNT 时推荐
B:代码自增 需先 getLong 读出 读-改-写有中间态 查重已拿到旧值,顺手

落地版选 B 的原因:查重时已经拿到了 count(不拿白不拿),且 B 的语义一目了然。选型标准是「旧值是否顺手可得」——旧值已在手,B 少写一条 SQL;旧值没查到,A 更简洁。注意 UPDATE 的谓词用 equalTo('id', id) 而不是 equalTo('keyword', kw)——用主键定位行,命中唯一、绝不误更新多条。

9.3 LIKE 搜索:历史词联想

搜索框输入时可以联想历史里的相似词:

SELECT keyword, count FROM search_history
WHERE keyword LIKE '%?%'
ORDER BY count DESC, search_time DESC;
const pred = new relationalStore.RdbPredicates(SearchHistoryDao.TABLE);
pred.like('keyword', `%${kw}%`);   // 模糊匹配
pred.orderByDesc('count');          // 热度优先
pred.orderByDesc('search_time');    // 同热度按时间

LIKE '%关键词%' 是包含匹配:搜「ark」能命中「ArkTS 语法」「元服务与 ArkTS」。LIKE 的百分号必须自己拼——RdbPredicates.like 不会自动加,漏写就退化成等值查询,这是最隐蔽的坑。中文搜索同样适用,默认按字符匹配。

十、热门榜单:GROUP BY + ORDER BY 的热度聚合

搜索历史除了「最近记录」,还能产出「热门榜单」——按热度给关键词排名。榜单的 SQL 有两条路径:

10.1 已有热度列:ORDER BY 直接排序

本表的 count 字段就是热度,直接排序即可:

SELECT keyword, count FROM search_history
ORDER BY count DESC, search_time DESC
LIMIT 5;

ORDER BY 多列时先按第一列、相同时再按第二列:count 相同(并列热度)时,最近搜的排前面。这就是「热门榜 + 时效」的经典双排序。

10.2 无热度列:GROUP BY 现算热度

如果表里没有 count 列(只有 keyword + search_time),榜单要靠分组聚合现算:

SELECT keyword, COUNT(*) AS hot, MAX(search_time) AS last_time
FROM search_history
GROUP BY keyword
ORDER BY hot DESC, last_time DESC
LIMIT 5;
子句 作用 本例
GROUP BY keyword 同词归一组 每个词一组
COUNT(*) 组内行数 = 热度 搜 3 次 → hot = 3
MAX(search_time) 组内最近时间 并列热度时用
ORDER BY hot DESC 热度降序 最热在前

GROUP BY 的经典坑:SELECT 里除了聚合函数,只能出现 GROUP BY 的列——想带出别的字段必须套 MAX/MIN 聚合,否则 SQLite 取哪一行不确定。记住:落地版有 count 列走 10.1,GROUP BY 是「没有热度列」时的补救方案。

十一、清空历史:delete 谓词的空条件艺术

clearAll 是一键清空的实现,它揭示了 RdbPredicates 的一个特性:不挂任何条件的谓词 = 无条件删除

static async clearAll(context: common.Context): Promise<void> {
  const store = await SearchHistoryDao.getStore(context);
  // 谓词不带 equalTo —— 没有 WHERE 条件,删全表
  const pred = new relationalStore.RdbPredicates(SearchHistoryDao.TABLE);
  await store.delete(pred);
}

生成的 SQL 只有一句:

DELETE FROM search_history;

对比 deleteOne

const pred = new relationalStore.RdbPredicates(SearchHistoryDao.TABLE);
pred.equalTo('id', id);   // 带条件 → WHERE id = ?
await store.delete(pred);
方法 谓词 生成 SQL
deleteOne equalTo(‘id’, id) DELETE FROM search_history WHERE id = ?
clearAll 空谓词 DELETE FROM search_history

两个 delete 共用同一个 API,区别只在谓词是否挂条件——这是 RdbPredicates 的优雅之处:条件是可选参数,空条件即全表操作。页面层「清空」按钮调 clearAll 前弹确认框(promptAction.showDialog),防止误触不可恢复。

十二、完整 SQL 生成解读:方法 → SQL 对照

把整个 SearchHistoryDao 的每个方法展开成它实际执行的 SQL,一览全貌:

方法 RdbPredicates 链 / 行为 生成 SQL
queryAll orderByDesc(‘search_time’) SELECT * FROM search_history ORDER BY search_time DESC
addKeyword 查重 equalTo(‘keyword’, kw) SELECT * FROM search_history WHERE keyword = ?
addKeyword 更新 equalTo(‘id’, id) UPDATE search_history SET search_time = ?, count = ? WHERE id = ?
addKeyword 插入 insert 直接传表名 INSERT INTO search_history (keyword, search_time, count) VALUES (?, ?, ?)
trim 淘汰 IN 子查询 DELETE FROM search_history WHERE id IN (SELECT id … ORDER BY search_time ASC LIMIT ?)
deleteOne equalTo(‘id’, id) DELETE FROM search_history WHERE id = ?
clearAll 空谓词 DELETE FROM search_history
hotList orderByDesc(‘count’) SELECT … ORDER BY count DESC, search_time DESC LIMIT ?

读这张表的方式:每个方法 = 一条谓词链 + 一个 SQL 模板。RdbPredicates 链的每个方法调用(equalTo / orderByDesc / like)对应 SQL 的一个子句,链的顺序就是子句的顺序。读懂这张表,就等于读懂了 SearchHistoryDao 的全部数据库行为——以后调优(加索引、改排序、加条件)都能在 SQL 层面直接推演,无需翻代码。

十三、FAQ 补充

Q1:count 自增用 SQL 写(count = count + 1)会不会更好?
A:更原子——一条 SQL 完成「读-加-写」,没有中间态。落地版选代码自增纯粹因为查重时旧值已到手。如果查重用 COUNT 写法,建议改回 SQL 自增,两种选型各有适用前提。

Q2:LIKE 搜索会不会很慢?
A:历史表最多几十行,全表扫描也无感。若表很大可给 keyword 建索引,但 LIKE '%词%' 的前置百分号会导致索引失效(SQLite 只有前缀匹配才走索引)——本场景规模下无需优化。

Q3:清空历史能恢复吗?
A:不能。DELETE 是物理删除,没有回收站。页面已用确认框兜底,数据层不需要额外设计。需要「恢复」的业务应设计软删除(加 deleted 标记列),搜索历史不需要——需求匹配复杂度,不过度设计。

Q4:热门榜单和最近记录能用同一张表吗?
A:能,而且应该——一张 search_history 表同时服务「时间线」(ORDER BY search_time)和「热度榜」(ORDER BY count),只是排序维度不同。这就是一表多读:表结构不变,查询视角决定输出。

Q5:为什么 trim 不用 DELETE ... ORDER BY ... LIMIT 直接写?
A:SQLite 的 DELETE 语法不支持 ORDER BY / LIMIT 直接跟——必须用子查询包一层(见第三节写法一)。这是 SQLite 的语法限制,不是设计取舍,写法二/三只是它的变体。

Q6:唯一约束和先查后改,哪个才是真正的去重保证?
A:唯一约束是最终防线,先查后改是主流程。先查后改负责「正常路径」(存在则更新),唯一约束负责「异常兜底」(并发漏判时拒绝重复插入)。两者缺一不可——只靠查改会漏,只靠约束会报错。

Logo

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

更多推荐