В этой статье описывается поиск ясности, к которому я пришел в последние дни. Я не думаю, что результат или извлеченные уроки в какой-то мере новы или действительно очень удивительны, но, поскольку мне потребовалось несколько дней размышлений и экспериментов, чтобы разобраться в вещах, я надеюсь, что эта статья может помочь другим. или хотя бы развлечь их.

Введение: вариант использования

Все началось с того, что кто-то пожаловался мне, что вставка с перезаписью (также известная как UPSERT) в ArangoDB выполняется намного медленнее, чем чистая вставка. Сначала я подумал: Это не может быть, мне нужно разобраться и исправить эту проблему!. Ведь в обоих случаях нужно записать примерно одинаковое количество данных.

Более конкретно, эксперимент заключался в том, чтобы вставить пакеты из нескольких сотен тысяч новых документов JSON в коллекцию в кластере ArangoDB, и первоначально пакет занимал примерно 30 секунд. Это выглядело хорошо, но в реальном варианте использования некоторые документы в каждом пакете будут новыми документами (новыми первичными ключами), а некоторые - документами, заменяющими старые (тот же первичный ключ, что и предыдущий документ), которые следует просто перезаписать старую версию документа в базе данных.

Исходный API ArangoDB имеет для них два разных вызова, первый - это HTTP POST или «вставить» для новых документов, который возвращает ошибку, если какой-либо первичный ключ отправленного документа уже существует в коллекции. Другой - HTTP PUT или «замена» для старых документов, который возвращает ошибку, если в коллекции нет документа с данным первичным ключом.

Однако в данном случае мы не знаем априори, какие документы в пакете новые, а какие старые, поэтому нам действительно нужен третий вид операции, которую я бы назвал вставка с перезаписью или UPSERT. »Или REPSERT . Новый ArangoDB 3.4 фактически добавил эту операцию в API в виде опции overwrite=true для операции вставить.

К сожалению, человек, сообщивший мне о проблеме, сказал, что в случае «вставки с перезаписью» типичная партия занимала 3 минуты вместо 30 секунд, что в 6 раз больше!

Поэтому я провел быстрый эксперимент и обнаружил: Ничего! «Вставка с перезаписью» работает примерно так же быстро, как чистая «вставка». Поэтому я вернулся и попросил человека, который жаловался, предоставить более подробную информацию, и в результате я обнаружил, что это был случай, когда набор данных в базе данных значительно больше, чем доступная оперативная память машины. Это был первый намек на то, что за кулисами творится что-то более зловещее.

Я поставил эксперимент с таким же вкусом, на этот раз с одним экземпляром сервера ArangoDB и большим количеством данных на машине с относительно небольшой оперативной памятью. И действительно, я быстро подтвердил существенное замедление.

Что происходило? Как мы могли решить эту проблему? В то время я все еще был очень оптимистичен - и, конечно, наивен.

Что здесь происходит? Думаю!

Сначала я посмотрел на код «вставки» и сравнил его с «вставкой с перезаписью». И действительно, в этом нет ничего особенного: операция вставки для нового документа сначала ищет первичный ключ, и если он не найден, документ записывается. Обратите внимание, однако, что «не найти ключ» в RocksDB (который является нашим механизмом хранения по умолчанию) довольно дешево. Это связано с тем, что для быстрого исключения наличия заданного ключа используется умная технология фильтра Блума. Это работает даже в том случае, если набор данных намного больше, чем доступная оперативная память, поскольку фильтры Блума для всех файлов данных обычно могут храниться в ОЗУ. А поскольку RocksDB в основном оптимизирован для записи, обычная операция «вставки» выполняется довольно быстро.

Однако в случае «вставки с перезаписью» все обстоит иначе: код также сначала ищет первичный ключ. В этом случае, однако, в ситуации фактической перезаписи существующего документа ключ будет найден. И если набор данных намного больше, чем доступная ОЗУ, случайный документ обычно не будет резидентным в ОЗУ, так что даже поиск ключа будет намного дороже, потому что что-то нужно соскрести с диска, что может быть очень медленным. по сравнению с поиском в памяти. Мы обсудим необходимость этого поиска ниже более подробно.

Поэтому после некоторого изучения кода и общих размышлений я пришел к выводу, что чтение на самом деле замедляет запись в этом случае!

Поэтому я решил экспериментально подтвердить эту теорию, а также выяснить серьезность проблемы.

Что здесь происходит? Измерение!

