+21

[Phỏng vấn Backend]: Làm sao để tối ưu performance hệ thống? (phần 1: database)

I. Giới thiệu

Trong CV mình để là tôi có khả năng optimize API, nên mình cũng rất hay bị nhà tuyển dụng làm khó về tối ưu hệ thống như thế nào.Mình sẽ chia sẻ lại với mọi người cách mà mình đã trả lời để mọi người tham khảo.

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 nguyên nhân gốc rễ.
  • 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 (Database)

80% vấn đề performance nằm ở database, nên đây sẽ là thứ đầu tiên mà mình muốn tìm đến để tối ưu. Sau đây là những kỹ thuật mà mình biết (nếu bạn có cái nào khác thì hãy để lại comment nhé):

1 .Không lấy dư cột

  • 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).

2. Đá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 không hề đánh 1 chút index nào. Đôi khi chỉ 1 câu index đúng cũng thay đổi cả cục diện API.
  • Tuy nhiên đánh index cũng phải sao cho đúng, tránh đánh index mổ cỏ, tức là đánh nhiều index trong table mà mỗi index chỉ có 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 data không khớp trước càng tốt. Index xong thì nhớ dùng WHERE với các column đã index để tận dụng tối đa hiệu quả.
  • 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. Ví dụ cột giới tính chỉ có 3 dạng (male, female, other) thì dùng index cho riêng cột này sẽ không đạt hiệu quả cao, lúc này có thể thêm 1 column khác nữa như company_id chẳng hạn.
  • Các database sẽ sử dụng nhiều thuật toán index khác nhau, hãy lựa chọn đúng trong từng trường hợp. Ví dụ với postgres có các loại index như b-tree, hash-index, GIN... tuỳ mỗi hoàn cảnh mà mình sẽ lựa chọn 1 index phù hợp để tận dụng tối đa thuật toán index đó.
  • Á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, mình từng gặp trường hợp lúc đánh index các kiểu cho một table nhưng lúc query ra vẫn chậm rì, đi lần mò explain analyze mãi mới biết vì mình dùng order by để sort lại dữ liệu làm nó không ăn được index. Để giải quyết thì có nhiều cách, 1 là đánh index luôn cho cột đang sort đó, hoặc bạn có thể thử biện pháp defered join chẳng hạn.

3. Join

  • Cũng giống với index, join cũng có nhiều thuật toán khác nhau để lựa chọn (như mình dùng postgres thì khỏi phải lựa, nó tự chọn cái nào tối ưu nhất luôn).
  • 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: Nôm na là mình select kết quả ra trước để lấy id rồi mới đem join lại với chính bảng đó để lấy toàn bộ record. Cách này có thể dùng trong một số trường hợp như khi bạn sử dụng order by khiến câu query không ăn được index.
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 đơn giản hoá cách viết code hơn, 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.
  • Lưu ý: mình từng bị hỏi preloadjoin thì cái nào nhanh hơn. Thực tế thì không có trường hợp tuyệt đối, tuỳ trường hợp mà cái này sẽ nhanh hơn cái kia và ngược lại.

4. Xử lý các tác vụ nặng

  • 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 (khó chứ không phải không). Backend server thì có thể scale up tốt hơn. Nên việc gì khó cứ lấy về backend rồi để nó xử lý.
  • Lưu trước kết quả: Có một số thống kê mình phải join vào nhiều bảng khác nhau, tính toán các thứ để lấy ra kết quả cuối cùng trả về cho người dùng, việc này thường mất nhiều thời gian. Lúc này mình sẽ lựa chọn tính toán trước dữ liệu rồi lưu vào 1 table nào đó, khi nào cần chỉ việc lấy ra cho nhanh. Cơ mà cái gì cũng phải đánh đổi, bạn nhanh hơn ở bước đọc thì phải tốn công viết code insert, update khi có gì đó thay đổi, tăng write vô database.
  • 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 rồi ghi vào database, hãy chọn thời gian ít người dùng nhất, giảm tải cho database trong giờ cao điểm.

