Как в eXpress достигали мгновенного рендера отправки сообщения: оптимизация локальной базы данных

Откройте eXpress и отправьте в чат простое «Привет». Обратите внимание на то, сколько времени прошло между тем, как вы нажали на кнопку «отправить», и тем, как сообщение появилось в чате. Для человеческого глаза отображение отправленного сообщения в чате происходит мгновенно. Это современный стандарт — действия в чате должны происходить максимально быстро. В идеальном случае никаких задержек быть не должно.

Отправка сообщения, тем не менее, это сложная операция, которая требует проделать большой объем работы. Самая существенная её часть — это работа с локальной базой данных. eXpress спроектирован как offline-first и много данных хранит локально. У одного пользователя могут быть тысячи активных чатов и десятки/сотни тысяч сообщений, и работа с ними должна оставаться быстрой.

Меня зовут Воронская Дарья, я работаю в Android Core команде eXpress и занимаюсь оптимизацией работы приложения. Я расскажу про подходы, которые мы использовали, чтобы довести скорость отправки и рендера сообщения до того самого идеального результата. 

В этой статье речь пойдет только о работе с локальной базой данных. Оптимизация БД — лишь часть большой работы, мы расскажем про наши решения по оптимизации presentation слоя в других статьях.

Наш стек: Kotlin, Room, Coroutines, WebSocket, OkHttp.

Дисклеймер

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

Все замеры проводились на пустом проекте, на устройстве Android 13, Xiaomi Poco f3. 

Для сравнения времени выполнения функций будут использованы перцентили:

  • p50 (медиана) — время выполнения, которое оказалось быстрее 50 процентов всех замеров

  • p90 — время выполнения, которое оказалось медленнее 90 процентов всех тестовых замеров

  • p95 — время выполнения, которое оказалось медленнее 95 процентов всех тестовых замеров

Простые решения «на месте»

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

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

1. Чтение необходимых столбцов базы данных

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

@Query("SELECT * from chats where id = :id")

suspend fun getChatById(id: String): Chat

@Query("SELECT updated_at, updated_message_id from chats where id = :id")

suspend fun getChatById(id: String): ChatUpdatedMessage

Мы провели эксперимент, насколько сильно уменьшается время получения данных при уточнении нужных столбцов. Взяли таблицу из 5 столбцов типа TEXT, содержащую 5000 строк, и сравнили время выполнения двух функций.

@Query("SELECT * from table_name where id in (:ids)")

suspend fun getByIds(ids: Collection): Table

@Query("SELECT first_field from table_name where id in (:ids)")

suspend fun getChatById(ids: Collection): TableFirstField

Ниже представлена диаграмма, на которой видно, как различается время выполнения вышеупомянутых функций для одного элемента. 

c14597935234cc6eac6f647dd3fe39c1.png

Если таблица содержит мало столбцов, то селект всех столбцов сам по себе выполняется быстро. Запрос, в котором читается только 1 столбец, работает быстрее, но лишь на несколько процентов (в нашем случае на 10%).

На графике зависимости времени выполнения двух функций (на графике оранжевый — селект всей строки, красный — селект 1 поля) от размера исходного списка видно, что при увеличении количества запрашиваемых элементов время увеличивается линейно. Например, в нашем случае селект 1 столбца по 100 элементам быстрее селекта 5 столбцов почти в 2 раза. Но так как все еще селект всех полей выполняется быстро (в нашем случае 0.4 мс), в уточнении столбцов мало смысла, кроме тех случаев, когда дорога каждая доля секунды.

8e229786d3e166f0c37ae9f835419f71.png

На массивных таблицах время селекта всей строчки будет больше, а разница во времени выполнения вышеупомянутых функций будет значительно выше. Например, если в таблице 40 столбцов и нам нужен только 1 из них, то селект одного поля будет уже в 2 раза быстрее, чем селект всей строки.

