从零开发到工程查询:SQLite 嵌入式数据库与高级 SQL 实战
摘要:SQLite 是世界上最流行的嵌入式数据库,Chrome、微信、Android 都在用它。本文从 SQLite 的定位出发,拆解 Python 和 Node.js 中的使用差异,深入 SQL 查询的逻辑执行顺序与分组聚合技巧,帮你打通从"存数据"到"查数据"的完整链路。
📑 目录
- SQLite 是什么?------ 一个文件就是一个数据库
- SQLite 的核心定位与适用场景
- Python 中使用 SQLite(sqlite3)
- Node.js 中使用 SQLite(better-sqlite3)
- 两种语言使用方式对比
- SQL 查询的逻辑执行顺序
- 聚合函数与 GROUP BY 的"两把锁"规则
- WHERE vs HAVING:过滤行还是过滤组?
- 工程级复杂查询的"积木式拆解"思维
- 互动讨论
SQLite 是什么?------ 一个文件就是一个数据库
平时你写 Node.js 或 Python 脚本时,习惯用 fs.readFileSync('./data.json') 读配置文件,再用 JSON.parse() 翻数据。这种方式的问题是:数据量大了之后,每次都要读整个文件、解析整个 JSON,效率很低。
SQLite 本质上就是把查询语言换成了 SQL,解析器编译进应用里,直接读写一个 .db 文件------没有独立进程,不占端口,不需要安装任何数据库服务。
一句话定位 :SQLite 是一个嵌入式关系型数据库。它不是一个独立的服务器进程,而是一个 C 语言库,应用程序通过函数调用直接操作数据库文件。
生活中的类比
想象你有一个 Excel 文件:
- 用 JSON 读数据就像打开整个 Excel,把所有 Sheet 全部加载到内存再查找
- 用 SQLite 就像给 Excel 配了一个索引目录,问"姓张的有哪些人",目录里直接告诉你答案
SQLite 的核心定位与适用场景
SQLite 的特点
| 特点 | 说明 |
|---|---|
| 嵌入式 | C 语言库,应用通过函数调用直接操作文件,无独立进程 |
| 零配置 | 无端口、无用户、无配置文件,sqlite3.connect('test.db') 即建库 |
| 单写者 | 同时只能一个写者,WAL 模式下读写可并发但写唯一 |
| 功能完整 | 支持 ACID、索引、窗口函数、CTE(公共表表达式) |
常见误区纠正
误区一:"轻量"=功能弱
"轻量"指部署轻(一个文件),不是功能弱。Chrome 用它存百万级书签,Android 用它做系统数据库,都不是"简单场景"。
误区二:适合"数据库逻辑简单"的场景
SQLite 适合"单机 + 低写入并发"的场景,SQL 逻辑可以很复杂------窗口函数、递归 CTE 都支持。
适用场景
| 场景 | 说明 |
|---|---|
| 本地开发 | 替代 Docker 起的 PostgreSQL,启动快,零配置 |
| 单元测试 | :memory: 模式,用完自动销毁 |
| 生产小工具 | 单文件备份,无运维负担 |
| 客户端应用 | 浏览器、手机 App 的本地数据存储 |
| 不适合 | 高并发写场景(如电商订单),写入锁会拖慢响应 |
Python 中使用 SQLite(sqlite3)
Python 标准库自带 sqlite3,无需额外安装。
核心流程
text
sql
连接 → 游标 → 执行 SQL → 提交(DML)→ 关闭
完整示例代码
python
python
import sqlite3
# 1. 连接(自动建库文件)
conn = sqlite3.connect('test.db')
cursor = conn.cursor()
# 2. 建表(DDL 自动提交,但加 commit 更安全)
cursor.execute('''
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
age INTEGER
)
''')
conn.commit()
# 3. 插入(DML 必须 commit)
cursor.execute("INSERT INTO users (name, age) VALUES (?, ?)", ("张三", 25))
conn.commit()
print(f"新增行ID: {cursor.lastrowid}")
# 4. 查询
cursor.execute("SELECT * FROM users WHERE age > ?", (20,))
rows = cursor.fetchall() # 返回元组列表 [(1, '张三', 25)]
for row in rows:
print(f"ID: {row[0]}, Name: {row[1]}, Age: {row[2]}")
# 5. 更新
cursor.execute("UPDATE users SET age = ? WHERE name = ?", (26, "张三"))
conn.commit()
print(f"影响行数: {cursor.rowcount}")
# 6. 删除
cursor.execute("DELETE FROM users WHERE name = ?", ("张三",))
conn.commit()
print(f"删除行数: {cursor.rowcount}")
# 7. 关闭
cursor.close()
conn.close()
关键 API 速查
| API | 作用 |
|---|---|
sqlite3.connect('test.db') |
连接数据库,文件不存在则自动创建 |
conn.cursor() |
创建游标对象 |
cursor.execute(sql, params) |
执行单条 SQL,params 为元组 |
cursor.executemany(sql, list_of_tuples) |
批量插入同模板的多组数据 |
conn.executescript(sql_string) |
执行多条不同 SQL(用分号分隔) |
conn.commit() |
提交事务(DML 必须调用) |
cursor.fetchall() |
获取所有查询结果 |
cursor.lastrowid |
获取最后插入行的 ID |
cursor.rowcount |
获取受影响的行数 |
重要提醒
DML(增删改)必须
conn.commit()才落盘。 DDL(建表等)自动提交,但加commit()更安全。连接关闭时未提交的 DML 会自动回滚。
Node.js 中使用 SQLite(better-sqlite3)
Node.js 生态中最流行的 SQLite 库是 better-sqlite3------同步 API、性能好、手感自然。
安装
bash
npm install better-sqlite3
完整示例代码
typescript
javascript
import Database from 'better-sqlite3';
const db = new Database('test.db');
// 重要:外键默认关闭,每次连接手动打开
db.pragma('foreign_keys = ON');
db.pragma('journal_mode = WAL');
// 建表(exec 跑静态 SQL,不支持占位符)
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
age INTEGER
)
`);
// 插入(prepare + run)
const insertStmt = db.prepare('INSERT INTO users (name, age) VALUES (?, ?)');
const info = insertStmt.run('张三', 25);
console.log(`新增行ID: ${info.lastInsertRowid}`);
// 查询(prepare + all)
const selectStmt = db.prepare('SELECT * FROM users WHERE age > ?');
const rows = selectStmt.all(20); // 直接返回对象数组
console.log(rows);
// 更新
const updateStmt = db.prepare('UPDATE users SET age = ? WHERE name = ?');
const updateInfo = updateStmt.run(26, '张三');
console.log(`影响行数: ${updateInfo.changes}`);
// 删除
const deleteStmt = db.prepare('DELETE FROM users WHERE name = ?');
const deleteInfo = deleteStmt.run('张三');
console.log(`删除行数: ${deleteInfo.changes}`);
db.close();
关键 API 速查
| API | 作用 |
|---|---|
new Database('test.db') |
连接数据库,无游标,直接操作 db |
db.exec(sql_string) |
执行静态 SQL(建表等),不支持占位符 |
db.prepare(sql).run(params) |
执行增删改,返回 { lastInsertRowid, changes } |
db.prepare(sql).all(params) |
查询所有结果,返回对象数组 |
db.prepare(sql).get(params) |
查询单条结果 |
db.close() |
关闭数据库 |
重要提醒
exec不支持占位符,只接纯字符串,有注入风险。 所有带外部输入的操作必须走prepare。
两种语言使用方式对比
| 对比维度 | Python (sqlite3) |
Node.js (better-sqlite3) |
|---|---|---|
| 连接 | sqlite3.connect() |
new Database() |
| 游标 | 需要 cursor |
不需要,直接操作 db |
| 事务 | DML 必须手写 commit() |
每条语句自动提交 |
| 建表 | cursor.execute(DDL) |
db.exec(DDL),不支持占位符 |
| 带参数操作 | cursor.execute(sql, params) |
db.prepare(sql).run/all(params) |
| 结果集 | 元组列表 [(1, '张三')] |
对象数组 [{id, name}] |
| 查询单条 | cursor.fetchone() |
stmt.get(params) |
| 批量插入 | executemany(sql, params_list) |
Transaction + 循环 run |
占位符写法对比
| 语言 | 位置占位符 | 命名占位符 |
|---|---|---|
| Python | ?,传元组 |
:name 或 %(name)s,传字典 |
| Node.js | ?,传数组或展开 |
@name 或 $name,传对象 |
两者都支持两种风格,选一种即可。
SQL 查询的逻辑执行顺序
书写顺序 ≠ 执行顺序。
你写 SQL 的物理顺序是:
sql
vbnet
SELECT → FROM → JOIN → WHERE → GROUP BY → HAVING → ORDER BY → LIMIT
但数据库逻辑上执行的顺序是:
| 执行顺序 | 关键词 | 作用 |
|---|---|---|
| ① | FROM / JOIN |
确定数据来源,做连接生成虚拟表 |
| ② | WHERE |
按条件过滤行(此时没有分组,也没有聚合结果) |
| ③ | GROUP BY |
将过滤后的行按字段分组 |
| ④ | HAVING |
对分组后的结果进行过滤(聚合函数已计算完毕) |
| ⑤ | SELECT |
计算表达式、取别名、去重,生成最终列 |
| ⑥ | ORDER BY |
对结果集排序 |
| ⑦ | LIMIT / OFFSET |
截取部分行 |
常见错误
错误一:在 WHERE 里使用聚合函数
sql
sql
-- ❌ 错误
SELECT user_id, COUNT(*) FROM orders
WHERE COUNT(*) > 3 GROUP BY user_id;
-- ✅ 正确:用 HAVING
SELECT user_id, COUNT(*) FROM orders
GROUP BY user_id
HAVING COUNT(*) > 3;
因为 WHERE 在第 2 步执行,那时还没分组,数据库不知道 COUNT(*) 是多少。
错误二:在 WHERE 里使用 SELECT 中定义的别名
sql
sql
-- ❌ 错误
SELECT SUM(price) AS total FROM orders WHERE total > 100;
-- ✅ 正确:用 HAVING(如果是聚合字段)
SELECT SUM(price) AS total FROM orders HAVING total > 100;
SELECT 在第 5 步才执行,别名对第 2 步的 WHERE 来说不存在。
聚合函数与 GROUP BY 的"两把锁"规则
聚合函数速查
| 函数 | 作用 | 注意 |
|---|---|---|
COUNT(*) |
统计总行数 | 包含 NULL 值行 |
COUNT(column) |
统计该列非 NULL 的行数 | 自动忽略 NULL |
COUNT(DISTINCT column) |
统计去重后的非 NULL 值个数 | 常用在"有多少种"场景 |
SUM(column) |
求和 | NULL 被忽略(当作 0) |
AVG(column) |
求平均 | NULL 不参与分子分母计算 |
MAX / MIN |
最大/最小值 | 字符串也可比较(按字典序) |
GROUP BY 的"两把锁"规则
执行 GROUP BY 之后,SELECT 后面只能写两种东西:
- 分组字段本身 (即
GROUP BY后面写的列) - 聚合函数 (如
SUM(amount)、COUNT(*))
为什么? 分组后,每个组里可能有多行原始数据。如果写一个既不是分组字段也不是聚合函数的列,数据库不知道该取组里哪一行的值。
sql
sql
-- ❌ 错误:user_id 不是分组字段也不是聚合函数
SELECT user_id, category, SUM(amount)
FROM orders
GROUP BY user_id;
-- ✅ 正确:加 category 到 GROUP BY,或用聚合函数包裹
SELECT user_id, MAX(category) AS category, SUM(amount)
FROM orders
GROUP BY user_id;
WHERE vs HAVING:过滤行还是过滤组?
一句话区分:WHERE 过滤"行",HAVING 过滤"组"。
| 维度 | WHERE |
HAVING |
|---|---|---|
| 执行时机 | 分组前(第2步) | 分组后(第4步) |
| 能否用聚合函数 | ❌ 不能 | ✅ 能 |
| 能否用别名 | ❌ 不能 | ❌ 不能(取决于数据库) |
| 能否走索引 | ✅ 能(快) | ❌ 基本不能(慢) |
工程原则 :能用 WHERE 提前过滤掉的,绝不留到 HAVING 里。
比如"只统计已完成的订单"------一定写在 WHERE status = 'completed',而不是写在 HAVING 里。因为 WHERE 在第 2 步就把无效数据筛掉了,后续 GROUP BY 和 HAVING 处理的数据量更小,性能更好。
工程级复杂查询的"积木式拆解"思维
遇到复杂业务需求,按顺序一步步搭:
需求示例:统计 2026 年每个月、每个产品类别的总销售额,且只展示月销售额 > 1 万的,按月份倒序、销售额倒序排列。
积木式搭建过程:
sql
sql
SELECT
DATE_FORMAT(order_date, '%Y-%m') AS month,
category,
SUM(amount) AS total_sales,
COUNT(DISTINCT user_id) AS unique_buyers
FROM orders
WHERE order_date BETWEEN '2026-01-01' AND '2026-12-31'
AND status = 'completed' -- ① WHERE 提前过滤无效单,减少分组负担
GROUP BY month, category -- ② 按月、类别分组
HAVING SUM(amount) > 10000 -- ③ 用聚合结果过滤组
ORDER BY month DESC, total_sales DESC; -- ④ 排序
积木式步骤:
- 先写
FROM和JOIN:确认需要哪几张表,怎么关联 - 再写
WHERE:把业务上"哪些行不需要"的过滤条件全加上 - 定
GROUP BY:按什么维度统计(时间?类别?用户?) - 写
SELECT聚合表达式:要算什么指标(总和?去重人数?平均值?) - 写
HAVING:对统计结果有什么门槛要求 - 最后
ORDER BY+LIMIT:排序和分页
互动讨论
💬 SQLite 能用在生产环境吗?
可以。Chrome、Firefox、Android、iOS 都在生产环境使用 SQLite。单机应用、桌面软件、移动 App 的本地存储,SQLite 是首选。不适用的是"高并发写"场景(如电商订单系统)。
💬 Python 和 Node.js 的 SQLite 库,哪个更好?
取决于项目。如果项目本身是 Python 后端,直接用标准库 sqlite3 无需额外依赖。如果是 Node.js 项目,better-sqlite3 性能更好、手感更自然,同步 API 在 SQLite 这种低延迟场景下完全够用。
💬 exec 和 prepare 有什么区别?
exec 执行静态 SQL,不支持占位符,只能用于 DDL(建表)或不含变量的 SQL。prepare 支持占位符,所有带外部输入的操作必须走 prepare,防止 SQL 注入。
💬 COUNT(*) 和 COUNT(1) 性能有区别吗?
没有。现代数据库优化器已完全等价,按习惯写 COUNT(*) 即可。
💬 WHERE 和 HAVING 的工程原则是什么?
能用 WHERE 不用 HAVING。 因为 WHERE 能在分组前过滤掉大量数据,减少分组和聚合的计算量。HAVING 是分组后的过滤,数据已经汇总完了,此时过滤成本更高。