5.Connection

  • Mỗi database sẽ có số lượng Input/Output Operations Per Second (IOPS), cần được phân chia và tận dụng tốt. Ngày đi học thì chúng ta hay được thầy cô dạy dùng singleton pattern để kết nối đến cơ sở dữ liệu. Singleton được cái tiết kiệm tài nguyên, chỉ tạo 1 kết nối duy nhất đến database, tránh phải tạo dư thừa connection. Nhưng nhược điểm là nó sẽ gây ra độ trễ vì chỉ có 1 connection duy nhất đến database để xử lý, các yêu cầu sẽ phải chờ nhau trong khi database thì còn quá trời IOPS.
  • Bình thường mình hay sử dụng connection pooling để kết nối đến database. Mình set một số lượng min connection được mở để dùng, các tác vụ có thể tận dụng connection từ pool, dùng xong thì trả lại cho thằng khác sài, giúp tiết kiệm được tài nguyên, bên cạnh đó mình cũng set cả max connection có thể kết nối đồng thời để tránh làm quá tải database.

6. Scale up

  • Tác vụ read thường gấp 9 lần write. Khi hệ thống đạt đến ngưỡng scale up dọc (tăng server vật lý), khi không dọc được nữa mình scale ngang. Mô hình replica gồm 1 primary node để write và nhiều replica node để read. Việc tách biệt read/write 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. Thường mình nói phần này ra nhà tuyển dụng hay vặn tiếp về distribute system như thế nào, nên ai phỏng vấn mà lỡ nói thì hãy chắc là bạn hiểu cách scale up ngang nhé.

7.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.
  • Nếu bạn cần 1 tính năng search mạnh mẽ mà database bạn sử dụng lại không mạnh về search thì nên dùng database search riêng (như Elasticsearch, Sorl). Mình đã có một bài viết cho database dạng này nên sẽ không chia sẻ lại ở đây.

8.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 vật lý khác đễ dễ dàng truy xuất. Ví dụ bạn xây dựng trang facebook, bài đăng mới thì ngày nào cũng tăng lên, nhưng thực tế người dùng sẽ ít khi coi lại những bài đăng cách đây 5 năm, 10 năm của họ. Lúc này mình sẽ dùng partition theo range time.
  • Note: partition thì không được tự động tạo, thường sẽ phải viết trigger hoặc cronjob để sau một khoảng thời gian (hoặc theo một điều kiện nào đó của bạn setup) thì nó sẽ tạo thêm partition mới.
  • Với những data rất rất lâu rồi, khách hàng gần như không sài nhưng vẫn phải giữ lại nếu họ cần trong tương lai. Lúc này có thể di chuyển nó qua một vùng chưa dài hạn khác (ổ cứng, S3...) nhằm giảm tải cho database chính.

10.Cache ngu là cook

  • Sử dụng cơ chế materialize view để cache trước những dữ liệu báo cáo (ít thay đổi), cần là có đem ra sài luôn.
  • Thông thường thì database có sẵn cache in-memory nhưng thường nó sẽ tự chọn thứ mà nó muốn cache, nếu bạn muốn cache chủ động hơn, đồng thời giảm bớt việc cho database chính, thì có thể cân nhắc sử dụng các database chuyên biệt cho caching như redis.

Nói đến thẳng redis làm mình lại nhớ đến một câu chuyện buồn. Mình từng phỏng vấn ở 1 công ty, ông sếp giao cho mình một project nhỏ để code trong vài ngày, mình nhanh tay flex khả năng của mình bằng việc sử dụng hết các công nghệ xin xò mà mình biết vào đó, trong đó có redis. Sau khi làm xong thì ổng chỉ hỏi mình đúng 1 câu hỏi nhưng lặp đi lặp lại 5 lần "tại sao mày dùng redis, tại sao mày lại dùng redis?". Lúc đó mình chẳng hiểu ý của ổng là gì cả, theo những gì mình đọc trên mạng thì dùng nó nhanh hơn database chính này kia, ăn trong RAM nên nhanh hơn nhiều lắm này kia. Xong rồi mình bị cút luôn, sau này mình mới nhận ra một điều đó là tốt nhất không bằng phù hợp nhất. Lúc đó thời gian có hạn, đề bài chỉ là một project nhỏ xíu, dùng cache trong server thôi cũng đã đủ rồi cần gì bỏ tiền cho redis, mà cũng chẳng có usecase cụ thể gì lắm mà mình lại đi áp dụng redis cho ngầu rồi chẳng giải quyết được vấn đề gì. Việc này thể hiện mình không hiểu rõ về thứ mà mình đang làm, nên việc bị cút là tất yếu 😃)

11.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.

Tham khảo

Group discord 2k+ mems: chém gió về lập trình và làm pet project cùng nhau


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí