6 phút đọc

Server ngốn gần 10 GB RAM vì dữ liệu dùng chung bị lấy lại ở mỗi request

Một server dev chiếm 9,8 GB sau 2.000 request, bản chạy thật chỉ 150 MB. Gốc là mẫu lỗi chung của mọi loại server: dữ liệu dùng chung bị đọc lại ở mỗi request. Số đo từ một dự án Next.js, cách sửa và cách rà app của bạn, dùng framework nào cũng áp dụng được.

Sau hơn bốn tiếng chạy next dev qua nhiều vòng test E2E, Activity Monitor báo tiến trình next-server của một web app chiếm 16,75 GB RAM, trong đó 14 GB đã bị hệ điều hành nén. App đang thêm tiếng Anh bên cạnh tiếng Việt, bản dịch nằm trong thư mục messages/ gồm khoảng 70 file JSON nhỏ cho mỗi ngôn ngữ.

Robot linh vật đưa tay chặn thanh bộ nhớ khổng lồ đang nóng đỏ, cuộn giấy dữ liệu chảy vào

Không có dòng lỗi nào, trang vẫn trả đúng. RAM chỉ tăng. Đo lại cho gọn: khởi động server sạch, bắn 2.000 request vào 10 trang, rồi đo.

Chế độ Bộ nhớ sau 2.000 request
next dev, trước khi sửa 9,8 GB (ép GC xong vẫn còn 7,1 GB heap sống)
next start, bản build production 128 đến 167 MB

Bản chạy thật không bị. Vậy lỗi nằm ở chế độ dev, và khi điều tra thì hóa ra có hai nguyên nhân khác nhau.

Mẫu lỗi này không riêng Next.js

Chi tiết dưới đây đo trên Next.js, nhưng cái bẫy thì có ở mọi loại server: Express, Django, Rails, Laravel, Spring, Go. Chỗ nào code chạy ở mỗi request mà lại lấy một thứ mà request nào cũng cần như nhau (bản dịch, cấu hình, danh mục, template email, prompt, kết quả gọi API ngoài), thì bạn trả cùng một chi phí hàng nghìn lần. Thường nó chỉ hiện ra là CPU và độ trễ. Nó thành RAM khi có thứ gì đó giữ lại kết quả của mỗi lần lấy: dấu vết gỡ lỗi của chế độ dev, một Map không bao giờ xóa, bộ nhớ đệm của ORM gắn theo từng request, log kèm cả payload.

Mình chỉ đo trên Next.js nên không nói được con số cho framework khác. Cách giữ lại khác nhau ở mỗi nơi, còn gốc thì giống nhau.

Nguyên nhân thứ nhất: đọc lại 70 file ở mỗi request

Hàm nạp bản dịch trông như thế này, kiểu code AI viết rất tự nhiên:

const cache = new Map()

async function load(locale: string) {
  const hit = cache.get(locale)
  if (hit && process.env.NODE_ENV === 'production') return hit   // chỉ dùng cache khi chạy thật
  const out = await readAllFiles(locale)                          // đọc ~70 file
  cache.set(locale, out)
  return out
}

Chữ production có lý do: lúc phát triển bạn sửa một câu chữ và muốn thấy ngay, nên cache bị tắt. Hậu quả là ở dev, mọi request đều đọc lại 70 file. Đo bằng sampling heap profiler thì các mục bị giữ nhiều nhất là hàm đọc bản dịch và hàm chụp stack trace mà runtime dev của Next/React gọi cho từng promise, kể cả promise của readFile. Nói gọn: bản dev ghi lại dấu vết mọi lần đọc, và 70 lần mỗi request thì dấu vết chất đống.

Cách sửa gồm hai thay đổi nhỏ. Cache chính promise, không đợi có kết quả mới cache, để mười request đồng thời cùng chờ một lần đọc:

const cache = new Map<string, Promise<Messages>>()

function load(locale: string) {
  let hit = cache.get(locale)
  if (!hit) {
    hit = readAllFiles(locale)
    hit.catch(() => cache.delete(locale))   // lỗi thì lần sau thử lại
    cache.set(locale, hit)
  }
  return hit
}

Và giữ tiện lợi của dev bằng cách xóa cache khi file đổi, thay cho việc tắt hẳn cache:

if (process.env.NODE_ENV !== 'production') {
  watch(messagesDir, { recursive: true }, () => cache.clear()).unref()
}

