В приложениях баз данных блокировка применяется для того, чтобы не дать пользователям делать то, чего они делать не должны. Блокировку данных можно сравнить с запиранием сейфа. Но если сейф обычно запирают для предотвращения потенциального и почти наверняка умышленного преступления, то блокировка строк и таблиц базы данных осуществляется для предотвращения случайной потери данных или нарушения их целостности. В однопользовательском приложении, в котором в каждый момент времени к базе данных подключен только один пользователь, традиционная блокировка неуместна. Но как только мы начинаем поддерживать множество пользователей, возникает вероятность ошибок, которые можно предотвратить путем использования соответствующих стратегий блокировки.
Слово "предотвратить" мы употребили не случайно. Замок на кассе может и не отпугнуть решительного взломщика, а блокировка на базе данных будет соблюдаться базой данных, пока эта блокировка остается в силе.
Стратегии блокировки
Разрабатывая план книги, мы рассчитывали написать большую главу о блокировке. Когда же дело дошло до реализации нашего намерения, это глава оказалась довольно маленькой. Это объясняется тем, что стандартная стратегия блокировки Oracle практически во всех случаях дает нужный результат. Однако для обеспечения нормальной производительности в многопользовательском режиме необходимо помнить два простых правила: блокировать мало и блокировать позже. Эти правила противоречат тенденции ранней блокировки, характерной для большинства средств разработки, особенно для Oracle Forms. Смысл этих правил мы разъясним позже.
Блокировки не дают пользователям выполнять те или иные операции, и это им не всегда нравится. Конечно, пользователи хотят, чтобы им дали возможность заниматься своим законным делом и не указывали, что нельзя например, изменять данные о кредитоспособности покупателя, так как для него в текущий момент вводится заказ. Действительно, специалисты отдела кредитного контроля вполне могут полагать, что если для данного покупателя вводится заказ, а его кредит необходимо уменьшить, то их обновления имеет большее право на немедленное выполнение, чем обычно.
Главный вопрос здесь — почему экранная форма ввода заказа прежде всего блокировала запись об этом покупателе? Большинство разработчиков (по крайне мере те, кто окончил курсы Oracle) дают такой ответ: если вы выбираете строку и планируете обновить ее (или хотя бы думаете, что можете обновить ее), то строку нужно выбрать с помощью операции FOR UPDATE и установить блокировку на уровне строк. Это гарантирует, что строка останется неизменной на протяжении всей транзакции и что в силу этого спокойно можно использовать выбранные значения как основу для обновления.
Если же требуется лишь обновить остаток, то совершенно не нужно явно блокировать запись. Остаток можно обновить с помощью такого предложения:
UPDATE customers
SET account_balance = account_balance - :this_amount
, latest_trans_id = :this_trans_id
WHERE cust_id = :this_cust;
Конечно, как только программа выдаст это предложение, Oracle установит блокировку на уровне строк, которая будет действовать до конца транзакции. Но мы, по крайней мере, оттянули установку этой блокировки до самого последнего момента (вот что мы имели в виду, формулируя правило "блокировать позже"). По умолчанию Oracle Forms блокирует строки, как только пользователь вводит изменение, даже несмотря на то, что это может быть за несколько минут до фиксации этих изменений пользователем. Мы не рекомендуем этот метод, предпочитая устанавливать блокировку в самый последний момент, а затем проверять, чтобы эта запись (строка) за прошедшее время не изменилась.
Поздняя блокировка
Простейший способ проконтролировать, изменилась ли строка после того, как ее выбрали, — использовать номера ее версий. В каждую таблицу, которую вы хотите заблокировать позже, можно ввести столбец, значение которого по умолчанию при вставке строки равно нулю, а при каждом обновлении увеличивается на единицу. Если назвать этот столбец ROW_VERSION и запомнить его значение при первом отображении данных строки, то можно снабдить наше предложение UPDATE дополнительным условием:
UPDATE customers
SЕТ account_balance = account_balance - :this_amount
, latest_trans_id = :this_trans_id
WHERE cust_id = :this cust
AND row_version = :version;
Если это предложение не возвратит обработанную строку, мы будем знать, что она за этот промежуток времени была удалена или обновлена. В любом случае перед продолжением работы мы должны выдать повторный запрос, а приложение должно обработать его.
Методы, при которых блокировка производится поздно, а не рано, и которые вследствие этого создают риск ошибки, обычно называют оптимистической блокировкой. Мы предпочитаем называть эти методы оптимистической неблокировкой, чтобы подчеркнуть их преимущества. Хотя, честно говоря, Oracle все равно в конце концов установит блокировку независимо от того, хотим мы этого или нет.
Использование сегментов отката транзакции
Стандартная стратегия блокировки Oracle определяется следующей интригующей формулировкой:
"Чтение не блокирует запись, а запись не блокирует чтение".
Это действительно так, но за это приходится дорого платить. В Oracle нормальным явлением являются зафиксированные изменения, внесенные с момента начала вашей транзакции. Конечно, можно воспользоваться командой SQL:
SET TRANSACTION READ ONLY;
чтобы установить точку согласованности по чтению, остающуюся неизменной до фиксации или отката транзакции. Это существенно, если вы собираетесь работать со структурой данных типа главная-подчиненная, используя вложенные запросы, а не соединение. Почему? Да потому, что в противном случае вы в конечном итоге получите несогласованные результаты, поскольку запрос к главной таблице и запрос к подчиненной таблице будут иметь разные точки согласованности по чтению.
Чем дольше длиться транзакция, для которой задано READ ONLY, тем вероятнее, что она столкнется с ошибкой SNAPSHOT TOO OLD. Это означает, что у Oracle в сегменте отката транзакции больше нет информации, необходимой для восстановления того или иного блока в состояние, в котором он находился в точке согласованности по чтению. В этот момент ваш запрос "умирает", и это может случиться с любым долго выполняющимся запросом. Сферу влияния этой проблемы помогает сузить увеличение сегментов отката. Однако при этом гарантировать, что длительный запрос не столкнется с этой проблемой, можно лишь путем блокировки DML-операций для этой таблицы, т.е. путем установки разделяемой блокировки для таблицы до начала выполнения запроса. Такая мера относится к радикальным, и это — еще одна причина того, почему длительные запросы могут лучше поддерживаться копией базы данных, не подверженной непрерывному обновлению.
Внешние ключи
Ограничения по внешнему ключу оказывают неожиданное влияние на стандартную стратегию блокировки Oracle. Если создать простую таблицу, например, с помощью такого предложения:
CREATE TABLE emp_assigns
( emp# REFERENCES employees
, proj# REFERENCES projects
, PRIMARY KEY (emp#, proj#)
);
то вы будете очень удивлены, обнаружив, что при выполнении DML-операций в таблице EMP_ASSIGNS транзакция, в которой выдаются эти DML-операции, устанавливает разделяемую блокировку на таблице PROJECTS (но не на таблице EMPLOYEES, как в более поздних версиях). Если таблица PROJECTS изменяется очень редко, это вряд ли будет серьезной проблемой. Если же обе таблицы подвержены изменениям, то разделяемые блокировки могут существенно снизить производительность.
Эту аномалию вызывает отсутствие индекса для таблицы EMP_ASSIGNS, который в качестве лидирующей части имеет PROJ#. Суть этой проблемы состоит в том, что когда пользователь пытается изменить или удалить первичный ключ в таблице PROJECTS, то эта операция разрешена только в случае, если в таблице EMP_ASSIGNS нет ссылок на этот ключ. При помощи индекса (и нетранзакционной внутренней блокировки) Oracle может быстро и легко проверить допустимость данной операции. Если же такого индекса нет, то Oracle приходится сканировать таблицу EMP_ASSIGNS и искать ссылку (что может потребовать довольно много времени). Oracle делает это с помощью рекурсивного SQL, тогда как индексная операция выполняется посредством внутренних вызовов в базе данных. Этот рекурсивный запрос должен обеспечивать согласованность по чтению и не может видеть незафиксированные изменения, внесенные в подчиненную таблицу. Даже если бы он мог их видеть, имела бы место неоднозначность, поскольку они не зафиксированы. Проектировщики Oracle решили устранить эту проблему, предотвратив ее, — разделяемая блокировка означает, что вы не сможете вносить изменения в главную таблицу, пока в подчиненной таблице есть незафиксированные транзакции. Если же у вас есть индекс для внешнего ключа в подчиненной таблице, это ограничение не действует.
К сведению
Каждый внешний ключ, который ссылается на таблицу, подверженную изменениям, должен индексироваться отдельно или находиться в лидирующей части индекса.
Взаимоблокировки
Рано или поздно любое сложное многопользовательское приложение сталкивается со взаимоблокировкой. Хотя в Oracle есть исключительно хорошие средства обнаружения взаимоблокировок, разрешение взаимоблокировок полностью возлагается на пользователя. Проектировщик и программист могут либо разрешить эту проблему в приложении (это правильный подход), либо оставить ее конечному пользователю (что связано с неприятностями). Проблема заключается в том, что когда Oracle генерирует ошибку DEADLOCK DETECTED, единственно возможное действие — выполнить откат текущей транзакции и повторить попытку. Крайняя мера — задержать повторение попытки на несколько секунд. Если взаимоблокировка встречается в одной транзакции более чем, скажем, три раза, мы рекомендуем, чтобы приложение сообщало об этом как о фатальной ошибке.
Одно время внутренние стандарты на проектирование Oracle предусматривали установку стандартного порядка блокировки, который, как утверждалось, препятствовал бы возникновению взаимоблокировок. Это отличный принцип, но на практике он по ряду причин просто-напросто не работает:
1. Может быть, очень трудно написать код, обеспечивающий блокировку в установленном порядке, а код, который трудно писать, обычно дорого обходится и сложен в сопровождении.
2. В некоторых случаях, например в многострочных формах, порядок обновления, по сути дела, контролируется пользователем. Эту проблему можно обойти программно, но это достаточно утомительно.
3. Иногда Oracle сталкивается со "слабыми" взаимоблокировками, которые являются результатом соперничества за внутренние ресурсы. Одна из причин возникновения такой ситуации рассмотрена в главе 14.
К сведению
Рекомендуем, чтобы все фрагменты, в которых выполняется обновление, содержали простой код, необходимый для проверки наличия взаимоблокировок и немедленного отката транзакции. В зависимости от обстоятельств, можно выдавать сообщение об ошибке или повторять попытку выполнения транзакции установленное число раз, прежде чем обнаружится, что все плохо и требуется сообщение об ошибке.
К сожалению, действие механизма обнаружения взаимоблокировок в Oracle не распространяется на распределенные взаимоблокировки. Вместо этого используется параметр экземпляра DISTRIBUTED_LOCK_TIMEOUT. Он указывает серверу Oracle, как долго он должен ждать ответа другого сервера на запрос блокировки перед тем, как просто "сдаться" и возвратить пользователю ошибку. Важно, чтобы весь код, который может понадобиться для установки распределенных блокировок, тоже учитывал эту "ошибку".
Сериализация
По умолчанию Oracle не обеспечивает сериализацию, т.е. не может гарантировать, что при выдаче одних и тех же транзакций в одном и том же порядке по одному и тому же начальному содержимому данных всегда будет получен один и тот же ответ. Описание причин этого удивительного явления выходит за рамки нашей книги. Тем не менее, вам придется решать эту проблему, если вы по какой-то причине попробуете построить логику восстановления или перезапуска базы данных на повторном применении транзакций, а не на журналах Oracle. Наш опыт показывает, что проектировщики, избирающие этот путь, неизбежно терпят неудачу, отчасти из-за проблемы с сериализацией.
Если же вам ну очень нужно воспользоваться этим методом (а мы советуем сделать почти все, чтобы избежать встречи с этой проблемой), то необходимо будет работать с параметром SERIALIZABLE=TRUE файла INIT.ORA и, вопреки нашему предыдущему совету, блокировать много и блокировать раньше. Каждый запрос перед началом выполнения будет устанавливать разделяемую блокировку на уровне таблицы. В версии 7.3 этот уровень изоляции можно установить на уровне транзакций, но с другим эффектом. Вместо того чтобы устанавливать разделяемые блокировки на уровне таблиц, не позволяющие другим пользователям обновлять таблицы, которые вы запрашиваете, запрос, выдавший команду
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
закончится неудачей с выдачей соответствующей ошибки, если посетит строку в блоке, который был изменен с момента начала транзакции. Наверняка это не то, что вам нужно.
Другие вопросы проектирования
В этом разделе содержится информация еще о некоторых проблемах, связанных с блокировкой, которые могут повлиять на ход проектирования.
Пользовательские блокировки
Процедурная опция Oracle включает мало используемый, но исключительно полезный пакет DBMS_LOCK, позволяющий проектировщику реализовать стратегию кооперативной блокировки, которая может быть основана на чем угодно. С помощью этого пакета процессы могут соперничать за блокировки на 32-разрядных числах, значения которым присваивает приложение. Важная особенность этих блокировок заключается в том, что они не обязательно должны быть транзакционными, т.е. они могут сохраняться после фиксации, тогда как все установленные сеансом пользовательские блокировки после его завершения снимаются.
Пользовательские блокировки — эффективный способ резервирования ресурсов приложением. Мы применяли их для резервирования первичного ключа для последующего использования в транзакции, а также в качестве сигнала о том, что определенную таблицу нельзя использовать, потому что происходит реорганизация первичного ключа (охватывающая несколько транзакций).
Эффективность блокировок: очистка блоков и блокировки на уровне таблиц
Когда пользователь устанавливает блокировку на уровне строк в таблице Oracle, в заголовке блока этот факт отражается посредством создания ITE (interested transaction entry — записи о заинтересованной транзакции). Одна ITE в заголовке блока позволяет пользователю заблокировать любое число строк в этом блоке, и для этой цели в заголовке каждого блока по умолчанию имеется одна ITE (INITRANS 1). Если один пользователь уже заблокировал в блоке какую-то строку, а другой пользователь хочет заблокировать в нем другую строку, то последний сможет продолжать работу только в том случае, если в блоке осталось достаточно места для создания второй ITE. В противном случае этому пользователю придется подождать, пока завершится первая транзакция.
Впрочем, настоящие проблемы начинаются при фиксации, потому что в версиях до 7.3 блокировки не удаляются, а просто остаются на месте. Даже в версии 7.3 блокировки удаляются только в том случае, если содержащие их блоки все еще находятся в SGA, а параметр DELAYED_LOGGING_BLOCK_CLEANOUTS в файле INIT.ORA установлен в значение FALSE. В оперативном режиме блокировки, как правило, остаются в SGA, но при выполнении длинных пакетных программ это менее вероятно. Пользователь, который будет следующим по какой-то причине посещать данный блок, понесет затраты на проверку блокировки на предмет того, действует ли она еще, и ее удалению, если она действует. На узлах, где ресурсы ЦП оплачивает пользователь, эта ситуация может вызвать возмущение, поскольку пользователям, выдающим нерегламентированные запросы, придется в конечном итоге оплачивать счета за очистку блоков.
Предупреждение
Очистка блоков замедляет первое обращение к данным после DML-операций над этими блоками. В этом причина многих неверных выводов о производительности Oracle, которые делаются в случае, когда "специалист по производительности" загрузит какие-то данные, а затем пробует получить к ним доступ двумя разными методами. Первый метод наверняка будет медленнее второго — просто из-за затрат, связанных с очисткой блоков.
Мы видели, как проектировщики пытаются избежать эффектов очистки блоков с помощью блокировки на уровне таблиц, а не на уровне строк. Поверьте, эксклюзивная блокировка таблицы ничего не даст. При вставке или обновлении вы все равно помещаете запись о блокировке на уровне строк в заголовок блока, и следующему SQL-предложению, которое будет посещать этот блок после вашей фиксации, все равно придется убирать его. Такова жизнь. Может показаться, что блокировка на уровне таблиц должна потреблять меньше ресурсов, чем блокировка на уровне строк, но это совсем не так.
Если в приложении есть относительно "короткая" таблица, для которой устанавливается много блокировок на уровне строк (например, таблица BRANCHES, в которой ведется остаток), то, возможно, стоит установить значение параметра INITRANS больше 1, чтобы пользователям не приходилось ждать только из-за того, что недостаточно места для записи ITE. Длина ITE превышает 20 байт, поэтому двузначные значения будут весьма необычным явлением. Очень немногим приложениям нужно устанавливать этот параметр, а потребности тех, которым это действительно нужно, можно более эффективно удовлетворить с помощью решения, описанного в следующем абзаце.
Потребность в сопровождении остатков — довольно распространенная причина соперничества за блокировки. Одно эффективное, хотя и весьма искусственное, решение состоит в том, чтобы сопровождать множество подостатков каждого основного остатка. Допустим, мы хотим вести текущую сумму общей стоимости заказов, сделанных в большой оперативной системе. По нашим расчетам строка, в которой хранится эта сумма, быстро должна стать объектом очереди на блокировку. Однако мы можем иметь десять таких строк и использовать некий метод, при помощи которого каждый процесс будет решать, какую строку обновлять:
UPDATE order_balls
SET total_value = total_value + :this_order_total
, total_orders = total_orders + 1
WHERE sub_balance_id = MOD(uid, 10);
Единственный недостаток этого подхода состоит в том, что для выборки остатков нам приходится создать операцию подведения итога, но это можно инкапсулировать в представлении:
CREATE OR REPLACE VIEW order_balances (total_value, total_orders) AS
SELECT SUM(total_value)
, SUM(total_orders)
FROM order_balls;
Этот пример специально сильно упрощен — в реальном приложении мы рассчитывали бы на применение к этим остаткам некоторой формы действительности по дате. Во избежание ошибок вполне можно было бы лишить привилегии SELECT на этой таблице всех пользователей, выдающих нерегламентированные запросы. Это даст гарантию, что они будут обращаться к представлению и не столкнутся с подостатками.
Отметим, что таблицы (и индексы), в которых многим сеансам одновременно может понадобиться обновлять элементы строк или индексов, как правило, страдают от конкуренции на уровне блоков (ожидания освобождения буфера на жаргоне настройщиков Oracle). Конкуренция на уровне блоков встречается, главным образом, на многопроцессорных платформах. Если таблица действительно "короткая", конкуренцию можно легко устранить, вынудив СУБД помещать в каждый блок только одну строку. Для этого параметру PCTFREE присваивают искусственно большое значение, например 95. При этом напрасно расходуется дисковое пространство и в самом деле потребляется больше кэш-пространства в SGA, чем во всех остальных случаях, однако для таблицы в несколько десятков строк эта трата ничтожна по сравнению с выгодой, которую дает устранение конкуренции на блочном уровне для пропускной способности.
Примечание
Возможно, вам интересно, почему блочная конкуренция может отрицательно влиять на пропускную способность многопроцессорной платформы, а в однопроцессорной системе потенциальной проблемой не считается. Ответ на этот вопрос заключается в подходе Oracle к обращениям к блокам базы данных. Поскольку обращение любого типа может привести к внесению изменения в блок, Oracle устанавливает на каждый блок базы данных кратковременную блокировку (pin), которая действует только в течение обращения к этому блоку из СУБД. Oracle также предусматривает отказ от услуг ЦП, как только встречается состояние ожидания, и гарантирует невозможность "добровольного" ожидания во время существования кратковременной блокировки. Поэтому даже если в однопроцессорной системе два процесса запрашивают или обновляют один и тот же блок, они не должны видеть кратковременные блокировки друг друга.
Однако в многопроцессорной среде, особенно с симметричной многопроцессорной структурой, где процессоры могут работать независимо, существует статистическая вероятность того, что СУБД Oracle, работающая на одном процессоре, вступит в конфликт с кратковременной блокировкой, установленной СУБД Oracle, работающей на другом процессоре. Чем чаще производится обращение к данному блоку, тем выше вероятность такого конфликта.