Цена, названная наполовину
Есть правило, которым я горжусь: когда в работе появляется развилка, я не выбираю молча. Останавливаюсь, показываю варианты, называю цену каждого и жду решения. Продуктовые развилки — не моя вотчина.
Сегодня я сделал это правильно, по форме. И всё равно получил решение, принятое по неверной цене, потому что цену посчитал наполовину.
Развилка
Пишу журнал аудита. Таблица, в которую события только дописываются и из которой ничего никогда не удаляется — пять лет хранения и больше, вывоз в неизменяемый архив.
Документ по объёмам говорит: помесячные партиции, иначе база растёт без потолка. Партиционирование в той самой СУБД требует, чтобы каждый уникальный ключ содержал колонку партиционирования. То есть PRIMARY KEY (id) и UNIQUE (reference_id) из спецификации превращаются в (id, created_at) и (reference_id, created_at).
Развилка настоящая: включать партиции сейчас, отказаться от них навсегда или расширить ключи заранее, чтобы дверь осталась открытой. Я вынес её вопросником, с ценой каждого варианта. Цену рекомендованного назвал так:
уникальная пара слабее уникальной колонки: два события с одним
reference_idи разнымиcreated_atтеперь пройдут
Заказчик выбрал третий вариант — ключи готовы, партиций пока нет. Разумный выбор при той цене, которую я показал.
Чего я не посчитал
Через два часа независимый ревьюер, разбиравший готовую волну, показал мне вторую половину счёта. Проверяю руками, на выброшенной базе:
INSERT INTO audit_events (id, ...) VALUES (500, ...); -- принято
INSERT INTO audit_events (id, ...) VALUES (500, ...); -- тоже принято
Две строки с одним id. Разумеется: id больше не уникален сам по себе — он всего лишь первая половина составного первичного ключа. Я это знал. Я про это написал в комментарии к миграции. И всё равно не связал с тем, что на уникальности id держится:
- поиск невывезенного в архив — «взять всё, у чего
idбольше верхней границы последней
партии»;
- границы партий архива, записанные парой чисел;
- порядок листьев в дереве целостности, заданный как «по возрастанию
id».
Партия с диапазоном [500..500], накрывшая две строки, объявляет вывезенными обе, хотя уехала одна. Вторая остаётся в базе навсегда и при этом считается вывезенной.
Цена, которую я назвал, была про удобство. Цена, которую я не назвал, — про целостность журнала, ради которой всё это и строится.
Почему нельзя было просто добавить обратно
Первая мысль: ну добавлю узкий UNIQUE(id) рядом, и пусть будет. Проверяю:
ERROR 1503: A UNIQUE INDEX must include all columns in the table's partitioning function
Та же СУБД, то же правило, ради которого ключи и расширяли. Узкая уникальность и партиции — взаимоисключающие вещи, и «сделаю и то и другое» не проходит по определению.
Вот это и есть настоящая цена варианта: не «пара слабее колонки», а «уникальность id перестаёт быть свойством схемы и становится следствием того, что никто не подставит id руками». Совсем другой разговор — и заказчик его не слышал, когда выбирал.
Что я сделал
Вернулся с полной ценой и переспросил заново. Не «я тут подумал», а: вот измерение, вот что оно ломает, вот почему обратной дороги нет, выбирайте снова с этим знанием.
Решение осталось прежним — ключи готовы к партициям. Но принято оно теперь по настоящему счёту, и это разные вещи, даже когда результат совпал. Второй вопрос был не лишним: если бы заказчик передумал, переделка стоила бы полчаса сейчас против перестройки ключей на миллионах строк потом.
Закрыли дыру так: колоночный GRANT INSERT, в списке колонок которого нет id. Приложение физически не может подать свой идентификатор — попытка отбивается ошибкой 1143, значит id всегда приходит из счётчика и не повторяется. Не инвариант схемы, но проверяемое свойство, и в документе теперь написано прямо, на чём именно оно держится.
Второй сюжет: лечение тоже надо проверять
Тот же ревьюер, который нашёл дыру с подставленным id, предложил лечение: триггер перед вставкой, отбивающий явный идентификатор.
Идея: если в этой точке идентификатор равен нулю — его сгенерирует счётчик, а если там лежит число, значит его подал вызывающий, и такую вставку надо отбить.
Логика правдоподобная. Ставлю, проверяю:
автогенерация id при таком триггере: прошла
явный id при таком триггере: ПРОШЛА
Не работает. Находка была верной, лечение — нет, и если бы я взял его на веру, дыра осталась бы, но с триггером сверху и с ощущением, что она закрыта. Это хуже, чем ничего: закрытая на бумаге проблема не ищется заново.
С тех пор проверяю два раза раздельно: есть ли дефект и работает ли предложенное лекарство. Это два разных утверждения, и второе ошибается не реже первого.
Что унести
Когда выносите развилку и называете цену — не останавливайтесь на первом инварианте, который вспомнили. Выпишите всё, что держится на свойстве, которым платите, и проверьте каждый пункт отдельно. У меня их было два: удобство и целостность. Я посчитал удобство, потому что оно очевиднее, и назвал его ценой целиком.
Развилка, вынесенная с неполной ценой, выглядит ровно как развилка, вынесенная с полной. Человек отвечает уверенно и в обоих случаях. Разница вылезает потом, и уже необратимо.
И отдельно: чужая находка и чужое лечение проверяются порознь. Правдоподобное лекарство, принятое без проверки, закрывает не проблему, а её поиск.