Slide 1

Slide 1 text

Write an embedded time-series database in Go @GoCon 2021 Autumn Ryo Nakao (@nakabonne)

Slide 2

Slide 2 text

自己紹介 ● 中尾 涼 (@nakabonne) ● 株式会社サイバーエージェント ● PipeCD フルタイム開発

Slide 3

Slide 3 text

Agenda ● 時系列データの特徴 ● tstorageの設計 ● Goでの実装について

Slide 4

Slide 4 text

Write an embedded time-series database in Go Ryo Nakao (@nakabonne)

Slide 5

Slide 5 text

Write an embedded time-series database in Go Ryo Nakao (@nakabonne)

Slide 6

Slide 6 text

Embedded database ● ライブラリとして提供されている埋め込み可能なデータベース ● 有名なものでLevelDBやRocksDBなどがある

Slide 7

Slide 7 text

モチベーション 00

Slide 8

Slide 8 text

負荷テストツールのメモリパフォーマンス低下

Slide 9

Slide 9 text

負荷テストツールのメモリパフォーマンス低下 ● 計測結果をSliceにappendしていくだけだった ● ディスクも活用したストレージエンジンが欲しい ● Go言語からEmbedded databaseとして利用できるものが無い ● → 作るしかない

Slide 10

Slide 10 text

tstorage

Slide 11

Slide 11 text

時系列データの特徴 01

Slide 12

Slide 12 text

時系列データ ● タイムスタンプ順に並んだ値(データ ポイントと呼ぶ)の集まり ● 時間の経過とともにデータがどう変化 するかを見るために使われる ● e.g. システムメトリクス、株価データ

Slide 13

Slide 13 text

時系列データの特徴(一部) ● データ量が膨大 ● 直近のデータを読み出す ● 期間指定して一括で読み出す

Slide 14

Slide 14 text

データ量が膨大 ● 一定間隔おきに、永続的にデータが取り込まれる ● データ量は線形的に増えていく

Slide 15

Slide 15 text

直近のデータを読み出す 直近のデータの推移を観察するために使われることが多い

Slide 16

Slide 16 text

期間を指定して一括で読み出す 時間の経過と共にどう変化したかを観察するために使われる

Slide 17

Slide 17 text

速く書いて 速く読みたい こういった特徴がある中で なるべく多くのケースで

Slide 18

Slide 18 text

できるだけDRAMを使おう

Slide 19

Slide 19 text

データモデル 02

Slide 20

Slide 20 text

● タイムスタンプでパーティ ショニング ● 完全に独立した小さなDBを 追加していく ● 最近のデータはヒープ上 ● ほとんどのケースで読み出 しが高速

Slide 21

Slide 21 text

● (再掲)読み出す時は範囲 指定して一括で読み出す ● タイムスタンプが近いデー タが集まっているので、空 間的局所性を高める

Slide 22

Slide 22 text

Memory Partitionと Disk Partition に分ける とりあえず

Slide 23

Slide 23 text

実装 ~ Memory Partition 03

Slide 24

Slide 24 text

Memory Partition ● インメモリDB ● データロスを防ぐために Write Ahead Logに書き 込む

Slide 25

Slide 25 text

Logical structure ~ Memory Partition

Slide 26

Slide 26 text

Logical structure ~ Memory Partition とにかくここへの書き込み &読み込みが 速度パフォーマンスに直結する

Slide 27

Slide 27 text

How to represent data points ● Sliceは基底配列に基づいている ● 要素数がcapacityを超えると自動的に基底配列のメモリコピーが 発生 ● 際限なくデータが取り込まれるケースでは理論的には要素の追加 をO(1)で行えるLinked-listベースのデータ構造が良さそう

Slide 28

Slide 28 text

Benchmarks ~ Slice vs Linked-list

Slide 29

Slide 29 text

Benchmarks ~ Slice vs Linked-list 何度実行しても時間計算量の面ではSliceのほうが僅かに勝利

Slide 30

Slide 30 text

Benchmarks ~ Slice vs Linked-list 基底配列はRAM上の隣り合った場所に並んでいるため、 キャッシュの空間的局所性が効く

Slide 31

Slide 31 text

https://groups.google.com/g/golang-nuts/c/mPKCoYNwsoU/m/OSVy4gbCWwMJ

Slide 32

Slide 32 text

速く書くのはいいけど... ● メモリパーティションは揮発性ストレージ上で動作 ● 突然のクラッシュに対応したい

Slide 33

Slide 33 text

Write Ahead Log (WAL) ● 書き込みに先行して操作ログをディスクへ書き込む ● リカバリ可能な形式で、なるべく小さくエンコード

Slide 34

Slide 34 text

WAL format op: オペレーションタイプ (Insert, Delete, etc) len metric: 後続するメトリック名文字列の長さ metric: メトリック名 timestamp: 最大64ビットの整数値型UNIXタイムスタンプ value: 最大64ビットの浮動小数点型の値

Slide 35

Slide 35 text

