تصميم متجر قيم-مفاتيح (Key-Value Store) يدعم المعاملات (Transactions) بلغة Go

دقائق القراءة: 9

مقدمة: بناء متجر قيم-مفاتيح تفاعلي يدعم المعاملات بلغة Go

إذا كنت تسعى لتصميم واجهة سطر أوامر تفاعلية (Interactive Shell) تتيح الوصول إلى متجر قيم-مفاتيح (key-value store) في الذاكرة يدعم المعاملات (transactional in-memory)، فأنت في المكان الصحيح. دعنا ننطلق معًا في رحلة تصميم هذا النظام المثير باستخدام لغة Go.

خلفية وتحديات تصميم الأنظمة

لطالما أثارت أسئلة تصميم الأنظمة اهتمامي العميق، وذلك لأنها تطلق العنان للإبداع وتدفعك للتفكير خارج الصندوق. مؤخرًا، اطلعت على مدونة Uduak، حيث شارك تجربته في ماراثون مقابلات عمل استمر 30 يومًا، وكانت تجربة ملهمة للغاية. أنصح بشدة بقراءتها. من خلال هذه المدونة، تعرفت على سؤال مثير للاهتمام في تصميم الأنظمة طُرح عليه خلال إحدى المقابلات.

التحدي المطروح: متجر قيم-مفاتيح بمعاملات

يمكن صياغة السؤال على النحو التالي:

ابنِ واجهة سطر أوامر تفاعلية (interactive shell) تتيح الوصول إلى متجر قيم-مفاتيح في الذاكرة يدعم المعاملات (transactional in-memory key/value store).

ملاحظة: تم إعادة صياغة السؤال لتوضيح الفهم. لقد كان في الأصل مشروعًا منزليًا (take-home project) للمؤلف المذكور أعلاه.

يجب أن تقبل واجهة سطر الأوامر (shell) الأوامر التالية:

الأمر (Command) الوصف (Description)
SET يُعيِّن المفتاح المُعطى إلى القيمة المحددة. يمكن أيضًا تحديث المفتاح.
GET يطبع القيمة الحالية للمفتاح المحدد.
DELETE يحذف المفتاح المُعطى. إذا لم يتم تعيين المفتاح مسبقًا، يتم تجاهل الأمر.
COUNT يُرجع عدد المفاتيح التي تم تعيينها للقيمة المحددة. إذا لم يتم تعيين أي مفاتيح لتلك القيمة، يطبع 0.
BEGIN يبدأ معاملة جديدة (transaction). تسمح لك هذه المعاملات بتعديل حالة النظام ثم تأكيد (commit) أو التراجع (rollback) عن التغييرات.
END ينهي المعاملة النشطة. يتم فقدان كل ما تم إجراؤه ضمن المعاملة النشطة.
ROLLBACK يتجاهل التغييرات التي تمت ضمن سياق المعاملة النشطة. إذا لم تكن هناك معاملة نشطة، يطبع “No Active Transaction“.
COMMIT يؤكد التغييرات التي تمت ضمن سياق المعاملة النشطة وينهي المعاملة النشطة.

أسئلة أولية وافتراضات التصميم