4dc737f28800734d77d002ca25d50350.png

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

  • При получении 100 строк селект 1 поля отработал быстрее селекта всей строки в 8.6 раз

  • При получении 50 строк селект 1 поля отработал быстрее селекта всей строки в 5.8 раз 

88fadb5e40d552427d3d681d77e90824.png

Естественно, если в столбцах хранится тяжелая информация, это тоже влияет на скорость чтения. 

Для наглядности: заполнили таблицу из 40 столбцов строками по 400 кб (в продакшен коде таблицы такого размера не стоит делать) и провели тот же эксперимент (синяя линяя — select *, зеленая линия — select field). Для данного случая селект одного поля работает быстрее селекта всей строки в 50–100 раз.

16696954c3fef746d0fce6b4712b6ee5.png

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

Также если таблица содержит поля типов, которые требуют конвертации через TypeConverter, то особенно имеет смысл конкретизировать поля в запросе. Кастомные объекты обычно хранят в SQLite в виде строки, например формата JSON. Конвертеры типов работают для кастомных объектов при каждом селекте и могут выполняться длительное время.

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

Также можно перенести часть простой логики в запрос. Например, с помощью оператора EXISTS можно определить, существует ли в таблице нужная нам строка.

// Определение существования записи через SELECT *
@Query("SELECT * from chats where chat_id = :chatId”)
fun getChatById(chatId: String): Chat?
 
val isChatExists = getChatById(chatId) != null
// Определение существования записи через SELECT EXISTS
@Query("SELECT EXISTS (SELECT 1 from chats where chat_id = :chatId)”)
fun isChatExists(chatId: String): Boolean
 
val isChatExists = isChatExists(chatId)

Ускорение в данном конкретном случае будет лишь за счёт отсутствия необходимости читать содержимое полей и формировать из них объект. В гистограмме ниже приведено сравнение скорости выполнения функций для таблицы из 20 столбцов. Прирост получается небольшой, но и каких-то потенциальных проблем такой подход не имеет:

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

2. Объединение нескольких записей в одну транзакцию

Каждая запись в базу данных выполняется в транзакции записи. Сначала собирается набор изменений, и в конце транзакции — записывается в базу данных. С помощью extension функций withTransaction, runInTransaction, а также аннотации @Transaction можно выполнить несколько запросов в рамках одной транзакции.

appDatabase.withTransaction {
   chatsDao.clearDraft()
   messagesDao.insert(messageDB)
   attachmentsDao.insert(attachmentDB)
}

Если выполнить в одной транзакции несколько запросов, то изменения по нескольким запросам (и таблицам) применятся одной операцией. Объединение нескольких запросов на запись в одну транзакцию дает несколько преимуществ:

  1. Консистентность данных — база данных не будет в состоянии, когда данные, которые друг от друга зависят, не будут друг другу соответствовать. Если запрос на чтение совершается между двумя запросами на запись в одной транзакции, race condition не произойдет.

  2. Скорость — так как количество операций записи в БД уменьшается до 1, мы достигаем улучшения в производительности. Это скорее побочный эффект транзакции, но в данном случае он для нас наиболее важный.

Ниже представлены график зависимости времени выполнения от количества INSERTв одной транзакции (синяя линия), и график зависимости от количества INSERT без общей транзакции (зеленая линия). Вставка в транзакции работает быстрее вставки без транзакции примерно в 5 раз вне зависимости от количества вставок. Если операций совсем мало, то прирост будет маленький или его вообще не будет.

d9241f5c343e7ac5298d4309e1e3fdcb.png

Аналогичная ситуация с операциями UPDATE. Ниже представлены график зависимости времени выполнения от количества update в одной транзакции (красная линия) и график зависимости от количества update без общей транзакции (оранжевая линия). UPDATEв транзакции работает быстрее update без транзакции примерно в 7 раз вне зависимости от количества операций. 

880d2b8ea1547ac1aaa688951fe641fa.png

В любом случае, если операций совсем мало (до 5) прирост в производительности будет небольшой, или наоборот, транзакция замедлит запись.

Стоит внимательно относиться к тому, что попадает внутрь лямбды транзакции. Так как SQLIte — однопоточная для записи, случайно попавшие в транзакцию сетевой запрос или математическая логика могут заблокировать работу всех участков приложения, которым требуется БД.

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

3. Объединение записей нескольких полей в 1 таблицу

В SQL запросе на запись можно перечислить несколько полей, которые нужно обновить:

@Query("UPDATE chats SET input = :input, reply = :reply WHERE chat_id = :chatId")

1 update, который обновляет несколько полей, всегда работает быстрее, чем несколько, вне зависимости от того, выполняются ли эти несколько запросов в 1 транзакции. Добиться ускорения работы записи в несколько раз можно, начиная уже с объединения 2 операций в 1:

c12020b88d00c421f12ad86b36cb65d4.png

4. Игнорирование бесполезных запросов

Результат некоторых запросов очевиден еще до начала их выполнения. Например, запись dao.insertList(emptyList()), очевидно не изменит таблицу, запись dao.getById(emptyList()) очевидно вернёт пустой список. Оптимизация, планирование запросов только происходит на уровне нативного кода, а java классы обертки сам запрос не анализируют. Перед тем как SQLite поймет, что никакой операции выполнять не надо, всё равно нужно открыть транзакцию, скомпилировать запрос, сделать ряд другой мелкой работы по обеспечению запроса. Такой бесполезный запрос может занимать до нескольких миллисекунд. Если таких запросов много, а скорость должна быть почти мгновенной, то нужно таких запросов избегать.

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

fun saveChats(chats: List) {
	if (chats.isEmpty()) return
	chatsDao.insertList(chats)
	// какая-нибудь еще обработка списка
}

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

Ниже представлена гистограмма, на которой видно, сколько времени выполняются INSERT, DELETE, UPDATE, SELECT, в параметрах, у которых пустой список.

4813650c627158e83645c9da85d86122.png

Одна бесполезная операция выполняется быстро, но если их много, в сумме они могут выполняться внушительное время.

Ускорение чтения

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

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

1. Параллельное выполнение запросов

При использовании journal_mode=WAL (в Room этот режим включён по умолчанию) SQLite может проводить несколько операций чтения одновременно. Это позволяет получить прирост в скорости при распараллеливании IO операций. По наблюдениям, при объединении нескольких селектов в одну транзакцию достигается примерно схожее ускорение, что и при распараллеливании.

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

2. Удаление бесполезных транзакций

Любая операция в SQLite так или иначе выполняется в транзакции. Вложенными транзакции быть не могут. Но в Room с помощью аннотации @Transaction или функции withTransaction мы можем объединять несколько операций в одну транзакцию, более того, вложенные withTransaction не вызовут падения приложения.

// Матрешка из транзакций не имеет смысла, но краша не вызовет
Db.withTransaction {
	Db.withTransaction {
       	Dao.insert(entity)
	}
}

Это происходит потому, что транзакция, которая создается обертками над SQLite, не соответствует транзакции внутри нативного кода. Вложенные транзакции на уровне java кода распрямляются в одну транзакцию на уровне нативного кода. Однако поддержание вложенных транзакций не бесплатно, и если будет обнаружена «матрешка» из транзакции или аннотация @Transaction, приставленная к функции dao, которая выполняет один запрос, можете смело удалять лишние транзакции.

Ускорение обработки массива данных

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

1. Изменение контракта функций под обработку массива

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

// сложнее оптимизировать
list.map { interactor.process(it) }
// проще оптимизировать
interactor.process(list)

В рамках функции, которая работает со списком, можно:

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

  2. Объединить IO операций по каждому элементу в одну. Особенно сильно ухудшает перформанс выполнения сетевых запросов в функциях, которые обрабатывают 1 элемент. Если необходимо, просите у backend-разработчиков добавить сетевые запросы, которые обрабатывают список данных.

У такого изменения контракта функции есть 2 проблемы:

  1. Если на класс написаны тесты, то их придётся значительно переписывать.

  2. Обычно функции, которые обрабатывают один элемент, читаются чуть легче.

Дальнейшие советы могут потребовать изменения контракта функций.

2. Решение N+1 проблемы

Типичная N+1 проблема выглядит так:

// получили список ids
val messageIds = chatsDao.getLastMessageIdsByChatIds(chatIds)
// в дальнейшем для каждого из ids выполнили запрос
val message = messagesDao.getById(messageId)

Проблема называется n+1, так как выполняется N вопросов для каждого из элементов, полученных в 1 запросе.

Скорее всего, в вашем коде не будет такой ситуации, когда в одном методе сначала получаете список id, а потом для каждого id вызывается отдельный запрос. Скорее всего список будет получен в одном методе, а n других запросов — в других методах. Это хороший пример того, почему нужно проектировать функции так, чтобы они принимали список в качестве параметра, а не один элемент.

Идет дискуссия, насколько N+1 проблема на самом деле требует решения. Разработчики SQLite, например, утверждают в своей документации, что N+1 не является проблемой.

В обычных ситуациях, особенно в мобильных приложениях, действительно ситуация N+1 не приводит к плохому перформансу.

В нашей ситуации, где есть много больших таблиц, а требования к скорости высокие, решать N+1 проблему пришлось.

Проще всего сократить количество запросов до двух.

// получили список ids
// SELECT last_event_id from chats …
val messageIds = chatsDao.getLastMessageIdsByChatIds(chatIds)
// получили список сущностей по ids
// SELECT * from messages where id in (:messageIds)
val messages = messagesDao.getByIds(messageIds)

При использовании оператора IN нужно следить за величиной передаваемого списка. Увеличение длины списка приводит, наоборот, к медленной работе запроса или его отмене. Может произойти краш при получении большого массива данных, из-за того что размер курсора (указателя на строку таблицы) ограничен 1 Мб. 

На гистограмме ниже видно, как различается скорость нескольких селектов по 1 элементу от одного селекта по 10 элементам. 

c05b7bf59fa26be7d0f631e81582641a.png

Ниже представлены график зависимости 1 селекта по нескольким элементам от количества элементов (красный график) и график зависимости нескольких селектов по 1 элементу. Использование 1 селекта всегда будет быстрее нескольких. 

  • 1 селект по 100 элементам быстрее 100 селектов по 1 элементам в 20 раз

  • 1 селект по 50 элементам быстрее 50 селектов по 1 элементам в 18 раз

  • 1 селект по 10 элементам быстрее 100 селектов по 1 элементам в 6 раз

99695f78e77c22f5cf6b9635e4e50c69.png

2. Использование @Embedded и @Relation

С помощью встроенных аннотаций room можно решить N+1 проблему.

Допустим, нам необходимо получить список сообщений с реакциям. Используем для этого класс MessageReactionDB

 class MessageReactionDB(
     	@Embedded val message: MessageDB,
     	@Relation(
             	parentColumn = "message_id",
             	entityColumn = "message_id",
             	entity = ReactionDB::class
     	)
     	val reaction ReactionDb?,
)

Напишем функцию dao для получения этого объекта:

@Query("SELECT * FROM `messages` WHERE chat_id = :chatId ")
abstract suspend fun getMessagesReactions(chatId: String): List

Мы написали самостоятельно только SQL запрос получения message, а запрос получения reactionDB напишет за нас room. Если посмотреть сгенерированный код (имплементацию абстрактного класса или интерфейса dao), то можно увидеть, что создается SQL запрос типа:

SELECT <перечисление нужных полей> FROM `reactions` WHERE `message_id` 
IN (:<список id сообщений, полученных от запроса сообщений>)

Получается, функция getMessagesReactions() вернет нам результат за 2 запроса: 1 запрос на получение списка messageDB, другой — на получение списка reactionDB. Полученный список reactionDB room соотносит с нужными messageDB самостоятельно.

3. Минимизация лишних срабатываний реактивной подписки на БД.

С помощью Room+Coroutines можно создавать функции, с помощью которых можно реактивно получать изменения базы данных. Например:

@Query("SELECT unread_messages from chats where chat_id = :chatId”)
fun observeUnreadMessagesByChatId(chatId: String): Flow  

Механизм подписки на изменения базы данных основан на триггерах. Триггеры — внутренний механизм Sqlite, который позволяет установить коллбек на определённые события. Когда мы создаем подписку на обновление БД, Room создает триггеры на события insert, update, delete в таблице. В результате этих событий выполняется запрос, результат эмитится во flow, и подписчик получает информацию об изменениях. Здесь важно понимать несколько нюансов:

  1. Запрос подписки выполняется при обновлении любой строки таблицы. Например, если мы вызываем вышеупомянутую функцию observeUnreadMessagesByChatId с параметром chatId = 1, её запрос выполнится при изменении строк с любым chatId.

  2. Запрос подписки не выполняется, если insert ничего не вставил или update с выборкой по несуществующим в таблице данным.

  3. Запрос подписки выполняется в результате update, который перезаписывает одинаковые данные. Предположим, в таблице chats в строке с id = 1, unread_messages = 12. Если мы вызовем update, в котором запишем в строку с id = 1 unread_messages = 12, то селект подписки всё равно вызовется, несмотря на то, что фактические данные не изменились.

По итогу если в приложении есть подписки на изменения БД, то стоит всегда вызывать у flow подписок distinctUntilChanged().

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

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

Обнаружить такую проблему можно, если включить отображение выполняемых запросов в логах с помощью adb команды adb shell setprop log.tag.SQLiteStatements VERBOSE или с помощью самостоятельной обработки через RoomDatabase.QueryCallback

Если вдруг проблема обнаружилась, то можно пойти следующими путями:

  1. Проверять значение поля перед его обновлением в таблице.

  2. Если циклично обновляется поле в таблице, переделать обновление только в конце цикла. Например, при отправке сообщения у нас обновляется в БД id последнего сообщения в чате. Если отправлялось несколько сообщений (например, при пересылке), то перезапись id последнего сообщения происходила на каждое отправляемое сообщение. Так как у нас есть подписки на изменения таблицы чатов, такая перезапись сильно влияла на общую производительность.

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

  4. Использовать свою систему подписки на обновления БД.

Наблюдения в результате оптимизации

  1. Существуют более глобальные методы оптимизации, например, изменение структуры БД: раздробление/соединение таблиц, добавление индексов, изменение типов данных полей. Прежде чем производить точечные изменения на местах, нужно проверить, что всё правильно со структурой БД. Лучше проектировать новые таблицы, учитывая перформанс, до появления первых проблем.

  2. Часто не требуется создавать сложные алгоритмы, чтобы достичь хорошего результата. Для оптимизаций запросов в сеть и БД часто хватает простых инструментов и подходов.

  3. Оптимизируя, всегда нужно руководствоваться замерами. То, что советует документация android или сообщества, может не дать гарантированного результата на практике.

  4. При оптимизации комплексной задачи не стоит концентрироваться на выжимке максимально возможной скорости отдельных участков. Задача перформанса стоит в поиске баланса между скоростью, читабельностью и безопасностью. Стоит придерживаться постепенного подхода — сначала простые, безопасные, читабельные изменения по всей задаче и по мере необходимости все более сложные, спорные.

  5. Коммиты с оптимизациями стоит делать минимального размера. Иногда бывает соблазн сразу сделать много изменений, собрать метрики, получить классный результат и выложить большой коммит, который должен сделать всё лучше. Но с большой вероятностью он что-нибудь сломает. Лучше разбивать изменения на мелкие, но не такие грандиозные коммиты.

Продолжение следует

Процесс ускорения отправки сообщения не состоял только из оптимизации базы данных. Про оптимизацию presentation слоя мы расскажем в следующей статье.

© Habrahabr.ru