Node.js SQLite 数据库:从安装到查询 – wiki基地


Node.js 与 SQLite 数据库:从安装到查询的深度指南

在现代 Web 开发和应用程序构建中,数据库是不可或缺的核心组件。对于许多项目,特别是中小型应用、原型设计、桌面应用(如使用 Electron)或嵌入式系统,并非总是需要重量级的、基于服务器的数据库系统(如 PostgreSQL 或 MySQL)。在这种场景下,SQLite 以其轻量级、零配置、基于文件的特性脱颖而出。结合 Node.js 的高效异步 I/O 和庞大的生态系统,Node.js + SQLite 成为一个强大而便捷的技术组合。

本文将深入探讨如何在 Node.js 环境中使用 SQLite 数据库,涵盖从环境准备、驱动选择、安装、数据库连接、表结构定义(Schema)、数据增删改查(CRUD)操作、事务处理,到一些最佳实践和注意事项,旨在为您提供一份全面而实用的指南。

一、 SQLite 简介:轻量级数据库的魅力

SQLite 是一个 C 语言库,它实现了一个自包含的(self-contained)、无服务器的(serverless)、零配置的(zero-configuration)、事务性的(transactional)SQL 数据库引擎。它的主要特点包括:

  1. 文件即数据库: SQLite 将整个数据库(包括定义、表、索引和数据本身)存储在一个单一的跨平台文件中。这使得数据库的部署、备份和共享变得极其简单——只需复制文件即可。
  2. 零配置: 无需安装独立的数据库服务器进程,也无需进行复杂的配置。应用程序直接通过库函数与数据库文件交互。
  3. 自包含: 依赖性极小,通常只需要 SQLite 库本身。
  4. 事务性: 支持 ACID(原子性、一致性、隔离性、持久性)事务,确保数据操作的可靠性,即使在程序崩溃或系统断电的情况下也能保证数据库的完整性。
  5. 标准 SQL 支持: 支持大部分 SQL92 标准,提供了丰富的查询能力。
  6. 轻量级: 核心引擎代码量小,内存占用低,非常适合资源受限的环境。

局限性:
* 并发写入性能: SQLite 在同一时间只允许一个写入者。虽然读取可以并发进行,但在高并发写入场景下性能会受限。
* 网络访问: 它不是一个基于客户端/服务器模型的数据库,不适合需要通过网络被多个客户端直接访问的场景(尽管可以通过应用层封装实现)。
* 功能集: 相较于大型数据库系统,可能缺少一些高级特性(如存储过程、复杂的权限管理等)。

二、 Node.js 与 SQLite 的结合点

Node.js 是一个基于 Chrome V8 引擎的 JavaScript 运行时环境,以其非阻塞 I/O 和事件驱动的特性,非常适合构建高性能的网络应用和后端服务。将 Node.js 与 SQLite 结合使用的优势场景包括:

  • 快速原型开发: 快速搭建具有数据持久化功能的应用程序原型,无需配置数据库服务器。
  • 小型 Web 应用/API: 对于用户量不大、并发写入要求不高的网站或 API 服务,SQLite 是一个简单、成本效益高的选择。
  • 桌面应用程序: 使用 Electron、NW.js 等框架构建的桌面应用,可以将 SQLite 作为本地数据存储方案。
  • 移动应用后端(特定场景): 某些架构下,移动应用可能与一个简单的 Node.js 后端同步数据,后端使用 SQLite。
  • 测试环境: 在单元测试或集成测试中,使用内存模式或文件模式的 SQLite 可以快速创建和销毁测试数据库。
  • 数据处理脚本: 对于需要处理存储在本地文件中的结构化数据的 Node.js 脚本,SQLite 提供强大的查询和操作能力。

三、 环境准备与驱动选择

1. 前提条件:
* Node.js 和 npm/yarn: 确保你的开发环境中已安装 Node.js(建议使用 LTS 版本)及其包管理器 npm 或 yarn。你可以通过在终端运行 node -vnpm -v (或 yarn -v) 来检查。

