Rust + MySQL + sqlx: Async Query, Connection Pool và Migration Type-safe cho Microservices hiệu năng cao

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

2 giờ sáng, service crash vì “too many connections”

Mình vẫn nhớ cái đêm đó rõ mồn một — 2 giờ sáng, Slack nhảy liên tục, MySQL trả về ERROR 1040: Too many connections. Service Rust đang chạy production, mỗi request tự mở một connection MySQL mới và không đóng lại. 800 Tokio task, mỗi task giữ một connection mở. Database sập hết.

Sau sự cố đó, mình refactor toàn bộ database layer sang sqlx với connection pool đúng nghĩa. Bài này ghi lại cái setup đó — đủ để bạn tránh vết xe đổ.

Quick Start: Chạy trong 5 phút

1. Thêm dependencies vào Cargo.toml

[dependencies]
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "mysql", "macros", "migrate"] }
tokio = { version = "1", features = ["full"] }
dotenvy = "0.15"

macros bật query! macro — cái kiểm tra SQL ngay lúc compile, không phải runtime. Còn migrate cần thiết để sqlx-cli chạy migration về sau.

2. Tạo pool và chạy query đầu tiên

use sqlx::mysql::MySqlPoolOptions;
use sqlx::MySqlPool;

#[tokio::main]
async fn main() -> Result<(), sqlx::Error> {
    dotenvy::dotenv().ok();
    let database_url = std::env::var("DATABASE_URL")
        .expect("DATABASE_URL must be set");

    let pool: MySqlPool = MySqlPoolOptions::new()
        .max_connections(20)
        .connect(&database_url)
        .await?;

    let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users")
        .fetch_one(&pool)
        .await?;

    println!("Total users: {}", row.0);
    Ok(())
}

File .env:

DATABASE_URL=mysql://user:password@localhost:3306/mydb
cargo run

In ra được số users là connect thành công. Tiếp theo là phần quan trọng hơn: pool sizing và type-safe query.

Giải thích chi tiết: Connection Pool và Async Query

Tại sao pool quan trọng?

MySQL mặc định giới hạn max_connections = 151. Mỗi connection tốn RAM (~8MB) và thời gian TLS handshake (~10–50ms). Nếu mỗi request mở connection mới là bạn sẽ gặp đúng sự cố mình gặp lúc 2 giờ sáng đó.

sqlx pool hoạt động kiểu: tạo sẵn N connections, tái sử dụng. Request đến → mượn connection → trả lại sau khi xong. Không mở/đóng liên tục.

Cấu hình pool đúng cho production

use std::time::Duration;
use sqlx::mysql::MySqlPoolOptions;

async fn create_pool(database_url: &str) -> Result<MySqlPool, sqlx::Error> {
    MySqlPoolOptions::new()
        .max_connections(20)           // Tổng connection tối đa
        .min_connections(5)            // Giữ sẵn 5 connection idle
        .acquire_timeout(Duration::from_secs(3))  // Timeout khi chờ connection rảnh
        .idle_timeout(Duration::from_secs(600))   // Đóng connection idle > 10 phút
        .max_lifetime(Duration::from_secs(1800))  // Tạo lại connection sau 30 phút
        .connect(database_url)
        .await
}

Kinh nghiệm thực tế: max_connections ≈ (số CPU core × 2) + số disk. VPS 4 core thì đặt 10–15 là ổn. Đặt quá cao không giúp gì thêm — MySQL vẫn bị bottleneck ở I/O disk.

Type-safe query với macro query!

Đây mới là điểm khiến sqlx khác hẳn các thư viện khác. Macro query! kết nối thẳng vào database lúc compile để kiểm tra schema:

#[derive(Debug, sqlx::FromRow)]
struct User {
    id: i64,
    username: String,
    email: String,
    created_at: chrono::NaiveDateTime,
}

async fn get_user_by_id(
    pool: &MySqlPool,
    user_id: i64,
) -> Result<Option<User>, sqlx::Error> {
    sqlx::query_as!(
        User,
        "SELECT id, username, email, created_at FROM users WHERE id = ?",
        user_id
    )
    .fetch_optional(pool)
    .await
}

Viết sai tên cột, sai kiểu dữ liệu → compiler báo lỗi ngay, không phải runtime. Đây là type-safety thật sự, không phải ORM giả vờ.

Cần set DATABASE_URL lúc build để sqlx kết nối DB kiểm tra schema:

export DATABASE_URL=mysql://user:password@localhost:3306/mydb
cargo build

Các fetch method thường dùng

// Lấy đúng 1 row — lỗi nếu không có hoặc nhiều hơn 1
let user = sqlx::query_as!(User, "SELECT ...").fetch_one(pool).await?;

// Lấy 1 row nếu có, None nếu không
let user = sqlx::query_as!(User, "SELECT ...").fetch_optional(pool).await?;

// Lấy tất cả
let users = sqlx::query_as!(User, "SELECT ...").fetch_all(pool).await?;

// Stream — xử lý từng row, tiết kiệm RAM khi dataset lớn
use futures::TryStreamExt;
let mut stream = sqlx::query_as!(User, "SELECT ...").fetch(pool);
while let Some(user) = stream.try_next().await? {
    process_user(user).await;
}

