Thiết kế cấu trúc dữ liệu cho một mạng xã hội
Nội dung gốc
Lưu ý: Tài liệu này dẫn link trực tiếp tới các phần liên quan trong danh mục chủ đề system design để tránh lặp lại. Hãy tham khảo nội dung được dẫn link để nắm các ý chính cần thảo luận, các đánh đổi (tradeoff) và các phương án thay thế.
Bước 1: Phác thảo các trường hợp sử dụng và ràng buộc
Thu thập yêu cầu và xác định phạm vi bài toán. Đặt câu hỏi để làm rõ các trường hợp sử dụng (use case) và ràng buộc (constraint). Thảo luận các giả định.
Vì không có người phỏng vấn để trả lời các câu hỏi làm rõ, ta sẽ tự định nghĩa một số use case và ràng buộc.
Các trường hợp sử dụng (use cases)
Ta giới hạn bài toán chỉ xử lý các use case sau
- Người dùng (User) tìm kiếm một người nào đó và thấy đường đi ngắn nhất (shortest path) tới người được tìm
- Dịch vụ (Service) có tính sẵn sàng cao (high availability)
Ràng buộc và giả định
Nêu các giả định
- Lưu lượng truy cập không phân bố đều
- Một số lượt tìm kiếm phổ biến hơn hẳn các lượt khác, trong khi một số khác chỉ được thực hiện đúng một lần
- Dữ liệu đồ thị (graph) không vừa trên một máy duy nhất
- Các cạnh (edge) của đồ thị không có trọng số (unweighted)
- 100 triệu người dùng
- Trung bình 50 bạn bè mỗi người dùng
- 1 tỷ lượt tìm kiếm bạn bè mỗi tháng
Hãy luyện tập sử dụng các hệ thống truyền thống - không dùng các giải pháp chuyên cho đồ thị như GraphQL hay một cơ sở dữ liệu đồ thị (graph database) như Neo4j
Tính toán mức sử dụng
Hỏi rõ người phỏng vấn xem bạn có nên thực hiện các phép ước lượng nhanh (back-of-the-envelope) về mức sử dụng hay không.
- 5 tỷ quan hệ bạn bè
- 100 triệu người dùng * trung bình 50 bạn bè mỗi người dùng
- 400 yêu cầu tìm kiếm mỗi giây
Bảng quy đổi tiện dụng:
- 2,5 triệu giây mỗi tháng
- 1 yêu cầu mỗi giây = 2,5 triệu yêu cầu mỗi tháng
- 40 yêu cầu mỗi giây = 100 triệu yêu cầu mỗi tháng
- 400 yêu cầu mỗi giây = 1 tỷ yêu cầu mỗi tháng
Bước 2: Tạo thiết kế tổng quan (high level design)
Phác thảo thiết kế tổng quan với tất cả các thành phần quan trọng.

