Хранимая процедура базы данных с «режимом предварительного просмотра»

15

Довольно распространенным шаблоном в приложении базы данных, с которым я работаю, является необходимость создания хранимой процедуры для отчета или утилиты, которая имеет «режим предварительного просмотра». Когда такая процедура выполняет обновления, этот параметр указывает, что результаты действия должны быть возвращены, но процедура на самом деле не должна выполнять обновления базы данных.

Один из способов сделать это - просто написать ifоператор для параметра и получить два полных блока кода; один из которых обновляет и возвращает данные, а другой просто возвращает данные. Но это нежелательно из-за дублирования кода и относительно низкой степени уверенности в том, что данные предварительного просмотра на самом деле являются точным отражением того, что произойдет с обновлением.

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

Примечание. Откат транзакции не является опцией, поскольку сам вызов этой процедуры может быть вложен в транзакцию. Это проверено на SQL Server 2012.

CREATE TABLE dbo.user_table (a int);
GO

CREATE PROCEDURE [dbo].[PREVIEW_EXAMPLE] (
  @preview char(1) = 'Y'
) AS

CREATE TABLE #dataset_to_return (a int);

BEGIN TRANSACTION; -- preview mode required infrastructure
  DECLARE @output_to_return TABLE (a int);
  SAVE TRANSACTION savepoint;

  -- do stuff here
  INSERT INTO dbo.user_table (a)
    OUTPUT inserted.a INTO @output_to_return (a)
    VALUES (42);

  -- catch preview mode
  IF @preview = 'Y'
    ROLLBACK TRANSACTION savepoint;

  -- save output to temp table if used for return data
  INSERT INTO #dataset_to_return (a)
  SELECT a FROM @output_to_return;
COMMIT TRANSACTION;

SELECT a AS proc_return_data FROM #dataset_to_return;
RETURN 0;
GO

-- Examples
EXEC dbo.PREVIEW_EXAMPLE @preview = 'Y';
SELECT a AS user_table_after_preview_mode FROM user_table;

EXEC dbo.PREVIEW_EXAMPLE @preview = 'N';
SELECT a AS user_table_after_live_mode FROM user_table;

-- Cleanup
DROP TABLE dbo.user_table;
DROP PROCEDURE dbo.PREVIEW_EXAMPLE;
GO

Я ищу отзывы об этом коде и шаблоне проектирования, и / или если другие решения одной и той же проблемы существуют в разных форматах.

NReilingh
источник

Ответы:

12

