چگونه پایگاه داده ها در زیر کاپوت کار می کنند: ساختن یک فروشگاه ارزش کلیدی در GO

آیا تا به حال فکر کرده اید که چگونه پایگاه داده هایی مانند Postgres ، MySQL یا Cassandra را ذخیره کرده و داده ها را بسیار کارآمد بازیابی می کنند؟ من وقتی اولین بار فهمیدم که بانکهای اطلاعاتی – این سیستم های قدرتمند و پیچیده – در هسته اصلی آنها فقط پرونده های دیسک هستند ، شگفت زده شدم. بله ، پرونده ها! ساختار یافته و بهینه سازی شده برای عملیات سریع و کارآمد. دو مؤلفه اصلی این امکان را فراهم می کند:
لایه پرس و جو: نمایش داده های کاربر ، بهینه سازی آنها و تعامل با موتور ذخیره سازی.
موتور ذخیره سازی: نحوه ذخیره ، بازیابی و به روزرسانی داده ها در دیسک یا حافظه را مدیریت می کند.
موتور ذخیره سازی چیست؟
موتور ذخیره سازی یک مؤلفه اصلی یک سیستم پایگاه داده است که مسئول مدیریت نحوه ذخیره ، بازیابی و به روزرسانی داده ها بر روی دیسک یا حافظه است. این جزئیات سطح پایین مانند سازمان پرونده ، نمایه سازی و کنترل همزمانی را کنترل می کند. در قلب خود ، یک موتور ذخیره سازی به ساختارهای داده کارآمد متکی است که به طور خاص برای دسترسی مبتنی بر دیسک طراحی شده است.
موتورهای ذخیره سازی را می توان به طور گسترده ای به دو نوع طبقه بندی کرد که بر اساس نحوه عملکرد آنها با تغییر داده ها طبقه بندی می شوند:
موتورهای ذخیره سازی قابل تغییر: این موتورها ، مانند درختان B ، برای بارهای کاری سنگین بهینه شده اند. آنها داده ها را در محل به روز می کنند ، به این معنی که داده های موجود هنگام اصلاح رونویسی می شوند. مثالها شامل Postgres ، InnoDB (MySQL) و SQLite است.
موتورهای ذخیره غیرقابل تغییر: این موتورها ، مانند درختان ادغام ساختار یافته (درختان LSM) و جداول هش ساختار یافته (LSHT) ، برای بارهای کار سنگین نوشتن بهینه شده اند. آنها به جای نوشتن داده ها ، داده های جدید را به یک پرونده ورود به سیستم اضافه می کنند و به طور دوره ای داده های قدیمی یا حذف شده را از طریق فرآیندی به نام تراکم پاک می کنند. مثالها شامل LevelDB ، RocksDB ، Bitcask و Cassandra است.
در این پست ، ما یک فروشگاه با ارزش کلیدی تغییر ناپذیر و با الهام از مدل BitCask ایجاد خواهیم کرد. BitCask بر اساس جدول هش ساختاری (LSHT) ساخته شده است ، که داده ها را به صورت متوالی به یک پرونده ورود می پردازد و یک جدول هش در حافظه را برای جستجوی سریع نگه می دارد. موتورهای مبتنی بر LSHT در مقایسه با سایر طرح هایی مانند درختان LSM ساده تر هستند ، زیرا از پیچیدگی مرتب سازی ، ادغام چند سطحی و ساختار داده های اضافی جلوگیری می کنند.
در پایان این پست ، شما می توانید درک اساسی در مورد نحوه کار موتورهای ذخیره سازی و اجرای کار یک فروشگاه با ارزش کلیدی داشته باشید. در پست های آینده ، ما این اجرای را با اضافه کردن ویژگی هایی مانند تراکم ، نمایه سازی پیشرفته و کارآمد با کارآیی داده های بزرگ تقویت خواهیم کرد.
کد منبع کامل در GitHub موجود است
بررسی اجمالی
فروشگاه ارزش کلیدی ما از یک فرمت فایل باینری فقط ضمیمه با ساختار ضبط ساده و در عین حال کارآمد استفاده می کند:
هر رکورد شامل:
Timestamp (4 بایت)
طول کلید (4 بایت)
طول مقدار (4 بایت)
پرچم سنگ قبر (1 بایت)
داده های کلیدی (طول متغیر)
داده های ارزش (طول متغیر)
قالب ضبط:
+------------------+------------------+------------------+--------------------+--------------------+--------------------+
| Timestamp (4B) | Key len (4B) | Value len (4B) | Tombstone flag (1B)| Key (variable) | Value (variable) |
+------------------+------------------+------------------+--------------------+--------------------+--------------------+
| 0x5F8D3E8F | 0x00000005 | 0x00000007 | 0x00 | "mykey" | "myvalue" |
+------------------+------------------+------------------+--------------------+--------------------+--------------------+
چرا فرمت دودویی برای سریال سازی؟
در حالی که JSON و XML قابل خواندن انسانی هستند و برای تعویض داده ها بسیار مورد استفاده قرار می گیرند ، بیشتر سیستم های ذخیره سازی مدرن از قالب های باینری برای ذخیره سازی اصلی خود استفاده می کنند. در اینجا چرا:
عملکرد: این سیستم می تواند قالب های باینری را مستقیماً بدون تجزیه سربار بخواند و بنویسد.
راندمان فضا: بازنمودهای باینری به طور معمول به فضای ذخیره سازی قابل توجهی کمتر از قالب های مبتنی بر متن نیاز دارند.
سازگاری متقابل: با استفاده از قالب های باینری استاندارد ، تضمین می کند که داده ها می توانند به طور مداوم در سیستم های مختلف خوانده شوند.
اجرای
انواع
type Record struct {
Key []byte
Value []byte
Timestamp uint32
Tombstone bool
}
type Store struct {
filename string
mu sync.RWMutex
file *os.File
index map[string]int64
}
ساختار MINKV یک دسته فایل مداوم و یک فهرست حافظه را برای جستجوی کارآمد حفظ می کند.
ایجاد فروشگاه
func Open(filename string) (*Store, error) {
file, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE, 0666)
if err != nil {
return nil, fmt.Errorf("failed to open file: %w", err)
}
store := &Store{
filename: filename,
file: file,
index: make(map[string]int64),
}
if err := store.buildIndex(); err != nil {
file.Close()
return nil, fmt.Errorf("failed to rebuild index: %w", err)
}
return store, nil
}
بازسازی فهرست
برای پشتیبانی از جستجوی کلید کارآمد ، buildIndex
عملکرد پرونده را اسکن می کند و شاخص درون حافظه را با آخرین جبران خسارت برای هر کلید جمع می کند. (اما اگر پرونده یک ساختمان بزرگ باشد ، شاخص کند خواهد بود).
func (s *Store) buildIndex() error {
s.mu.Lock()
defer s.mu.Unlock()
var offset int64
for {
record, err := s.readRecord(offset)
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("failed to read record: %v", err)
}
// mark tombstoned records as deleted
if record.Tombstone {
s.index[string(record.Key)] = tombstoneOffset
} else {
s.index[string(record.Key)] = offset
}
offset += int64(headerSize + len(record.Key) + len(record.Value))
}
return nil
}
سریال سازی دودویی
در writeRecord
عملکرد به طور کارآمد سوابق را در قالب باینری سریال می کند:
func (s *Store) writeRecord(record *Record) (int64, error) {
totalSize := headerSize + len(record.Key) + len(record.Value)
buf := make([]byte, totalSize)
// serialize timestamp (4 bytes)
binary.BigEndian.PutUint32(buf[0:4], record.Timestamp)
// serialize key len (4 bytes)
binary.BigEndian.PutUint32(buf[4:8], uint32(len(record.Key)))
// serialize value len (4 bytes)
binary.BigEndian.PutUint32(buf[8:12], uint32(len(record.Value)))
// serialize tombstone (1 byte)
if record.Tombstone {
buf[12] = 1
} else {
buf[12] = 0
}
// copy key data
copy(buf[headerSize:headerSize+len(record.Key)], record.Key)
// copy value data
copy(buf[headerSize+len(record.Key):], record.Value)
// write record to file
offset, err := s.file.Seek(0, io.SeekEnd)
if err != nil {
return 0, err
}
if _, err := s.file.Write(buf); err != nil {
return 0, err
}
return offset, nil
}
در readRecord
تابع سوابق رمزگذاری شده باینری را از پرونده می خواند:
func (s *Store) readRecord(offset int64) (*Record, error) {
if _, err := s.file.Seek(offset, io.SeekStart); err != nil {
return nil, fmt.Errorf("failed to seek: %w", err)
}
record := &Record{}
// read timestamp (4 bytes)
timestampBuf := make([]byte, 4)
if _, err := s.file.Read(timestampBuf); err != nil {
return nil, err
}
record.Timestamp = binary.BigEndian.Uint32(timestampBuf)
// read key len (4 bytes)
keyLenBuf := make([]byte, 4)
if _, err := s.file.Read(keyLenBuf); err != nil {
return nil, err
}
keyLen := binary.BigEndian.Uint32(keyLenBuf)
// read value len (4 bytes)
valueLenBuf := make([]byte, 4)
if _, err := s.file.Read(valueLenBuf); err != nil {
return nil, err
}
valueLen := binary.BigEndian.Uint32(valueLenBuf)
// read tombstone (1 byte)
tombstoneBuf := make([]byte, 1)
if _, err := s.file.Read(tombstoneBuf); err != nil {
return nil, err
}
record.Tombstone = tombstoneBuf[0] == 1
// read key data
key := make([]byte, keyLen)
if _, err := s.file.Read(key); err != nil {
return nil, err
}
record.Key = key
// read value data
value := make([]byte, valueLen)
if _, err := s.file.Read(value); err != nil {
return nil, err
}
record.Value = value
return record, nil
}
عمل کردن
در Put
عملکرد یک رکورد جدید را به پرونده می نویسد و شاخص درون حافظه را به روز می کند. با دستیابی به قفل نوشتن ، ایمنی موضوع را تضمین می کند:
func (s *Store) Put(key, value []byte) error {
if len(key) == 0 {
return fmt.Errorf("key cannot be empty")
}
s.mu.Lock()
defer s.mu.Unlock()
record := &Record{
Key: key,
Value: value,
Timestamp: uint32(time.Now().Unix()),
}
offset, err := s.writeRecord(record)
if err != nil {
return fmt.Errorf("failed to write record: %w", err)
}
s.index[string(key)] = offset
return nil
}
عمل کردن
در Get
تابع با استفاده از شاخص درون حافظه ، یک مقدار را با کلید خود بازیابی می کند. برای اطمینان از ایمنی موضوع ، قفل خوانده شده را به دست می آورد:
func (s *Store) Get(key []byte) ([]byte, error) {
if len(key) == 0 {
return nil, fmt.Errorf("key cannot be empty")
}
s.mu.RLock()
defer s.mu.RUnlock()
offset, exists := s.index[string(key)]
if !exists || offset == tombstoneOffset {
return nil, fmt.Errorf("key not found: %s", key)
}
record, err := s.readRecord(offset)
if err != nil {
return nil, fmt.Errorf("failed to read record: %w", err)
}
return record.Value, nil
}
حذف عمل
در Delete
تابع یک رکورد Tombstoned را به پرونده می نویسد تا اطمینان حاصل شود که حذف ها در راه اندازی مجدد ادامه دارند و سپس شاخص درون حافظه را به روز می کنند که ضبط شده را به صورت حذف شده نشان می دهد.
(توجه کنید که داده های ضبط هنوز در پرونده وجود دارد که بعداً هنگام اجرای تراکم آن را حذف خواهیم کرد).
func (s *Store) Delete(key []byte) error {
if len(key) == 0 {
return fmt.Errorf("key cannot be empty")
}
s.mu.Lock()
defer s.mu.Unlock()
record := &Record{
Key: key,
Tombstone: true,
Timestamp: uint32(time.Now().Unix()),
}
_, err := s.writeRecord(record)
if err != nil {
return fmt.Errorf("failed to write record: %w", err)
}
s.index[string(key)] = tombstoneOffset
return nil
}
دریافت همه سوابق
ما یک الگوی تکرار ساز را برای اسکن کارآمد تمام سوابق موجود در پرونده بدون بارگذاری همه سوابق به یکباره و پرش از سوابق Tombstoned اجرا کرده ایم.
type Iterator interface {
Next() bool
Record() (*Record, error)
}
استفاده مثال:
package main
import (
"fmt"
"github.com/galalen/minkv"
)
func main() {
store, _ := minkv.Open("store.db")
defer store.Close()
// Put data
_ = store.Put([]byte("os"), []byte("mac"))
_ = store.Put([]byte("db"), []byte("kv"))
_ = store.Put([]byte("lang"), []byte("go"))
// Retrieve data
value, _ := store.Get([]byte("os"))
fmt.Printf("os => %s\n", value)
// update value
_ = store.Put([]byte("os"), []byte("linux"))
// Delete data
_ = store.Delete([]byte("db"))
// Iterate over records
iter, _ := store.Iterator()
for iter.Next() {
record, _ := iter.Record()
fmt.Printf("Key: %s, Value: %s\n", record.Key, record.Value)
}
}
بهینه سازی های آینده
در اینجا برخی از زمینه ها وجود دارد که می توان این فروشگاه ارزش کلیدی را بیشتر بهبود بخشید:
– تراکم: در حال حاضر ، سوابق حذف شده یا رونویسی تا زمان انجام تراکم در پرونده باقی می مانند. اجرای یک مکانیسم تراکم به کاهش اندازه پرونده و بهبود عملکرد کمک می کند.
– Hintfiles: موارد دیسک را کاهش داده و بازسازی شاخص را سرعت بخشید.
– Batching می نویسد: چند گروه برای کاهش تعداد عملیات همگام سازی دیسک به یک دسته واحد می نویسند.
پایان
ما یک فروشگاه با ارزش کلیدی و در عین حال قدرتمند ساخته ایم که اصول اصلی ذخیره سازی پایگاه داده را نشان می دهد. فروشگاه های ارزش کلیدی ستون فقرات بسیاری از پایگاه داده ها و سیستم های دنیای واقعی هستند. به عنوان مثال:
– ریپل: یک پایگاه داده NOSQL توزیع شده که ساختار ورود به سیستم BitCask را برای انجام کارآیی کارآمد نوشتن کار می کند.
– کاساندرا: از اصول ارزش کلیدی برای ذخیره سازی داده های توزیع شده استفاده می کند.
– RocksDB: یک فروشگاه با ارزش کلیدی که در سیستمهایی مانند MySQL و Kafka استفاده می شود ، بر روی یک معماری مبتنی بر LSM ساخته شده است.
کد منبع کامل در GitHub موجود است
منابع
https://tech-lessons.in/en/blog/bitcask/
https://riak.com/assets/bitcask-intro.pdf
https://tikv.org/deep-dive/key-value-engine/introduction/
https://tikv.org/deep-dive/key-value-engine/b-tree-vs-lsm/
https://github.com/avinassh/py-caskdb
https://www.amazon.com/database-internals-deep-distributed-systems/dp/1492040347