+1

Tối ưu hệ thống kiểu... lười: Sửa tí xíu mà được quá trời

Câu chuyện về những thay đổi nhỏ nhưng mang lại hiệu quả lớn đến không ngờ. Liệu có một ngày đẹp trời bạn cũng "lỡ tay" sửa hệ thống tý xíu mà tiết kiệm được vài ngàn đô không?

First things first

Tất nhiên, lời đầu tiên vẫn là phải nhắc đến tác giả - chính là mình - Minh Monmen, một người vô danh mà bày đặt mai danh ẩn tích đã lâu nhưng khi nghe nói cộng đồng Webuild "đến tháng" - có nghĩa là đến tháng 12 - tháng của sự kiện Advent of Sharing thì phải tức tốc trồi lên để biên ra bài viết này. Mình xin hứa rằng mọi lời lẽ trong bài viết này đều không có sự giúp đỡ của AI, do đó nếu có gì sơ suất (như lỗi chính tả) cũng xin độc giả lượng thứ.

Hôm nay mình có hai câu chuyện nhỏ muốn kể với mọi người, thật ra thì mặc dù là hai câu chuyện, nhưng chúng đều nằm cùng trong một trải nghiệm đáng nhớ của mình nên ít nhiều cũng có tý liên quan nhất quán. Hai câu chuyện này đều kể về những lần quick win - tức là chỉ cần một thay đổi nhỏ nhưng đem lại hiệu quả rất lớn trong quá trình tối ưu hệ thống của mình. Thế nhưng liệu chúng có phải là may mắn, ngẫu nhiên đạt được không? Chúng ta hãy cùng xem xét.

Câu chuyện 1: 01 dòng code, 70% CPU

Cái gì? Chỉ đổi 01 dòng code mà có thể giảm được 70% CPU cơ á? Đừng có mà điêu!

Ờ thật ra mình cũng hơi điêu thật, chắc cũng phải sửa mất vài dòng, chính xác hơn là thay đổi thứ tự 01 dòng code, dòng đó kéo theo vài cái type thay đổi, thế nên kết quả là sửa vài dòng. Nhưng giảm 70% CPU thì uy tín (nguồn trust me bro) nhé.

Nói đi cũng phải nói lại cái nguồn gốc của sự thay đổi này. Số là mình có cơ hội được nhìn một team sản phẩm có vài tỷ req/ngày, vì cuộc đời bán mình cho tư bản quá nhàm chán đã nghĩ ra diệu kế chuyển đổi ngôn ngữ lập trình để giết thời gian (cụ thể là bye bye NodeJS mà nhào đến với Golang). Nhờ sức mạnh công nghệ cực kỳ tân tiến của cách mạng công nghiệp 4.0 mà việc chuyển đổi diễn ra khá suôn sẻ và dễ dàng. Mặc dù không đạt được mục đích kéo dài thời gian (làm việc) vì mất đâu có 2-3 tuần, nhưng cũng gọi là có thành tựu trong việc tối ưu tài nguyên sử dụng: CPU của app go giảm so với nodejs những... 10%.

Quả là một con số khổng lồ khiến mình (người cũng có tí trách nhiệm trong việc xúi giục chuyển đổi) cảm thấy nghi ngờ nhân sinh và hơn 30 năm kinh nghiệm gâu lang (mình 94) của bản thân. Tất nhiên là với vài tỷ req/ngày thì người ta cũng đã có kha khá thời gian tối ưu cho cái app NodeJS đấy rồi, nên thật ra cũng không có quá nhiều đất mà diễn thêm nữa. Nhưng thật sự đã dùng tới ultimate skill là đổi ngôn ngữ mà kết quả thu được chỉ có thế thì cũng khiến mình hơi bất ngờ.

Tất nhiên, nếu như các bạn đã đọc series Performance Optimization Guideline của mình rồi thì chắc cũng có vài chủ ý trong đầu để bắt đầu tối ưu tiếp. Mình vẫn theo các phương pháp trong Nghệ thuật tìm kiếm bottleneck, tìm ra một vài cái critical path - tức là điểm chạm có ảnh hưởng lớn nhất tới user và đóng vai trò lớn trong cả hệ thống để bắt đầu xem xét cụ thể quá trình nào sẽ chiếm thời gian và tài nguyên nhiều nhất.

Kết quả thu được như sau:

  • Mình xem xét request A là loại request chiếm 60-70% số request của hệ thống
  • Trong request A, bỏ qua các yếu tố ảnh hưởng lớn nhất là slow query, connection pool,... bởi dữ liệu của request A đã được cache hoàn toàn trong memory của từng instance, không phát sinh query db, connection tới db hay redis,...
  • Latency của A lúc này đã đạt mức p99 < 10ms (giảm ~96% so với p99 của NodeJS là 200ms), gần như không phát sinh IO tới hệ thống bên ngoài, ở đây mục tiêu tối ưu cũng không còn là giảm thêm latency nữa mà là giảm CPU, vì vậy mình tập trung vào những operation tốn cpu trong code như các vòng lặp, những tác vụ cần tính toán như hashing, encode, decode,...
  • Sau 10 phút đọc code, mình tìm ra một câu lệnh trông rất ngây thơ vô số tội là json.Unmarshal (giống như JSON.parse trong NodeJS) được thực hiện sau khi lấy data cache từ memory. Pseudo code như sau:
cache := MemoryCache[string][]byte{}

func getCachedData(key string) (DataResponse, error) {
   data, err := cache.get(key)
   if err != nil {
      return nil, err
   }
   
   response := DataResponse{}

   // parse json string to DataResponse
   err = json.Unmarshal(data, &response)
   
   if err != nil {
      return nil, err
   }
   return response, nil
}

func buildCache(key string) (error) {
   data, err := datasource.get(key)
   if err != nil {
      return err
   }

   cache.set(key, data)
   
   return nil
}

Trông cũng rất bình thường, đây là một function được chuyển đổi tương đương từ việc sử dụng cache bằng Redis (với key-value thường lưu dạng string) sang sử dụng cache trên memory, việc parse json sau khi lấy dữ liệu json string từ cache cũng không phải điều gì quá lạ và cũng thường bị bỏ qua không nhắc đến. Thế nhưng từ kinh nghiệm xử lý những trường hợp cao CPU trong quá khứ, mình nhận ra rằng parse json nếu đặt trong ngữ cảnh code chờ IO thì có cost không đáng kể, nhưng nếu đặt trong ngữ cảnh code chờ compute thì lại ảnh hưởng rất lớn. Cụ thể ở đây là mọi request có sử dụng cachedData đều sẽ tốn thêm một công đoạn là parse json.

Vậy là mình đã làm một thay đổi nhỏ, với lợi thế của memory cache so với redis cache là memory cache có thể trực tiếp lưu trữ object có thể sử dụng được luôn mà không cần phải trải qua bước serialize/deserialize thành string:

Code sẽ như sau:

cache := MemoryCache[string]DataResponse

func getCachedData(key string) (DataResponse, error) {
   data, err := cache.get(key)
   if err != nil {
      return nil, err
   }
   
   return data, nil
}

func buildCache(key string) (error) {
   data, err := datasource.get(key)
   if err != nil {
      return err
   }

   parsedData := DataResponse{}

   // parse json string to DataResponse
   err = json.Unmarshal(data, &parsedData)
   if err != nil {
      return err
   }

   cache.set(key, parsedData)
   
   return nil
}

Anh em bên này triển khai cũng bài bản, canary A/B test các kiểu nên phát hiện vấn đề này từ sớm qua hệ thống đo lường (cũng do mình xúi). Với 10% traffic thật, kết quả thu được của thay đổi trên như sau:

Tài nguyên để phục vụ 10% traffic đang chiếm khoảng 2 CPU đã ngay lập tức giảm xuống chỉ còn ~0.58 CPU, tức là đã giảm hơn 70% chỉ từ một thay đổi về việc parse json trước hay sau khi cache. Chà, tính trên tổng traffic là cũng giảm được 14 CPU usage, tương đương 20 CPU provision, tính giá EC2 sương sương cũng giảm được gần ngàn đô mỗi tháng ròi.

Câu chuyện 2: Vài cái CORS header, 63% chi phí data transfer

Câu chuyện thứ hai cảm giác còn hoang đường hơn câu chuyện đầu tiên nữa. Nó kể về việc người ta phải trả gấp 3 lần (thêm hơn ngàn đô) chi phí data transfer chỉ vì thêm vài cái header CORS. Mọi thứ đáng ra chỉ đơn giản (và khó tin) như vậy nếu như không nhắc đến một sai lầm khá buồn cười của mình là coi thường đối thủ. Đối thủ của mình ở đây là ai? Là một hệ thống với vài tỷ req/ngày, và bất kể một thay đổi nhỏ xíu nào (kể cả việc thêm một cái header) cũng có thể tạo ra một hệ quả đau ví đáng kể.

Ban đầu, hệ thống được triển khai trên ECS, với CORS header được trả ra từ code NodeJS gồm đúng một cái header sau:

access-control-allow-origin: *

Mọi chuyện sẽ không có gì đáng bàn nếu sau khi trải qua 77 49 lần thay đổi hạ tầng, triển khai Kubernetes rồi Golang đồ, đến cuối cùng phần CORS header được gỡ khỏi code và chuyển sang nginx ingress (là lớp routing đứng phía trước) với một config vô cùng quen thuộc đã được mình triển khai cho hằng hà sa số cluster:

annotations:
   nginx.ingress.kubernetes.io/enable-cors: "true"
   nginx.ingress.kubernetes.io/cors-allow-origin: "*"

Bỗng nhiên, tiền data transfer (out) mỗi ngày tăng thêm vài chục đô. Con số này nhanh chóng bị nhận ra bởi anh em trước đó đã tối ưu tiền data transfer cực hạn với rất nhiều biện pháp (nén gzip, loại bỏ nhiều header thừa, xài status 204, 304 tối đa,...). Việc có thể bị tính thêm gần ngàn đô tiền mạng dĩ nhiên là không thể lọt qua mắt của các thanh niên tằn tiện rồi.

Sau khi debug, so sánh đủ kiểu để tìm ra điều khác biệt, bao gồm cả sự thay đổi trong thuật toán nén body (br và gzip), rồi mức độ nén,... thì anh em phát hiện cái thay đổi nhiều nhất lại là cái không được nén, tức là cái... header. Nginx ingress với config trên đã thêm vào các header sau:

access-control-allow-origin: *
access-control-allow-credentials: true
access-control-allow-methods: GET,POST,DELETE,PUT,PATCH,OPTIONS
access-control-allow-headers: DNT,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization
access-control-max-age: 86400

Đối với các hệ thống bình thường, việc thêm vào header như trên có thể tăng size của request thêm khoảng hơn 300 bytes, nếu so sánh với việc body thông thường dao động từ vài KB đến vài chục KB thì con số 300 bytes tăng thêm này không hề đáng kể. Thế nhưng với một hệ thống có lượng request rất lớn nhưng body lại nhỏ hoặc trống (status 204, 304), mỗi request chỉ loanh quanh vài trăm bytes đến 1KB thì 300 bytes này nhân lên lại là một con số khổng lồ.

Ví dụ đoạn header trên chiếm 301 bytes (làm tròn 300 đi), thử nhân với số lượng 1 tỷ req/ngày trong 30 ngày ta sẽ có lượng data transfer tiêu tốn là:

300 * 1000.000.000 (req) * 30 (ngày) / 1000.000.000 (GB) * 0.09 (/GBcaAWS)=810/GB của AWS) = 810

Chỉ với 1 tỷ req/ngày thôi mà ta đã tiêu tốn 810$ chỉ vì vài cái header CORS. OMG!!!

Tất nhiên, sau khi nhận ra điểm mấu chốt này, mình đã chuyển toàn bộ việc xử lý CORS header, CORS preflight request lên một nền tảng thần thánh đứng giữa AWS với người dùng là Cloudflare. Cảm ơn Cloudflare đã tài trợ hoàn toàn hơn ngàn đô data transfer trên bằng một tính năng miễn phí là Snippet. À đúng ra là không miễn phí hoàn toàn nhé, domain của bạn phải đăng ký từ gói Pro (20$/tháng) của Cloudflare thì mới được dùng tính năng này. Bạn có thể tưởng tượng Snippet là phiên bản mini của Cloudflare Worker dùng để làm mấy việc vô tri như thêm CORS header như trên mà không tốn thêm chi phí như Worker.

Bất ngờ chưa, sau khi chuyển tất cả CORS header lên Cloudflare (mất chừng 5 phút setup), lượng data transfer out bỗng nhiên giảm những... 63%, chỉ còn 1/3 so với trước đây.

Tổng kết:

Qua hai câu chuyện trên mình rút ra bài học gì? Liệu tất cả những điều làm được ở trên đều đơn giản đến mức chỉ là may mắn vô tình đạt được thôi hay sao? Đây là những câu hỏi mà mình vẫn băn khoăn từ đầu bài viết. Nếu như ngồi một lúc nữa thì có khi mình có thể kể thêm 2-3 câu chuyện có tiêu đề tương tự, ví dụ như:

  • Giảm 90% băng thông Redis (từ 1Gbps về 100Mbps) sau khi tăng thời gian của memory cache.
  • Giảm 99% tải cho Redis (20k RPS về 200 RPS) sau khi thêm bloomfilter vào việc check blacklist.
  • Giảm 100% random 5xx (15k req/day) sau khi sửa keep-alive timeout của NodeJS.

Tất nhiên những câu chuyện này mình sẽ để dành khi khác, kể trong một bối cảnh đầy đủ và phù hợp hơn. Điều mình muốn nói ở đây là: Có rất nhiều thay đổi khi thực hiện thì rất đơn giản nhưng hiệu quả thì lại rất lớn, nhiều khi nguyên nhân cũng đến từ những điều nhỏ nhặt mà người ta thường bỏ qua, hoặc dù có thấy cũng sẽ không nghĩ tới ảnh hưởng nó lại lớn thế. Hai câu chuyện này không phải tuyệt kỹ công nghệ, nhưng nhát búa đơn giản trị giá 20k$ người ta thường nói phải đến từ việc đầu tư bài bản và tiếp cận có phương pháp:

  • Bắt đầu từ xây dựng hệ thống monitoring toàn diện, chi tiết và đầy đủ để nhìn ra bất kỳ điểm bất thường nào.
  • Sắp xếp, thiết đặt mức độ ưu tiên cho các vấn đề dựa trên ảnh hưởng.
  • Rà soát tỉ mỉ, không bỏ qua những khả năng đơn giản, nhất là trong những hệ thống lớn.

Hết rồi, hẹn gặp lại các bạn trong một bài viết không xa.


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í