У этого подхода есть несколько недостатков:

  1. Термин «предварительный просмотр» в большинстве случаев может вводить в заблуждение, в зависимости от характера обрабатываемых данных (который меняется от операции к операции). Что нужно для того, чтобы текущие данные были в том же состоянии между моментом сбора данных «предварительного просмотра» и когда пользователь возвращается через 15 минут - после того, как взял немного кофе, вышел на улицу, чтобы покурить, прогуляться вокруг блока, возвращаясь и проверяя что-то на eBay - и понимает, что они не нажимали кнопку «ОК», чтобы фактически выполнить операцию, и, наконец, нажимают кнопку?

    У вас есть лимит времени на выполнение операции после создания предварительного просмотра? Или, возможно, способ определить, что данные находятся в том же состоянии во время изменения, что и в начальный SELECTмомент?

  2. Это второстепенный момент, поскольку пример кода можно было сделать поспешно и не представлять истинный вариант использования, но почему для INSERTоперации должен быть «Предварительный просмотр» ? Это может иметь смысл при вставке нескольких строк с помощью чего-то подобного, INSERT...SELECTи может быть вставлено переменное число строк, но это не имеет особого смысла для одноэтапной операции.

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

    Откуда именно эта «низкая степень доверия»? Хотя можно обновить иное количество строк, чем показано для, SELECTкогда объединены несколько таблиц и имеется дублирование строк в наборе результатов, это не должно быть проблемой здесь. Любые строки, на которые должен воздействовать объект, UPDATEможно выбирать самостоятельно. Если есть несоответствие, то вы делаете запрос неправильно.

    И те ситуации, в которых есть дублирование из-за таблицы JOINed, которая соответствует нескольким строкам в таблице, которая будет обновлена, не являются ситуациями, когда генерируется «Предварительный просмотр». И если есть случай, когда это имеет место, то пользователю необходимо объяснить, что он обновляет подмножество отчета, который повторяется в отчете, чтобы это не выглядело как ошибка, если кто-то только глядя на количество затронутых рядов.

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

    Должны ли мы обрабатывать транзакции в коде C #, а также в хранимых процедурах

  5. ДАЖЕ ЕСЛИ проблемы, о которых говорилось выше, были учтены, все еще существует критический недостаток: в течение короткого периода времени, когда выполняется операция (т. Е. До ROLLBACK), любые грязные запросы (запросы с использованием WITH (NOLOCK)или SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED) могут захватывать данные, которые нет там ни минуты спустя. В то время как любой, кто использует грязные запросы на чтение, должен уже знать об этом и принять такую ​​возможность, такие операции, как это, значительно увеличивают вероятность введения аномалий данных, которые очень трудно отлаживать (то есть: сколько времени вы хотите потратить, пытаясь найти проблему, которая не имеет явной прямой причины?).

  6. Подобный шаблон также снижает производительность системы, увеличивая блокировку, снимая больше блокировок и создавая больше активности в журнале транзакций. (Теперь я вижу, что @MartinSmith также упомянул эти 2 проблемы в комментарии к Вопросу.)

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

  7. В связи с тем, что было отмечено выше - усиление блокировок - использование Транзакции увеличивает вероятность возникновения тупиковых ситуаций, особенно если задействованы триггеры.

  8. Менее серьезная проблема, которая должна относиться только к менее вероятному сценарию INSERTопераций: данные «Предварительный просмотр» могут не совпадать с данными, вставляемыми в отношении значений столбцов, определенных DEFAULTОграничениями ( Sequences/ NEWID()/ NEWSEQUENTIALID()) и IDENTITY.

  9. Нет необходимости в дополнительных затратах на запись содержимого переменной таблицы во временную таблицу. Это ROLLBACKне повлияет на данные в табличной переменной (именно поэтому вы сказали, что в первую очередь используете табличные переменные), так что было бы более целесообразно просто SELECT FROM @output_to_return;в конце, а потом даже не беспокоиться о создании временного Стол.

  10. На всякий случай, если этот нюанс точек сохранения неизвестен (это трудно понять из примера кода, поскольку он показывает только одну хранимую процедуру): вам нужно использовать уникальные имена точек сохранения, чтобы ROLLBACK {save_point_name}операция работала так, как вы ожидаете. Если вы повторно используете имена, ROLLBACK откатит самую последнюю точку сохранения этого имени, которая может быть не на том же уровне вложенности, откуда ROLLBACKвызывается. Посмотрите первый пример блока кода в следующем ответе, чтобы увидеть это поведение в действии: Транзакция в хранимой процедуре