2. Node.js SQLite 驱动:
要在 Node.js 中操作 SQLite,你需要一个相应的驱动程序(库)。社区中有几个流行的选择,最主要的有两个:

  • sqlite3:
    • 特点: 这是最早也是广泛使用的驱动之一。它提供了异步 API,与 Node.js 的异步模型更为契合。操作(如查询、执行)通常接受回调函数或返回 Promise(取决于 API 使用方式)。
    • 优点: 异步操作不会阻塞 Node.js 的事件循环,适合 I/O 密集型操作。社区成熟,文档资源相对丰富。
    • 缺点: 异步回调的嵌套(回调地狱)可能使代码变得复杂,尽管 Promises 和 async/await 可以缓解这一点。安装时需要编译 C++ 绑定,有时在特定环境下可能遇到编译问题。
  • better-sqlite3:
    • 特点: 这个库的设计哲学不同,它提供了完全同步的 API。这意味着数据库操作会阻塞当前的执行流程,直到操作完成。
    • 优点: API 设计简洁明了,代码通常更易于编写和理解,避免了异步带来的复杂性。性能通常优于 sqlite3,尤其是在大量小查询的场景下,因为它减少了异步调度的开销。安装通常更顺畅。
    • 缺点: 同步操作会阻塞 Node.js 的事件循环。如果执行长时间运行的查询或事务,可能会影响应用程序的响应能力。对于需要高并发处理或长时间查询的应用,需要谨慎使用,或将其放在 Worker Threads 中运行。

选择哪个?
* 如果你的应用主要是简单的 CRUD 操作,查询执行时间短,或者你更偏好简洁直观的同步代码,并且能接受短时间的阻塞,better-sqlite3 是一个非常好的选择,通常也是性能更优的选择。
* 如果你的应用需要处理可能长时间运行的查询,或者你严格遵循 Node.js 的非阻塞哲学,并且对处理异步代码(Callbacks, Promises, async/await)没有障碍,sqlite3 是一个可靠的选择。

本文后续示例将主要使用 better-sqlite3,因为它在许多常见场景下提供了更好的开发体验和性能。

3. 安装驱动:
在你的 Node.js 项目目录下,打开终端,运行以下命令安装 better-sqlite3

“`bash
npm install better-sqlite3

或者使用 yarn

yarn add better-sqlite3

“`

安装过程会自动下载预编译的二进制文件(如果可用),或者尝试在本地编译。

四、 连接到 SQLite 数据库

使用 better-sqlite3 连接(或创建)数据库非常简单。

“`javascript
// main.js
const Database = require(‘better-sqlite3’);
let db;

try {
// 尝试连接到名为 ‘mydatabase.db’ 的文件。
// 如果文件不存在,better-sqlite3 会自动创建它。
db = new Database(‘mydatabase.db’, { verbose: console.log }); // ‘verbose’ 选项会在控制台打印执行的 SQL 语句,便于调试

console.log(‘成功连接到 SQLite 数据库 mydatabase.db’);

// 在这里执行数据库操作…
// 例如: 创建表、插入数据、查询等

} catch (err) {
console.error(‘数据库连接或操作失败:’, err.message);
} finally {
// 无论成功还是失败,确保在程序结束前关闭数据库连接
if (db) {
db.close((err) => {
if (err) {
return console.error(‘关闭数据库连接失败:’, err.message);
}
console.log(‘数据库连接已关闭。’);
});
}
}

// 注意: 在实际应用中,数据库连接通常在应用启动时建立,
// 在应用关闭时释放,而不是像上面这样立即关闭。
// 对于长时间运行的服务,db 实例会被保持并在需要时重用。
“`

内存数据库:
如果想创建一个临时的、只存在于内存中的数据库(程序结束后数据丢失),可以将文件名替换为 :memory:

javascript
const db = new Database(':memory:', { verbose: console.log });
console.log('成功连接到内存 SQLite 数据库');

