← Tất cả bài viết

[ Blog ]

11 tháng 7, 2026

11 phút đọc

Chuyên mục: Góc nhìn

Vì Sao Neon Hết Giới Hạn Dù Site Ít Traffic

Vì sao database Neon hết giới hạn dù site ít traffic? Nguyên nhân là compute-hours chứ không phải lượt truy cập — sửa bằng cache read và chuyển write sang Redis.

Next.jsNeonPostgresServerlessPrismaRedisPerformance
Vì Sao Neon Hết Giới Hạn Dù Site Ít Traffic

Một buổi sáng site này bắt đầu báo Can't reach database server. Không phải vì tải nặng — mỗi ngày nó chỉ có vài trăm lượt truy cập. Tôi mở dashboard Neon, tưởng gặp lỗi connection string, thì thấy quota compute đã cạn sạch dù mới giữa tháng. Nếu bạn đang thắc mắc vì sao database Neon hết giới hạn dù site ít traffic, thì câu trả lời gần như không bao giờ là do traffic. Là vì serverless Postgres tính tiền theo thời gian compute thức, và có thứ gì đó trong app đang giữ nó thức suốt ngày đêm. Đây là đúng thủ phạm trên chính stack của tôi, và cách sửa ba nhánh đã đưa compute về trạng thái ngủ giữa các lần chỉnh nội dung.

Vì sao database Neon hết giới hạn dù site ít traffic

Gói Free của Neon không tính theo số request hay số dòng — nó tính theo compute-hours (CU-hours). Bạn được 100 CU-hours mỗi project mỗi tháng, và compute sẽ tự suspend sau 5 phút không hoạt động, tính năng Neon gọi là scale-to-zero. Cửa sổ 5 phút đó chính là mấu chốt. Một compute 0.25 CU (mặc định của gói Free) chạy không nghỉ sẽ đốt khoảng 0.25 × 730 = 182.5 CU-hours mỗi tháng — gần gấp đôi con số 100 bạn được cấp. Nên nếu bất kỳ thứ gì trong app query database thường xuyên hơn 5 phút một lần, compute không bao giờ được ngủ, và bạn cạn quota vào khoảng giữa tháng bất kể có bao nhiêu người thật ghé thăm.

Traffic gần như không liên quan. Một tiến trình ping ngầm, một health check, một uptime monitor, hoặc — như trường hợp của tôi — chính pipeline render của app đụng vào DB theo lịch, là đủ để ghim compute ở 100% uptime. Hóa đơn không phải "theo lượt khách". Nó là "theo số phút đèn còn sáng".

Hai thủ phạm, cả hai đều né cache

Khi tôi lần theo mọi đường đụng tới Postgres, có hai đường bắn nhiều hơn hẳn mức một site ít traffic nên có:

  1. Read trực tiếp vào DB mà không bao giờ được bọc cache. Một reader Prisma tự viết ở chỗ này, một prisma.page.findMany() trong route sitemap ở chỗ kia — mỗi cái chạy một query mới ở mỗi request và mỗi lần ISR regenerate. Chỉ riêng crawler và prefetch đã đủ giữ chúng nóng, thừa trong cửa sổ 5 phút.
  2. Một write ở mỗi request. View counter làm một prisma.upsert() cho mỗi lượt xem để tăng một con số. Cái này khó chịu hơn, vì cache không cứu được write. Read thì bạn phục vụ từ cache; còn write, theo định nghĩa, phải chạm tới database — và mỗi write đánh thức compute dậy dù nó vừa mới bắt đầu chợp mắt.

Read thì sửa bằng cách ngay từ đầu đừng hỏi database. Write thì sửa bằng cách đừng gửi nó vào Postgres nữa. Toàn bộ chiến lược chỉ có vậy.

Sửa 1: cache read bằng unstable_cache (và hồi sinh Date)

Mọi read không cần dữ liệu live nên được phục vụ từ unstable_cache của Next.js, gắn tag để một lần chỉnh CMS có thể bust nó theo yêu cầu. Chi tiết quan trọng: đặt revalidate: false để entry không bao giờ hết hạn theo đồng hồ — nó chỉ refresh khi bạn chủ động invalidate tag. Điều đó biến "một query mỗi request" thành "một query mỗi lần chỉnh sửa".

import { unstable_cache } from 'next/cache';
import { prisma } from '@/lib/prisma';