Đã kiểm tra: sửa một file bản dịch thì trang đổi ngay, trả lại thì về như cũ.

Nguyên nhân thứ hai: lỗi riêng của Next.js dev

Sau bản sửa, server sạch bắn riêng trang /lien-he vẫn thấy heap sau GC tăng theo đường thẳng: 290, 628, 967, 1.303 MB, mỗi mốc 500 request. Một trang admin trống chỉ có root layout cũng tăng khoảng 1,2 MB mỗi request.

Người điều tra dựng một app Next trống, không có dòng code nào của dự án. Root layout gọi await headers(), trang chỉ có một thẻ h1:

// app/layout.jsx
import { headers } from 'next/headers'
export default async function L({ children }) {
  await headers()
  return <html><body>{children}</body></html>
}
// app/page.jsx
export default function P() { return <h1>hi</h1> }

Chạy next dev, gọi / 1.500 lần, đo heap sau khi ép GC:

Phiên bản Next Heap sau 1.500 request
16.4.2 90 → 343–350 MB
16.4.3 82 → 334 MB
16.5.0-canary.27 80 → 335 MB

Đối chứng trên cùng app, mỗi lần 1.500 request:

Thay đổi Kết quả
await connection() trong layout thay cho headers() 91 → 109 MB
Không gọi API động nào 88 → 126 MB
headers() đặt trong server component con bọc children vẫn rò (90 → 348 MB)

Mức rò còn tăng theo kích thước dữ liệu mà trang gửi xuống trình duyệt. App này đang đưa toàn bộ khoảng 240 KB bản dịch xuống mọi trang qua NextIntlClientProvider. Bỏ phần đó thì mức rò từ 1,2 xuống khoảng 0,43 MB mỗi request. Ở app trống, payload lớn nhưng không có headers() thì không rò.

Mình chưa thấy issue tương ứng ở kho Next.js, và người điều tra chưa gửi. Nếu bạn gặp đúng tổ hợp này, đây là kịch bản tái hiện nhỏ nhất.

Ý nghĩa

Hai nguyên nhân cộng lại. Lỗi của Next làm mỗi request để lại một ít. Đoạn code đọc 70 file làm cái “một ít” đó to ra nhiều lần. Sửa cache bản dịch kéo con số từ 9,8 GB xuống mức chấp nhận được cho một buổi làm việc, nhưng không đưa nó về phẳng như production.

Hai bài học nhỏ:

  • Trước khi đổ lỗi cho code của mình, hoặc cho framework, hãy dựng một app trống làm đối chứng. Không có đối chứng thì chỉ có suy đoán.
  • Dữ liệu dùng chung, ít đổi, đừng đọc lại ở mỗi request, dù bạn dùng server nào. Bản dịch, cấu hình, danh mục, template email, prompt của chatbot đều thuộc nhóm này. Cache promise, xóa cache đúng lúc dữ liệu đổi.

Kiểm tra app của bạn

  1. Chạy dev, ghi RAM tiến trình server, rồi gọi một trang 500 lần: for n in $(seq 1 500); do curl -s -o /dev/null localhost:3000/trang-cua-ban; done. Đo lại bằng footprint -f bytes <pid> (macOS) hoặc top. Tăng đều và không hạ là có vấn đề.
  2. Chạy cùng kịch bản trên bản build chạy thật của app. Nếu bản đó đứng yên thì phần lớn là chuyện của chế độ dev, đừng vội sửa code chạy thật. Nếu bản thật cũng tăng thì lỗi nằm ở code của bạn.
  3. Tìm các điều kiện kiểu NODE_ENV === 'production' hay DEBUG đứng cạnh một cache, và các readFile, fetch, truy vấn database nằm trong code chạy ở mỗi request mà kết quả không phụ thuộc người gọi.
  4. Riêng với Next.js: nếu layout gốc của bạn gọi await headers() hay cookies(), thử đổi sang await connection() hoặc chuyển phần cần header xuống sâu hơn để xem bộ nhớ có hết tăng không. Chỉ đo, chưa chắc áp dụng được cho app của bạn.
  5. Đừng đưa toàn bộ bản dịch xuống trình duyệt khi trang chỉ dùng vài nhóm chữ.
Chia sẻ lên Facebook

Bình luận

Đang tải bình luận…