MySQL ON DUPLICATE KEY UPDATE с нулевым столбцом в уникальном ключе

Наша база данных веб-аналитики MySQL содержит сводную таблицу, которая обновляется в течение дня по мере импорта новых действий. Мы используем ON DUPLICATE KEY UPDATE, чтобы суммирование перезаписывало более ранние вычисления, но возникают трудности, поскольку один из столбцов в UNIQUE KEY сводной таблицы является необязательным FK и содержит значения NULL.

Эти значения NULL предназначены для обозначения «отсутствует, и все такие случаи эквивалентны». Конечно, MySQL обычно рассматривает значения NULL как означающие «неизвестно, и все такие случаи не эквивалентны».

Базовая структура выглядит следующим образом:

Таблица «Активность», содержащая запись для каждого сеанса, каждая из которых принадлежит кампании, с необязательным фильтром и идентификаторами транзакций для некоторых записей.

CREATE TABLE `Activity` (
    `session_id` INTEGER AUTO_INCREMENT
    , `campaign_id` INTEGER NOT NULL
    , `filter_id` INTEGER DEFAULT NULL
    , `transaction_id` INTEGER DEFAULT NULL
    , PRIMARY KEY (`session_id`)
);

«Сводная» таблица, содержащая ежедневные сводки общего количества сеансов в таблице действий и общее количество тех сеансов, которые содержат идентификатор транзакции. Эти сводки разделены по одному для каждой комбинации кампании и (необязательного) фильтра. Это нетранзакционная таблица, использующая MyISAM.

CREATE TABLE `Summary` (
    `day` DATE NOT NULL
    , `campaign_id` INTEGER NOT NULL
    , `filter_id` INTEGER DEFAULT NULL
    , `sessions` INTEGER UNSIGNED DEFAULT NULL
    , `transactions` INTEGER UNSIGNED DEFAULT NULL
    , UNIQUE KEY (`day`, `campaign_id`, `filter_id`)
) ENGINE=MyISAM;

Фактический запрос суммирования выглядит примерно так: подсчитывается количество сеансов и транзакций, затем группируется по кампаниям и (необязательно) фильтру.

