それが僕には楽しかったんです。

僕と MySQL と時々 MariaDB

CMU Database Systems をひたすら追っていく ~10 Query Processing~

この記事は「けんつの1人 DBMS アドベントカレンダー Advent Calendar 2019 - Adventar」19日目の記事です

はじめに

どうも、最近という出だしでブログ記事を 19 個も連続で書くと流石にそろそろネタがなくなってくるけんつです。
今回は SQL の実行周りに関連する話で、久々に CMU の講義に戻ってきます。

Query Processing

Query Plan

Query がどのように実行されるかというと、まず SQLDBMS に投げられるとそれを構文解析に通してツリー構造を作る。
そのツリー構造をもとにどのように実行するか決定する。このとき、実行計画では可能な限りインデックススキャンを利用する必要がある。

Processing Model

DBMSではどのようにクエリプランを実行するかということを定義する。ワークロードに対して色々なトレードオフをもつ様々なモデルが存在する。
ここではそれらについて解説する。

Iterator Model

殆どの DBMS で採用されているモデルで以下のような特徴がある。

- next がコールされる度にオペレータは単一のタプルを返すかタプルが無い場合は Null を返す
- オペレータは子の next を呼び出してタプルを取得して処理するループを実装する

  • DBMS が次のタプルを取得する前にできるだけ多くの演算子を介してタプルを処理する
  • ただし、一部の JOIN, Subquery, ORDER BY などはタプルを全て取得するまでブロックする。

Access Methods

Access Methods とはそのままで、SQL を解析した結果のクエリプランに基づいてクエリを実行する際にどうやってテーブルにアクセスするかという話
大きく分けて2つある。

Sequential Scan

これは順番にアクセスしていくやつ。

  • Prefetch: 各ページにアクセスする際に、DBMS がディスクからのロードなどを極力挟まないように事前に数ページまとめて保持しておく
  • 並列化: 複数のスレッドやプロセスをつかって並行スキャンしていく。
  • Zone Map: 各タプル属性の集計を事前に計算しておく。DBMS はゾーンマップを最初にチェックすることで、ページにアクセスする必要があるかどうかを判断できる。検索するページの総数を減らす目的がある
  • Late Materialization: 各オペレータは次のオペレータが必要する最小限の情報を渡す。
  • Heap Clustering: タプルをクラスタインデックスで指定された順序を使用して、ページに格納する。
Index Scan

一つ以上のインデックスを選択して、クエリに必要なタプルを見つける。
複数のインデックスを使用する場合、DBMS は一致するレコードIDの集合を生成する。Bitmap, Hash Table, Bloom Filter などを使用することが多い。

おわりに

書かれていることが少なかったので自分で調べていく必要がありそう。
OLTP を目指していきたいので Iterator Model を実装する必要があるのかな?

SystemPage の実装

この記事は「けんつの1人 DBMS アドベントカレンダー Advent Calendar 2019 - Adventar」 18 日目の記事です。

はじめに

どうも、最近ダクソリマスタードRTA をやりはじめたけんつです。黒騎士斧槍めちゃ強いですね。
今回もめちゃくちゃ短いが、SystemPageの実装についてです。

SystemPage と言っているけどおそらく catalog といったほうがよくてテーブルの情報を持っているやつです

実装

import "encoding/json"

type SystemPage struct {
	MaxPageId   uint64   `json:"MaxPageId"`
	TableName   string   `json:"TableName"`
	ColumnTypes []string `json:"ColumnType"`
	ColumnNames []string `json:"ColumnName"`
}

func NewSystemPage(tableName string) *SystemPage {
	return &SystemPage{
		MaxPageId:   0,
		TableName:   tableName,
		ColumnTypes: []string{},
		ColumnNames: []string{},
	}
}

func SerializeSystemPage(s *SystemPage) []byte {
	buf, err := json.Marshal(*s)
	if err != nil {
		panic(err)
	}
	return buf
}

