Thiết kế Schema Database cho ứng dụng Chat: Từ Zero đến Scale triệu User

Database tutorial - IT technology blog
Database tutorial - IT technology blog

Bắt đầu nhanh: Schema cơ bản trong 5 phút

Muốn làm app chat kiểu Telegram hay Messenger? Đừng vội vung tay vẽ Schema quá phức tạp. Với các dự án khởi đầu sử dụng PostgreSQL hoặc MySQL, bạn chỉ cần 4 bảng cốt lõi dưới đây là đủ để vận hành mượt mà.

-- Bảng người dùng
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    username VARCHAR(50) UNIQUE NOT NULL,
    avatar_url TEXT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Quản lý hội thoại (Dùng cho cả chat 1-1 và group)
CREATE TABLE conversations (
    id SERIAL PRIMARY KEY,
    is_group BOOLEAN DEFAULT FALSE,
    title VARCHAR(255),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Theo dõi thành viên trong từng hội thoại
CREATE TABLE participants (
    conversation_id INT REFERENCES conversations(id),
    user_id INT REFERENCES users(id),
    joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (conversation_id, user_id)
);

-- Lưu trữ tin nhắn
CREATE TABLE messages (
    id BIGSERIAL PRIMARY KEY,
    conversation_id INT REFERENCES conversations(id),
    sender_id INT REFERENCES users(id),
    content TEXT NOT NULL,
    message_type ENUM('text', 'image', 'file') DEFAULT 'text',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

Cấu trúc này xử lý tốt việc gửi tin nhắn và phân biệt chat đơn/nhóm. Tuy nhiên, khi hệ thống chạm mốc 1 triệu tin nhắn, những vấn đề về hiệu năng sẽ bắt đầu lộ diện.

Giải quyết các bài toán hóc búa khi scale

1. Chat 1-1 và Chat Group: Đừng tách riêng bảng

Sai lầm phổ biến của các bạn Junior là tách bảng private_messagesgroup_messages. Cách làm này khiến tính năng tìm kiếm tin nhắn toàn cục (Global Search) trở thành một cơn ác mộng. Hãy gộp chung vào bảng messages và dùng bảng participants để kiểm soát quyền truy cập.

Bảng participants chính là “trạm điều khiển”. Tại đây, bạn có thể dễ dàng thêm các cột như is_admin, is_muted hoặc last_read_at mà không làm ảnh hưởng đến logic cốt lõi của tin nhắn.

2. Quản lý trạng thái Online (Online Status)

Tuyệt đối không cập nhật trạng thái online trực tiếp vào SQL mỗi khi người dùng thao tác. Nếu có 10.000 người dùng hoạt động cùng lúc, số lượng query UPDATE khổng lồ sẽ khiến database “bay màu” ngay lập tức.

Giải pháp tối ưu là dùng Redis với cơ chế Heartbeat. Khi Client kết nối Socket, hãy chạy lệnh: SET user:1:status online EX 60. Cứ mỗi 30 giây, Client gửi một tín hiệu “ping” để gia hạn key. Nếu Redis không tìm thấy key, hệ thống tự hiểu người dùng đã offline.

3. Tối ưu hóa truy vấn lịch sử trò chuyện

Khi bảng tin nhắn phình to, câu lệnh SELECT kèm ORDER BY sẽ chạy chậm dần đều. Để xử lý, bạn cần đánh Index (Chỉ mục) cho cặp (conversation_id, created_at).

CREATE INDEX idx_messages_conversation_time ON messages (conversation_id, created_at DESC);

Thực tế cho thấy, Index đúng giúp giảm thời gian truy vấn từ 2-3 giây xuống còn vài miligiây. Tuy nhiên, hãy nhớ rằng Index càng nhiều thì tốc độ INSERT sẽ càng chậm lại. Bạn cần cân bằng giữa trải nghiệm đọc và ghi.

Nâng cao: Khi SQL bắt đầu quá tải

Nếu ứng dụng may mắn đạt quy mô như Zalo hay Slack, một database SQL duy nhất sẽ gặp hiện tượng nghẽn cổ chai. Đây là lúc bạn cần những vũ khí hạng nặng hơn.

Chuyển sang NoSQL cho tin nhắn

Tin nhắn có đặc thù là ghi rất nhiều nhưng cực kỳ ít sửa. MongoDB hoặc Cassandra là lựa chọn tuyệt vời cho bài toán này. Cấu trúc Document cho phép bạn lưu hàng tá Reaction (thả tim, icon) ngay trong tin nhắn mà không cần thực hiện các lệnh JOIN phức tạp.

Database Sharding

Với PostgreSQL, bạn có thể áp dụng Sharding để chia nhỏ dữ liệu ra nhiều server. Ví dụ: Server A chứa hội thoại từ ID 1 đến 1 triệu, Server B chứa phần còn lại. Hệ thống sẽ chịu tải tốt hơn nhưng bù lại việc vận hành và backup sẽ phức tạp hơn đáng kể.

Tips thực tế cho dân Backend

Xử lý trạng thái “Đã xem” (Read Receipts)

Đừng tạo bảng riêng cho mỗi lượt xem, vì số bản ghi sẽ tăng theo cấp số nhân. Mẹo nhỏ: Chỉ cần lưu last_read_message_id trong bảng participants. Khi cần tính số tin nhắn chưa đọc, bạn chỉ việc đếm các tin nhắn có ID lớn hơn ID cuối cùng người dùng đã xem.

Phân trang bằng Cursor-based Pagination

Đừng dùng OFFSET khi phân trang tin nhắn cũ. OFFSET bắt database phải quét qua toàn bộ các dòng trước đó, cực kỳ tốn tài nguyên. Hãy dùng Cursor: Client gửi ID của tin nhắn cũ nhất đang có, Server sẽ lấy 20 tin nhắn có ID nhỏ hơn ID đó.

-- Cách làm chuẩn:
SELECT * FROM messages 
WHERE conversation_id = 1 AND id < 12345 
ORDER BY id DESC 
LIMIT 20;

Thiết kế database cho chat là bài toán cân bằng giữa tốc độ ghi và khả năng truy xuất. Hy vọng những kinh nghiệm thực chiến này giúp bạn tự tin hơn khi xây dựng hệ thống của riêng mình.

Share: