Хабрахабр

Транзакции в глобалах InterSystems IRIS

InterSystems IRIS and transactionСУБД InterSystems IRIS поддерживает любопытные структуры для хранения данных — глобалы. По сути это многоуровневые ключи с различными дополнительными плюшками в виде транзакций, быстрых функций для обхода деревьев данных, блокировок и своего языка ObjectScript.

Подробнее о глобалах в цикле статей «Глобалы — мечи-кладенцы для хранения данных»:

Часть 1.
Деревья. Деревья. Часть 3. Часть 2.
Разреженные массивы.

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

A — Atomic (атомарность). Записываются все изменения сделанные в транзакции или вообще никаких.

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

I — Isolate (изолированность). Параллельно выполняющиеся транзакции не должны оказывать влияние друг на друга.

D — Durable (долговечность). После успешного завершения транзакции проблемы на нижних уровнях (сбой по питанию, например) не должны оказывать влияние на данные изменённые транзакцией.

Они создавались для сверхбыстрой работы на очень ограниченном железе. Глобалы — это нереляционные структуры данных. Давайте разберёмся в реализации транзакций в глобалах с помощью официального docker-образа IRIS.

Для поддержка транзакций в IRIS используются команды: TSTART, TCOMMIT, TROLLBACK.

1. АТОМАРНОСТЬ

Легче всего проверить атомарность. Проверяем из консоли базы данных.

Kill ^a
TSTART
Set ^a(1) = 1
Set ^a(2) = 2
Set ^a(3) = 3
TCOMMIT

Потом делаем вывод:

Write ^a(1), “ ”, ^a(2), “ ”, ^a(3)

Получим:

1 2 3

Всё в порядке. Атомарность соблюдена: все изменения записались.

Усложним задачу, введём ошибку и посмотрим как сохранится транзакция, частично или вообще никак.

Ещё раз проверим атомарность:

Kill ^A
TSTART
Set ^a(1) = 1
Set ^a(2) = 2
Set ^a(3) = 3

После чего принудительно остановим контейнер, запустим и посмотрим.

docker kill my-iris

Эта команда практически эквивалентна насильственному выключению питания, так как отправляет сигнал немедленной остановки процесса SIGKILL.

Может быть транзакция сохранилась частично?

WRITE ^a(1), ^a(2), ^a(3)
^
<UNDEFINED> ^a(1)

— Нет, не сохранилась.

Испытаем команду отката:

Kill ^A
TSTART
Set ^a(1) = 1
Set ^a(2) = 2
Set ^a(3) = 3
TROLLBACK WRITE ^a(1), ^a(2), ^a(3)
^
<UNDEFINED> ^a(1)

Тоже ничего не сохранилось.

2. СОГЛАСОВАННОСТЬ

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

Например у нас есть глобал ^person, в котором мы храним персоналии и в качестве ключа мы используем ИНН.

^person(1234567, ‘firstname’) = ‘Sergey’
^person(1234567, ‘lastname’) = ‘Kamenev’
^person(1234567, ‘phone’) = ‘+74995555555
...

Для того, чтобы иметь быстрый поиск по фамилии и имени мы сделали ключ ^index.

^index(‘Kamenev’, ‘Sergey’, 1234567) = 1

Для того, чтобы база была согласована мы должны добавлять персоналию так:

TSTART
^person(1234567, ‘firstname’) = ‘Sergey’
^person(1234567, ‘lastname’) = ‘Kamenev’
^person(1234567, ‘phone’) = ‘+74995555555
^index(‘Kamenev’, ‘Sergey’, 1234567) = 1
TCOMMIT

Соответственно, при удалении мы также должны использовать транзакцию:

TSTART
Kill ^person(1234567)
ZKill ^index(‘Kamenev’, ‘Sergey’, 1234567)
TCOMMIT

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

3. ИЗОЛИРОВАННОСТЬ

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

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

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

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

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

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

SQL определяет 4 уровня изолированности:

  • READ UNCOMMITTED
  • READ COMMITTED
  • REPEATABLE READ
  • SERIALIZABLE

Затраты на реализацию каждого уровня растут чуть-ли не экспоненциально. Рассмотрим каждый уровень в отдельности.

Транзакции могут читать изменения внесённые друг другом. READ UNCOMMITTED — это самый низкий уровень изолированности, но при этом самый скоростной.

Транзакции не могут читать изменения внесённые друг другом до коммита, но могут читать любые изменения внесённые после коммита. READ COMMITTED — это следующий уровень изоляции, который является компромиссом.

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

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

Он характеризуется тем, что данные каким-либо образом используемые в транзакции (чтение или изменение) становятся доступными другим транзакциям только после завершения первой транзакции. SERIALIZABLE — самый высокий уровень изоляции.

Откроем 2 окна терминала.
Для начала разберёмся есть ли изоляция операций в транзакции от основного потока.

Изоляции нет. Один поток видит, что делает второй открывший транзакцию.

Посмотрим видят ли транзакции разных потоков то, что происходит внутри них.

Откроем 2 окна терминала и откроем 2 транзакции параллельно.

Параллельные транзакции видят данные друг друга. Итак, мы получили самый простой, но и самый быстрый уровень изоляции READ UNCOMMITED.

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

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

Здесь нужно задуматься зачем вообще нужны уровни изоляции и как они работают.

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

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

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

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

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

Подробнее о двухфазном методе блокировок на русском и английском языках:

https://ru.wikipedia.org/wiki/Двухфазная_блокировка
https://en.wikipedia.org/wiki/Two-phase_locking

Как этого избежать? Сложность состоит в том, что во время транзакции состояние базы может быть несогласованным, однако эти несогласованные данные видны другим процессам.

И все обращения к таким окнам видимости согласованного состояния будет контролироваться блокировками. Мы сделаем с помощью блокировок такие окна видимости, в которых состояние базы будет согласовано.

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

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

  1. любой процесс, если данные свободны
  2. только тот процесс, который имеет на эти данные shared-блокировку и первый запросил эксклюзивную блокировку.

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

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

Это позволяет нам распараллелить работу вместо ожидания освобождения блокировки.

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

Соответственно нам придётся с помощью shared блокировок разрешить чтение данных только в моменты согласованности.

Допустим у нас есть база пользователей ^person, которые переводят друг другу деньги.

Момент перевода от персоны 123 к персоне 242:

LOCK +^person(123), +^person(242)
Set ^person(123, amount) = ^person(123, amount) - amount
Set ^person(242, amount) = ^person(242, amount) + amount
LOCK -^person(123), -^person(242)

Момент запроса количества денег у персоны 123 перед списанием должен сопровождаться эксклюзивной блокировкой (по умолчанию):

LOCK +^person(123)
Write ^person(123)

А если нужно показать состояние счёта в личном кабинете, то можно использовать shared блокировку или вообще её не использовать:

LOCK +^person(123)#”S”
Write ^person(123)

Однако, если допустить, что операции работы с БД выполняются практически мгновенно (напомню, что глобалы — это намного более низкоуровневая структура, чем реляционная таблица), то необходимость этого уровня падает.

REPEATABLE READ — в этом уровне изоляции допускается, что могут быть несколько чтений данных, которые могут быть изменены параллельными транзакциями.

Соответственно нам придётся ставить shared блокировку на чтение данных, которые мы меняем и эксклюзивные блокировки на данные которые мы меняем.

Благо оператор LOCK позволяет в одном операторе детально перечислить все необходимые блокировки, которых может быть очень много.

LOCK +^person(123, amount)#”S”
чтение ^person(123, amount)

другие операции (в это время параллельные потоки пытаются изменить ^person(123, amount), но не могут)

LOCK +^person(123, amount)
изменение ^person(123, amount)
LOCK -^person(123, amount) чтение ^person(123, amount)
LOCK -^person(123, amount)#”S”

При перечислении блокировок через запятую они берутся последовательно, а если сделать так:

LOCK +(^person(123),^person(242))

то они берутся атомарно все сразу.

Для этого подхода большинство блокировок должны быть эксклюзивными и браться на самые маленькие области глобала для производительности. SERIALIZE — нам придётся выставить блокировки так, чтобы в конечном итоге все транзакции, которые имеют общие данные выполнялись последовательно.

Если же говорить о списаниях средств в глобале ^person, то для него приемлем только уровень изоляции SERIALIZE, так как деньги должны тратиться строго последовательно, иначе возможно потратить одну и ту же сумму несколько раз.

4. Долговременность

Я проводил тесты с жёстким вырубанием контейнера посредством

docker kill my-iris

База их переносила хорошо. Проблем не было выявлено.

Заключение.

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

Уровень изоляции у глобалов без использования блокировок — это READ UNCOMMITED, а при использовании блокировок можно его обеспечить вплоть до уровня SERIALIZE.

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

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»