التصفح عالي الأداء في قواعد البيانات: لماذا يدمر OFFSET السرعة وكيف ينقذها Cursor
تحليل تقني لانحدار أداء OFFSET مع زيادة البيانات وكيف يحقق Keyset/Cursor سرعة ثابتة O(1) ويمنع تكرار أو تفويت السجلات في القوائم اللحظية.
📄 Pagination: مش مجرد Page 1 و Page 2
<img src="https://img.shields.io/badge/API-Pagination-blue" alt="API Pagination"/> <img src="https://img.shields.io/badge/Database-Indexing-orange" alt="Database Indexing"/> <img src="https://img.shields.io/badge/Scalability-Cursor%20%26%20Offset-green" alt="Scalability"/>اختيار نوع الـPagination ممكن يفرق بين API سريعة وAPI بتختنق مع الـscale.
</div>Pagination مش مجرد UI feature — دي Database & API Architecture Decision.
📚 Table of Contents
- يعني إيه Pagination؟
- الفكرة في دقيقة
- يعني إيه Offset Pagination؟
- ليه ORDER BY مهم؟
- Stable Ordering
- مشكلة Large Offsets
- Offset Pagination إمتى تكون مناسبة؟
- Cursor / Keyset Pagination
- ليه Cursor Pagination ممكن تكون أسرع؟
- Cursor Pagination مش سحر
- GUID وPagination
- Indexing
- Composite Indexes
- Cursor لازم يكون Stable
- Dynamic Data وConsistency
- Opaque Cursors
- Filters وSorting
- Frontend + Backend Flow
- Response Design
- Offset vs Cursor
- Decision Framework
- Common Misconceptions
- Production Checklist
- الخلاصة
- Further Reading
🧩 يعني إيه Pagination؟
Pagination هي طريقة لتقسيم مجموعة كبيرة من البيانات إلى أجزاء صغيرة بدل ما الـAPI ترجع كل الـrecords مرة واحدة.
بدل:
10,000,000 products
↓
API
↓
return everything 😵
تعمل:
Request
↓
20 products
↓
Next request
↓
20 products
↓
...
وده يقلل:
- Response size
- Memory usage
- Network traffic
- Rendering cost
- Database work في بعض السيناريوهات
لكن طريقة تنفيذ الـPagination نفسها مهمة جدًا.
⚡ الفكرة في دقيقة
ناس كتير أول ما تسمع:
Pagination
تعمل:
Skip(100000).Take(20)
وتقول:
"كده عملنا Pagination محترم." 😂
لكن Pagination مش مجرد:
Page 1
Page 2
Page 3
هي ممكن تؤثر على:
API latency
Database load
Memory
Network usage
Scalability
Consistency
Index usage
خصوصًا لما البيانات تكبر.
📄 يعني إيه Offset Pagination؟
دي الطريقة التقليدية جدًا.
مثلاً في SQL:
SELECT *
FROM products
ORDER BY id
OFFSET 100000 ROWS
FETCH NEXT 20 ROWS ONLY;
الفكرة:
Skip 100,000
↓
Return 20
وفي ORM ممكن تشوف:
query
.Skip(100000)
.Take(20);
أو في frameworks مختلفة:
offset = 100000
limit = 20
الميزة الأساسية:
ممكن تروح مباشرة لـpage معينة.
مثلاً:
Page 1
Page 2
Page 50
Page 5000
وده مفيد جدًا في:
- Admin dashboards
- Back-office systems
- Reporting
- Search results
- Tables فيها page numbers
⚠️ لكن Offset مش مجرد Skip
فيه نقطة ناس كتير متعرفهاش:
Offset Pagination لازم يكون مبني على ordering واضح لو عايز نتائج مستقرة.
مينفعش تعتمد على:
OFFSET 1000
FETCH NEXT 20 ROWS ONLY
من غير ORDER BY وتفترض إن الـdatabase هترجع rows بترتيب ثابت.
الـSQL table مش عندها مفهوم عام اسمه:
"natural row order"
الـdatabase engine ممكن تغير طريقة تنفيذ query أو access path.
فلو pagination مبنية على ترتيب غير محدد، الصفحات قد لا تكون deterministic.
📌 ليه ORDER BY مهم؟
الأفضل تحدد ترتيب واضح:
ORDER BY created_at
أو:
ORDER BY id
لكن هنا تظهر مشكلة ثانية.
لو:
ORDER BY name
وعندك:
Ahmed
Ahmed
Ahmed
Mohamed
Mohamed
فالـrows اللي عندها نفس name لا تملك tie-breaker فريدًا.
ممكن ترتيبها النسبي يتغير.
🧷 Stable Ordering
الحل الشائع:
ORDER BY created_at, id
بحيث:
created_at
+
unique id
يحدد ترتيبًا deterministic أكثر.
مثلاً:
2026-08-17 10:00:00 — ID 101
2026-08-17 10:00:00 — ID 102
2026-08-17 10:01:00 — ID 103
حتى لو created_at متساوي، الـid يعمل tie-breaker.
ملحوظة: لازم الـordering والـtie-breaker يتوافقوا مع الـquery والـindex المستخدمين فعليًا.
🐌 مشكلة Large Offsets
دي أهم مشكلة في Offset Pagination.
تخيل:
Page size = 20
وعايز:
Page 5000
تقريبًا:
OFFSET 99,980
FETCH NEXT 20
الـdatabase مش بالضرورة تقدر "تقفز سحريًا" للـ20 rows المطلوبة.
حسب الـquery plan والـindex والـdatabase engine، ممكن تضطر تعمل work على عدد كبير من rows قبل ما ترجع الجزء المطلوب.
يعني:
أنت عايز:
20 rows
الـDB ممكن تضطر تتعامل مع:
100,000+ rows
وده يخلي cost الـdeep pagination يزيد.
📉 ليه المشكلة بتكبر؟
مع:
Page 1
الـoffset صغير.
لكن:
Page 100
Page 1000
Page 5000
Page 50000
الـoffset يكبر.
في systems ضخمة ده ممكن يعمل:
Higher latency
+
More database work
+
More CPU / I/O
+
Poor scalability
خصوصًا لو الـquery نفسها معقدة.
⚠️ لكن ملحوظة مهمة عن Offset
مش صحيح نقول:
"OFFSET دائمًا يعمل full table scan."
ده تبسيط غلط.
الـdatabase ممكن تستفيد من index مناسب، والـquery planner يختار execution plan مختلف حسب:
- Database engine
- Indexes
- WHERE conditions
- ORDER BY
- Statistics
- Query shape
- Data distribution
المشكلة الأساسية إن الـdatabase قد تضطر لتجاوز عدد كبير من rows قبل الوصول للجزء المطلوب، والـwork قد يزيد مع الـoffset.
🎯 Offset Pagination إمتى تكون مناسبة؟
Offset ممتازة لما تحتاج:
Page numbers
Jump to page N
Small / moderate datasets
Admin tables
Reports
Stable snapshots
مثلاً:
Page 1 2 3 4 5 ... 100
الـuser يقدر يضغط:
50
ويروح للصفحة 50.
ده شيء Cursor Pagination لا توفره بنفس السهولة.
🧭 Cursor / Keyset Pagination
هنا بنغير طريقة التفكير.
بدل:
"هات page 500"
نقول:
"هات الـrecords اللي بعد آخر record شفته."
مثلاً:
SELECT *
FROM products
WHERE id > @lastId
ORDER BY id
LIMIT 20;
بدل:
OFFSET 100000
نستخدم:
WHERE id > last_seen_id
وده يسمى غالبًا:
Cursor Pagination
أو:
Keyset Pagination
وفي كثير من الاستخدامات المصطلحين بيتقالوا على نفس الفكرة، مع إن "cursor" ممكن يكون abstraction/API representation فوق keyset condition.
🚀 ليه Cursor Pagination ممكن تكون أسرع؟
لو عندك index مناسب على:
id
أو على الـcolumns المستخدمة في الـordering/filtering، فالـdatabase تقدر تستخدم index seek/range scan بدل ما تتعامل مع offset ضخم بنفس الطريقة.
مثلاً:
Index
1
2
3
...
100000
100001 ← start here
100002
100003
...
أنت بتقول:
WHERE id > 100000
فتبدأ من المكان المناسب في الـindex ثم ترجع الـ20 التالية.
🔥 Cursor Pagination ممتازة في
خصوصًا:
- Infinite scrolling
- Social feeds
- Notifications
- Chat history
- Activity streams
- Large datasets
- APIs لا تحتاج page numbers
مثلاً:
User scrolls
↓
Frontend asks for next items
↓
Backend uses cursor
↓
Returns next batch
↓
Frontend asks again
❌ Cursor Pagination مش سحر
ناس كتير بعد ما تتعلم Cursor تقول:
"خلاص Cursor أسرع دائمًا."
لا.
لازم يكون عندك:
Good ordering
+
Suitable index
+
Correct cursor condition
لو بتعمل:
WHERE created_at > @cursor
ORDER BY created_at
ومفيش index مناسب، الـdatabase ممكن تضطر تعمل scan أو sort expensive.
فممكن تلاقي:
Cursor Pagination
لكن performance سيئة.
🧭 Composite Cursor
لو الـordering:
ORDER BY created_at, id
مينفعش cursor يعتمد على:
created_at فقط
لأن ممكن يكون فيه rows كثيرة بنفس timestamp.
الـcursor لازم يمثل الـordering بشكل كافي.
مثلاً:
(created_at, id)
والـnext page condition تكون منطقياً مثل:
WHERE
created_at > @createdAt
OR (
created_at = @createdAt
AND id > @id
)
ORDER BY created_at, id
LIMIT 20;
وفي بعض databases يمكن كتابة equivalent row-value comparison بشكل أبسط حسب الـdialect.
🆔 GUID وPagination
هنا لازم نفرق بين حاجتين.
استخدام GUID كـcursor:
WHERE id > @cursor
ORDER BY id
ممكن يكون مشكلة منطقية لو الـGUID random لأن الترتيب نفسه لا يمثل ترتيبًا زمنيًا أو insertion order مفيدًا للـfeed.
لكن المشكلة الأكبر التي يجب فهمها هي:
Random GUID كـclustered/primary access key قد يسبب index fragmentation/page splits في بعض database engines وdesigns.
مش معنى كده إن:
GUID = bad
لا.
GUID له فوائد مهمة جدًا:
- Distributed ID generation
- Uniqueness
- Avoiding central ID coordination
- Public identifiers في بعض architectures
لكن اختيار:
Random GUID
vs
Sequential/ordered ID
قرار له trade-offs.
🧱 Random GUID وتأثيره على Indexes
في بعض designs، لما تستخدم random GUID كـclustered key:
A81F...
92BC...
FF71...
1AA2...
الـinserts ممكن تدخل في أماكن مختلفة من الـindex.
وده قد يؤدي إلى:
Page Splits
Fragmentation
Extra I/O
More index maintenance
والتفاصيل تختلف حسب:
Database engine
Clustered vs non-clustered index
Fill factor
Insert pattern
Workload
عشان كده بعض الأنظمة تستخدم IDs أكثر ترتيبًا، مثل:
BIGINT IDENTITY
Sequential IDs
Sequential GUID variants
لكن ده مش معناه إن كل system لازم يستخدم sequential IDs.
📌 Cursor محتاج Stable Ordering
دي من أهم قواعد Cursor Pagination.
لو أنت عامل:
ORDER BY name
وفيه:
Ahmed
Ahmed
Ahmed
فالـcursor مش قادر يعتمد على name وحده بشكل موثوق.
الأفضل:
ORDER BY name, id
بحيث id يكون unique tie-breaker.
🔄 Dynamic Data وConsistency
هنا تظهر مشكلة أصعب.
تخيل:
Page 1
أنت جبت أول 20 item.
وبينما أنت بتطلب:
Page 2
دخل record جديد في بداية الـfeed.
أو record اتحذف.
أو الـordering اتغير.
ممكن يحصل:
Duplicate
Missing item
وده شائع في:
Social feeds
Notifications
Chat history
Live dashboards
لأن البيانات بتتغير أثناء pagination.
🧊 Snapshotting
لو محتاج consistency أقوى عبر مجموعة صفحات، ممكن تستخدم concepts مثل:
Snapshot
Fixed timestamp
Version
Stable dataset boundary
مثلاً بدل ما كل request تقول:
"هات latest data"
تحدد:
as_of = timestamp
وتكمل pagination بالنسبة لنفس الـwindow.
ده يقلل تغير النتائج أثناء التنقل بين الصفحات، لكن يضيف complexity وtrade-offs.
🔐 Opaque Cursor
الـAPI غالبًا مش محتاجة تعرض cursor داخلي بالشكل:
1000
ممكن تستخدم:
eyJpZCI6MTAwMH0=
أو token أطول يحتوي على:
{
"id": 1000,
"createdAt": "2026-08-17T10:30:00Z"
}
ثم تعمل:
serialize
↓
encode
↓
cursor
وده يخلي الـcursor:
Opaque
يعني الـclient يتعامل معاه كقيمة opaque بدون ما يحتاج يفهم معناها.
⚠️ Base64 مش Encryption
دي ملحوظة مهمة جدًا.
لو عملت:
Base64(JSON)
فده:
Encoding
مش:
Encryption
أي حد يقدر يفك الـBase64.
فلو الـcursor يحتوي على information حساسة أو محتاج تمنع التلاعب، استخدم mechanism مناسب مثل:
Signed cursor
أو:
Encrypted cursor
حسب الـsecurity requirements.
🎛️ Filters وSorting
دي من أهم مشاكل Cursor Pagination.
افترض إن الـcursor اتعمل بناءً على:
category = keyboards
sort = newest
price > 500
وبعدين الـfrontend غير:
category
أو:
search
أو:
price range
أو:
sorting
وبعت نفس الـcursor.
هنا cursor القديم ممكن يكون غير صالح للـquery الجديدة.
عشان كده القاعدة العملية:
أي تغيير يغير dataset أو ordering غالبًا يحتاج reset للcursor.
يعني:
Change filter
↓
Reset cursor
↓
Start from first batch
🔄 Frontend + Backend Flow
مثلاً أول request:
GET /products?limit=20
الـbackend يرجع:
{
"items": [
"...20 products..."
],
"nextCursor": "eyJpZCI6MTAwMH0=",
"hasMore": true
}
الـfrontend يحتفظ بـ:
nextCursor
ولما الـuser يعمل scroll:
GET /products?limit=20&cursor=eyJpZCI6MTAwMH0=
الbackend:
Decode cursor
↓
Validate cursor
↓
Apply filters
↓
Apply ordering
↓
Use suitable index
↓
Fetch next batch
↓
Return new cursor
والعملية تتكرر:
Page A
↓ cursor A
Page B
↓ cursor B
Page C
↓ cursor C
...
🧪 Cursor Validation
الـbackend المحترم ممكن يتحقق إن الـcursor:
- صالح
- غير malformed
- متوافق مع endpoint
- متوافق مع sorting
- متوافق مع filters
- لم ينتهِ لو فيه expiration
- لم يتم العبث به لو signed
ممكن الـcursor يحتوي على version:
{
"v": 1,
"id": 1000,
"createdAt": "2026-08-17T10:30:00Z"
}
وده يسهل تغيير format مستقبلًا.
📦 Response Design
شكل response كويس ممكن يكون:
{
"items": [],
"pagination": {
"nextCursor": "...",
"hasMore": true
}
}
أو:
{
"data": [],
"nextCursor": "...",
"hasMore": true
}
الميزة إن الـfrontend مش محتاج يفهم تفاصيل الـdatabase.
هو فقط يقول:
give me next
⚖️ Offset vs Cursor
| Feature | Offset | Cursor / Keyset |
|---|---|---|
| Page numbers | ✅ ممتاز | ❌ غير طبيعي |
| Jump to page N | ✅ | ❌ |
| Infinite scroll | ✅ ممكن | ✅ ممتاز |
| Deep pagination | ⚠️ قد تصبح مكلفة | ✅ غالبًا أفضل |
| Large datasets | ⚠️ حسب workload/index | ✅ مناسب جدًا |
| Simple implementation | ✅ | ⚠️ أعقد |
| Stable under changing data | ⚠️ يحتاج care | ⚠️ يحتاج care |
| Index-friendly | يعتمد | غالبًا ممتاز مع index مناسب |
| Flexible sorting | ✅ أسهل | ⚠️ يحتاج تصميم cursor |
| Admin tables | ✅ ممتاز | ⚠️ أقل ملاءمة |
| Social feeds | ⚠️ | ✅ |
| Chat history | ⚠️ | ✅ |
مفيش winner مطلق. الـworkload هو اللي يحدد.
🧭 إمتى أستخدم Offset؟
استخدم Offset لما تحتاج:
Page 1
Page 2
Page 3
...
Page 500
خصوصًا في:
- Admin dashboards
- Data tables
- Reports
- Back-office systems
- User-facing numbered pagination
🧭 إمتى أستخدم Cursor؟
استخدم Cursor لما يكون المطلوب:
Give me the next N items
خصوصًا في:
- Infinite scroll
- Social feeds
- Notifications
- Chat
- Activity streams
- Very large datasets
🧠 أهم قاعدة في Pagination
الـPagination مش:
Frontend problem
فقط.
هي:
Frontend
+
API
+
Database
+
Indexes
+
Consistency
+
Workload
يعني قرار الـpagination لازم يتاخد مع فهم:
Data size
Access pattern
Ordering
Indexes
Mutation rate
UX requirements
🗺️ Decision Framework
flowchart TD
A["Need Pagination"] --> B{"Need page numbers / jump to page N?"}
B -->|Yes| C["Consider Offset Pagination"]
B -->|No| D{"Need next N items / infinite scroll?"}
D -->|Yes| E["Consider Cursor / Keyset"]
D -->|No| F["Analyze workload + UX"]
C --> G{"Large / deep pagination?"}
G -->|Yes| H["Benchmark + inspect query plan"]
G -->|No| I["Offset may be enough"]
E --> J{"Stable ordering + suitable index?"}
J -->|Yes| K["Cursor is a strong candidate"]
J -->|No| L["Design ordering/index first"]
H --> M["Measure"]
I --> M
K --> M
L --> M
❌ Common Misconceptions
"Pagination = Skip + Take"
لا.
دي implementation واحدة من implementations الـpagination.
"Cursor أسرع دائمًا"
لا.
لو مفيش index مناسب، أو query معقدة، أو ordering سيئ، ممكن الأداء يبقى سيئ جدًا.
"Offset يعمل Table Scan دائمًا"
لا.
الـdatabase optimizer والindexes ممكن يغيروا execution plan.
المشكلة إن deep offset قد يتطلب work كبير للوصول للصفوف المطلوبة.
"لازم Cursor لو البيانات كبيرة"
غالبًا Cursor مناسب جدًا للـlarge/deep pagination، لكن القرار يعتمد على:
UX
Query
Indexes
Ordering
Workload
Consistency requirements
"GUID سيئ"
لا.
المشكلة مش GUID نفسه.
المشكلة في اختيار:
Random GUID
كـordering أو clustered access pattern في workload معين.
"Base64 يخفي الـcursor"
لا.
Base64 encoding وليس encryption.
"Cursor يمنع duplicates/missing rows"
لا.
لو البيانات بتتغير أثناء pagination، ممكن تحصل مشاكل consistency.
تحتاج تصميم مناسب حسب الـuse case.
🏗️ Production Checklist
قبل ما تعمل pagination:
- حدد هل الـUX يحتاج page numbers
- حدد حجم الـdataset
- حدد expected growth
- حدد sorting requirements
- استخدم deterministic ordering
- أضف unique tie-breaker عند الحاجة
- تأكد من وجود index مناسب
- راجع
WHERE + ORDER BYمعًا - اختبر deep offsets
- benchmark على production-like data
- راجع query execution plan
- حدد behavior عند تغير البيانات
- تعامل مع filter/sort changes
- اجعل cursor opaque
- لا تعتبر Base64 encryption
- validate cursor على backend
- حدد
hasMore/ end-of-list behavior - لا تعرض database internals بدون سبب
- راقب latency وDB load في production
🧠 مثال كامل
Offset
GET /products?page=5000&limit=20
Backend:
SELECT *
FROM products
ORDER BY created_at DESC, id DESC
OFFSET 99980 ROWS
FETCH NEXT 20 ROWS ONLY;
مفيد لأن الـclient يفكر في:
Page 5000
Cursor
أول request:
GET /products?limit=20
Response:
{
"items": ["..."],
"pagination": {
"nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTA4LTE3VDEwOjMwOjAwWiIsImlkIjoxMDAwfQ==",
"hasMore": true
}
}
Next request:
GET /products?limit=20&cursor=...
Backend conceptually يعمل:
SELECT *
FROM products
WHERE
created_at < @createdAt
OR (
created_at = @createdAt
AND id < @id
)
ORDER BY created_at DESC, id DESC
LIMIT 20;
وده مثال على keyset condition متوافق مع الـdescending order.
🔥 أهم نقطة في المقال
الفرق مش:
OFFSET vs CURSOR
بس.
الفرق الحقيقي:
Pagination strategy
+
Ordering
+
Indexes
+
Query plan
+
Data mutation
+
UX requirements
ممكن API بسيطة جدًا تكون:
Fast
لأن عندها:
Correct pagination
+
Correct index
+
Stable ordering
وممكن API شكلها modern جدًا:
Cursor Pagination 🚀
لكن مفيش index مناسب:
WHERE ...
ORDER BY ...
فتبقى بطيئة جدًا.
🎯 الخلاصة
Pagination مش مجرد:
Page 1
Page 2
Page 3
هي قرار له تأثير على:
API latency
Database load
Memory
Network
Consistency
Scalability
Offset
ممتاز لما تحتاج:
Page numbers
Jump to page
Admin tables
Reports
لكن deep offsets ممكن تكون مكلفة.
Cursor / Keyset
ممتاز لما تحتاج:
Infinite scrolling
Feeds
Notifications
Chats
Large datasets
خصوصًا مع:
Stable ordering
+
Proper indexes
وأهم حاجة:
Cursor Pagination مش magic.
ولو الـindex غلط:
Cursor
↓
No suitable index
↓
Scan / Sort
↓
Performance problem
وفي النهاية:
Pagination
↓
API Design
↓
Database Query
↓
Index Design
↓
Scalability
يعني أحيانًا الفرق بين:
API سريعة جدًا
و:
API بتموت تحت الضغط
مش framework جديد ولا server أقوى.
ممكن يكون مجرد:
اختيار Pagination
+
Ordering
+
Index Design
📚 Further Reading
PostgreSQL
SQL Server
API Design
<div align="center">
📄 Pagination is an Architecture Decision.
Don't just paginate. Design for scale.
</div>منشورات مقترحة
مشاريع ذات صلة
منصة عقارية متكاملة تدعم دورة حياة الإعلانات، وإشعارات الواتساب المباشرة، والبحث الجغرافي بالخريطة في القاهرة والجيزة.