Mở rộng Leaderboard thời gian thực cho hơn 10 triệu người dùng với Redis Sorted Sets

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

Xếp hạng thời gian thực ở quy mô lớn

Cơ sở dữ liệu MySQL của chúng tôi đã chạm giới hạn ngay khi đạt mốc một triệu người dùng. Chúng tôi đang xây dựng tính năng thi đấu trong game, nơi người chơi mong muốn thấy thứ hạng toàn cầu của họ thay đổi ngay lập tức khi nhận được điểm. Mặc dù cơ sở dữ liệu quan hệ rất tốt cho nhiều việc, nhưng việc chạy ORDER BY score DESC trên một bảng có hàng triệu dòng mỗi khi người dùng kiểm tra hồ sơ là một thảm họa.

Độ trễ rất rõ rệt. Thời gian phản hồi API vọt lên hơn 2.000ms và CPU của database luôn ở mức 90%. Chúng tôi cần một giải pháp có thể xử lý hàng nghìn cập nhật điểm số đồng thời mỗi giây trong khi vẫn cung cấp khả năng tra cứu thứ hạng tức thì. Điều đó đã dẫn chúng tôi đến với Redis Sorted Sets (ZSETs). Sau sáu tháng triển khai thực tế, hệ thống xếp hạng của chúng tôi hiện xử lý các mức tải đỉnh điểm một cách nhẹ nhàng. Đây là cách chúng tôi đã xây dựng nó.

Bắt đầu nhanh: Tạo Leaderboard trong 5 phút

Redis Sorted Sets được xây dựng cho chính mục đích này. Mỗi phần tử trong một Sorted Set được đi kèm with một điểm số (score). Redis sử dụng các điểm số này để tự động giữ mọi thứ theo thứ tự. Không giống như một danh sách tiêu chuẩn, bạn có thể tìm thứ hạng của một người dùng cụ thể hoặc lấy danh sách những người chơi hàng đầu trong thời gian logarit.

Các lệnh cơ bản

Bạn chỉ cần bốn lệnh cốt lõi để quản lý một bảng xếp hạng hiệu năng cao:

# Thêm người dùng hoặc cập nhật điểm số của họ
ZADD game_leaderboard 1500 "user_88"

# Tăng điểm số (hoàn hảo cho việc cộng điểm thời gian thực)
ZINCRBY game_leaderboard 50 "user_88"

# Lấy danh sách 10 người chơi hàng đầu (từ cao đến thấp)
ZREVRANGE game_leaderboard 0 9 WITHSCORES

# Tìm thứ hạng chính xác của một người dùng cụ thể
ZREVRANK game_leaderboard "user_88"

Triển khai bằng Python

Sử dụng thư viện redis-py, bạn có thể đóng gói các lệnh này vào một dịch vụ đơn giản. Cách tiếp cận này giúp logic ứng dụng của bạn sạch sẽ và các truy vấn cơ sở dữ liệu nhanh chóng.

import redis

r = redis.Redis(host='localhost', port=6379, db=0)

def update_score(user_id, points):
    # ZINCRBY cộng thêm điểm vào điểm hiện có hoặc tạo mới
    r.zincrby("global_leaderboard", points, user_id)

def get_top_players(n=10):
    # Trả về danh sách các bộ giá trị (user_id, score)
    return r.zrevrange("global_leaderboard", 0, n-1, withscores=True)

def get_user_rank(user_id):
    # ZREVRANK bắt đầu từ index 0, vì vậy chúng ta cộng thêm 1 để có thứ hạng dễ đọc
    rank = r.zrevrank("global_leaderboard", user_id)
    return rank + 1 if rank is not None else None

# Cách sử dụng
update_score("player_alpha", 100)
print(f"Top 10: {get_top_players()}")
print(f"Thứ hạng của bạn: {get_user_rank('player_alpha')}")

Kiến trúc: Tại sao nó có thể mở rộng

Bên dưới hệ thống, Redis kết hợp **Skip List** with một **Hash Table**. Cách tiếp cận cấu trúc kép này đảm bảo rằng hầu hết các hoạt động, bao gồm thêm người dùng hoặc lấy thứ hạng, đều duy trì độ phức tạp O(log(N)). Trong một bảng xếp hạng có 10 triệu người dùng, việc tìm kiếm một thứ hạng chỉ tốn khoảng 24 phép so sánh.

Các cơ sở dữ liệu SQL tiêu chuẩn gặp khó khăn vì chúng thường cần quét các index lớn hoặc xử lý I/O ổ đĩa nặng để duy trì thứ tự. Redis giữ toàn bộ cấu trúc trong bộ nhớ. Khi bạn cập nhật điểm số, Skip List cho phép Redis định vị lại người dùng gần như ngay lập tức mà không cần xây dựng lại toàn bộ bảng.

Vào lúc cao điểm, chúng tôi xử lý 5.000 cập nhật điểm mỗi giây. Mức sử dụng CPU của Redis hiếm khi vượt quá 15%. So với thiết lập PostgreSQL cũ của chúng tôi (gây trễ API tới 2 giây), thời gian phản hồi dưới một mili giây từ Redis giống như phép màu đối với người dùng.

Sử dụng nâng cao: Giải quyết các vấn đề thực tế

1. Xử lý trường hợp bằng điểm một cách công bằng

Redis mặc định sắp xếp theo bảng chữ cái cho những người dùng có điểm số giống nhau. Trong môi trường cạnh tranh, người chơi đạt được điểm số trước thường xứng đáng có thứ hạng cao hơn. Chúng tôi giải quyết vấn đề này bằng cách tạo ra một “điểm số tổng hợp” (composite score).

Bằng cách thêm một phần thập phân từ dấu thời gian (timestamp) vào điểm gốc, bạn có thể phân định thứ hạng theo trình tự thời gian. Vì chúng ta muốn timestamp sớm hơn giành chiến thắng, chúng ta trừ thời gian hiện tại từ một hằng số ngày trong tương lai.

import time

# Một hằng số tương lai (ví dụ: năm 2050)
MAX_TIMESTAMP = 2524608000 

def update_score_with_tie_break(user_id, base_score):
    # Dấu thời gian càng sớm, phần thập phân càng lớn
    time_fraction = (MAX_TIMESTAMP - int(time.time())) / MAX_TIMESTAMP
    final_score = base_score + time_fraction
    r.zadd("leaderboard_with_ties", {user_id: final_score})

2. Khung thời gian theo ngày và tuần

Người chơi tham gia nhiều hơn khi họ có cơ hội chiến thắng mới. Chúng tôi quản lý các bảng xếp hạng theo thời gian bằng cách sử dụng các key động như leaderboard:2023-10-27. Chúng tôi cũng thiết lập thời gian hết hạn (TTL) cho các key này. Điều này đảm bảo dữ liệu cũ hàng ngày được xóa tự động, giúp giữ chi phí bộ nhớ ở mức thấp.

3. Chế độ xem “Mạng xã hội”: Những người quanh tôi

Thứ hạng toàn cầu rất tuyệt, nhưng việc nhìn thấy ba người chơi ngay trên và dưới bạn thường tạo động lực hơn. Bạn có thể triển khai tính năng này bằng cách lấy thứ hạng của người dùng và tính toán một phạm vi nhỏ quanh đó.

def get_around_me(user_id, radius=3):
    rank = r.zrevrank("global_leaderboard", user_id)
    if rank is None: return []
    
    # Lấy danh sách người chơi trong phạm vi xác định
    start = max(0, rank - radius)
    end = rank + radius
    return r.zrevrange("global_leaderboard", start, end, withscores=True)

Bài học sau 6 tháng vận hành thực tế

Quản lý bộ nhớ

RAM là tài nguyên đắt đỏ nhất trong Redis. Đối với một bảng xếp hạng có 10 triệu người dùng sử dụng UUID làm key, dự kiến sẽ tiêu tốn khoảng 1,2GB đến 1,5GB bộ nhớ. Chúng tôi khuyên bạn nên thiết lập chính sách maxmemory. Chúng tôi sử dụng volatile-lru để ngăn chặn sự cố, mặc dù chúng tôi giám sát chặt chẽ để mở rộng theo chiều dọc (scale vertically) trước khi chạm tới giới hạn đó.

Chiến lược lưu trữ dữ liệu bền vững

Dữ liệu bảng xếp hạng cần phải tồn tại sau khi khởi động lại. Chúng tôi sử dụng **AOF (Append Only File)** with chính sách everysec. Điều này cân bằng giữa hiệu năng và an toàn. Trong trường hợp xảy ra sự cố, chúng tôi có thể mất một giây cập nhật điểm số, đây là một sự đánh đổi hợp lý cho tốc độ cực nhanh mà chúng tôi có được trong quá trình vận hành bình thường.

Logic nguyên tử với Lua

Đôi khi bạn chỉ muốn cập nhật điểm nếu điểm mới tốt hơn điểm cũ. Việc gửi nhiều lệnh qua lại gây ra độ trễ mạng. Một đoạn mã Lua nhỏ cho phép bạn chạy logic này trực tiếp trên máy chủ Redis trong một bước nguyên tử duy nhất.

-- Chỉ cập nhật nếu điểm mới (ARGV[1]) cao hơn điểm hiện tại
local current_score = redis.call('ZSCORE', KEYS[1], ARGV[2])
if not current_score or tonumber(ARGV[1]) > tonumber(current_score) then
    return redis.call('ZADD', KEYS[1], ARGV[1], ARGV[2])
end
return 0

Kết luận cuối cùng

Chúng tôi vẫn sử dụng PostgreSQL cho hồ sơ người dùng và lịch sử thanh toán, nhưng việc chuyển logic xếp hạng sang Redis là một bước ngoặt. Nó tách biệt các lượt ghi tần suất cao khỏi cơ sở dữ liệu chính và cắt giảm đáng kể độ trễ. Nếu ứng dụng của bạn đang gặp khó khăn trong việc sắp xếp hàng triệu dòng trong thời gian thực, Redis Sorted Sets là công cụ hiệu quả nhất cho công việc này.

Share: