鸿蒙应用开发实战【80】— 数据库查询性能分析与索引设计
鸿蒙应用开发实战【80】— 数据库查询性能分析与索引设计
本文是「号码助手全栈开发系列」第 80 篇,持续更新中…
开源社区:https://openharmonycrossplatform.csdn.net
本文涵盖:
- "号码助手"三个核心索引的原理与作用
EXPLAIN QUERY PLAN分析查询执行计划- 复合索引与覆盖索引设计策略
- 索引选择性对查询性能的影响
- 索引对写入性能的代价与权衡
- 实战:根据查询模式优化数据库索引
一、"号码助手"数据表与索引现状
"号码助手"的数据库 number_assistant.db 包含三张主要表和一张元数据表,其中 app_bindings 表上建有三个索引:
1.1 当前索引一览
| 索引名 | 表 | 列 | 用途 | 查询场景 |
|---|---|---|---|---|
idx_ab_card |
app_bindings |
card_id |
加速按卡号查询绑定 | 卡号详情页、换绑查询 |
idx_ab_status |
app_bindings |
status |
加速按状态筛选 | 首页统计、状态筛选页 |
idx_ab_name |
app_bindings |
app_name |
加速应用名搜索 | 搜索页 LIKE 查询 |
1.2 建表与索引 SQL
从 DatabaseService.ts 中可以看到完整的 DDL:
private async upgrade(store: relationalStore.RdbStore, fromVersion: number, toVersion: number): Promise<void> {
if (fromVersion < 1) {
const ddls: string[] = [
// 卡号表
`CREATE TABLE IF NOT EXISTS cards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
label TEXT NOT NULL,
phone_number TEXT NOT NULL,
carrier TEXT NOT NULL DEFAULT '',
remark TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT 'blue',
sort_order INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)`,
// 应用绑定表
`CREATE TABLE IF NOT EXISTS app_bindings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
app_name TEXT NOT NULL,
icon_key TEXT NOT NULL DEFAULT '#4F7CFF',
category TEXT NOT NULL DEFAULT 'APP',
card_id INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT '使用中',
remark TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT '手动',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)`,
// 索引
`CREATE INDEX IF NOT EXISTS idx_ab_card ON app_bindings(card_id)`,
`CREATE INDEX IF NOT EXISTS idx_ab_status ON app_bindings(status)`,
`CREATE INDEX IF NOT EXISTS idx_ab_name ON app_bindings(app_name)`
];
for (const sql of ddls) {
await store.executeSql(sql);
}
}
}
二、EXPLAIN QUERY PLAN 分析
2.1 如何使用
HarmonyOS 的 relationalStore.RdbStore 支持通过 executeSql 执行 EXPLAIN QUERY PLAN:
async function explainQuery(store: relationalStore.RdbStore, sql: string): Promise<void> {
const rs = await store.querySql(`EXPLAIN QUERY PLAN ${sql}`);
console.info('查询计划:');
while (rs.goToNextRow()) {
const id = rs.getLong(rs.getColumnIndex('id'));
const parent = rs.getLong(rs.getColumnIndex('parent'));
const detail = rs.getString(rs.getColumnIndex('detail'));
console.info(` id=${id} parent=${parent} ${detail}`);
}
rs.close();
}
// 执行分析
const store = DatabaseService.getInstance().getStore();
await explainQuery(store, "SELECT * FROM app_bindings WHERE card_id = 1");
2.2 常见查询计划解读
-- 有索引的查询
EXPLAIN QUERY PLAN SELECT * FROM app_bindings WHERE card_id = 1;
-- 输出: SEARCH TABLE app_bindings USING INDEX idx_ab_card (card_id=?)
-- ✅ 使用索引,高效
-- 无索引的查询
EXPLAIN QUERY PLAN SELECT * FROM app_bindings WHERE remark LIKE '%测试%';
-- 输出: SCAN TABLE app_bindings
-- ❌ 全表扫描,低效
-- 排序优化
EXPLAIN QUERY PLAN SELECT * FROM app_bindings ORDER BY updated_at DESC;
-- 输出: SCAN TABLE app_bindings USING INDEX idx_ab_name
-- ⚠️ 使用了索引但可能不是最优的
三、当前查询模式与索引使用分析
3.1 查询模式总表
| # | DAO 方法 | SQL 模式 | 当前索引 | 索引效率 |
|---|---|---|---|---|
| 1 | listByCardId(cardId) |
WHERE card_id = ? ORDER BY updated_at DESC |
idx_ab_card |
✅ 使用索引,但需要文件排序 |
| 2 | listByStatus(status) |
WHERE status = ? ORDER BY updated_at DESC |
idx_ab_status |
✅ 使用索引,但选择性低 |
| 3 | search(keyword) |
WHERE app_name LIKE '%keyword%' |
idx_ab_name |
❌ LIKE 以 % 开头无法使用索引 |
| 4 | countByStatus() |
SELECT status, COUNT(*) GROUP BY status |
idx_ab_status |
✅ 覆盖查询 |
| 5 | countByCardId(cardId) |
SELECT id WHERE card_id = ? |
idx_ab_card |
✅ 覆盖索引 |
| 6 | listAll() |
ORDER BY updated_at DESC |
无 | ❌ 全表扫描 + 文件排序 |
3.2 索引选择性分析
选择性 = 不同值的数量 / 总行数。值越接近 1,索引效率越高。
| 列 | 不同值数量 | 总行数(预估) | 选择性 | 索引效果 |
|---|---|---|---|---|
card_id |
~10 | 200 | 0.05 | 良好(每个卡号约 20 条) |
status |
4 | 200 | 0.02 | 差(仅 4 种状态) |
app_name |
~180 | 200 | 0.9 | 优秀(几乎唯一) |
四、复合索引设计
4.1 为什么需要复合索引
当前查询 listByCardId 虽然使用了 idx_ab_card,但 ORDER BY updated_at DESC 需要文件排序:
-- 当前:用 idx_ab_card 找到数据 → 然后排序
EXPLAIN: SEARCH TABLE app_bindings USING INDEX idx_ab_card
ORDER BY updated_at DESC -- 额外排序步骤!
4.2 复合索引消除排序
-- 复合索引:card_id + updated_at,排序已由索引提供
CREATE INDEX idx_ab_card_updated ON app_bindings(card_id, updated_at DESC);
修改后查询计划:
EXPLAIN QUERY PLAN SELECT * FROM app_bindings WHERE card_id = 1 ORDER BY updated_at DESC;
-- 输出: SEARCH TABLE app_bindings USING INDEX idx_ab_card_updated
-- ✅ 索引已经排好序,无需额外排序
4.3 推荐的复合索引方案
| 复合索引 | 列顺序 | 覆盖的查询 | 节省的排序 |
|---|---|---|---|
idx_ab_card_updated |
card_id, updated_at DESC |
listByCardId |
消除 ORDER BY 文件排序 |
idx_ab_status_updated |
status, updated_at DESC |
listByStatus |
消除 ORDER BY 文件排序 |
idx_ab_card_status |
card_id, status |
换绑时检查状态 | 按卡号筛选后额外的状态过滤 |
五、覆盖索引
5.1 什么是覆盖索引
当索引包含查询所需的所有列时,数据库可以不回表(访问主表数据),直接从索引返回结果。
5.2 实战演示
当前 countByCardId 查询仅需要 id 列:
// 只查 id,不需要回表
static async countByCardId(cardId: number): Promise<number> {
const predicates = new relationalStore.RdbPredicates(TABLE);
predicates.equalTo('card_id', cardId);
const rs = await store.query(predicates, ['id']); // 只投影 id
const count = rs.rowCount;
rs.close();
return count;
}
如果 idx_ab_card 是 (card_id, id) 的复合索引,则查询可直接从索引返回,无需访问主表:
CREATE INDEX idx_ab_card_covering ON app_bindings(card_id, id);
5.3 覆盖索引 vs 普通索引
| 对比项 | 普通索引 | 覆盖索引 |
|---|---|---|
| 访问主表 | 需要回表(额外 I/O) | 不需要 |
| 查询速度 | 较快 | 最快 |
| 存储开销 | 较小 | 较大(含更多列) |
| 适用场景 | WHERE 条件查询 | 投影列少的查询 |
六、索引对写入性能的影响
6.1 索引的代价
每增加一个索引,INSERT、UPDATE、DELETE 操作都需要维护索引 B+ 树:
| 操作 | 无索引 | 1 个索引 | 3 个索引 | 5 个索引 |
|---|---|---|---|---|
| INSERT 耗时 | 1x | ~1.5x | ~2.5x | ~4x |
| UPDATE 耗时(涉及索引列) | 1x | ~1.8x | ~3x | ~5x |
| DELETE 耗时 | 1x | ~1.5x | ~2.5x | ~4x |
| 磁盘占用 | 1x | ~1.3x | ~1.8x | ~2.5x |
6.2 在"号码助手"中的权衡
当前 app_bindings 表有 3 个索引,对于:
- 批量导入场景(
PasteImportPage):一次插入 20-50 条,每条需维护 3 个索引,可接受 - 批量更新状态场景(
batchUpdateStatus):更新status列,需维护idx_ab_status,可接受 - 每日新增场景:用户每天新增 3-5 条,影响极小
结论:当前 3 个索引的开销在可接受范围内,不建议为了写性能牺牲读性能。
七、LIKE 查询的性能优化
7.1 当前问题
search 方法使用 LIKE '%keyword%',这是一个无法使用索引的查询:
// 当前:前后模糊匹配,无法使用 idx_ab_name
predicates.like('app_name', `%${keyword}%`);
7.2 优化方案
// 方案 A:后缀匹配(可以使用索引)
predicates.like('app_name', `${keyword}%`); // '支付宝%' 可用 idx_ab_name
// 方案 B:前缀匹配 + 全文搜索
predicates.like('app_name', `%${keyword}%`); // 仍然全表扫描
// 或使用 contains 方法(如果数据库支持)
推荐:对搜索功能,权衡用户体验,继续使用 %keyword% 模式,配合限制返回行数:
static async search(keyword: string): Promise<AppBindingEntity[]> {
const store = AppBindingDao.getStore();
const predicates = new relationalStore.RdbPredicates(TABLE);
predicates.like('app_name', `%${keyword}%`);
predicates.orderByDesc('updated_at');
predicates.limit(50); // 最多返回 50 条,控制扫描范围
const rs = await store.query(predicates, COLS);
// ...
}
八、索引最佳实践汇总表
| 最佳实践 | 说明 | 项目中的应用 |
|---|---|---|
| 为 WHERE 条件建索引 | 高频查询的筛选列 | card_id、status、app_name |
| 复合索引消除排序 | 将 ORDER BY 列加入索引 | (card_id, updated_at DESC) |
| 覆盖索引减少回表 | 索引包含查询所需全部列 | (card_id, id) 用于 count |
| 控制索引数量 | 每张表不超过 5-6 个 | 当前 3 个,合理 |
| 监控写性能 | 批量操作场景验证 | 批量导入时测试 |
| 前缀 LIKE 可用索引 | 避免以 % 开头 | 搜索页用 %keyword% + limit |
| 定期分析查询计划 | EXPLAIN QUERY PLAN | 每次新增查询时 |
| 高选择性列优先 | 索引列应具有高区分度 | app_name > card_id > status |
九、实战:数据库优化检查清单
9.1 DAO 层索引审计
对照 AppBindingDao 中的每个方法,确认索引是否被有效使用:
| DAO 方法 | 查询列 | 排序 | 使用索引 | 建议 |
|---|---|---|---|---|
findById |
id (PK) |
无 | ✅ 主键索引 | 无需优化 |
listByCardId |
card_id |
updated_at DESC |
✅ 但需文件排序 | 改为复合索引 |
listByStatus |
status |
updated_at DESC |
✅ 但需文件排序 | 改为复合索引 |
search |
app_name LIKE |
updated_at DESC |
⚠️ 无法使用 | 加 limit |
countByStatus |
status, COUNT |
GROUP BY status |
✅ 覆盖索引 | 无需优化 |
countByCardId |
card_id |
无 | ✅ 可覆盖 | 改为覆盖索引 |
9.2 推荐迁移步骤
// 步骤 1:添加复合索引(消除排序)
const newIndexes = [
// 替代 idx_ab_card
`CREATE INDEX IF NOT EXISTS idx_ab_card_updated
ON app_bindings(card_id, updated_at DESC)`,
// 替代 idx_ab_status
`CREATE INDEX IF NOT EXISTS idx_ab_status_updated
ON app_bindings(status, updated_at DESC)`,
// 覆盖索引用于 countByCardId
`CREATE INDEX IF NOT EXISTS idx_ab_card_covering
ON app_bindings(card_id, id)`
];
// 步骤 2:在新版本升级时添加(DatabaseService.upgrade)
if (fromVersion < 2) {
for (const sql of newIndexes) {
await store.executeSql(sql);
}
// 可选:删除不再需要的旧索引
// await store.executeSql('DROP INDEX IF EXISTS idx_ab_card');
}
// 步骤 3:验证 EXPLAIN QUERY PLAN
await explainQuery(store,
"SELECT * FROM app_bindings WHERE card_id = 1 ORDER BY updated_at DESC");
// 预期: SEARCH USING INDEX idx_ab_card_updated (排序已由索引提供)
小结
| 维度 | 内容 |
|---|---|
| 当前索引 | idx_ab_card、idx_ab_status、idx_ab_name |
| 查询分析 | EXPLAIN QUERY PLAN 识别全表扫描和文件排序 |
| 复合索引 | 消除 ORDER BY 排序,(card_id, updated_at DESC) |
| 覆盖索引 | 减少回表 I/O,(card_id, id) 用于 count 查询 |
| 索引选择性 | app_name > card_id > status |
| 写入代价 | 当前 3 个索引开销可接受 |
| 实践建议 | 复合索引消除排序 + 覆盖索引加速统计 + limit 控制搜索范围 |
如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!
相关资源:
- 开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
- HarmonyOS 关系型数据库官方文档:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/data-relational-store
- SQLite 查询计划文档:https://www.sqlite.org/eqp.html
更多推荐




所有评论(0)