- Thiết kế hệ thống tốt là kiểu thiết kế không trông quá phức tạp và không phát sinh vấn đề đáng kể trong thời gian dài
- Việc xử lý trạng thái (state) là phần khó nhất trong thiết kế hệ thống, và điều quan trọng là giảm số lượng thành phần lưu trữ trạng thái xuống mức thấp nhất có thể
- Cơ sở dữ liệu chủ yếu là nơi lưu trữ trạng thái, nên cần một cách tiếp cận tập trung vào thiết kế schema và indexing, cũng như giải quyết điểm nghẽn
- Caching, xử lý sự kiện, tác vụ nền nên được đưa vào một cách thận trọng để phục vụ hiệu năng và bảo trì, và tốt hơn là tránh lạm dụng
- Thay vì thiết kế phức tạp, việc sử dụng hợp lý các thành phần và phương pháp luận đơn giản đã được kiểm chứng là chìa khóa để xây dựng hệ thống bền vững và ổn định
Định nghĩa thiết kế hệ thống và cách tiếp cận tổng thể
- Nếu thiết kế phần mềm là việc lắp ráp mã nguồn, thì thiết kế hệ thống là quá trình kết hợp nhiều dịch vụ khác nhau
- Các thành phần chính của thiết kế hệ thống gồm app server, cơ sở dữ liệu, cache, queue, event bus, proxy
- Một thiết kế tốt tạo ra những phản ứng như “không có vấn đề gì đặc biệt”, “kết thúc dễ hơn tưởng tượng”, “không cần phải bận tâm đến phần này”
- Ngược lại, một thiết kế phức tạp và quá nổi bật có thể che giấu vấn đề gốc rễ hoặc cho thấy hệ thống đã bị thiết kế quá mức
- Thay vì đưa vào một hệ thống phức tạp ngay từ đầu, hướng đi có lợi hơn là bắt đầu từ cấu trúc đơn giản tối thiểu có thể vận hành rồi dần phát triển
Phân biệt trạng thái (state) và phi trạng thái (stateless)
- Phần khó nhất trong thiết kế phần mềm chính là quản lý trạng thái
- Những dịch vụ không lưu trữ thông tin mà trả kết quả ngay lập tức (như render PDF của GitHub) là phi trạng thái
- Ngược lại, những dịch vụ thực hiện ghi vào cơ sở dữ liệu là đang quản lý trạng thái
- Nên giảm tối đa số thành phần lưu trữ trạng thái trong hệ thống. Điều này giúp hạ thấp độ phức tạp và khả năng xảy ra sự cố
- Kiến trúc được khuyến nghị là chỉ để một dịch vụ đảm nhận việc quản lý trạng thái, còn các dịch vụ khác tập trung vào vai trò phi trạng thái như gọi API hoặc phát sinh sự kiện
Thiết kế cơ sở dữ liệu và điểm nghẽn
Thiết kế schema và index
- Để lưu trữ dữ liệu, cần thiết kế schema ở dạng con người dễ đọc
- Schema quá linh hoạt (ví dụ: lưu toàn bộ vào cột JSON) có thể tạo gánh nặng cho mã ứng dụng và hiệu năng
- Cần thiết lập index phù hợp dựa trên các cột sẽ được truy vấn thường xuyên. Đánh index cho mọi thứ ngược lại còn tạo thêm overhead không cần thiết
Cách giải quyết điểm nghẽn
- Truy cập cơ sở dữ liệu thường là một điểm nghẽn nặng
- Về hiệu năng, có lợi hơn nếu xử lý dữ liệu phức tạp ngay trong cơ sở dữ liệu bằng JOIN thay vì làm ở tầng ứng dụng
- Khi dùng ORM, cần cẩn thận với lỗi phát sinh query bên trong vòng lặp
- Khi cần, cũng có thể chia nhỏ query để điều chỉnh tải cho cơ sở dữ liệu hoặc độ phức tạp của query
- Chiến lược phân tán các truy vấn đọc sang read-replica rất hiệu quả để giảm tải cho node chính (write)
- Khi lượng lớn query đổ dồn vào, transaction và thao tác ghi có thể dễ dàng làm quá tải cơ sở dữ liệu, vì vậy nên cân nhắc throttling (giới hạn) query
Tách biệt tác vụ chậm và tác vụ nhanh
- Những thao tác người dùng tương tác cần phản hồi trong vòng vài trăm mili giây
- Với các tác vụ mất nhiều thời gian (ví dụ: chuyển đổi PDF dung lượng lớn), mô hình hiệu quả là chỉ cung cấp ngay phần việc tối thiểu ở phía trước, còn lại chuyển sang nền
- Tác vụ nền thường hoạt động dưới dạng kết hợp giữa queue (ví dụ: Redis) và job runner
- Với các tác vụ được lên lịch xa trong tương lai, cách thực tế hơn là quản lý bằng một bảng DB riêng thay vì Redis, rồi dùng scheduler để thực thi
Caching
- Caching giúp tiết kiệm chi phí và cải thiện hiệu năng khi cùng một phép tính hoặc phép tính đắt đỏ được lặp lại
- Thông thường, junior engineer mới học cache sẽ muốn cache mọi thứ, còn engineer nhiều kinh nghiệm thì thận trọng hơn trong việc đưa cache vào
- Cache đưa thêm một trạng thái mới vào hệ thống, nên tồn tại rủi ro về đồng bộ hóa/lỗi/stale data
- Tốt hơn là trước tiên thử cải thiện hiệu năng như thêm index cho query rồi mới áp dụng cache
- Với cache dung lượng lớn, ngoài Redis/Memcached còn có thể dùng cách lưu định kỳ vào kho lưu trữ tài liệu như S3/Azure Blob Storage
Xử lý sự kiện
- Phần lớn doanh nghiệp đều có event hub (ví dụ: Kafka), và nhiều dịch vụ được phân tán xử lý theo hướng event-driven
- Thay vì lạm dụng event, thiết kế API request–response đơn giản lại hữu ích hơn về mặt logging và xử lý sự cố
- Xử lý dựa trên event phù hợp khi bên phát không cần quan tâm đến hành vi của bên nhận, hoặc trong các kịch bản dung lượng lớn và chấp nhận độ trễ
Cách truyền dữ liệu: push và pull
- Có hai cách truyền dữ liệu là Pull (yêu cầu rồi nhận phản hồi) và Push (tự động gửi khi có thay đổi)
- Cách Pull thì đơn giản nhưng có thể phát sinh vấn đề lặp lại request và quá tải
- Cách Push cho phép máy chủ gửi ngay dữ liệu tới client khi có thay đổi, nên hiệu quả hơn và thuận lợi cho việc duy trì dữ liệu mới nhất
- Khi xử lý lượng lớn client, cần mở rộng hạ tầng phù hợp với từng cách tiếp cận (queue sự kiện, nhiều cache server, v.v.)
Tập trung vào hot path
- Hot path là đường đi quan trọng nhất trong hệ thống, nơi dữ liệu lưu chuyển nhiều nhất
- Hot path có ít lựa chọn hơn, và nếu thiết kế thất bại có thể gây ra vấn đề nghiêm trọng cho toàn bộ dịch vụ, nên bắt buộc phải thiết kế cẩn thận
- So với các tính năng phụ có nhiều lựa chọn, việc tập trung nguồn lực vào thiết kế và kiểm thử hot path sẽ hiệu quả hơn
Logging, metric, tracing
- Khi xảy ra sự cố, để chẩn đoán nguyên nhân thì cần tích cực ghi lại log chi tiết cho các đường đi bất thường (unhappy path)
- Cần thu thập các chỉ số quan sát được cơ bản như tài nguyên hệ thống (CPU/bộ nhớ), kích thước queue, thời gian request/tác vụ
- Thay vì chỉ nhìn giá trị trung bình, nhất định phải quan sát các chỉ số phân phối như độ trễ p95, p99. Một số ít request chậm nhất có thể là vấn đề của nhóm người dùng quan trọng
Kill switch, retry, khôi phục sự cố
- Việc sử dụng chiến lược kill switch (tạm chặn hệ thống) và retry một cách hợp lý là rất quan trọng
- Retry một cách vô điều kiện chỉ làm tăng gánh nặng cho dịch vụ khác; chỉ hiệu quả khi có cơ chế kiểm soát request trước đó như circuit breaker
- Việc đưa vào Idempotency Key giúp tránh xử lý trùng lặp khi cùng một request được gửi lại
- Trong một số tình huống sự cố, cần chọn giữa fail open hoặc fail closed. Ví dụ, Rate Limiting nên theo hướng fail open (cho phép) để giảm tác động đến người dùng. Ngược lại, xác thực thì bắt buộc phải fail closed
Kết luận
- Dù một số chủ đề như tách dịch vụ, container, VM, tracing đã được lược bỏ, việc dùng các thành phần đã được kiểm chứng đúng chỗ vẫn là con đường ổn định nhất để xây dựng hệ thống về lâu dài
- Những thiết kế “đặc biệt” về mặt kỹ thuật thực ra rất hiếm; trái lại, thiết kế đơn giản đến mức nhàm chán mới là thứ được dùng thường xuyên nhất trong thực tế
- Về bản chất, thiết kế hệ thống tốt là quá trình kết hợp an toàn các phương pháp đã được chứng minh đầy đủ mà không quá phô trương
Chưa có bình luận nào.