这对于测试或临时数据处理非常有用。

五、 定义数据库模式 (Schema) – 创建表

数据库模式定义了数据的结构。在 SQLite 中,这通常意味着创建表(Table)。使用 better-sqlite3exec() 方法可以执行不返回结果集的 SQL 语句,如 CREATE TABLE

“`javascript
// … 接上面的连接代码 …

try {
// 检查 users 表是否存在,如果不存在则创建
const createTableSql = CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT UNIQUE,
age INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
;
db.exec(createTableSql);
console.log(“表 ‘users’ 已检查/创建。”);

// 可以创建更多的表…
const createPostsTableSql = CREATE TABLE IF NOT EXISTS posts (
post_id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
title TEXT NOT NULL,
content TEXT,
published_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
;
db.exec(createPostsTableSql);
console.log(“表 ‘posts’ 已检查/创建。”);

} catch (err) {
console.error(‘创建表失败:’, err.message);
}

// … 后续操作 …
“`

解释:
* CREATE TABLE IF NOT EXISTS: 这个语句确保如果表已经存在,则不会尝试重新创建,避免了错误。
* 数据类型: SQLite 支持几种基本数据类型:INTEGER, REAL (浮点数), TEXT (字符串), BLOB (二进制数据), NULL。它使用动态类型系统,但定义类型有助于明确意图和约束。
* PRIMARY KEY AUTOINCREMENT: id 列是主键,并且其值会自动递增生成。
* NOT NULL: 确保 name 列不能存储 NULL 值。
* UNIQUE: 确保 email 列的值是唯一的。
* DEFAULT CURRENT_TIMESTAMP: 如果插入时未提供 created_at 值,则默认为当前时间戳。
* FOREIGN KEY: 定义了 posts 表的 user_id 列与 users 表的 id 列之间的外键关系。ON DELETE CASCADE 表示如果 users 表中的某条记录被删除,那么 posts 表中引用该用户的所有帖子也会被自动删除。

六、 CRUD 操作:数据的增删改查

better-sqlite3 的核心在于 Prepared Statements(预处理语句)。这是执行 SQL 操作(特别是包含用户输入时)推荐的方式,因为它:
1. 防止 SQL 注入攻击: 参数是分开传递的,而不是直接拼接到 SQL 字符串中。
2. 提高性能: SQL 语句只需被数据库解析一次,之后可以用不同的参数值多次执行。

1. 创建 (Create) – 插入数据 (INSERT)

使用 prepare() 方法创建一个预处理语句对象,然后使用 run() 方法执行它。run() 用于执行不返回数据行(或只关心元信息)的语句,如 INSERT, UPDATE, DELETE

“`javascript
try {
// 准备 INSERT 语句
const insertUserSql = ‘INSERT INTO users (name, email, age) VALUES (?, ?, ?)’;
const stmt = db.prepare(insertUserSql);

// 执行单条插入
const info1 = stmt.run(‘Alice Wonderland’, ‘[email protected]’, 30);
console.log(‘插入用户 Alice:’, info1); // info 对象包含 { changes: 1, lastInsertRowid: 1 } (假设是第一条插入)

// 执行另一条插入
const info2 = stmt.run(‘Bob The Builder’, ‘[email protected]’, 45);
console.log(‘插入用户 Bob:’, info2); // { changes: 1, lastInsertRowid: 2 }

// 插入帖子,引用刚插入的用户 Bob (假设其 ID 是 2)
const insertPostSql = ‘INSERT INTO posts (user_id, title, content) VALUES (?, ?, ?)’;
const postStmt = db.prepare(insertPostSql);
const postInfo = postStmt.run(info2.lastInsertRowid, ‘My First Post’, ‘This is the content of my first post!’);
console.log(‘插入帖子:’, postInfo);

// 插入多条数据 (通常在事务中进行,见下文)
const usersToInsert = [
{ name: ‘Charlie Chaplin’, email: ‘[email protected]’, age: null },
{ name: ‘Diana Prince’, email: ‘[email protected]’, age: 28 },
];

// 重用 stmt 插入多条记录
for (const user of usersToInsert) {
stmt.run(user.name, user.email, user.age);
}
console.log(批量插入了 ${usersToInsert.length} 个用户。);

} catch (err) {
console.error(‘插入数据失败:’, err.message);
// 如果 email UNIQUE 约束失败,这里会捕获到错误
}
“`

  • ? 是参数占位符。
  • stmt.run(...) 的参数按顺序对应 SQL 语句中的 ?
  • run() 返回一个 info 对象,包含 changes(受影响的行数)和 lastInsertRowid(最后插入行的 ROWID)。

