Write an embeddedtime-series database in Go@GoCon 2021 AutumnRyo Nakao (@nakabonne)
View Slide
自己紹介● 中尾 涼 (@nakabonne)● 株式会社サイバーエージェント● PipeCD フルタイム開発
Agenda● 時系列データの特徴● tstorageの設計● Goでの実装について
Write an embeddedtime-series database in GoRyo Nakao (@nakabonne)
Embedded database● ライブラリとして提供されている埋め込み可能なデータベース● 有名なものでLevelDBやRocksDBなどがある
モチベーション00
負荷テストツールのメモリパフォーマンス低下
負荷テストツールのメモリパフォーマンス低下● 計測結果をSliceにappendしていくだけだった● ディスクも活用したストレージエンジンが欲しい● Go言語からEmbedded databaseとして利用できるものが無い● → 作るしかない
tstorage
時系列データの特徴01
時系列データ● タイムスタンプ順に並んだ値(データポイントと呼ぶ)の集まり● 時間の経過とともにデータがどう変化するかを見るために使われる● e.g. システムメトリクス、株価データ
時系列データの特徴(一部)● データ量が膨大● 直近のデータを読み出す● 期間指定して一括で読み出す
データ量が膨大● 一定間隔おきに、永続的にデータが取り込まれる● データ量は線形的に増えていく
直近のデータを読み出す直近のデータの推移を観察するために使われることが多い
期間を指定して一括で読み出す時間の経過と共にどう変化したかを観察するために使われる
速く書いて速く読みたいこういった特徴がある中でなるべく多くのケースで
できるだけDRAMを使おう
データモデル02
● タイムスタンプでパーティショニング● 完全に独立した小さなDBを追加していく● 最近のデータはヒープ上● ほとんどのケースで読み出しが高速
● (再掲)読み出す時は範囲指定して一括で読み出す● タイムスタンプが近いデータが集まっているので、空間的局所性を高める
Memory PartitionとDisk Partitionに分けるとりあえず
実装 ~ Memory Partition03
Memory Partition● インメモリDB● データロスを防ぐためにWrite Ahead Logに書き込む
Logical structure ~ Memory Partition
Logical structure ~ Memory Partitionとにかくここへの書き込み &読み込みが速度パフォーマンスに直結する
How to represent data points● Sliceは基底配列に基づいている● 要素数がcapacityを超えると自動的に基底配列のメモリコピーが発生● 際限なくデータが取り込まれるケースでは理論的には要素の追加をO(1)で行えるLinked-listベースのデータ構造が良さそう
Benchmarks ~ Slice vs Linked-list
Benchmarks ~ Slice vs Linked-list何度実行しても時間計算量の面ではSliceのほうが僅かに勝利
Benchmarks ~ Slice vs Linked-list基底配列はRAM上の隣り合った場所に並んでいるため、キャッシュの空間的局所性が効く
https://groups.google.com/g/golang-nuts/c/mPKCoYNwsoU/m/OSVy4gbCWwMJ
速く書くのはいいけど...● メモリパーティションは揮発性ストレージ上で動作● 突然のクラッシュに対応したい
Write Ahead Log (WAL)● 書き込みに先行して操作ログをディスクへ書き込む● リカバリ可能な形式で、なるべく小さくエンコード
WAL formatop: オペレーションタイプ (Insert, Delete, etc)len metric: 後続するメトリック名文字列の長さmetric: メトリック名timestamp: 最大64ビットの整数値型UNIXタイムスタンプvalue: 最大64ビットの浮動小数点型の値
Varints (Variable integers)● Protocol Bufferが内部で使用している可変長エンコーディング手法● 数値の大きさに応じて消費サイズが変わる● Goでは binary.PutVarint が担当
例) 10を64ビット整数値型として固定長エンコードした場合→ 必ず8バイト(64ビット)消費してしまう
例) Varintsで可変長エンコードした場合→ 1バイト(8ビット)で収まる
適切な位置で読み出しを終了どのように終了位置を特定しているのか?
Varints の仕組み先頭ビットが0であればそのバイトで読み出しを停止する
Varints (Variable integers)数値型の値に関してはこのように Varints を用いて書き込んでいく
実装 ~ Disk Partition04
Disk Partition● オンディスクDB● 1つのファイルに書き込まれる
File format● 時系列データは不変で、範囲指定して一括で読み込むケースがほとんど● Metric 毎にデータポイントをまとめて圧縮することで局所性↑● mmapによって透過的にキャッシュ
Memory-mapped data● マップされたファイルは []byte としてアクセス可能● なんらかのインデックス構造がないと非効率
Metadata file● パーティション毎にjson形式のメタデータファイル● メトリック毎のファイル内バイトオフセットやサイズが保存されている● ヒープに乗るのはこのメタデータのみ
やりたいこと● 書き込み時のオフセットの保存● 読み出し時のオフセットの指定● これらを同じインタフェースで行いたい● Goではここでio.Seekerが活躍
io.Seeker● 次に読み書きする位置を指定する● 現在のオフセットを返してくれるので、取得も指定もこれ一つで可能● ランダムアクセスを許容するバイト列であれば基本何でもOK
When flushing● ディスクへ書き込む際に各メトリックのオフセットを保存する必要● io.Seekerを満たす *os.File を利用● メトリックの書き込みを開始するタイミングでSeek(0, io.SeekCurrent)を呼ぶ
When reading● []byte の指定したオフセットから読み出したい● io.ReadSeekerを満たす*bytes.Reader を利用
高速に読み出せるようになったけど...
(再掲)データ量が膨大● Varintsでもかなり圧縮されるが、最低でも1バイト必要● 時系列データの特徴に注目して1バイト未満に圧縮できないか
Encoding● タイムスタンプは単調増加する傾向にある● 差分の差分だけ書き込めば良いのでは?● → Delta-of-delta encoding
Delta-of-delta encoding1600000000 1600000015 1600000030
Delta-of-delta encoding1600000000 1600000015 1600000030±15 ±15
Delta-of-delta encoding1600000000 1600000015 1600000030±15 ±15±0
Delta-of-delta encoding1600000000 1600000015 1600000030±15 ±15±03番目以降はこの0 (1bit) だけを書き込んでいく
When reading1600000000 1600000015 1600000030±15 ±15±0シーケンシャルに読み出してこのdelta-of-deltaを加算していく
Bit単位でストリームに読み書きする● Goの標準ioパッケージの読み書き最小単位はbyte● bit単位で読み書きするためにはビット演算する必要
Bit単位でストリームに読み書きする0001000 100010001io.Writer.Write()byte完成!
Bit単位でストリームに読み書きする● bitはboolで表現
Bit単位でストリームに読み書きする
Bit単位でストリームに読み書きする● 0で敷き詰められた8bitsを用意
Bit単位でストリームに読み書きする● 1を書き込む場合は末尾の0と1のビット和を取る● そして必要な分だけ左シフトする
負荷テストツールのパフォーマンスが改善
● (ja) ゼロから作る時系列データベースエンジン :https://zenn.dev/nakabonne/articles/d300838a1500c7● (en) Write a time-series database engine from scratch:https://nakabonne.dev/posts/write-tsdb-from-scratch● Repo: https://github.com/nakabonne/tstorageMore...
ThanksAny questions?@nakabonne