func DeserializeSystemPage(data []byte) (*SystemPage, error) {
	var sys SystemPage
	err := json.Unmarshal(data, &sys)
	if err != nil {
		return nil, err
	}
	return &sys, nil
}

これに関しては語ることがない。
テーブルの情報を持っていたかった。以上。

おわりに

こんな短くていいのか?

Disk Manager の実装について

この記事は「けんつの1人 DBMS アドベントカレンダー Advent Calendar 2019 - Adventar」17 日目の記事です。

はじめに

どうも、最近そろそろ DBMS 作るのでなく MySQL とかの勉強をしたいなと思い始めたけんつです。
今日はディスク周りの話です。
といってもすることがあまりないのでめちゃくちゃ短くなりそうです。

データの永続化

ディレクトリ構成

.toybox
├── hello
│   └── page_0
├── sys
│   └── hello.json
└── world
    └── page_0

ディスク周りの永続化は↑のようなディレクトリ構成のもと実行される。
カタログと言われるテーブル情報を保持するデータは sys ディレクトリ以下にテーブルごとの json ファイルとしてまとめて保存されている。

テーブル毎の Page データは page_[pageid] といったファイルにバイナリを書き込んでいる。

何故こうなったか

Page

これはとくにない。テーブル毎にディレクトリを作って、PageId 0 から一意に id を割り振りたかった。
バイナリにシリアライズしたのは、今後いろんな対応をする上で楽になると思ったから。

System Page

Buffer Pool を作っていて、新しく Page を追加する際に PageId を割り振る必要があるのだがテーブル毎に
メモリ、ディスクの両方で(この場合は主にディスクを考慮している)ページをどれだけ保持しているかを確認したかったから。
そのためにディスクも含めて一番大きい pageId がいくつなのかを知る必要があった。

実装

今回は Direct I/O を使っていない
やっていることも簡単でファイルの RW だけ
一応 Page, SysPage 毎に分けているけどこれを書いていて、もう少し抽象化した方がいいなと思った

package storage

import (
	"io/ioutil"
	"os"
	"path"
	"path/filepath"
	"strconv"
)

type DiskManager struct {
}

const (
	RootDir             = ".toybox"
	PagePrefix          = "page_"
	SystemPageDirectory = "sys"
	SystemPageFileExt   = ".json"
)

func NewDisk() *DiskManager {
	return &DiskManager{}
}

func (d *DiskManager) SavePage(tableName string, pageId uint64, page *Page) error {
	buf, err := SerializePage(page)
	if err != nil {
		return err
	}

	tablePath := filepath.Join(RootDir, tableName)
	_, err = os.Stat(tablePath)
	if os.IsNotExist(err) {
		err := os.MkdirAll(tablePath, 0777)
		if err != nil {
			panic(err)
		}
	}

	pid := strconv.FormatUint(pageId, 10)
	pagePath := path.Join(RootDir, tableName, PagePrefix+pid)
	return ioutil.WriteFile(pagePath, buf[:], 0777)
}

func (d *DiskManager) SaveSystemPage(tableName string, sysPage *SystemPage) error {
	json := SerializeSystemPage(sysPage)

	tablePath := filepath.Join(RootDir, SystemPageDirectory)
	_, err := os.Stat(tablePath)
	if os.IsNotExist(err) {
		err := os.MkdirAll(tablePath, 0777)
		if err != nil {
			panic(err)
		}
	}

	sysPagePath := path.Join(RootDir, SystemPageDirectory, tableName+SystemPageFileExt)
	return ioutil.WriteFile(sysPagePath, json, 0777)
}

func (d *DiskManager) LoadPage(tableName string, pageId uint64) (*Page, error) {

	pid := strconv.FormatUint(pageId, 10)
	pagePath := path.Join(RootDir, tableName, PagePrefix+pid)

	_, err := os.Stat(pagePath)
	if os.IsNotExist(err) {
		return nil, err
	}

	bytes, err := ioutil.ReadFile(pagePath)
	if err != nil {
		return nil, err
	}

	var buf [PageByteSize]byte
	copy(buf[:], bytes)
	return DeserializePage(buf)
}

