BattleMonAgent/doc/better-sqlite3.md

476 lines
12 KiB
Markdown
Raw Normal View History

下面是一份结合目前(约 2026 年)社区共识的 **better-sqlite3 使用指南 + 最佳实践实战版**,你可以直接拿来做项目的参考模板。
---
# better-sqlite3 使用指南(含最佳实践)
## 一、定位与总体建议
### 1.1 为什么选 better-sqlite3
在 Node.js 中操作 SQLite目前主流选型是
- 新项目首选 **better-sqlite3**
- Node.js 24+ 内置的 `node:sqlite` 适合:轻量脚本、小工具、对依赖敏感的场景
- 旧项目上的 `node-sqlite3` 已被弃用,不建议新项目继续使用
**better-sqlite3 的优点:**
- **同步 API**:代码简单可读,事务逻辑不被 Promise/回调搅乱
- **性能优秀**:普遍被认为是 Node 生态里最快的 SQLite 绑定之一
- **完整事务支持**:内置事务封装,写多步业务逻辑非常顺手
- **PRAGMA / 备份等高级能力完备**
安装:
```bash
npm install better-sqlite3
```
---
## 二、连接管理:一个进程一个连接
### 2.1 最佳实践:进程级单例
SQLite 本质是操作一个文件;和 MySQL/Postgres 不同,连接很轻量,不需要连接池。
**推荐模式:**
- 应用启动时创建 **一个全局 Database 实例**
- 全程复用,不要在每个请求里频繁 open/close
- 在进程退出前再统一 close很多服务甚至不显式 close
```js
// db/index.js
import Database from 'better-sqlite3';
let db;
export function getDB() {
if (!db) {
db = new Database('app.sqlite', {
readonly: false,
fileMustExist: false,
timeout: 5000, // 等待锁超时时间
// verbose: console.log // 调试时可打开
});
// 基础 PRAGMA见后文性能调优部分
db.pragma('journal_mode = WAL');
db.pragma('synchronous = NORMAL');
db.pragma('cache_size = -64000'); // ~64MB
db.pragma('temp_store = MEMORY');
}
return db;
}
```
**反例(不要这样写):**
```js
// ❌ 每次请求新建 & 关闭连接
function handler(req, res) {
const db = new Database('app.sqlite');
const row = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
db.close();
res.json(row);
}
```
问题:
- 无谓的 open/close 开销
- 预编译语句无法复用
- 容易出现并发锁和资源浪费
### 2.2 多线程Worker Threads场景
当读操作很多、单线程成为瓶颈时:
- 使用 **Worker Threads**
- **每个 worker 线程自己 new 一个 Database 实例**
- 不在多个线程之间共享同一个 `Database` 对象
示意:
```js
// workers/readWorker.js
import { workerData, parentPort } from 'node:worker_threads';
import Database from 'better-sqlite3';
const db = new Database('app.sqlite', {
readonly: true,
fileMustExist: true,
timeout: 3000
});
db.pragma('cache_size = -32000');
db.pragma('temp_store = MEMORY');
db.pragma('query_only = ON'); // 防止误写
const stmt = db.prepare(workerData.sql);
const result = stmt.all(...(workerData.params || []));
parentPort.postMessage(result);
```
---
## 三、查询与预编译语句Prepared Statements
### 3.1 必须坚持使用预编译
**目标:同时保证性能和安全(防 SQL 注入)**。
```js
const db = getDB();
// 预编译语句在模块级缓存
const getUserById = db.prepare('SELECT * FROM users WHERE id = ?');
const listUsersByEmail = db.prepare('SELECT * FROM users WHERE email = ?');
export function findUserById(id) {
return getUserById.get(id); // 单行
}
export function findUsersByEmail(email) {
return listUsersByEmail.all(email); // 多行
}
```
**防注入反例:**
```js
// ❌ 直接拼字符串,存在注入风险
const sql = `SELECT * FROM users WHERE email = '${email}'`;
db.prepare(sql).all();
// ✅ 使用参数绑定
db.prepare('SELECT * FROM users WHERE email = ?').all(email);
```
### 3.2 get / all / run / exec 的使用场景
| 方法 | 用途 | 返回值典型用法 |
|--------|-----------------------|--------------------------------------|
| `get` | 查询**一条**记录 | `const row = stmt.get(id)` |
| `all` | 查询**多条**记录 | `const rows = stmt.all(status)` |
| `run` | INSERT/UPDATE/DELETE | `const info = stmt.run(...params)` |
| `exec` | 执行多条 SQL/DDL 语句 | `db.exec('CREATE TABLE ...; ...')` |
示例:
```js
const db = getDB();
const findActiveUsers = db.prepare('SELECT * FROM users WHERE active = 1');
const deactivateUser = db.prepare('UPDATE users SET active = 0 WHERE id = ?');
// 多行
const users = findActiveUsers.all();
// 更新
const info = deactivateUser.run(123);
console.log(info.changes); // 受影响行数
console.log(info.lastInsertRowid); // 如是INSERT语句则有意义
```
### 3.3 善用 RETURNING 减少一次查询
如果 SQLite 版本支持 `RETURNING`
```js
const createOrder = db.prepare(`
INSERT INTO orders (user_id, amount)
VALUES (?, ?)
RETURNING id, user_id, amount, created_at
`);
const order = createOrder.get(userId, amount);
// 这里已经拿到刚插入的订单信息,无需再额外 SELECT
```
---
## 四、事务管理:写操作的必备武器
任何「多步写入」逻辑(比如:下单、转账、批量导入)都应该放在事务里。
### 4.1 使用 better-sqlite3 的事务封装
```js
const db = getDB();
const placeOrder = db.transaction((userId, items) => {
const insertOrder = db.prepare(`
INSERT INTO orders (user_id, status)
VALUES (?, 'pending')
`);
const insertOrderItem = db.prepare(`
INSERT INTO order_items (order_id, product_id, qty, price)
VALUES (?, ?, ?, ?)
`);
const updateStock = db.prepare(`
UPDATE products
SET stock = stock - ?
WHERE id = ?
`);
const { lastInsertRowid: orderId } = insertOrder.run(userId);
for (const item of items) {
insertOrderItem.run(orderId, item.productId, item.qty, item.price);
updateStock.run(item.qty, item.productId);
}
return orderId;
});
// 使用
try {
const orderId = placeOrder(userId, items);
// 成功自动 COMMIT
} catch (err) {
// 抛出异常时自动 ROLLBACK
console.error('placeOrder 失败', err);
}
```
**注意事项:**
- 事务函数体 **必须是同步代码**,不要在里面 `await`/Promise/`setTimeout`
- 所有需要原子性的写操作都要包进同一次 `db.transaction()`
- 对于批量插入/更新,**一个大事务远快于 N 个小事务**
---
## 五、性能调优:常用 PRAGMA 配置
### 5.1 典型 Web 服务推荐配置
在应用启动时统一设置一次:
```js
const db = getDB();
// 提升并发 & 写性能:建议生产使用
db.pragma('journal_mode = WAL'); // 写前日志
// 写安全性 & 性能折中
db.pragma('synchronous = NORMAL'); // 不要贸然用 OFF
// 内存缓存(负数表示 KiB
db.pragma('cache_size = -64000'); // ≈64MB
// 临时对象走内存
db.pragma('temp_store = MEMORY');
// 利用 mmap 加速 IO根据机器内存调整
db.pragma('mmap_size = 268435456'); // 256MB
```
**不推荐在有写入的生产环境用 `synchronous = OFF`**,否则断电/崩溃时有丢数据风险。
### 5.2 读密集型 Worker 优化
针对只读 worker
```js
const db = new Database('app.sqlite', { readonly: true, fileMustExist: true });
db.pragma('cache_size = -32000'); // ~32MB
db.pragma('temp_store = MEMORY');
db.pragma('mmap_size = 1073741824'); // ~1GB
db.pragma('query_only = ON'); // 强制只读
```
### 5.3 查询计划与优化
数据库结构或索引大改之后,可以执行一次:
```js
db.exec('ANALYZE;');
db.pragma('optimize');
```
有需要时,用 `EXPLAIN QUERY PLAN` 分析慢查询(开发/排查时用)。
---
## 六、安全实践:防注入、防误删、防泄露
### 6.1 防 SQL 注入
核心原则:
- 所有用户输入**必须**通过「参数绑定」进入 SQL
- 不拼接字符串、不直接插入变量
```js
// ✅ 推荐使用形式
const stmt = db.prepare('SELECT * FROM users WHERE email = ?');
const user = stmt.get(email);
```
### 6.2 参数校验
在进入 SQL 前尽量校验类型 / 长度 / 格式:
```js
function assertId(id) {
if (!Number.isInteger(id) || id <= 0) {
throw new Error('非法ID');
}
}
```
### 6.3 SQLite 文件存放与权限
- **不要**把 `.sqlite` 文件放在可被 Web 服务器直接访问的静态目录下
- 赋予最小权限:如仅运行用户可读写
```bash
chmod 600 app.sqlite
chown node_user:node_group app.sqlite
```
---
## 七、备份与恢复
### 7.1 使用 better-sqlite3 的备份 API在线备份
```js
const db = getDB();
// 备份到另一个文件(热备,无需停服务)
await db.backup('backup.sqlite');
```
你可以把这段逻辑放进定时任务里,例如每天低峰执行一次。
### 7.2 冷备份(停机拷贝)
如果可以短暂停机:
1. 停止 Node 进程
2. 直接复制数据库文件
```bash
cp app.sqlite app.sqlite.bak
```
在 WAL 模式下,确保 `.sqlite` 和相关的 `-wal`/`-shm` 文件也一并妥善处理(备份 API 已经帮你做了这件事,一般更推荐)。
---
## 八、迁移与工程化
### 8.1 简单迁移系统示例
建议至少维护一个简易迁移机制,例如:
- 建一张 `migrations` 表,记录已执行的版本
- 在应用启动时按顺序执行 `migrations/*.sql`
伪代码:
```js
// db/migrate.js
import { getDB } from './index.js';
import fs from 'node:fs';
import path from 'node:path';
export function runMigrations() {
const db = getDB();
db.exec(`CREATE TABLE IF NOT EXISTS migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
run_at TEXT NOT NULL
)`);
const applied = new Set(
db.prepare('SELECT name FROM migrations').all().map(r => r.name)
);
const dir = path.join(process.cwd(), 'src/db/migrations');
const files = fs.readdirSync(dir).sort(); // 如001_init.sql, 002_add_user.sql
const runMigration = db.transaction((name, sql) => {
db.exec(sql);
db.prepare('INSERT INTO migrations (name, run_at) VALUES (?, datetime(\'now\'))')
.run(name);
});
for (const file of files) {
if (applied.has(file)) continue;
const sql = fs.readFileSync(path.join(dir, file), 'utf8');
runMigration(file, sql);
console.log('已执行迁移:', file);
}
}
```
### 8.2 典型项目结构建议
```txt
src/
db/
index.js # 初始化 & 导出 db 实例 + PRAGMA
migrate.js # 迁移逻辑
migrations/ # *.sql 文件
models/
user.js # 用户相关 SQL & 事务
order.js
workers/
readWorker.js # 高并发只读 worker
services/
userService.js # 业务逻辑。只调用 models不直接写 SQL
app.js # 应用入口启动HTTP服务 & 调用 runMigrations()
```
---
## 九、与 Node.js 内置 `node:sqlite` 的关系
- Node.js 24 LTS 起,内置了 `node:sqlite` 模块,方便做无依赖的小工具
- 但在实际生产服务里:
- **性能、特性、生态** 综合考虑better-sqlite3 依然是首选
- 两者 API 思路类似:同步操作、小而美,更方便从一种迁移到另一种
可以简记为:
> **重业务 / 生产服务better-sqlite3**
> **简单脚本 / 工具node:sqlite 也可以考虑**
---
## 十、一页小抄(速查版)
1. **连接管理**
- 进程内只创建一个 `Database` 实例,全局复用
- Worker Threads 中,一线程一实例,不共享连接
2. **查询写法**
- 所有 SQL 使用 **prepared statement + 参数绑定**
- 单行用 `get`,多行用 `all`,写操作用 `run`
3. **事务**
- 多步写操作必须放在 `db.transaction()`
- 事务函数里面不要写任何异步代码
4. **PRAGMA 调优**
- `journal_mode = WAL`
- `synchronous = NORMAL`
- 合理设置 `cache_size` / `temp_store = MEMORY` / `mmap_size`
5. **安全**
- 禁止拼接 SQL 字符串
- 校验输入
- 数据库文件不要暴露在静态目录,限制文件权限
6. **扩展读性能**
- 使用 Worker Threads
- 每个 worker 使用只读连接 + `query_only = ON`
如果你后续有更具体的场景(例如 Electron 桌面应用、嵌入式设备、日志收集服务等),可以再描述一下,我可以基于这些最佳实践帮你落到更贴近场景的代码模板与配置。