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

Задача следующая: система хранит данные о пользователях. Cервис работает изолированно и не имеет прямого доступа к базам с этими данными. Для работы сервису необходимо иметь в своей внутренней базе имена и фамилии пользователей. Их можно получить из Identity текущего пользователя во время запроса. Требуется добавлять или обновлять имена во время каждого запроса. Желательно осуществлять это в отдельном потоке, чтобы эта работа не влияла на время выполнения основного запроса.

Уточнение задачи


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

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

Тем, кто хочет узнать о фоновых задачах в ASP.NET больше, советую почитать хорошую статью об этом.

Решение


У нас есть класс DbRefresher, осуществляющий добавление или изменение данных пользователя в методе RefreshAsync.

Наши контроллеры использует атрибут Authorize. Добавим своего наследника этого класса и переопределим метод OnAuthorization:

public override void OnAuthorization(HttpActionContext actionContext)
{
    base.OnAuthorization(actionContext);

    if (IsAuthorized(actionContext))
        DbRefresher.RefreshAsync(actionContext.RequestContext.Principal)
            .ContinueWith(t =>
            {
                LogFactory.For<AuthorizeAndRefreshUserAttribute>()
                    .ErrorException("Error occured", t.Exception);
            }, TaskContinuationOptions.OnlyOnFaulted);
}

DbRefresher.RefreshAsync – это асинхронный метод, возвращающий объект Task, который продолжит своё выполнение в другом потоке. Выход из метода OnAuthorize осуществляется немедленно без ожидания завершения задачи. В случае аварийного завершения в лог будет добавлено сообщение об ошибке.

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

Тестирование


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

[TestMethod]
public void ConcurrentTest()
{
    const int threadCount = 10;

    var tasks = new Task[threadCount];
    for (int i = 0; i < threadCount; i++)
    {
        // DoOperations contains several CRUD operations on resources
        tasks[i] = Task.Factory.StartNew(DoOperations);
    }
    Task.WaitAll(tasks);
}

Проблемы


Если для работы каких-то action-методов контроллера требуется, чтобы база заведомо содержала данные текущего пользователя, этот подход не годится. Придётся использовать явный вызов DbRefresher.RefreshAsync в теле метода.

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

Заключение


Этот подход успешно работает в одном из наших сервисов в Конфёрмите более года.
Мне он представляется простым и изящным. Было бы интересно узнать мнение сообщества.

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


  1. aosja
    09.02.2019 18:04

    Если для работы каких-то action-методов требуется, чтобы база заведомо содержала данные текущего пользователя, этот подход не годится. Придётся использовать явный вызов DbRefresher.RefreshAsync в теле метода.

    А еще, если я не ошибаюсь, ждать окончания выполнения этого метода (в этом же или другом потоке, не суть).
    Итого, можно лЁгко сделать метод синхронным и вызывать его в другом потоке только тогда, когда это реально надо.
    CatchPrimaryKeyViolationAsync я бы перенес в DbRefresher.RefreshAsync, а оттуда выбрасывал бы что-то более значимое для доменной области. Например, DuplicatedUserException.


    1. AndreyRodin Автор
      09.02.2019 18:51

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

      а оттуда выбрасывал бы что-то более значимое для доменной области. Например, DuplicatedUserException.

      Это исключение не нужно, оно не имеет смысла. Если произошло primary key violation, значит, в данный момент другой поток выполняет работу по обновлению базы. Он её и закончит.

      Постарался объяснить это более подробно в статье, спасибо за комментарий.


      1. aosja
        09.02.2019 19:10

        Если обновление в БД происходит по первичному ключу, а, исходя из исключения, это именно так, я бы вообще не делал асинхронный метод.
        Если вам важен именно primary key violation в контроллере, то это протаскивание DAL на уровень, где о нем вряд ли должно быть что-то известно.


        1. AndreyRodin Автор
          09.02.2019 21:19

          Если обновление в БД происходит по первичному ключу, а, исходя из исключения, это именно так, я бы вообще не делал асинхронный метод.

          Почему?
          Если вам важен именно primary key violation в контроллере, то это протаскивание DAL на уровень, где о нем вряд ли должно быть что-то известно.

          Не совсем понял, что вы имели в виду.


  1. mayorovp
    11.02.2019 10:42

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

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

    Или же можно вовсе вытащить сам токен из свойства BootstrapContext, и положить в кеш его.


    1. AndreyRodin Автор
      11.02.2019 11:23

      Мысль интересная, буду держать её в голове.
      Тем более, что сейчас алгоритм обновления выглядит следующим образом:

      1. Обновляем компанию пользователя
        • Получаем компанию из БД
        • Если такой компании нет, добавляем новую компанию в БД
        • Если компания изменилась, обновляем компанию в БД

      2. То же самое для пользователя

      Пока пользователей у нас немного, поэтому проблем с загрузкой СУБД нет. Если появятся – подумаем над вашим предложением.

      Но должен отметить, что если используется вышеописанный алгоритм, загрузка СУБД будет одинаковой вне зависимости от того, выполняется обновление в фоне или в самом запросе.


      1. mayorovp
        11.02.2019 12:39

        Но должен отметить, что если используется вышеописанный алгоритм, загрузка СУБД будет одинаковой вне зависимости от того, выполняется обновление в фоне или в самом запросе.

        Для типового клиента частота запросов к API будет зависеть от времени выполнения запроса. Чем быстрее запрос — тем быстрее придёт следующий.


        Если же перед API поставить какое-нибудь балансировщик или ограничитель запросов — то он, опять-таки, будет измерять загруженность исходя из времени ответа на запрос, а висящие в фоне "хвосты" запросов учитываться не будут.


        1. AndreyRodin Автор
          11.02.2019 13:28

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

          If the database is the bottleneck, asynchronous calls will not speed up the database response.


          1. mayorovp
            11.02.2019 13:30
            +1

            Вот я и предлагаю просто не делать лишних запросов к базе.


  1. pelhu
    11.02.2019 15:16
    +2

    Если не ошибаюсь то async метод выполняется синхронно до первой await инструкции в его теле. Я бы посоветовал обернуть вызов DbRefresher.RefreshAsync в Task.Run.


    1. AndreyRodin Автор
      11.02.2019 18:37

      Дельное замечание. Но вот какая штука: я специально расставил трассировки и убедился в том, что в случае Task.Run создаётся один поток до первого await и другой после.
      Вопрос в том, стоит ли плодить лишние потоки?

      На всякий случай, я пробовал такой код:

                  if (IsAuthorized(actionContext))
                      Task.Run(
                              () => DbRefresher.RefreshAsync(actionContext.RequestContext.Principal))
                          .ContinueWith(t =>
                          {
                              LogFactory.For<AuthorizeAndRefreshUserAttribute>()
                                  .ErrorException("Error occured", t.Exception);
                          }, TaskContinuationOptions.OnlyOnFaulted);
      


      1. pelhu
        11.02.2019 18:39

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


      1. mayorovp
        11.02.2019 18:59

        Тот поток, который «другой после», не создаётся — а берётся из пула.