Chọn thiết kế đơn giản

Có lẽ chúng ta đề rất tò mò không biết một hệ thống chat của những tập đoàn khổng lồ như Facebook hay Viber, Telegram có thiết kế như thế nào mà có thể phục vụ được hàng tỉ người dùng như thế, cho đến khi chúng ta cũng taọ ra một hệ thống tương tự. Mặc dù chúng ta có thể test tải ngay từ khi sản phẩm còn chưa đưa ra thị trường nhưng chừng đó là chưa đủ, chỉ khi có người dùng thật sự thì chúng ta mới biết được tất cả tình huống có thể xảy ra và khắc phục nó. Vậy nên ngay từ đầu chúng hãy lựa chọn một thiết kế đơn giản nhất có thể để sau này khi có nâng cấp thì mọi thứ mới đỡ rắc rối.

Các thành phần của một ứng dụng chat

Về cơ bản thì một ứng dụng chat được cấu thành bởi 3 thành phần chính

  1. Ở trung tâm đó chính là kênh chat (channel). Kênh chat này có thể bao gồm 2 người (chúng ta gọi đó là chat riêng) hoặc bao gồm rất nhiều người (chúng ta gọi đó là nhóm chat hay phòng chat)
  2. Người dùng (User): Là người sẽ đăng nhập vào hệ thống và tham gia vào các channel
  3. Tin nhắn (Message): Là tin nhắn mà người dùng sẽ gửi vào các kênh chat

Phân tích sâu hơn

Nào, bây giờ chúng ta sẽ phân tích kỹ hơn một chút về các nghiệp vụ có thể xảy ra để có một thiết kế phù hợp nhất. Câu hỏi đầu tiên: tại sao phải có channel? Mình đã chứng kiến thực tế một ứng dụng chat cho cả công ty với thiết kế bảng kiểu này:

Table UserMessage {
    messageId: Long,
    sender: String,
    receiver: String,
    message: String
}

Và thật kinh ngạc là hệ thống này phục vụ hàng trăm nghìn người trong suốt nhiều năm mà không gặp vấn đề gì nghiêm trọng (hoặc nghiêm trọng nhưng mình không biết). Và đến một ngày phát sinh nghiệp chat nhóm, nghĩa là những cuộc hội thoại cũ đều có thể thêm người mới vào được để trở thành một nhóm chat, và cơn đâu đầu diễn ra, giờ phải làm thế nào?

  1. Thêm một cột channelId vào bảng? Không được vì đang có hàng chục triệu. trăm triệu bản ghi rồi thêm trường sẽ làm treo hệ thống tạm thời, điều này ảnh hưởng tới dịch vụ
  2. Tạo ra một bảng mới? Không được, vì còn liên quan đến việc lấy các tin nhắn mà 2 người đã chat trước đó
  3. Thay đổi nghiệp vụ, tách phần chat nhóm ra thành một nghiệp vụ riêng biệt, từ đó có thể tạo được các bảng mới và không làm gián đoạn dịch vụ.

Vậy đó, nếu không có channel thì chúng ta sẽ gặp khó khăn trong việc mở rộng nghiệp vụ, vậy chẳng tội gì mà không tạo bảng Channel ngay từ đầu.

Bắt đầu thiết kế (phương án 1)

Tương ứng với 3 thành phần đã nói ở trên, chúng ta cũng sẽ có 3 bảng như hình vẽ.

Ưu điểm của thiết kế này đó là: Khi muốn gửi một tin nhắn đến một channel, nó sẽ tìm thấy danh sách các user nhận rất nhanh, chỉ đúng một câu truy vấn findByChannelId mà thôi. Nhưng nó cũng phát sinh vấn đề.

Vấn đề 1NF

Với thiết kế của bảng Channel: users: Set<String> tức là nó chứa một danh sách các users đúng không? Thiết kế này đang phi phạm nguyên tắc 1NF.

Vấn đề lấy danh sách channel

Vấn đề tiếp theo đó là lấy danh sách channel của user. Khi vào màn hình chat chúng ta sẽ cần trả cho user 1 loạt các channel được xếp theo tứ tự nào đó. Nếu sử dụng bảng Channel hiện tại thì chúng phải dùng câu truy vấn kiểu này:

select channelId from Channel where users contains :user

Rõ ràng là việc truy vấn kiểu này thì nó có thể rất chậm và chỉ có các hệ quản trị cơ sở dữ liệu NoSQL như MongoDB hay các công cụ đánh index như Elasticsearch mới làm được thôi, còn với các hệ quản trị cơ sở dữ liệu quan hệ như MySQL thì chịu, vậy nên chúng ta cần thêm bảng UserChannels

