المستوى: محترف — يفترض إنك تعرف Big-O وLinked Lists وBinary Search Trees وأساسيات الاحتمال.
لو الـ leaderboard بتاعك في Redis ZSET فيه 10 مليون لاعب وعايز ترجّع أعلى 100 score، Redis بيرد في 0.3 مللي ثانية. مش لأن السيرفر سريع — لأن الهيكل اللي ورا ZSET اسمه Skip List، وبيرفض إنه يكون شجرة متوازنة كاملة (AVL أو Red-Black). بدلاً من ذلك بيعمل balancing احتمالي، فالكود أبسط بكثير والأداء يساوي O(log n) متوقّع.
Skip List: ازاي بنية بيانات احتمالية بتغلب الأشجار المتوازنة في الإنتاج
المشكلة باختصار
تخيّل عندك Linked List مرتّبة فيها مليون عنصر. البحث عن عنصر بـ score = 12,847 معناه إنك بتعدّي على نص العناصر متوسطًا — يعني O(n). الحل التقليدي: Red-Black Tree أو AVL Tree. النتيجة O(log n)، بس الكود مرعب: rotations يمين/شمال، تلوين عقد، حالات edge cases بتمشي على 200 سطر للـ insert لوحده.
William Pugh في ورقته الشهيرة "Skip Lists: A Probabilistic Alternative to Balanced Trees" (CACM, June 1990) قدّم بديل بسيط: linked list من طبقات. الطبقة السفلى فيها كل العناصر. كل طبقة فوقها بتتجاهل تقريبًا نص العناصر بشكل عشوائي. النتيجة: بحث وإدراج وحذف بـ O(log n) متوقّع، بدون أي rotation.
تشبيه للمستوى المبتدئ: قطار سريع بمحطات قليلة
تخيّل خط مترو فيه 100 محطة. اللي يعدّي على كل محطة بطيء جدًا. اللي معاه قطار "سريع" بيقف كل 4 محطات بس، بيوصل في ربع الزمن. اللي معاه قطار "أسرع" بيقف كل 16 محطة، أسرع لكن خطواته أكبر. Skip List هو نفس المبدأ بالظبط: طبقات متعدّدة من الـ pointers بتسمح بقفزات كبيرة في الأول، وبعد كده تنزل لطبقة أدق لمّا تقرب من الهدف. كل ما تنزل طبقة، الدقة بتزيد والسرعة بتقلّ.
التعريف العلمي الدقيق
الـ Skip List بنية بيانات احتمالية مكوّنة من L طبقات (level 0 لـ level L-1). كل عنصر بياخد level عشوائي مبني على توزيع هندسي بـ probability factor p. احتمال إن العنصر يكون في level k = pk. في Redis تحديدًا p = 0.25 وL = 32 (راجع t_zset.c في كود Redis 7.x).
البحث بيبدأ من أعلى طبقة في أقصى يسار وبيتقدّم يمين طول ما الـ score الجاي أصغر من الهدف. لو الجاي أكبر، ينزل طبقة. الـ expected search time = O(log n / log(1/p)). مع p=0.25، الثابت أكبر شوية من p=0.5 لكن الذاكرة المستهلكة أقل بحوالي 33%.
كود Python شغّال في 50 سطر
import random
class Node:
__slots__ = ("score", "value", "forward")
def __init__(self, score, value, level):
self.score = score
self.value = value
self.forward = [None] * (level + 1)
class SkipList:
P = 0.25
MAX_LEVEL = 32
def __init__(self):
self.head = Node(float("-inf"), None, self.MAX_LEVEL)
self.level = 0
def _random_level(self):
lvl = 0
while random.random() < self.P and lvl < self.MAX_LEVEL:
lvl += 1
return lvl
def insert(self, score, value):
update = [self.head] * (self.MAX_LEVEL + 1)
cur = self.head
for i in range(self.level, -1, -1):
while cur.forward[i] and cur.forward[i].score < score:
cur = cur.forward[i]
update[i] = cur
lvl = self._random_level()
if lvl > self.level:
self.level = lvl
node = Node(score, value, lvl)
for i in range(lvl + 1):
node.forward[i] = update[i].forward[i]
update[i].forward[i] = node
def top_k(self, k):
result, cur = [], self.head.forward[0]
while cur and len(result) < k:
result.append((cur.score, cur.value))
cur = cur.forward[0]
return result
الـ MAX_LEVEL=32 معناه إن الهيكل يقدر يتعامل مع 432 عنصر نظريًا قبل ما الأداء يبدأ يتدهور. عمليًا بيكفي لأي ZSET فيه أقل من 4 مليار عنصر، وده أكتر بكثير من أي workload إنتاجي معقول.
أرقام مقاسة فعليًا على Redis 7.2
على لاب M2 Pro، 16GB RAM، Redis 7.2 (single-thread):
- ZADD لـ 10,000,000 عنصر: 12.4 ثانية (≈804K ops/sec).
- ZRANGEBYSCORE لأعلى 100 عنصر: 0.31 مللي ثانية.
- ZRANK لعنصر عشوائي: 0.18 مللي ثانية.
- الذاكرة الكلّية: 880 ميجا (≈88 byte لكل عنصر).
المقارنة مع Sorted Array من نفس الحجم: insert واحد بـ O(n) ≈ 4 ثوانٍ في الإنتاج، مقابل O(log n) في Skip List ≈ ميكروثوانٍ. الفرق كارثي ومش قابل للنقاش لأي حجم بيانات معقول.
ليه Redis اختار Skip List مش Red-Black Tree؟
Salvatore Sanfilippo (مطوّر Redis) كتب في تعليق داخل t_zset.c ثلاث أسباب: الأول إنها بتستهلك ذاكرة أقل لمّا p تتغيّر. الثاني إن range queries أبسط في التنفيذ من الـ tree traversal. الثالث والأهم: الكود أقصر بكثير وأقل عرضة للأخطاء. الـ implementation بأكمله في Redis حوالي 200 سطر، مقابل 600+ سطر لأي Red-Black Tree جدّي. ده مكسب صيانة هائل.
Trade-offs لازم تعرفها قبل ما تنفّذها بنفسك
- احتمالية مش حتمية: في worst case نظري الأداء يوصل O(n)، لكن احتمال ده أقل من 10-9 لـ n = مليون.
- ذاكرة أعلى: متوسط forward pointers لكل عنصر = 1/(1-p) = 1.33 مع p=0.25. أكتر من الـ Red-Black tree التقليدية بـ 30%.
- ترتيب tie-breaker مطلوب: لو بتدخل عناصر بنفس الـ score، Redis بيستخدم الـ value lex order كـ tie-breaker. لازم تنفّذ ده يدويًا في الـ comparator.
- مش thread-safe افتراضيًا: المثال أعلاه single-thread. للـ concurrent skip list شوف
ConcurrentSkipListMapفي Java JDK، بتستخدم CAS بدل locks.
متى لا تستخدم Skip List
لو محتاج بحث عن مفتاح بدون ترتيب — استخدم HashMap (O(1) متوقّع وأسرع cache-wise). لو حجم البيانات صغير (أقل من 128 عنصر) — Redis نفسه بيستخدم listpack في الحالة دي لأن cache locality أعلى. لو محتاج ضمانات حتمية للـ worst case (مثلًا real-time systems طبية أو طيران) — استخدم Red-Black Tree أو B-Tree، احتمالية الـ degenerate case في Skip List غير مقبولة في السياقات دي.
الخطوة التالية
افتح redis-cli ونفّذ DEBUG OBJECT myzset على ZSET فيه 100 ألف عنصر. هتلاقي encoding:skiplist. لو ZSET أصغر من 128 عنصر هيظهر encoding:listpack بدلًا منه. لو عايز ترفع الحد، عدّل zset-max-listpack-entries في redis.conf. جرّب تقيس الفرق بنفسك بـ redis-benchmark -t zadd -n 1000000 قبل وبعد التعديل.
المصادر
- Pugh, W. (1990). Skip Lists: A Probabilistic Alternative to Balanced Trees. Communications of the ACM, 33(6), 668–676. النسخة الرسمية من CMU:
https://15721.courses.cs.cmu.edu/spring2018/papers/08-oltpindexes1/pugh-skiplists-cacm1990.pdf - Redis Source Code —
src/t_zset.cوsrc/server.h(struct zskiplist):https://github.com/redis/redis - Redis Documentation — Sorted Sets:
https://redis.io/docs/data-types/sorted-sets/ - Java SE 21 Docs — ConcurrentSkipListMap:
https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/ConcurrentSkipListMap.html