Bài 9 · Nâng cao · 26 phút
Cơ sở dữ liệu với node:sqlite
Biên soạn bởi Nguyễn Anh Tuấn
Làm việc với cơ sở dữ liệu (database) trong Node: module node:sqlite tích hợp sẵn cho CRUD và transaction; giới thiệu nhanh Postgres và ORM.
Ở bài Mạng: HTTP, fetch & API, mèo con đã dựng REST API quản lý kho cá - dữ liệu nằm trong một mảng JavaScript, tắt server là bay sạch. Cách chữa đầu tiên ai cũng nghĩ tới: ghi cả mảng xuống một tệp JSON bằng writeFileSync. Chạy được - nhưng dính 3 điểm yếu: (a) mỗi lần đổi dù chỉ 1 con cá là ghi đè CẢ TỆP - kho 10.000 con, thêm 1 con cũng viết lại cả 10.000; (b) hai request cùng ghi sẽ giẫm nhau - cả hai đọc bản cũ, ai ghi sau đè mất thay đổi của người ghi trước; (c) muốn hỏi "cá nào nặng hơn 2 kg?" phải nạp cả kho vào RAM rồi lọc tay - kho càng phình càng chậm.
Đây đúng là bài toán mà cơ sở dữ liệu quan hệ (relational database) sinh ra để giải: dữ liệu xếp thành bảng (dòng - cột), bạn hỏi bằng SQL - ngôn ngữ query chuyên dụng. Trong họ này, SQLite là loại gọn nhất: toàn bộ cơ sở dữ liệu nằm trong một tệp duy nhất, engine chạy ngay trong tiến trình của bạn - không cần cài, không cần server riêng (nó đang chạy trong điện thoại và trình duyệt của bạn ngay lúc này). Và từ Node 22.5, SQLite có sẵn trong Node: module node:sqlite.
- ▸Tệp JSON tự chế: ghi đè cả tệp, hai bên ghi giẫm nhau, truy vấn phải đọc hết - không bền khi dữ liệu lớn dần.
- ▸Cơ sở dữ liệu quan hệ: dữ liệu thành bảng, hỏi bằng SQL - lọc/sắp xếp/đếm là việc của engine, không phải của vòng for.
- ▸SQLite = cả cơ sở dữ liệu trong MỘT tệp, không cần server riêng; Node 22.5+ có sẵn qua node:sqlite.
Lớp DatabaseSync mở một tệp cơ sở dữ liệu - chưa có thì tạo mới. Phương thức exec() chạy SQL "thô", hợp cho câu lệnh không chứa dữ liệu người dùng - như dựng schema (bản thiết kế bảng): bảng tên gì, cột nào, kiểu gì.
tao-kho.mjs - 2 dòng cuối là cảnh báo experimental, in ở MỌI lần chạy (số sau node: là PID, mỗi lần một khác); các ví dụ sau lược 2 dòng này
import { DatabaseSync } from "node:sqlite";
const db = new DatabaseSync("kho-ca.db"); // mở tệp kho-ca.db - chưa có thì tạo mới
db.exec(`CREATE TABLE IF NOT EXISTS ca (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ten TEXT NOT NULL,
khoi_luong REAL,
chu_meo TEXT
)`);
console.log("kho-ca.db sẵn sàng, đã có bảng: ca");
db.close(); Kết quả khi chạy
kho-ca.db sẵn sàng, đã có bảng: ca (node:8019) ExperimentalWarning: SQLite is an experimental feature and might change at any time (Use `node --trace-warnings ...` to show where the warning was created)
kho-ca.db giờ là một tệp thật trên đĩa - đúng loại tệp mèo con đã thao tác ở bài Làm việc với tệp & thư mục: copy, gửi, backup được. Mỗi cột khai một kiểu, và SQLite chỉ có vài kiểu lõi: INTEGER → number (số nguyên - id, số đếm, 0/1 thay boolean), REAL → number (số thực float64 - đúng loại số của JS), TEXT → string (UTF-8 - tiếng Việt thoải mái), NULL → null, và BLOB → Uint8Array cho dữ liệu nhị phân (bài này chưa cần tới).
Trung thực: node:sqlite đang experimental
CRUD - Create, Read, Update, Delete - là 4 thao tác sống còn của mọi kho dữ liệu, ứng với 4 lệnh SQL INSERT / SELECT / UPDATE / DELETE. Từ đây dữ liệu là do người dùng nhập, nên ta đổi công cụ: prepared statement - câu SQL soạn sẵn với chỗ trống ?, được prepare() biên dịch MỘT lần, mỗi lần dùng chỉ điền giá trị.
kho-crud.mjs - Create + Read (đã lược 2 dòng cảnh báo)
import { DatabaseSync } from "node:sqlite";
const db = new DatabaseSync("kho-ca.db");
// CREATE - soạn câu lệnh MỘT lần (chỗ trống đánh dấu ?), dùng lại nhiều lần
const them = db.prepare("INSERT INTO ca (ten, khoi_luong, chu_meo) VALUES (?, ?, ?)");
them.run("cá thu", 2.4, "Mun");
them.run("cá cơm", 0.1, "Miu");
them.run("cá ngừ", 5.2, "Mun");
console.log(them.run("cá rô", 0.8, "Tom"));
// READ - .get() lấy MỘT dòng; .all() lấy mọi dòng khớp, lọc/sắp/cắt ngay trong SQL
console.log(db.prepare("SELECT ten, khoi_luong FROM ca WHERE ten = ?").get("cá cơm"));
const sql = "SELECT ten, khoi_luong FROM ca WHERE khoi_luong > ? ORDER BY khoi_luong DESC LIMIT 2";
for (const ca of db.prepare(sql).all(2)) console.log(`${ca.ten} - ${ca.khoi_luong} kg`);
db.close(); Kết quả khi chạy
{ lastInsertRowid: 4, changes: 1 }
[Object: null prototype] { ten: 'cá cơm', khoi_luong: 0.1 }
cá ngừ - 5.2 kg
cá thu - 2.4 kg.run() trả về changes (bao nhiêu dòng bị ảnh hưởng) và lastInsertRowid (id vừa cấp - cột AUTOINCREMENT tự đếm 1, 2, 3…). Dòng thứ hai hơi lạ mắt: [Object: null prototype] - node:sqlite trả object "trần" không kế thừa gì, để tên cột không đụng thuộc tính có sẵn như __proto__; cứ dùng ca.ten như object thường. Còn một câu hỏi treo từ đầu bước: vì sao phải đi đường ? cầu kỳ vậy?
Vì sao KHÔNG BAO GIỜ nối chuỗi SQL
tiem-va-don.mjs - chuỗi độc bị vô hiệu, rồi Update + Delete (đã lược 2 dòng cảnh báo)
import { DatabaseSync } from "node:sqlite";
const db = new DatabaseSync("kho-ca.db");
const tenDoc = "'; DROP TABLE ca; --"; // chuỗi "độc" giả làm tên cá
// Đi qua ?, cả chuỗi chỉ là MỘT giá trị tên - không bao giờ thành lệnh SQL
db.prepare("INSERT INTO ca (ten, khoi_luong, chu_meo) VALUES (?, ?, ?)").run(tenDoc, 0.5, "Tom");
console.log(db.prepare("SELECT id, ten FROM ca WHERE ten = ?").get(tenDoc));
console.log("số cá trong kho:", db.prepare("SELECT COUNT(*) AS n FROM ca").get().n);
// UPDATE / DELETE - .changes nói thật bao nhiêu dòng bị đổi
const canLai = db.prepare("UPDATE ca SET khoi_luong = ? WHERE ten = ?");
console.log("cân lại cá thu:", canLai.run(2.6, "cá thu").changes, "dòng");
console.log("dọn cá độc:", db.prepare("DELETE FROM ca WHERE ten = ?").run(tenDoc).changes, "dòng");
console.log("còn lại:", db.prepare("SELECT COUNT(*) AS n FROM ca").get().n, "con cá");
db.close(); Kết quả khi chạy
[Object: null prototype] { id: 5, ten: "'; DROP TABLE ca; --" }
số cá trong kho: 5
cân lại cá thu: 1 dòng
dọn cá độc: 1 dòng
còn lại: 4 con cáChuỗi độc nằm im trong cột ten như một cái tên xấu xí - kho vẫn đủ 5 con, bảng còn nguyên. DELETE dọn nó đi, và .changes xác nhận đúng 1 dòng bị xoá.
- ▸prepare một lần - run/get/all nhiều lần: nhanh hơn và an toàn hơn exec với chuỗi tự ghép.
- ▸Dữ liệu người dùng LUÔN đi qua ?: chuỗi độc chỉ là dữ liệu, không bao giờ thành lệnh SQL.
- ▸WHERE / ORDER BY / LIMIT để engine lọc-sắp-cắt giúp; .changes nói thật bao nhiêu dòng bị đổi.
Nghiệp vụ khó hơn: Mun chuyển cho Miu hai con cá - tức HAI lệnh UPDATE. Nếu lệnh đầu xong rồi lệnh sau thất bại (bug, dữ liệu thiếu, mất điện), kho kẹt ở trạng thái "một nửa": chuyển được đúng một con. Transaction là câu trả lời của cơ sở dữ liệu: gom nhiều câu lệnh thành một khối được cả hoặc thôi cả - mở bằng BEGIN, chốt bằng COMMIT, huỷ bằng ROLLBACK.
chuyen-ca.mjs - lần 1 hỏng giữa chừng (rollback), lần 2 trọn vẹn (đã lược 2 dòng cảnh báo)
import { DatabaseSync } from "node:sqlite";
const db = new DatabaseSync("kho-ca.db");
const doiChu = db.prepare("UPDATE ca SET chu_meo = ? WHERE ten = ? AND chu_meo = ?");
const inKho = (luc) => {
const dong = db.prepare("SELECT ten, chu_meo FROM ca ORDER BY id").all();
console.log(luc, dong.map((c) => `${c.ten}→${c.chu_meo}`).join(" "));
};
function chuyenCa(dsTen, tuMeo, denMeo) {
db.exec("BEGIN"); // mở transaction - từ đây mọi thay đổi là "nháp"
try {
for (const ten of dsTen) {
const kq = doiChu.run(denMeo, ten, tuMeo);
if (kq.changes === 0) throw new Error(`${tuMeo} không có ${ten}`);
}
db.exec("COMMIT"); // được CẢ - chốt mọi thay đổi
console.log(`chuyển xong: ${dsTen.join(", ")} → ${denMeo}`);
} catch (err) {
db.exec("ROLLBACK"); // thôi CẢ - kho về đúng như trước BEGIN
console.log("rollback vì:", err.message);
}
}
inKho("trước: ");
chuyenCa(["cá thu", "cá mập"], "Mun", "Miu"); // cá mập không có → lỗi giữa chừng
inKho("sau lỗi: ");
chuyenCa(["cá thu", "cá ngừ"], "Mun", "Miu"); // lần này đủ cả hai
inKho("sau cùng: ");
db.close(); Kết quả khi chạy
trước: cá thu→Mun cá cơm→Miu cá ngừ→Mun cá rô→Tom rollback vì: Mun không có cá mập sau lỗi: cá thu→Mun cá cơm→Miu cá ngừ→Mun cá rô→Tom chuyển xong: cá thu, cá ngừ → Miu sau cùng: cá thu→Miu cá cơm→Miu cá ngừ→Miu cá rô→Tom
Nhìn kỹ dòng "sau lỗi": giống hệt "trước". UPDATE cho cá thu đã chạy thành công (changes = 1) - nhưng nó nằm trong transaction chưa COMMIT, nên ROLLBACK kéo tất cả về đúng trạng thái trước BEGIN. Khuôn mẫu đáng nhớ: việc nằm trong try, COMMIT ở cuối try, ROLLBACK trong catch - lỗi ở bước nào cũng không để kho "một nửa". Mọi nghiệp vụ nhiều bước ngoài đời - chuyển khoản, đặt vé, trừ kho rồi tạo đơn - đều dựng trên transaction.
SQLite đưa mèo con đi xa đến đâu? Xa hơn nhiều người tưởng - nhưng không vô hạn. Ranh giới nằm ở chỗ ai cần ghi, và ghi từ đâu:
| SQLite | Postgres / MySQL | |
|---|---|---|
| Chạy ở đâu | trong tiến trình app; dữ liệu = MỘT tệp, không cài gì | server riêng; app kết nối qua mạng |
| Hợp với | app local, CLI, demo, test, site nhỏ-vừa | nhiều máy / nhiều service cùng ghi đồng thời |
| Giới hạn chính | sống trên một máy; ghi đồng thời phải xếp hàng | phải cài đặt, cấu hình, vận hành - nặng đô với dự án nhỏ |
Còn ORM (Prisma, Drizzle…) là một lớp nằm TRÊN cơ sở dữ liệu: bạn viết JavaScript/TypeScript, nó sinh SQL giúp và gắn kiểu cho kết quả. Tiện - nhưng khi query chậm hay trả về thứ không ngờ, cái bạn phải đọc để gỡ vẫn là SQL nó sinh ra. Vì vậy lộ trình đúng là như bài này: học SQL trần trước, ORM tính sau. Còn cần SQLite cho production ngay hôm nay thì dùng better-sqlite3 (xem FAQ).
Tiếp theo
Câu hỏi thường gặp
Có - mỗi query chạy xong mới tới dòng sau, đúng như chữ Sync trong tên. Nghe có vẻ "phạm luật" với những gì mèo con đã học về event loop, nhưng đây là đánh đổi có chủ ý: SQLite đọc một tệp ngay trên đĩa máy mình, query thông thường xong trong vài chục micro giây - rẻ hơn cả chi phí xếp hàng callback. Chỉ khi query thật sự nặng (quét hàng triệu dòng) chạy trên server đang đông khách thì mới cần tính chuyện đẩy nó sang Worker Thread.
Không phí: better-sqlite3 là thư viện npm lâu năm, cũng đồng bộ, API gần như trùng (prepare/run/get/all) - kiến thức bài này mang sang dùng được gần nguyên vẹn. Khác biệt chính: better-sqlite3 đã ổn định nhiều năm nhưng phải cài qua npm; node:sqlite có sẵn trong Node nhưng đang experimental. Dự án thật hôm nay: better-sqlite3. Script nhỏ, bài tập, công cụ cá nhân: node:sqlite cho đỡ phải cài gì.
Theo quy ước: boolean lưu bằng INTEGER 0/1; ngày giờ lưu bằng TEXT chuỗi ISO ("2026-06-11T08:30:00Z") hoặc INTEGER mili giây epoch (chính là Date.now()). Cả hai dạng đều so sánh và sắp xếp được trong SQL; quay về JS thì new Date(giaTri) là xong. Chọn một dạng rồi dùng nhất quán cho cả bảng.
Nó vô hiệu hoá chuỗi độc ở vị trí GIÁ TRỊ - đúng chỗ dữ liệu người dùng hay đứng. Nhưng tên bảng, tên cột hay chiều ORDER BY thì không tham số hoá được (SQL không cho viết SELECT * FROM ?). Nếu những phần đó phụ thuộc đầu vào người dùng, phải đối chiếu với một danh sách cho phép (whitelist) do bạn tự định nghĩa - tuyệt đối không ghép thẳng chuỗi vào query.
Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.
Trả lời vài câu để chắc rằng em đã nắm bài.
Cần INSERT tên cá do NGƯỜI DÙNG nhập vào bảng ca. Cách viết đúng?
- 1
Kho bền qua tắt mở
Mèo con chạy tao-kho.mjs rồi kho-crud.mjs (Bước 2-3), tắt hẳn terminal, mở terminal mới và viết một script CHỈ SELECT để đọc lại kho.
Hoàn thành khi: Không chạy lại INSERT mà 4 con cá vẫn đó - dữ liệu sống trong kho-ca.db, không sống trong tiến trình Node.
- 2
Tự hại mình bằng nối chuỗi
Viết bản INSERT "xấu" bằng nối chuỗi (ghép thẳng biến tên cá vào giữa hai dấu nháy đơn trong câu SQL), thử thêm con cá tên
ca'basa. Rồi làm lại bằng prepared statement với?.Hoàn thành khi: Bản nối chuỗi văng lỗi
near "basa": syntax errordù chẳng ai tấn công; bản dùng?lưu đúng cái tên có dấu nháy. - 3
Thêm cột cho kho
Chạy
ALTER TABLE ca ADD COLUMN ngay_bat TEXT, UPDATE vài con cá với chuỗi ISO (gợi ý:new Date().toISOString()), rồi SELECT những conWHERE ngay_bat IS NULL.Hoàn thành khi: Schema đổi mà dữ liệu cũ còn nguyên; mèo con phân biệt được NULL (chưa có giá trị) với chuỗi rỗng.
- 4
Thống kê kho theo mèo
Sau Bước 4, chạy query
SELECT chu_meo, COUNT(*) AS so_ca, SUM(khoi_luong) AS tong_kg FROM ca GROUP BY chu_meobằng.all().Hoàn thành khi: Kết quả: Miu 3 con - 7.9 kg, Tom 1 con - 0.8 kg; khớp khi mèo con tự cộng tay từng dòng.
- 5
Transaction đổi cá
Viết
doiCa(tenA, meoA, tenB, meoB): mèo A đưa cá A cho mèo B và mèo B đưa cá B lại cho mèo A - hai UPDATE trong MỘT transaction. Thử trường hợp cá B không tồn tại.Hoàn thành khi: Hoặc cả hai con cùng đổi chủ, hoặc không con nào - kho không bao giờ rơi vào trạng thái "một nửa".
- 6
Cho kho cá bài 7 chỗ ở bền
Lấy REST API kho cá in-memory của bài 7, thay mảng JS bằng kho-ca.db: GET đọc bằng
.all(), POST ghi bằng.run().Hoàn thành khi: Tắt server rồi bật lại - dữ liệu POST trước đó vẫn trả về qua GET; đó chính là phần "bền" mà mảng không bao giờ có.