+3

Design System: Mình học tối ưu độ trễ qua Tips, Tricks, Fancy Diagrams rồi quên luôn ngay hôm sau

Đây là một phần trong series mình chia sẻ về những gì mình đã làm khi xây dựng và vận hành hạ tầng backend trên AWS cho công ty. Không phải là best practice hoàn hảo, cũng không phải kiến thức quá cao siêu. Chỉ đơn giản là những gì mình đã học được, thử nghiệm và áp dụng trong quá trình làm việc hằng ngày. Nếu thấy hay, kết nối với mình tại: LinkedIn

Từ trước tới giờ, trong quá trình mình học, suy nghĩ, làm việc, khái niệm về latency (độ trễ) của một request đối với mình vẫn còn rất mơ hồ.

Khi nghe đến nó, trong đầu mình luôn là:

À, ok, thì latency là thời gian một request được tính bắt đầu từ lúc client bắt đầu gửi đi cho đến khi client nhận lại được kết quả. Hết.

Ừ thì cách hiểu này không có gì là sai cả, hoàn toàn đúng, nhưng nó lại làm ra một vấn đề mơ hồ nhưng lại rất hiển nhiên, như là ừ cái đó thì rõ ràng rồi. Nhưng khi thực sự bắt đầu lao vào những task mà làm việc với latency, kiểu như sếp của mình kêu là:

Request này chậm quá, có lấy mỗi cái danh sách thôi mà tốn gì tới 2 giây

hoặc

tại sao cùng một request, lúc thì lại là 200 ms, lúc thì 1s, lúc thì 2s

thì cái định nghĩa này dường như trả giải quyết được gì cả. Vậy cái gì gây ra độ trễ, và vì sao và nó ảnh hưởng như nào tới độ trễ của một request.

Tips, Tricks và Fancy Diagrams

Nếu bạn tìm kiếm trên mạng các lời khuyên về design system, làm cách nào để tối ưu latency của một request, thì có rất nhiều cách, hàng trăm cái sơ đồ kiến trúc, rồi giải thích rất nhiều kỹ thuật rồi thuật ngữ rất fancy. Mình cũng đã làm như vậy rồi mình thấy

ồ ngon, giải thích thật có lý, áp dụng thôi

Mình cũng đã cóp nhặt được rất nhiều skill để tối ưu latency, haha, nếu được phỏng vấn hỏi mấy cái đó sẽ ngon ngay. Ví dụ:

  • Nếu quá nhiều message gửi tới hệ thống để xử lý, trong trường hợp hệ thống quá tải, thì đẩy hết message vào một queue (Kafka, SQS …), xử lý từ từ, lần lượt từng request, sẽ không bị mất message.
  • Hệ thống quá tải thì scale theo chiều ngang (Horizontal Scaling), tăng số lượng instance, pod, node các thứ để tăng khả năng xử lý hàng triệu request, autoscaling với load balancer để làm gì.
  • Đưa server gần hơn với người dùng bằng hệ thống CDN, server khắp mọi nơi trên thế giới, user ở châu âu thì sẽ request tới server đặt tại châu âu, mỹ sẽ request đến server tại mỹ, ngoài ra còn giảm tải cho server.
  • Caching data bằng Redis, dữ liệu đọc trên RAM thì nhanh hơn nhiều so với load từ disk I/O, lại còn đỡ tốn request vào database làm quá tải database.
  • Database có thể nghiên cứu các kỹ thuật như Replica (tăng khả năng đọc) hay thêm Index, Partitioning, Sharding database để tăng performance cho các query.

Còn rất rất nhiều nữa và mình cũng công nhận sau khi đọc xong giải thích vì sao lại có thể giảm latency thì mình hoàn toàn đồng ý.

Nhưng...

Tất cả những lời khuyên đó, với mình cũng chỉ là tip, trick các kiểu, có thể hôm nay mình nhớ, ngày mai mình quên. Có quá nhiều thứ giữa cuộc sống bộn bề này, không thể nhớ hết được. Mình vốn không hiểu rõ bản chất, rồi thành phần của latency, một request nó phải trải qua và đối mặt những gì, vì sao lúc nhanh, lúc chậm. Nhanh thì vì sao nhanh, chậm thì vì sao chậm. Mình không hiểu gì cả nếu chỉ dựa vào tip trick rồi đoán mò nguyên nhân.