قبل الشروع في التصميم، من الضروري طرح بعض الأسئلة الإضافية لتحديد نطاق المشكلة:

  1. هل تستمر البيانات بعد انتهاء جلسة واجهة سطر الأوامر التفاعلية؟
  2. هل تنعكس العمليات على البيانات في واجهة سطر الأوامر العامة (global shell
  3. هل يؤدي تأكيد التغييرات (COMMITing) في معاملة متداخلة (nested transaction) إلى انعكاسها على المعاملات الأصلية (grandparents) أيضًا؟

قد تختلف أسئلتك، وهذا أمر طبيعي ومرحب به. كلما طرحت المزيد من الأسئلة، كلما فهمت المشكلة بشكل أفضل. سيعتمد حل المشكلة إلى حد كبير على الأسئلة المطروحة. لذا، دعنا نحدد الافتراضات التي سنبني عليها متجر القيم-مفاتيح الخاص بنا:

  • البيانات غير مستمرة: بمجرد انتهاء جلسة واجهة سطر الأوامر، تُفقد البيانات.
  • القيم-مفاتيح هي سلاسل نصية فقط: يمكننا تنفيذ واجهات لأنواع بيانات مخصصة، لكن هذا خارج نطاق هذا الدليل.

فهم مفهوم “المعاملة” (Transaction)

تُنشأ المعاملة باستخدام الأمر BEGIN وتُنشئ سياقًا للعمليات الأخرى لتحدث ضمنه. على سبيل المثال:

> BEGIN // Creates a new transaction
> SET X 200
> SET Y 14
> GET Y
14

هذه هي المعاملة النشطة الحالية، وجميع العمليات تعمل داخلها فقط. حتى يتم تأكيد المعاملة النشطة باستخدام الأمر COMMIT، لا تستمر تلك العمليات. ويقوم الأمر ROLLBACK بإلغاء أي تغييرات أجرتها تلك العمليات في سياق المعاملة النشطة. وبشكل أكثر دقة، فإنه يحذف جميع أزواج القيم-مفاتيح من الـ map الخاص بالمعاملة.

على سبيل المثال:

> BEGIN //Creates a new transaction which is currently active
> SET Y 2020
> GET Y
2020
> ROLLBACK //Throws away any changes made
> GET Y
Y not set // Changes made by SET Y have been discarded

يمكن أن تكون المعاملة متداخلة (nested)، أي أن يكون لها معاملات فرعية (child transactions) أيضًا:

هيكل تسلسلي يوضح العلاقة بين المعاملة الأصل (Parent Transaction) والمعاملة الفرعية (Child Transaction) في متجر قيم-مفاتيح.

ترث المعاملة المنشأة حديثًا المتغيرات من معاملتها الأصلية، وستنعكس التغييرات التي تتم في سياق المعاملة الفرعية في المعاملة الأصلية أيضًا. على سبيل المثال:

> BEGIN //Creates a new active transaction
> SET X 5
> SET Y 19
> BEGIN //Spawns a new transaction in the context of the previous transaction and now this is currently active
> GET Y
Y = 19 //The new transaction inherits the context of its parent transaction
> SET Y 23
> COMMIT //Y's new value has been persisted to the key-value store
> GET Y
Y = 23 // Changes made by SET Y 19 have been discarded

تصميم متجر القيم-مفاتيح

ناقشنا أن المعاملات قد تحتوي على معاملات فرعية. يمكننا استخدام بنية البيانات المكدس (stack) لتعميم هذا المفهوم:

رسم توضيحي لمكدس المعاملات (Transaction Stack) يوضح كيفية تمثيل المعاملات النشطة والمعلقة.

كل عنصر في المكدس يمثل معاملة. يخزن الجزء العلوي من المكدس (Top of the stack) المعاملة “النشطة” الحالية. يحتوي كل عنصر معاملة على map خاص به، والذي سنطلق عليه “المتجر المحلي” (local store) ويعمل كذاكرة تخزين مؤقتة محلية. في كل مرة نقوم فيها بتعيين متغير (SET) داخل معاملة، يتم تحديث هذا المتجر. بمجرد تأكيد التغييرات (COMMITed) داخل المعاملة، يتم كتابة القيم في هذا المتجر “المحلي” إلى كائن map العام لدينا.

سنستخدم تطبيق قائمة متصلة (Linked-list) للمكدس. يمكن تحقيق ذلك أيضًا باستخدام المصفوفات الديناميكية (dynamic arrays)، لكن هذا سيكون واجبًا منزليًا للقارئ.

تعريف هياكل البيانات الأساسية

لنبدأ بتعريف هياكل البيانات (structs) التي ستمثل المعاملات والمكدس:

package main

import (
	"fmt"
	"os"
	"bufio"
	"strings"
)

/*GlobalStore holds the (global) variables*/
var GlobalStore = make(map[string]string)

/*Transaction points to a key:value store*/
type Transaction struct {
	store map[string]string // every transaction has its own local store
	next  *Transaction
}

/*TransactionStack maintains a list of active/suspended transactions */
type TransactionStack struct {
	top  *Transaction
	size int // more meta data can be saved like Stack limit etc.
}

يتم تمثيل المكدس الخاص بنا بواسطة هيكل TransactionStack الذي يخزن فقط مؤشرًا إلى top المكدس. المتغير size هو متغير هيكل يمكن استخدامه لتحديد حجم المكدس، أي للعثور على عدد المعاملات المعلقة والنشطة (اختياري تمامًا، يمكنك حذفه). يحتوي هيكل Transaction على store الذي عرفناه سابقًا كـ map، ومؤشر إلى المعاملة التالية في الذاكرة. أما GlobalStore فهو map يتم مشاركته بواسطة جميع المعاملات في المكدس. هذه هي الطريقة التي نحقق بها علاقة الأب-الابن، وسنتحدث عن هذا لاحقًا بتفصيل أكبر.

عمليات المكدس: الدفع والسحب

الآن، دعنا نكتب طرق الدفع (Push) والسحب (Pop) لهيكل TransactionStack الخاص بنا:

/*PushTransaction creates a new active transaction*/
func (ts *TransactionStack) PushTransaction() {
	// Push a new Transaction, this is the current active transaction
	temp := Transaction{store: make(map[string]string)}
	temp.next = ts.top
	ts.top = &temp
	ts.size++
}

/*PopTransaction deletes a transaction from stack*/
func (ts *TransactionStack) PopTransaction() {
	// Pop the Transaction from stack, no longer active
	if ts.top == nil {
		// basically stack underflow
		fmt.Printf("ERROR: No Active Transactions\n")
	} else {
		node := &Transaction{}
		ts.top = ts.top.next
		node.next = nil
		ts.size--
	}
}

مع كل عملية BEGIN، يتم دفع عنصر مكدس جديد إلى TransactionStack ويتم تحديث top ليشير إلى هذه القيمة. لكل عملية COMMIT أو END، يتم سحب المعاملة النشطة من المكدس، ويتم تعيين العنصر التالي في المكدس إلى top. وبالتالي، تصبح المعاملة الأصلية هي المعاملة النشطة الحالية.

إذا كنت جديدًا على لغة Go، لاحظ أن PushTransaction() و PopTransaction() هما طريقتان (methods) وليستا دالتين (functions) من نوع المستقبِل (receiver type) *TransactionStack. في لغات مثل JavaScript و Python، يتم تحقيق استدعاء طريقة المستقبِل بواسطة الكلمات المفتاحية this و self على التوالي. ومع ذلك، في Go الأمر مختلف، يمكنك تسمية المستقبِل بأي شيء تريده. لتسهيل الفهم، اخترنا ts للإشارة إلى مكدس المعاملات.

طريقة Peek: الوصول إلى المعاملة النشطة

الآن، سنقوم بإنشاء طريقة Peek لإرجاع العنصر العلوي (top element) من المكدس:

/*Peek returns the active transaction*/
func (ts *TransactionStack) Peek() *Transaction {
	return ts.top
}

لاحظ أننا نُرجع متغير مؤشر من نوع Transaction.

تأكيد المعاملة (Commit)

يتضمن تأكيد المعاملة (COMMITing) “نسخ” جميع القيم الجديدة و/أو المحدثة من المتجر المحلي للمعاملة إلى GlobalStore الخاص بنا:

/*Commit write(SET) changes to the store with TranscationStack scope
Also write changes to disk/file, if data needs to persist after the shell closes */
func (ts *TransactionStack) Commit() {
	ActiveTransaction := ts.Peek()
	if ActiveTransaction != nil {
		for key, value := range ActiveTransaction.store {
			GlobalStore[key] = value
			if ActiveTransaction.next != nil {
				// update the parent transaction
				ActiveTransaction.next.store[key] = value
			}
		}
	} else {
		fmt.Printf("INFO: Nothing to commit\n")
	}
	// write data to file to make it persist to disk
	// Tip: serialize map data to JSON
}

التراجع عن المعاملة (Rollback)

يعد التراجع عن المعاملة (rolling back) أمرًا سهلاً للغاية. ما عليك سوى حذف جميع المفاتيح من الـ map (الخريطة المحلية للمعاملة):

/*RollBackTransaction clears all keys SET within a transaction*/
func (ts *TransactionStack) RollBackTransaction() {
	if ts.top == nil {
		fmt.Printf("ERROR: No Active Transaction\n")
	} else {
		for key := range ts.top.store {
			delete(ts.top.store, key)
		}
	}
}

دوال GET و SET

وأخيرًا، إليك دالتي GET و SET:

/*Get value of key from Store*/
func Get(key string, T *TransactionStack) {
	ActiveTransaction := T.Peek()
	if ActiveTransaction == nil {
		if val, ok := GlobalStore[key]; ok {
			fmt.Printf("%s\n", val)
		} else {
			fmt.Printf("%s not set\n", key)
		}
	} else {
		if val, ok := ActiveTransaction.store[key]; ok {
			fmt.Printf("%s\n", val)
		} else {
			fmt.Printf("%s not set\n", key)
		}
	}
}

أثناء تعيين متغير (SETing)، يجب أن نأخذ في الاعتبار الحالة التي قد لا يقوم فيها المستخدم بتشغيل أي معاملات على الإطلاق. هذا يعني أن المكدس سيكون فارغًا، أي أن المستخدم يقوم بتعيين المتغيرات في واجهة سطر الأوامر العامة (global shell) نفسها.

> SET F 55
> GET F
55

في هذه الحالة، يمكننا تحديث GlobalStore الخاص بنا مباشرة:

/*Set key to value */
func Set(key string, value string, T *TransactionStack) {
	// Get key:value store from active transaction
	ActiveTransaction := T.Peek()
	if ActiveTransaction == nil {
		GlobalStore[key] = value
	} else {
		ActiveTransaction.store[key] = value
	}
}

صورة مضحكة لشخصية كرتونية تعبر عن الاستمرار في المتابعة.

لقد انتهينا تقريبًا من متجر القيم-مفاتيح الخاص بنا، لذا دعنا نكتب الكود الرئيسي (driver code):

func main() {
	reader := bufio.NewReader(os.Stdin)
	items := &TransactionStack{}

	for {
		fmt.Printf("> ")
		text, _ := reader.ReadString('\n')
		// split the text into operation strings
		operation := strings.Fields(text)

		switch operation[0] {
		case "BEGIN":
			items.PushTransaction()
		case "ROLLBACK":
			items.RollBackTransaction()
		case "COMMIT":
			items.Commit()
			items.PopTransaction()
		case "END":
			items.PopTransaction()
		case "SET":
			Set(operation[1], operation[2], items)
		case "GET":
			Get(operation[1], items)
		case "DELETE":
			Delete(operation[1], items) // Assuming Delete is implemented
		case "COUNT":
			Count(operation[1], items) // Assuming Count is implemented
		case "STOP":
			os.Exit(0)
		default:
			fmt.Printf("ERROR: Unrecognised Operation %s\n", operation[0])
		}
	}
}

تعد عمليات COUNT و DELETE سهلة التنفيذ إذا واصلت المتابعة حتى الآن. أشجعك على القيام بذلك كواجب منزلي، لكنني قدمت تنفيذًا خاصًا بي أدناه إذا واجهت صعوبة في أي مكان.

صورة متحركة (GIF) تظهر سيفين متقاطعين، ترمز إلى وقت الاختبار أو التحدي.

وبهذا، نكون قد انتهينا من تصميم متجر القيم-مفاتيح الخاص بنا. يمكنك العثور على الكود المصدري الكامل في مستودع GitHub الخاص بالمؤلف الأصلي، ولا تتردد في دعم عمله إذا أعجبك هذا الشرح. إذا كان لديك أي استفسارات، أو وجدت خطأً ما، أو لديك ملاحظات، فلا تتردد في التواصل.

الخلاصة التقنية

لقد استعرضنا في هذا المقال تصميمًا عمليًا لمتجر قيم-مفاتيح يدعم المعاملات في الذاكرة باستخدام لغة Go. يبرز هذا التصميم أهمية استخدام بنية بيانات المكدس (stack) لإدارة سياقات المعاملات المتداخلة بكفاءة، مما يوفر مرونة قوية في التعامل مع تعديلات البيانات المؤقتة والتراجع عنها أو تأكيدها. إن فصل المتجر العام (GlobalStore) عن المتاجر المحلية للمعاملات (local stores) يمثل حجر الزاوية في تحقيق عزل المعاملات، بينما تضمن آليات الدفع والسحب في المكدس التسلسل الصحيح لتطبيق التغييرات. هذا النموذج ليس مجرد حل لمشكلة مقابلة، بل هو أساس متين لفهم كيفية بناء أنظمة بيانات موثوقة وقابلة للتوسع تدعم مبادئ ACID (على الأقل جزئيًا فيما يتعلق بالعزل والمتانة) في بيئة الذاكرة.

اترك تعليقاً

لن يتم نشر عنوان بريدك الإلكتروني. الحقول الإلزامية مشار إليها بـ *