Откройте 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<Int>): Table

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

suspend fun getChatById(ids: Collection<Int>): TableFirstField

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

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

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

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

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

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

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

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

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

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

Также если таблица содержит поля типов, которые требуют конвертации через 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 раз вне зависимости от количества вставок. Если операций совсем мало, то прирост будет маленький или его вообще не будет.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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 элементам. 

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

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

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

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

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<MessageReactionDB>

Мы написали самостоятельно только 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<Int?>  

Механизм подписки на изменения базы данных основан на триггерах. Триггеры — внутренний механизм 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 слоя мы расскажем в следующей статье.

Комментарии (13)


  1. Kill_Voice
    22.10.2024 09:04

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


    1. express_ms
      22.10.2024 09:04

      Добрый день, напишите, пожалуйста, в нашу поддержку (support@express.ms) - разберемся и поможем


  1. BloodyR00t
    22.10.2024 09:04

    В целом классно что получилось достигнуть такой мощный прирост, за это респект. С другой стороны это размывание бизнес-логики между слоями приложения, в частности, между уровнем базы данных (DAO) и бизнес-логикой, например когда часть логики переносится непосредственно в SQL-запросы (например, через использование оператора EXISTS для проверки существования записей или объединение нескольких полей в один запрос) и тд. приводит к трудностями в тестировании и размыванию ответственности. Что вы думаете на этот счет?


    1. seal_android Автор
      22.10.2024 09:04

      Спасибо за обратную связь)

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

      1. Если эта логика не содержит контекста. Например, в запросе select exists (select 1 from table_name where id = :id)) нет контекста, нет нюансов, которые надо помнить, нет бизнес логики как таковой. Есть только сущности, поля, которые описаны в дб, и стандартные функции, которые SQL предоставляет. Если у DAO есть такая функция, она всё еще не знает, зачем она используется. Другое дело, если мы будем писать функцию, которая будет определять, например, существует ли чат, у которого тип = канал, и есть непрочитанные сообщения. Если мы всё это условие опишем в SQL запросе, то да - размывание бизнес логики, сложность в тестировании, поддержки - всё это будет.
      2. Если заранее ясно, что в конкретном месте нам важен хороший перформанс, который можно получить сложным запросом, и последствия размывания логики мы осознанно готовы принимать. У каждого проекта будут свои правила по тому, в какой момент считать, что стоит перенести логику в запрос.


  1. dyadyaSerezha
    22.10.2024 09:04

    Можно пойти ещё дальше и писать сообщение в чат ещё до записи его в локальную БД, помечая некой галочкой со смыслом "пишется в базу", и удаляя сообщение в редком случае ошибки записи.


    1. seal_android Автор
      22.10.2024 09:04

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


      1. dyadyaSerezha
        22.10.2024 09:04

        Почему ещё один? Пусть будет единственный. Сообщение, свое или чужое, появилось в памяти - ототрази его с той самой галочкой и потом записывай в базу, а потом в облако (если надо), в чат на сервер или абонентам и т.д


        1. seal_android Автор
          22.10.2024 09:04

          Сообщения в мессенджере, где в одном чате могут сидеть сотни/тысячи человек, подвержена быстрым изменениям, некоторые из которых отменяют предыдущие. Сообщение мы получили, его тут же кто-то отредактировал/создал тред/добавил-отменил реакцию/атач загрузился на 0.1% - это всё может произойти буквально одновременно, и всё это ведёт к изменению ui чата, может повлиять на еще какую-то логику - и это всё довольно сложно поддерживать, если опираться на разные источники информации об изменениях.
          Чтобы избежать проблем с подобными сущностями на разных уровнях, мы придерживаемся концепции "единственный источник правды". Если мы должны что-то отобразить, получить какую-то информацию - то идти мы должны непосредственно к этому источнику. От многих багов это решение нас спасло
          Для нас источник правды - БД. Поэтому решение что-то обновить в обход БД - выходит за рамки архитектуры. Обновлять другие сообщения не через БД - с учетом высокой реактивности - для нас стрелять себе в ногу.


  1. Kahelman
    22.10.2024 09:04

    Честно говоря у меня вопрос по замеру производительности запросов:
    Запускать два селектора последовательно и мерить скорость нельзя - оптимизатор запросов будет подготовлен к выполнению похожего запроса и кеш базы данных будут заполнены данными ( холодный кеш vs горячий кеш)

    Далее выбор всех строк из таблицы - явно ресурсоёмкая операция при количестве столбцов >X и размере данный > Y (подробности зависят от размера блока в БД, размера кластера на диске и т.д.)

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

    Например: поля в таблице: a,b,c,d,…h
    Индекс построен по столбцам a,b,c,d

    Если выбираем столбца a,b,c - нам повезло все данные из индекса.
    Если добавим поле f - придётся лезть в базу данных и скорость упадёт.

    Вопрос что будет при выборе a,b,d - зависит от вашей базы данных. Некоторые умеют выдавать столбцы из индекса игнорирую пропущенные, некоторые нет.

    Если у вас ORM - то вы вообще не понимаете что происходит на уровне базы данных, и это для вас :(

    В общем, дело ясное что дело темное. Похоже вы добились улучшения на тестовом наборе данных, но не факт что скорость улучшиться на «реальных» данных.

    Опять таки, могу предположить что операция записи в БД гораздо быстрее чем операция отсылки сообщения - нет сетевых задержек, не надо устанавливать соединение.

    То что вы клиенту показали что сообщение сразу отправлено- не то же самое что сообщение фактически отправлено.

    И тем более не равно утверждению что оно дошло до адресата.