export const getPublishedPosts = unstable_cache(
  async () => {
    const rows = await prisma.post.findMany({
      where: { published: true },
      orderBy: { date: 'desc' },
    });
    // unstable_cache serialize qua JSON — Date biến thành string.
    // Hồi sinh lại để phần còn lại của app vẫn nhận Date thật.
    return rows.map((r) => ({ ...r, date: new Date(r.date) }));
  },
  ['published-posts'], // các phần của cache key
  { tags: ['posts'], revalidate: false }, // không hết hạn theo thời gian; bust qua tag
);

Có hai điểm hay cắn người ta ở đây. Thứ nhất, unstable_cache serialize giá trị trả về qua JSON, nên mọi Date, Decimal, hay Map khi đi ra sẽ thành string hoặc object thường. Nếu component của bạn gọi .toISOString() trên thứ chúng tưởng là Date, chúng sẽ throw sau khi bạn thêm cache, chứ không phải trước — hãy hồi sinh các field đó trên đường ra, như new Date(r.date) ở trên. Thứ hai, khi nội dung thay đổi trong CMS, bust tag để request kế tiếp nạp lại:

import { revalidateTag } from 'next/cache';

// gọi cái này từ mutation / webhook của CMS
revalidateTag('posts');

Giờ sitemap, trang index blog, và mọi trang lấy từ CMS chỉ đụng Postgres một lần sau khi chỉnh, rồi phục vụ từ cache cho tới lần chỉnh tiếp theo. Nếu bạn thắc mắc vì sao mấy read này ngay từ đầu đã không được cache, đó là đúng lớp lỗi tôi mổ xẻ trong bài vì sao Next.js cứ gọi CMS ở mọi request — App Router không cache mặc định, và rất dễ ship một reader âm thầm query ở mỗi lượt.

Sửa 2: chuyển write khỏi Postgres sang Redis

View counter là thứ cache không đụng tới được, nên nó phải rời Postgres hoàn toàn. Một counter là workload hoàn hảo cho Redis — HINCRBY có tính atomic, độ phức tạp O(1), và không bao giờ chạm tới compute của database. Tôi dùng Upstash vì nó dựa trên HTTP và tính tiền theo request, hợp với mô hình serverless của Vercel mà không cần connection pool.

Chi tiết production quan trọng: ship nó kèm fallback về Postgres để bạn có thể deploy code trước khi provision KV store, và để một cú trục trặc Redis chỉ tụt xuống thành một write chậm chứ không phải một write mất.

import { Redis } from '@upstash/redis';
import { prisma } from '@/lib/prisma';

const redis = process.env.UPSTASH_REDIS_REST_URL ? Redis.fromEnv() : null;

export async function incrementView(slug: string) {
  if (redis) {
    // atomic, O(1), không đánh thức Postgres
    return redis.hincrby('post:views', slug, 1);
  }
  // fallback: vẫn chạy trước khi KV được provision, hoặc khi Redis chết
  await prisma.post.update({
    where: { slug },
    data: { views: { increment: 1 } },
  });
}

Việc đọc con số đếm cũng lấy thẳng từ Redis, nên đường nóng của counter không dính tới Postgres chút nào. Bạn có thể flush các con số tích lũy ngược về Postgres theo lịch (một cron ban đêm) nếu muốn tổng số bền vững — nhưng đó là một query mỗi ngày thay vì một query mỗi lượt xem. Chính thay đổi đó mới giúp compute của tôi cuối cùng chạm được cửa sổ nghỉ 5 phút.

Sửa 3: rate-limit các endpoint public để bot không giữ DB thức

Kể cả khi đã cache read và đưa write ra ngoài, một API route public hay endpoint view-counter vẫn có thể bị bot dập liên tục — và mỗi lượt không-cache là một cơ hội đánh thức compute hoặc rút quota. Một rate limiter đặt trước các endpoint nóng sẽ chặn điều đó. @upstash/ratelimit chạy trên cùng Redis, và hai lựa chọn quan trọng để đúng đắn dưới môi trường serverless:

  • ephemeralCache khử trùng lặp trong cùng một instance function đang nóng, nên một burst không nở ra thành một lời gọi Redis cho mỗi request.
  • Fail-open: nếu chính limiter lỗi (Redis không với tới được), bạn cho request đi qua thay vì để cả site 500. Một rate limiter không bao giờ được trở thành một điểm chết đơn lẻ mới.
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(20, '10 s'),
  ephemeralCache: new Map(), // khử trùng lặp trong một instance đang nóng
  analytics: false,
});

export async function POST(req: Request) {
  const ip = req.headers.get('x-forwarded-for') ?? 'anonymous';

  try {
    const { success } = await ratelimit.limit(ip);
    if (!success) {
      return new Response('Too many requests', { status: 429 });
    }
  } catch {
    // fail OPEN — limiter sập không được kéo cả route sập theo
  }

  // ... xử lý request
}

