Thiết kế Mint.com
Nội dung gốc
Lưu ý: Tài liệu này liên kết 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 liên kết để nắm các ý thảo luận chung, các đánh đổi (tradeoff) và phương án thay thế.
Bước 1: Phác thảo use case 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õ use case và ràng buộc. 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.
Use case
Ta giới hạn bài toán chỉ xử lý các use case sau
- Người dùng (User) kết nối tới một tài khoản tài chính
- Dịch vụ (Service) trích xuất giao dịch từ tài khoản
- Cập nhật hằng ngày
- Phân loại giao dịch
- Cho phép người dùng ghi đè danh mục thủ công
- Không tự động phân loại lại
- Phân tích chi tiêu hằng tháng theo danh mục
- Dịch vụ đề xuất ngân sách
- Cho phép người dùng tự đặt ngân sách
- Gửi thông báo khi sắp chạm hoặc vượt ngân sách
- Dịch vụ có tính sẵn sàng cao (high availability)
Ngoài phạm vi
- Dịch vụ thực hiện thêm việc ghi log và phân tích (analytics)
Ràng buộc và giả định
Các giả định
- Lưu lượng truy cập không phân bố đều
- Việc tự động cập nhật tài khoản hằng ngày chỉ áp dụng cho người dùng hoạt động trong 30 ngày gần nhất
- Việc thêm hoặc xóa tài khoản tài chính tương đối hiếm
- Thông báo ngân sách không cần tức thời
- 10 triệu người dùng
- 10 danh mục ngân sách mỗi người dùng = 100 triệu mục ngân sách
- Ví dụ danh mục:
- Nhà ở (Housing) = $1,000
- Ăn uống (Food) = $200
- Xăng (Gas) = $100
- Người bán (seller) được dùng để xác định danh mục của giao dịch
- 50,000 người bán
- 30 triệu tài khoản tài chính
- 5 tỷ giao dịch mỗi tháng
- 500 triệu yêu cầu đọc mỗi tháng
- Tỷ lệ ghi:đọc là 10:1
- Thiên về ghi (write-heavy): người dùng phát sinh giao dịch hằng ngày, nhưng ít người vào trang web hằng ngày
Tính toán mức sử dụng
Hãy hỏi 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) hay không.
- Kích thước mỗi giao dịch:
user_id- 8 bytecreated_at- 5 byteseller- 32 byteamount- 5 byte- Tổng: ~50 byte
- 250 GB nội dung giao dịch mới mỗi tháng
- 50 byte mỗi giao dịch * 5 tỷ giao dịch mỗi tháng
- 9 TB nội dung giao dịch mới trong 3 năm
- Giả định phần lớn là giao dịch mới chứ không phải cập nhật giao dịch cũ
- Trung bình 2,000 giao dịch mỗi giây
- Trung bình 200 yêu cầu đọc 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 kết nối tới một tài khoản tài chính
Ta có thể lưu thông tin của 10 triệu người dùng trong một cơ sở dữ liệu quan hệ (relational database). Ta nên thảo luận về các use case và đánh đổi khi chọn SQL hay NoSQL.
- Client gửi yêu cầu tới Web Server, đang chạy dưới dạng reverse proxy
- Web Server chuyển tiếp yêu cầu tới máy chủ Accounts API
- Máy chủ Accounts API cập nhật bảng
accountstrong SQL Database với thông tin tài khoản vừa nhập
Hãy hỏi người phỏng vấn xem bạn cần viết bao nhiêu code.
Bảng accounts có thể có cấu trúc như sau:
id int NOT NULL AUTO_INCREMENT
created_at datetime NOT NULL
last_update datetime NOT NULL
account_url varchar(255) NOT NULL
account_login varchar(32) NOT NULL
account_password_hash char(64) NOT NULL
user_id int NOT NULL
PRIMARY KEY(id)
FOREIGN KEY(user_id) REFERENCES users(id)
Ta sẽ tạo chỉ mục (index) trên id, user_id và created_at để tăng tốc tra cứu (thời gian log thay vì quét toàn bộ bảng) và để giữ dữ liệu trong bộ nhớ. Đọ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
Ta sẽ dùng một REST API công khai:
$ curl -X POST --data '{ "user_id": "foo", "account_url": "bar", \
"account_login": "baz", "account_password": "qux" }' \
https://mint.com/api/v1/account
Với giao tiếp nội bộ, ta có thể dùng gọi thủ tục từ xa (Remote Procedure Call - RPC).
Tiếp theo, dịch vụ trích xuất giao dịch từ tài khoản.
Use case: Dịch vụ trích xuất giao dịch từ tài khoản
Ta sẽ muốn trích xuất thông tin từ tài khoản trong các trường hợp sau:
- Người dùng liên kết tài khoản lần đầu
- Người dùng làm mới (refresh) tài khoản thủ công
- Tự động mỗi ngày cho những người dùng hoạt động trong 30 ngày gần nhất
Luồng dữ liệu:
- Client gửi yêu cầu tới Web Server
- Web Server chuyển tiếp yêu cầu tới máy chủ Accounts API
- Máy chủ Accounts API đặt một job vào Hàng đợi (Queue) như Amazon SQS hoặc RabbitMQ
- Việc trích xuất giao dịch có thể mất khá lâu, nên ta có lẽ muốn làm việc này bất đồng bộ bằng hàng đợi, dù điều này làm tăng độ phức tạp
- Transaction Extraction Service thực hiện các việc sau:
- Lấy job từ Queue và trích xuất giao dịch của tài khoản tương ứng từ tổ chức tài chính, lưu kết quả dưới dạng file log thô (raw log) trong Object Store
- Dùng Category Service để phân loại từng giao dịch
- Dùng Budget Service để tính tổng chi tiêu hằng tháng theo danh mục
- Budget Service dùng Notification Service để báo cho người dùng biết nếu họ sắp chạm hoặc đã vượt ngân sách
- Cập nhật bảng
transactionstrong SQL Database với các giao dịch đã phân loại - Cập nhật bảng
monthly_spendingtrong SQL Database với tổng chi tiêu hằng tháng theo danh mục - Thông báo cho người dùng rằng việc xử lý giao dịch đã hoàn tất thông qua Notification Service:
- Dùng một Queue (không vẽ trong hình) để gửi thông báo bất đồng bộ
Bảng transactions có thể có cấu trúc như sau:
id int NOT NULL AUTO_INCREMENT
created_at datetime NOT NULL
seller varchar(32) NOT NULL
amount decimal NOT NULL
user_id int NOT NULL
PRIMARY KEY(id)
FOREIGN KEY(user_id) REFERENCES users(id)
Ta sẽ tạo chỉ mục trên id, user_id và created_at.
Bảng monthly_spending có thể có cấu trúc như sau:
id int NOT NULL AUTO_INCREMENT
month_year date NOT NULL
category varchar(32)
amount decimal NOT NULL
user_id int NOT NULL
PRIMARY KEY(id)
FOREIGN KEY(user_id) REFERENCES users(id)
Ta sẽ tạo chỉ mục trên id và user_id .
Category service
Với Category Service, ta có thể khởi tạo sẵn (seed) một từ điển ánh xạ người bán sang danh mục (seller-to-category) cho những người bán phổ biến nhất. Nếu ước tính có 50,000 người bán và mỗi mục chiếm dưới 255 byte, từ điển này chỉ tốn khoảng 12 MB bộ nhớ.
Hãy hỏi người phỏng vấn xem bạn cần viết bao nhiêu code.
class DefaultCategories(Enum):
HOUSING = 0
FOOD = 1
GAS = 2
SHOPPING = 3
...
seller_category_map = {}
seller_category_map['Exxon'] = DefaultCategories.GAS
seller_category_map['Target'] = DefaultCategories.SHOPPING
...
Với những người bán chưa được seed sẵn trong map, ta có thể dựa vào sức mạnh cộng đồng (crowdsourcing) bằng cách đánh giá các lần ghi đè danh mục thủ công mà người dùng cung cấp. Ta có thể dùng một heap để tra nhanh lựa chọn ghi đè phổ biến nhất của mỗi người bán trong thời gian O(1).
class Categorizer(object):
def __init__(self, seller_category_map, seller_category_crowd_overrides_map):
self.seller_category_map = seller_category_map
self.seller_category_crowd_overrides_map = \
seller_category_crowd_overrides_map
def categorize(self, transaction):
if transaction.seller in self.seller_category_map:
return self.seller_category_map[transaction.seller]
elif transaction.seller in self.seller_category_crowd_overrides_map:
self.seller_category_map[transaction.seller] = \
self.seller_category_crowd_overrides_map[transaction.seller].peek_min()
return self.seller_category_map[transaction.seller]
return None
Cài đặt lớp Transaction:
class Transaction(object):
def __init__(self, created_at, seller, amount):
self.created_at = created_at
self.seller = seller
self.amount = amount
Use case: Dịch vụ đề xuất ngân sách
Để bắt đầu, ta có thể dùng một mẫu ngân sách chung (generic budget template), phân bổ số tiền cho từng danh mục dựa trên các bậc thu nhập. Với cách này, ta không phải lưu 100 triệu mục ngân sách đã nêu trong phần ràng buộc, mà chỉ lưu những mục người dùng ghi đè. Nếu người dùng ghi đè một danh mục ngân sách, ta có thể lưu giá trị ghi đè đó trong bảng TABLE budget_overrides.
class Budget(object):
def __init__(self, income):
self.income = income
self.categories_to_budget_map = self.create_budget_template()
def create_budget_template(self):
return {
DefaultCategories.HOUSING: self.income * .4,
DefaultCategories.FOOD: self.income * .2,
DefaultCategories.GAS: self.income * .1,
DefaultCategories.SHOPPING: self.income * .2,
...
}
def override_category_budget(self, category, amount):
self.categories_to_budget_map[category] = amount
Với Budget Service, ta có thể chạy các truy vấn SQL trên bảng transactions để tạo ra bảng tổng hợp monthly_spending. Bảng monthly_spending nhiều khả năng có ít dòng hơn nhiều so với tổng số 5 tỷ giao dịch, vì mỗi người dùng thường có nhiều giao dịch trong một tháng.
Một phương án khác là chạy các job MapReduce trên các file giao dịch thô để:
- Phân loại từng giao dịch
- Tạo tổng chi tiêu hằng tháng theo danh mục
Chạy phân tích trên các file giao dịch có thể giảm đáng kể tải cho cơ sở dữ liệu.
Ta có thể gọi Budget Service để chạy lại phân tích nếu người dùng cập nhật một danh mục.
Hãy hỏi người phỏng vấn xem bạn cần viết bao nhiêu code.
Định dạng file log mẫu, phân tách bằng tab:
user_id timestamp seller amount
Cài đặt MapReduce:
class SpendingByCategory(MRJob):
def __init__(self, categorizer):
self.categorizer = categorizer
self.current_year_month = calc_current_year_month()
...
def calc_current_year_month(self):
"""Trả về năm và tháng hiện tại."""
...
def extract_year_month(self, timestamp):
"""Trả về phần năm và tháng của timestamp."""
...
def handle_budget_notifications(self, key, total):
"""Gọi API thông báo nếu sắp chạm hoặc đã vượt ngân sách."""
...
def mapper(self, _, line):
"""Phân tích từng dòng log, trích xuất và biến đổi các dòng liên quan.
Tham số line có dạng:
user_id timestamp seller amount
Dùng categorizer để chuyển seller thành category,
phát ra (emit) các cặp key-value có dạng:
(user_id, 2016-01, shopping), 25
(user_id, 2016-01, shopping), 100
(user_id, 2016-01, gas), 50
"""
user_id, timestamp, seller, amount = line.split('\t')
category = self.categorizer.categorize(seller)
period = self.extract_year_month(timestamp)
if period == self.current_year_month:
yield (user_id, period, category), amount
def reducer(self, key, value):
"""Cộng dồn các giá trị cho mỗi key.
(user_id, 2016-01, shopping), 125
(user_id, 2016-01, gas), 50
"""
total = sum(values)
yield key, sum(values)
Bước 4: Mở rộng thiết kế (scale)
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 đánh giá các phương án thay thế và đánh đổi, và 4) lặp lại. Xem Thiết kế hệ thống phục vụ hàng triệu người dùng trên AWS làm ví dụ về cách mở rộng thiết kế ban đầu theo từng vòng lặp.
Đ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ụ: thêm Load Balancer với nhiều Web Server giải quyết được vấn đề gì? CDN? Master-Slave Replicas? 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ẽ để hình đỡ 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
- CDN
- 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
- Hệ quản trị cơ sở dữ liệu quan hệ (RDBMS)
- Fail-over master-slave cho SQL ghi
- Nhân bản master-slave (master-slave replication)
- Bất đồng bộ (asynchronism)
- Các mẫu nhất quán (consistency patterns)
- Các mẫu sẵn sàng (availability patterns)
Ta thêm một use case bổ sung: Người dùng xem bản tóm tắt và các giao dịch.
Phiên người dùng (user session), số liệu tổng hợp theo danh mục và các giao dịch gần đây có thể được đặt trong Memory Cache như Redis hoặc Memcached.
- Client gửi yêu cầu đọc tới Web Server
- Web Server chuyển tiếp yêu cầu tới máy chủ Read API
- Nội dung tĩnh có thể được phục vụ từ Object Store như S3, được cache trên CDN
- Máy chủ Read API thực hiện các việc sau:
- Kiểm tra nội dung trong Memory Cache
- Nếu url có trong Memory Cache, trả về nội dung đã cache
- Ngược lại
- Nếu url có trong SQL Database, lấy nội dung
- Cập nhật Memory Cache với nội dung đó
- Nếu url có trong SQL Database, lấy nội dung
- Kiểm tra nội dung trong Memory Cache
Tham khảo Khi nào cập nhật cache để biết các đánh đổi và phương án thay thế. Cách làm ở trên mô tả mẫu cache-aside.
Thay vì giữ bảng tổng hợp monthly_spending trong SQL Database, ta có thể tạo một Analytics Database riêng dùng giải pháp kho dữ liệu (data warehouse) như Amazon Redshift hoặc Google BigQuery.
Ta có thể chỉ muốn lưu dữ liệu transactions của một tháng trong cơ sở dữ liệu, phần còn lại lưu trong data warehouse hoặc trong Object Store. Một Object Store như Amazon S3 có thể dễ dàng đáp ứng ràng buộc 250 GB nội dung mới mỗi tháng.
Để xử lý 200 yêu cầu đọc mỗi giây trung bình (cao hơn vào giờ cao điểm), lưu lượng cho nội dung phổ biến nên được xử lý bởi Memory Cache thay vì cơ sở dữ liệu. Memory Cache cũng hữu ích để xử lý lưu lượng phân bố không đều và các đợt tăng đột biến (spike). SQL Read Replicas hẳn sẽ xử lý được các lần cache miss, miễn là các replica không bị quá tải vì phải nhân bản các thao tác ghi.
2,000 giao dịch ghi mỗi giây trung bình (cao hơn vào giờ cao điểm) có thể là quá sức với một SQL Write Master-Slave duy nhất. Ta có thể cần áp dụng thêm các mẫu mở rộng SQL:
- Liên kết (federation)
- Phân mảnh (sharding)
- Phi chuẩn hóa (denormalization)
- Tinh chỉnh SQL (SQL tuning)
Ta cũng nên cân nhắc chuyển một phần dữ liệu sang NoSQL Database.
Các ý thảo luận thêm (Additional talking points)
Các chủ đề bổ sung để đào sâu, tùy vào phạm vi bài toán và thời gian còn lại.
NoSQL
- Kho key-value (key-value store)
- Kho tài liệu (document store)
- Kho cột rộng (wide column store)
- Cơ sở dữ liệu đồ thị (graph database)
- SQL vs NoSQL
Caching
- Cache ở đâu
- Cache cái gì
- Khi nào cập nhật cache
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 REST
- Giao tiếp nội bộ - RPC
- Khám phá dịch vụ (service discovery)
Bảo mật
Tham khảo phần bảo mật.
Các con số về độ trễ
Xem Các con số về độ trễ mà 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