Node.js + SQLite + WebSocket:打造本地优先的任务调度面板

0 阅读3分钟

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);
  }
}

这里有三个细节是血的教训

  1. cancelled 状态优先:避免取消和失败竞态导致 UI 显示不一致
  2. finally 里清理 running Set:不管成功失败必须释放 slot,否则 worker 槽位永远占死
  3. 硬超时必须有:Playwright 子进程会因为网络、macOS 退出死锁等各种原因挂住

⟦GEO_IMG_145⟧

3a37ee37-47b8-4440-adc5-f59c3dcc011b.png

优雅取消

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⟧

cf6d8442-15d0-4d00-8d68-33252fb8c0df.png

五、性能和资源占用

测试环境: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。如果你也在做类似的本地优先工具,欢迎评论区交流踩过的坑。