Đừng đổi vendor — hãy đổi query pattern

Mọi thread về chuyện này đều kết thúc bằng ai đó bảo "cứ rời Neon đi". Tôi đã ngó các lựa chọn thay thế trước khi sửa đúng cái bug, và không cái nào chạm tới vấn đề thật:

Lựa chọnThực chất nó đổi gìCái bẫy
Cloudflare D1SQLite trên WorkersSai fit cho Vercel + Prisma; phải viết lại dialect và mất các tính năng Postgres
Supabase FreeVẫn là PostgresĐổi compute-hours lấy pause sau 7 ngày idle — bẫy khác, cùng loại
Tự host trên VPSToàn quyềnGiờ bạn tự lo pooling, backup, vá bảo mật, và uptime
Neon Launch (trả phí)Cùng DB, quota cao hơnTốn tiền — nhưng zero migrate, và rẻ hơn VPS khi tính cả thời gian ops

Vấn đề compute-hours là một vấn đề query-pattern, không phải vấn đề nền tảng. Chuyển sang D1 hay Supabase mà không sửa các read không-cache và cú write mỗi request chỉ dời đúng hành vi đó sang một database có failure mode khác. Sửa pattern trước; quota sẽ hết là vấn đề ngay trên nền tảng bạn đang có. Nếu sau đó bạn thật sự vượt gói Free, tier Launch của Neon cần zero migrate và — một khi tính thật thà cả pooling, backup, và uptime — rẻ hơn tự host cùng thứ đó trên một droplet DigitalOcean.

Cách kiểm chứng đã ăn

Đừng tin code — hãy nhìn compute. Sau khi deploy cả ba nhánh sửa, đồ thị compute trên dashboard Neon nên cho thấy thanh bar tụt về idle / suspended giữa các lần chỉnh, thay vì một đường thẳng ghim ở active. Cú lật từ đường-thẳng sang răng-cưa đó chính là bằng chứng: compute cuối cùng đã chạm cửa sổ nghỉ 5 phút và scale về zero, đúng bản chất của serverless Postgres. Cho nó một ngày traffic thật rồi xem xu hướng CU-hours — của tôi từ "cạn vào ngày 15" xuống còn một sai số làm tròn.

Đây cũng chính là kiểu audit chi-phí-hạ-tầng-âm-thầm tôi viết trong bài cái giá thật của một website chậm, và là kiểu việc Next.js với serverless tôi nhận làm — xem dịch vụ frontend và hiệu năng của tôi nếu hóa đơn database không khớp với traffic của bạn.

FAQ

Một site ít traffic thật sự có thể đốt hết compute-hours free của Neon không?

Có, và traffic chỉ là mồi nhử. Neon Free cho 100 CU-hours/tháng và suspend compute sau 5 phút idle. Một compute 0.25 CU chạy không nghỉ dùng ~182.5 CU-hours — nên bất cứ thứ gì query DB thường hơn 5 phút một lần (một cron, một monitor, một read không-cache, một write mỗi request) sẽ giữ nó thức 24/7 và làm cạn quota bất kể số lượt khách.

Vì sao cache không sửa được view counter của tôi?

Vì counter là một write, mà cache chỉ giúp read. Mỗi lần tăng phải chạm tới database, và mỗi write đánh thức compute. Cách sửa là chuyển write khỏi Postgres hoàn toàn — một HINCRBY atomic trong Redis tốn số không so với compute DB, và bạn có thể batch-flush tổng số về Postgres theo lịch nếu cần con số bền vững.

revalidate: false trong unstable_cache làm gì?

Nó bảo Next.js entry cache không bao giờ hết hạn theo đồng hồ — nó chỉ refresh khi bạn gọi revalidateTag() với tag khớp. Điều đó lý tưởng cho nội dung lấy từ CMS: database bị query một lần sau khi chỉnh, rồi không nữa cho tới lần chỉnh sau, thay vì một TTL cố định cứ query lại kể cả khi chẳng có gì đổi.

Có nên đổi từ Neon sang Cloudflare D1 hoặc Supabase không?

Thường là không, nếu triệu chứng là cạn compute-hours. D1 là SQLite trên Workers — không hợp với app Vercel + Prisma + Postgres. Supabase Free vẫn là Postgres nhưng pause sau 7 ngày idle, một cái bẫy khác cùng loại. Gốc rễ là query pattern của bạn (read không-cache, write mỗi request); sửa cái đó thì vấn đề quota biến mất ngay trên nền tảng bạn đang chạy.

Bài liên quan