Cách làm này tương đối thú vị vì nó rất nhanh, chỉ trong một câu truy vấn là có thể lấy toàn bộ các channel của user rồi. Nhưng câu hỏi đặt ra là đối với những user có đến hàng triệu channel thì sao? Cứ giải sử là mỗi channel kiểu long 8 bytes thì hàng triệu channel sẽ là hàng triệu bytes tức là hàng chục hàng trăm MB dữ liệu sẽ gửi đến client cùng lúc, điều này không ổn. Nhưng liệu có bao giờ có user nào có đến hàng triệu kênh chat không nhỉ? Vậy hãy thử thay đổi bảng UserChannels thành UserChannel xem sao.

Với cách làm này thì dù user có đến hàng triệu kênh chat cũng không sao, chúng ta sẽ trả lần lượt các kênh chat cho user thông qua cơ chế phân trang. Nhưng chúng ta cũng cần phải đánh đổi bằng một ít hiệu năng vì câu truy vấn sẽ phức tạp hơn kiểu:

select channel from UserChannel where user = :user and channelId > :channeld
Vấn đề mở rộng hệ thống

Thêm một vấn đề nữa là khi chúng ta muốn cân tải hệ thống chat với nhiều server kiểu thế này:

Không thể lúc nào chúng ta cũng truy xuất vào db findByChannelId để lấy danh user thuộc channel cần gửi được, như vậy hiệu năng sẽ bị giảm thê thảm. Điều này buộc chúng ta phải lưu cache local trên từng server. Câu hỏi đặt ra là khi có nhiều user được thêm vào channel đồng thời trên tất cả các server thì sao? Rõ ràng là chúng ta sẽ gặp vấn đề về bài toán bất đồng bộ dữ liệu (inconsistent). Và chúng ta phải sinh ra một server chuyên để xử lý transaction kiểu thế này:

Lock lock = lockByChannel.get(channelId);
lock.lock();
try {
    Channel channel = channelCollection.findChannelById(channelId);
    channel.addUser(newUser);
    channelCollection.saveChannel(channel);
}
finally {
    lock.unlock();
}

Tuy nhiên nếu bạn không muốn sinh ra một server như vậy, bạn có thể tận dụng các hệ cơ sở dữ liệu có sẵn như MySQL để tận dụng cơ chế transaction.

Một thiết kế khác (phương án 2)

Chúng ta sẽ thay đổi thiết kế của bảng Channel và khoá chính sẽ là (Channel + user), nghĩa là một channel sẽ lưu thành nhiều bản ghi, và dữ liệu được lưu xuống sẽ có kiểu này:

1. channeld: 1, user: foo
2. channeld: 1, user: bar

Ưu điểm của phương pháp này là chúng ta sẽ tận dụng được các hệ quản trị cơ sở dữ liệu có sẵn, các câu truy vấn rất dễ dàng và chúng ta không cần thêm service nào khác nữa.

Nhưng nhược điểm thì lại không ít:

  1. Một channel sẽ bị duplicate ra nhiều bản ghi gây tốn chi phí ổ cứng và làm cơ sở dữ liệu bị phình to.
  2. Câu truy vấn sẽ nặng nề và cần ghép nối nhiều bản ghi mới lấy được một danh sách user hoàn chỉnh, nhất là khi cơ sở dữ liệu bị phân tán ra nhiều nơi thì tốc độ truy cập càng chậm
  3. Khi chúng ta muốn tổ chức với memory cache như Redis hay Hazelcast chẳng hạn thì dữ liệu được lưu trên cache (channelId: users) và dữ liệu trên database sẽ không đồng nhất, dẫn đến việc truy vấn khó khăn.

Kết luận

Hoá ra việc thiết kế bảng cho hệ thống chat cũng không hề đơn giản, với 2 cách thiết kế về cơ bản là như nhau chúng ta có thể sử dụng cách nào cũng được. Vì ban đầu khi công ty còn nhỏ, chưa có nhiều người sử dụng thì mình cũng chỉ cần một server và một database thôi cũng có thể đáp ứng hàng chục nghìn người dùng đồng thời rồi, và sau này khi đã có tiền thì mình cũng dễ dàng bổ sung các service xử lý transaction để giảm tải cho database, mà vẫn đảm bảo được hiệu năng. Còn với các bạn, các bạn thích phương án nào? Với MySQL phương án 2 có vẻ là hợp lý hơn.

Tham khảo

  1. Freechat demo
  2. Freechat project