032-关系型数据库在记账应用中的建模与查询优化

关系型数据库在记账应用中的建模与查询优化

一、引言

记账应用的核心是数据的录入、存储和统计分析,关系型数据库(RDB)是实现这些功能的基础。合理的数据库表结构设计能保证数据的一致性、查询的高效性和扩展的灵活性。鸿蒙 RDB 基于 SQLite 引擎,支持标准的 SQL 语法和事务管理。MoneyTrack 通过 AccountingDB 实现了账单、资产、分类等核心数据的持久化,并通过聚合查询实现了月度统计、分类汇总等数据分析功能。

二、核心知识点

2.1 表结构设计 --- ER 关系图

良好的表结构是数据库性能的基石。记账应用的核心表通过外键形成清晰的关联关系:
#mermaid-svg-guYNjhuT1SUtiGKh{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-guYNjhuT1SUtiGKh .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-guYNjhuT1SUtiGKh .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-guYNjhuT1SUtiGKh .error-icon{fill:#552222;}#mermaid-svg-guYNjhuT1SUtiGKh .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-guYNjhuT1SUtiGKh .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-guYNjhuT1SUtiGKh .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-guYNjhuT1SUtiGKh .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-guYNjhuT1SUtiGKh .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-guYNjhuT1SUtiGKh .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-guYNjhuT1SUtiGKh .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-guYNjhuT1SUtiGKh .marker{fill:#333333;stroke:#333333;}#mermaid-svg-guYNjhuT1SUtiGKh .marker.cross{stroke:#333333;}#mermaid-svg-guYNjhuT1SUtiGKh svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-guYNjhuT1SUtiGKh p{margin:0;}#mermaid-svg-guYNjhuT1SUtiGKh .entityBox{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-guYNjhuT1SUtiGKh .relationshipLabelBox{fill:hsl(80, 100%, 96.2745098039%);opacity:0.7;background-color:hsl(80, 100%, 96.2745098039%);}#mermaid-svg-guYNjhuT1SUtiGKh .relationshipLabelBox rect{opacity:0.5;}#mermaid-svg-guYNjhuT1SUtiGKh .labelBkg{background-color:rgba(248.6666666666, 255, 235.9999999999, 0.5);}#mermaid-svg-guYNjhuT1SUtiGKh .edgeLabel .label{fill:#9370DB;font-size:14px;}#mermaid-svg-guYNjhuT1SUtiGKh .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-guYNjhuT1SUtiGKh .edge-pattern-dashed{stroke-dasharray:8,8;}#mermaid-svg-guYNjhuT1SUtiGKh .node rect,#mermaid-svg-guYNjhuT1SUtiGKh .node circle,#mermaid-svg-guYNjhuT1SUtiGKh .node ellipse,#mermaid-svg-guYNjhuT1SUtiGKh .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-guYNjhuT1SUtiGKh .relationshipLine{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-guYNjhuT1SUtiGKh .marker{fill:none!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-guYNjhuT1SUtiGKh .edgeLabel{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-guYNjhuT1SUtiGKh .edgeLabel .label rect{fill:rgba(232,232,232, 0.8);}#mermaid-svg-guYNjhuT1SUtiGKh .edgeLabel .label text{fill:#333;}#mermaid-svg-guYNjhuT1SUtiGKh :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 分类
资产
成员
categories
int
id
PK
string
name
string
icon
int
type
0=收入, 1=支出
transactions
int
id
PK
real
amount
int
type
0=收入, 1=支出
int
category_id
FK
int
account_id
FK
string
member_id
FK
string
date
string
note
accounts
int
id
PK
string
name
int
type
0=现金, 1=银行卡, ...
real
init_balance
real
current_balance
int
sort_order
members
int
id
PK
string
name
string
avatar

表之间通过外键关联:transactions.categoryId → categories.idtransactions.accountId → accounts.idtransactions.memberId → members.id

