Khi mới bắt đầu làm một sản phẩm, chúng ta thường chỉ deploy nó trên một server duy nhất. Mọi thứ đều đơn giản, application server tự quản lý state của người dùng trong bộ nhớ của nó là xong. Nhưng khi sản phẩm phát triển, lượng người dùng tăng lên, một server không thể gánh nổi. Lúc này, bài toán scale ứng dụng được đặt ra. Cách đơn giản nhất là scale theo chiều ngang (horizontal scaling) - tức là chạy ứng dụng trên nhiều server (instances) và dùng một Load Balancer để phân phối traffic.
Và đây là lúc vấn đề thực sự xuất hiện: làm thế nào để các instances này có cùng một "trạng thái" (state) về dữ liệu?
Hãy tưởng tượng một ứng dụng chat có nhiều server. User A đang kết nối với Server 1 gửi tin nhắn cho User B, người lại đang kết nối với Server 2. Làm thế nào để Server 2 biết có tin nhắn mới cho User B? Hay một trang e-commerce, khi một khách hàng mua sản phẩm cuối cùng trong kho, làm sao để tất cả các server khác đều cập nhật số lượng sản phẩm về 0 và không cho khách hàng khác đặt mua nữa?
Đây là bài toán kinh điển trong thiết kế hệ thống phân tán (distributed systems). Trong bài note này, chúng ta sẽ cùng tìm hiểu các cách tiếp cận phổ biến để giải quyết nó.
"State" là gì? Stateful vs. Stateless
Trước khi đi vào giải pháp, chúng ta cần định nghĩa rõ "state" là gì. Hiểu đơn giản, state là toàn bộ dữ liệu ghi nhớ trạng thái hiện tại của ứng dụng hoặc của một phiên làm việc (session). Ví dụ về state:
- Thông tin trong giỏ hàng của người dùng.
- Danh sách người dùng đang online trong một phòng chat.
- Nội dung của một tài liệu Google Docs đang được nhiều người chỉnh sửa chung.
- Dữ liệu session của người dùng sau khi đăng nhập.
Từ khái niệm state, chúng ta có hai loại kiến trúc chính:
1. Stateful Architecture
Đây là kiến trúc mà server có lưu trữ thông tin về phiên làm việc của client. Các request tiếp theo từ cùng một client sẽ phụ thuộc vào state đã được lưu từ các request trước. Ví dụ, server lưu giỏ hàng của bạn trong RAM của chính nó.
- Nhược điểm: Rất khó scale. Nếu một người dùng đã có session trên Server A, thì tất cả các request sau đó của họ phải được Load Balancer điều hướng đúng đến Server A (kỹ thuật này gọi là sticky session). Nếu Server A "chết", toàn bộ state của người dùng trên server đó cũng mất theo.
2. Stateless Architecture
Với kiến trúc này, mỗi request từ client gửi đến server đều chứa tất cả thông tin cần thiết để server có thể xử lý nó. Server không lưu bất kỳ thông tin nào về session của client giữa các request.
- Ưu điểm: Cực kỳ dễ scale theo chiều ngang. Vì không instance nào giữ state riêng của client, Load Balancer có thể gửi request đến bất kỳ instance nào còn trống. Đây là xu hướng thiết kế của các hệ thống hiện đại, đặc biệt là microservices.
Vậy câu hỏi là: nếu application server của chúng ta stateless, thì state sẽ được quản lý ở đâu? Câu trả lời là chúng ta sẽ đẩy việc quản lý state ra một thành phần chuyên dụng bên ngoài, và tất cả các instances sẽ giao tiếp với thành phần này.
Các giải pháp chia sẻ State
Đây là phần chính của bài viết. Dưới đây là 3 giải pháp phổ biến nhất để quản lý và chia sẻ state trong một hệ thống phân tán.
1. Centralized Database (Cơ sở dữ liệu tập trung)
Đây là cách tiếp cận đơn giản và quen thuộc nhất. Mọi state cần được chia sẻ sẽ được lưu vào một database chung mà tất cả các instances đều có thể truy cập (ví dụ: PostgreSQL, MySQL, MongoDB). Database lúc này trở thành "single source of truth" (nguồn chân lý duy nhất).
- Cách hoạt động: Khi một instance cần thay đổi state (ví dụ: người dùng thêm đồ vào giỏ hàng), nó sẽ ghi thay đổi đó vào database. Khi một instance khác cần đọc state (ví dụ: hiển thị giỏ hàng ở trang thanh toán), nó sẽ đọc từ database.
- Ưu điểm:
- Dễ triển khai, vì developer nào cũng quen thuộc với database.
- Đảm bảo tính nhất quán dữ liệu (consistency) cao, đặc biệt với các database hỗ trợ ACID.
- Dữ liệu được lưu trữ bền vững (persistent).
- Nhược điểm:
- Database có thể trở thành điểm nghẽn (bottleneck) của toàn hệ thống khi traffic tăng cao.
- Độ trễ (latency) truy cập sẽ cao hơn so với việc lưu state trong bộ nhớ, vì phải đọc/ghi từ đĩa.
Ví dụ: Lưu thông tin giỏ hàng vào bảng `carts`.
-- User thêm sản phẩm vào giỏ hàng, request được xử lý bởi Instance A INSERT INTO carts (user_id, product_id, quantity) VALUES (123, 888, 1) ON CONFLICT (user_id, product_id) DO UPDATE SET quantity = carts.quantity + 1; -- User vào trang thanh toán, request được xử lý bởi Instance B SELECT product_id, quantity FROM carts WHERE user_id = 123;
2. Distributed Cache / In-memory Data Store (Redis, Memcached)
Khi tốc độ là ưu tiên hàng đầu, việc đọc/ghi liên tục vào database cho những state thay đổi thường xuyên (như session, thông báo real-time) sẽ không hiệu quả. Đây là lúc các hệ thống cache phân tán như Redis tỏa sáng.
Redis không chỉ là một cache đơn thuần, nó là một "in-memory data structure server", cho phép lưu trữ và truy xuất dữ liệu từ RAM với tốc độ cực nhanh.
- Cách hoạt động: Các instances sẽ đọc/ghi state vào một Redis cluster chung. Vì dữ liệu nằm trên RAM, độ trễ gần như không đáng kể.
- Ưu điểm:
- Tốc độ truy xuất cực nhanh, giảm tải đáng kể cho database chính.
- Hỗ trợ nhiều cấu trúc dữ liệu mạnh mẽ (Strings, Hashes, Lists, Sets, Pub/Sub) giúp giải quyết nhiều bài toán phức tạp.
- Nhược điểm:
- Mặc định dữ liệu trên RAM sẽ mất nếu server bị sập (dù Redis có cơ chế persistence để khắc phục phần nào).
- Chi phí cho RAM đắt hơn ổ đĩa.
- Việc vận hành và quản lý một Redis cluster sẽ phức tạp hơn.
Ví dụ: Dùng Redis để quản lý session của user trong Node.js (với ioredis).
// File: redis-client.js
import Redis from 'ioredis';
const redis = new Redis({ host: 'my-redis-cluster.com', port: 6379 });
export default redis;
// Instance A xử lý login và lưu session
import redis from './redis-client';
async function handleLogin(userId, userData) {
const sessionKey = `session:${userId}`;
// Lưu session dưới dạng JSON string, tự hết hạn sau 1 giờ
await redis.set(sessionKey, JSON.stringify(userData), 'EX', 3600);
}
// Instance B xử lý request cần xác thực
async function getUserFromSession(userId) {
const sessionKey = `session:${userId}`;
const sessionData = await redis.get(sessionKey);
return sessionData ? JSON.parse(sessionData) : null;
}
3. Message Queue / Pub/Sub (RabbitMQ, Kafka, Redis Pub/Sub)
Cách tiếp cận này hơi khác hai cách trên. Thay vì các instances cùng truy cập vào một nơi lưu trữ chung, chúng sẽ giao tiếp với nhau một cách bất đồng bộ thông qua một hệ thống trung gian gọi là message broker.
- Cách hoạt động: Khi có một sự kiện làm thay đổi state xảy ra ở Instance A, nó sẽ không tự mình thay đổi state mà sẽ "publish" một message chứa thông tin về sự kiện đó lên một "topic" hoặc "channel". Các instances khác (B, C, D...) đang "subscribe" (lắng nghe) topic này sẽ nhận được message và tự cập nhật state của mình hoặc thực hiện hành động tương ứng.
- Ưu điểm:
- Loose coupling: Các service không cần biết đến sự tồn tại của nhau, chúng chỉ cần biết về message broker. Rất phù hợp cho kiến trúc microservices.
- Khả năng mở rộng và độ bền cao: Dễ dàng thêm subscriber mới mà không ảnh hưởng hệ thống. Message có thể được xếp hàng (queue) và xử lý sau nếu service nhận đang bận hoặc bị lỗi.
- Nhược điểm:
- Phức tạp trong việc thiết lập và vận hành.
- Hệ thống sẽ đạt được eventual consistency (nhất quán cuối cùng), tức là sẽ có một độ trễ nhỏ giữa lúc sự kiện xảy ra và lúc tất cả các instances cập nhật xong. Không phù hợp cho các tác vụ yêu cầu nhất quán ngay lập tức.
Ví dụ: Dùng Redis Pub/Sub cho hệ thống chat real-time.
// Server 1: Nhận tin nhắn và publish
import redis from './redis-client';
const publisher = redis.duplicate(); // Cần tạo một client riêng cho publisher
function onNewMessage(roomId, message) {
const channel = `chat:${roomId}`;
publisher.publish(channel, JSON.stringify(message));
}
// Server 2, 3, 4...: Lắng nghe và đẩy tin nhắn cho client qua WebSocket
import redis from './redis-client';
const subscriber = redis.duplicate();
const roomId = 'general';
const channel = `chat:${roomId}`;
subscriber.subscribe(channel, (err, count) => {
if (err) {
console.error("Failed to subscribe: %s", err.message);
} else {
console.log(`Subscribed successfully to ${count} channels.`);
}
});
subscriber.on('message', (channel, message) => {
const msgObject = JSON.parse(message);
// Gửi tin nhắn đến tất cả client đang kết nối với server này trong phòng 'general'
pushMessageToClientsInRoom(roomId, msgObject);
});
Kết luận:
Không có một "chén thánh" nào cho mọi bài toán. Trong thực tế, các hệ thống lớn thường kết hợp nhiều giải pháp với nhau để tận dụng ưu điểm của từng loại:
- Centralized Database: Dùng cho các dữ liệu cốt lõi, yêu cầu tính nhất quán và bền vững cao (thông tin người dùng, đơn hàng, sản phẩm).
- Distributed Cache (Redis): Dùng cho các dữ liệu cần truy xuất với tốc độ nhanh, có thể chấp nhận mất mát ở mức độ nhất định (sessions, caching kết quả query, leaderboards, rate limiting).
- Message Queue: Dùng cho giao tiếp bất đồng bộ giữa các services, các tác vụ xử lý nền, và các hệ thống real-time (thông báo, chat, event sourcing).
Việc chuyển đổi kiến trúc từ stateful sang stateless ở tầng application server và lựa chọn đúng công cụ để quản lý state ở bên ngoài là bước đi tối quan trọng để xây dựng một hệ thống có khả năng mở rộng, chịu lỗi tốt và dễ dàng bảo trì.
Hy vọng qua bài note này, mọi người đã có cái nhìn tổng quan hơn về việc xử lý state trong hệ thống phân tán. Anh em hay dùng giải pháp nào trong dự án của mình, cùng chia sẻ ở phần bình luận nhé!