func (d *DiskManager) LoadSystemPage(tableName string) (*SystemPage, error) {

	pagePath := path.Join(RootDir, SystemPageDirectory, tableName+SystemPageFileExt)
	_, err := os.Stat(pagePath)
	if err != nil {
		return nil, err
	}

	bytes, err := ioutil.ReadFile(pagePath)
	if err != nil {
		return nil, err
	}
	return DeserializeSystemPage(bytes)
}

func (d *DiskManager) LoadAllSystemPage() (map[string]*SystemPage, error) {

	path := path.Join(RootDir, SystemPageDirectory)
	files, err := ioutil.ReadDir(path)

	if err != nil {
		return nil, err
	}

	systems := make(map[string]*SystemPage)
	for _, file := range files {
		p := filepath.Join(path, file.Name())
		bytes, err := ioutil.ReadFile(p)
		if err != nil {
			return nil, err
		}

		sysPage, err := DeserializeSystemPage(bytes)
		if err != nil {
			return nil, err
		}
		systems[file.Name()[:len(file.Name())-5]] = sysPage
	}

	return systems, nil
}

おわりに

次は、SystemPage (Catalog) の話なのでまた短い。

Buffer Pool を実装する。

この記事は「けんつの1人 DBMS アドベントカレンダー Advent Calendar 2019 - Adventar」 16 日目の記事です。

はじめに

どうも、最近 co shu nie*1 にめちゃくちゃハマっているけんつです。
「asphyxia piano ver」 がめっちゃいいので聴いてください。壊れそうなぐらい綺麗な透き通った声がめちゃくちゃ好きです。

www.youtube.com


今回はくそがつくほど苦戦した Buffer Pool の実装についてです。
もう解説するのが非常に厳しいので単純に紹介していきます。

普段は MySQL に寄せようと頑張って来ましたが、いよいよ資料がなさすぎてついに PostgreSQL のおそらく Ver 8.x 系の文献を参考にしています。
http://www.interdb.jp/pg/pgsql08.html

CMU の講義に拠らない感じのできになっています。

Buffer Pool の実装

Buffer Pool が持つべき責務を考える

これができなくてめちゃくちゃ苦戦した。というのも、この後に DB Storage 全体を管理する Storage Manager を実装するから
どこからどこまでを Buffer Pool の責務として保証するのかが微妙に噛み合わなくて苦戦した。

その上で、Storage Manager はメモリ上の Buffer Pool とディスク周りの処理を担保する Disk Manager を操作するメソッドをコールするだけ*2にしようということになった。

どういうことかというと、例えばページが Buffer Pool 上に存在しない場合ディスクから読み取る必要があるがこれは Buffer Pool と Disk Manager 両方の機能を必要とするため Buffer Pool で簡潔させるのではなく
Storage Manager に該当する双方の処理を呼ばせて実現することにした。

Buffer Pool 全体の仕様

Buffer Pool とさっきからいっているが実際は Buffer Manager であり、そいつが Buffer Pool を内包している。
全体的には以下のようになっている。

f:id:RabbitFoot141:20191217235423p:plain

Hash Table は可変長の map[string]*BufferTag を使っている。ここでこの Hash Table 、 Buffer Descriptor が Clock Sweep を使って Descriptor を開放したらそれに対応する hash が開放されない実装もれがあることを思い出した。

Buffer Descriptor は Clock Sweep によって開放されるキャッシュライクなもので、固定長の配列をベースにしている。
Buffer Pool は単に Page 構造体の参照を配列でもっているだけ。

ここに出てくる固定長の配列は全て同じサイズで宣言されている。
基本的に Descriptor が持っているインデックスを参照して、 Buffer Pool 内の Page を取得するがこの時にどちらかがスライスだと、ハッシュテーブルからディスクリプタディスクリプタからプールのどちらかの処理をインデックスベースで行う事が面倒になるので
すべて固定長としている。