typescript 复制代码
const CREATE_TRANSACTIONS_TABLE = `
  CREATE TABLE IF NOT EXISTS transactions (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    amount REAL NOT NULL,
    type INTEGER NOT NULL,
    category_id INTEGER,
    account_id INTEGER,
    member_id TEXT,
    date TEXT NOT NULL,
    note TEXT,
    FOREIGN KEY (category_id) REFERENCES categories(id),
    FOREIGN KEY (account_id) REFERENCES accounts(id)
  )
`

2.2 CRUD 操作

AccountingDB 封装了完整的 CRUD 操作:

  • Createinsert(transaction) 插入新账单
  • Readquery(selection, args) 按条件查询
  • Updateupdate(transaction) 更新账单信息
  • DeletedeleteTransaction(id) 删除指定账单

所有操作通过 RdbStore 实例的 insert / query / update / delete 方法执行。

2.3 进阶 SQL 查询示例

除了基本的 CRUD,记账应用中还需要大量进阶查询:

LEFT JOIN 关联分类表查询

typescript 复制代码
// 关联查询:账单列表带分类名称和图标
const sql = `
  SELECT t.*, c.name as category_name, c.icon as category_icon
  FROM transactions t
  LEFT JOIN categories c ON t.category_id = c.id
  WHERE t.date BETWEEN ? AND ?
  ORDER BY t.date DESC, t.id DESC
`
const result = await this.store.querySql(sql, ['2026-01-01', '2026-01-31'])

WHERE LIKE 模糊搜索备注

typescript 复制代码
// 按备注模糊搜索
const searchSql = `
  SELECT * FROM transactions
  WHERE note LIKE ?
  ORDER BY date DESC
  LIMIT 20
`
const result = await this.store.querySql(searchSql, ['%' + keyword + '%'])

ORDER BY 多字段排序

typescript 复制代码
// 先按日期降序,同一天按金额降序,再按 id 降序
const sql = `
  SELECT * FROM transactions
  WHERE account_id = ?
  ORDER BY date DESC, amount DESC, id DESC
`

2.4 聚合查询

聚合查询是记账应用数据分析的核心能力,通过 SQL 聚合函数实现:

typescript 复制代码
// 按月统计收支
const sql = `
  SELECT strftime('%Y-%m', date) as month,
         SUM(CASE WHEN type = 0 THEN amount ELSE 0 END) as income,
         SUM(CASE WHEN type = 1 THEN amount ELSE 0 END) as expense
  FROM transactions
  GROUP BY month
  ORDER BY month DESC
`

2.5 事务管理

在批量操作中,使用事务保证数据一致性。事务将多个数据库操作合并为一个原子操作,要么全部成功,要么全部回滚。

typescript 复制代码
deleteTransactions(ids: number[]): Promise<void> {
  return new Promise((resolve, reject) => {
    this.store.beginTransaction()
    try {
      ids.forEach(id => {
        this.store.delete(this.TABLE_NAME, 'id = ?', [id])
      })
      this.store.commit()
      resolve()
    } catch (err) {
      this.store.rollback()
      reject(err)
    }
  })
}

三、索引优化

索引是提升查询性能最有效的手段之一。在常用查询条件的字段上创建索引,可以大幅减少数据扫描范围:

创建索引的 SQL 语句:

typescript 复制代码
const CREATE_INDEXES = [
  `CREATE INDEX IF NOT EXISTS idx_transactions_date
   ON transactions(date)`,

  `CREATE INDEX IF NOT EXISTS idx_transactions_category
   ON transactions(category_id)`,

  `CREATE INDEX IF NOT EXISTS idx_transactions_account
   ON transactions(account_id)`,

  `CREATE INDEX IF NOT EXISTS idx_transactions_type_date
   ON transactions(type, date)`
]

索引优化原则:

原则 说明 示例
高频查询字段优先 WHERE 中最常出现的字段 date, category_id
复合索引匹配查询顺序 WHERE 条件中字段的顺序应与索引一致 (type, date) 联合索引
避免过多索引 每个索引都会增加写入开销 控制在 5-10 个以内
覆盖索引 查询的所有字段都在索引中 无需回表查询

