Kết nối MySQL trong Node.js với mysql2: Connection Pooling, Prepared Statements và Transaction

MySQL tutorial - IT technology blog
MySQL tutorial - IT technology blog

Vấn đề thực tế khi kết nối MySQL trong Node.js

Mình nhớ hồi mới làm backend Node.js, cách kết nối MySQL đầu tiên mình học trông như thế này:

const mysql = require('mysql');
const connection = mysql.createConnection({
  host: 'localhost',
  user: 'root',
  password: '123456',
  database: 'myapp'
});

connection.connect();

app.get('/user/:id', (req, res) => {
  const id = req.params.id;
  connection.query(`SELECT * FROM users WHERE id = ${id}`, (err, results) => {
    res.json(results);
  });
});

Code chạy được, test local thấy ổn, push lên production… rồi khách hàng phản ánh web chậm, thỉnh thoảng lỗi connection. Sau đó mình phát hiện thêm vấn đề nghiêm trọng hơn: URL /user/1 OR 1=1 trả về toàn bộ dữ liệu users trong database.

Ba lỗi cùng lúc: SQL Injection, không có connection pooling, và đang dùng thư viện cũ đã ngừng maintain. Bài này mình giải quyết cả ba, dùng mysql2 — phiên bản hiện đại hơn với Promise/async-await native và performance tốt hơn đáng kể so với package mysql gốc.

Phân tích nguyên nhân

Tại sao một connection đơn lẻ không đủ?

Node.js xử lý request theo event loop — nhiều request đến cùng lúc. Nếu chỉ có một connection MySQL, các request phải xếp hàng chờ nhau. Tệ hơn nữa, khi connection đó bị ngắt do timeout hoặc MySQL server restart, toàn bộ ứng dụng lỗi ngay lập tức, không có cơ chế tự recover.

SQL Injection xảy ra như thế nào?

Khi bạn ghép chuỗi trực tiếp vào query:

// NGUY HIỂM — đừng bao giờ làm vậy
const query = `SELECT * FROM users WHERE username = '${username}'`;

Attacker nhập username là ' OR '1'='1, query biến thành:

SELECT * FROM users WHERE username = '' OR '1'='1'

Câu lệnh này trả về toàn bộ users. Nguy hiểm hơn là '; DROP TABLE users; -- có thể xóa sạch bảng dữ liệu.

Vì sao không dùng package mysql cũ?

Package mysql không được maintain tích cực từ nhiều năm nay. mysql2 hỗ trợ Promise native, tốt hơn về performance, tương thích hoàn toàn với API cũ và hỗ trợ MySQL 8.0 đầy đủ.

Các cách giải quyết

Cài đặt mysql2

npm install mysql2

Cách 1: Connection Pooling

Thay vì tạo một connection dùng mãi, tạo một pool — tập hợp các connection sẵn sàng. Khi có request, lấy connection từ pool ra dùng, xong trả lại:

// db.js — module dùng chung toàn ứng dụng
const mysql = require('mysql2/promise');

const pool = mysql.createPool({
  host: process.env.DB_HOST || 'localhost',
  user: process.env.DB_USER || 'appuser',
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME || 'myapp',
  waitForConnections: true,
  connectionLimit: 10,   // tối đa 10 connections đồng thời
  queueLimit: 0          // không giới hạn request chờ trong queue
});

module.exports = pool;

Database production của mình chạy MySQL 8.0 với khoảng 50GB data, và connectionLimit: 10 là điểm khởi đầu hợp lý — không nên đặt quá cao vì mỗi connection tốn RAM cả phía MySQL server. Mình monitor Threads_connected qua SHOW STATUS rồi điều chỉnh dựa trên metric thực tế thay vì đoán mò.

Cách 2: Prepared Statements — chặn SQL Injection

Prepared statement tách biệt code SQL và data. MySQL engine nhận câu SQL trước, compile rồi mới nhận data riêng — data không bao giờ được interpret như SQL code:

const pool = require('./db');

async function getUserById(id) {
  // Dấu ? là placeholder, mysql2 tự escape giá trị
  const [rows] = await pool.query(
    'SELECT id, username, email FROM users WHERE id = ?',
    [id]
  );
  return rows[0] || null;
}

async function searchUsers(keyword) {
  const [rows] = await pool.query(
    'SELECT id, username FROM users WHERE username LIKE ? OR email LIKE ?',
    [`%${keyword}%`, `%${keyword}%`]
  );
  return rows;
}

Attacker nhập ' OR '1'='1, mysql2 chuyển thành string literal được escape — hoàn toàn vô hại, query chỉ tìm đúng giá trị đó.

Cách 3: execute() vs query() — khi nào dùng cái nào?

mysql2 có hai cách chạy query có placeholder:

// query() — escape phía client, gửi một câu SQL đã xử lý
const [rows1] = await pool.query('SELECT * FROM users WHERE id = ?', [id]);

