鸿蒙应用开发实战【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 索引的代价

每增加一个索引,INSERTUPDATEDELETE 操作都需要维护索引 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_idstatusapp_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_cardidx_ab_statusidx_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 控制搜索范围

如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!


相关资源:

Logo

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

更多推荐