Có bạn nói, ừ thì request thì từ ông client, gửi lên server, ông server xử lý, tính toán gì đó, rồi trả về cho mình kết quả, có gì đâu mà lăn tăn, hiểu thế là được rồi, đào sâu làm gì. Nhưng thực sự nếu mình chỉ biết có thế, thì sẽ chẳng debug, và mãi chẳng hiểu request đã phải đối mặt những gì để có thể trả lại kết quả cho mình.

Công thức đã thay đổi mọi thứ

Cảm ơn

Mình xin cảm ơn một cái Handbook về design system của bạn Quang Hoang mới chia sẻ gần đây. Sau khi đọc xong tài liệu đó, mình đã hiểu thêm nhiều điều về latency, có những thứ rất mới mẻ mà mình lần đầu đọc được thay vì mấy cái tip trick mà mình cứ quên rồi lại phải đọc lại. Với mình giá trị nhất chính là công thức (mình xin được phép chia sẻ lại):

Latency = Propagation + Queueing + Service

Mọi thứ giớ đây được biểu diễn dưới dạng toán học, thật tường minh và đơn giản. Độ trễ được tính bằng 3 giá trị:

  • Propagation: thời gian khi request di chuyển từ lúc gửi đi tới lúc nhận về.
  • Queueing: Thời gian mà request phải chờ đợi: trong thread pool của webserver, connection pool của database, …
  • Service: Thời gian mà server xử lý request.

Thực sự khi mình nhìn vào công thức này, mọi thứ tip trick bên trên được giải thích trở nên đơn giản hơn. Rõ ràng là:

chẳng phải để tối ưu latency của một request, chỉ cần tối ưu 3 thành phần bên trên hay sao.

Và từ đây, các chiến lược để tối ưu request, cũng được cấu thành từ công thức này, ví dụ:

  • 2 ông mà đường truyền kết nối (TCP handshakes, TLS handshakes, network…) quá lâu thì nên cho nó gần nhau, nếu gộp được với nhau, hoặc ở cạnh nhau thì càng tốt.
  • Hàng đợi mà dài quá thì phải tìm cách giảm hàng đợi, tăng tốc khả năng xử lý (scale server) để cho hàng đợi ngắn đi.
  • Ông xử lý request lâu quá thì lo mà tối ưu thuật toán, tối ưu database, hoặc sử dụng ngôn ngữ lập trình thích hợp tối ưu cho bài toán business, hoặc là tìm cách xử lý song song bất đồng bộ.

Tối ưu được càng nhiều ông thì càng tốt. Còn nếu vẫn phức tạp quá thì ẩn cái latency đó đi, trả về là xử lý xong rồi nhé (succeded), cho ông client yên tâm, chứ thực ra bên dưới còn đang căng mông lên mà xử lý. Ví dụ như khi mình tạo một AMI của EC2 Instance, AWS sẽ trả về luôn là việc tạo image của phiên bản EC2 đang xử lý, người dùng có thể làm việc khác, không cần phải thêm cái loading rồi đợi khi nào xong mới hết block trang web.

Giờ đây mình lại có một thói quen, ví dụ khi đọc được một tip trick nào đó để tối ưu độ trễ, thì mình sẽ tự hỏi xem nó sẽ tối ưu ông nào trong 3 ông này, bài toán trade-off là gì, hoặc là tối ưu tất cả thay vì trước đây, mình phải đi đọc hết lời giải thích dài loằng ngoằng mà chưa chắc mình sẽ nhớ được 1 ngày.

Có lẽ tối ưu latency vẫn là một cái gì đó rất rộng lớn, vẫn còn thêm những thứ gì đó nằm phía sau nữa, nhưng với mình, với công thức này, nó đã thể hiện được một phần lớn về:

  • định nghĩa về độ trễ
  • các thành phần cấu thành nên độ trễ
  • chiến lược để tối ưu độ trễ.

Ở trên mạng có quá nhiều bài viết về design system, các kỹ thuật để tối ưu (Caching, Loadbalancer, Scaling, CDN …) rồi nên chắc mình cũng sẽ không viết thêm vào đây nữa, mọi người tự tìm hiểu rồi so sánh với công thức nhé.

Áp dụng thực tế

Đang chủ đề về latency, chả là trước đây mình có chia sẻ một bài viết về xây dựng hệ thống monitoring cho công ty, có 2 thứ mình đã kể ra:

Tại sao chỉ số p99 lại quan trọng hơn độ trễ trung bình