Чтобы количественно оценить эффект и провести эксперимент для проверки решений (которые я все еще надеялся найти на этом этапе!), Я создал следующую ситуацию.

Я использовал относительно небольшой экземпляр на Google Cloud Engine с 4 ГБ ОЗУ, 4 виртуальными ЦП и подключил сетевой том на 500 ГБ. 500 ГБ достаточно для хранения данных, которые значительно больше, чем доступная оперативная память, но в то же время достаточно малы, так что объем не позволяет выполнять много операций ввода-вывода в секунду (IOPS). Как и большинство поставщиков облачных услуг, Google ограничивает количество операций ввода-вывода до значения, пропорционального размеру тома. Для тома 500 ГБ в настоящее время скорость чтения 60 МБ и записи 60 МБ в секунду, но только 375 операций чтения в секунду и 750 операций записи в секунду.

В этой системе я установил один экземпляр ArangoDB 3.4 и вставил 10 миллионов документов размером примерно 1200 байт каждый в пакетах по 10000. Мне потребовались некоторые эксперименты, поскольку RocksDB по умолчанию использует Snappy для сжатия данных, и сначала я использовал одну и ту же строку в каждом документе для увеличить размер до 1200 байт, которые были быстро сжаты с помощью RocksDB.

В конце концов, я создал «случайную» строку, выбрав два случайных числа r и d и объединив цепочки r, r + d, r + 2d, r + 3d,…, r + 99d в одну большую строку. Это было достаточно «хаотично», чтобы избежать сжатия.

Точнее, я использовал версию сообщества ArangoDB 3.4 в двоичном дистрибутиве .tar.gz и использовал следующую команду запуска:

arangodb --starter.mode=single --all.cache.size 256000000 --all.query.memory-limit 100000000 --all.rocksdb.block-cache-size 256000000 --all.rocksdb.enforce-block-cache-size-limit true --all.rocksdb.total-write-buffer-size 100000000

Дополнительные параметры используются для ограничения использования ОЗУ экземпляра ArangoDB.

Вот результат времени вставки пакетов из 10000 таких документов, что означает, что размер каждого пакета составляет примерно 12 МБ:

+-----------+----------+
|    What   |   Time   |
+-----------+----------+
| Minimum   | 415 ms   |
| Median    | 571 ms   |
| Average   | 659 ms   |
| 90%ile    | 948 ms   |
| 99%ile    | 1538 ms  |
| Maximum   | 1857 ms  |
+-----------+----------+

Это примерно 20 МБ данных, записываемых в секунду в Median, что неплохо, особенно учитывая усиление записи, которое имеет RocksDB (поскольку это реализация дерева слияния с лог-структурой).

Вот время выполнения «вставки с перезаписью» для пакетов из 10000 документов, все первичные ключи которых уже находятся в базе данных. Это были времена, когда нужно было заменить 10 000 документов:

+-----------+------------+
|    What   |    Time    |
+-----------+------------+
| Minimum   | 1358 ms    |
| Median    | 7807 ms    |
| Average   | 9783 ms    |
| 90%ile    | 21212 ms   |
| 99%ile    | 44423 ms   |
| Maximum   | 44423 ms   |
+-----------+------------+

Должен признать, что я заменил не все 1000 пакетов из 10000 документов, а только 100, так что это меньшая статистика. Кроме того, я удостоверился, что пакеты замены фактически приводят к случайному доступу, по существу помещая 10000 случайных документов в каждый пакет. То есть операция «вставка с перезаписью» должна была найти документы в случайном порядке, и для большинства из них пришлось обращаться к диску!

Это дает количественную оценку первоначально наблюдаемого эффекта. В среднем на операцию перезаписи требуется примерно в 15 раз больше времени, чем на операцию вставки. Кроме того, производительность намного более изменчива, поскольку она зависит от того, что находится в кэшах RAM и сколько фактических операций ввода-вывода необходимо выполнить для каждого документа.

Затем я пропатчил ArangoDB и сделал так, чтобы в операции «вставить и перезаписать» не выполнялось никакого чтения, а выполнялись только операции записи для нового документа. Обратите внимание, что это невозможно улучшить, потому что это повреждает внутренние структуры данных, количество документов больше не соответствует норме, а последующие запросы возвращают как старую, так и новую ревизию, что совершенно неверно.

Однако время «вставки с перезаписью» было существенно уменьшено до прежнего времени «вставки», может быть, даже немного быстрее, поскольку я полностью исключил поиск по первичному индексу. Это по сути подтвердило мою теорию.