またハッシュテーブルは uint64 の pageId と string の tableName から SHA1 を計算している。
それをキーとして Buffer Tag を見つけ、その buffer tag が持っているインデックスを元にページを参照することができるという仕組み。

Buffer Pool で持つ他の情報

Buffer Pool では他の DBMS がそうなっているようにインデックスを持っている。また今回これより重要となるのがカタログと言われるテーブル情報を格納している代物で SystemPage と呼ばれているが、PageId の管理などを行っている。
これによってテーブル毎に 0 スタートで一意な pageid を割り振れるようにしている。

構造体

type BufferManager struct {
        Table map[string]*BufferTag
        Descriptors *ClockSweep
        Pools [BufferPoolLimitSize]*Page
        SysPage map[string]*SystemPage
        Indexes map[string]*BTree
}

type BufferTag struct {
        TableName string
        PageId uint64
        Index int
}

type BufferDescriptor struct {
        Ref int
        Usage int
        Dirty bool
        Index int
        Mutex sync.RWMutex
}

Buffer Pool の操作

Append, Read Page だけコードを載せておくけどこんな感じになった。要リファクタリング

func (b *BufferManager) AppendPage(tableName string, p *Page) {

        sys := b.ReadSystemPage(tableName)
        pageId := sys.MaxPageId + uint64(1)
        sys.MaxPageId = pageId
        sha := Hash(tableName, pageId)

        newDesc := NewBufferDescriptor()
        victim, index := b.Descriptors.AppendPage(newDesc)

        if victim != nil {
                b.Pools[victim.Index] = nil
        }

        b.Pools[index] = p
        newDesc.Index = index

        tag := NewBufferTag(tableName, pageId)
        tag.Index = index

        b.Table[sha] = tag
}

func (b *BufferManager) ReadPage(tableName string, pageId uint64) (*Page, *BufferDescriptor) {

        hash := Hash(tableName, pageId)

        t, exist := b.Table[hash]
        if exist {
                desc := b.Descriptors.GetDescriptor(t.Index)
                //desc.IncreaseRef()
                desc.IncreaseUsage()

                return b.Pools[desc.Index], desc
        }

        // if page is null, storage manager read page from disk
        return nil, nil
}

おわりに

ここから怒涛の勢いで Storage Manager を実装する

*1:正しくは Cö shu Nie

*2:API をイメージしている

B Tree Index を実装する

この記事は「けんつの1人 DBMS アドベントカレンダー Advent Calendar 2019 - Adventar」 15 日目の記事です。

はじめに

どうも、最近眼精疲労がひどすぎるけど眼球ってどうやって休めたらいいかわからないけんつです。

今日明日は現状最も苦戦した B Tree Index と Buffer Pool の実装話になります。

B Tree を実装する

今回は B+ Tree でなく B Tree を実装する。また削除は考慮しない。
完全に考慮しない訳ではなくて一応削除を実装することを考慮はしている。ただ物理削除ではなく論理削除としている。
というのもなぜかと言うと、B Tree のなかで最もコストの高い動作が発生するのが物理削除であるという理由から。

どのように論理削除するというとフラグを立てて探索をスキップするという単純なものを実装している。

インデックスの色々

インデックスと言っても色々あってここではその話をする。あくまでも MySQL が前提。

クラスタインデックス

MySQL のインデックスには2種類あってクラスタインデックスとセカンダリインデックスというものが存在する。クラスタインデックスは雑に言うと主キーで構成されたインデックスで暗黙的に構築される。 PK が定義されていないなら全てのカラムから NOT NULL な UNIQUE インデックスを最初に検索し、それを元に構成する。それすらもない場合は InnoDB が内部的に行に自動で付与されるインクリメントカラムを追加しその値で構成する。

セカンダリインデックス

