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

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

В своих проектах подобную проблему различными способами я решал довольно давно. Еще со времен учебы в университете у меня был небольшой проект, написанный на .NET 4.0, из которого я время от времени брал небольшие части кода для реальных проектов. Я давно планировал провести рефакторинг этого проекта, почистить его и оформить в виде отдельного мини-фреймворка, позволяющего красиво и минималистично решать задачу наблюдения за состоянием системы. Потратив несколько вечеров и пару выходных я таки привел этот код в порядок и выложил его на GitHub. Далее, предлагаю более подробно рассмотреть что и как реализовано в этом проекте.

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



¦Сущности и определения


Базовые сущности, которые будут использоваться:

  • Датчик – отвечает за проверку состояния одного из показателей системы;
  • Наблюдатель– опрашивает один, или несколькими датчиками. Изменяет свое состояние в зависимости от текущих показаний датчиков;
  • Вычислитель состояния – на основе журнала метрик вычисляет текущее состояние.
  • Журнал состояний – набор показателей каждого из датчиков с указанием времени опроса.

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

Датчик


Базовый интерфейс IProbe. Классы реализующие IProbe, предоставляют по запросу значение наблюдаемых ими параметров системы, или конкретного модуля/сервиса.

    public interface IProbe
    {
        string Name { get; }
        
        Task<ProbeResult> Check();
    }

В качестве результата метода Check, экземпляр IProbe возвращает структуру ProbeResult

    public struct ProbeResult
    {
        public string ProbeName { get; set; }
        public DateTime Time { get; set; }
        public bool Success { get; set; }
        public string Data { get; set; }
        public Exception Exception { get; set; }

        public override string ToString() => $"{Time}: {Success}";
    }

где поле Success определяет, удачно ли с точки зрения датчика прошла проверка параметра, а поля Data и Exception могут хранить дооплнительную информацию на случая если она окажется необходима для отладки, или логирования.

Наблюдатель


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

    public interface ISpectator<TState> where TState : struct, IConvertible
    {
        event EventHandler<StateEventArgs<TState>> StateChanged;
        event EventHandler<HealthCheckEventArgs> HealthChecked;
        
        TState State { get; }
        
        TimeSpan Uptime { get; }
        
        string Name { get; }
        
        void AddProbe(IProbe probe);

        void CheckHealth();
    }

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

Вычислитель состояния


Базовый интерфейс IEvaluator. Вычисляет текущее состояние системы на основе журнала состояний датчиков.

    public interface IStateEvaluator<TState>
    {
        TState Evaluate(
            TState currentState, 
            DateTime stateChangedLastTime, 
            IReadOnlyCollection<JournalRecord> journal);
    }

Журнал состояний


Коллекция экземпляров структуры JournalRecord. Экземпляр JournalRecord хранит информацию обо всех опрошенных датчиках, в момент, когда опрос был инициирован наблюдтелем.

    public struct JournalRecord
    {
        public JournalRecord(DateTime time, IEnumerable<ProbeResult> values)
        {
            Time = time;
            Values = values.ToImmutableList();
        }

        public DateTime Time { get; set; }
        
        public IReadOnlyCollection<ProbeResult> Values { get; set; }

        public override string ToString() => $"{Time}: [{string.Join(",", Values)}]";
    }

¦Как это работает


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

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

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

Синхронный и асинхронный режимы работы


Рассмотрим два основных сценария, при которых наблюдатель инициирует опрос «датчиков».

Синхронный режим


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

В составе проекта уже есть базовая реализация такого наблюдателя – SpectatorBase.

