Как одна настройка compatibility level превратила рутинный реиндекс в production-инцидент

#mssql#performance#инциденты

Сломанная SQL база данных

История о том, как изменение уровня совместимости базы данных, сделанное когда-то давно, тихо ждало своего часа - и сработало в самый неожиданный момент.

Если вы администрируете SQL Server 2019/2022 и когда-либо поднимали compatibility level до 150 - прочитайте до конца. Возможно, у вас прямо сейчас тикает такая же мина.

Контекст

Я работаю старшим DBA в финансовой компании. Под моим управлением - кластер AlwaysOn Availability Groups на двух мощных серверах (144 CPU, 1 ТБ RAM каждый), несколько десятков инстансов, из которых один - ядро торговой системы. Основная база - назовём её MainDB - порядка 1,5 ТБ данных, таблицы от 18 до 34 миллионов строк, нагрузка типичная OLTP с элементами аналитики.

Принимая инфраструктуру, я обратил внимание на несколько особенностей конфигурации. Уровень совместимости базы стоял на 150 (SQL Server 2019). Автоматическое обновление статистик было отключено - вместо этого работали джобы, обновлявшие статистики с семплированием 20% в асинхронном режиме. А вот обслуживание индексов не проводилось вообще. Фрагментация - 95-97% практически по всем индексам. Даже по мелким и давно не менявшимся.

Почему так? На тот момент я этого не знал. Выглядело как «забыли настроить» или «решили не трогать, работает же». Позже стало понятно, что за этим стояла вполне конкретная причина. Но обо всём по порядку.

Начало: реиндекс

В конце февраля 2026 я взялся за фрагментацию. Для AG-окружения написал специализированный скрипт реиндексации с адаптивным троттлингом - стандартные средства (включая Ola Hallengren IndexOptimize) не умеют контролировать рост транзакционного лога и очереди AG в процессе перестроения индексов. Мой скрипт проверял четыре метрики перед каждым индексом: размер лога, процент использования, send queue, redo queue - и приостанавливался, если пороги превышены.

Работа шла поэтапно, в нерабочие часы. К 6 марта я завершил примерно 90% индексов.

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

6 марта планы запросов окончательно съехали.

Расследование: незнакомый wait type

Первое, что я сделал - собрал статистику wait types за 6 часов активной нагрузки. И увидел картину, которая меня удивила:

Wait TypeWait Time% от Total% Sample Time
HASH_TABLE_MEMO6 151 680 мс100%28,48%

Один единственный wait type. 100% от всех ожиданий. И я его раньше здесь не видел.

Через пару часов повторил замер - стало ещё хуже: 51% sample time. Больше половины процессорного времени системы уходило на какую-то синхронизацию hash-таблиц.

Полез разбираться. HASH_TABLE_MEMO (он же HTMEMO) - это семейство HT*-ожиданий, которые возникают при работе с общей hash-таблицей в Batch Mode. То есть когда SQL Server выполняет hash join или hash aggregate в пакетном режиме, потоки делят одну hash-таблицу и синхронизируются на доступе к ней. Чем больше перекос данных между потоками (skew), тем выше accumulated wait time.

Но подождите. Batch Mode - это же про columnstore индексы? Columnstore у нас нет. Откуда batch mode на rowstore-таблицах?

Ключевая находка

Вытащил план выполнения самого тяжёлого запроса из кэша. Это оказался ORM-запрос, генерируемый прикладным ПО - LEFT JOIN четырёх таблиц с извлечением всех колонок (суммарно ~400 штук, ~1,7 ГБ на одно выполнение).

И вот оно - прямо в заголовке XML-плана:

BatchModeOnRowStoreUsed="true"

Два Hash Match оператора в Batch Mode. Один сканировал heap-таблицу на 18,5 миллионов строк целиком (Table Scan). Второй - кластерный индекс другой таблицы на 20 миллионов строк. Оба в параллельном режиме, DOP 8.

Проверил database-scoped конфигурацию:

SELECT name, value FROM sys.database_scoped_configurations
WHERE name = 'BATCH_MODE_ON_ROWSTORE';
-- Результат: 1 (ON)

