[Перевод] PostgreSQL: как всего одно изменение привело к росту производительности в 9 раз

В самом сердце проекта Actual, который предназначен для управления персональными финансами, лежит система синхронизации данных собственной разработки. Недавно я реализовал в проекте полное сквозное шифрование (оно, правда, пока не вышло в продакшн). Эта работа вдохновила меня на исследование производительности внутренних механизмов системы. Сегодня я хочу рассказать об одной возможности PostgreSQL, которая позволила добиться 9–10 кратного увеличения производительности проекта.

envxagx-4vx97fcie_xoqxjgche.jpeg

Проблема 169000 сообщений


Actual — это полностью локальное приложение, синхронизация выполняется в фоновом режиме с использованием бесконфликтных реплицированных типов данных (Conflict-free replicated data type, CRDT). Это означает, что серверная часть проекта очень проста. Её основная задача заключается в том, чтобы сохранять и загружать «сообщения» для клиентов. Весь код, касающийся синхронизации данных, занимает всего лишь примерно 200 строк JavaScript.

Нам нужно обрабатывать очень много сообщений для того чтобы синхронизация была бы быстрой. На самом деле, как-то раз произошла одна странность: новый пользователь за один день сгенерировал 169000 сообщений. Это — показатель, который очень резко отклоняется от обычных значений. Например, при импорте в систему 1000 транзакций генерируется примерно 6000 сообщений. И это — вполне нормальное значение, которое больше, чем среднее количество сообщений, генерируемых одним пользователем за день. Я так думаю, что эти 169000 сообщения были сгенерированы при попытке использования API для импорта в систему большого объёма данных. У нас есть разные API для подобных целей. Но такое количество сообщений — это очень много. Поэтому я решил превратить их в бенчмарк для исследования производительности Actual.

Я попытался пропустить через систему 169000 сообщений и «подвесил» сервер. Запрос был отклонён по тайм-ауту, а сервер всё ещё пытался разобраться с сообщениями, замедляя всё остальное. Я тут же понял, что проблема заключается именно в этом.

Сообщения хранятся в базе данных PostgreSQL. Соответствующая таблица выглядит так:

CREATE TABLE messages_binary
  (timestamp TEXT,
   group_id TEXT,
   is_encrypted BOOLEAN,
   content bytea,
   PRIMARY KEY(timestamp, group_id));


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

Сервер оказался перегруженным из-за того, что попытался добавить в базу данных огромное количество строк. К сожалению, мы, добавляя в базу сообщения, не можем просто выполнить один запрос, содержащий множество операторов INSERT. Особенности наших CRDT накладывают на работу с базой данных некоторые ограничения:

  1. Сообщение не может быть продублировано (оно идентифицируется полем timestamp).
  2. Нам нужно обновлять дерево хешей, эта процедура зависит от того, было ли сообщение добавлено в базу данных.


С первым ограничением справиться было легко. Так как мы сделали timestamp первичным ключом, мы можем выполнять запросы вида INSERT INTO messages_binary (...) VALUES (...) ON CONFLICT DO NOTHING. Выражение ON CONFLICT указывает системе на то, что при возникновении конфликта делать ничего не нужно. Конфликт первичных ключей возникает у дубликатов уже существующих записей.

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

if(inserted) {
  trie = merkle.insert(trie, Timestamp.parse(msg.timestamp));
}


Очень важно то, чтобы каждая отметка времени в системе добавлялась бы в дерево хешей лишь один раз. Это дерево ответственно за поддержание системы в однородном состоянии, оно используется для работы с хешами данных. Если не обеспечить то, что отметки времени добавляются в дерево один и только один раз, хеши (и верификация) окажутся некорректными.

Весь код обновления базы данных выглядит так, как показано ниже (тут используются некоторые абстракции над node-postgres):