Đầu tiên là mình đã nhấn mạnh tầm quan trọng của các chỉ số percentile (p50, p95, p99) đặc biệt là ông p99. Còn vì sao cái thông số độ trễ trung bình không dùng được ở đây vì trong thực tế nó chả có tác dụng gì cả, nó không biêủ hiện cho độ trễ thực tế của hệ thống mà các user gặp phải.

Nếu bạn nói với sếp rằng, “Độ trễ trung bình hệ thống của chúng ta là 200ms,” thì điều đó không đại diện cho toàn bộ người dùng phải trải qua. Điều đó chỉ là một con số thống kê. Khái niệm "Trung bình" là một cái bẫy toán học. Nó giả định rằng mọi người dùng đều có trải nghiệm tương tự. Nhưng trong một hệ thống phân tán, một yêu cầu đơn lẻ không chỉ "xảy ra" một cách ngẫu nhiên—nó sẽ đi qua bộ cân bằng tải, qua thread pool, cơ sở dữ liệu và có thể gọi ra API bên ngoài.

  • p50: Đây là trải nghiệm của người dùng "điển hình". Một nửa nhanh hơn, một nửa chậm hơn.
  • p95: Cứ 20 yêu cầu thì có 1 yêu cầu chậm hơn mức này.
  • p99: "Trải nghiệm của 1%." Cứ 100 yêu cầu thì có 1 yêu cầu chậm hơn giá trị này.

Hệ quả: Nếu trang web của bạn thực hiện 50 cuộc gọi mạng khác nhau để tải (image, CSS, tracking, API), thì khả năng người dùng gặp phải độ trễ p99 ít nhất một lần là gần 40%.

Và khi đó, "vấn đề của 1%" trở thành vấn đề "40% người dùng của bạn cảm thấy khó chịu". Đó là lý do tại sao chúng ta tối ưu hóa cho các trường hợp như p99, chứ không phải cho mức trung bình.

Câu chuyện gỡ lỗi của mình từ môi trường sản xuất

Thứ hai là câu chuyện mà mình debug một lỗi liên quan đến câu chuyện:

  • hàng trăm request webhook payment của bên thứ 3 đến cùng một thời điểm vào lúc 12h trưa gây nghẽn database
  • vCPU khi insight yêu cầu tới 20 vCPUs trong khi hệ thống có 2 thôi, haha.
  • tình trạng của server thì mọi thứ ok, RAM, CPU đều đẹp.

Cứ tới lúc đó là cả hệ thống chậm như rùa khoảng 30 phút, tất cả đều sống chậm lại. Sếp, đồng nghiệp, khách hàng phàn nàn, ảnh hưởng tới uy tín và chất lượng dịch vụ của công ty. Hôm nay mình sẽ phân tích dưới góc nhìn khi mình đã biết công thức của độ trễ bên trên:

Không biết tại thời điểm đó mình quá non, hay đọc tip trick nhiều quá nên bị overthinking, trong đầu mình nghĩ:

Nếu request tới nhiều quá mà không xử lý kịp, thì đẩy nó vào hàng đợi rồi xử lý dần dần, easy (mình thật thiên tài, haha)

Thế là lao vào thiết kế một cái archi tuyệt đẹp với SQS và lambda với reserved concurency (ông này giúp hệ thống của mình lúc nào cũng có sẵn một số lượng lambda để xử lý yêu cầu của mình, ngoài ra cũng sẽ giới hạn số lượng lambda đồng thời xử lý tại cùng một thời điểm) bây giờ thì mọi request webhook payment đến sẽ được giải quyết dần dần, để xem làm sao ăn hết CPU của database nữa.

Đời không như là mơ, mình và đồng nghiệp mất 2 tuần để implement phương án này nhưng kết quả chẳng đi tới đâu

  • Mọi thứ vẫn vậy, không được cải thiện.
  • Hệ thống vẫn chậm lúc 12h
  • Sếp và Team không hài lòng.
  • Và mất thời gian mà không để làm gì

Nếu như mình biết trước cái công thức bên trên, thì mọi thứ sẽ dễ dàng hơn đối với mình thay vì chăm chăm nghĩ đến mấy thứ fancy.

Áp dụng công thức nào: Propagation, Queueing, Service

Propagation

Cái này thì khó tối ưu rồi, mấy cái hệ thống bên thứ 3 (như provider payment) kết nối với hệ thống của mình, hơi khó can thiệp, cái này chắc với cái lỗi của mình, chỉ có scale theo chiều dọc cho ông database lên 20vCPU, thì sẽ handler kịp yêu cầu =]]]

Queueing

Queueing là hàng đợi để mọi request ở đó trước khi được xử lý. Hàng đợi vào router (network) và hàng đợi vào CPU, cái này cao siêu quá, mình không thể tối ưu với trình độ của mình được =]].

Hàng đợi để vào threadpool và hàng đợi vào connection pool, 2 cái này mình khống chế được. Mình đã thay đổi các giá trị mặc định sẵn trong spring boot để phù hợp với hệ thống của mình, từ nay việc xử lý sẽ tuần tự hơn, không cần chen lấn, không cần ôm đồm quá nhiều việc song song một lúc trong khi tài nguyên thì có hạn.

  • Các request được xử lý tuần tự hơn.
  • Ít xung đột hơn, ít tranh chấp tài nguyên hơn.
  • Không còn tình trạng cố gắng xử lý quá nhiều request song song trong khi tài nguyên có hạn.

Service

Lúc đó không hiểu sao mình không nghĩ tới ông này để tối ưu mà cứ nghĩ đến mấy cái archi fancy làm gì. Cái method xử lý webhook lúc đó có nhiều vấn đề cần tối ưu.

  • Thiết kế bất đồng bộ các chain không hợp lý (rõ ràng đã là xử lý theo chain thì cần gì bất đồng bộ).
  • Cùng một yêu cầu select transaction, invoice, payment lặp đi lặp lại
    • → gây áp lực trực tiếp lên cơ sở dữ liệu
    • → tại sao không sử dụng cache?
    • → in-memory cache hoạt động hoàn hảo trong trường hợp này
  • Các tác vụ không quan trọng (audit, tracking) được thực thi trực tiếp
    • → gây quá tải cơ sở dữ liệu
    • → Mình đã chuyển chúng xuống xử lỹ sau cùng webhook
    • → có thể sau này mình sẽ đưa chúng vào queue, xem sao, lúc đấy tính sau 😄
  • Database thì thiếu quá nhiều index quan trọng và thừa nhiều index không sử dụng.
    • → Mình chỉ cần theo dõi trace_id của request trong hệ thống monitoring là ra ngay request nào chậm, request nào nhanh,
    • → chạy lại query trong sql là ra ông nào fulltable scan.
    • → Còn check ông index nào không sử dụng hoặc ít sử dụng, thì các loại cơ sở dữ liệu đều có, lên mạng tra là ra ngay (hiện tại mình quên rồi, đúng là toàn học qua tip trick =)))).

Hiện tại thì mình còn chưa xét đến việc tối ưu các thuật toán cao siêu tới từng con bit đấy nhé, đến đây hệ thống đã có vẻ ngon rồi (biết đủ, hài lòng với những gì mình đang có, ngon quá vừa phức tạp, vừa mất thời gian).

Sau khi áp dụng các cách trên, không có gì bất ngờ những thứ mà nên làm ngay từ đầu, lúc này chỉ cần 0.5 vCPU (max là 2vCPU), chắc mình phải tăng các thông số concurency với threadpool và connection pool quá, haha.

Kết

Hiểu bản chất của latency thay vì học tip trick, giúp mình chủ động hơn trong việc đọc, hiểu và có tư duy phản biện khi áp dụng những kỹ thuật tối ưu latency. Nó giúp mình:

  • Suy nghĩ thấu đáo, rõ ràng hơn
  • Debug hiệu quả hơn
  • Tránh sự phức tạp không cần thiết (chạy theo mấy cái sơ đồ fancy design system)

Chỉ khi mọi thứ rõ ràng (như công thức toán học), việc ghi nhớ những tip đó không còn là vấn đề, mà sẽ là chuyển sang câu hỏi:

Liệu cái tip (trick) đó có thực sự giải quyết được vấn đề đang gặp phải không hay lại tăng thêm tính phức tạp của vấn đề.


Bài viết này cũng được mình dịch sang tiếng Anh trên blog substack của mình.

Mình viết lại những điều này như một cách để ghi nhớ hành trình làm nghề của mình. Nếu bạn cũng đang làm backend, devops hoặc cloud, hy vọng những chia sẻ này có thể giúp bạn một chút gì đó. Còn nếu có chỗ nào mình hiểu chưa đúng, mình vẫn luôn sẵn sàng học thêm.


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í