[WIP] RFC: Index Build 4.x #12465
Astronomax
started this conversation in
RFC
Replies: 1 comment
-
|
Эта штука как-то адресует проблему блокирующего создания индексов в синхронной репликации? |
Beta Was this translation helpful? Give feedback.
0 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
Ревьюеры
Проблема
Memtx doesn't wait for prepared transactions to become committed on yielding DDL
https://github.com//issues/10930В Vinyl, прежде чем начать построение нового индекса, вызывается
journal_sync(), чтобы дождатьсяCONFIRMна всеpreparedтранзакции. В Memtx этого не происходит, поэтому возможна ситуация, при которой основной цикл построения вставитprepared(неconfimed) тапл, который, например, висел в WAL-очереди еще до начала построения, и он не будет удален из индекса при откате на WAL-failure. То есть нам хотелось бы, чтобы если что-то и могло откатывать то это только параллельные транзакции, возникшие уже после того, как началось построение индекса.На самом деле
journal_sync()здесь будет мало, потому что есть еще несколько мест, где транзакция может повисать - очередь перед WAL,limbo, очередь передlimbo, возможно уже что-то еще.Memtx doesn't handle rollback of concurrent writers on yielding DDL
https://github.com//issues/10931Проблема имеет те же последствия, что и предыдущая - после отката транзакции тапл остается в индексе. В отличие от предыдущего тикета здесь проблема связана с теми таплами, которые возникли в процессе построения и попали правее курсора. На такие таплы не заводится rollback-триггер, поэтому никто не заберет эти таплы из строящегося индекса, если соответствующие им транзакции вдруг откатятся.
В качестве возможного решения можно было бы всегда вешать rollback-триггер независимо от того, по какую сторону от курсора находится тапл в момент вызова
on_replace, при этом добавить в этот триггер логику, которая проверяла бы, прошел ли курсор по данному таплу к моменту отката, и если прошел, то производила бы соответствующий откат в строящемся индексе.Vinyl doesn't wait for writers to be confirmed by replicas on yielding DDL
https://github.com//issues/10929Эта проблема уже упоминалась выше: если в Memtx нет даже
journal_sync()перед началом построения, то в Vinyl он есть, но оказывается, что его недостаточно, поскольку кроме WAL есть и другие очереди, в которых может висеть транзакция, прежде чем станетconfirmed.memtx: crash when building index with mvcc
https://github.com//issues/12261Проблема заключается в том, что
memtx_tx_invalidate_spaceпо сути допускает неверное предположение о том, что можно просто взять и удалить из MVCC-стейта всю дополнительную информацию (в частностиmemtx_story), связанную сpreparedтранзакциями.memtx_tx_invalidate_spaceсейчас делает следующее:memtx_story) связанные с определенным спейсомin-progressтранзакцииprepared/confirmedтранзакций.Так, конечно, делать нельзя сразу по многим причинам.
Например, представим, такую ситуацию:
preparedтранзакцияtxn.memtx_tx_invalidate_space). Он даже может не коммититься и не попадать в очередь WAL - достаточно будет простоbox.begin() DDL() box.rollback().tuplepreparedтранзакцииtxn. При этом связанная с нимstoryудалена, а вместе с ней потряна связь между таплом и соответствующим стейментомstmtтранзакцииtxn.in-progressтранзакции, которые выполняютreplaceс ключом из таплаtuple(первичным или вторичным - не важно). Для таплаtupleснова создается сториstory', которая на этот раз уже не содержит ссылку на стейтментstmt. При этом все эти реплейсы фактически применяются к индексу сразу, так чтобы сохранять инвариант, что верхушка цепочки физически находится в индексеtarantool/src/box/memtx_tx.c
Lines 2972 to 2976 in 1e3c241
story'.txnоткатывается. Откат выполняется черезrollback_empty_stmt(т.к. ссылка наstmtотсутствует), который просто неглядя пореплейсит в индексеrollback_info->new_tupleнаrollback_info->old_tuple.В тикете приведен немного другой пример. Вместо того, чтобы выстраивать новые цепочки из
in-progressстори, начиналось построение нового индекса. Какни странно, даже в этом случае у нас есть определенные проблемы. Потому что откат
preparedстори не заметит никто опять же потому, что послеmemtx_tx_invalidate_spaceэта стори больше не связана со своимadd_stmtстейтментом. В итоге тапл, успели вставить в строящийся вторичный индекс, а затем на ролбеке откатили его только из первичного. При этом несмотря на то, что в стори был добавлен соответствующий read-трекер, построение вторички не заабортилось опять же потому, что нет связиstmt->story.Только
confirmedстори можно совсем безопасно безо всякой дополнительной логики отсоединить отadd_stmt.Одним из возможных решений этой проблемы было бы - при откате такого
preparedстейтмента аборитить сначала все связанныеin-progressтранзакции, например, прямо изrollback_empty_stmt.vinyl: building a unique index can end up with duplicates in it
https://github.com//issues/12284Проблема заключается в том, что процедура построения индексов в Vinyl пропускает некоторые сценарии, при которых возможно возникновение дубликатов в строящемся уникальном индексе. Проверка уникальности выполняется в двух местах:
confirmedтаплаtupleвозникшего до построения (vy_stmt_lsn(tuple) <= build_lsn)on_replaceтриггере при обработке параллельныхin-progressтранзакцийВ обоих случаях проверка устроена одинаково -
vy_getв строящемся индексе по соответствующему ключу таплаtupleи сравнение первичного ключаtupleи того, что было прочитаноvy_get. В первом случае изоляция -p_committed_read_view(т.е.READ_CONFIRMED), во второмvy_tx_read_view(tx)(т.е. в соответствии с уровнем изоляцииin-progressтранзакции, для который сработалon_replace). Оба выглядят странно.В приведенном там репродьюсере происходит следующее:
{1, 1}, йилдит.in-progressтранзакцияtxnвыполняетreplace{3, 2}, проверяет дубликаты, ничего с ключом2не обнаруживает.txnкоммитится, становитсяprepared, повисает в WAL.{2, 2}. Выполняетvy_getвp_committed_read_view, опять же ничего с ключом2не обнаруживает, потому что{3, 2}ещё не записался в WAL.txnи построение успешно записываются в WAL.Кажется, что достаточно было бы изменить
p_committed_read_viewнаp_global_read_viewпри вставке в цикле, чтобы пофиксить конкретно этот класс сценариев. Но возможно также стоило бы использоватьp_committed_read_viewи при проверке вon_replaceдляin-progressпараллельных транзакций - я не проверял, но скорее всего можно воспроизвести проблему, если последовательно один за другим вставить два дубликата параллельно с построением индекса при заторможенном WAL. Все остальные ситуации обрабатываются в том числе благодаря двум вещам:in-progressтранзакций на спейсе.in-progressтранзакция становитсяprepared, Vinyl итерируется по ееwrite_setи абортит всеin-progressвставки с тем же ключом.Allow to write to space on background index build with MVCC enabled
https://github.com//issues/10170Построение Memtx индекса с MVCC, если опустить лишние детали, выглядит в данный момент следующим образом:
index_count, чтобы проверить, что индекс не пустой.index_countоставляет некоторые свои трекеры (gap_item_type = GAP_COUNT), чтобы гаранитровать корректность этого чтения.preparedтранзакций и трекает все прочитанные ключи, а также интервалы между ними.replaceв спейсе и закоммитится, построение индекса заабортится. Кажется, даже не важно, по какую сторону от курсора случится этот реплейс.Таким образом, любая небольшая параллельная операция записи абортит долгую операцию построения индекса. Хочется, чтобы новая процедура построения индекса с MVCC позволяла совершать параллельные операции, чтобы это как можно реже приводило к аборту построения.
Требования
fiber()->txn == NULL), чтобы в дальнейшем использовать ее в контексте неблокирующего построения https://github.com/orgs/tarantool/discussions/11018.Возможное решение №1 (Антон Кузнец @Astronomax)
У этого решения даже есть реализация, которая проходит тесты и репродьюсеры из тикетов. На самом деле стоит сказать, что существующие в данный момент тесты слабо покрывают построение индексов.
Полностью красиво избежать специфических деталей, связанных с конкретными движками, при данном подходе, к сожалению, не получилось, далее обсудим, почему.
Поэтому процедура принимает от движка
vtabс методами:replace_confirmed(index, tuple)replace_in_progress(index, old_tuple, new_tuple)rollback(index, old_tuple, new_tuple)finalize(index)Далее увидим, для чего они нужны, и как реализованы в Memtx и Vinyl.
В общем предлагаемая процедура похожа на ту, которая работает сейчас в Memtx:
on_replaceдля обработки параллельныхreplace. Основная причина, по которой он будет необходим - поиск дубликата в индексеVinyl. Что именно он делает, и какая в нем необходимость, обсудим далее.CONFIRMна всеpreparedтранзакции, висящие в различных очередях:txn_limbo_flush,journal_sync. Примерно так же, как здесь:tarantool/src/box/checkpoint.c
Lines 69 to 74 in 9ec082b
ITER_ALL) и в цикле (далее будем называть "build-loop") вставляет таплы в индекс, периодически отдавая управление. Поскольку активная транзакция в файбере в этот момент отсутствует, итератор не оставляет никаких трекеров или чего-то подобного. В обоих случаях Memtx и Vinyl итератор читаетpreparedтаплы. На самом деле, как мы увидим далее, даже сread-committed(read-prepared) изоляцией, цикл будет пропускатьpreparedтаплы, поэтому, даже если итератор сам по себе будет читать толькоconfirmed, это не сломает корректность.vtab->finalize, необходмая пока только в Vinyl, обсудим дальше, что это такое.on_replaceтриггер.in-progressтранзакции на спейсе. Важно: между этой инвалидацией и предыдущим пунктом не должно бытьyield.В процессе будут поддерживаться несколько структур:
processed- мн-во ключей, которые build-loop должен пропустить, потому что их консистентность гарантируют stmt-level триггеры.Представим, что параллельно с build-loop происходит
replace{tuple}, т.ч.tupleоказывается правее текущего положения курсора. Можно было бы отложить вставку таких таплов (сделать это ответственностью build-loop), как это реализовано сейчас.write_set(только для unique индексов) - нечто аналогичноеwrite_setв Vinyl, маппингbuild_key -> txn set. Можно сказать, что это мн-во "write points" параллельных транзакций. "write point" - это пара(key, txn), которая создается в момент, когда параллельная транзакцияtxnвставляетtuple, т.ч.build_key(tuple) = key. Используется для того, чтобы абортить конфликтующихin-progresswriter-ов, и тем самым избегать дубликатов.not_confirmed(только для unique индексов):key -> int. Его назначение будет рассмотрено дальше.on_replace:new_tupleдля нового формата индекса.result = building_index.get(build_key(new_tuple))и сравнениеresult == old_tuple. Если находится какой-то таплresult, отличный отold_tuple- это дубликат,new_tupleвставлять нельзя. В этом случаеreplaceв транзакции завершается ошибкой, build-loop продолжает работать.write_setдобавляется(build_key(new_tuple), txn).before_commit,on_commit,on_rollback.before_commit(on_prepare):pk = primary_key(new_tuple ?: old_tuple).old_tupleв индексе, чтобы корректно выполнитьvtab->replace_in_progress.old_tupleуже находится в индексе:pkprocessed.pkнаходится левее или ровно на build-курсоре - build-loop прошел черезpk.not_confirmed[build_key(old_tuple)]++. Назначениеnot_confirmedобсудим ниже, пока пропускаем.vtab->replace_in_progress(old_tuple or NULL, new_tuple).pkбыл правее курсора,pkдобавляется вprocessed. Теперь build-loop пропустит этот PK и не продублирует уже примененное изменение.write_set, т.к. транзакция перестала бытьin-progress.in-progresswriter-ы изwrite_setпо ключуbuild_key(new_tuple).on_rollback:in-progress(не вызвалbox.commit):write_setудаляется соответствующий "write point".prepareк этому моменту уже успел выполниться:on_prepare.old_tupleдля нового формата индекса. Потому что возможно такое, чтоold_tupleбыл закоммичен до начала построения и для него никогда еще ни разу не выполнялась эта проверка.in-progresswriter-ы изwrite_setпо ключуbuild_key(old_tuple).not_confirmed[build_key(old_tuple)]--. Назначениеnot_confirmedобсудим ниже, пока пропускаем.vtab->rollback(old_tuple, new_tuple). Откатывает изменения, внесенные ранее изreplace_in_progress: удаляетnew_tuple, возвращаетold_tuple. Что это означает для каждого конкретного движка, обсудим позже.on_commit(on_confirm):build-loop:
tupleон очищает изprocessedключи, лежащие строго левее текущегоtuple.tupleнайден вprocessed, build-loop пропускает его целиком: этот PK уже был обработан черезon_prepare/on_rollback.tupleдля нового формата индекса.not_confirmed[build_key(tuple)] == 0. Если> 0, построение абортится. Пока не говорим, почему.in-progresswriter-ы изwrite_setпо ключуbuild_key(tuple).vtab->replace_confirmed(tuple).sleep(0)на каждыйN-ой итерации.Note
Важно учитывать, что некоторые триггеры могут йилдить. За время, пока файбер триггера "suspended", build-loop может успеть дойти до конца или заабортиться и освободить ресурсы, которые нужны триггеру. Это стоит учитывать при реализации.
Что такое
not_confirmed, какую задачу решает?К этому моменту может показаться, что всего того, что описано выше, достаточно для того, чтобы обеспечить корректное поведение на всем мн-ве возможных сценариев, однако есть еще как минимум одна проблема:
Представим себе ситуацию:
{1, 1},{2, 2},{3, 2}{1, 1}replace{3, 3}вREPLACE_TXN, идет на запись в WAL и там зависает{2, 2}и засыпает. В предположении, что проверка дубликатов читает сread-committed(read-prepared) изоляцией, дубликат{3, 2}не обнаружатся ({3, 3}в этот момент ужеprepared).REPLACE_TXNоткатывается в{3, 2}. В этот момент необходимо проверить дубликаты, чтобы обнаружить пару дубликатов{2, 2},{3, 2}. Самое очевидное - выполнитьgetпо ключу2в индексе. Однако в случаеVinylесть проблема -getпоилдит, что недопустимо при ролбеке.При внимательном рассмотрении оказывается, что подобные "конфликты" (дубликаты) возникают именно между вставкой из основного цикла и параллельной вставкой из транзакции. Две вставки из основного цикла и даже, что не так очевидно, параллельные вставки из транзакций не создают такой проблемы между собой. Ниже обсудим, почему.
Несколько возможных решений этой проблемы:
BUILD_TXN. Причем транзакция должна что-нибудь записать, чтобы пройти через WAL (например какую-то статистику в_index_local, чтобы не выглядело костыльно). Тогда с приведенным выше сценарием все нормально -BUILD_TXNоткатится каскадно вместе сREPLACE_TXN. Но что, еслиREPLACE_TXNоткатится до того, какBUILD_TXNвызоветbox.commit(станетprepared)? К счастью, в том числе для таких ситуаций в процедуре предусмотренwrite_set, благодаря которомуREPLACE_TXNзаабортитBUILD_TXN. Этот вариант как раз встретится дальше в возможном решении №2.{2, 2}из основного цикла проверять дубликаты дважды: один раз с изоляциейread-committed, второй сread-confimed. Вon_replaceдостаточно будет одной проверки сon_replace. То есть, если перед началом построения были дубликаты, то построение может завершиться успешно только, если все они каким-то образом будут replaced до того, как до них дойдет build-курсор.not_confirmed. При переходе транзакции изin-progressвpreparedинкрементировать счетчикnot_confirmed[build_key(old_tuple)]++, а при переходе изpreparedвconfirmed/rollbacked- декрементировать. При вставке из build-loop проверятьnot_confirmed[build_key(tuple)] == 0. В приведенном выше сценарии в момент preparereplace{3, 3}выпонитсяnot_confirmed[2]++. А далее build-loop попытается вставить{2, 2}, но т.к.not_confirmed[2] > 0, заабортит построение.Ключевое различие в модели Memtx и Vinyl
Прежде чем рассмотреть
vtabдля обоих движков, стоит проговорить одно важное различие междуMemtxиVinyl.В
Memtxизменение индекса - это по сути немедленное изменение соответствующего дерева. Если в строящийся индекс изon_prepareбыл вставленnew_tuple, то при rollback его нужно так же явно удалить из этого дерева и при необходимости вернутьold_tuple. Поэтому вMemtxrollback- этоindex_replaceв обратную кreplace_in_progressсторону.В
Vinylмодель другая. Там изменение не переписывает индекс на месте, а дописывается в последовательность statement-ов (INSERT/REPLACE/DELETE/UPSERT), которые сначала живут вvy_txи егоwrite_set, а затем наprepareпопадают вvy_memсоответствующегоLSMчерезvy_lsm_set(). Наconfirmони подтверждаются черезvy_lsm_commit_stmt(), а при rollback prepared-транзакции движок сам удаляет их изvy_memчерезvy_lsm_rollback_stmt(). То есть вVinylrollback prepared writer-а обрабатывается полностью на стороне самого движка, а не снаружи.Поэтому
replace_in_progressдляVinylиMemtxвыглядят по-разному в реализации:Memtxмы напрямую меняем строящийся индекс, как структуру.Vinylмы только добавляем вvy_txstatement-ы для строящегосяLSM: surrogateDELETEдляold_tupleиINSERTдляnew_tuple. Дальше их жизненным циклом управляет уже самVinyl.Получается, что в
Vinylrollbackвvtabможет бытьNoop, потому что фактический откат делает неindex_build, аvy_tx_rollback()/vy_lsm_rollback_stmt().В таком виде
vtabполучается следующим:replace_confirmed(index, tuple): вставкаconfirmedтаплаtupleиз build-loop.Memtx:index_replace(index, NULL, tuple, DUP_INSERT).Vinyl: прямая вставка statement-а в строящийсяLSM(vy_build_insert_tuple()).replace_in_progress(index, old_tuple, new_tuple): обработка параллельной транзакции, вызывается изon_prepare.Memtx:index_replace(index, old_tuple, new_tuple, DUP_REPLACE_OR_INSERT).Vinyl: запись соответствующих statement-ов вvy_txдля строящегося индекса:DELETE(old_tuple)и/илиINSERT(new_tuple)черезvy_tx_set(). Дальше эти statement-ы наprepareбудут записаны вvy_mem, а затем либоconfirmed, либо откачены средствамиVinyl.rollback(index, old_tuple, new_tuple): откат параллельнойpreparedтранзакции.Memtx: явная обратная операция, по сутиindex_replace(index, new_tuple, old_tuple, ...).Vinyl:Noop, потому что rollback prepared statement-ов уже выполняется движком приvy_tx_rollback().finalize(index): финализация, необходимая толькоVinyl.Memtx:Noop.Vinyl: инициировать dump новогоLSM, чтобы построенный индекс не пришлось пересобирать при recovery.Возможное решение №2 (Андрей Саранчин @drewdzzz)
Начнем рассмотрение с основного каркаса - с того, что точно хотелось бы включить в это решение, а дальше различные дополнительные решения будем рассматривать и принимать по ходу.
Во-первых, build-loop теперь будет заворачивать вставку целого батча таплов в транзакцию
BUILD_TXN.Примерно эта транзакция могла бы выглядеть таким образом:
box.begin()tupleиз итератораpk_iter. Memtx/Vinyl MVCC будут отслеживать, что прочиталось в этом месте.res = index.get(build_key(tuple))<------------- ???res == NULLwrite_setдобавляется(build_key(tuple), BUILD_TXN)index_replace. Нетранзакционный. Т.е. не попадает в stmts транзакции. В принципе будет странно и даже не очень понятно, как бы он мог быть транзакционным. Здесь вставка происходит только в один индекс, и ролбек соответственно, тоже при необходимости нужно будет осуществить только в одном этом индексе, в то время какREPLACE-statement означает атомарную вставку в спейс в целом (во все его индексы). А если это не statement, тогда не понятно, как эту вставку откатывать, если вдруг откатитсяBUILD_TXN, например, по конфликту с параллельной транзакцией. Для этого придется, видимо, пригородить что-то дополнительное в build-loop.pk_iter.next()._index_local.box.commit()Warning
Еще одну важную вещь я заметил, пока писал пункт про
index_get(build_key(tuple)). Конкретно в реализации решения №1 используетсяindex_get_internal, который в Memtx реализован таким образом (memtx_tree_index_get_internal), что при включенном MVCC он еще дополнительно ищет по цепочке видимую для читающей транзакции версию (memtx_tx_tuple_clarify). Но проблема в том, что Memtx MVCC не обрабатывает строящиеся индексы, т.е. не поддерживает для них цепочку версий и разрешение конфликтов. Если вызыватьindex_get_internalна строящемся Memtx индексе, то Memtx MVCC попытается проитерироваться по цепочке. Здесь он обратится кstory->link[index->dense_id], которого нет (linkаллоцированы в предположении, что индекса с такимdense_idеще не существует), и получится segfault. В предложенном решении №1 этого не происходит по счастливой случайности. Там все чтенияindex_get_internalделаются сread-prepared(is_prepared_ok = true) изоляцией, а все параллельные вставки происходят именно вon_prepare(before_commit). То естьindex_get_internalвсегда вызывается в момент, когда в индексе лежит видимый prepared тапл, поэтому цикл всегда завершается на первой же итерации, ни разу не обратившись кlink[index->dense_id]. Вообще, что в решении №1, что в решении №2 нам нужен просто сыройgetв структуре, без всякого итерирования по истории, поэтому скорее всего придется просто поменять что-то в интерфейсеengine_vtab, чтобы там появлился сыройget(просто обертка надmemtx_tree_find). Но опять же для Vinyl это будет выглядеть странно, потому что там все совсем по-другому. Не так уж понятно, что такое "сырой"getдля Vinyl. В vinyl история в каком-то смысле отслеживается даже для строящегося индекса. В Vinyl есть свои внутренние statemtents на каждый индекс отдельно. Как только параллельная транзакция добавляет Vinyl-statement (building_index:REPLACE()) изменение попадает в историю. Для Vinyl этотgetможно было бы определить какgetс изоляциейread_prepared, т.е. "get last tuple in index history".on_replaceдля параллельных транзакций:write_setдобавляется(build_key(tuple), BUILD_TXN)index_replace. Нетранзакционный для Memtx MVCC - имеется ввиду, что Memtx MVCC в текущем своем виде не выстраивает историю изменений для строящегося вторичного индекса, не ослеживает дубликаты, конфликты, связанные со строящимся вторичным индексом. НасчетОдним из минусов решения №1 является то, что параллельная вставка справа от курсора оставляет флаг в мапе
processed, откуда дальше этот флаг удалится только, когда курсор пересечет соответствующий первичный ключ. То есть флаг появляется вon_prepareи дальше не удаляется ни вon_confirmни вon_rollback. Таким образом, это в худшем случаеO(кол-во транзакций, случившихся параллельно с построением)дополнительной памяти. Хотелось бы справиться хотя бы заO(макс. кол-во одновременно активных in-progress/prepared транзакций). Если это принципиально, можно будет вынести в требования выше. На самом деле не совсем понятно, почему это важно, ведь пропорциональное кол-во памяти все равно будет выделено под соответствующие таплы.Поэтому здесь хотелось бы вернуть старый подход, и вставлять в триггерах только то, что попадает левее курсора, тем самым избежать дополнительных пометок в виде
processed.Beta Was this translation helpful? Give feedback.
All reactions