クラスタインデックス以外のインデックスを全てセカンダリインデックスと呼ぶ。これは特定のカラムに貼るものと思って間違いは無いはず。
そして実際にセカンダリインデックスとクラスタインデックスがどのように関連しているかというと、セカンダリインデックスで探索するものは主キー、つまりクラスタインデックスであることが多く
セカンダリインデックスで探すべき主キーを見つけ、そのあとクラスタインデックスを探索してその主キーに紐付いている行を見つけている。
しかし、セカンダリインデックスに必要な情報が全て格納されていれば、クラスタインデックスによる探索をスキップできるため高速に動作させることができる。

B Tree アルゴリズム

「B Tree 実装」とかってググるとなぜか MySQL のインデックスなどが引っかかってきてハイパーわかりにくかったのでアルゴリズムの手順的なものは以下のスライドを参考にした。

speakerdeck.com

これを見れば大体行ける。
そして、以下のデモサイトを見ると困った時に B Tree がどのように処理を行っていくかが試せるからすごく良かった。
www.cs.usfca.edu

実装方針

  • B+ Tree ではなく B Tree
  • 物理削除は実装しない
  • 数値と文字列*1に対応させる
  • クラスタインデックスとしての利用を想定

実装するにあたっての知見・感想

アイテムの閾値

ノードが最大何個までアイテムを持つかという値を決める必要があるが、これは間違いなく奇数がいい。
なぜかというと、閾値を 3 にした場合を例に説明するならばノードが持つアイテムが3つになった段階で最大値・最小値を2番目のアイテムの子ノードにする必要があるから。
偶数にする場合はまた何かしら別に処理する必要があるっぽいが単純に面倒くさいので 3, 5, 7 あたりの真ん中の値が一意に求まるものにすると実装しやすい。

ラッチの実装

CMU の講義でも言われているが、ノードを探索、追加、更新する場合にはラッチを利用するのがよいと言われている。
例えば探索なら、まずルートノードのロック*2をとり、探索する前に子ノードのロックも確保する。
ルートノードの探索が終わったら、ロックを開放して子ノードの更に子ノードのロックを取得する、というプロセスを値が見つかるまで繰り返す。

今回は Github の PR でもわかる通り、今回はラッチをノードレベルではなくインデックスのツリー構造単位でかけている。
これはめちゃくちゃパフォーマンスが悪いのは知っているけど、↑のようにロックをノードレベルで取得がめちゃくちゃめんどくさくて*3 そうなってしまった。

Golang の interface を上手く使う

ノードが保持するアイテムを Item という以下の interface として宣言している

type Item interface {
        Less(itm Item) bool
        Equal(itm Item) bool
        IsSkip() bool
}

そのため B Tree で使うデータ型は上記のメソッドを実装していればいいから、データ型によって異なる大小比較とかが抽象化できて
B Tree 自体のコードがあまり複雑にならなかった。
データ型の宣言は以下の様になっている。

package storage


type IntItem struct {
        Value int32
        Delete bool
}
type StringItem struct {
        Value string
        Delete bool
}

type Item interface {
        Less(itm Item) bool
        Equal(itm Item) bool
        IsSkip() bool
}

func (i IntItem) Less(itm Item) bool {
        v, ok := itm.(IntItem)
        if !ok {
                return false
        }

        return i.Value < v.Value
}

func (i IntItem) Equal(itm Item) bool {
        v, ok := itm.(IntItem)
        if !ok {
                return false
        }
        return i.Value == v.Value
}

func (i IntItem) IsSkip() bool {
        return i.Delete
}

func (s StringItem) Less(itm Item) bool {
        v, ok := itm.(StringItem)
        if !ok {
                return false
        }
        return s.Value < v.Value
}

func (s StringItem) Equal(itm Item) bool {
        v, ok := itm.(StringItem)
        if !ok {
                return false
        }
        return s.Value == v.Value
}

func (s StringItem) IsSkip() bool {
        return s.Delete
}

B Tree の実装

もう解説がめんどくさくなったので、これは本当に自信が無いが*4 コード載せて終わりとする

package storage

import (
        "sort"
        "sync"
)

