Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Проблемы разработки базы данных числовых времен...

Проблемы разработки базы данных числовых временных рядов с нуля на Go – Александр Вишератин

GopherCon Russia

April 13, 2019
Tweet

More Decks by GopherCon Russia

Other Decks in Programming

Transcript

  1. Проблемы разработки базы данных числовых временных рядов с нуля на

    Go Александр Вишератин старший научный сотрудник Лаборатория «Когнитивные системы в промышленности» Университет ИТМО, Санкт-Петербург [email protected] @visheratin
  2. Зачем изобретать новую базу для временных рядов? Нетривиальный набор требований

    от заказчика: 1. Хранение терабайтов временных рядов за десятки лет. Пойдет любая современная зрелая база (PostgreSQL, Cassandra). 2. Поддержка операций поиска по условиям и извлечения данных. Всё еще любая нормальная база данных. 3. 1 секунда на выполнение как поиска по всем данным, так и извлечение 450 тысяч записей из любого интервала. Это сложнее, может подойти TimescaleDB или ClickHouse с материализованными представлениями. 4. Данные должны лежать в Amazon S3. … 2
  3. Peregreen – бинарное хранилище, построенное с нуля с учетом специфики

    работы с временными рядами. Ни таблиц, ни колонок, только сенсоры с временными метками и численными значениями. Как мы решили эту проблему? 3
  4. Проблемы, с которыми мы столкнулись 1. Выбор структур данных для

    индекса. Что лучше – слайсы или деревья? 2. Формат хранения данных. Как хранить данные в базе – массив байтов, gob, колонки (Parquet)? 3. Поддержка множества типов данных. Красивая имплементация через интерфейсы или композитный тип с громоздкими switch’ами по всей кодовой базе? 4
  5. Как организовать индекс? Назначение блоков: фильтрация по заданным условиям –

    извлечение из индекса всех блоков, которые удовлетворяют критериям клиента. type Block struct { Size int ElNum int Min float64 Max float64 } Решение: деревья. Даже простое бинарное дерево даст O(log2N) сложность поиска. 6
  6. Простое бинарное дерево type TreeIndex struct { ID string Root

    *TreeNode } type TreeNode struct { LeftPart *TreeNode RightPart *TreeNode Min float64 Max float64 Block data.Block } 7
  7. Поиск в дереве func (node TreeNode) search(min float64, max float64)

    []data.Block { if filter(node.Min, node.Max, min, max) { if node.Block.Size != 0 { return []data.Block{node.Block} } lp := node.LeftPart.search(min, max) rp := node.RightPart.search(min, max) return append(lp, rp...) } return nil } Создание новых объектов с указателями Выделение памяти Сборка мусора 8
  8. Бенчмарк простого дерева (поиск) Постоянное создание объектов и выделение памяти

    снижают производительность. Решение: упразднить выделение памяти. 9 Время выполнения (мкс) Количество аллокаций 1000 элементов 35 391 10000 элементов 673 4025 100000 элементов 7370 39922 1000000 элементов 151296 400223
  9. Бинарное дерево посложнее При добавлении данных увеличиваем счетчик количества элементов

    type AdvTreeIndex struct { ID string Length int Root *AdvTreeNode } type AdvTreeNode struct { LeftPart *AdvTreeNode RightPart *AdvTreeNode Min float64 Max float64 Block data.Block } 10
  10. Поиск в новом дереве func (node AdvTreeNode) search(min float64, max

    float64, res []data.Block) []data.Block { if filter(node.Min, node.Max, min, max) { if node.Block.Size != 0 { res = append(res, node.Block) return res } res = node.LeftPart.search(min, max, res) res = node.RightPart.search(min, max, res) } return res } Можно инициализировать слайс заранее, используя длину индекса в качестве ёмкости. Новые участки памяти не выделяются, всё дописывается в уже существующую 11
  11. Бенчмарк нового дерева (время, мкс) 12 35 9 4 0

    5 10 15 20 25 30 35 40 1000 элементов Tree Advanced tree (empty) Advanced tree (init) 151296 42433 25800 0 20000 40000 60000 80000 100000 120000 140000 160000 1000000 элементов Tree Advanced tree (empty) Advanced tree (init) init – слайс создавался заранее, empty – слайс создавался в бенчмарке. Скорость выросла в 4 раза, поиск занимает почти половину общего времени выполнения запроса.
  12. Бенчмарк нового дерева (аллокации) 13 391 1 0 0 50

    100 150 200 250 300 350 400 450 1000 элементов Tree Advanced tree (empty) Advanced tree (init) 400323 1 0 0 50000 100000 150000 200000 250000 300000 350000 400000 450000 1000000 элементов Tree Advanced tree (empty) Advanced tree (init) Такое дерево получается очень глубоким. Как можно улучшить положение дел? Хранить несколько элементов вместо одного.
  13. (Что-то похожее на)B-дерево type BTreeNode struct { LeftPart *BTreeNode RightPart

    *BTreeNode Min float64 Max float64 Blocks []data.Block } Слайс блоков вместо одного В отличие от других реализаций, данные в не-листовых нодах хранятся 14
  14. Поиск в псевдо-B-дереве func (node BTreeNode) search(min float64, max float64,

    res []data.Block) []data.Block { if filter(node.Min, node.Max, min, max) { for _, b := range node.Blocks { if filter(b.Min, b.Max, min, max) { res = append(res, b) } } res = node.LeftPart.search(min, max, res) res = node.RightPart.search(min, max, res) } return res } Фильтруем блоки, потому что не все могут подходить под условия 15
  15. Бенчмарк псевдо-B-дерева (время, мкс) 16 9,3 6,2 3,7 0,8 0

    1 2 3 4 5 6 7 8 9 10 1000 элементов Advanced tree (empty) B-tree (empty) Advanced tree (init) B-tree (init) 42433 10480 25800 5406 0 5000 10000 15000 20000 25000 30000 35000 40000 45000 1000000 элементов Advanced tree (empty) B-tree (empty) Advanced tree (init) B-tree (init) Ускорение поиска еще в 4 раза, поиск теперь занимает половину времени. Меньше глубина дерева, меньше рекурсия, меньше вызовов функций.
  16. Индекс на слайсах type SliceIndex struct { ID string Blocks

    []data.Block } func (idx *SliceIndex) Search(min float64, max float64, res []data.Block) []data.Block { for _, b := range idx.Blocks { if filter(b.Min, b.Max, min, max) { res = append(res, b) } } return res } Поиск Очень простая структура Поиск также очень простой 17
  17. Бенчмарк слайса (время, мкс) 18 6,2 8,7 0,8 2,1 0

    2 4 6 8 10 1000 элементов B-tree (empty) Slice (empty) B-tree (init) Slice (init) 10480 9840 5406 6774 0 2000 4000 6000 8000 10000 12000 1000000 элементов B-tree (empty) Slice (empty) B-tree (init) Slice (init) Внезапно он такой же быстрый, как самое лучшее дерево. Почему? Нет дерева, нет рекурсии, нет вызовов функций.
  18. Вывод № 1 При проектировании решений стоит начинать с простых

    вариантов и усложнять по мере необходимости. Если простого решения достаточно для удовлетворения ваших требований – остановитесь. 19
  19. Преимущества схемы хранения 21 1. Возможность разделения данных на блоки

    произвольной длины. 2. Возможность хранить статистические метрики данных в индексе. 3. Работать с одним большим файлом проще, чем с тысячами маленьких.
  20. Внутреннее представление данных Нужен способ сериализации данных, который позволит быстро

    преобразовывать данные во внутреннее представление и обратно. Варианты: 1. gob – доступен из коробки, прост, довольно быстр. 2. Parquet – популярный формат с доказанной эффективностью, доступно сжатие данных. 3. свой формат – возможно будет быстрее, много подводных камней, долго разрабатывать. 22
  21. Интерфейс хранилища данных type Store interface { Insert(dataParts [][]data.Element) ([]data.Block,

    error) Read(blockIds []int, blockSizes []int, blockNums []int, offset int64) ([]data.Element, error) } 23 Загрузка частей данных в хранилище Извлечение данных по метаинформации
  22. GobStore Insert buf := bytes.NewBuffer(nil) err := gob.NewEncoder(buf).Encode(d) b :=

    buf.Bytes() 24 Read buf := bytes.NewBuffer(rawData) err = gob.NewDecoder(buf).Decode(&allData) res = append(res, allData...) 1. Простая и понятная реализация. 2. Минимальное количество усилий. Но как оно работает внутри?
  23. Профилирование в Go Одна волшебная строка в функции main: defer

    profile.Start().Stop() Позволяет получить исчерпывающую информацию о процессе выполнения программы. Сам пакет – https://github.com/pkg/profile 25
  24. ParquetStore Insert fw, err := ParquetFile.NewBufferFile(nil) pw, err := ParquetWriter.NewParquetWriter(fw,

    new(data.Element), 4) for _, el := range d { err = pw.Write(el); err != nil } b := fw.(ParquetFile.BufferFile).Bytes() 28 Read fw, err := ParquetFile.NewBufferFile(rawData) pr, err := ParquetReader.NewParquetReader(fw, new(data.Element), 4) allData := make([]data.Element, blockNums[i]) err = pr.Read(&allData) res = append(res, allData...) Пакет для работы с Parquet – https://github.com/xitongsys/parquet-go/
  25. 30 Профиль работы ParquetStore Большая часть времени – маршаллинг объектов.

    Много времени тратится на обработку данных через пакет reflect. Причина – пакет работает с интерфейсами.
  26. ProtoStore. Схема данных message ProtoElement { required int64 Timestamp =

    1 [(gogoproto.nullable) = false]; required double Value = 2 [(gogoproto.nullable) = false]; } message ProtoElements { repeated ProtoElement Data = 1 [(gogoproto.nullable) = false]; } 31 Пакет для работы с Protobuf – https://github.com/gogo/protobuf Делаем так, чтобы поля не содержали указателей
  27. ProtoStore. Операции Insert blockBuf, err = d.Marshal() buf = append(buf,

    bd...) 32 Read var d ProtoElements err = d.Unmarshal(rawData) res.Data = append(res.Data, d.Data...)
  28. BinaryStore Ручная сериализация данных – преобразовываем значения в байты с

    помощью пакета binary. Для сжатия данных – delta encoding (https://en.wikipedia.org/wiki/Delta_encoding). Заранее рассчитываем объем преобразованных данных и создаем итоговый массив. 35
  29. BinaryStore. Insert 36 bl := 12 * len(d) buf :=

    make([]byte, bl) l := len(d) for i := 0; i < l; i++ { t = d[i].Timestamp ts = uint32(t - tsp) binary.LittleEndian.PutUint32(buf[c:c+4], ts) f64 = math.Float64bits(d[i].Value) f64d = f64 - f64p binary.LittleEndian.PutUint64(buf[c:c+8], uint64(f64d)) } 4 байта uint32 для времени 8 байт uint64 для значений Инициализируем весь массив сразу delta encoding времени delta encoding значений
  30. BinaryStore. Read 37 res = make([]data.Element, elNum) for i <

    len(bd) { tb = bd[i : i+4] tsV = binary.LittleEndian.Uint32(tb) ts += int64(tsV) vb = bd[i : i+8] f64 += binary.LittleEndian.Uint64(vb) f64e = data.Element{ Timestamp: ts, Value: math.Float64frombits(f64), } res[ec] = f64e } Инициализируем весь массив сразу delta decoding времени delta decoding значений
  31. Профиль работы BinaryStore 38 Профиль намного меньше, благодаря фактически ручному

    управлению процессом. Всегда точно известны типы данных, нет нужды в преобразованиях. Количество блоков GobStore 74 ParquetStore 90 ProtoStore 46 BinaryStore 40
  32. Бенчмарк хранилищ. Insert (время, сек) 39 11,9 22,6 6,1 2,1

    0 5 10 15 20 25 6000000 элементов GobStore ParquetStore ProtoStore BinaryStore
  33. Бенчмарк хранилищ. Extract (время, мс) 40 61,9 243,2 74,1 5,1

    0 50 100 150 200 250 300 400000 элементов GobStore ParquetStore ProtoStore BinaryStore
  34. Вывод № 2 Иногда стандартных и проверенных решений может быть

    недостаточно. Когда стоит действительно сложная задача, переход на более низкие уровни абстракции является единственным решением. 41
  35. Поддержка множества типов данных 42 Необходимо реализовать поддержку всех базовых

    численных типов – int8, int16, int32, int64, float32, float64. Классический способ – использовать интерфейсы. type Element interface { Timestamp() int64 Value() float64 } type Float32Element struct { Ts int64 Val float32 } type Int32Element struct { Ts int64 Val int32 } type Float64Element struct { Ts int64 Val float64 } для простоты только три типа
  36. Реализация хранилища на интерфейсах 43 switch dtype { case part3.Int32:

    f32 = uint32(d[i].(part3.Int32Element).Val) f32d = f32 - f32p f32p = f32 binary.LittleEndian.PutUint32(buf[c:c+4], f32d) c += 4 case part3.Float32: … case part3.Float64: … } (–) много практически одинакового кода; (–) необходимо писать switch’и для обработки типов; (+) в любой другой части кодовой базы можно пользоваться интерфейсными методами.
  37. Бенчмарк интерфейсной реализации Insert 44 219 154 167 246 0

    50 100 150 200 250 300 600000 элементов Binary Interface (int32) Interface (float32) Interface (float64) 2081 2080 2080 2080 0 500 1000 1500 2000 2500 600000 элементов Binary Interface (int32) Interface (float32) Interface (float64) Время выполнения (мс) Количество аллокаций
  38. Бенчмарк интерфейсной реализации Extract 45 5 234 237 207 -40

    10 60 110 160 210 260 400000 элементов Binary Interface (int32) Interface (float32) Interface (float64) 176 408603 408603 408603 0 50000 100000 150000 200000 250000 300000 350000 400000 450000 400000 элементов Binary Interface (int32) Interface (float32) Interface (float64) Время выполнения (мс) Количество аллокаций
  39. Профилирование извлечения данных 46 Что такое convT2Inoptr? Согласно официальной документации:

    «Type to non-empty-interface conversion». Из исходного кода* понятно, что при каждой конвертации происходит выделение памяти. * https://github.com/golang/go/blob/master/src/runtime/iface.go Что делать? Придумать, как не выделять память.
  40. Хранилище на комбинированной структуре 47 (–) посчитать длину – нужен

    switch; (–) извлечь значение – нужен switch; (–) добавить элементы – нужен switch; … (–) во всей кодовой базе придется использовать switch’и; (+) должен быть быстрым. type Elements struct { Type part3.DataType I32 []part3.Int32Element F32 []part3.Float32Element F64 []part3.Float64Element }
  41. Бенчмарк комбинированной структуры Insert 48 219 145 164 236 0

    50 100 150 200 250 600000 элементов Binary Interface (int32) Interface (float32) Interface (float64) 2081 2080 2080 2080 0 500 1000 1500 2000 2500 600000 элементов Binary Interface (int32) Interface (float32) Interface (float64) Время выполнения (мс) Количество аллокаций
  42. Бенчмарк комбинированной структуры Extract 49 5 5 6 7 0

    1 2 3 4 5 6 7 8 400000 элементов Binary Combined (int32) Combined (float32) Combined (float64) 176 176 176 176 0 50 100 150 200 400000 элементов Binary Interface (int32) Interface (float32) Interface (float64) Время выполнения (мс) Количество аллокаций
  43. Вывод № 3 Использование интерфейсов в Go не бесплатное, но

    не в том направлении, которое вы бы предположили (из интерфейса в тип, а не наоборот). Если нужно сделать быстро и с минимальными накладными расходами, иногда придется пожертвовать красотой и простотой кода. 50
  44. Что мы получили в итоге? 1. Поиск по сложным условиям

    в миллиардах записей в пределах 100-200 миллисекунд (до 10 раз быстрее конкурентов). 2. Извлечение 15 миллионов записей в секунду на одной машине. 3. Загрузка 2 миллионов записей в секунду на одной машине. 4. Сжатие данных в 6.5 раз (Delta + Zstandard). 5. Семплирование и агрегация. 6. Легковесный индекс (1 миллиард записей – 1 мегабайт). 7. Расширяемость новыми форматами входных/выходных данных и хранилищ (файловая система, Amazon S3, HDFS). 51
  45. Итоги 1. Необходимо, насколько это возможно, выделять память заранее. 2.

    Начинать стоит с простых вариантов. Гиперпроектирование – плохо. 3. Самый быстрый (но не в плане времени разработки) способ – делать все руками. 4. В погоне за скоростью иногда приходится жертвовать удобством кода. 52
  46. Спасибо за внимание! Презентация и исходный код: https://github.com/visheratin/tsdb-challenges Вопросы/уточнения/замечания: @visheratin

    53 Благодарности: - Юрий Кузнецов - Александр Логинов - Денис Насонов - Александр Бухановский