Bước 3: Thiết kế các thành phần cốt lõi
Đi sâu vào chi tiết từng thành phần cốt lõi.
Use case: Người dùng tìm kiếm một người nào đó và thấy đường đi ngắn nhất tới người được tìm
Hỏi rõ người phỏng vấn xem bạn cần viết bao nhiêu code.
Nếu không có ràng buộc hàng triệu người dùng (đỉnh - vertex) và hàng tỷ quan hệ bạn bè (cạnh - edge), ta có thể giải bài toán đường đi ngắn nhất không trọng số này bằng thuật toán tìm kiếm theo chiều rộng (BFS - breadth-first search) thông thường:
class Graph(Graph):
def shortest_path(self, source, dest):
if source is None or dest is None:
return None
if source is dest:
return [source.key]
prev_node_keys = self._shortest_path(source, dest)
if prev_node_keys is None:
return None
else:
path_ids = [dest.key]
prev_node_key = prev_node_keys[dest.key]
while prev_node_key is not None:
path_ids.append(prev_node_key)
prev_node_key = prev_node_keys[prev_node_key]
return path_ids[::-1]
def _shortest_path(self, source, dest):
queue = deque()
queue.append(source)
prev_node_keys = {source.key: None}
source.visit_state = State.visited
while queue:
node = queue.popleft()
if node is dest:
return prev_node_keys
prev_node = node
for adj_node in node.adj_nodes.values():
if adj_node.visit_state == State.unvisited:
queue.append(adj_node)
prev_node_keys[adj_node.key] = prev_node.key
adj_node.visit_state = State.visited
return None
Ta sẽ không thể chứa toàn bộ người dùng trên cùng một máy, nên cần phân mảnh (shard) người dùng ra nhiều Person Server và truy cập chúng thông qua một Lookup Service (dịch vụ tra cứu).
- Client gửi yêu cầu tới Web Server, chạy vai trò reverse proxy
- Web Server chuyển tiếp yêu cầu tới server Search API
- Server Search API chuyển tiếp yêu cầu tới User Graph Service
- User Graph Service thực hiện các việc sau:
- Dùng Lookup Service để tìm Person Server đang lưu thông tin của người dùng hiện tại
- Tìm tới Person Server tương ứng để lấy danh sách
friend_idscủa người dùng hiện tại - Chạy BFS với người dùng hiện tại là
sourcevà danh sáchfriend_idscủa người dùng hiện tại là id của từngadjacent_node(node kề) - Để lấy
adjacent_nodetừ một id cho trước:- User Graph Service sẽ lại phải giao tiếp với Lookup Service để xác định Person Server nào đang lưu
adjacent_nodeứng với id đó (chỗ này có thể tối ưu)
- User Graph Service sẽ lại phải giao tiếp với Lookup Service để xác định Person Server nào đang lưu
Hỏi rõ người phỏng vấn xem bạn nên viết bao nhiêu code.
Lưu ý: Phần xử lý lỗi được lược bỏ bên dưới cho đơn giản. Hãy hỏi xem bạn có cần viết code xử lý lỗi đầy đủ hay không.
Cài đặt Lookup Service:
class LookupService(object):
def __init__(self):
self.lookup = self._init_lookup() # key: person_id, value: person_server
def _init_lookup(self):
...
def lookup_person_server(self, person_id):
return self.lookup[person_id]
Cài đặt Person Server:
class PersonServer(object):
def __init__(self):
self.people = {} # key: person_id, value: person
def add_person(self, person):
...
def people(self, ids):
results = []
for id in ids:
if id in self.people:
results.append(self.people[id])
return results
Cài đặt Person:
class Person(object):
def __init__(self, id, name, friend_ids):
self.id = id
self.name = name
self.friend_ids = friend_ids
Cài đặt User Graph Service:
class UserGraphService(object):
def __init__(self, lookup_service):
self.lookup_service = lookup_service
def person(self, person_id):
person_server = self.lookup_service.lookup_person_server(person_id)
return person_server.people([person_id])
def shortest_path(self, source_key, dest_key):
if source_key is None or dest_key is None:
return None
if source_key is dest_key:
return [source_key]
prev_node_keys = self._shortest_path(source_key, dest_key)
if prev_node_keys is None:
return None
else:
# Duyệt ngược path_ids, bắt đầu từ dest_key
path_ids = [dest_key]
prev_node_key = prev_node_keys[dest_key]
while prev_node_key is not None:
path_ids.append(prev_node_key)
prev_node_key = prev_node_keys[prev_node_key]
# Đảo ngược danh sách vì ta đã duyệt ngược
return path_ids[::-1]
def _shortest_path(self, source_key, dest_key, path):
# Dùng id để lấy Person
source = self.person(source_key)
# Cập nhật hàng đợi bfs
queue = deque()
queue.append(source)
# prev_node_keys ghi lại từng bước nhảy từ
# source_key tới dest_key
prev_node_keys = {source_key: None}
# Dùng visited_ids để ghi lại các node đã thăm,
# khác với bfs thông thường vốn có thể
# lưu trạng thái này ngay trong node
visited_ids = set()
visited_ids.add(source.id)
while queue:
node = queue.popleft()
if node.key is dest_key:
return prev_node_keys
prev_node = node
for friend_id in node.friend_ids:
if friend_id not in visited_ids:
friend_node = self.person(friend_id)
queue.append(friend_node)
prev_node_keys[friend_id] = prev_node.key
visited_ids.add(friend_id)
return None
Ta sẽ dùng một REST API công khai:
$ curl https://social.com/api/v1/friend_search?person_id=1234
Phản hồi:
{
"person_id": "100",
"name": "foo",
"link": "https://social.com/foo",
},
{
"person_id": "53",
"name": "bar",
"link": "https://social.com/bar",
},
{
"person_id": "1234",
"name": "baz",
"link": "https://social.com/baz",
},
Với giao tiếp nội bộ, ta có thể dùng gọi thủ tục từ xa (RPC - Remote Procedure Call).
Bước 4: Mở rộng thiết kế (scale the design)
Xác định và xử lý các điểm nghẽn (bottleneck), dựa trên các ràng buộc.

