Node.js + Express + SQLite 项目把日志和本地排查步骤搭好

小型 Node 服务最怕“页面坏了但终端没信息”。日志不是上线后才补的东西,本地开发阶段就应该让请求、错误、关键业务动作都能落到文件里。

下面用 Express + SQLite 举例,重点不是框架花活,而是把问题发生时能看的东西提前放好。

目录先分清楚

目录结构:

app-demo
├─ data
│  ├─ app.sqlite
│  └─ logs
│     ├─ app.log
│     └─ error.log
├─ src
│  ├─ logger.js
│  ├─ db.js
│  └─ server.js
└─ package.json

data/ 放运行期文件,src/ 放源码。这样备份、清理、部署时不容易把数据库和代码混在一起。

1. 初始化项目

mkdir app-demo
cd app-demo
npm init -y
npm install express better-sqlite3

package.json 加一个启动脚本:

{
  "scripts": {
    "start": "node src/server.js",
    "dev": "node --watch src/server.js"
  }
}

如果你的项目已经用别的 SQLite 包,也没关系。日志和排障思路一样。

2. 写一个只做文件落盘的 logger

src/logger.js

const fs = require('fs');
const path = require('path');

const logDir = path.join(__dirname, '..', 'data', 'logs');
const appLogPath = path.join(logDir, 'app.log');
const errorLogPath = path.join(logDir, 'error.log');

fs.mkdirSync(logDir, { recursive: true });
fs.closeSync(fs.openSync(appLogPath, 'a'));
fs.closeSync(fs.openSync(errorLogPath, 'a'));

function writeLine(filePath, level, message, fields = {}) {
  const entry = {
    time: new Date().toISOString(),
    level,
    message,
    ...fields
  };
  fs.appendFileSync(filePath, `${JSON.stringify(entry)}\n`, 'utf8');
}

function info(message, fields) {
  writeLine(appLogPath, 'info', message, fields);
}

function error(message, fields) {
  writeLine(errorLogPath, 'error', message, fields);
}

module.exports = { info, error, appLogPath, errorLogPath };

本文用 JSON Lines,一行一条日志。排障时能 tail,也能用脚本按字段过滤。

3. 初始化 SQLite 表

src/db.js

const path = require('path');
const Database = require('better-sqlite3');

const dbPath = path.join(__dirname, '..', 'data', 'app.sqlite');
const db = new Database(dbPath);

db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');

db.exec(`
  CREATE TABLE IF NOT EXISTS notes (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    body TEXT NOT NULL,
    created_at TEXT NOT NULL
  )
`);

module.exports = db;

WAL 对小型 Web 服务更友好一些:读写并发时不那么容易互相堵住。它不是万能药,慢查询和大事务仍然要单独处理。

4. 在 Express 里记录请求和错误

src/server.js

const express = require('express');
const db = require('./db');
const logger = require('./logger');

const app = express();
app.use(express.json());

app.use((req, res, next) => {
  const startedAt = Date.now();
  res.on('finish', () => {
    logger.info('request_finished', {
      method: req.method,
      path: req.path,
      status: res.statusCode,
      duration_ms: Date.now() - startedAt
    });
  });
  next();
});

app.get('/health', (req, res) => {
  res.json({ ok: true });
});

app.post('/notes', (req, res, next) => {
  try {
    const title = String(req.body.title || '').trim();
    const body = String(req.body.body || '').trim();
    if (!title || !body) {
      return res.status(400).json({ error: 'title and body are required' });
    }

    const result = db.prepare(`
      INSERT INTO notes(title, body, created_at)
      VALUES(?, ?, ?)
    `).run(title, body, new Date().toISOString());

    logger.info('note_created', { note_id: result.lastInsertRowid });
    res.status(201).json({ id: result.lastInsertRowid });
  } catch (err) {
    next(err);
  }
});

app.get('/notes', (req, res, next) => {
  try {
    const notes = db.prepare(`
      SELECT id, title, body, created_at
      FROM notes
      ORDER BY id DESC
      LIMIT 20
    `).all();
    res.json({ notes });
  } catch (err) {
    next(err);
  }
});

app.use((err, req, res, next) => {
  logger.error('request_failed', {
    method: req.method,
    path: req.path,
    message: err.message,
    stack: err.stack
  });
  res.status(500).json({ error: 'internal server error' });
});

const port = Number(process.env.PORT || 3000);
app.listen(port, () => {
  logger.info('server_started', { port });
  console.log(`listening on http://127.0.0.1:${port}`);
});

注意两点:

  • 对外返回的错误信息不要带堆栈。
  • 文件日志里也不要写 token、cookie、密码、完整请求头。

5. 跑一次最小验证

启动:

npm run dev

另开终端:

curl -i http://127.0.0.1:3000/health
curl -i -X POST http://127.0.0.1:3000/notes \
  -H 'content-type: application/json' \
  -d '{"title":"first note","body":"hello sqlite"}'
curl -i http://127.0.0.1:3000/notes

看日志:

tail -n 20 data/logs/app.log
tail -n 20 data/logs/error.log

预期:

  • app.logserver_startedrequest_finishednote_created
  • error.log 文件存在,即使还没有内容。
  • /notes 能返回刚插入的数据。

6. 故意打一个错误分支

发一个缺字段请求:

curl -i -X POST http://127.0.0.1:3000/notes \
  -H 'content-type: application/json' \
  -d '{"title":"missing body"}'

这是 400,不应该进 error.log。它是用户输入错误,不是服务异常。

再临时写一个测试路由:

app.get('/boom', (req, res, next) => {
  next(new Error('forced test error'));
});

请求:

curl -i http://127.0.0.1:3000/boom
tail -n 5 data/logs/error.log

确认 error.log 里有 request_failed。验证完删掉 /boom,不要把测试炸点留在项目里。

7. 查 SQLite 现场

安装了 sqlite3 命令行的话,可以直接看数据:

sqlite3 data/app.sqlite '.tables'
sqlite3 data/app.sqlite 'select id,title,created_at from notes order by id desc limit 5;'
sqlite3 data/app.sqlite 'pragma integrity_check;'

pragma integrity_check; 正常输出:

ok

如果没有 sqlite3 命令,也可以写一个只读脚本查,但不要在排障时直接拿编辑器改数据库文件。

8. 常见坑

日志目录不存在

服务启动时就 mkdirSync 并预创建日志文件。不要等第一条错误出现才创建 error.log,否则你会分不清“没报错”和“日志系统没工作”。

请求日志太多

保留最基础字段:方法、路径、状态码、耗时。别把 body 全写进去,里面很容易混进隐私数据。

SQLite 被锁

先看是不是长事务或一次写太多数据。排查命令:

lsof data/app.sqlite
ls -lh data/app.sqlite data/app.sqlite-wal data/app.sqlite-shm

WAL 文件长期很大时,可以在低峰期做 checkpoint:

sqlite3 data/app.sqlite 'pragma wal_checkpoint(TRUNCATE);'

停写入或确认低峰再做,不要在线上高峰随手执行。

9. 本地完成标准

每次改接口前后都跑这几条:

npm start
curl -fsS http://127.0.0.1:3000/health
curl -fsS http://127.0.0.1:3000/notes
sqlite3 data/app.sqlite 'pragma integrity_check;'
tail -n 20 data/logs/app.log
tail -n 20 data/logs/error.log

确认健康检查、数据查询、数据库检查和文件日志都能跑,再写业务功能。