Посмотреть код
    public class SpectatorBase<TState> : ISpectator<TState> 
        where TState : struct, IConvertible
    {
        private TState _state;

        private readonly IList<IProbe> _probes;
        private readonly IStateEvaluator<TState> _stateEvaluator;
        private readonly List<JournalRecord> _journal;
        private readonly ReaderWriterLockSlim _journalLock;
        private readonly ReaderWriterLockSlim _stateLock;
        private readonly Stopwatch _stopwatch;

        public event EventHandler<StateEventArgs<TState>> StateChanged;
        public event EventHandler<HealthCheckEventArgs> HealthChecked;

        public virtual TState State
        {
            get
            {
                _stateLock.EnterReadLock();
                
                try
                {
                    return _state;
                }
                finally
                {
                    _stateLock.ExitReadLock();
                }
            }
        }

        public TimeSpan Uptime => _stopwatch.Elapsed;

        public string Name { get; set; }

        public IReadOnlyCollection<JournalRecord> Journal
        {
            get
            {
                _journalLock.EnterReadLock();
                
                try
                {
                    return _journal;
                }
                finally
                {
                    _journalLock.ExitReadLock();
                }
            }
        }
        
        public DateTime StateChangedDate { get; private set; }

        public TimeSpan RetentionPeriod { get; private set; }

        public SpectatorBase(IStateEvaluator<TState> stateEvaluator, TimeSpan retentionPeriod, TState initialState)
        {
            RetentionPeriod = retentionPeriod;
            _state = initialState;
            StateChangedDate = DateTime.UtcNow;
            
            _stateEvaluator = stateEvaluator;
            _stopwatch = Stopwatch.StartNew();
            _probes = new List<IProbe>();
            _journal = new List<JournalRecord>();
            _journalLock = new ReaderWriterLockSlim();
            _stateLock = new ReaderWriterLockSlim();
        }

        public void AddProbe(IProbe probe) => _probes.Add(probe);

        protected virtual void ChangeState(TState state, IEnumerable<string> failedProbes)
        {
            _stateLock.EnterWriteLock();

            try
            {
                _state = state;
            }
            finally
            {
                _stateLock.ExitWriteLock();
            }

            StateChangedDate = DateTime.UtcNow;

            StateChanged?.Invoke(this, new StateEventArgs<TState>(state, StateChangedDate, failedProbes));
        }

        public virtual void CheckHealth()
        {
            var results = new Stack<ProbeResult>();

            var tasks = _probes
                .Select(async o => { results.Push(await o.Check().ConfigureAwait(false)); })
                .ToArray();

            Task.WaitAll(tasks);

            var now = DateTime.UtcNow;
            
            _journalLock.EnterWriteLock();
            
            try
            {
                //cleanup state records
                _journal.RemoveAll(o => o.Time < now.Subtract(RetentionPeriod));
                
                _journal.Add(new JournalRecord(now, results));
            }
            finally
            {
                _journalLock.ExitWriteLock();
            }

            //Recalculate state
            var state = _stateEvaluator.Evaluate(State, StateChangedDate, _journal);

            if (!EqualityComparer<TState>.Default.Equals(State, state))
            {
                ChangeState(state, results.Where(o => !o.Success).Select(o => o.ProbeName));
            }

            OnHealthChecked(now, results);
        }

        protected virtual void OnHealthChecked(DateTime now, IReadOnlyCollection<ProbeResult> results) =>
            HealthChecked?.Invoke(this, new HealthCheckEventArgs(now, results));
    }


Асинхронный режим


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

Посмотреть код
public class AutomatedSpectator<TState> : SpectatorBase<TState>, IAutomatedSpectator<TState> 
        where TState : struct, IConvertible
    {
        public TimeSpan CheckHealthPeriod { get; }

        private readonly System.Timers.Timer _timer;

        public AutomatedSpectator(
            TimeSpan checkHealthPeriod,
            TimeSpan retentionPeriod,
            IStateEvaluator<TState> stateEvaluator, TState initialState)
            : base(stateEvaluator, retentionPeriod, initialState)
        {
            CheckHealthPeriod = checkHealthPeriod;

            _timer = new System.Timers.Timer(CheckHealthPeriod.TotalMilliseconds);
            _timer.Elapsed += (sender, args) => CheckHealth();
            _timer.AutoReset = true;
        }

        public void Start() => _timer.Start();
    }


¦Заключение


Использование X.Spectator помогло лично мне в нескольких высоконагруженных проектах значительно увеличить стабильность работы ряда сервисов. Лучше всего предложенный фреймворк проявил себя при внедрении в распределенные системы, построенные на базе микросервисной архитектуры. Наиболее оптимальный вариант интеграции — используя принцип инверсии управления, а именно внедрения зависимостей, когда процесс внедрения датчиков реализуется с помощью IoC-контейнера, а наблюдатели представлены в виде синглтонов, где единственный экземпляр каждого из наблюдателей может быть доступен различным классам модулям и сервиса.

¦Ссылки и полезная информация


> Репозиторий проекта
> Примеры
> NuGet-пакет

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


  1. Mikluho
    25.07.2019 14:23

    А вы не пробовали заиспользовать Performance Counters?
    Минусом — только цифровые данные.
    Плюсом — реализация на уровне ядра системы, возможность получать данные снаружи приложения, ставить алерты и пр.


    1. Ernado Автор
      25.07.2019 14:35

      Для получения метрик снаружи, в различных проектах я используются Azure Insights и AppMetrics, из которых дальше данные попадают соотвественно в Azure и Kubernetes.
      Также для внешнего мониторинга я использую ASP.NET Health Checks. Но в данном случае решалась немного другая задача. Анализировались метрики относящиеся к бизнес-логике и реагировала на них система "изнутри".


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


      1. Mikluho
        25.07.2019 20:20

        Метрики же можно разными делать. Я на счётчиках выводил именно показатели «живости» системы, а также полезные бизнес-показатели. Что-то что на дашборд в отдел сопровождения — там же и алерты были взведены. Что-то что на дашборд бизнесу — у них тоже была в итоге красивая картинка по их метрикам.
        Счётчики были и с абсолютными значениями, и с аналитическими, такими как соотношения разных типов транзакций или уровень качества.
        И всё это используя стандартные механизмы.


        1. Ernado Автор
          25.07.2019 21:52

          Я правильно понимаю, что вы имеете ввиду .NET PerformanceMonitor?


          1. Mikluho
            25.07.2019 23:15

            Я имел в виду классические счётчики, которые недоступны напрямую в .Net Core. Под Core я со счётчиками пока не игрался.


            1. Ernado Автор
              26.07.2019 10:35

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