Разработка
Параллелизм в SQLite для Android
Давайте разберемся во внутренностях SQLite для Android!
В документации SQLDelight есть такой пример:
val players: Flow<List<HockeyPlayer>> =
playerQueries.selectAll()
.asFlow()
.mapToList(Dispatchers.IO)
Это выглядит разумно, верно? В приложении Square Point Of Sale мы недавно обнаружили, что использование Dispatchers.IO
для выполнения запросов SQLite может замедлить выполнение других задач приложения. Давайте разберемся во внутренностях SQLite для Android!
Одиночное соединение
В Android по умолчанию SQLite может открыть не более одного соединения с базой данных (источник: SQLiteConnectionPool.java).
Это означает, что одновременно SQL-запрос может выполнять только один поток.
Если вы запустите два SQLite-запроса к одной и той же базе данных параллельно с помощью Dispatchers.IO
, один поток будет блокироваться, пока другой поток выполняет свой запрос, и ваше приложение потребит два потока из пула Dispatchers.IO
. Первый поток будет удерживаться в течение всего времени выполнения первого запроса. Второй поток будет заблокирован на время выполнения первого + второго запросов, так как сначала он будет ждать, пока первый поток освободит соединение SQLite, прежде чем сможет его базу данных.
Параллелизм Dispatchers.IO
Ну и в чем проблема? Ведь если есть максимум одно соединение, то логично, что все запросы должны выполняться один за другим.
Dispatchers.IO
отправляет корутины не более чем на 64 потока параллельно (источник: Dispatcher.kt). По мере увеличения количества параллельно запущенных запросов потоки Dispatchers.IO
будут задерживаться все дольше и дольше, проводя большую часть времени в блокировке и ожидании. В конце концов, все 63 из 64 потоков из пула Dispatchers.IO
будут заблокированы в ожидании освобождения блокировки, а новые задачи Dispatchers.IO будут ожидать в очереди диспетчера.
Поэтому, хотя предполагается, что все запросы к одной и той же базе данных должны выполняться один за другим, если мы заваливаем Dispatchers.IO запросами, то в итоге блокируем другие несвязанные задачи ввода-вывода, которые не связаны с этим ограничением.
На самом деле это распространенная проблема Dispatchers.IO
, и в kdoc описано, как ее можно решить.
Эластичность для ограниченного параллелизма
Dispatchers.IO
обладает уникальным свойством эластичности: его представления, полученные с помощью CoroutineDispatcher.limitedParallelism
, не ограничены параллелизмом Dispatchers.IO
. Концептуально существует диспетчер, работающий с неограниченным пулом потоков, и как Dispatchers.IO
, так и представления Dispatchers.IO
на самом деле являются представлениями этого диспетчера. На практике это означает, что, несмотря на несоблюдение ограничений параллелизма Dispatchers.IO
, его представления разделяют с ним потоки и ресурсы. В следующем примере:
xxxxxxxxxx
// 100 threads for MySQL connection
val myMysqlDbDispatcher = Dispatchers.IO.limitedParallelism(100)
// 60 threads for MongoDB connection
val myMongoDbDispatcher = Dispatchers.IO.limitedParallelism(60)
Во время пиковых нагрузок система может иметь до 64 + 100 + 60 потоков, предназначенных для блокирующих задач, но в устойчивом состоянии есть только небольшое количество потоков, распределенных между Dispatchers.IO
, myMysqlDbDispatcher
и myMongoDbDispatcher
.
Поэтому, учитывая, что мы знаем, что можем выполнять не более одного запроса за раз, мы могли бы использовать отдельный экземпляр диспетчера Dispatchers.IO.limitedParallelism(1)
для каждой базы данных, к которой мы обращаемся. Если бы у нас было N баз данных, мы бы использовали не более N + 64 потоков для блокирующих операций и никогда не опустошили бы пул потоков Dispatchers.IO
.
Переключение потоков и кэш процессора
Хотя Dispatchers.IO.limitedParallelism(1)
гарантирует, что наши запросы к базе данных SQLite будут выполняться в одном потоке, он не дает никаких гарантий относительно того, в каком именно потоке. Это означает, что каждый запрос может выполняться в разных потоках. Регуляторы CPU стараются, чтобы потоки по возможности придерживались одного ядра процессора, чтобы избежать постоянной перезагрузки кэша процессора. Поэтому при использовании Dispatchers.IO.limitedParallelism(1)
мы имеем высокие шансы разрушить кэш процессора при каждом новом запросе. Вместо этого мы можем использовать диспетчер одного потока для каждой базы данных:
xxxxxxxxxx
val playerDbDispatcher = newSingleThreadContext("player-db")
xxxxxxxxxx
val players: Flow<List<HockeyPlayer>> =
playerQueries.selectAll()
.asFlow()
.mapToList(playerDbDispatcher)
WAL: больше соединений SQLite
В SQLite есть опция Write-Ahead Log (WAL), которая может привести к значительному повышению производительности и была включена по умолчанию в Android 9. WAL также поддерживает больший параллелизм (concurrency), однако по умолчанию в Android эта опция не включена, чтобы не сломать приложения, которые полагаются на предыдущее поведение: одно соединение приводит к последовательному выполнению операций, и некоторые приложения могут полагаться на это. Для поддержки дополнительного параллелизма в Android 11 добавлен метод SQLiteDatabase#enableWriteAheadLogging
:
Этот метод позволяет параллельно выполнять запросы из нескольких потоков к одной и той же базе данных. Для этого открывается несколько соединений с базой данных и для каждого запроса используется отдельное соединение с базой данных. […] Хорошей идеей является включение write-ahead logging во всех случаях, когда к базе данных одновременно обращаются и изменяют ее несколько потоков. Однако write-ahead logging использует значительно больше памяти, чем обычное журналирование, поскольку к базе данных sa me устанавливается несколько соединений. Поэтому если база данных будет использоваться только одним потоком или если оптимизация параллелизма не очень важна, то логирование с опережением записи следует отключить.
После вызова SQLiteDatabase.enableWriteAheadLogging(true)
база данных будет поддерживать одно первичное соединение и несколько вторичных.
Первичное соединение
Первичное соединение используется для запросов, которые пишут в базу данных, а также для любого запроса, выполняемого как часть транзакции (т. е. после SQLiteDatabase.beginTransaction()
, источник: SQLiteDatabase.java).
Когда вы выполняете запрос, Android Framework автоматически определяет, является ли запрос запросом SELECT или запросом на запись (источник: SQLiteProgram.java). По-видимому, в старых версиях Android в разборе пользовательских запросов были ошибки, поэтому в библиотеке Room все запросы на запись автоматически оборачиваются в транзакцию, чтобы гарантировать, что они используют основное соединение. Рассмотрите возможность обертывания всех ваших запросов на запись в транзакцию.
Стоит ли вообще использовать SQLiteDatabase.beginTransaction()
для запросов, предназначенных только для чтения?
- В идеале — нет, поскольку начало транзакции означает, что вы теперь используете основное соединение, а значит, не можете выполнять параллельные запросы.
- Однако фреймворк Android загружает результаты запросов в окно CursorWindow, максимальный размер которого определяется приватным ресурсом Android
R.integer.config_cursorWindowSize
(источник: config.xml) и настраивается приложениями начиная с API 28 (источник: CursorWindow.java).- Если вы выполняете запрос на чтение, который загружает окно курсора и начинает читать из него, а в это время выполняется запрос на запись, и затем курсор обновляет свое окно, запись может привести к изменению количества строк. Это может привести к несогласованным данным или аварийному завершению работы:
java.lang.IllegalStateException: Couldn't read row 4247, col 0 from CursorWindow. Make sure the Cursor is initialized correctly before accessing data from it.
SQLiteDatabase#beginTransactionReadOnly
была добавлена в API 35, предположительно для поддержки этого случая использования и использования вторичных соединений для транзакций только для чтения.- До API 35 вы могли либо использовать
SQLiteDatabase.beginTransaction()
и потерять параллелизм, либо начать пагинацию запросов, загружая небольшие подмножества результатов, которые помещаются в окно. Корректная работа с пейджингом очень сложна, поскольку размер строк может быть динамическим (например, если они включают в себя блоб или строку), поэтому вы не можете предсказать максимальное количество строк, которые нужно получить. - Прочитайте это, чтобы узнать больше: Запросы к большим базам данных на Android
- Если вы выполняете запрос на чтение, который загружает окно курсора и начинает читать из него, а в это время выполняется запрос на запись, и затем курсор обновляет свое окно, запись может привести к изменению количества строк. Это может привести к несогласованным данным или аварийному завершению работы:
Вторичные соединения
Вторичные соединения используются для выполнения запросов SELECT
(источник: SQLiteProgram.java).
- В документации утверждается, что «максимальное количество соединений, используемых для параллельного выполнения запросов, зависит от памяти устройства и, возможно, других характеристик».
- На практике максимальное количество соединений определяется приватным ресурсом Android
R.integer.db_connection_pool_size
(источник: SQLiteGlobal.java). - В AOSP значение по умолчанию для
R.integer.db_connection_pool_size
равно 4 (источник: config.xml). - Для транзакций «только чтение»
SQLiteConnectionPool.waitForConnection()
сначала попытается получить одно из вторичных соединений, а затем вернется к первичному соединению, если оно доступно (источник: SQLiteConnectionPool.java).- Максимальное количество соединений включает первичное соединение, так что фактически мы получаем 1 первичное соединение и 3 вторичных (источник: SQLiteConnectionPool.java).
- Это означает, что у нас может быть либо 4 параллельно выполняющихся запроса
SELECT
, либо 1 запрос на запись и 3 параллельно выполняющихся запросаSELECT
.
Вы можете прочитать R.integer.db_connection_pool_size
во время выполнения с помощью следующего вспомогательного метода:
xxxxxxxxxx
// Based on SQLiteGlobal.getWALConnectionPoolSize()
fun getWALConnectionPoolSize() {
val resources = Resources.getSystem()
val resId =
resources.getIdentifier("db_connection_pool_size", "integer", "android")
return if (resId != 0) {
resources.getInteger(resId)
} else {
2
}
}
Неисключительные транзакции
В документации SQLiteDatabase#enableWriteAheadLogging
дается неожиданная рекомендация:
Для запуска записывающей транзакции следует использовать
beginTransactionNonExclusive()
. Неэксклюзивный режим позволяет файлу базы данных быть доступным для чтения другими потоками, выполняющими запросы.
На практике это означает, что beginTransaction()
выполнит BEGIN EXCLUSIVE;
а beginTransactionNonExclusive()
выполнит BEGIN IMMEDIATE;
(источник: SQLiteSession.java).
Однако в документации sqlite по транзакциям говорится следующее:
EXCLUSIVE и IMMEDIATE одинаковы в режиме WAL, но в других режимах журналирования EXCLUSIVE не позволяет другим соединениям базы данных читать базу данных, пока транзакция находится в процессе выполнения.
Я не уверен, почему документация рекомендует использовать beginTransactionNonExclusive()
, когда включен режим WAL, так как это фактически то же самое, что beginTransaction()
, это кажется ошибкой.
WAL и диспетчеры
Теперь мы можем использовать одновременное чтение и запись с помощью WAL, используя последовательный диспетчер для записи и одновременный диспетчер для чтения с соответствующей эластичностью, чтобы увеличить параллелизм без блокирования большего количества потоков, чем необходимо:
xxxxxxxxxx
val dbWriteDispatcher = Dispatchers.IO.limitedParallelism(1)
val connectionPoolSize = getWALConnectionPoolSize()
val dbReadDispatcher = Dispatchers.IO.limitedParallelism(connectionPoolSize)
Или, если вы предпочитаете использовать выделенные пулы потоков:
xxxxxxxxxx
val dbWriteDispatcher = newSingleThreadContext("$dbName-writes")
val connectionPoolSize = getWALConnectionPoolSize()
val dbReadDispatcher = newFixedThreadPoolContext(connectionPoolSize, "$dbName-reads")
При обоих предыдущих подходах мы получаем 5 потоков, выделенных для выполнения работы пула из 4 соединений, поэтому будут моменты, когда один поток будет заблокирован. Room применил другой, более оптимальный подход: использовать пул из 4 потоков как для чтения, так и для записи, но контролировать задачи записи, чтобы убедиться, что мы пытаемся выполнить только одну за раз, в последовательном порядке (источник: TransactionExecutor.kt).
Слон в комнате
Как все это применимо к Room?
Танец WAL в Room
При использовании Room, WAL с поддержкой одновременных соединений включен по умолчанию на API 16+, если только устройство не является устройством с низким уровнем оперативной памяти (источник: RoomDatabase.kt).
Room можно настроить на использование кастомных эксекюторов (RoomDatabase.Builder.setQueryExecutor()
& RoomDatabase.Builder.setTransactionExecutor()
, или RoomDatabase.Builder.setQueryCoroutineContext()
).
В kdoc про RoomDatabase.Builder.setQueryExecutor
говорится, что исполнитель запросов должен использовать максимальное количество потоков — без уточнения, сколько именно — и что исполнитель транзакций будет выполнять не более одного запроса за раз. Упоминание @Transaction
в kdoc гласит: «Методы Insert, Update или Delete всегда выполняются внутри транзакции».
В документе также упоминается, что «если оба исполнителя транзакций и запросов не заданы, то будет использоваться исполнитель по умолчанию, который распределяет и делит потоки между библиотеками Architecture Components».
Таким исполнителем по умолчанию на самом деле является ArchTaskExecutor.getIOThreadExecutor()
(источник: RoomDatabase.android.kt):
xxxxxxxxxx
abstract class RoomDatabase {
open class Builder<T : RoomDatabase> {
open fun build(): T {
if (queryExecutor == null && transactionExecutor == null) {
transactionExecutor = ArchTaskExecutor.getIOThreadExecutor()
queryExecutor = transactionExecutor
}
// ...
ArchTaskExecutor.getIOThreadExecutor()
делегирует DefaultTaskExecutor.mDiskIO
, который представляет собой фиксированный пул потоков размером 4 (источник: DefaultTaskExecutor.java). Этот пул потоков также используется в качестве планировщика выборки по умолчанию для Rx пейджинга (источник: RxPagedListBuilder.kt) и исполнителя по умолчанию для ComputableLiveData
(источник: ComputableLiveData.kt).
Такой дизайн не идеален, поскольку этот пул потоков в итоге используется для несвязанной работы. Даже если бы он использовался только в Room, это все равно не идеально, поскольку все базы данных в итоге используют один и тот же глобальный пул потоков, что приводит к его голоданию. Если у вас более одной базы данных, управляемой Room, вы должны предоставить свои собственные исполнители.
Связанный SQLite
Стоит отметить, что Room теперь предлагает связанную версию SQLite вместо стандартной версии для Android. Это увеличивает размер APK (~1 МБ), но означает, что вы получаете последнюю версию SQLite, получаете точно такое же поведение на всех версиях Android и не используете код SQLite из Android Framework. Чтобы узнать больше, я рекомендую посмотреть Powering Room with Bundled SQLite.
Room с подключенным SQLite также предлагает надлежащую поддержку корутинов, то есть больше нет блокировки потоков в ожидании соединения с SQLite благодаря приостанавливающемуся пулу соединений (источник: ConnectionPoolImpl.kt). Это означает, что потоки блокируются только тогда, когда они действительно выполняют запрос, и снижается вероятность того, что пул потоков окажется заблокированным в ожидании соединения.
Ключевые выводы
- Делайте пагинацию запросов, чтобы избежать возврата результатов, превышающих размер окна курсора (это сложно настроить), или используйте
beginTransactionNonExclusive()
на API 35+, а в остальных случаях возвращайтесь кbeginTransaction()
. - Если вы используете Room
- Если у вас более одной базы данных Room
- По умолчанию все базы данных Room используют один и тот же фиксированный пул потоков размером 4, хотя вместо этого вы должны иметь возможность использовать до 4 соединений одновременно для каждой базы данных.
- Вызовите
RoomDatabase.Builder.setQueryCoroutineContext()
илиRoomDatabase.Builder.setQueryExecutor()
с ограниченным эксекютором, чтобы обеспечить одновременное выполнение запросов к каждой базе данных в 4 разных потоках.
- Рассмотрите возможность использования Room с Bundled SQLite для повышения производительности, снижения трудоемкости обслуживания и устранения риска блокировки потоков в ожидании соединения.
- Если у вас более одной базы данных Room
- Если вы не используете Room
- Оберните все запросы на запись в транзакцию.
- Используйте ограниченные исполнители для запросов на чтение и запись, разный набор для каждой базы данных.
- Рассмотрите возможность использования связанного SQLite (я еще не изучал практические детали этого).
Не могу не упомянуть, что ваши приложения для Android, вероятно, не должны пытаться параллельно выполнять миллиард SQL-запросов. У вас может возникнуть «проблема N + 1 запроса» (поищите!) или просто слишком много асинхронного кода.
Огромная благодарность Шейну Тангу за изучение документации по SQLite, Йигиту Бояру и Дани Сантьяго за то, что поделились тонной информации о внутреннем устройстве Room и SQLite, а также Заку Клиппенштейну и Джесси Уилсону за вычитку!
-
Видео и подкасты для разработчиков3 недели назад
Как устроена мобильная архитектура. Интервью с тех. лидером юнита «Mobile Architecture» из AvitoTech
-
Новости4 недели назад
Видео и подкасты о мобильной разработке 2025.9
-
Новости3 недели назад
Видео и подкасты о мобильной разработке 2025.10
-
Новости2 недели назад
Видео и подкасты о мобильной разработке 2025.11