Включена. Но я её не включал. Кто?

Разгадка: никто не включал

Batch Mode on Rowstore включается автоматически при compatibility level 150. Это дефолтное поведение SQL Server 2019. Вам не нужно ничего делать - достаточно поднять compat level, и оптимизатор получает право использовать batch mode для обычных rowstore-таблиц.

И вот тут вся история сложилась в одну картину.

Когда-то на базе был поднят compatibility level до 150. Возможно, при обновлении SQL Server, возможно - сознательно. Batch Mode on Rowstore активировался автоматически. Но пока старые планы сидели в кэше - ничего не происходило. Оптимизатор не перекомпилировал планы, batch mode не использовался.

Потом, вероятно, кто-то попытался сделать реиндекс. Реиндекс обновляет статистики (с FULLSCAN), статистики инвалидируют кэш планов, оптимизатор перекомпилирует - и для тяжёлых запросов с большими scan-ами выбирает batch mode hash join. Производительность падает. Виноватым кажется реиндекс.

Логичное решение? Не делать реиндекс. Статистики обновлять аккуратно, вручную, с семплированием 20%. Автообновление отключить, чтобы внезапная перекомпиляция не сломала планы. Индексы не трогать.

И это работало. Годами. Ценой фрагментации 95-97%.

Пока не пришёл я и не сделал то, чего система боялась - полноценный реиндекс.

Решение

Собственно, fix оказался простым:

ALTER DATABASE SCOPED CONFIGURATION SET BATCH_MODE_ON_ROWSTORE = OFF;

Одна строчка. HTMEMO исчез мгновенно. Все планы вернулись в Row Mode, производительность восстановилась.

Но был побочный эффект: одна хранимая процедура (проверка статусов заявок с OUTER APPLY по той самой таблице на 18,5 млн строк) деградировала с 1 секунды до 2 минут - ей batch mode реально помогал.

Решение - точечный хинт:

-- Внутри процедуры, на конкретных запросах
... OPTION (USE HINT('ALLOW_BATCH_MODE'))

Глобально batch mode выключен (ORM-запросы в безопасности), но конкретная процедура получает его обратно через хинт. Хирургическое решение.

Что из этого вынести

  1. Compatibility level - это не просто цифра. Повышение с 140 до 150 включает Batch Mode on Rowstore, Adaptive Joins, Scalar UDF Inlining, и ещё десяток изменений в поведении оптимизатора. Каждое из них может изменить планы ваших запросов. И вы не узнаете об этом, пока планы не перекомпилируются.

  2. Перекомпиляция планов - это триггер. Реиндекс, UPDATE STATISTICS, рестарт SQL Server, failover AG - всё это сбрасывает кэш планов. Если у вас есть скрытые изменения в поведении оптимизатора (повышенный compat level, включённые database-scoped features), перекомпиляция их активирует.

  3. «Не трогай - работает» - это технический долг, не решение. Отказ от реиндексации маскирует проблему, но создаёт новые: фрагментация растёт, I/O деградирует, buffer pool используется неэффективно. Рано или поздно кто-то тронет - и получит весь накопленный эффект разом.

  4. Проверьте прямо сейчас. Вот запрос:

SELECT
    DB_NAME() AS database_name,
    compatibility_level,
    (SELECT value FROM sys.database_scoped_configurations
     WHERE name = 'BATCH_MODE_ON_ROWSTORE') AS bmor_enabled
FROM sys.databases
WHERE database_id = DB_ID();

Если compatibility_level = 150 и bmor_enabled = 1 - у вас активна та же функция. Это не значит, что у вас будут проблемы - Batch Mode on Rowstore отлично работает для аналитических запросов с агрегациями. Но если ваша нагрузка - OLTP с ORM-генерируемыми запросами, full scan-ами по большим таблицам и hash join-ами - стоит присмотреться к wait stats на предмет HT*-ожиданий.

Максим Юдин
Автор
Максим Юдин

Основатель WARP.D. 30 лет в отрасли - от администрирования SQL Server до архитектуры data-платформ на открытом стеке. Банки, инвестиционные компании, федеральный ритейл.

Подробнее о команде →