const (
        MergeThreshold = 3
)

type Items []Item

type Node struct {
        Items Items
        Children []*Node
}

type BTree struct {
        Top *Node
        Mutex sync.RWMutex
}

func NewBTree() *BTree {
        return &BTree{
                Top: nil,
                Mutex: sync.RWMutex{},
        }
}

func (b *BTree) Insert(item Item) {

        b.Mutex.Lock()
        defer b.Mutex.Unlock()

        if b.Top == nil {
                node := new(Node)
                node.Items.InsertAt(item, 0)
                b.Top = node
        } else {
                b.Top.Insert(item)
        }
}

func (n *Node) Insert(item Item) {

        _, index := n.Items.Find(item)

        if len(n.Children) == 0 {

                n.Items.InsertAt(item, index)

                if len(n.Items) == MergeThreshold {
                        n.Split()
                }
                return
        }

        if len(n.Children[index].Items) == MergeThreshold - 1 {
                n.SplitChildren(item, index)

                if len(n.Items) == MergeThreshold {
                        n.Split()
                }
                return
        }

        n.Children[index].Insert(item)
}

func (n *Node) SplitChildren(item Item, index int) {

        _, i := n.Children[index].Items.Find(item)
        n.Children[index].Items.InsertAt(item, i)

        leftItem := n.Children[index].Items[MergeThreshold/2-1]
        middleItem := n.Children[index].Items[MergeThreshold/2]
        rightItem := n.Children[index].Items[MergeThreshold/2+1]

        n.Children = append(n.Children[:index], n.Children[index+1:]...)

        _, middleIndex := n.Items.Find(middleItem)
        n.Items.InsertAt(middleItem, middleIndex)

        leftNode := new(Node)
        rightNode := new(Node)

        leftNode.Insert(leftItem)
        rightNode.Insert(rightItem)

        n.Children = append(n.Children, leftNode)
        n.Children = append(n.Children, rightNode)

        sort.Slice(n.Children, func(i, j int) bool {
                return n.Children[i].Items[0].Less(n.Children[j].Items[0])
        })

}

func (n *Node) Split() {

        left := new(Node)
        middle := n.Items[MergeThreshold/2]
        right := new(Node)

        left.Items.InsertAt(n.Items[MergeThreshold/2-1], 0)
        right.Items.InsertAt(n.Items[MergeThreshold/2+1], 0)
        n.Items = append([]Item{}, middle)

        if len(n.Children) == MergeThreshold + 1 {

                var nodes []*Node

                left.Children = append(left.Children, []*Node{n.Children[0], n.Children[1]}...)
                right.Children = append(right.Children, []*Node{n.Children[2], n.Children[3]}...)

                nodes = append(nodes, left)
                nodes = append(nodes, right)
                n.Children = nodes
                return
        }

        n.Children = append(n.Children, left)
        n.Children = append(n.Children, right)
}

func (b *BTree) Find(item Item) (bool, int) {

        b.Mutex.RLock()
        defer b.Mutex.RUnlock()

        if b.Top == nil {
                return false, -1
        }

        return b.Top.Find(item)
}

func (n *Node) Find(item Item) (bool, int) {
        ok, index := n.Items.Find(item)
        if ok {
                return ok, index
        }

        if len(n.Children) == 0 {
                return false, -1
        }

        return n.Children[index].Find(item)
}

func (i *Items) Find(item Item) (bool, int) {
        for index,itm := range *i {

                if itm.IsSkip() {
                        continue
                }

                if itm.Equal(item) {
                        return true, index
                }

                if !itm.Less(item) {
                        return false, index
                }

        }
        return false, len(*i)
}

func (i *Items) InsertAt(item Item, index int) {
        *i = append(*i, nil)
        if index < len(*i) {
                copy((*i)[index+1:], (*i)[index:])
        }
        (*i)[index] = item
}

func (b *BTree) Get(item Item) Item {

        b.Mutex.RLock()
        defer b.Mutex.RUnlock()

        if b.Top == nil {
                return nil
        }

        return b.Top.Get(item)
}