INSERT INTO `Summary` 
    (`day`, `campaign_id`, `filter_id`, `sessions`, `transactions`)
    SELECT `day`, `campaign_id`, `filter_id
        , COUNT(`session_id`) AS `sessions`
        , COUNT(`transaction_id` IS NOT NULL) AS `transactions`
    FROM Activity
    GROUP BY `day`, `campaign_id`, `filter_id`
ON DUPLICATE KEY UPDATE
    `sessions` = VALUES(`sessions`)
    , `transactions` = VALUES(`transactions`)
;

Все отлично работает, за исключением сводки случаев, когда filter_id равен NULL. В этих случаях предложение ON DUPLICATE KEY UPDATE не соответствует существующей строке, и каждый раз записывается новая строка. Это связано с тем, что "NULL != NULL". Однако нам нужно «NULL = NULL» при сравнении уникальных ключей.

Я ищу идеи для обходных путей или отзывы о тех, которые мы придумали до сих пор. Обходные пути, о которых мы думали до сих пор, следуют.

  1. Перед выполнением суммирования удалите все сводные записи, содержащие значение ключа NULL. (Это то, что мы делаем сейчас) Это имеет негативный побочный эффект, возвращая результаты с отсутствующими данными, если запрос выполняется во время процесса суммирования.

  2. Измените столбец DEFAULT NULL на DEFAULT 0, что позволяет последовательно сопоставлять UNIQUE KEY. Это имеет негативный побочный эффект, заключающийся в чрезмерном усложнении разработки запросов к сводной таблице. Это вынуждает нас использовать много «CASE filter_id = 0 THEN NULL ELSE filter_id END» и делает неудобным объединение, поскольку все другие таблицы имеют фактические NULL для filter_id.

  3. Создайте представление, которое возвращает «CASE filter_id = 0 THEN NULL ELSE filter_id END» и используйте это представление вместо таблицы напрямую. Сводная таблица содержит несколько сотен тысяч строк, и мне сказали, что производительность представления довольно низкая.

  4. Разрешить создание повторяющихся записей и удалить старые записи после завершения суммирования. Имеет аналогичные проблемы с удалением их раньше времени.

  5. Добавьте суррогатный столбец, который содержит 0 для NULL, и используйте этот суррогат в UNIQUE KEY (на самом деле мы могли бы использовать PRIMARY KEY, если все столбцы НЕ NULL).
    Это решение кажется разумным, за исключением того, что приведенный выше пример является только примером. ; фактическая база данных содержит полдюжины сводных таблиц, одна из которых содержит четыре столбца, допускающих значение NULL, в UNIQUE KEY. Некоторые опасаются, что накладные расходы слишком велики.

У вас есть лучший обходной путь, структура таблицы, процесс обновления или лучшие практики MySQL, которые могут помочь?

РЕДАКТИРОВАТЬ: уточнить «значение нуля»

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

Единственная причина поместить их в уникальный ключ сводной таблицы — разрешить автоматическое обновление (путем ON DUPLICATE KEY UPDATE) при повторном расчете сводных отчетов.

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

Изменение остальных таблиц данных таким образом, чтобы они имели явное 0-значение «THERE_IS_NO_ZIP_CODE» и размещение специальной записи в таблице ZipCodePrefix, представляющей это значение, является неправильным — эта связь действительно равна NULL.


person ryandenki    schedule 19.08.2009    source источник


Ответы (4)


Я думаю, что что-то вроде (2) действительно лучший выбор — или, по крайней мере, это было бы, если бы вы начинали с нуля. В SQL NULL означает неизвестное. Если вам нужно какое-то другое значение, вам действительно следует использовать для этого специальное значение, и 0, безусловно, является подходящим выбором.

Вы должны сделать это для всей базы данных, а не только для этой таблицы. Тогда вы не должны сталкиваться со странными особыми случаями. Фактически, вы должны иметь возможность избавиться от многих ваших текущих (пример: в настоящее время, если вам нужна сводная строка, в которой нет фильтра, у вас есть особый случай «фильтр равен нулю», в отличие от обычного случая "фильтр = ?".)

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

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

редактировать 1

Хммм, в таком случае вам действительно нужно обновление дублирующегося ключа? Если вы делаете INSERT ... SELECT, то вы, вероятно, делаете. Но если ваше приложение предоставляет данные, просто сделайте это вручную — выполните обновление (сопоставление zip = null с zip is null), проверьте, сколько строк было изменено (MySQL возвращает это), если 0, выполните вставку.

person derobert    schedule 19.08.2009
comment
Да, сводная таблица явно не является реляционной таблицей. Это просто удобный контейнер для хранения результатов отчетности. Мое утверждение о том, что эти значения NULL означают «отсутствуют, и все такие случаи эквивалентны», возможно, вводит в заблуждение. В реляционных таблицах, содержащих нормализованные данные, filter_id и другие отношения, допускающие значение NULL, которые я упоминаю как часть уникального ключа в сводной таблице, действительно имеют значение неизвестного и не являются частью каких-либо первичных или уникальных ключей. См. редактирование выше. - person ryandenki; 19.08.2009
comment
Совершенно верно. Мы используем INSERT...SELECT, используя предложение ON DUPLICATE KEY для обновления записей в течение дня. Фактически, первая реализация два года назад была такой, как вы предлагаете: сначала выбрать данные, выполнить некоторые дополнительные манипуляции, а затем выдать отдельные INSERTS с предложениями WHERE, учитывающими случай IS NULL. Этот подход имеет то преимущество, что блокировки, вставляющие отдельные строки, короче, чем для метода INSERT...SELECT. Но эти блокировки действуют только на мастере, использующем репликацию строк, и мы могли бы заменить весь код на стороне приложения одним оператором SQL. - person ryandenki; 20.08.2009

Измените столбец DEFAULT NULL на DEFAULT 0, что позволяет последовательно сопоставлять UNIQUE KEY. Это имеет негативный побочный эффект, заключающийся в чрезмерном усложнении разработки запросов к сводной таблице. Это вынуждает нас использовать много «CASE filter_id = 0 THEN NULL ELSE filter_id END» и делает неудобным объединение, поскольку все другие таблицы имеют фактические NULL для filter_id.

Создайте представление, которое возвращает «CASE filter_id = 0 THEN NULL ELSE filter_id END» и используйте это представление вместо таблицы напрямую. Сводная таблица содержит несколько сотен тысяч строк, и мне сказали, что производительность представления довольно низкая.

Производительность представления в MySQL 5.x будет хорошей, так как представление не делает ничего, кроме замены нуля на ноль. Если вы не используете агрегаты/сортировки в представлении, почти любой запрос к представлению будет переписан оптимизатором запросов, чтобы просто попасть в базовую таблицу.

И, конечно же, поскольку это FK, вам придется создать запись в указанной таблице с нулевым идентификатором.

person tpdi    schedule 19.08.2009

В современных версиях MariaDB (ранее MySQL) upserts можно выполнять просто с помощью вставки в операторы обновления повторяющихся ключей, если вы используете суррогатный столбец маршрута № 5. Добавление сгенерированных сохраненных столбцов MySQL или постоянных виртуальных столбцов MariaDB для применения ограничения уникальности к полям, допускающим значение NULL, косвенно удерживает бессмысленные данные из базы данных в обмен на некоторое раздувание.

e.g.

CREATE TABLE IF NOT EXISTS bar (
    id INT PRIMARY KEY AUTO_INCREMENT,
    datebin DATE NOT NULL,
    baz1_id INT DEFAULT NULL,
    vbaz1_id INT AS (COALESCE(baz1_id, -1)) STORED,
    baz2_id INT DEFAULT NULL,
    vbaz2_id INT AS (COALESCE(baz2_id, -1)) STORED,
    blam DOUBLE NOT NULL,
    UNIQUE(datebin, vbaz1_id, vbaz2_id)
);

INSERT INTO bar (datebin, baz1_id, baz2_id, blam)
    VALUES ('2016-06-01', null, null, 777)
ON DUPLICATE KEY UPDATE
    blam = VALUES(blam);

Для MariaDB замените STORED на PERSISTENT, индексы требуют постоянства.

Созданные столбцы MySQL Виртуальные столбцы MariaDB

person JoelBondurant    schedule 22.06.2016

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

Для вашего примера:

UPDATE `Summary` s
    LEFT JOIN `Activity` a
    ON s.`campaign_id` = a.`campaign_id`
SET s.`sessions`     = a.COUNT(`session_id`)                ,
SET s.`transactions` = a.COUNT(`transaction_id` IS NOT NULL)
WHERE s.`day`         = a.`day`
AND   s.`campaign_id` = a.`campaign_id`
AND   s.`filter_id` IS NULL
AND   a.`filter_id` IS NULL;

INSERT INTO `Summary` 
    (`day`, `campaign_id`, `filter_id`, `sessions`, `transactions`)
    SELECT `day`, `campaign_id`, `filter_id`
        , COUNT(`session_id`) AS `sessions`
        , COUNT(`transaction_id` IS NOT NULL) AS `transactions`
    FROM Activity
    WHERE `filter_id` IS NOT NULL
    GROUP BY `day`, `campaign_id`, `filter_id`
ON DUPLICATE KEY UPDATE
    `sessions` = VALUES(`sessions`)
    , `transactions` = VALUES(`transactions`);
person RallozarX    schedule 15.10.2020