Как избежать "Нарушения ограничения UNIQUE KEY" при выполнении LOTS одновременных INSERT

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

Фон:

Я работаю над проектом ASP.NET MVC4 WebApi, который получает МНОГО HTTP POST запросов к INSERT записям. Он получает около 5K - 10K запросов в секунду. Ответственность за проект несет де-дублирование и агрегирование записей. Это очень тяжело писать; он имеет относительно небольшое количество запросов на чтение; все из которых используют транзакцию с IsolationLevel.ReadUncommitted.

Схема базы данных

Вот таблица DB:

CREATE TABLE [MySchema].[Records] ( 
    Id BIGINT IDENTITY NOT NULL, 
    RecordType TINYINT NOT NULL, 
    UserID BIGINT NOT NULL, 
    OtherID SMALLINT NULL, 
    TimestampUtc DATETIMEOFFSET NOT NULL, 
    CONSTRAINT [UQ_MySchemaRecords_UserIdRecordTypeOtherId] UNIQUE CLUSTERED ( 
        [UserID], [RecordType], [OtherID] 
    ), 
    CONSTRAINT [PK_MySchemaRecords_Id] PRIMARY KEY NONCLUSTERED ( 
        [Id] ASC 
    ) 
) 

Код репозитория

Вот код для метода Upsert, вызывающий исключение:

using System;
using System.Data;
using System.Data.SqlClient;
using System.Linq;
using Dapper;

namespace MyProject.DataAccess
{
    public class MyRepo
    {
        public void Upsert(MyRecord record)
        {
            var dbConnectionString = "MyDbConnectionString";
            using (var connection = new SqlConnection(dbConnectionString))
            {
                connection.Open();
                using (var transaction = connection.BeginTransaction(IsolationLevel.ReadCommitted))
                {
                    try
                    {
                        var existingRecord = FindByByUniqueKey(transaction, record.RecordType, record.UserID, record.OtherID);

                        if (existingRecord == null)
                        {
                            const string sql = @"INSERT INTO [MySchema].[Records] 
                                                 ([UserID], [RecordType], [OtherID], [TimestampUtc]) 
                                                 VALUES (@UserID, @RecordType, @OtherID, @TimestampUtc) 
                                                 SELECT CAST(SCOPE_IDENTITY() AS BIGINT";
                            var results = transaction.Connection.Query<long>(sql, record, transaction);
                            record.Id = results.Single();
                        }
                        else if (existingRecord.TimestampUtc <= record.TimestampUtc)
                        {
                            // UPDATE
                        }

                        transaction.Commit();
                    }
                    catch (Exception e)
                    {
                        transaction.Rollback();
                        throw e;
                    }
                }
            }
        }

        // all read-only methods use explicit transactions with IsolationLevel.ReadUncommitted

        private static MyRecord FindByByUniqueKey(SqlTransaction transaction, RecordType recordType, long userID, short? otherID)
        {
            const string sql = @"SELECT * from [MySchema].[Records] 
                                 WHERE [UserID] = @UserID
                                 AND [RecordType] = @RecordType
                                 AND [OtherID] = @OtherID";
            var paramz = new {
                UserID = userID,
                RecordType = recordType,
                OtherID = otherID
            };
            var results = transaction.Connection.Query<MyRecord>(sql, paramz, transaction);
            return results.SingleOrDefault();
        }
    }

    public class MyRecord
    {
        public long ID { get; set; }
        public RecordType RecordType { get; set; }
        public long UserID { get; set; }
        public short? OtherID { get; set; }
        public DateTimeOffset TimestampUtc { get; set; }
    }

    public enum RecordType : byte
    {
        TypeOne = 1,
        TypeTwo = 2,
        TypeThree = 3
    }
}

Проблема

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

Нарушение ограничения UNIQUE KEY 'UQ_MySchemaRecords_UserIdRecordTypeOtherId'. Невозможно вставить дубликат ключа в объект "MySchema.Records". Значение повторяющегося ключа - (1234567890, 1, 123). Заявление было прекращено.

Это исключение происходит часто, до 10 раз в минуту.

Что я пробовал

  • Я попытался изменить IsolationLevel на Serializable. Исключение происходило гораздо реже, но все же происходило. Кроме того, производительность кода сильно пострадала; система могла обрабатывать только 2K запросов в секунду. Я подозреваю, что это снижение пропускной способности было фактически причиной уменьшенных Исключений, поэтому я пришел к выводу, что это не решило мою проблему.
  • Я рассмотрел использование UPDLOCK Table Hint, но я не совсем понимаю, как он взаимодействует с уровнями изоляции или как применять это к моему коду. Похоже, это могло бы быть лучшим решением, хотя из моего нынешнего понимания.
  • Я также попытался добавить исходный оператор SELECT (для существующих записей) как часть инструкции INSERT, как показано здесь, но эта попытка все еще была та же проблема.
  • Я попробовал реализовать мой метод Upsert с помощью инструкции SQL MERGE, но это также пострадало от той же проблемы.

Мой вопрос (ы)

  • Есть ли что-нибудь, что я могу сделать, чтобы предотвратить столкновение с ограничениями типа UNIQUE?
  • Если я должен использовать подсказку таблицы UPDLOCK (или любую другую подсказку в таблице), как бы добавить это к моему коду? Добавлю ли я его в INSERT? SELECT? Оба?

Ответ 1

Сделать проверочное чтение занятием блокировки:

FROM SomeTable WITH (UPDLOCK, ROWLOCK, HOLDLOCK)

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


HOLDLOCK (= SERIALIZABLE) защищает диапазон значений. Это гарантирует, что строка, которая не существует, продолжает существовать, поэтому INSERT преуспевает.

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

ROWLOCK побуждает движок блокировки на уровне строк.

Эти изменения могут увеличить шансы на тупик.

Ответ 2

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

Вы можете создать одноуровневый менеджерский класс, в котором хранятся уникальные ограничения записей в хэш-наборе, поэтому вы автоматически удаляете дубликаты, когда они будут добавлены в набор. Записи добавляются до отправки в БД и удаляются после завершения заявления. Таким образом либо хешсет съедает дубликат, либо существующую проверку записи, которую вы делаете в верхней части вашей попытки, обнаруживает зафиксированную дублируемую запись.

Ответ 3

AFAIK, единственным решением является проверка дублирования до insert. Это требует, по крайней мере, одного кругового путешествия к DB приводит к низкой производительности.

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

PS: Основываясь на комментарии Арона, и это хорошая работа, я должен сказать, что мое предлагаемое решение основано на этом предположении, что вы не хотите использовать буфер или очередь.