func (n *Node) Get(item Item) Item {
        found, i := n.Items.Find(item)
        if found {
                return n.Items[i]
        } else if len(n.Children) > 0 {
                return n.Children[i].Get(item)
        }
        return nil
}

終わりに

次は、インデックスより苦戦した Buffer Pool について

*1:文字列は辞書順で比較

*2:ここでいうロックとは同時実行制御のロックではなくマルチスレッドの排他処理のロックをイメージして欲しい

*3:INSERT, UPDATE 時のような更新、追加処理のラッチはもっと面倒くさい

*4:テストが本当の正常系しか担保できていない

Clock Sweep と LRU Cache を実装する

この記事は「けんつの1人 DBMS アドベントカレンダー Advent Calendar 2019 - Adventar」の 14 日目の記事です。

はじめに

どうも、最近遊戯王netflix で配信されていてめちゃくちゃ幼少期を思い出しているけんつです。

今回は Buffer Pool に関連して、キャッシュ周りの実装を進めていく。

DBMSのキャッシュ

これは MySQLPostgreSQL でかなり違いがある。
少し前に記事でもまとめたが、MySQL は LRU で PostgreSQL はクロックスイープを使っている。

MySQL の LRU キャッシュ

MySQL では List 構造を2つ持っていて、LRU 全体サイズの 5/8 が New List、3/8 が Old List となっている。
最初に要素が LRU キャッシュに追加されるときは Old List に追加され、再度参照されることで New List に移動される。
これによって追加されて以降参照されていない要素を優先的に開放する仕組みが構築される。

PostgreSQL の Clock Sweep

こちらはクロックスイープで実装されている。
クロックスイープではそれぞれの要素が参照された総数を持っている。リストがいっぱいになった場合、この参照回数をそれぞれ1ずつ減算し、0 になったものを開放するという仕組みを取っている。

実装

とりあえず両方とも実装してみた。

LRU

LRU は要素に key, value のセットを持つようにしている。

type Lru struct {
        Size int
        NewSublist LruCache
        OldSublist LruCache
        Mutex sync.RWMutex
}

type Entry struct {
        Key interface{}
        Value interface{}
}

type LruCache []Entry

この構造体とデータ型を宣言している。
LRU に対する要素の追加と取得は次のように実装している。

func (l *Lru) Append(key interface{}, value interface{}) {

        if OldSublistSizeLimit < len(l.OldSublist) {
                l.OldSublist.RemoveOldest()
        }

        l.OldSublist.Append(key, value)
}

func (l *Lru) Get(key interface{}) interface{} {
        e := l.NewSublist.Get(key)
        if e != nil {
                return e.Value
        }

        e = l.OldSublist.Get(key)
        if e != nil {

                if NewSublistSizeLimit < len(l.NewSublist) {
                        l.NewSublist.RemoveOldest()
                }

                l.NewSublist.Append(key, e.Value)
                l.OldSublist.RemoveFirst()
                return e.Value
        }

        return nil
}

それぞれのリストに対する一番最後に参照された要素を削除するコードは雑に次のようになっている。

func (l *LruCache) MoveToFront(index int) {
        e := (*l)[index]
        *l = append((*l)[:index], (*l)[index+1:]...)
        *l = append(LruCache{e}, (*l)[0:]...)
}

// fix return value
func (l *LruCache) RemoveOldest() *Entry {
        e := l.Last()
        *l = (*l)[:len(*l)-1]
        return e
}

// fix return value
func (l *LruCache) RemoveFirst() *Entry {
        e := l.First()
        *l = (*l)[1:]

        return e
}

今回実装した LRU キャッシュはサブリストを2つ保持するようになっているからすこし Append 周りが複雑になってしまった。

Clock Sweep

クロックスイープは以下のような構造体として宣言されている。

type ClockSweep struct {
        Cap int
        Cache Clocks
}