Это сводится к тому, что:

  • Выполнение «Предварительного просмотра» не имеет большого смысла для операций, ориентированных на пользователя. Я делаю это часто для операций обслуживания, чтобы я мог видеть, что будет удалено / Сборка мусора, если я продолжу операцию. Я добавляю необязательный параметр с именем @TestModeи делаю IFоператор, который либо делает, а SELECTкогда @TestMode = 1это делает DELETE. Иногда я добавляю @TestModeпараметр в хранимые процедуры, вызываемые приложением, чтобы я (и другие) могли выполнять простое тестирование, не влияя на состояние данных, но этот параметр никогда не используется приложением.

  • На всякий случай это было не понятно из верхнего раздела «Проблемы»:

    Если вам нужен / нужен режим «Предварительный просмотр» / «Тест», чтобы увидеть, что должно быть затронуто, если должен выполняться оператор DML, НЕ используйте транзакции (то есть BEGIN TRAN...ROLLBACKшаблон) для выполнения этого. Это шаблон, который в лучшем случае действительно работает только в однопользовательской системе, и в этой ситуации это даже не очень хорошая идея.

  • Повторение большей части запроса между двумя ветвями IFоператора представляет потенциальную проблему необходимости обновления их обоих каждый раз, когда необходимо внести изменения. Тем не менее, различия между этими двумя запросами, как правило, достаточно просты, чтобы их можно было обнаружить в обзоре кода, и их легко исправить. С другой стороны, такие проблемы, как различия в состоянии и грязное чтение, найти и исправить гораздо сложнее. И проблему снижения производительности системы невозможно решить. Мы должны признать и принять, что SQL не является объектно-ориентированным языком, а инкапсуляция / сокращение дублированного кода не была целью разработки SQL, как это было со многими другими языками.

    Если запрос достаточно длинный / сложный, вы можете инкапсулировать его во встроенную табличную функцию. Затем вы можете сделать простой SELECT * FROM dbo.MyTVF(params);для режима «Предварительный просмотр» и присоединиться к значению (ям) ключа для режима «сделать это». Например:

    UPDATE tab
    SET    tab.Col2 = tvf.ColB
           ...
    FROM   dbo.Table tab
    INNER JOIN dbo.MyTVF(params) tvf
            ON tvf.ColA = tab.Col1;
  • Если это сценарий отчета, как вы упомянули, это может быть, тогда запуск исходного отчета - «Предварительный просмотр». Если кто-то хочет изменить то, что он видит в отчете (возможно, статус), то это не требует дополнительного предварительного просмотра, поскольку ожидается изменение отображаемых в данный момент данных.

    Если операция заключается в том, чтобы, возможно, изменить ставку на определенный процент или бизнес-правило, то это может быть выполнено на уровне представления (JavaScript?).

  • Если вам действительно необходимо выполнить «Предварительный просмотр» для операции , ориентированной на конечного пользователя , то вам необходимо сначала зафиксировать состояние данных (возможно, хеш всех полей в наборе результатов для UPDATEопераций или значения ключей для DELETEоперации), а затем, перед выполнением операции, сравните захваченную информацию о состоянии с текущей информацией - внутри транзакции, выполняющей HOLDблокировку таблицы, чтобы ничего не изменилось после выполнения этого сравнения - и, если есть какая-либо разница, выведите ошибка и сделайте ROLLBACKвместо того, чтобы продолжить UPDATEили DELETE.

    Для обнаружения различий в UPDATEоперациях альтернативой вычислению хэша в соответствующих полях будет добавление столбца типа ROWVERSION . Значение типа ROWVERSIONданных автоматически изменяется каждый раз, когда происходит изменение в этой строке. Если бы у вас был такой столбец, вы бы добавили SELECTего вместе с другими данными «Предварительный просмотр», а затем передали его на шаг «обязательно, продолжайте и обновите» вместе со значением (ями) ключа и значением (ями). изменить. Затем вы сравнили бы эти ROWVERSIONзначения, переданные из «Предварительного просмотра», с текущими значениями (для каждого ключа) и продолжили UPDATEбы только если ВСЕиз сопоставленных значений. Преимущество здесь в том, что вам не нужно вычислять хеш, который имеет потенциал, даже если он маловероятен, для ложноотрицательных результатов и занимает некоторое время каждый раз, когда вы делаете SELECT. С другой стороны, ROWVERSIONзначение увеличивается автоматически только при изменении, поэтому вам не о чем беспокоиться. Однако ROWVERSIONтип имеет 8 байтов, которые могут складываться при работе со многими таблицами и / или многими строками.

    У каждого из этих двух методов есть свои плюсы и минусы, связанные с обнаружением несогласованного состояния, связанного с UPDATEоперациями, поэтому вам нужно будет определить, какой метод имеет больше «за», чем «против» для вашей системы. Но в любом случае вы можете избежать задержки между генерацией Preview и выполнением операции, которая может вызвать поведение, не соответствующее ожиданиям конечного пользователя.

  • Если вы работаете с режимом «Предварительный просмотр» для конечного пользователя, то в дополнение к регистрации состояния записей во время выбора, передаче и проверке во время модификации, добавьте DATETIMEfor SelectTimeи заполните через GETDATE()или что-то подобное. Передайте это вместе со слоем приложения, чтобы его можно было передать обратно в хранимую процедуру (чаще всего в виде одного входного параметра), чтобы ее можно было проверить в хранимой процедуре. Затем вы можете определить, что если операция не является режимом «Предварительный просмотр», тогда @SelectTimeзначение должно быть не более, чем за X минут до текущего значения GETDATE(). Может быть, 2 минуты? 5 минут? Скорее всего, не более 10 минут. Сгенерируйте ошибку, если значение DATEDIFFIN MINUTES превышает этот порог.