await runQuery('BEGIN');
let trie = await getMerkle(runQuery, groupId);
for (let message of messages) {
  let timestamp = message.getTimestamp();
  let isEncrypted = message.getIsencrypted();
  let content = message.getContent();
  let { changes } = await runQuery(
    `INSERT INTO messages_binary (timestamp, group_id, is_encrypted, content)
       VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING`,
    [timestamp, groupId, isEncrypted, content]
  );
  if (changes === 1) {
    // Обновить дерево хешей
    trie = merkle.insert(trie, Timestamp.parse(timestamp));
  }
}
await runQuery(
  `INSERT INTO messages_merkles (group_id, merkle)
     VALUES ($1, $2)
         ON CONFLICT (group_id) DO UPDATE SET merkle = $2`,
  [groupId, JSON.stringify(trie)]
);
await runQuery('COMMIT');


Это, по большей части, реальный код. Его основное отличие от того кода, который работает у нас, заключается в том, что мы, при сбое, выполняем откат транзакции. Невероятно важно, чтобы всё это происходило бы в транзакции, и чтобы сообщения и дерево хешей обновлялись бы атомарно. Опять же, дерево хешей обеспечивает верификацию содержимого сообщений, дерево и сообщения всегда должны быть синхронизированы. Если это не так — пользователи столкнуться с ошибками синхронизации.

Теперь суть проблемы стала мне совершенно понятна: мы выполняем запросы INSERT для каждого отдельного сообщения. В нашем примере, создающем огромную нагрузку на систему, мы пытаемся выполнить 169000 подобных запросов. PostgreSQL работает на отдельном сервере (правда, он расположен близко к серверу приложения). Выполнение такого огромного количества сетевых запросов и само по себе способно ухудшить производительность системы, не говоря уже о том, как подобная задача способна перегрузить PostgreSQL.

Какова реальная производительность системы?


Я знал о том, что данные в нашу базу добавляются медленно, но я не понимал того, насколько медленно. Поэтому я решил протестировать систему, использовав меньшее количество сообщений. Такое, чтобы их обработка могла бы быть нормально завершена. Оказалось, что для обработки 4000 сообщений нужно 6.9 секунды. Это — лишь результат профилирования вышеприведённого кода, затраты времени на передачу данных по сети не учитываются.

Это — колоссальная проблема UX проекта. В процессе обработки данных пользователю приходится сидеть перед экраном компьютера и наблюдать, как крутится, крутится и крутится значок синхронизации.

Если вернуться к вопросам архитектуры нашей системы, то вот что нам было нужно:

  • Выполнять как можно меньше запросов.
  • Знать о том, какие именно сообщения были добавлены в базу данных.
  • Атомарно выполнять операции по записи сообщений и по обновлению дерева хешей.


Мы могли бы узнать о том, какие сообщения уже существуют, и отфильтровать их, но это потребовало бы выполнения тяжёлых запросов SELECT (и одним запросом тут, вероятно, обойтись бы было нельзя, так как кого-нибудь вряд ли порадовала бы перспектива передачи в запрос 169000 параметров). У меня была ещё одна идея, которая заключалась в добавлении в базу сообщений с уникальным номером. Потом можно было бы выполнить запрос на получение сообщений с этим номером, так как этот номер имели бы только новые сообщения.

Оптимизация запросов


Красота реляционных баз данных (в сравнении с базами типа ключ-значение) заключается в том, что в их реализациях обычно имеются надёжные решения для проблем такого рода. Я полагал, что просто должен быть способ это сделать, так как наш паттерн не отличался особой оригинальностью. Первое, с чем я разобрался, касалось добавления в базу нескольких строк с использованием единственного оператора INSERT:

-- Передать несколько элементов в INSERT можно, по крайней мере, в PostgreSQL
INSERT INTO messages_binary (timestamp, group_id, content) VALUES
  ("1", "group1", "binary-blob1"),
  ("3", "group1", "binary-blobb6"),
  ("2", "group1", "binary-blobbbb");


Это — лучше чем объединять множество операторов INSERT в один запрос, так как такая конструкция, вероятно, будет работать быстрее. А ещё важнее то, что у нас была надежда на получение сведений о том, что было сделано.

Углубившись в документацию, я обнаружил выражение RETURNING оператора INSERT. По умолчанию PostgreSQL, добавляя данные в базу, не возвращает ничего кроме сведений о количестве изменённых строк. Но если выполняется запрос вида INSERT INTO table (value) VALUES (1) RETURNING id — система вернёт идентификатор новой строки.

Главный вопрос заключался в том, работает ли подобная конструкция так, как было нужно мне. А именно, если применяется оператор INSERT, рассчитанный на обработку нескольких элементов, и при этом в нём имеется выражение ON CONFLICT DO NOTHING, будет ли возвращён массив идентификаторов только для элементов, которые были реально добавлены в базу? Я подозревал, что подобная конструкция может вернуть идентификаторы всех элементов, даже тех, при записи которых произошёл конфликт, то есть таких, которые, в итоге, записаны не были.

Я, по-быстрому, написал скрипт для исследования вышеизложенной идеи. И тут мне повезло: выражение RETURNING работало именно так, как мне было нужно. Вот соответствующий запрос:

INSERT INTO messages_binary (timestamp, group_id, content) VALUES
  ('1', 'group5', '...'),
  ('2', 'group6', '...'),
  ('3', 'group7', '...')
ON CONFLICT DO NOTHING RETURNING timestamp;


Если, при выполнении этого запроса уже существовало сообщение, отметка времени которого равнялась 1, в базу записывались только сообщения 2 и 3. А возвращаемый массив выглядел как [{ id: '2' }, { id: '3' }]. Получилось!

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

Новый код выглядел примерно так, как показано ниже. Я всё ещё исследую безопасность вспомогательного механизма pg-promise:

// Используем вспомогательный механизм из библиотеки `pg-promise`
// для генерирования конструкций INSERT с множеством значений.
// Значения соответствующим образом форматируются.
// http://vitaly-t.github.io/pg-promise/helpers.html#.insert
let stmt = pgp.helpers.insert(
  messages.map(msg => ({
    timestamp: msg.getTimestamp(),
    group_id: groupId,
    is_encrypted: msg.getIsencrypted(),
    content: msg.getContent()
  })),
  ['timestamp', 'group_id', 'is_encrypted', 'content'],
  'messages_binary'
);
let { changes, rows } = await runQuery(
  stmt + ' ON CONFLICT DO NOTHING RETURNING timestamp'
);
rows.forEach(row => {
  trie = merkle.insert(trie, Timestamp.parse(row.timestamp));
});
// Запись дерева хешей…


Теперь взглянем на результаты:

4000 messages
Before: 6.9s
After: .75s
40000 messages
Before: 59s
After: 7.1s


Если вас это удивляет — знайте — всё выглядит именно так. Раньше на обработку 40000 нужно было 59 секунд. А теперь — всего 7.1 секунды. Это значит, что у нас появилась возможность обрабатывать в 10 раз больше сообщений за то же время, что и раньше.

Добавлю, что после выхода этого материала оказалось, что вышеприведённые данные были искажены из-за ошибки SQL. Каждый фрагмент данных оказывался большего размера, чем нужно (из-за неправильной кодировки бинарного блоба). После исправления этой ошибки сгенерированные INSERT-запросы стали примерно на 25% меньше. Это привело к тому, что 40000 сообщений теперь обрабатываются примерно за 5 секунд.

А как насчёт обработки 169000 сообщений?

Насколько большим может быть отдельный запрос?


Возможно, вас интересует то, как повёл себя в новых условиях наш бенчмарк — запись в базу 169000 сообщений. Ну, оказалось, что выполнить его всё равно нельзя из-за ограничений PostgreSQL. Эту проблему решить не так уж и легко.

Первой проблемой, с которой я столкнулся при попытке обработки 169000 записей, было падение Node.js. А именно, сбой вызвал вспомогательный метод pgp.helpers.insert из pg-promise при передаче ему настолько большого количества элементов. Я точно не знаю о том, почему это случилось, но я не вижу смысла выяснять причину этого, так как я столкнулся и с другими проблемами.

Для начала, обработать 169000 элементов — значит — выгрузить 21 Мб данных. Это неприемлемо из-за того, что слишком велики шансы того, что в ходе этой операции может произойти ошибка.

Если уменьшить бенчмарк до 100000 сообщений, то мы уже можем получить что-то рабочее. Запрос INSERT, в котором используется множество значений, представляет собой строку размером в 72 Мб. Попытка выполнить столь огромный запрос просто… подвешивает весь сервер. Я не знаю точно о том, в чём кроется источник проблемы. Не знаю я и о том, что, может быть, можно настроить PostgreSQL так, чтобы система могла бы обрабатывать подобные запросы. Но, опять же, запросы таких размеров мы обрабатывать не можем.

Более приемлемое решение заключается в разбиении запросов на части и в указании верхней границы количества сообщений для отдельного запроса. На роль подобного лимита, как кажется, хорошо подходят 40000 сообщений. Если говорить о размере, то объём данных составляет 5 Мб, на их обработку уходит 7 секунд (правда, и при таком количестве сообщений размер строки запроса составляет 40 Мб, но PostgreSQL без проблем обрабатывает такой запрос). Для обработки 169000 сообщений нужно отправить 5 запросов, в четырёх из которых содержится по 40000 сообщений, а в одном ещё 9000 сообщений. Общее время обработки таких запросов составит примерно 29.6 секунд (169000 / 40000×7). Это, при условии показа пользователям сведений о ходе проведения операции, не так уж и много для такого огромного объёма данных.

Но это — наихудший сценарий. Обычно нам не приходится работать с запросами, время обработки которых выражается в секундах. Чаще всего выполняются запросы на обработку 10–200 сообщений. На синхронизацию данных при таком подходе уходит что-то около 20 мс. 169000 сообщений — это, пожалуй, худший из худших сценариев, это ситуация, которая может возникнуть в том случае, если кто-то заваливает API тысячами запросов в секунду, а потом пытается синхронизировать изменения. Такое не случается практически никогда. Но мы должны быть готовы к ситуации, в которой нашим пользователям это может понадобиться.

Ещё одно улучшение


Ещё одно улучшение системы, о котором я хочу рассказать, не связано с вышеописанной проблемой. Так как дерево хешей хранится в базе данных, серверу нужно прочитать его, изменить, а потом записать обратно. Это означает, что, при работе с деревом, нельзя, в конкурентном режиме, менять его, установив с сервером ещё одно соединение.

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

У меня есть такое ощущение, что использование для транзакций уровня изоляции Serializable может решить эту проблему. Транзакцию начинают, используя BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE. PostgreSQL отменит транзакцию в том случае, если обнаружит возможность возникновения состояния гонок. Я не полностью уверен в том, подойдёт ли это мне, учитывая то, что в рамках одной транзакции я что-то читаю из базы и что-то в неё пишу. Но если это мне подойдёт, то, при сбое транзакции, мне достаточно будет просто её перезапустить. В результате процессы синхронизации будут выполняться последовательно.

Что дальше?


Я пока не пробовал исследовать производительность синхронизации на 169000 сообщений. Клиент выполняет больше работы в ходе синхронизации из-за того, что в его системе присутствует немало дополнительных механизмов. Поэтому тут ещё достаточно места для оптимизации. Сомневаюсь, что в текущих условиях система справится с обработкой сразу 169000 сообщений, но вот в том, что она нормально обработает 40000 сообщений, я уверен. И я считаю удачной идеей разбиение больших запросов на запросы, рассчитанные на обработку 40000 сообщений. Это, в частности, упрощает обратную связь с пользователем, позволяя сообщать ему о ходе выполняемой работы.

Но, в целом, можно отметить, что оптимизация систем в расчёте на экстремально большие нагрузки — это весьма полезное занятие. Достигнутое улучшение производительности в 9–10 раз сказывается и на мелких запросах, которые составляют около 95% общей нагрузки на систему. Запрос, который раньше занимал 100 мс, теперь занимает всего около 10 мс. Это просто замечательно!

Если вам приходилось когда-либо оптимизировать работу с базами данных — просим рассказать о том, каких результатов вам удалось добиться.

guabcgmwuqoopx1ar80sjpz6keq.png

de0yl-6ppopvisr_a80b4yuhjj8.png

© Habrahabr.ru