Комментарии 45
Я расскажу об одном таком случае, когда наша команда потратила много времени и сил, но всё-таки нашла оптимальный способ решения проблемы дублирования данных.
Это неграмотно. Вы разработали свой вариант.
Оптимальность всегда по какому-то критерию. Скорости, стоимости или ещё каким-либо затрачиваемым ресурсам.
Оптимальность надо доказывать на сравнении, что вот такой-то метод оптимальнее по заданному критерию другого метода потому-что… и мы вот замерили и получили.
И в опросе почему-то нет банального — ключи. Заказы у вас специфические и сложно погрузиться в вашу предметную область. А классическая тема — это платежи.
Платежи грузятся постоянно и разными способами. Может прийти инфа через уведомление, а может быть загружена через выписки, через API или вообще вручную.
Чем вас уникальные ключи не устроили я из статьи не понял.
Вы правы в том, что нужно сравнение.
Но в данном случае, пришлось бы сильно погрузить в детали и тем самым раздуть публикацию, а многие детали нельзя раскрывать по вполне понятным причинам.
Про ключи было сказано выше в публикации, что невсегда возможно сделать ограничение уникальности из-за сложности определения уникальности строки в таблице.
И в целом это не свой вариант, а он всегда был: стандартные транзакции и ограничения уникальности.
В статье просто показал как можно применить гибридный вариант и описал в конце о его недостатках в том числе.
А что Вы используете, чтобы избежать дублирования? Или если Ваша система позволяет существовать дублям, то как разрешаете их?
Про ключи было сказано выше в публикации, что невсегда возможно сделать ограничение уникальности из-за сложности определения уникальности строки в таблице.
Вот это следовало бы раскрыть. Потому что это как раз и надо анализировать и разбирать детально. А вынося код куда-то появляются дополнительные риски. Ключи хороши тем, что они простые и очевидные. А код сложный. В коде легко и ошибку допустить.
Мы используем ключи. Иногда это даже приводит к проблемам. Например, в банке может быть два одинаковых пп: один номер, одна дата, одна сумма, одни и те же инн. Но вот это уже ручками разруливаем. Меняем дату, убедившись, что в банке действительно дубль.
Мы тоже используем ключи, если проверка уникальности простая-по комбинации полей в одной таблице.
Но, увы, часто нельзя просто навесить ключ уникальности, т к она определяется сложным образом в том числе не просто по вычислениям из комбинации полей одной таблицы, но и в зависимости от данных в других таблицах, а использовать триггеры-не самое лучшее решение.
Позволить возникнуть исключению при появлению дубля и ожидать, когда руками поправят, в нашем случае недопустимо.
Интересно, а кто нибудь делал отдельную службу для уникальных ключей?
А чем SEQUENCE не устраивает?
Возможно я не сделал пояснения. Предположим сильно распределённую базу. И добавление сущностей на местах требует уникального ключа для "центрального" хранилища. В свое время использовал майкрософтовскую DateTime (по сути это Int64, которая хранит с 100-наносекундных интервалах время от 1.1.1 (год, месяц, день), по сути от начала эпохи). То есть на секунду приходится 10_000_000 отcчетов. Понятно дело что для генерации этого числа нужна высочайшая согласованность таймеров. Выкручивался тем, что при поступлении сущностей в таблицу от "сторонних" поставщиков данных было нечто вроде:while(MainTable.ContainsKey(externalkey)){
++externalkey;
}
Так же была идея что рассылается класс. Поставщик выдает предполагаемое количество вставок (предположим за сутки) и ему выдается "квота" от new DateTime(2021, 09, 24, 10, 0, 0, 0).Ticks + 10_000) на сутки.
В своей практике в таких системах делают один из следующих вариантов:
1) добавляют поле по дате и времени в UTC с максимальной точностью (datetime2) и метку для определения на каком сервере была создана запись (при необходимости и метку на каком сервере происходила последняя правка)
2) заранее договариваются о диапазонах целочисленных идентификаторов для каждого сервера (напр, для первого-до 1 млн, для второго-от 1 000 001 до 2 млн и т д)+метки на каком сервере создана запись и на каком сервере последний раз было обновление.
Но также по своему опыту скажу, что если система распределённая, т е сервера даже в разных городах, то лучше сущность дробить на атомарные записи таким образом, чтобы какой сервер создал запись, только тот и мог ее править, а при переходе в другой сервер создавалась новая атомарная запись, которая изначально берет данные из предыдущей атомарной записи.
Таким образом достигается изолированность изменений и преодолевается целый пул возможных проблем, а саму сущность можно вывести через представление как совокупность этих атомарных записей.
А зачем отдельно одноместные заказы? Это же частный случай многоместных
А нужно было правильно применить уровень изоляции транзакций — так, чтобы блокировка на вставку и обновление данных была минимальной
Если я ничего не путаю, (X) блокировка накладывается до конца транзакции на любом уровне изоляции
Обновление всегда происходит монопольно для строки, т е одновременно одну и туже строку нельзя обновить, но можно перебить значение, когда предыдущий процесс закончит обновление.
Со вставкой же иначе-можно вставлять одновременно несколько строк.
Но делать весь алгоритм в транзакции уровня изоляции сериализуемая-слишком дорогое удовольствие. Однако, если ввести новую таблицу для синхронизации асинхронных вставок, а вставку и обновление в этой таблице вложить в транзакцию уровня изоляции сериализуемая, то тем самым можно минимизировать время ожидания. Также эту таблицу удобно использовать для обновления с тем учетом, чтобы параллельные процессы не обновили уже проставленную сумму.
Попытался понять в чем секрет, но так и не понял. В первой хранимке есть такое:
WHERE (NOT EXISTS ( SELECT 1 FROM dbo.LogisticOrderMultiPostingPrincipalTariff WHERE (LogisticOrderID = @LogisticOrderID) AND (TariffTypeID = @TariffTypeID)));
Но эти поля LogisticOrderID и TariffTypeID как раз и являются Primary Key который уникален по определению. В итоге вы пришли к первому варианту, создание таблицы с ограничением, не позволяющим вставить не уникальную запись.
Да, в данной реализации используется ограничение уникальности.
Однако, чтобы не порождать исключения нарушения этого ограничения, а также в общем случае (когда сложно сделать такое ограничение), предлагается ввести новую таблицу для синхронизации асинхронных обновлений, вставка и обновление над строками которой находятся в транзакции уровня изоляции сериализуемая.
И ещё важный момент: сама новая таблица создана для синхронизации. Вне этой новой таблицы нет никаких ограничений уникальности именно по этой комбинации полей. Потому оба подхода (ограничение уникальности и уровень изоляции транзакции сериализуемая) были применены для новой таблицы.
Мы делаем так:
процессы записи пишут батчами, взятыми из мессаджинга; используются уникальные ключи, и not exists по ним (нам по бизнес-специфике в некоторых случаях одна и та же запись может прийти очень много раз), и еще вдобавок используется хинт ignore_row_on_dupkey_index (у нас оракл) чтобы в редких случаях коллизий не падать, а продолжать;
эти же процессы в той же транзакции на каждый батч отписывают в оракловую очередь, что такие-то сущности поменялись (чаще всего тут на несколько порядков меньше записей, чем строк в батче, но это опять же наша специфика);
раз в какое-то время, например, в минуту, срабатывает джоб,, забирает данные из своей очереди, и делает ту обработку, которая нужна; так как джоб существует в единственном экземпляре, то мы избегаем ситуаций, когда что-то не обработано, или наоборот обработано несколько раз.
Все операции делаются со стандартным уровнем изоляции read committed.
Тоже интересный вариант, спасибо.
В данном варианте информация в транзакционные таблицы попадает через джобу, которая (как Вы писали) срабатывает примерно один раз в минуту.
Не наблюдали каких-либо побочек в задержке на это время от появления записи в очереди до непосредственной записи в нужные транзакционные таблицы? Например, когда ожидается, что информация сразу появится, а не будет в ожидании целую минуту пусть и в асинхронном режиме. Или что дата фактического обновления информации будет позже на 1 минуту от времени ее поступления в очередь-на сколько это критично в Вашем случае?
У нас бизнес такой, что нам надо много всего посчитать к утру, и то, что данные появляются раз в минуту (или даже чуть реже, если одно выполнение джоба занимает больше минуты) – это не проблема. Разных таймстемпов мы храним много, и можем проследить когда какая запись прошла через какой этап.
Понятно, что если за данными в реальном времени смотрят пользователи, то они могут запереживать, если что-то не произошло мгновенно. Но даже там можно написать «заказ в обработке», и пользователь, скорее всего, будет спокоен.
Благодарю за развернутый ответ.
Увы, у нас в данном случае так нельзя, но сам подход интересен в случае, когда допускается задержка.
А что происходит, если джоба выполняется более минуты? Т е если джоба должна запускаться каждую минуту, то нет проблем наслоения вызова на выполнения одной и той же джобы?
Оракл нам гарантирует выполнение джобы в единственном экземпляре. Если я правильно помню, в таком случае один запуск джобы будет сразу идти за другим.
А так задачу единственности джоба можно решить многими способами: например, можно сделать сервис, который будет брать блокировку в базе или в каком-нибудь кластере, можно поднять один под в кубернетесе, если он есть
Спасибо за информацию.
Главное-чтобы очередь не росла, а то так задача будет все время выполняться подряд.
да и пусть выполняется, раз в минуту – это и так аппроксимация выполнения всё время.
Если ваш бизнес перевести в эти термины, то получится что-то вроде:
пользователь набрал заказ и нажал «заказать» – в систему прилетел заказ, и те пункты, что в него вошли (тут придется сделать синхронно);
мы его сохраняем в базу безо всякой логики, пользователю пишем «в обработке», и при сохранении еще откладываем order_id в очередь, например, складского сервиса;
складской сервис забирает пачку order_id из очереди, списывает с остатков, генерирует команды «упаковать» по каждому складу, и тоже кладет эти команды в очередь; как только сформированы команды, меняем статус заказа на «набираем на складе»
работники склада (ну или роботы) упаковывают конкретные товары, и, как только упаковали, меняем статус конкретной order_line_id на «упаковано», и откладывают еще в одну очередь order_id;
еще какой-то сервис читает эту очередь, и проверяет по всем прилетевшим order_id, все ли товары по заказу набраны; когда набраны, меряем статус заказа на «собран», и опять кладем order_id еще в одну очередь;
Еще один сервис читает эту очередь, и человек (или робот) отдает заказы курьерам, меняем статус заказа на «отдан курьеру»
Как только заказ доставлен (когда курьер прокликал это у себя), мы опять же сохраняем это событие безо всякой логики в базу, и кладем order_id еще в одну очередь;
и еще один сервис читает эти order_id, делает какие-то завершающие шаги (например, отправка емейла), и переводит статус соответствующих заказов в «исполнен»
Я, конечно, несколько утрировал пример, но, думаю, идея понятна. Что мы сохраняем события без логики. Что можем, обрабатываем в фоне, и, где можем, батчами.
Мы используем ограничения уникальности, а обработку исключений делаем на уровне сервисов.
Да, такой подход тоже возможен, если можно достаточно просто идентифицировать строку в таблице по набору полей из этой же таблицы.
Увы, это невсегда возможно, потому в публикации был предложен гибридный вариант-уровень изоляции транзакций и ограничение уникальности на специально созданной таблице для синхронизации записи асинхронных процессов.
Не совсем понимаю фразу "Заказы пользователей обрабатываются разными системами", что под ней имеется в виду?
Зачем использовать разные системы для заказов с одного сайта?
Проще поддерживать одну систему, нежели несколько!
Не с одного сайта.
Разные системы-разные сайты.
Но системы между собой взаимодействуют.
Что имеется в виду под разными системами?
Речь идет о других сайтах, помимо ozone.ru?
Конечно. Как пример:
1) личный кабинет пользователя (кто покупает)-приложение Ozon
2) личный кабинет принципалов
3) приложение для курьера
4) приложение для складского учёта
5) приложение для карты Ozon
6) приложение LogOzon
И много всяких других приложений в том числе для аналитики.
В данном случае пример описан в основном для интеграции между системами из п.2 и п.6.
P.S.: даже в банкинге как минимум три приложения:
1) для физлица
2) для инвестиций
3) для бизнеса.
И все они интегрируются между собой (по крайней мере по хорошему должны интегрироваться).
Я так понял система изначально не проектировалась для данного функционала. И именно с этим связаны текущие так называемые костыли.
Из личного опыта, система была написана на 1С и имела ограниченный функционал. Со временем появлялись хотелки со стороны бизнеса и система заказов начала обрастать "костылями". В результате через 5 лет получился франкенштейн, состоящий целиком из костылей. И при добавлении нового костыля, частенько ломался другой.
В маем случае изначально заказ=отправление. И не было какой нибудь объединяющей сущности. Поэтому было принято решение написать систему с нуля, на основе полученного опыта. Было не просто, но в итоге система написанная в прошлом году почти никак не корректировалась, а вносились мелкие исправления и корректировки.
Порой проще создать что то новое, нежели поддерживать и дорабатывать старое
В целом потребности появляются быстрее, чем развивается любое ИТ-решение.
Т е в целом да:
1) сначала делают из потребностей
2) затем доделывают из новых или изменившихся потребностей
3) затем ещё вносят изменения (оптимизация, рефакторинг, фичи, багофиксинг и т д и т п)
4) рано или поздно количество технического долга становится так много, что проще в итоге написать систему или группу систем с нуля
5) вновь повторяются п.1-4-это и есть жизненный цикл любого ИТ-продукта, где также может поменяться частично или полностью стек технологий.
И из своего опыта: после каждой реализации понимаешь как можно сделать лучше-это и есть развитие. Нет предела для совершенства.
Кстати, не подскажете, вы используете MVCC в MS SQL, или по-старинке блокировки? Это на моей памяти единственная СУБД, которая умеет и так, и так, но родилась как блокировочная. А поддержку MVCC ей добавили, если не ошибаюсь, в 2005-м
Интересное решение, хотя первое, что мне пришло в голову - это использовать ключи. Собственно ключи для подобных целей и существуют, но не исключено, что иногда важнее данные быстро записать, нежели проверить всю цепочку правил перед записью, учитывая нагрузку OZON, не удивительно, что данные проверяются уже после вставки.
Пусть и с опозданием, но решил вставить свои "пять копеек". Пример описанный в статье мне понравился. Приведу рабочий вариант по немного другой теме (но похожей) вдруг кому будет полезным:
DECLARE @ReceiptId UNIQUEIDENTIFIER
DECLARE @LockKey CHAR(36) = CAST(@ReceiptId AS CHAR(36))
, @LockResult INT
EXEC @LockResult = sys.sp_getapplock @Resource = @LockKey
, @LockMode = 'Exclusive'
, @LockOwner = 'Session'
, @LockTimeout = 5000
IF @LockResult IN (0, 1) BEGIN
IF EXISTS(SELECT * FROM dbo.Receipt WHERE ReceiptId = @ReceiptId) BEGIN
/* ReceiptAlreadyPosted */
END
ELSE BEGIN
/* PostReceipt */
END
EXEC sys.sp_releaseapplock @Resource = @LockKey
, @LockOwner = 'Session'
END
ELSE BEGIN
/* Unable to acquire exclusive lock */
END
У нас сейчас проект по розничной торговле и иногда возникает ситуация, когда одна и та же квитанция повторно отправляется с кеш-устройства через API в базу (например, из-за нестабильного сетевого соединения). Это приводит к созданию для таких записей нескольких отдельных промежуточных сессий в рамках которых нужно запроцессать одну и ту же квитанцию.
В большинстве случаев дополнительные сеансы автоматически отбрасываются, но если несколько заданий попытаются обработать такие промежуточные сеансы одновременно (а так и происходит), все, кроме одного такого сеанса, потерпят неудачу, что приведет к ложному предупреждению об ошибке публикации квитанции (мы не можем вставить дубликат ключа в уникальный индекс).
Если человеческим языком, чтобы перестраховаться от дубликата по заранее известному уникальному ключу мы используем sp_getapplock, который без лишних блокировок помогает проверять дубликаты.
Интересное решение, спасибо!
Здесь при одновременном доступе-первый пройдет, а остальные будут ждать пока первый не закончит или же у остальных будет ошибка доступа?
Когда один поток работает, то остальные потоки с тем же @ReceiptId ждут до 5 секунд своей возможности получить экслюзивный лок. Если за 5 секунд это не получается сделать то можно генерировать осмысленную ошибку или как-то это все протоколировать руками.
Делал нагрузочное тестирование данного кода и не заметил каких-либо проблем - ни мемори-ликов, ни каких-то просадок производительности.
Стал применять и именно, чтобы дублей не было, нужно выставить @LockTimeout в 0, чтобы сразу получить код 2, если невозможно наложить блокировку. Детальнее описано здесь.
Добавлю, что если нужно вернуть строку, то тогда-нужно ставить не 0, а какое-то значение (выведенное опытным путём). Чтобы не получилось так, что если пытаются 2 и более сессий вставить одно и тоже значение, первый вставит, а последующие не смогут прочитать вставленную строку. Но это в сценарии, когда нельзя бросить исключение или прервать вставку строки, а нужно именно вернуть эту строку или как-то еще её обработать.
Как избавиться от дублей в базе данных (на примере MS SQL)