Всем привет! Меня зовут Евгений, я занимаюсь разработкой и проектированием в Ozon. Больше всего работаю с MS SQL и C#, но попадаются и другие СУБД и языки программирования.

Ozon как продукт быстро растёт: во втором квартале этого года мы доставляли больше миллиона посылок в день. Для обработки такого объёма заказов мы используем разные языки и платформы: .NET (C#), Go, MS SQL Server и PostgreSQL.

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

Взаимодействие информационных систем
Взаимодействие информационных систем

Я расскажу об одном таком случае, когда наша команда потратила много времени и сил, но всё-таки нашла оптимальный способ решения проблемы дублирования данных.

Но сначала позвольте погрузить вас немного в предметную область — объясню, на примере чего будет демонстрироваться проблема дублирования данных, и освещу некоторые методы её решения.

План такой:

  1. расскажу немного о специфике предметной области;

  2. рассмотрим популярные варианты борьбы с дублированием данных;

  3. опишу, в чём заключается наш способ;

  4. покажу реализацию;

  5. приведу пример, как это всё работает.

Примеры будут приведены для MS SQL Server. Однако аналогичное решение можно реализовать на любой другой СУБД с учётом её особенностей.

Предметная область: логистика заказов

У нас в Ozon все заказы делятся по отправлениям:

Состав заказа
Состав заказа

В отправлении может быть один или несколько типов товаров.

В базе данных существуют отдельные таблицы для заказов и для отправлений.

Заказы бывают двух типов:

  1. Одноместный заказ, состоящий из одного отправления (Упаковка 1 — одно отправление):

    Одноместный заказ из одного отправления (упаковки)
    Одноместный заказ из одного отправления (упаковки)
  2. Многоместный заказ, состоящий из двух и более отправлений (Упаковка 1 — одно отправление, Упаковка 2 — второе отправление):

    Многоместный заказ из нескольких отправлений (упаковок)
    Многоместный заказ из нескольких отправлений (упаковок)

При совершении над отправлением определённых действий происходит тарификация — расчёт суммы за предоставленные услуги. Эти действия записываются в отдельную таблицу операций, а сумма попадает в соответствующее поле  этой таблицы.

В контексте статьи нас интересуют два основных действия над отправлениями с точки зрения тарификации: выдача и возврат. Они бывают полными и частичными. Полные — это когда полностью выдали или полностью вернули заказ, а частичные — когда заказ частично выдан или частично возвращён после полной выдачи.

Все отправления записываются в систему LogOzon, в которой заказы существуют только на уровне базы данных. Таким образом, LogOzon имеет дело только с отправлениями и ничего не знает про заказы, с которыми работают другие системы Ozon. Получается, что изменения на уровне всего заказа нельзя сделать в LogOzon; для этого нужно внести корректировки в составные части заказа, а именно в отправления. Так сложилось исторически при создании и масштабировании новых сервисов.

Асинхронная тарификация отправлений
Асинхронная тарификация отправлений

Проблема дублирования данных возникает в отношении многоместных заказов.

Сумма тарификации по бизнес-требованиям должна проставляться целиком только на одном отправлении одного типа тарификации в рамках заказа (выдача или возврат).

И тут мы подходим к сути проблемы. Как заставить асинхронные процессы из других систем Ozon обновлять сумму только в рамках одного типа тарификации, то есть на одном отправлении? Если не делать синхронизацию изменений, то можно получить дублирование сумм для разных отправлений заказа по одному и тому же типу тарификации, то есть когда на одном или нескольких отправлениях в рамках одного заказа будут проставлены одни и те же суммы.

Дублирование тарификаций отправлений
Дублирование тарификаций отправлений

Выбираем метод борьбы с дублированием данных

На практике я встречал два основных способа не допустить дублирования данных:

  1. Навесить ограничение на уникальность для нужной комбинации полей в таблице и обрабатывать исключения при нарушении этого ограничения (хорошо работает при вставках и обновлениях данных).

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

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

А что, если сделать гибридный вариант?

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

Примечания

  • На самом деле можно делать пересчёт и постфактум (то есть жить с дублирующими суммами), но в нашем случае это плохое решение, так как разные сервисы будут считывать невалидные данные до момента пересчёта, что приведёт к некорректным счетам;

  • мы используем уровень изоляции транзакций снимками (SNAPSHOT).

Алгоритм предотвращения дублирования данных

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

  1. Создаём таблицу, в которой будет содержаться вся необходимая информация для предотвращения дублирования сумм при обновлениях.

  2. Делаем хранимую процедуру, которая будет в монопольном режиме добавлять в новую таблицу запись с проверкой и возвращать статус, отражающий, удалось ли добавить запись или нет (если нет, значит, запись уже была добавлена).

  3. Создаём хранимую процедуру для записи суммы тарифа. При каждом проставлении суммы сравниваем её с суммой из новой таблицы и сначала обновляем сумму в новой таблице, если там она была 0:

    • в случае успешного обновления обновляем сумму в операции;

    • если в новой таблице уже указана сумма, отличная от 0, и отправление в обработке отличается от отправления, на котором проставлена сумма (то есть кто-то уже обновил сумму и нам не нужны дубли), обнуляем её;

    • в противном случае ничего не делаем.

Схема алгоритма
Схема алгоритма

Примечание: если нужно изменить уже проставленную сумму, необходимо это сделать на протарифицированном отправлении, информация о котором находится в новой таблице.

Создаём таблицу с информацией для предотвращения дублирования сумм при обновлениях

Создадим таблицу dbo.LogisticOrderMultiPostingPrincipalTariff следующим образом:

Определение таблицы dbo.LogisticOrderMultiPostingPrincipalTariff
SET QUOTED_IDENTIFIER ON;

SET ANSI_NULLS, NOCOUNT, XACT_ABORT ON;
GO
SET DEADLOCK_PRIORITY HIGH;
GO

IF (OBJECT_ID(N'dbo.LogisticOrderMultiPostingPrincipalTariff', N'U') IS NULL)
BEGIN
  CREATE TABLE [dbo].[LogisticOrderMultiPostingPrincipalTariff] (
    [LogisticOrderID] int      NOT NULL
  , [TariffTypeID]    int NOT NULL
  , [ArticleID]       int      NOT NULL
  , [OperationID]     int      NOT NULL
  , [Amount]          money    NOT NULL
  , [InsertUTCDate]   datetime NOT NULL
  , CONSTRAINT [PK_LogisticOrderMultiPostingPrincipalTariff]
      PRIMARY KEY CLUSTERED
      (
        [LogisticOrderID] ASC
      , [TariffTypeID] ASC
      ) ON [PRIMARY]
  ) ON [PRIMARY];

  ALTER TABLE [dbo].[LogisticOrderMultiPostingPrincipalTariff]
  ADD
    CONSTRAINT [DF_LogisticOrderMultiPostingPrincipalTariff_InsertUTCDate]
    DEFAULT (GETUTCDATE()) FOR [InsertUTCDate];

  CREATE NONCLUSTERED INDEX [IX_ArticleID]
  ON [dbo].[LogisticOrderMultiPostingPrincipalTariff]
  (
    [ArticleID] ASC
  )
  ON [PRIMARY];

  CREATE UNIQUE NONCLUSTERED INDEX [IX_Operation]
  ON [dbo].[LogisticOrderMultiPostingPrincipalTariff]
  (
    [OperationID] ASC
  )
  ON [PRIMARY];

  CREATE NONCLUSTERED INDEX [IX_InsertUTCDate]
  ON [dbo].[LogisticOrderMultiPostingPrincipalTariff]
  (
    [InsertUTCDate] ASC
  )
  ON [PRIMARY];
END;
GO

Таблица dbo.LogisticOrderMultiPostingPrincipalTariff содержит следующие поля:

  • LogisticOrderID — идентификатор заказа;

  • TariffTypeID — идентификатор типа тарификации;

  • ArticleID — идентификатор отправления;

  • OperationID — идентификатор операции;

  • Amount — сумма тарификации;

  • InsertUTCDate — дата и время создания записи в UTC (служебное поле).

Создаём хранимую процедуру для монопольного добавления записи в таблицу

Создадим хранимую процедуру dbo.AddLogisticOrderMultiPostingPrincipalTariff, которая будет в монопольном режиме добавлять в новую таблицу dbo.LogisticOrderMultiPostingPrincipalTariff запись с проверкой на существование записи с заданной парой (заказ, тип тарификации) и возвращать статус, показывающий, удалось добавить запись или нет (если нет, то запись уже была добавлена):

Определение хранимой процедуры dbo.AddLogisticOrderMultiPostingPrincipalTariff
SET ANSI_NULLS, QUOTED_IDENTIFIER ON;
GO

CREATE OR ALTER PROCEDURE dbo.AddLogisticOrderMultiPostingPrincipalTariff
  @LogisticOrderID int
, @TariffTypeID    int
, @ArticleID       int
, @OperationID     int
, @IsResult        bit = NULL OUT
AS
BEGIN
  SET NOCOUNT, XACT_ABORT ON;

  DECLARE @ID table (
    ID int NULL
  );

  SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;

  INSERT INTO dbo.LogisticOrderMultiPostingPrincipalTariff
  (
    LogisticOrderID
  , TariffTypeID
  , ArticleID
  , OperationID
  , Amount
  )
  OUTPUT
    Inserted.LogisticOrderID
  INTO @ID
  SELECT
    @LogisticOrderID
  , @TariffTypeID
  , @ArticleID
  , @OperationID
  , 0
  WHERE
    (NOT EXISTS ( SELECT
                    1
                  FROM
                    dbo.LogisticOrderMultiPostingPrincipalTariff
                  WHERE
                    (LogisticOrderID = @LogisticOrderID)
                    AND (TariffTypeID = @TariffTypeID)));

  SET @IsResult = CASE
                    WHEN (EXISTS ( SELECT
                                     1
                                   FROM
                                     @ID
                                   WHERE
                                     (ID > 0))) THEN 1
                    ELSE 0
                  END;
END;
GO

Хранимая процедура dbo.AddLogisticOrderMultiPostingPrincipalTariff принимает следующие параметры:

  • LogisticOrderID — идентификатор заказа;

  • TariffTypeID — идентификатор типа тарификации;

  • ArticleID — идентификатор отправления;

  • OperationID — идентификатор операции;

  • IsResult — выходной параметр, возвращающий значение 1 (успех вставки) или 0 (неуспех — когда уже есть запись с таким же заказом и типом тарификации).

Обратите внимание, что при добавлении записи в таблицу dbo.LogisticOrderMultiPostingPrincipalTariff в хранимой процедуре dbo.AddLogisticOrderMultiPostingPrincipalTariff сумма тарификации Amount принимает значение 0, так как проставление этой суммы произойдёт позже через обновление.

Создаём хранимую процедуру для записи суммы тарифа

Создадим ещё одну хранимую процедуру. При каждой записи суммы тарифа сравниваем её с суммой из новой таблицы dbo.LogisticOrderMultiPostingPrincipalTariff и сначала обновляем в ней сумму Amount, если там она 0. В случае успешного обновления меняем сумму в операции. В противном случае (если в операции стоит иная сумма, то есть кто-то её уже обновил) — обнуляем её.

Создадим эту хранимую процедуру dbo.UpdAmountLogisticOrderMultiPostingPrincipalTariff:

Определение хранимой процедуры dbo.UpdAmountLogisticOrderMultiPostingPrincipalTariff
SET ANSI_NULLS, QUOTED_IDENTIFIER ON;
GO

CREATE OR ALTER PROCEDURE dbo.UpdAmountLogisticOrderMultiPostingPrincipalTariff
  @LogisticOrderID int
, @TariffTypeID    int
, @ArticleID       int
, @OperationID     int
, @Amount          money
, @IsResult        bit = NULL OUT
AS
BEGIN
  SET NOCOUNT, XACT_ABORT ON;

  SET @IsResult = NULL;

  DECLARE @NewAmount table (
    Val money
  );

  DECLARE @ArticleOldID int;

  SELECT
    @ArticleOldID = LOMPPT.ArticleID
  FROM
    dbo.LogisticOrderMultiPostingPrincipalTariff LOMPPT
  WHERE
    (LOMPPT.LogisticOrderID = @LogisticOrderID)
    AND (LOMPPT.TariffTypeID = @TariffTypeID);

  UPDATE
    LOMPPPPT
  SET
    Amount = @Amount
  , OperationID = @OperationID
  , ArticleID = @ArticleID
  OUTPUT
    inserted.Amount
  INTO @NewAmount
  FROM
    dbo.LogisticOrderMultiPostingPrincipalTariff LOMPPT
  WHERE
    (LOMPPT.LogisticOrderID = @LogisticOrderID)
    AND (LOMPPT.TariffTypeID = @TariffTypeID)
    AND ( (LOMPPT.Amount = 0)
          OR ( (LOMPPT.Amount <> @Amount)
               AND (LOMPPT.ArticleID = @ArticleID)));

  IF (COALESCE(( SELECT
                   SUM(Val)
                 FROM
                   @NewAmount)
             , 0.0) = 0)
  BEGIN
    IF (@ArticleOldID <> @ArticleID)
    BEGIN
      SET @IsResult = 0;
    END;
    ELSE
      SET @IsResult = NULL;
  END;
  ELSE
    SET @IsResult = 1;
END;
GO

Хранимая процедура dbo.UpdAmountLogisticOrderMultiPostingPrincipalTariff принимает следующие параметры:

  • LogisticOrderID — идентификатор заказа;

  • TariffTypeID — идентификатор типа тарификации;

  • ArticleID — идентификатор отправления;

  • OperationID — идентификатор операции;

  • Amount — сумма тарификации;

  • IsResult — выходной параметр, возвращающий значение:

    • 1 — успех обновления;

    • 0 — неуспех, т е когда уже есть запись с нужной суммой

    • NULL — когда не нужно ничего менять, например при попытке записать сумму, которая уже указана, в то же отправление, на котором она указана.

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

Пример использования созданных хранимых процедур

Давайте посмотрим, как пользоваться хранимыми процедурами, которые мы создали.

У нас все отправления заказа создаются и переходят в статус «Сформировано» до того, как хотя бы одно из них куда-либо поедет. Напомним, что тарификация происходит при выдаче или возврате товара, когда сформированы все отправления заказа.

При тарификации у нас на входе есть следующие параметры:

  • LogisticOrderID — идентификатор заказа;

  • TariffTypeID — идентификатор типа тарификации;

  • ArticleID — идентификатор отправления;

  • OperationID — идентификатор операции.

На старте тарификации мы определяем, является ли заказ многоместным, то есть состоит ли он более чем из одного отправления. Если да, то вызывается хранимая процедура dbo.AddLogisticOrderMultiPostingPrincipalTariff для монопольного добавления записи о том, что мы стали тарифицировать заказ:

EXEC dbo.AddLogisticOrderMultiPostingPrincipalTariff
  @LogisticOrderID = @LogisticOrderID
, @TariffTypeID = @TariffTypeID
, @ArticleID = @ArticleID
, @OperationID = @OperationID
, @IsResult = @IsResult OUT;

По значению переменной @IsResult мы узнаём, получилось ли добавить запись. В нашем случае это нужно просто для информации и не используется для какой-либо логики в коде.

Далее мы вычисляем сумму тарифа по определённому алгоритму и кладём её в переменную @Amount.

На следующем этапе мы записываем ненулевую сумму тарификации @Amount в новую таблицу dbo.LogisticOrderMultiPostingPrincipalTariff через вызов хранимой процедуры dbo.UpdAmountLogisticOrderMultiPostingPrincipalTariff:

EXEC dbo.UpdAmountLogisticOrderMultiPostingPrincipalTariff
  @LogisticOrderID = @LogisticOrderID
, @TariffTypeID = @TariffTypeID
, @ArticleID = @ArticleID
, @OperationID = @OperationID
, @Amount = @Amount
, @IsResult = @IsResult OUT;

Теперь смотрим на вернувшееся значение в переменной @IsResult:

  1. если @IsResult = 1, то сумму тарифа @Amount не меняем;

  2. если @IsResult = 0, то обнуляем сумму тарифа @Amount и после этого записываем получившуюся сумму тарификации @Amount в нужную операцию отправления заказа;

  3. если @IsResult IS NULL, то ничего не делаем.

Синхронизация проставления суммы тарификации в отправлении в рамках заказа
Синхронизация проставления суммы тарификации в отправлении в рамках заказа

В случае же с одноместным заказом мы просто записываем сумму тарификации @Amount в соответствующую операцию отправления заказа.

Ключ к уменьшению количества дублей — уровень изоляции транзакций

Смотрите, что у нас получилось. Раньше стоимость доставки и возврата рассчитывалась по составу операций в рамках одного отправления. Теперь мы научились анализировать все отправления заказа для определения конечной операции с ним. Благодаря этому стало возможным автоматически рассчитывать корректные суммы для многоместных заказов.

К этому решению мы пришли не сразу. Изначально мы искали способ, как избежать появления дублей в строках. А нужно было правильно применить уровень изоляции транзакций — так, чтобы блокировка на вставку и обновление данных была минимальной, то есть с выделением нового объекта для блокировки и синхронизации, которым стала новая таблица, описанная выше.

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

Результат правильной тарификации
Результат правильной тарификации

Приведённое решение можно использовать на разных СУБД в задачах, где происходят асинхронные вставки и обновления данных разными процессами и из-за этого возможно дублирование данных.

Такие задачи распространены во многих сферах:

  • логистика,

  • транспортная безопасность,

  • складской учёт,

  • финансы,

  • бухгалтерский учёт,

  • строительство,

  • аудит.

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

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

Ссылки по теме

  1. Транзакции MS SQL Server: как вызывать транзакции, откатывать и фиксировать их, какие существуют уровни изоляции транзакций и различия между ними.

  2. Предложение OUTPUT: как получить то, что вставили, обновили (заодно смотрим, что было до обновления) или удалили при различных командах модификации данных.

  3. MS SQL Server: общая документация по СУБД, которую я выбрал для реализации описанного метода предотвращения дублирования данных.

Комментарии (43)


  1. itsoft
    25.08.2021 09:59
    +5

    Я расскажу об одном таком случае, когда наша команда потратила много времени и сил, но всё-таки нашла оптимальный способ решения проблемы дублирования данных.


    Это неграмотно. Вы разработали свой вариант.

    Оптимальность всегда по какому-то критерию. Скорости, стоимости или ещё каким-либо затрачиваемым ресурсам.

    Оптимальность надо доказывать на сравнении, что вот такой-то метод оптимальнее по заданному критерию другого метода потому-что… и мы вот замерили и получили.

    И в опросе почему-то нет банального — ключи. Заказы у вас специфические и сложно погрузиться в вашу предметную область. А классическая тема — это платежи.
    Платежи грузятся постоянно и разными способами. Может прийти инфа через уведомление, а может быть загружена через выписки, через API или вообще вручную.

    Чем вас уникальные ключи не устроили я из статьи не понял.


    1. jobgemws Автор
      25.08.2021 10:17
      +4

      Вы правы в том, что нужно сравнение.

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

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

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

      В статье просто показал как можно применить гибридный вариант и описал в конце о его недостатках в том числе.

      А что Вы используете, чтобы избежать дублирования? Или если Ваша система позволяет существовать дублям, то как разрешаете их?


      1. itsoft
        25.08.2021 10:41
        -2

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


        Вот это следовало бы раскрыть. Потому что это как раз и надо анализировать и разбирать детально. А вынося код куда-то появляются дополнительные риски. Ключи хороши тем, что они простые и очевидные. А код сложный. В коде легко и ошибку допустить.

        Мы используем ключи. Иногда это даже приводит к проблемам. Например, в банке может быть два одинаковых пп: один номер, одна дата, одна сумма, одни и те же инн. Но вот это уже ручками разруливаем. Меняем дату, убедившись, что в банке действительно дубль.


        1. jobgemws Автор
          25.08.2021 10:47
          +1

          Мы тоже используем ключи, если проверка уникальности простая-по комбинации полей в одной таблице.

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

          Позволить возникнуть исключению при появлению дубля и ожидать, когда руками поправят, в нашем случае недопустимо.


        1. HerrJohan
          31.08.2021 14:20
          +1

          Интересно, а кто нибудь делал отдельную службу для уникальных ключей?


          1. uaggster
            23.09.2021 22:34
            +1

            А чем SEQUENCE не устраивает?


            1. HerrJohan
              24.09.2021 06:44
              +1

              Возможно я не сделал пояснения. Предположим сильно распределённую базу. И добавление сущностей на местах требует уникального ключа для "центрального" хранилища. В свое время использовал майкрософтовскую 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. jobgemws Автор
                24.09.2021 08:44
                +1

                В своей практике в таких системах делают один из следующих вариантов:
                1) добавляют поле по дате и времени в UTC с максимальной точностью (datetime2) и метку для определения на каком сервере была создана запись (при необходимости и метку на каком сервере происходила последняя правка)
                2) заранее договариваются о диапазонах целочисленных идентификаторов для каждого сервера (напр, для первого-до 1 млн, для второго-от 1 000 001 до 2 млн и т д)+метки на каком сервере создана запись и на каком сервере последний раз было обновление.
                Но также по своему опыту скажу, что если система распределённая, т е сервера даже в разных городах, то лучше сущность дробить на атомарные записи таким образом, чтобы какой сервер создал запись, только тот и мог ее править, а при переходе в другой сервер создавалась новая атомарная запись, которая изначально берет данные из предыдущей атомарной записи.
                Таким образом достигается изолированность изменений и преодолевается целый пул возможных проблем, а саму сущность можно вывести через представление как совокупность этих атомарных записей.


                1. HerrJohan
                  28.09.2021 20:27
                  +1

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


                  1. jobgemws Автор
                    28.09.2021 20:51

                    Кстати да)


  1. Naf2000
    25.08.2021 12:25
    +1

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


    1. jobgemws Автор
      25.08.2021 13:01
      +2

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

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


  1. m0ntana
    25.08.2021 19:43
    +1

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

    Если я ничего не путаю, (X) блокировка накладывается до конца транзакции на любом уровне изоляции


    1. jobgemws Автор
      25.08.2021 19:48

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

      Со вставкой же иначе-можно вставлять одновременно несколько строк.

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


      1. m0ntana
        25.08.2021 20:14
        +1

        Параллельная вставка уводит нас к разговору о латчах :)

        Выбор уровня изоляции - баланс между целостностью данных и временем (блокировками). Ваше решение реализует смешанный подход, спасибо за обзор :)


  1. ViperRU
    25.08.2021 20:29
    +2

    Попытался понять в чем секрет, но так и не понял. В первой хранимке есть такое:

    WHERE (NOT EXISTS ( SELECT 1 FROM dbo.LogisticOrderMultiPostingPrincipalTariff WHERE (LogisticOrderID = @LogisticOrderID) AND (TariffTypeID = @TariffTypeID)));

    Но эти поля LogisticOrderID и TariffTypeID как раз и являются Primary Key который уникален по определению. В итоге вы пришли к первому варианту, создание таблицы с ограничением, не позволяющим вставить не уникальную запись.


    1. jobgemws Автор
      25.08.2021 20:41
      +2

      Да, в данной реализации используется ограничение уникальности.

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

      И ещё важный момент: сама новая таблица создана для синхронизации. Вне этой новой таблицы нет никаких ограничений уникальности именно по этой комбинации полей. Потому оба подхода (ограничение уникальности и уровень изоляции транзакции сериализуемая) были применены для новой таблицы.


  1. Tellamonid
    28.08.2021 12:52
    +2

    Мы делаем так:

    • процессы записи пишут батчами, взятыми из мессаджинга; используются уникальные ключи, и not exists по ним (нам по бизнес-специфике в некоторых случаях одна и та же запись может прийти очень много раз), и еще вдобавок используется хинт ignore_row_on_dupkey_index (у нас оракл) чтобы в редких случаях коллизий не падать, а продолжать;

    • эти же процессы в той же транзакции на каждый батч отписывают в оракловую очередь, что такие-то сущности поменялись (чаще всего тут на несколько порядков меньше записей, чем строк в батче, но это опять же наша специфика);

    • раз в какое-то время, например, в минуту, срабатывает джоб,, забирает данные из своей очереди, и делает ту обработку, которая нужна; так как джоб существует в единственном экземпляре, то мы избегаем ситуаций, когда что-то не обработано, или наоборот обработано несколько раз.

      Все операции делаются со стандартным уровнем изоляции read committed.


    1. jobgemws Автор
      28.08.2021 13:42
      +2

      Тоже интересный вариант, спасибо.

      В данном варианте информация в транзакционные таблицы попадает через джобу, которая (как Вы писали) срабатывает примерно один раз в минуту.

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


      1. Tellamonid
        28.08.2021 16:26
        +1

        У нас бизнес такой, что нам надо много всего посчитать к утру, и то, что данные появляются раз в минуту (или даже чуть реже, если одно выполнение джоба занимает больше минуты) – это не проблема. Разных таймстемпов мы храним много, и можем проследить когда какая запись прошла через какой этап.

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


        1. jobgemws Автор
          28.08.2021 17:25
          +1

          Благодарю за развернутый ответ.

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

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


          1. Tellamonid
            28.08.2021 18:55
            +1

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

            А так задачу единственности джоба можно решить многими способами: например, можно сделать сервис, который будет брать блокировку в базе или в каком-нибудь кластере, можно поднять один под в кубернетесе, если он есть


            1. jobgemws Автор
              28.08.2021 18:59
              +1

              Спасибо за информацию.

              Главное-чтобы очередь не росла, а то так задача будет все время выполняться подряд.


              1. Tellamonid
                28.08.2021 20:37
                +3

                да и пусть выполняется, раз в минуту – это и так аппроксимация выполнения всё время.

                Если ваш бизнес перевести в эти термины, то получится что-то вроде:

                • пользователь набрал заказ и нажал «заказать» – в систему прилетел заказ, и те пункты, что в него вошли (тут придется сделать синхронно);

                • мы его сохраняем в базу безо всякой логики, пользователю пишем «в обработке», и при сохранении еще откладываем order_id в очередь, например, складского сервиса;

                • складской сервис забирает пачку order_id из очереди, списывает с остатков, генерирует команды «упаковать» по каждому складу, и тоже кладет эти команды в очередь; как только сформированы команды, меняем статус заказа на «набираем на складе»

                • работники склада (ну или роботы) упаковывают конкретные товары, и, как только упаковали, меняем статус конкретной order_line_id на «упаковано», и откладывают еще в одну очередь order_id;

                • еще какой-то сервис читает эту очередь, и проверяет по всем прилетевшим order_id, все ли товары по заказу набраны; когда набраны, меряем статус заказа на «собран», и опять кладем order_id еще в одну очередь;

                • Еще один сервис читает эту очередь, и человек (или робот) отдает заказы курьерам, меняем статус заказа на «отдан курьеру»

                • Как только заказ доставлен (когда курьер прокликал это у себя), мы опять же сохраняем это событие безо всякой логики в базу, и кладем order_id еще в одну очередь;

                • и еще один сервис читает эти order_id, делает какие-то завершающие шаги (например, отправка емейла), и переводит статус соответствующих заказов в «исполнен»

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


                1. jobgemws Автор
                  28.08.2021 20:42
                  +1

                  Отличное решение, спасибо!


                  1. Tellamonid
                    29.08.2021 10:26
                    +1

                    Пожалуйста :-)

                    Всегда рад хорошей дискуссии


                    1. jobgemws Автор
                      29.08.2021 10:56

                      Взаимно)


  1. Alex_Reef
    29.08.2021 10:05
    +1

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


    1. jobgemws Автор
      29.08.2021 10:12
      +2

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

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


  1. KilloN
    29.08.2021 10:56
    +1

    Не совсем понимаю фразу "Заказы пользователей обрабатываются разными системами", что под ней имеется в виду?

    Зачем использовать разные системы для заказов с одного сайта?
    Проще поддерживать одну систему, нежели несколько!


    1. jobgemws Автор
      29.08.2021 10:57

      Не с одного сайта.

      Разные системы-разные сайты.

      Но системы между собой взаимодействуют.


      1. KilloN
        29.08.2021 11:14
        +1

        Что имеется в виду под разными системами?
        Речь идет о других сайтах, помимо ozone.ru?


        1. jobgemws Автор
          29.08.2021 11:21
          +1

          Конечно. Как пример:

          1) личный кабинет пользователя (кто покупает)-приложение Ozon

          2) личный кабинет принципалов

          3) приложение для курьера

          4) приложение для складского учёта

          5) приложение для карты Ozon

          6) приложение LogOzon

          И много всяких других приложений в том числе для аналитики.

          В данном случае пример описан в основном для интеграции между системами из п.2 и п.6.

          P.S.: даже в банкинге как минимум три приложения:

          1) для физлица

          2) для инвестиций

          3) для бизнеса.

          И все они интегрируются между собой (по крайней мере по хорошему должны интегрироваться).


  1. KilloN
    29.08.2021 11:40
    +1

    Я так понял система изначально не проектировалась для данного функционала. И именно с этим связаны текущие так называемые костыли.

    Из личного опыта, система была написана на 1С и имела ограниченный функционал. Со временем появлялись хотелки со стороны бизнеса и система заказов начала обрастать "костылями". В результате через 5 лет получился франкенштейн, состоящий целиком из костылей. И при добавлении нового костыля, частенько ломался другой.

    В маем случае изначально заказ=отправление. И не было какой нибудь объединяющей сущности. Поэтому было принято решение написать систему с нуля, на основе полученного опыта. Было не просто, но в итоге система написанная в прошлом году почти никак не корректировалась, а вносились мелкие исправления и корректировки.

    Порой проще создать что то новое, нежели поддерживать и дорабатывать старое


    1. jobgemws Автор
      29.08.2021 11:46

      В целом потребности появляются быстрее, чем развивается любое ИТ-решение.

      Т е в целом да:

      1) сначала делают из потребностей

      2) затем доделывают из новых или изменившихся потребностей

      3) затем ещё вносят изменения (оптимизация, рефакторинг, фичи, багофиксинг и т д и т п)

      4) рано или поздно количество технического долга становится так много, что проще в итоге написать систему или группу систем с нуля

      5) вновь повторяются п.1-4-это и есть жизненный цикл любого ИТ-продукта, где также может поменяться частично или полностью стек технологий.

      И из своего опыта: после каждой реализации понимаешь как можно сделать лучше-это и есть развитие. Нет предела для совершенства.


  1. Tellamonid
    29.08.2021 13:10
    +2

    Кстати, не подскажете, вы используете MVCC в MS SQL, или по-старинке блокировки? Это на моей памяти единственная СУБД, которая умеет и так, и так, но родилась как блокировочная. А поддержку MVCC ей добавили, если не ошибаюсь, в 2005-м


    1. jobgemws Автор
      29.08.2021 13:13
      +1

      У нас все на снимках, так что да, используем.


  1. apatkin
    07.09.2021 13:21
    +1

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


    1. jobgemws Автор
      07.09.2021 14:48
      +1

      В данном случае как раз сделано, чтобы дубли не вставлялись и обновления не перезатирались, но так, чтобы сервисам не приходилось обрабатывать эти исключения при обработке.


  1. AlanDenton
    10.09.2021 15:40
    +1

    Пусть и с опозданием, но решил вставить свои "пять копеек". Пример описанный в статье мне понравился. Приведу рабочий вариант по немного другой теме (но похожей) вдруг кому будет полезным:

    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, который без лишних блокировок помогает проверять дубликаты.


    1. jobgemws Автор
      10.09.2021 16:41
      +1

      Интересное решение, спасибо!

      Здесь при одновременном доступе-первый пройдет, а остальные будут ждать пока первый не закончит или же у остальных будет ошибка доступа?


      1. AlanDenton
        14.09.2021 10:28
        +1

        Когда один поток работает, то остальные потоки с тем же @ReceiptId ждут до 5 секунд своей возможности получить экслюзивный лок. Если за 5 секунд это не получается сделать то можно генерировать осмысленную ошибку или как-то это все протоколировать руками.

        Делал нагрузочное тестирование данного кода и не заметил каких-либо проблем - ни мемори-ликов, ни каких-то просадок производительности.


        1. jobgemws Автор
          14.09.2021 15:52

          Благодарю за развернутый ответ