2. 读取 (Read) – 查询数据 (SELECT)

查询数据通常使用 prepare() 创建语句,然后根据需要选择不同的执行方法:

  • get(): 获取查询结果的第一行。如果没有匹配的行,返回 undefined
  • all(): 获取所有匹配的行,以数组形式返回。如果没匹配到,返回空数组 []
  • iterate(): 返回一个迭代器,可以逐行处理结果,适用于结果集非常大的情况,避免一次性加载所有数据到内存。

“`javascript
try {
// 查询:获取所有用户
console.log(‘\n— 所有用户 —‘);
const selectAllUsersSql = ‘SELECT id, name, email, age FROM users ORDER BY name ASC’;
const stmtAll = db.prepare(selectAllUsersSql);
const allUsers = stmtAll.all(); // 获取所有结果
console.log(allUsers);

// 查询:获取特定用户 (使用参数绑定)
console.log(‘\n— 查询特定用户 (ID=1) —‘);
const selectUserByIdSql = ‘SELECT id, name, email, age FROM users WHERE id = ?’;
const stmtUserById = db.prepare(selectUserByIdSql);
const user = stmtUserById.get(1); // 获取 ID 为 1 的用户
if (user) {
console.log(user);
} else {
console.log(‘未找到 ID 为 1 的用户’);
}

// 查询:获取年龄大于指定值的用户
console.log(‘\n— 年龄大于 35 的用户 —‘);
const selectUsersByAgeSql = ‘SELECT name, age FROM users WHERE age > ?’;
const stmtUsersByAge = db.prepare(selectUsersByAgeSql);
const olderUsers = stmtUsersByAge.all(35); // 获取 age > 35 的用户
console.log(olderUsers);

// 查询:获取用户的帖子数量 (聚合查询)
console.log(‘\n— 用户帖子数量 —‘);
const selectPostCountSql = SELECT u.name, COUNT(p.post_id) as post_count
FROM users u
LEFT JOIN posts p ON u.id = p.user_id
GROUP BY u.id
ORDER BY post_count DESC;
;
const stmtPostCount = db.prepare(selectPostCountSql);
const userPostCounts = stmtPostCount.all();
console.log(userPostCounts);

// 查询:使用迭代器处理大量数据 (示例)
console.log(‘\n— 使用迭代器遍历用户 —‘);
const stmtIterate = db.prepare(‘SELECT name FROM users’);
for (const row of stmtIterate.iterate()) {
console.log(- ${row.name});
}

} catch (err) {
console.error(‘查询数据失败:’, err.message);
}
“`

3. 更新 (Update) – 修改数据 (UPDATE)

使用 prepare()run() 方法,类似于 INSERTrun() 返回的 info 对象中的 changes 属性表示有多少行被更新。