type Clocks [BufferPoolLimitSize]*BufferDescriptor

ここで登場する BufferDescripor とは明日紹介する BufferPool において、ページの情報や Pin/Referrence Counter などを持っている構造体のこと。
今回実装する Clock Sweep では基本的に固定長の配列を構造体で保持していて、空きスロットに追加していく形となる。

スロットを開放する必要がある場合は、Usage を1ずつ減算していく。ただしこの時参照カウンタが1以上の場合は探索をスキップする必要がある。

おわりに

LRU, Clock Sweep の実装はこれにて終わり。
結構使いやすいデータ構造がなくて作るはめになってしまったけど、おそらく Clock Sweep の方が実装が簡単なので
それをベースに Buffer Pool のキャッシュを作れるとよさそう。

Page を実装する

この記事は「けんつの1人 DBMS アドベントカレンダー Advent Calendar 2019 - Adventar」 13 日目の記事です。

はじめに

どうも、なんかブログ記事を書きまくってたら右手親指の付け根が痛くてたまらないけんつです。
今日は Page の実装に関する話です。

BufferPool あたりの実装が完了した段階で思ったことなどなど書いていきます。

Page の実装

基本的に Page Header と Tuple の塊となっている。
先頭 4byte を Page Header として、残りの領域で Tuple を追加された順番で保持する単純な構造になっている。
全体の容量は約 16 KB で固定。mmap は使用しない。

バイナリへのシリアライズなどなどは、前回実装したタプルのシリアライズをそのまま使う。
というのも、Page Header が今のところ 4 byte しかなく、あとはタプルが列挙するだけどとわかっているからバイナリにシリアライズするのは比較的簡単にできるようになっている。

また、ページは単にテーブル毎のディレクトリに格納されているだけでヒープや単方向リストを使って参照を持っているわけではない。

削除を考慮しない

削除を考慮しないのであれば、ただタプルを列挙すればいいだけとなる。
しかし、これで削除を考えようとすると一気に考えることが多くなる。

というのも、テーブル単位でそれぞれページを複数もっているが、タプルが追加されるだけとわかっていれば既に存在しているページを探索して空きスロットを探すとかしなくても良いため。

もしも削除を考慮するとするなら、同時実行制御をどのように実装するかを考える必要がある。
というのも、仮にデータを追加・更新する際に、PostgreSQL のように追記型を採用しているなら単にページを追加していき、タプルを末尾に追加すればいいだけとなる。削除なら単に論理削除すればいいだけなのでそこまで難しくない。

しかし MySQL の様に、更新型を取る場合は削除を考慮すると空きスロットが発生する可能性がある。
これを単にタプルを並べただけの構造から見つけ出すのは明らかにコストとなる。

これは CMU の講義のなかでも触れられている。
それらの問題を解決するために Slotted Page や Log-Structured File Organization などがある。
今回はそういった実装を現段階ではしないこととしている。

番外

特にタプルとページは愚直に実装したので特に話すことがない。
だから少しだけ Golang でこまった配列、スライス周りの話をする。

これら固定長のページやタプルを実現するために、固定長の配列を基本的には使用している。
例えばページを扱う構造体はこうなっている。

// Max Size: 16KB
type Page struct {
        Header PageHeader // 4byte
        Tuples [PageBodySize]Tuple // 128 byte per Tuple
}

そう、ここから固定長にしている。
ただこれの問題は色々あって、この配列を扱う場合はその関数やメソッドの引数や戻り値の型を全て固定長にしないといけない。
そのため、スライスで扱ったあり配列として扱ったりとかなり処理が忙しないことになっている。

Golang, 排他制御とかめちゃくちゃ便利なのに配列、スライスまわりとリスト、循環リストなど
データ構造に関わる部分が柔軟でないから少し実装にこまるときがある。
これは Clock Sweep の実装と、 Buffer Pool の実装でも同様に頭を悩ませた。

おわりに

次は Clock Sweep だったかな、そろそろメモリ上でページやタプルを管理する話になる。