I. Giới thiệu
Trong CV mình có để mình có khả năng optimize API, nên mình khá hay bị hỏi làm cách nào để mình tối ưu API, hay nói rộng hơn là tối ưu hệ thống. Và đây là cách mà mình trả lời.
II. Xác định & phân tích vấn đề
Bước đầu tiên thì phải tìm ra vấn đề nằm ở đâu trước, không thể bụp cái vào tối ưu tùm lum rồi không giải quyết được vấn đề. Nguồn lực là có hạn, không phải cái nào cũng đem đi tối ưu hết, theo kinh nghiệm của mình thì sẽ làm các bước sau:
- Đầu tiên là thu thập thông tin từ khách hàng để khoanh vùng trang nào, phần nào bị chậm, từ đó truy vết ra API bị chậm.
- Setup hệ thống log, monitor để phát hiện những api nào chậm, từ đó sẽ optimize những cái có response time cao nhất.
III. Các kỹ thuật tối ưu
Nếu đã xác định được vấn đề từ chỗ nào, API nào thì chúng ta bắt đầu đến với các kỹ thuật tối ưu thôi nào.
1. Database
80% vấn đề performance nằm ở database, nên đây sẽ là thứ đầu tiên mà mình muốn tối ưu đến. Trước hết mình sẽ xem 1 API đó gọi những câu query nào, câu nào đang làm tốn thời gian nhất. Sau khi tìm ra câu query gây chậm thì mình sẽ sử dụng explain analyze để xem chiến lược thực thi của nó là gì rồi sẽ tối ưu nó (giảm cost xuồng càng nhiều thì cpu database càng khoẻ hơn). Các nguyên nhân và cách mà mình đã áp dụng trong phần database là:
Không lấy cột dư thừa
- Giúp giảm tải bằng thông. (Tuy nhiên chỉ thực sự áp dụng với các câu cần optimize, đôi khi bạn phải đánh đổi 1 phần nhỏ hiệu suất để các function trong backend có thể được tái sử dụng nhiều. Nếu cứ tối ưu bằng việc loại bỏ các cột dư thừa thì code của bạn lại bị vi phạm vấn đề DRY (don't repeat yourself).
Đánh index
- Nếu dữ liệu nhiều thì nên được đánh index. Nghe thì cơ bản nhưng mình đã từng thấy trong công ty mình làm từng không hề đánh 1 chút index nào.
- Tuy nhiên đánh index cũng phải sao cho đúng, tránh đánh index mổ cỏ (chỉ index cho 1 column), cũng không nên đánh index trên quá nhiều cột (chọn tối đa từ 2-4 cột là đẹp).
- Thứ tự đánh index của các cột là rất quan trọng, nên chọn cột nào càng loại bỏ được nhiều page không khớp càng tốt. Đánh index xong thì nhớ dùng nó để tận dụng được index.
- Không đánh index đơn lẻ cho những trường nào có giá trị trùng lặp nhiều (low cardinality), nếu vẫn muốn đánh thì nên kết hợp thêm với cột khác.
- Mình chủ yếu postgres, mySQL, mongoDB thì thường có khá nhiều loại index khác nhau nên chọn giá đúng. Trong hầu hết trường hợp thì dùng b-tree, hash index algorithm, nhưng cũng có các trường hợp khác để bạn lựa chọn.
- Áp dụng partial index cho những table có dữ liệu lớn.
- Chú ý đến một số câu lệnh order by, đôi khi chỉ vì order by mà nó không ăn được index, lúc này có thể tìm cách để đánh index cho trường order by hoặc defered join chẳng hạn.
Join
- Trong dự án thì mình thường chọn postgres thay vì mySQL vì nó khá mạnh read. Postgres hỗ trợ nhiều loại join khác nhau, và nó sẽ tự động chọn thuật toán join mà chiến lược thực thi cho là phù hợp.
- Sử dụng filtered join: giống partial index, trong một vài trường hợp mình sẽ thêm vài điều kiện để loại bỏ bớt các record được join vào.
- Sử dụng Subquery với EXISTS hoặc IN: đôi khi mình cũng dùng trong một vài trường hợp, nó cũng mang lại kết quả tương tự join mà không bị duplicate record.
SELECT * FROM table1 t1 WHERE EXISTS ( SELECT 1 FROM table2 t2 WHERE t1.id = t2.id AND t2.status = 'active' )
- Lateral Joins (trong PostgreSQL): Đây là một loại join cho phép bạn tham chiếu đến các cột từ các bảng được join trước đó trong mệnh đề FROM, cho phép các join phức tạp và có điều kiện.
- Defered joins: Select ra kết quả theo index trước rồi join với chính bảng nó đó rồi lấy ra toàn bộ record. cách này giúp lọc và sắp xếp record trước, giảm đáng kể lượng record phải query
SELECT * FROM ( SELECT user_id FROM crm_users WHERE created_at BETWEEN '2024-07-03 00:00:00' AND '2024-07-03 00:00:00' ORDER BY created_at, user_id LIMIT 9000000 OFFSET 50
) AS temp
INNER JOIN crm_users
WHERE created_at BETWEEN '2024-07-03 00:00:00' AND '2024-07-03 00:00:00'
ORDER BY created_at, user_id
LIMIT 9000000 OFFSET 50;
- Sử dụng join thay vì n+1 query: Trong nhiều trường hợp mình sẽ sử dụng ORM trong code backend để preload ra các table relationship đi kèm. Việc này giúp code nhanh hơn, trả ra object cũng dễ dàng, dễ tái sử dụng code trong nhiều trường hợp. Tuy nhiên với một vài trường hợp cần tối ưu thì phải xem xét, vì n+1 query tức là bạn làm tăng thêm gánh nặng cho database, lúc này có thể lựa chọn join.
- Với noSQL thì không mạnh cho join nên cách tốt nhất là nên embeded lại để gọi query 1 lần lấy được thay vì phải join.
Tính toán
- Tính toán trước kết quả để read ra: có một số thống kê mình phải lấy ra các số liệu từ table, đôi khi việc tính toán này khá nhiều. Lúc này lựa chọn tính toán trước để giảm response time read là 1 trong những lựa chọn có thể cân nhắc. Nhưng cái gì cũng phải đánh đổi, nó làm tăng công việc của bạn viết code insert, update dữ liệu, đồng thời tăng tác vụ ghi, tính toán cho database, backend server.
- Những dữ liệu không cần thiết real-time, nếu được nên sài cronjob để tính toán vào thời gian ít người dùng, giảm tải cho database trong giờ cao điểm.
- Phần lớn việc nghẽn cổ chai (bottle neck) đến từ database chứ không phải nơi nào khác. Đặc biệt với các database SQL khó nâng cấp theo chiều ngang được. Các backend server thì có thể scale up tốt được, đặc biệt mình dùng golang, lượng conccurrency có thể xử lý lên tới hàng triệu. Nên việc gì khó, tính toán đồ cứ lấy về backend rồi để nó xử lý.
Connection
- Mỗi database sẽ có số lượng ipos, cần được phân chia và tận dụng tốt. Nên set max connection lên database để tránh làm nghẽn database. Set min connection được mở từ server tới database để khỏi phải khởi tạo lại mỗi khi cần.
Scale up
- Tác vụ read sẽ chiếm nhiều hơn so với tác vụ write. Khi hệ thống đạt đến ngưỡng scale up dọc (tăng server vật lý), thì sử dụng replica để scale up theo chiều ngang. Mô hình replica gồm 1 primary (master) node để write và nhiều replica (slaves) node để read. Việc tách biệt read write này giúp tăng khả năng xử lý. Có nhiều RAM hơn để lưu cache, query sẽ nhanh hơn.
- Với các noSQL thì được cái khá mạnh về scale chiều ngang với replica set và sharding nên có thể tận dụng yếu tố này.
Search & filter
- Thống nhất cách filter và search trong hệ thống, giúp tiết kiệm thời gian build source code
- Sử dụng Elasticsearch thay vì query prefix, regex, text: Sử dụng query kiểu này thường chậm, thường thì database có hỗ trợ các extension cho search full-text, có thể sử dụng sẵn. Còn nếu cần tối ưu search thì có thể (tuy nhiên cần cân nhắc giữa thời gian triển khai, tiền bạc và nguồn lực)
Partition & remove old data
- Dữ liệu sẽ ngày càng nhiều lên dẫn đến việc query sẽ bị chậm dần. Lúc này việc partition là 1 việc rất tốt, nó giúp chia nhỏ bảng (hoặc index) thành các bảng nhỏ hơn được lưu trong các phân vùng khác đễ dễ dàng truy xuất. Ví dụ đơn hàng hoặc dữ liệu chấm công hàng ngày của nhân viên, ngày nào cũng tăng lên thì có thể partition theo range time.
- Partition thì không được tự động tạo, thường sẽ phải viết trigger hoặc cronjob để tạo sau một khoảng thời gian.
- Có những data cũng rất rất lâu rồi, khách hàng gần như không sài tới thì có thể xoá đi để giảm sức chưa cho database. Nhưng nói xoá cũng không hẳn là xoá, chỉ đơn giản là chuyển nó đến 1 vùng lưu trữ dài hạn khác như nén lại lưu vào S3, lưu ra ổ cứng...
Cache
- Sử dụng materialize view: Một số loại báo cáo ít thay đổi, có thể được cache trước để khi người dùng vào coi là có liền.
- Caching báo cáo theo time range
- Thông thường thì database hay server có sẵn cache in-memory, có thể tận dụng nó để caching dữ liệu hay được sử dụng. Nếu có nhiều dữ liệu cần cache, cần giảm tải cho database chính, cần cache hiệu suất cao và đồng thời cao thì có thể cân nhắc sử dụng các database chuyên biệt cho caching như redis.
Dữ liệu bị phân mảnh
- Dữ liệu trong SQL (ví dụ postgreSQL) sẽ được lưu trữ trong các page có dung lượng 8kb, đôi khi do việc đọc ghi, xoá quá nhiều dẫn đến dữ liệu bị lưu trữ trên nhiều page khác nhau, mặc dù dữ liệu lại chả có bao nhiêu.Giống như kiểu bạn mua 1 quyển sổ 200 trang nhưng mỗi trang bạn chỉ viết 1 2 từ, điều này gây lãng phí không gian và giảm hiệu suất.
- Để khắc phục thì trước tiên phải tìm hiểu xem có dữ liệu có bị phân mảnh không thông qua câu lệnh:
SELECT schemaname || '.' || relname AS table_name, pg_size_pretty(pg_total_relation_size(relid)) AS total_size, pg_size_pretty(pg_table_size(relid)) AS table_size, pg_size_pretty(pg_indexes_size(relid)) AS index_size, pg_size_pretty(pg_total_relation_size(relid) - pg_table_size(relid) - pg_indexes_size(relid)) AS toast_size, round(100 * (pg_total_relation_size(relid) - pg_table_size(relid) - pg_indexes_size(relid)) / NULLIF(pg_total_relation_size(relid), 0), 2) AS fragmentation_percent
FROM pg_stat_user_tables
WHERE relname = '&TABLE_NAME'
ORDER BY pg_total_relation_size(relid) DESC;
- Nếu kiểm tra thấy có table thì tức là dữ liệu đã bị phân mảnh rồi, lúc này có thể ngâm cứu tiến hành chữa bệnh cho nó.
- Giải quyết thì có thể tạo lại bảng mới, chạy lại index.
- Thiết kế schema hiệu quả hơn để giảm tải phân mảnh ngay từ đầu.
Còn phần sau nữa mà dài quá rồi, thôi để bài viết sau.