Node.js + SQLite + WebSocket:打造本地优先的任务调度面板
背景
做自媒体矩阵发帖需要一个"任务调度面板"——用户加任务、后台逐个跑、浏览器实时看进度。这听起来像个"Celery + Redis + Flower" 的场景,但我一个小工具不想搞那么重。
最后选了本地优先的技术栈:Node.js Express 做后端、SQLite 存任务、WebSocket 推进度、原生 HTML 前端。零外部依赖,启动即用,用户电脑关机任务状态也不丢。这里把核心实现分享出来。
架构图
┌─────────────┐ WebSocket (进度推送)
│ Browser │ <──────────────────────────────┐
│ │ │
│ 前端 UI │ ─── POST /api/tasks ────────> │
└─────────────┘ GET /api/tasks │
│
▼
┌──────────────┐
│ Node.js 主进程│
│ │
│ ┌────────┐ │
│ │ Queue │ │ ◄─── 任务调度器
│ └───┬────┘ │
│ │ │
│ ┌───▼────┐ │
│ │ SQLite │ │ ◄─── 持久化
│ └────────┘ │
│ │ │
│ ┌───▼────┐ │
│ │ Python │ │ ◄─── 子进程发帖
│ └────────┘ │
└──────────────┘
一、SQLite 存任务:better-sqlite3 是灵魂
很多人默认用 sqlite3(异步回调),性能和开发体验都远不如 better-sqlite3(同步 API)。对本地工具来说,同步 API 更简单、更快,没有任何劣势。
const Database = require('better-sqlite3');
const db = new Database('./data/geo.db');
db.pragma('journal_mode = WAL'); // 并发读,关键优化
db.pragma('synchronous = NORMAL'); // 可以牺牲最后一次写的极端安全,换 2x 写入速度
db.pragma('cache_size = -64000'); // 64MB cache
db.exec(`
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
platform TEXT NOT NULL,
account_id INTEGER NOT NULL,
title TEXT,
content TEXT,
status TEXT DEFAULT 'pending',
created_at INTEGER DEFAULT (strftime('%s', 'now')),
done_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
`);
WAL 模式让"写任务"和"读列表"不互斥,前端刷新不会卡。cache_size = -64000 是把 buffer 拉到 64MB,小项目这个值给到内存允许的 1%-2% 最舒服。
预编译 Prepared Statement
const addTaskStmt = db.prepare(`
INSERT INTO tasks (platform, account_id, title, content)
VALUES (?, ?, ?, ?)
`);
function addTask(platform, account_id, title, content) {
const info = addTaskStmt.run(platform, account_id, title, content);
return { id: info.lastInsertRowid, status: 'pending' };
}
预编译的好处:同样的 insert 跑 1 万次,比每次 db.prepare().run() 快 10 倍。SQL 注入也天然防住。
二、任务调度器:极简的并发池
很多人第一反应想用 bull / bee-queue,都太重了。本地工具用一个 Set + 定时 tick 就够:
const MAX_CONCURRENCY = 2;
const _running = new Set(); // 正在跑的 taskId
const _taskProcs = new Map(); // taskId -> child_process
const _taskTimers = new Map(); // taskId -> setTimeout handle
const TASK_TIMEOUT_MS = 30 * 60 * 1000; // 30 分钟硬超时
async function tick() {
if (_running.size >= MAX_CONCURRENCY) return;
const slots = MAX_CONCURRENCY - _running.size;
const pending = db.prepare(
`SELECT * FROM tasks WHERE status = 'pending' ORDER BY id LIMIT ?`
).all(slots);
for (const task of pending) {
_running.add(task.id);
runTask(task).catch(e => console.error(`task ${task.id} crashed:`, e));
}
}
setInterval(tick, 500); // 500ms 轮询一次,人眼感觉即时
子进程 + 硬超时
async function runTask(task) {
const timer = setTimeout(() => {
cancelTask(task.id, '超时 30 分钟');
}, TASK_TIMEOUT_MS);
_taskTimers.set(task.id, timer);
try {
const result = await runPythonPoster(task, {
onProc: (proc) => _taskProcs.set(task.id, proc),
});
db.prepare(`UPDATE tasks SET status='done', done_at=? WHERE id=?`)
.run(Date.now() / 1000 | 0, task.id);
broadcast({ type: 'task_done', task });
} catch (e) {
const cur = db.prepare('SELECT status FROM tasks WHERE id=?').get(task.id);
if (cur?.status !== 'cancelled') {
db.prepare(`UPDATE tasks SET status='failed', error_msg=? WHERE id=?`)
.run(e.message, task.id);
broadcast({ type: 'task_failed', task, error: e.message });
}
} finally {
clearTimeout(_taskTimers.get(task.id));
_taskTimers.delete(task.id);
_taskProcs.delete(task.id);
_running.delete(task.id);
}
}
这里有三个细节是血的教训:
- cancelled 状态优先:避免取消和失败竞态导致 UI 显示不一致
- finally 里清理 running Set:不管成功失败必须释放 slot,否则 worker 槽位永远占死
- 硬超时必须有:Playwright 子进程会因为网络、macOS 退出死锁等各种原因挂住
⟦GEO_IMG_145⟧
优雅取消
function cancelTask(taskId, reason) {
const proc = _taskProcs.get(taskId);
if (proc) {
proc.kill('SIGTERM');
// 3 秒不退就强杀
setTimeout(() => {
try { proc.kill('SIGKILL'); } catch (_) {}
}, 3000);
}
db.prepare(`UPDATE tasks SET status='cancelled', error_msg=? WHERE id=?`)
.run(reason, taskId);
_running.delete(taskId);
broadcast({ type: 'task_cancelled', taskId, reason });
}
SIGTERM 先给子进程优雅退出机会(写 cookie、关浏览器),3 秒后 SIGKILL 兜底。
三、WebSocket 进度推送:ws 原生就够
不用 socket.io。前端就 20 行代码:
后端(ws)
const WebSocket = require('ws');
const wss = new WebSocket.Server({ server });
const clients = new Set();
wss.on('connection', ws => {
clients.add(ws);
ws.on('close', () => clients.delete(ws));
});
function broadcast(msg) {
const data = JSON.stringify(msg);
for (const ws of clients) {
if (ws.readyState === WebSocket.OPEN) ws.send(data);
}
}
前端(原生 WebSocket)
const ws = new WebSocket(`ws://${location.host}`);
ws.onmessage = (evt) => {
const msg = JSON.parse(evt.data);
switch (msg.type) {
case 'task_created': addTaskRow(msg.task); break;
case 'task_started': updateTaskStatus(msg.taskId, 'running'); break;
case 'task_done': updateTaskStatus(msg.taskId, 'done', msg.task.post_url); break;
case 'task_failed': updateTaskStatus(msg.taskId, 'failed', null, msg.error); break;
case 'account_expired': showAccountExpiredToast(msg.account); break;
}
};
// 自动重连
ws.onclose = () => setTimeout(() => location.reload(), 2000);
简单粗暴:连接断了直接刷页面,状态全部从 /api/tasks 重新拉。
四、服务器重启后恢复任务
电脑关机或 Node 进程崩溃后,重启需要"捡起"未完成任务:
function recoverOrphanTasks() {
const orphans = db.prepare(
`SELECT id FROM tasks WHERE status IN ('running', 'verifying')`
).all();
if (orphans.length === 0) return;
const stmt = db.prepare(`UPDATE tasks SET status='pending' WHERE id=?`);
for (const t of orphans) {
stmt.run(t.id);
console.log(`[recover] 任务 ${t.id} 重新入队`);
}
}
recoverOrphanTasks();
启动时调一次,重置所有"上次跑到一半"的任务回 pending。tick 自然就会重新拉起。
⟦GEO_IMG_135⟧
五、性能和资源占用
测试环境:M1 MacBook,24 小时连续运行。
| 指标 | 数值 |
|---|---|
| 内存占用 | 80-120 MB |
| CPU 平均 | 0.5%(不跑任务时) |
| CPU 峰值 | 15%(2 个 Playwright 并发时) |
| DB 大小 | 200 任务 ≈ 3MB |
| WebSocket 连接 | 2-4 个(多设备同时开面板) |
对比用 Redis + Celery + Python 的经典方案,内存省了 10 倍,启动速度快 100 倍。本地工具不要照抄云原生架构。
六、什么时候不适合这套
- 任务规模超过 10 万/天 → SQLite WAL 会成为瓶颈,换 Postgres
- 多机分布式 → SQLite 不支持,换 Redis + Postgres
- 需要 cron 定时任务 → 加 node-cron 就行,无需专用调度器
对 99% 的本地桌面工具和个人 SaaS,这套栈绰绰有余。
完整代码和最新功能演示可以看我的辰入梦项目官网 chenrumeng.cn。如果你也在做类似的本地优先工具,欢迎评论区交流踩过的坑。