Код для этой части эксперимента можно найти здесь. Это код JavaScript для arangosh.

Что здесь происходит? Конкуренты!

Затем мне было интересно, были ли у наших конкурентов такая же проблема. Поэтому я посмотрел на PostgreSQL и поставил, по сути, такой же эксперимент с одной таблицей. См. Здесь код, который я использовал. В данном случае я использовал драйвер golang для PostgreSQL и постарался повторить эксперимент. Я должен признать, что я не эксперт по PostgreSQL, поэтому не стесняйтесь указывать мне на возможные улучшения. Я просто использовал операторы SQL INSERT для вставки и операторы SQL INSERT с ON CONFLICT (key) DO UPDATE … для каждого пакета.

Я использовал PostgreSQL 9.6, который идет в комплекте с дистрибутивом Debian Linux, и изменил только следующие параметры конфигурации по умолчанию:

data_directory = '/data/postgresdata'    # to use the right volume
shared_buffers = 256MB
temp_buffers = 64MB
work_mem = 64MB

И действительно, поведение производительности было очень похожим. Вот номера «вставных» партий:

+-----------+----------+
|    What   |   Time   |
+-----------+----------+
| Minimum   | 258 ms   |
| Median    | 310 ms   |
| Average   | 389 ms   |
| 90%ile    | 468 ms   |
| 99%ile    | 1841 ms  |
| Maximum   | 2300 ms  |
+-----------+----------+

А вот время для операции «вставка с перезаписью», на этот раз я сделал только 50 пакетов по 10000 строк:

+-----------+------------+
|    What   |    Time    |
+-----------+------------+
| Minimum   | 96178 ms   |
| Median    | 108445 ms  |
| Average   | 109383 ms  |
| 90%ile    | 116039 ms  |
| 99%ile    | 125041 ms  |
| Maximum   | 125041 ms  |
+-----------+------------+

Это означает, что в среднем пакет «перезаписи» из 10000 занимает в 281 раз больше времени, чем пакет чистой «вставки»! Значит, мы не так уж и плохи.

Справедливости ради следует отметить, что PostgreSQL по умолчанию использует меньше оперативной памяти, чем ArangoDB для внутренних кешей, и больше полагается на буферный кеш операционной системы. Я не исследовал, что на самом деле делается за кулисами. Я также ненадолго попробовал PostgreSQL версии 10, но он показал почти такое же поведение.

Кстати, вот скриншот обзора мониторинга для экземпляра в Google Compute Engine Console:

Это показывает, что единственная метрика, которая действительно показывает узкое место для пропускной способности, - это IOPS («Laufwerks E / A Vorgaenge» на немецком языке).

Результат: фундаментальная проблема.

В любом случае, когда я увидел эти числа и то, что очень известный PostgreSQL также страдает от этой проблемы, меня осенило, что это, возможно, фундаментальная проблема.

И действительно, аргумент выглядит следующим образом: представьте себе любое хранилище данных, в котором хранятся «записи» некоторых видов (например, документы JSON в случае ArangoDB, строки для PostgreSQL), которое допускает вторичные индексы. Предположим, что вы храните в хранилище данных значительно больше данных, чем доступная оперативная память. Это кажется очень общей ситуацией и далеко не редкостью.

Вполне возможно, что чистая операция «вставки» может быть реализована так, чтобы ее пропускная способность была примерно такой же, как базовая дисковая система может записывать данные, вероятно, всегда принимая во внимание некоторый вид усиления записи. Наши два примера показывают это достаточно ясно.

Однако для любого вида операции «перезаписи» ясно, что из-за огромной массы данных большинство записей не будут находиться в ОЗУ или в каком-либо кэше ОЗУ. Однако при замене записи, очевидно, необходимо обновить вторичные индексы. Для этого нужна старая версия записи, чтобы можно было удалить ее во всех вторичных индексах.

В качестве конкретного примера, если у вас есть вторичный индекс для «имени», и кто-то изменяет запись таким образом, что изменяется имя, тогда система должна прочитать старую версию записи, чтобы знать, какую запись во вторичном индексе необходимо изменить.

Но, тем не менее, данные организованы на диске, поэтому для чтения старой записи требуется как минимум одно произвольное чтение с диска. В зависимости от базового диска это может легко занять на 3 порядка больше, чем простой поиск в ОЗУ!

Поэтому после некоторого изучения этой проблемы мне кажется, что это фундаментальное препятствие, которое невозможно преодолеть.