Quan trọng: Đừng nhảy thẳng từ thiết kế ban đầu sang thiết kế cuối cùng!
Hãy nói rõ rằng bạn sẽ 1) Đo hiệu năng/Kiểm thử tải (Benchmark/Load Test), 2) Phân tích (Profile) để tìm điểm nghẽn, 3) xử lý các điểm nghẽn trong khi cân nhắc các phương án thay thế và đánh đổi, và 4) lặp lại. Xem bài Thiết kế hệ thống mở rộng tới hàng triệu người dùng trên AWS làm ví dụ về cách mở rộng dần thiết kế ban đầu.
Điều quan trọng là thảo luận những điểm nghẽn có thể gặp với thiết kế ban đầu và cách xử lý từng điểm. Ví dụ, việc thêm một Load Balancer với nhiều Web Server giải quyết được vấn đề gì? CDN? Master-Slave Replicas (bản sao master-slave)? Các phương án thay thế và đánh đổi của từng lựa chọn là gì?
Ta sẽ bổ sung một số thành phần để hoàn thiện thiết kế và xử lý các vấn đề về khả năng mở rộng. Các load balancer nội bộ không được vẽ ra để sơ đồ đỡ rối.
Để tránh lặp lại các thảo luận, hãy tham khảo các chủ đề system design sau để nắm các ý chính, đánh đổi và phương án thay thế:
- DNS
- Load balancer
- Mở rộng theo chiều ngang (horizontal scaling)
- Web server (reverse proxy)
- API server (tầng ứng dụng - application layer)
- Cache
- Các mẫu nhất quán (consistency patterns)
- Các mẫu sẵn sàng (availability patterns)
Để đáp ứng ràng buộc 400 yêu cầu đọc mỗi giây trung bình (cao hơn vào giờ cao điểm), dữ liệu người dùng có thể được phục vụ từ một Memory Cache (bộ nhớ đệm trong RAM) như Redis hoặc Memcached để giảm thời gian phản hồi và giảm lưu lượng tới các dịch vụ phía sau. Điều này đặc biệt hữu ích với những người thực hiện nhiều lượt tìm kiếm liên tiếp và những người có nhiều kết nối. Đọc tuần tự 1 MB từ bộ nhớ mất khoảng 250 micro giây, trong khi đọc từ SSD lâu hơn 4 lần và từ ổ đĩa (disk) lâu hơn 80 lần.1
Dưới đây là các tối ưu thêm:
- Lưu kết quả duyệt BFS toàn phần hoặc một phần vào Memory Cache để tăng tốc các lần tra cứu sau
- Tính toán theo lô (batch) ngoại tuyến rồi lưu kết quả duyệt BFS toàn phần hoặc một phần vào một NoSQL Database để tăng tốc các lần tra cứu sau
- Giảm số lần nhảy giữa các máy bằng cách gộp các lượt tra cứu bạn bè nằm trên cùng một Person Server thành một lô
- Shard các Person Server theo vị trí địa lý để cải thiện thêm, vì bạn bè thường sống gần nhau
- Chạy hai lượt BFS cùng lúc, một bắt đầu từ nguồn (source) và một từ đích (destination), rồi ghép hai đường đi lại
- Bắt đầu BFS từ những người có số lượng bạn bè lớn, vì họ có nhiều khả năng rút ngắn số bậc phân cách (degrees of separation) giữa người dùng hiện tại và người được tìm
- Đặt giới hạn theo thời gian hoặc số bước nhảy (hop) trước khi hỏi người dùng có muốn tiếp tục tìm không, vì trong một số trường hợp việc tìm kiếm có thể mất khá nhiều thời gian
- Dùng một Graph Database như Neo4j hoặc một ngôn ngữ truy vấn chuyên cho đồ thị như GraphQL (nếu không có ràng buộc cấm dùng Graph Database)
Các điểm thảo luận thêm (Additional talking points)
Các chủ đề bổ sung để đi sâu, tùy vào phạm vi bài toán và thời gian còn lại.
Các mẫu mở rộng SQL (SQL scaling patterns)
- Bản sao đọc (read replicas)
- Liên hợp (federation)
- Phân mảnh (sharding)
- Phi chuẩn hóa (denormalization)
- Tinh chỉnh SQL (SQL tuning)
NoSQL
Bộ nhớ đệm (caching)
- Cache ở đâu
- Cache cái gì
- Khi nào cập nhật cache
Xử lý bất đồng bộ và microservices
- Hàng đợi thông điệp (message queues)
- Hàng đợi tác vụ (task queues)
- Áp lực ngược (back pressure)
- Microservices
Giao tiếp (communications)
- Thảo luận các đánh đổi:
- Giao tiếp bên ngoài với client - HTTP API theo chuẩn REST
- Giao tiếp nội bộ - RPC
- Khám phá dịch vụ (service discovery)
Bảo mật (security)
Tham khảo phần bảo mật.
Các con số độ trễ (latency numbers)
Xem Các con số độ trễ mọi lập trình viên nên biết.
Liên tục
- Tiếp tục đo hiệu năng và giám sát hệ thống để xử lý các điểm nghẽn khi chúng xuất hiện
- Mở rộng hệ thống là một quá trình lặp đi lặp lại