Соломон Руцкий
источник
4

Самый простой подход - часто лучший, и у меня нет особых проблем с дублированием кода в SQL, особенно в том же модуле. Ведь два запроса делают разные вещи. Так почему бы не взять «Маршрут 1» или сохранить его простым и просто иметь два раздела в сохраненном процессе, один для имитации работы, которую вам нужно сделать, и один для ее выполнения, например, что-то вроде этого:

CREATE TABLE dbo.user_table ( rowId INT IDENTITY PRIMARY KEY, a INT NOT NULL, someGuid UNIQUEIDENTIFIER DEFAULT NEWID() );
GO
CREATE PROCEDURE [dbo].[PREVIEW_EXAMPLE2]

    @preview CHAR(1) = 'Y'

AS

    SET NOCOUNT ON

    --!!TODO add error handling

    IF @preview = 'Y'

        -- Simulate INSERT; could be more complex
        SELECT 
            ISNULL( ( SELECT MAX(rowId) FROM dbo.user_table ), 0 ) + 1 AS rowId,
            42 AS a,
            NEWID() AS someGuid

    ELSE

        -- Actually do the INSERT, return inserted values
        INSERT INTO dbo.user_table ( a )
        OUTPUT inserted.rowId, inserted.a, inserted.someGuid
        VALUES ( 42 )

    RETURN

GO

Преимущество этого метода в том, что он самодокументируется (то IF ... ELSEесть легко отслеживается), имеет низкую сложность (по сравнению с точкой сохранения при использовании метода табличных переменных IMO) и, следовательно, меньше вероятность появления ошибок (отличное место от @Cody).

Что касается вашей точки зрения о низкой уверенности, я не уверен, что понимаю. Логически два запроса с одинаковыми критериями должны выполнять одно и то же. Существует вероятность несоответствия мощности множестваUPDATE и a SELECT, но это будет особенностью ваших объединений и критериев. Можешь объяснить дальше?

Кроме того, вы должны установить NULLNOT NULL свойство / и ваши таблицы и переменные таблиц, рассмотрите возможность установки первичного ключа.

Ваш оригинальный подход кажется немного сложным и может быть более подвержен тупикам, так как INSERT/ UPDATE/DELETE операции требуют более высоких уровней блокировки, чем обычные SELECTs.

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

wBob
источник
3

Мои опасения заключаются в следующем.

  • Обработка транзакций не соответствует стандартной схеме размещения в блоке Begin Try / Begin Catch. Если это шаблон, то в хранимой процедуре с несколькими дополнительными шагами вы можете выйти из этой транзакции в режиме предварительного просмотра с данными, которые все еще изменены.

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

  • Некоторые хранимые процедуры не возвращают один и тот же формат данных каждый раз; Думайте о sp_WhoIsActive как типичный пример.

Я не предоставил лучший способ сделать это, но я не думаю, что у вас есть хороший образец.

Коди Кониор
источник