Как стать автором
Обновить

Комментарии 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 млн и т д)+метки на каком сервере создана запись и на каком сервере последний раз было обновление.
Но также по своему опыту скажу, что если система распределённая, т е сервера даже в разных городах, то лучше сущность дробить на атомарные записи таким образом, чтобы какой сервер создал запись, только тот и мог ее править, а при переходе в другой сервер создавалась новая атомарная запись, которая изначально берет данные из предыдущей атомарной записи.
Таким образом достигается изолированность изменений и преодолевается целый пул возможных проблем, а саму сущность можно вывести через представление как совокупность этих атомарных записей.

Точно. Поступить как в IPv6 - добавлять уникальный идентификатор. MAC адрес

Кстати да)

А зачем отдельно одноместные заказы? Это же частный случай многоместных

Да, одноместные-это частный случай многоместных.

Но проблема в дублировании может возникнуть только в многоместных, т е когда у заказа более одного отправления.

А нужно было правильно применить уровень изоляции транзакций — так, чтобы блокировка на вставку и обновление данных была минимальной

Если я ничего не путаю, (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 и более сессий вставить одно и тоже значение, первый вставит, а последующие не смогут прочитать вставленную строку. Но это в сценарии, когда нельзя бросить исключение или прервать вставку строки, а нужно именно вернуть эту строку или как-то еще её обработать.

Зарегистрируйтесь на Хабре, чтобы оставить комментарий