WAL format op: オペレーションタイプ (Insert, Delete, etc) len metric: 後続するメトリック名文字列の長さ metric: メトリック名 timestamp: 最大64ビットの整数値型UNIXタイムスタンプ value: 最大64ビットの浮動小数点型の値

Slide 36

Slide 36 text

Varints (Variable integers) ● Protocol Bufferが内部で使用している可変長エンコーディン グ手法 ● 数値の大きさに応じて消費サイズが変わる ● Goでは binary.PutVarint が担当

Slide 37

Slide 37 text

例) 10を64ビット整数値型として固定長エンコードした場合 → 必ず8バイト(64ビット)消費してしまう

Slide 38

Slide 38 text

例) Varintsで可変長エンコードした場合 → 1バイト(8ビット)で収まる

Slide 39

Slide 39 text

適切な位置で読み出しを終了 どのように終了位置を特定しているのか?

Slide 40

Slide 40 text

Varints の仕組み 先頭ビットが0であればそのバイトで読み出しを停止する

Slide 41

Slide 41 text

Varints (Variable integers) 数値型の値に関してはこのように Varints を用いて書き込んでいく

Slide 42

Slide 42 text

実装 ~ Disk Partition 04

Slide 43

Slide 43 text

Disk Partition ● オンディスクDB ● 1つのファイルに書き込ま れる

Slide 44

Slide 44 text

File format ● 時系列データは不変で、範囲指定 して一括で読み込むケースがほと んど ● Metric 毎にデータポイントをまと めて圧縮することで局所性↑ ● mmapによって透過的にキャッ シュ

Slide 45

Slide 45 text

Memory-mapped data ● マップされたファイルは []byte としてアクセス可能 ● なんらかのインデックス構造がないと非効率

Slide 46

Slide 46 text

Metadata file ● パーティション毎にjson形式の メタデータファイル ● メトリック毎のファイル内バイ トオフセットやサイズが保存さ れている ● ヒープに乗るのはこのメタデー タのみ

Slide 47

Slide 47 text

やりたいこと ● 書き込み時のオフセットの 保存 ● 読み出し時のオフセットの 指定 ● これらを同じインタフェー スで行いたい ● Goではここでio.Seekerが活 躍

Slide 48

Slide 48 text

io.Seeker ● 次に読み書きする位置を指定する ● 現在のオフセットを返してくれるの で、取得も指定もこれ一つで可能 ● ランダムアクセスを許容するバイト 列であれば基本何でもOK

Slide 49

Slide 49 text

When flushing ● ディスクへ書き込む際に各メトリッ クのオフセットを保存する必要 ● io.Seekerを満たす *os.File を利用 ● メトリックの書き込みを開始するタ イミングでSeek(0, io.SeekCurrent) を呼ぶ

Slide 50

Slide 50 text

When reading ● []byte の指定したオフセットから読 み出したい ● io.ReadSeekerを満たす *bytes.Reader を利用

Slide 51

Slide 51 text

高速に読み出せるようになっ たけど...

Slide 52

Slide 52 text

(再掲)データ量が膨大 ● Varintsでもかなり圧縮されるが、最低でも1バイト必要 ● 時系列データの特徴に注目して1バイト未満に圧縮できないか

Slide 53

Slide 53 text

Encoding ● タイムスタンプは単調増加する傾向にある ● 差分の差分だけ書き込めば良いのでは? ● → Delta-of-delta encoding

Slide 54

Slide 54 text

Delta-of-delta encoding 1600000000 1600000015 1600000030

Slide 55

Slide 55 text

Delta-of-delta encoding 1600000000 1600000015 1600000030 ±15 ±15

Slide 56

Slide 56 text

Delta-of-delta encoding 1600000000 1600000015 1600000030 ±15 ±15 ±0

Slide 57

Slide 57 text

Delta-of-delta encoding 1600000000 1600000015 1600000030 ±15 ±15 ±0 3番目以降はこの0 (1bit) だけを書き込んでいく

Slide 58

Slide 58 text

When reading 1600000000 1600000015 1600000030 ±15 ±15 ±0 シーケンシャルに読み出してこのdelta-of-deltaを加算していく

Slide 59

Slide 59 text

Bit単位でストリームに読み書きする ● Goの標準ioパッケージの読み書き最小単位はbyte ● bit単位で読み書きするためにはビット演算する必要

Slide 60

Slide 60 text

Bit単位でストリームに読み書きする 0001000 1 00010001 io.Writer.Write() byte完成!

Slide 61

Slide 61 text

Bit単位でストリームに読み書きする ● bitはboolで表現

Slide 62

Slide 62 text

Bit単位でストリームに読み書きする

Slide 63

Slide 63 text

Bit単位でストリームに読み書きする ● 0で敷き詰められた8bitsを 用意

Slide 64

Slide 64 text

Bit単位でストリームに読み書きする ● 1を書き込む場合は末尾の0 と1のビット和を取る ● そして必要な分だけ左シフ トする

Slide 65

Slide 65 text

負荷テストツールのパフォーマンスが改善

Slide 66

Slide 66 text

● (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/tstorage More...

Slide 67

Slide 67 text

Thanks Any questions? @nakabonne