Иван Бодягин
Введение
Очередную версию СУБД Microsoft SQL Server, являющейся одним из лидеров рынка, ждали довольно долго и, похоже, не зря. В этом продукте только список нововведений будет достаточно увесистым, а уж полное описание новых возможностей потянет на целую книгу. На данный момент доступна лишь альфа-версия продукта, а релиз ожидается примерно через год, но, тем не менее, уже по этой версии можно понять, что нас ожидает в будущем. В данной статье описывается только одно, но достаточно серьезное нововведение, а именно поддержка версионности. Эту функциональность попытались встроить в классический блокирующий сервер (далее – блокировочник), и очень интересно посмотреть, что же из этого получилось.
Общие принципы
Параллельное выполнение транзакций способно привести базу в несогласованное состояние даже в тех случаях, когда каждая транзакция, выполняющаяся отдельно от других, производит полностью корректные изменения. Поэтому очередность операций различных транзакций должна тем или иным образом регулироваться.
Во всех предыдущих версиях Microsoft SQL Server механизм подобной регулировки был основан на блокировках. Однако в новой версии (кодовое название Yukon) будет введена поддержка другого механизма, основанного на контроле версий (multiversioning). В дальнейшем два этих подхода я буду называть версионным и блокировочным соответственно.
Версионность сама по себе, не является новым словом в способах обеспечения корректности параллельной обработки транзакций, теоретические работы были еще в начале восьмидесятых, да и появление первых коммерческих реализаций относится примерно к тому же времени. Однако до последнего момента разработчики Microsoft SQL Server последовательно совершенствовали блокировочный механизм, получив в итоге одну из самых удачных реализаций классического блокировочника в индустрии, а вот теперь, похоже, дошли руки и до версионности. Привлекательность данного механизма заключается в том, что читающие запросы никак не мешают пишущим, и наоборот. Но ничто не дается даром. Впрочем, обо всем по порядку.
От механизма параллельной обработки транзакций формальная теория требует одного: чтобы конечный эффект от параллельного выполнения был таким, как будто бы транзакции выполнялись последовательно, при этом порядок их следования фактически не важен. При соблюдении этого условия, если все транзакции сами по себе корректны, то и их параллельное выполнение целостность базы никоим образом не нарушит. Данное условие носит название «критерия упорядоченности».
Способов соблюдения вышеупомянутого критерия существует достаточно много. Помимо уже упоминавшихся механизмов, основанных на блокировках и хранении версий, существует еще чуть ли не десяток других. Более того, существует и формальное доказательство того, что этому критерию можно следовать, используя и комбинированные подходы, применяя разные механизмы для разных типов запросов. Опять-таки известны и весьма успешные практические реализации подобных гибридов.
Критерий упорядоченности всем хорош, кроме одного – строгое следование ему слишком дорого обходится с точки зрения производительности. Но, поскольку данный критерий является достаточным, но не необходимым условием корректности параллельной обработки транзакций, то в зависимости от характера транзакций можно повысить степень их вмешательства в работу друг друга без печальных последствий. Чтобы хоть как-то формализовать эти вмешательства, были введены так называемые «уровни изоляции» (Isolation Level).
Если по-простому, то «уровень изоляции» - это степень параллелизма транзакций. В стандарте ANSI SQL уровни изоляции введены посредством феноменов – нежелательных побочных эффектов от излишнего параллелизма, таким образом, что каждый более высокий (более строгий) уровень изоляции устраняет очередной феномен, а также не допускает проявления феноменов, уже устраненных более низким (менее строгим) уровнем.
В стандарте описаны четыре уровня изоляции:
read uncommitted – чтение незафиксированных («грязных») данных. Это самый низкий уровень изоляции. Он лишь гарантирует, что не произойдет феномена «грязной записи» (Dirty Write). Но при этом уровне изоляции вполне возможен феномен «грязное чтение» (Dirty Read). Допустим, первая транзакция записала какие-то данные в X и не зафиксировалась, то есть данные в X не зафиксированы. Если другая транзакция эти данные прочитает, а потом первая будет отменена по какой-то причине, то получится, что вторая транзакция прочитала данные, которые никогда не существовали.
read committed - чтение зафиксированных данных. Гарантируется, что происходит чтение только зафиксированных данных. То есть такого безобразия, как в предыдущем примере, не произойдет. Но зато, если одной транзакции потребуется два раза прочитать некие данные, и между двумя чтениями вклинится другая транзакция, которая успеет эти данные поменять или вставить новые, и зафиксироваться, то два чтения одних и тех же данных в одной транзакции будут отличаться.
repeatable read. Проблему двух последовательных чтений одних и тех же данных этот уровень изоляции решает. Однако если между двумя чтениями в базу добавились новые данные, удовлетворяющие тому же критерию, по которому производилось первое чтение, то эти новые данные во второй раз так же прочитаются. Данный феномен носит название «Фантомное чтение» (Phantom read).
serializability. Самый высокий уровень изоляции. Он не опирается ни на какие феномены, а напрямую формулируется из критерия упорядоченности. То есть при данном уровне изоляции никакие феномены не возможны по определению, так как феномен – это нарушение последовательного выполнения транзакций, что в данном случае невозможно.
Однако за подобную классификацию стандарт подвергался неоднократной и, в общем-то, справедливой критике. Дело в том, что в данную градацию идеально вписываются только чистые блокировочники, но если применяется немного другой способ обеспечения параллелизма, то его уже достаточно проблематично свести к этим четырем уровням, да и не всегда нужно. Другие механизмы обеспечения параллелизма могут допускать другие феномены, быть чуть строже или чуть слабее. По большому счету, неизменным остается лишь одно требование – гарантия упорядоченности, все остальное – серые зоны с нечеткими границами, которые очень сильно зависят от деталей конкретной реализации.
Разберем сначала вкратце классический блокировочник, а затем рассмотрим, что принесла версионность.
Блокировочный механизм
Принцип действия, в общем-то, ясен из названия - в основе лежит протокол двухфазной блокировки. Перед чтением или изменением объект (запись) блокируется. То есть другим транзакциям запрещается изменять или даже читать этот объект до тех пор, пока первая транзакция не закончит с ним работать.
Уровень изоляции read committed обеспечивается за счет того, что читающие запросы в транзакциях не удерживают своих блокировок до конца транзакции, а снимают их сразу же, после прочтения. Таким образом, если read committed-транзакция дважды прочитает один и тот же объект, то его значение может отличаться, так как ничто не помешает другой транзакции изменить его в промежутке между двумя чтениями.
При уровне изоляции repeatable read читающие запросы удерживают свои блокировки до конца транзакции, но они блокируют множество реальных записей, существующих на начало транзакции, а не записей, отвечающих условию выборки, которые могут появиться во время жизни транзакции. Например, если выбрать все записи, где x=2, то на всех этих записях будет удерживаться блокировка и поменять их будет нельзя. Но ничто не помешает добавить в другой транзакции еще несколько записей с x=2, и вторая выборка записей с этим же условием в первой транзакции вернет, в том числе, и эти добавленные записи.
Уровень изоляции serializable обеспечивается наложением так называемых предикатных блокировок. Это означает, что блокировка накладывается не только на объект, но и на условие. Если брать предыдущий пример, то мы не сможем добавить запись с x=2 в другой транзакции, если первая сделала выборку по этому условию, так как само условие x=2 оказалось заблокированным. Таким образом, даже повторное чтение любых данных в первой транзакции всегда будет возвращать один и тот же результат.
Более подробно о блокировках можно прочитать в статье «Механизм блокировок Microsoft SQL Server 2000» в третьем номере RSDN Magazine за 2003 год. Сейчас больший интерес представляет версионный механизм.
Принцип действия версионности основан на том, что транзакция, изменяя данные, порождает новую копию (версию) данных, с которой и работает. Другим транзакциям эта версия не видна, до тех пор, пока первая не зафиксируется. При этом даже после фиксации первой транзакции, устаревшая версия какое-то время сохраняется для корректной работы транзакций, стартовавших до завершения работы первой, но еще не успевших зафиксироваться.
Для читающих запросов все работает очень красиво и эффективно. Они просто получают согласованный срез данных на момент начала транзакции или запроса, но для пишущих запросов и транзакций все не так просто. Если две транзакции решат изменить один и тот же объект, то возникнет конфликт версий. Побеждает та транзакция, которая успела первой, а опоздавшую, как правило, приходится откатывать. С точки зрения производительности откат довольно-таки дорогая операция, к тому же приходится в обязательном порядке предусматривать обработку подобного конфликта. Если в чистом блокировочнике откат транзакции явное следствие ошибки, то в версионнике откат может произойти во вполне невинной ситуации.
Что касается уровней изоляции в версионной модели, то они могут трансформироваться примерно в следующее.
Read uncommitted. В чистом версионнике обычно не реализован, так как «грязные» данные незафиксированных транзакций другим транзакциям не видны, да и не зачем.