四、查询分析(PRAGMA)

SQLite 提供了 PRAGMA 命令用于查询性能分析,帮助开发者定位慢查询:

typescript 复制代码
// 使用 EXPLAIN QUERY PLAN 分析查询计划
const explainSql = `
  EXPLAIN QUERY PLAN
  SELECT * FROM transactions
  WHERE date >= '2026-01-01'
    AND category_id = 5
  ORDER BY date DESC
`
const plan = await this.store.querySql(explainSql)
// 输出显示是否使用了索引、扫描类型等

// 获取表信息
const tableInfo = await this.store.querySql('PRAGMA table_info(transactions)')

// 获取索引列表
const indexList = await this.store.querySql('PRAGMA index_list(transactions)')

// 分析查询性能(需在查询前开启)
await this.store.executeSql('PRAGMA synchronous = NORMAL')
await this.store.executeSql('PRAGMA cache_size = -8000')  // 8MB 缓存

五、分页查询完整实现

当账单数据量达到数千条时,一次性加载所有数据会影响性能和内存占用。分页查询通过 LIMITOFFSET 实现按需加载:

typescript 复制代码
class PagedQueryManager {
  private readonly PAGE_SIZE = 20

  async loadTransactionsPage(
    accountId: number,
    page: number
  ): Promise<{ data: Transaction[], hasMore: boolean }> {
    const offset = (page - 1) * this.PAGE_SIZE

    const sql = `
      SELECT t.*, c.name as category_name, c.icon as category_icon
      FROM transactions t
      LEFT JOIN categories c ON t.category_id = c.id
      WHERE t.account_id = ?
      ORDER BY t.date DESC, t.id DESC
      LIMIT ? OFFSET ?
    `

    const result = await this.store.querySql(sql, [
      accountId.toString(),
      (this.PAGE_SIZE + 1).toString(), // 多查一条判断是否有更多
      offset.toString()
    ])

    const hasMore = result.rowCount > this.PAGE_SIZE
    const data: Transaction[] = []

    for (let i = 0; i < Math.min(result.rowCount, this.PAGE_SIZE); i++) {
      data.push(this.mapToTransaction(result, i))
    }

    return { data, hasMore }
  }
}

六、查询优化策略总结

  1. 索引优化:在 date、category_id、account_id 等常用查询字段上创建索引
  2. 分页查询 :使用 LIMIT ? OFFSET ? 实现分页加载,避免一次加载过多数据
  3. 字段选择 :只 SELECT 需要的字段,避免 SELECT *
  4. 查询分析 :使用 EXPLAIN QUERY PLAN 验证索引是否生效
  5. 连接优化:LEFT JOIN 时确保关联字段有索引
  6. 缓存策略:对不频繁变更的统计结果(如月度汇总)做内存缓存

七、参考文档

相关推荐
foolishlee1 小时前
openGauss慢SQL记录相关线程
数据库
空中湖1 小时前
Spring AI 多模型接入实战:OpenAI、Ollama、通义千问切换只需改配置
java·人工智能·spring
tianyatest2 小时前
表格分类统计及排序
java·excel·暖通
数行拙笔2 小时前
Redis---list类型
数据库·redis·缓存
万亿少女的梦1682 小时前
基于Spring Boot与MySQL的乡镇群众服务系统设计与实现
java·spring boot·mysql·权限管理·前后端分离
thefool1122662 小时前
Java 面向对象
java
2501_948106912 小时前
计算机毕业设计之jsp-智慧旅游分享平台
java·开发语言·spark·汽车·课程设计·旅游
阿里云云原生3 小时前
Agent 不再是“玩具”!AgentScope Java 2.0 GA 发布:构建企业级分布式智能体底座
java·开发语言·分布式·agentscope
4154113 小时前
MyBatis-Plus + PostGIS 实战(四):GeoJSON 序列化与前端地图对接,全局 WKT + 局部 GeoJSON,两种格式优雅共存
java·mybatis·postgis·geojson