آنچه در این مقاله میخوانید [پنهانسازی]
برای طراحی Pagination در API که روی داده های حجیم درست کار کند، از صفحه بندی مبتنی بر cursor با مرتب سازی پایدار و ایندکس مناسب استفاده کنید، اندازه صفحه را محدود و قابل تنظیم بگذارید (مثلا پیش فرض 50 و سقف 100)، پارامترها را واضح (limit و cursor یا page و per_page) تعریف کنید، در پاسخ متادیتا و لینک گام بعدی را برگردانید و از offset روی جداول خیلی بزرگ یا با نرخ درج بالا پرهیز کنید.
پیش نیازهای یک Pagination قابل اتکا
- ستون مرتب سازی پایدار: ستونی که همیشه وجود دارد، قابل ایندکس است و در طول زمان یکتا/تعیین کننده است؛ معمولا created_at به همراه id.
- ایندکس ترکیبی: مثلا (created_at DESC, id DESC) برای سرعت دادن به جستجوی صفحه بعد.
- قرارداد ثابت برای پارامترها: یکی از دو الگو را انتخاب کنید و در همه اندپوینت ها یکدست بمانید:
- page و per_page (مبتنی بر offset)
- cursor و limit (مبتنی بر keyset/cursor)
- سقف سختگیرانه برای limit: جلوگیری از درخواست های خیلی سنگین (DoS ناخواسته).
- مرتب سازی قطعی: در ORDER BY همیشه شکاف مساوی ها را با id حل کنید تا رکورد تکراری یا جاافتاده نباشد.
الگوهای رایج Pagination و انتخاب مناسب
page/per_page (offset/limit)
ساده و شهودی است: کاربر می گوید صفحه 10 با اندازه 20. برای داده های متوسط خوب است، اما روی مجموعه های بزرگ یا جدول هایی با درج/حذف مداوم، پرهزینه و ناپایدار می شود؛ هرچه page بزرگ تر، OFFSET بیشتری لازم است و پایگاه داده رکوردهای زیادی را دور می ریزد. در داده های در حال تغییر، امکان جابه جایی بین صفحات و تکراری/جاافتادگی وجود دارد.
cursor/keyset (seek)
به جای شماره صفحه، یک نشانه (cursor) برمی گردانید که موقعیت رکورد آخر صفحه فعلی را نشان می دهد. صفحه بعد براساس WHERE های «کم تر از/بالاتر از» آن موقعیت خوانده می شود. این روش روی جداول بزرگ، سریع و پایدار است، به شرطی که مرتب سازی قطعی و ایندکس مناسب داشته باشید. نقطه ضعف: پرش به صفحه دلخواه (مثلا صفحه 50) مستقیم نیست، اما معمولا برای UI های بی نهایت اسکرول یا «نمایش بیشتر» بهترین گزینه است.
time-based یا از روی فیلتر دامنه
وقتی داده ها بر اساس زمان مصرف می شوند (لاگ، رویداد)، می توانید از محدوده های زمانی صفحه بندی کنید. این روش ساده و همسو با ایندکس زمانی است، اما برای مرور تصادفی مناسب نیست.
طراحی قرارداد API: پارامترها و بدنه پاسخ
این قراردادی است که هم ساده است و هم پاسخگو به نیازهای واقعی:
- Query params:
- limit: عدد صحیح، پیش فرض 50، سقف مثلا 100.
- cursor: اختیاری، رشته base64 امن که موقعیت را رمزگذاری می کند.
- sort: فقط اگر چند ترتیب امن دارید؛ مقدارهای مجاز مستند شوند.
- Header ها:
- RateLimit و Cache-Control بر اساس سیاست شما.
- Link برای rel=”next” (اختیاری اما استاندارد).
- Body:
{ "data": [ { "id": 101, "title": "..." , "created_at": "2026-01-01T10:00:00Z" } ], "page": { "limit": 50, "nextCursor": "YmFzZTY0LWNvZGUtdHlwZS1lZzoxMDE6MjAyNi0wMS0wMVQxMDowMDowMFo=", "hasNext": true } }
توجه: totalCount محاسبه گرانی است. اگر واقعا لازم است، آن را جداگانه و کش شده ارائه کنید.
پیاده سازی عملی با Node.js و PostgreSQL (offset و cursor)
در این مثال یک اندپوینت /articles با مرتب سازی created_at DESC, id DESC پیاده سازی می کنیم. برای محیط های تولید ایندکس ترکیبی زیر ضروری است:
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_articles_created_id
ON articles (created_at DESC, id DESC);الگوی ساده offset/page برای موارد متوسط
import express from "express";
import { Pool } from "pg";
const app = express();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
app.get("/articles", async (req, res) => {
const perPageDefault = 20;
const perPageMax = 100;
const page = Math.max(parseInt(req.query.page || "1", 10), 1);
let perPage = parseInt(req.query.per_page || String(perPageDefault), 10);
if (Number.isNaN(perPage)) perPage = perPageDefault;
perPage = Math.min(Math.max(perPage, 1), perPageMax);
const offset = (page - 1) * perPage;
const client = await pool.connect();
try {
const { rows } = await client.query(
`
SELECT id, title, created_at
FROM articles
ORDER BY created_at DESC, id DESC
LIMIT $1 OFFSET $2
`,
[perPage, offset]
);
// (اختیاری) شمارش کل - برای جداول بزرگ گران است
// const { rows: countRows } = await client.query("SELECT COUNT(*) FROM articles");
// const total = parseInt(countRows[0].count, 10);
res.json({
data: rows,
page: {
page,
per_page: perPage,
// total, // در صورت نیاز
hasNext: rows.length === perPage // تقریبی
}
});
} catch (e) {
res.status(500).json({ error: "server_error" });
} finally {
client.release();
}
});
app.listen(3000);مزیت: پیاده سازی بسیار ساده. محدودیت: برای page های بزرگ کند می شود.
الگوی cursor/keyset برای داده های حجیم
Cursor را به صورت base64 از جفت created_at و id می سازیم تا پایدار و امن باشد.
import express from "express";
import { Pool } from "pg";
const app = express();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const MAX_LIMIT = 100;
const DEFAULT_LIMIT = 50;
function encodeCursor(createdAt, id) {
return Buffer.from(`${createdAt.toISOString()}:${id}`).toString("base64url");
}
function decodeCursor(cursor) {
const raw = Buffer.from(cursor, "base64url").toString("utf8");
const [iso, idStr] = raw.split(":");
const createdAt = new Date(iso);
const id = parseInt(idStr, 10);
if (!iso || Number.isNaN(id) || isNaN(createdAt.getTime())) {
throw new Error("invalid_cursor");
}
return { createdAt, id };
}
app.get("/articles", async (req, res) => {
let limit = parseInt(req.query.limit || String(DEFAULT_LIMIT), 10);
if (Number.isNaN(limit)) limit = DEFAULT_LIMIT;
limit = Math.min(Math.max(limit, 1), MAX_LIMIT);
const cursor = req.query.cursor ? String(req.query.cursor) : null;
const client = await pool.connect();
try {
let params = [limit + 1]; // یک رکورد اضافه برای تشخیص hasNext
let where = "";
if (cursor) {
const { createdAt, id } = decodeCursor(cursor);
params = [createdAt.toISOString(), id, limit + 1];
where = `WHERE (created_at < $1) OR (created_at = $1 AND id < $2)`;
}
const { rows } = await client.query(
`
SELECT id, title, created_at
FROM articles
${where}
ORDER BY created_at DESC, id DESC
LIMIT $${params.length}
`,
params
);
const hasNext = rows.length > limit;
const pageRows = hasNext ? rows.slice(0, limit) : rows;
const nextCursor = hasNext
? encodeCursor(pageRows[pageRows.length - 1].created_at, pageRows[pageRows.length - 1].id)
: null;
// Link header اختیاری
if (nextCursor) {
// نمونه فرمت:
// Link: </articles?cursor=<cursor>&limit=<limit>>; rel="next"
res.setHeader("Link", `</articles?cursor=${nextCursor}&limit=${limit}>; rel="next"`);
}
res.json({
data: pageRows,
page: {
limit,
nextCursor,
hasNext
}
});
} catch (e) {
const code = e.message === "invalid_cursor" ? 400 : 500;
res.status(code).json({ error: e.message });
} finally {
client.release();
}
});
app.listen(3000);نکات کلیدی این پیاده سازی:
- ORDER BY دوتایی (created_at, id) از تکراری/جاافتادگی جلوگیری می کند.
- limit+1 برای تشخیص hasNext برمی گردد، سپس یک رکورد حذف می شود.
- cursor با base64url رمزگذاری شده و از نظر ساختار اعتبارسنجی می شود.
- ایندکس ترکیبی شرط های WHERE را بسیار سریع می کند.
مثال کوتاه با MongoDB (cursor مبتنی بر _id)
import express from "express";
import mongoose from "mongoose";
const app = express();
const Article = mongoose.model("Article", new mongoose.Schema({
title: String
}, { timestamps: { createdAt: "created_at", updatedAt: "updated_at" } }));
const MAX_LIMIT = 100;
const DEFAULT_LIMIT = 50;
function encode(id) { return Buffer.from(String(id)).toString("base64url"); }
function decode(c) { return new mongoose.Types.ObjectId(Buffer.from(c, "base64url").toString("utf8")); }
app.get("/articles", async (req, res) => {
let limit = parseInt(req.query.limit || String(DEFAULT_LIMIT), 10);
if (Number.isNaN(limit)) limit = DEFAULT_LIMIT;
limit = Math.min(Math.max(limit, 1), MAX_LIMIT);
const cursor = req.query.cursor ? decode(String(req.query.cursor)) : null;
const filter = cursor ? { _id: { $lt: cursor } } : {};
const docs = await Article.find(filter)
.sort({ _id: -1 })
.limit(limit + 1)
.lean();
const hasNext = docs.length > limit;
const pageDocs = hasNext ? docs.slice(0, limit) : docs;
const nextCursor = hasNext ? encode(pageDocs[pageDocs.length - 1]._id) : null;
res.json({ data: pageDocs, page: { limit, nextCursor, hasNext } });
});
app.listen(3000);در MongoDB چون _id شامل زمان است، مرتب سازی نزولی روی _id رفتار cursor-دوست ایجاد می کند.
چگونه مطمئن شویم صفحه بندی درست کار می کند؟
- مرتب سازی پایدار را بررسی کنید: ORDER BY یا sort باید شامل ستون یکتا باشد (مثلا id به عنوان گره گشا).
- آزمون پیوستگی: چند بار پی در پی صفحه بعد را بخوانید و مطمئن شوید هیچ id دوباره تکرار نمی شود و شکافی ایجاد نمی شود.
- آزمون همزمانی: هنگام دریافت صفحه ها، درج/حذف همزمان انجام دهید؛ cursor باید همچنان بدون تکرار/جاافتادگی عمل کند.
- سقف بار: با limit های مختلف، تا سقف تعیین شده، تاخیر پرس و جو و مصرف حافظه را اندازه بگیرید.
- بررسی ورودی: cursor های خراب یا دستکاری شده باید 400 برگردانند، نه خطای داخلی.
خطاهای رایج و راه جلوگیری
- استفاده از offset در جداول بسیار بزرگ: با افزایش page کند می شود؛ به cursor مهاجرت کنید.
- مرتب سازی ناپایدار: فقط created_at کافی نیست؛ created_at + id را استفاده کنید.
- limit بدون سقف: درخواست های سنگین باعث فشار به DB می شود؛ سقف سخت بگذارید.
- cursor قابل حدس: اطلاعات داخلی را در cursor لو ندهید یا آن را امضا/رمزگذاری کنید.
- totalCount همزمان با هر صفحه: روی جداول بزرگ گران است؛ در خواست جداگانه یا کش.
- نبودن ایندکس سازگار با ORDER BY: باعث اسکن کامل جدول می شود؛ ایندکس ترکیبی بسازید.
چک لیست اجرایی سریع
- انتخاب الگو: cursor برای داده های حجیم، offset فقط برای مجموعه های کوچک/گزارشی.
- تعریف پارامترها: cursor و limit با سقف ثابت.
- مرتب سازی: ORDER BY created_at DESC, id DESC.
- ایندکس: (created_at DESC, id DESC) یا معادل در موتور شما.
- ساخت cursor امن: base64url و اعتبارسنجی.
- بازگرداندن متادیتا: hasNext و nextCursor، و Link اختیاری.
- مدیریت خطا: invalid_cursor → 400.
گام بعدی چیست؟
قبل از توسعه همه اندپوینت ها، یک قرارداد واحد برای Pagination انتخاب و در یک ماژول مشترک پیاده سازی کنید. سپس آن را روی پرمصرف ترین پرس و جوها اجرا و با لاگ زمان اجرا، ایندکس ها را بهینه کنید. در نهایت مستندات کوتاه اما دقیق پارامترها و پاسخ را کنار نمونه درخواست/پاسخ منتشر کنید.