Nâng cao: Migration An toàn với sqlx-cli

Cài sqlx-cli

cargo install sqlx-cli --no-default-features --features rustls,mysql

Tạo và viết migration

sqlx migrate add create_users_table
# Tạo ra: migrations/20240101000000_create_users_table.sql
-- migrations/20240101000000_create_users_table.sql
CREATE TABLE users (
    id         BIGINT       NOT NULL AUTO_INCREMENT,
    username   VARCHAR(100) NOT NULL UNIQUE,
    email      VARCHAR(255) NOT NULL UNIQUE,
    password_hash VARCHAR(255) NOT NULL,
    created_at DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    INDEX idx_email (email)
);
# Chạy tất cả migration pending
sqlx migrate run

# Kiểm tra trạng thái
sqlx migrate info

Tự động migrate khi service khởi động

Cách mình làm cho microservices — tự migrate khi start, không cần CI/CD step riêng:

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let pool = create_pool(&database_url).await?;

    sqlx::migrate!("./migrations")
        .run(&pool)
        .await
        .expect("Failed to run database migrations");

    start_http_server(pool).await?;
    Ok(())
}

sqlx ghi lại migration đã chạy vào bảng _sqlx_migrations. Deploy nhiều instance cùng lúc không lo chạy trùng — có distributed lock xử lý rồi. Idempotent hoàn toàn.

Transaction chuẩn

async fn transfer_credits(
    pool: &MySqlPool,
    from_id: i64,
    to_id: i64,
    amount: i64,
) -> Result<(), sqlx::Error> {
    let mut tx = pool.begin().await?;

    sqlx::query!(
        "UPDATE wallets SET balance = balance - ? WHERE user_id = ?",
        amount, from_id
    )
    .execute(&mut *tx)
    .await?;

    sqlx::query!(
        "UPDATE wallets SET balance = balance + ? WHERE user_id = ?",
        amount, to_id
    )
    .execute(&mut *tx)
    .await?;

    tx.commit().await?
    // Nếu function trả Err hoặc tx bị drop trước commit → rollback tự động
}

Tips thực tế từ production

SQLX_OFFLINE cho CI/CD không có DB

# Trên máy local — generate cache
cargo sqlx prepare

# Commit vào git
git add .sqlx/
git commit -m "chore: update sqlx offline cache"

# Trong CI pipeline
SQLX_OFFLINE=true cargo build

Monitor pool health

tokio::spawn({
    let pool = pool.clone();
    async move {
        loop {
            tokio::time::sleep(Duration::from_secs(30)).await;
            log::info!(
                "DB Pool — size: {}, idle: {}",
                pool.size(),
                pool.num_idle()
            );
        }
    }
});

Nếu idle = 0 liên tục — pool đang bị saturation, cần tăng max_connections hoặc tối ưu query.

Xử lý lỗi pool gracefully

use sqlx::Error as SqlxError;

match get_user_by_id(&pool, user_id).await {
    Ok(Some(user)) => handle_user(user),
    Ok(None)       => return Err(AppError::NotFound),
    Err(SqlxError::PoolTimedOut) => {
        log::error!("DB pool exhausted — tăng max_connections hoặc tối ưu query");
        return Err(AppError::ServiceUnavailable);
    }
    Err(e) => {
        log::error!("DB error: {:?}", e);
        return Err(AppError::Internal);
    }
}

Backup trước khi migrate — bắt buộc

Mình từng gặp database corruption lúc 3 giờ sáng. Mất gần 4 tiếng restore từ backup cũ nhất tìm được. Từ đó, backup trước mỗi lần migrate là quy tắc không thương lượng — dù chỉ thêm một cột:

#!/bin/bash
# pre-migrate.sh
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
mysqldump -u"$DB_USER" -p"$DB_PASS" "$DB_NAME" > "backup_${TIMESTAMP}.sql"
echo "Backup saved: backup_${TIMESTAMP}.sql"
sqlx migrate run

Bulk insert hiệu quả với QueryBuilder

async fn bulk_insert_users(
    pool: &MySqlPool,
    users: &[NewUser],
) -> Result<(), sqlx::Error> {
    let mut builder = sqlx::QueryBuilder::new(
        "INSERT INTO users (username, email) "
    );
    builder.push_values(users.iter(), |mut b, u| {
        b.push_bind(&u.username).push_bind(&u.email);
    });
    builder.build().execute(pool).await?;
    Ok(())
}

Với 1000 records, cách này nhanh hơn 50–100x so với insert từng cái một trong vòng lặp.

Khi nào dùng sqlx thay ORM như Diesel hay SeaORM?

  • sqlx: SQL thật, type-safe tại compile time, kiểm soát tuyệt đối query, async native. Phù hợp microservices cần hiệu năng cao.
  • Diesel: ORM đầy đủ, sync (async hạn chế), compile-time schema check kiểu khác. Tốt cho app phức tạp cần abstraction cao.
  • SeaORM: ORM async, API giống ActiveRecord, ít boilerplate hơn sqlx. Phù hợp khi cần tốc độ phát triển hơn fine-grained control.

Mình chọn sqlx cho tất cả service cần chịu tải cao — query được optimize thủ công, không có magic query generation ẩn bên dưới gây surprise lúc production.

Share: