+89

Tối ưu thời gian tải 1 trang web từ 7 phút còn 1 giây như thế nào?

Editors' Choice Mayfest2023

Hí mọi người,

Chuyện là hôm nay mình mới đi đám cưới về, vô tình trong đám cưới thì mình gặp lại 1 người em đồng nghiệp lúc trước làm chung dự án với mình. Và thế là trong đầu chợt nhớ ra 1 chủ đề mà mình dự định viết cách đây vài tháng nhưng bị bỏ quên. Nên là “trong cơn say đêm nay”, chúng ta sẽ cùng xem chủ đề này là gì nhé.

1. Trở thành thám tử

À mà quên, tiêu đề bài viết đã đề cập đến chủ đề rồi. Giờ chi tiết hơn nè. Cách đây khoảng 1 năm, mình bắt đầu tham gia 1 dự án tầm trung, với vai trò là maintainer. Nhiệm vụ đầu tiên của mình lúc đó là: Hey Phúc, web này có 1 vài trang load siêu chậm, trang load chậm nhất phải lên đến 7 phút, em xem tình hình xem thế nào rồi cho bọn anh giải pháp nhé

Okay, vậy là giờ phải đóng vai làm thám tử. Xem nào, hừm, vào load cái page 1 phát. Mình chủ động load nhiều lần. Đúng 7 phút thật, lần nào cũng vậy. Mà nó là màn hình Home (Trang chủ) nên rất quan trọng. Giao diện nó kiểu như thế này:

image.png

Thế là đi xem tài liệu cho trang này. Và mình chú ý nhất đến 2 thứ:

  • Số section: 5 (như hình bên trên)
  • Số API: 1

Rồi, giờ qua lại cái web, F12 rồi qua tab Network ngay, load phát nào. Đúng là 1 API thật, và API này mất trung bình 5 phút 23 giây để trả về kết quả. Như vậy thời gian còn lại để tiền xử lý ở FE và render mất hơn 1 phút

2. Điều tra API

Mình quyết định gửi giấy triệu tập đến anh API ở home. Câu hỏi đầu tiên mà mình đặt ra là:

Nhiệm vụ của cậu là gì, sao mà xử lý lâu vậy?

Và câu trả lời mình nhận được là 1 chuỗi JSON kết quả như thế này:

{
  "categories": [...],
  "topBookings": [...],
  "topUsers": [...],
  "suggestBookings": [...],
  "topColleges": [...]
}

Như vậy là API này đóng vai trò trả hết toàn bộ data cho trang chủ. Vậy là đúng rồi, 1 API làm đủ công việc thế này, trả nhiều dữ liệu như thế này, thì không ổn.

Thế là mình bắt tay vào sửa đổi. Phân tích UI một chút nhé. Chúng ta có tổng cộng 5 section, và nội dung của 5 section này là hoàn toàn riêng biệt. Do vậy, mình chủ đích tách 1 API lớn hiện tại thành 5 APIs riêng biệt, mỗi API tương ứng với 1 section. Vừa tăng tốc được thời gian, vừa tận dụng được tính năng “Lazy Loading” (mình sẽ nói rõ hơn phần này ở đoạn sau của bài viết)

Sau khi tách ra 5 API, mình tiến hành đo thời gian phản hồi trung bình:

API 1 API 2 API 3 API 4 API 5
5 giây 82 giây 15 giây 3 phút 27 giây 13 giây

Cộng lại thì cũng hơn 5 phút như trên.

Rồi, giờ xem mỗi API làm gì và mình đã điều chỉnh như thế nào nhé:

  • API 1: Thực hiện query trên DB để lấy danh sách danh mục booking trên hệ thống. Các danh mục này là các danh mục được đặt nhiều nhất. Bảng này có khoảng hơn 200 records. Danh sách này hiếm khi thay đổi, nên là mình quyết định đưa nó lên cache, danh sách sẽ được cập nhật mỗi 1 ngày / lần thông qua cronjob.
  • API 2: Thực hiện query trên DB để lấy danh sách top 20 bookings được đặt và có đánh giá cao nhất. Bảng này chứa hàng ngàn records. Tương tự, mình cũng đưa danh sách này lên cache, danh sách sẽ được cập nhật 1 giờ / lần thông qua cronjob
  • API 3: Thực hiện query trên DB để lấy danh sách người dùng thực hiện nhiều bookings nhất. Bảng này cũng chưa vài trăm records. Lại cache thôi, danh sách được cập nhật 1 giờ / lần
  • API 4: Thực hiện query trên DB để lấy danh sách booking gợi ý cho người dùng. Đây là API tốn nhiều thời gian nhất vì phải query trên bảng dữ liệu lớn, phải lấy số lượng lớn record (100 records). Mỗi người dùng sẽ có danh sách gợi ý riêng, đã được tính toán ở cronjob và lưu vào 1 bảng riêng trên DB. Mình quyết định lưu thêm 1 phiên bản vào cache để query nhanh hơn, đồng thời, sử dụng pagination cho phần này, mỗi lần users scroll chuột, chỉ load 10 booking. Như vậy ta có 10 trang cho phần này
  • API 5: Thực hiện query trên DB để lấy danh sách các trường đại học được đặt booking nhiều nhất. Này cũng dễ, lưu lên cache luôn, danh sách được cập nhật 1 giờ / lần

Và đây là bảng kết quả sau khi mình thực hiện một số thay đổi ở trên:

API 1 API 2 API 3 API 4 API 5
870 mili giây 540 mili giây 440 mili giây 20 giây 173 mili giây

Như vậy, tổng thời gian còn khoảng: 23 giây

Nếu các bạn để ý, ta thấy việc lưu data lên cache đã cải thiện rất nhiều. Tuy nhiên, việc 1 API đọc từ cache như API 4 mất đến 20 giây thì cũng lâu đấy. Lúc này, khả năng cao là do dữ liệu lưu lên cache lớn quá, nên thời gian đọc cũng lâu.

Một trong những nguyên tắc mình cực kì nghiêm khắc trong việc thiết kế API là: Chỉ trả về dữ liệu đủ dùng, không trả dữ liệu dư thừa. Việc trả dữ liệu dư thừa vừa làm tăng thời gian phản hồi, tăng thời gian đọc, vừa có nguy cơ lộ những thông tin không đáng có

Okay, lúc này xem xét UI, mình bắt đầu lọc ra những trường cần thiết để trả về cho FE. Sau khi lọc, đây là kết quả:

API 1 API 2 API 3 API 4 API 5
420 mili giây 330 mili giây 172 mili giây 961 mili giây 52 mili giây

Như vậy, lúc này thời gian chỉ còn khoảng: 2 giây
Ủa khoan, sao mình lại đi cộng thời gian của các API lại như vậy? Ok, xem lại chút nhé.

Khi người dùng vào trang, các API sẽ được gọi đồng thời , như vậy, theo lý thuyết thì thời gian cần để toàn bộ dữ liệu được trả về là:

TOTAL_TIME = MAX(T_API_1, …, T_API_5) = 961(ms)

Trong đó:

  • MAX là hàm tìm giá trị lớn nhất
  • T_API_i: Là thời gian phản hồi của API i

Đấy, như vậy nó mới đúng. Mình xong việc rồi, giờ giao việc lại cho đứa em FE thôi: Sử dụng API mới xem sao nhé

3. Tình tiết mới phát sinh

Sau khi hoàn thành nhiệm vụ, đứa em làm FE của mình cũng đã tích hợp API mới thành công. Web load nhanh hơn hẳn, khách hàng happy. Nhưng…

Hey Phúc, lần trước em báo với anh, là thời gian load chỉ tầm 1 giây, tại sao nó cũng mất hơn 5 giây để hiển thị em à? Em kiểm tra lại nhé

Ơ thế là lại phải làm à? À thì làm thôi. Mình tiếp tục call API theo cả 2 cách:

  • Gọi từng API đơn lẻ
  • Gọi kết hợp các API

Hừm, kết quả vẫn dưới 1 giây, sao lạ nhỉ?

4. Điều tra FE

Thôi, tầm này chắc có vấn đề gì đó ở dưới FE rồi. Mình gửi giấy triệu tập mời anh chàng FE (Reactjs) lên nói chuyện. Nào, cho tôi xem đoạn code khi call API nào:

useEffect(() => {
	API.getCategories()
		.then((categories) => {
			setCategories(categories)
			API.getTopBookings()
				.then((topBookings) => {
					setTopBookings(topBookings)
					API.getTopUsers()
						.then((topUsers) => {
							setTopUsers(topUsers)
							API.getSuggestBookings()
								.then((suggestBookings) => {
									setSuggestBookings(suggestBookings)
									API.getTopColleges()
										.then((topColleges) => {
											setTopColleges(topColleges)
										})
								})
						})
				})
		})
}, [])

Rối chưa 😃 Giải thích đoạn code trên 1 chút nhé: Chúng ta thấy là sau mỗi API, ta có hàm then để xử lý kết quả trả về (mỗi hàm API là 1 Promise), sau đó gọi API tiếp theo. Như vậy là các API này không được gọi đồng thời mà được gọi liên tiếp nhau, API sau muốn được gọi thì API trước đó phải hoàn thành.

Việc code như trên đã làm phí phạm đi thời gian tải trang. Bây giờ, hãy thử đổi đoạn code trên lại nhé:

useEffect(() => {
	Promise.all([
		(async () => {
			return API.getCategories()
		})(),
		(async () => {
			return API.getTopBookings()
		})(),
		(async () => {
			return API.getTopUsers()
		})(),
		(async () => {
			return API.getSuggestBookings()
		})(),
		(async () => {
			return API.getTopColleges()
		})()
	])
}, [])

Ở đây, ta sẽ sử dụng Promise.all để thực thi đồng thời nhiều API cùng 1 lúc. Những API này là không có sự ràng buộc với nhau, do vậy ta có thể gọi nó đồng thời để giảm đi thời gian chờ. Những đoạn code lưu giá trị vào state mình cũng đã đưa vào hàm call API luôn cho gọn. Các bạn muốn để ngoài cũng được nhưng nhớ thêm await lúc gọi mỗi API nhé.

Lúc này, thời gian đã giảm từ hơn 5 giây còn khoảng hơn 2,5 giây.

Ơ thế sao vẫn chưa đạt được dưới 1 giây nhỉ?. Thực ra, thời gian 2,5 giây đã bao gồm luôn thời gian render dữ liệu ra màn hình. Còn thực chất, thời gian tổng để gọi API đã giao động xung quanh 1 giây rồi các bạn nhé

Có thể các bạn thấy lỗi trên là rất cơ bản, tuy nhiên, rất nhiều bạn đã mắc phải lỗi như vậy vì chưa hiểu rõ cơ chế hoạt động của Promise, hoặc hiểu rõ nhưng chưa vận dụng được nhiều. Hy vọng qua bài viết này các bạn sẽ biết được thêm nhé

5. Lazy Loading

Như vậy là chúng ta có tổng cộng 5 APIs cho UI này. Tuy nhiên, liệu có thực sự cần phải gọi hết cả 5 API cùng 1 lúc?

Câu trả lời tuỳ thuộc vào thực tế website của bạn cần hiển thị thông tin như thế nào. Như UI mình vẽ ở trên, chỉ có Section 1 và Section 2 là hiển thị dữ liệu ngay khi người dùng tải trang. Những Section còn lại, người dùng phải scroll chuột xuống thì mới xem được

À vậy thì áp dụng Lazy Loading thôi, thay vì gọi hết cả 5 API như trên, mình chỉ gọi 2 API đầu tiên. 3 API còn lại đưa vào hàm xử lý sự kiện để kiểm tra, nếu Section tương ứng cần hiển thị, thì sẽ call API đó để lấy dữ liệu và render.

Lúc này, thời gian của chúng ta cần để load API là: TOTAL_TIME = MAX(T_API_1, T_API_2) = MAX(420, 330) = 420(ms)

Oke, tính thêm cả thời gian để tiền xử lý dữ liệu và hiển thị, thời gian rơi vào khoảng 1 giây.

Vậy là chúng ta đã đạt được mục tiêu đề ra rồi 😂

6. Ngoại truyện

Ngoài những công việc trên, mình cũng hướng dẫn đứa em mình làm thêm 1 tính năng nữa, là giảm kích thước ảnh. Ở dữ liệu trên, mỗi booking, user, college đều có ảnh. Nhưng ảnh hiển thị là rất nhỏ so với kích thước mặc định, do vậy, việc tạo ra 1 kích thước ảnh nhỏ hơn cũng góp phần giảm tải thời gian render cho dữ liệu đấy.

7. Áp tờ cờ re đuýt

Hãy cùng xem lại những gì mình đã làm nhé:

  • Tận dụng bất đồng bộ để gọi API đồng thời và xử lý vấn đề Promise Hell
  • Caching dữ liệu và xoá đi những dữ liệu dư thừa
  • Lazy loading và phân trang cho dữ liệu lớn
  • Xử lý, điều chỉnh kích thước ảnh phù hợp

Đấy, các bạn thấy không, những công việc đơn giản như thế này đã giúp chúng ta xử lý được rất nhiều thứ đấy.

Một lần nữa, cám ơn các bạn đã đọc đến đây. Đừng quên upvote, bookmark và để lại ý kiến của các bạn nhé. Hy vọng bài viết này sẽ hữu ích. Hẹn gặp lại mọi người!


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.