// execute() — true prepared statement, MySQL server compile riêng
const [rows2] = await pool.execute('SELECT * FROM users WHERE id = ?', [id]);
  • Dùng execute() cho query chạy lặp lại nhiều lần — MySQL cache execution plan, tốc độ cao hơn đáng kể khi load lớn
  • Dùng query() cho query chỉ chạy một lần hoặc query phức tạp không dùng được prepared statement

Cách 4: Transaction để đảm bảo tính toàn vẹn dữ liệu

Transaction đảm bảo “all or nothing” — hoặc tất cả thao tác thành công, hoặc rollback về trạng thái ban đầu. Ví dụ chuyển tiền giữa hai tài khoản:

async function transferMoney(fromId, toId, amount) {
  const connection = await pool.getConnection();

  try {
    await connection.beginTransaction();

    // Trừ tiền tài khoản nguồn (kiểm tra số dư trong cùng câu lệnh)
    const [result] = await connection.execute(
      'UPDATE accounts SET balance = balance - ? WHERE id = ? AND balance >= ?',
      [amount, fromId, amount]
    );

    if (result.affectedRows === 0) {
      throw new Error('Số dư không đủ hoặc tài khoản không tồn tại');
    }

    // Cộng tiền tài khoản đích
    await connection.execute(
      'UPDATE accounts SET balance = balance + ? WHERE id = ?',
      [amount, toId]
    );

    // Ghi log giao dịch
    await connection.execute(
      'INSERT INTO transactions (from_id, to_id, amount, created_at) VALUES (?, ?, ?, NOW())',
      [fromId, toId, amount]
    );

    await connection.commit();
    return { success: true };

  } catch (error) {
    await connection.rollback();
    throw error;
  } finally {
    // QUAN TRỌNG: luôn release về pool dù thành công hay thất bại
    connection.release();
  }
}

Pattern try/catch/finally với connection.release() trong finally là bắt buộc. Nếu quên release, connection bị “leak” ra khỏi pool — sau vài giờ chạy, hệ thống hết connection, toàn bộ request treo mà không rõ lý do.

Cách tốt nhất — Kết hợp cả ba kỹ thuật

Mình thấy cách tổ chức hiệu quả nhất là tách riêng module database và viết hàm wrapper cho từng tác vụ:

// models/user.js
const pool = require('../db');

class UserModel {
  async findById(id) {
    const [rows] = await pool.execute(
      'SELECT id, username, email, created_at FROM users WHERE id = ?',
      [id]
    );
    return rows[0] || null;
  }

  async create(username, email, hashedPassword) {
    const [result] = await pool.execute(
      'INSERT INTO users (username, email, password, created_at) VALUES (?, ?, ?, NOW())',
      [username, email, hashedPassword]
    );
    return result.insertId;
  }

  // Transaction: tạo user + profile trong một atomic operation
  async registerWithProfile(userData) {
    const conn = await pool.getConnection();
    try {
      await conn.beginTransaction();

      const [userResult] = await conn.execute(
        'INSERT INTO users (username, email, password) VALUES (?, ?, ?)',
        [userData.username, userData.email, userData.password]
      );
      const userId = userResult.insertId;

      await conn.execute(
        'INSERT INTO profiles (user_id, display_name, bio) VALUES (?, ?, ?)',
        [userId, userData.displayName, userData.bio || '']
      );

      await conn.commit();
      return userId;
    } catch (err) {
      await conn.rollback();
      throw err;
    } finally {
      conn.release();
    }
  }
}

module.exports = new UserModel();
// server.js — kiểm tra kết nối khi khởi động
const pool = require('./db');

async function startServer() {
  try {
    const [rows] = await pool.query('SELECT 1 AS health');
    console.log('Database:', rows[0].health === 1 ? 'Connected OK' : 'FAIL');
  } catch (err) {
    console.error('Cannot connect to database:', err.message);
    process.exit(1);
  }

  const app = require('./app');
  app.listen(3000, () => console.log('Server running on port 3000'));
}

startServer();

Checklist trước khi ship code

  • Không ghép chuỗi vào SQL — luôn dùng placeholder ?, không có ngoại lệ
  • Pool là singleton — khởi tạo một lần trong db.js, import ở mọi nơi cần dùng
  • Luôn release connection sau transaction trong finally block, không phải trong try
  • Credentials từ biến môi trường — không hardcode trong source code, không commit .env
  • execute() cho query lặp lại, query() cho query chạy một lần

Kể từ khi chuyển sang pattern này, hệ thống của mình ổn định hơn hẳn — không còn tình trạng hết connection lúc traffic cao, không phải lo SQL Injection nữa. Những optimization này đã giúp query nhanh hơn đáng kể nhờ MySQL cache execution plan cho các prepared statement được gọi nhiều lần, đặc biệt rõ khi query count lên đến vài trăm lần mỗi phút.

Share: