Kiến trúc hệ thốngThiết kế Mint.com

Thiết kế Mint.com

Nội dung bài

Thiết kế Mint.com

Bài tập22 phút đọcThe System Design Primer - bài giải "Design Mint.com"

Mục lục
  1. Nội dung gốc
  2. Ghi chú của người dịch

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
Ngoài phạm vi

Ràng buộc và giả định

Các giả định
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.

Bảng quy đổi tiện dụ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.

Thiết kế tổng quan Mint

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.

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:

Luồng dữ liệu:

Xử lý bất đồng bộ qua hàng đợi: phản hồi ngay, worker xử lý sau Client Accounts API yêu cầu phản hồi ngay Hàng đợi lấy job Worker trích xuất giao dịch Cập nhật DB + báo người dùng
Accounts API đặt job vào hàng đợi rồi trả lời client ngay; Transaction Extraction Service (worker) lấy job ra, trích xuất và phân loại giao dịch, cập nhật DB rồi báo người dùng qua Notification Service.

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
Phân loại giao dịch: tra bảng có sẵn, học thêm từ lựa chọn ghi đè của người dùng Giao dịch mới Bảng ánh xạ người bán → danh mục có sẵn Trả danh mục ngay chưa có Người dùng tự ghi đè Ghi đè phổ biến nhất → ghi vào bảng ánh xạ
Gặp người bán đã biết thì trả danh mục ngay; gặp người bán mới thì dùng lựa chọn phổ biến nhất của người dùng rồi ghi thêm vào bảng ánh xạ.

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ô để:

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.

Thiết kế Mint sau khi mở rộng

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ế:

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.

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:

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

Caching

Bất đồng bộ và microservices

Giao tiếp (communications)

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


Nguồn: The System Design Primer - bài giải "Design Mint.com" — Donne Martin và cộng đồng đóng góp

Giấy phép: CC BY 4.0 (nguyên bản: Donne Martin, The System Design Primer)

Xem bản gốc

Bài tiếp theoThiết kế tính năng xếp hạng bán chạy theo danh mục của Amazon (sales rank)