``javascript
try {
// 更新:修改 Alice 的年龄
console.log('\n--- 更新 Alice 的年龄 ---');
const updateUserAgeSql = 'UPDATE users SET age = ? WHERE name = ?';
const stmtUpdate = db.prepare(updateUserAgeSql);
const info = stmtUpdate.run(31, 'Alice Wonderland');
console.log(
更新操作影响了 ${info.changes} 行。`);

// 再次查询 Alice 验证
const stmtFindAlice = db.prepare(‘SELECT name, age FROM users WHERE name = ?’);
const updatedAlice = stmtFindAlice.get(‘Alice Wonderland’);
console.log(‘更新后的 Alice:’, updatedAlice);

} catch (err) {
console.error(‘更新数据失败:’, err.message);
}
“`

4. 删除 (Delete) – 移除数据 (DELETE)

同样使用 prepare()run()changes 表示删除了多少行。

``javascript
try {
// 删除:删除名为 'Charlie Chaplin' 的用户
console.log('\n--- 删除 Charlie Chaplin ---');
const deleteUserSql = 'DELETE FROM users WHERE name = ?';
const stmtDelete = db.prepare(deleteUserSql);
const info = stmtDelete.run('Charlie Chaplin');
console.log(
删除操作影响了 ${info.changes} 行。`);

// 验证删除 (查询应该返回 undefined)
const stmtFindCharlie = db.prepare(‘SELECT name FROM users WHERE name = ?’);
const deletedUser = stmtFindCharlie.get(‘Charlie Chaplin’);
if (!deletedUser) {
console.log(‘Charlie Chaplin 已成功删除。’);
}

} catch (err) {
console.error(‘删除数据失败:’, err.message);
}
“`

七、 事务 (Transactions)

事务用于将一系列数据库操作作为一个原子单元来执行。要么所有操作都成功提交(Commit),要么在发生错误时全部回滚(Rollback),恢复到事务开始前的状态。这对于维护数据一致性至关重要,特别是在执行多个相互依赖的操作时(如银行转账)。

better-sqlite3 提供了非常方便的事务处理方式:db.transaction() 方法。

“`javascript
// 假设我们要添加一个新用户及其第一篇帖子,这两个操作要么都成功,要么都失败

// 创建一个可重用的事务函数
const addUserAndFirstPost = db.transaction((userData, postData) => {
// 1. 插入用户
const insertUserStmt = db.prepare(‘INSERT INTO users (name, email, age) VALUES (?, ?, ?)’);
const userInfo = insertUserStmt.run(userData.name, userData.email, userData.age);

// 如果需要,可以在事务内部进行检查
if (!userInfo.lastInsertRowid) {
throw new Error(‘未能插入用户,无法获取用户ID’); // 抛出错误会自动触发回滚
}

// 2. 插入帖子,使用新用户的 ID
const insertPostStmt = db.prepare(‘INSERT INTO posts (user_id, title, content) VALUES (?, ?, ?)’);
insertPostStmt.run(userInfo.lastInsertRowid, postData.title, postData.content);

// 如果事务函数成功执行完毕(没有抛出错误),事务会自动提交
// 如果函数执行过程中抛出任何错误,事务会自动回滚

// 可以选择性地从事务函数返回一些信息
return { userId: userInfo.lastInsertRowid, message: ‘用户和帖子成功添加’ };
});

try {
console.log(‘\n— 使用事务添加用户 Eve 及其帖子 —‘);
const result = addUserAndFirstPost(
{ name: ‘Eve The Intern’, email: ‘[email protected]’, age: 22 },
{ title: ‘My Onboarding Experience’, content: ‘It was great!’ }
);
console.log(result.message, 用户ID: ${result.userId});

// 尝试一个会失败的事务 (例如,插入一个 email 已存在的用户)
console.log(‘\n— 尝试添加重复 email 的用户 (应失败并回滚) —‘);
addUserAndFirstPost(
{ name: ‘Another Alice’, email: ‘[email protected]’, age: 25 }, // email 重复
{ title: ‘This should not be inserted’, content: ‘…’ }
);

} catch (err) {
// 事务中抛出的错误会被这里捕获
console.error(‘事务执行失败:’, err.message);
// 可以验证数据是否回滚 (Alice 的帖子不应该存在)
const stmtFindPost = db.prepare(‘SELECT title FROM posts WHERE title = ?’);
const possiblyInsertedPost = stmtFindPost.get(‘This should not be inserted’);
if (!possiblyInsertedPost) {
console.log(‘验证:事务已成功回滚,失败的帖子未插入。’);
}
}
“`

db.transaction() 的优点:
* 自动管理: 它会自动处理 BEGIN TRANSACTION, COMMIT, ROLLBACK 语句。
* 错误处理: 事务函数中抛出的任何异常都会导致事务回滚。
* 性能: 对于大量连续的写入操作(如批量插入),将它们包裹在单个事务中可以显著提高性能,因为 SQLite 只需执行一次文件锁定和日志同步。

八、 进阶概念与最佳实践

  1. 索引 (Indexes): 对于经常用于 WHERE 子句、JOIN 条件或 ORDER BY 的列,创建索引可以极大提高查询性能。
    sql
    CREATE INDEX idx_users_email ON users (email);
    CREATE INDEX idx_posts_user_id ON posts (user_id);

    使用 db.exec() 执行 CREATE INDEX 语句。注意,索引会增加写操作的开销和数据库文件的大小。

  2. 错误处理: 始终对数据库操作使用 try...catch 块进行包裹,特别是对于可能因约束(如 UNIQUE)而失败的操作。检查 better-sqlite3 抛出的错误对象的 code 属性(如 SQLITE_CONSTRAINT_UNIQUE)可以进行更细粒度的错误处理。

  3. 关闭连接: 确保在应用程序退出前调用 db.close()。对于长时间运行的服务,通常在接收到退出信号(如 SIGINT, SIGTERM)时执行清理逻辑,包括关闭数据库连接。

  4. 异步操作考量 (better-sqlite3): 如果确实需要执行可能非常耗时的数据库操作,并且不想阻塞主线程,可以考虑将 better-sqlite3 的操作封装在 Node.js 的 Worker Threads 中进行。

  5. 数据库迁移 (Migrations): 随着应用程序的发展,数据库模式可能需要变更(添加列、修改表等)。手动修改 SQL 风险高且难以管理。推荐使用数据库迁移工具(如 knex.js 配合其迁移功能,或者专门的 SQLite 迁移库,如 node-sqlite-migrate)来管理模式的演变。

  6. 备份: SQLite 数据库是单个文件,备份非常简单——只需定期复制数据库文件即可。在应用程序运行时进行热备份,可以使用 SQLite 的在线备份 API(better-sqlite3 通过 db.backup() 方法支持)。

  7. WAL 模式: SQLite 支持预写日志(Write-Ahead Logging, WAL)模式,可以提高并发读取性能,并允许读写同时进行(读不阻塞写,写不阻塞读)。可以通过 PRAGMA 命令启用:db.exec('PRAGMA journal_mode = WAL;');。WAL 模式会生成额外的 -wal-shm 文件。

  8. 避免在循环中重复 Prepare: prepare() 操作是有成本的。如果要在循环中执行相同的 SQL 语句(只是参数不同),应该在循环外调用 prepare() 一次,然后在循环内多次调用 run(), get(), 或 all()

九、 总结

Node.js 与 SQLite 的组合为开发者提供了一个轻量级、高效且易于管理的数据库解决方案,特别适用于中小型项目、本地数据存储和快速原型开发。通过选择合适的驱动(如 better-sqlite3),开发者可以利用简洁的 API 和预处理语句的优势,安全、高效地执行数据库操作。掌握连接管理、Schema 定义、CRUD 操作、事务处理以及遵循最佳实践,将使你能够充分利用这对技术组合的潜力。

虽然 SQLite 有其并发性和扩展性上的局限,但在其适用的场景范围内,它的简单性、可靠性和零管理成本是无与伦比的。希望这篇详尽的指南能帮助你在 Node.js 项目中成功集成和使用 SQLite 数据库。


发表评论

您的邮箱地址不会被公开。 必填项已用 * 标注

滚动至顶部