Акторы Akka: отказоустойчивость и гарантии. Back pressure. Роутеры.

Страничка курса: https://maxcom.github.io/scala-course-2022/

План

  1. Доставка сообщений и сбои.
  2. CircuitBreaker.
  3. Back pressure
  4. Роутеры

Гарантии доставки сообщений

Сообщение теряется при:

  • Рестарте актора
  • Переполнении ограниченного mailbox
  • Сетевых проблемах
  • ...

Что гарантирует Akka?

  • "Доставку" в локальный mailbox
  • Получение "Terminated"

Только одна попытка доставки:
«at most once delivery»

Потеря сообщений - проблема "модели"?

Нет, это часть модели отказоустойчивости.

Пример: кофейня готовит кофе и принимает платежи.

Платежи со счета клиента,
счет хранится в БД кофейни.

Наша кофейня использует идеальную БД,
плюс нет параллельных запросов

  • A - Atomicity
  • C - Consistency
  • I - Isolation
  • D - Durability

Счет каждого посетителя хранится в табличке

Списания - через UPDATE

Согласована ли связка ПО + БД?

Наивное предположение:

  • Успешная транзакция - деньги списались
  • Неуспешная транзакция - не списались

Что значит ошибка сети или таймаут?

  • Не получилось начать транзакцию
  • Транзакция прошла, но не прошло подтверждение
  • Процесс еще не окончен
  • + ошибки и сбои (которых нет в идеальной БД)

Таймаут? Таймаут есть всегда

Итог - при сбое мы не знаем, списали деньги или нет.

Retry - возможно двойное списание, отказ от retry - возможна потеря денег

В асинхронной системе похожие проблемы:

  • Переполненные очереди
  • Медленные обработчики
  • Плохо обработанные сбои и ошибки

Эти проблемы - проблемы как с акторами, так и без.

Решение проблемы

  • Таймауты случаются. Нужно их обрабатывать.
  • Сбои допустимы, сбои всегда частичные.
  • Механизмы компенсации сбоев обязательны (например, журналы у всех участников операции и процедуры сверки)
Компоненты распределенной системы
действуют независимо.
  • Блокировки снижают производительность и доступность. Блокировки всегда локальны.
  • Глобальной консистентности нет, пока идут операции.
  • Каждая операция консистентна только в конечном счете

Вернемся к акторам

Сбой нельзя детектировать (в общем случае).

Сбой и таймаут не отличимы.
Таймаут — не конец операции.

Потеря сообщения при доставке - это вариант сбоя.

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

Что обычно можно гарантировать?

Положительный ответ только после записи в хранилище или выполнения действия

Откладывание выполнения с гарантиями - передача "эстафетной палочки". Похоже на передачу email.

Команды и события

  • Команда: сделай что-то. Выполняем её с гарантиями. Терять нельзя.
  • Событие: что-то произошло, реагируй на это. Можем пропустить, если потом можем это компенсировать.

Пример: гарантированное выполнение независимых запросов

Повторяем запрос, пока не получим подтверждение


object Requester {
  sealed trait Protocol

  case class Request(id: UUID, query: String, 
                     replyTo: ActorRef[String]) extends Protocol

  private implicit val timeout: Timeout = 10.seconds

  def behavior(worker: ActorRef[Protocol]): Behavior[Protocol]
}
					
private case class Finished(request: Request, response: String) 
  extends Protocol
private case class Failed(id: UUID, ex: Throwable) 
  extends Protocol

Behaviors.setup { ctx =>
  def start(rq: Request) = {
    ctx.ask(worker, Request(rq.id, rq.query, _)) {
      case Success(r) => Finished(rq, r)
      case Failure(ex) => Failed(rq.id, ex)
    }

    rq.id -> rq
  }
  ...
получение запроса

def process(processing: Map[UUID, Request]) = 
  Behaviors.receive[Protocol] {
    case (ctx, rq: Request) =>
      process(processing + start(ctx)(rq))
  ...
					
обработка ответа

case (_, Finished(rq, response)) =>
  rq.replyTo ! response
  process(processing - rq.id)
case (_, Failed(id, ex)) =>
  process(start(processing(id))
  Behaviors.same // logging?
					

Таймаут?

  • Нет общего таймаута на запрос.
  • Нет "защиты" получателя.

Итог: "At least once delivery"

Проблема - дублирование действий.

Хорошо, когда действия идемпотентны.

Другой вариант: фильтруем дубли в worker по id.

Итог: "Exactly once delivery"

Еще проблема - порядок не сохраняется

Реализуем очередь

Akka 2.6.4+: готовая реализация для нескольких сценариев

Пример дизайна сервиса индексации сообщений

Перегоняем данные СУБД в Elasticsearch и Clickhouse

Требования:

  • Индексация не мешает укладке
  • Когда все хорошо - индексируем новое сразу
  • Когда плохо - индексируем, когда можем
  • Иногда нужна переиндексация

Первый подход - нужна гарантированная очередь.

Это порождает массу проблем.

Появление нового в БД - событие, а не команда.

«Happy path & self heal»

«Happy path» — новые сообщения индексируем сразу; игнорируем сбои

«Self heal» – периодически проводим сверку и запускаем в обработку то, что не смогли обработать ранее.

В прошлом году была еще лекция
про Event Sourcing и CQRS.

Circuit Breaker

Паттерн, реализованный в Akka.
Есть другие реализации.

Проблема: каскадные сбои при перегрузках запросами. Безумные политики retry.

Проблема: медленное восстановление после нормализации потока.

Не только в акторах: очереди есть везде

«Предохранитель», который можно вставлять между подсистемами.

«Разрывает цепь» при серии сбоев

«Замыкает цепь» после паузы и успешных пробных запросов.

В Akka реализован для Future.

Реализация требует ActorSystem

Пример:


  private val breaker = new CircuitBreaker(
    scheduler = scheduler, // context.system.scheduler
    maxFailures = 5,
    callTimeout = 10.seconds,
    resetTimeout = 1.minute
  )

  def process(task: Task): Future[Result] = {
    breaker.withCircuitBreaker {
      askAnotherService(task) // :Future[Result]
    }
  }
	

Это защитный механизм.

Не используем для регулировки скорости!

Регулировка скорости обработки

Проблема: быстрый producer + медленный consumer = Out Of Memory

  • выбрасываем то, что не успеваем обработать
  • замедляем producer'а (back pressure)

При синхронных вызовах проблемы нет

Back pressure

Обратная связь от consumer к procuder.

Раньше делали сами - считали подтверждения для оценки размера очереди.

Это сильно усложняет актор.

Сейчас используем Akka Streams:
об этом на 10-й лекции.

Роутеры

Раздаем задачи пулу акторов

Похожи на акторы, low level реализация

Два механизма использования:

  • Pool - "рабочие" акторы являются дочерними
  • Group - распределяет нагрузку по существующим акторам

Простые стратегии:

  • RoundRobinRouting - для долгих задач
  • RandomRouting - для коротких
пример

object Calculator {
  case class Calc(x: Long, replyto: ActorRef[Long])

  def worker: Behavior[Calc] = Behaviors.receiveMessage {
    case Calc(x, replyto) =>
      replyto ! doWork(x) 
      Behaviors.same
  }

  ...
}
					

def calculator: Behavior[Calc] = Behaviors.setup[Calc] { ctx =>
  // round-robin по умолчанию
  val pool = Routers.pool(poolSize = 16)(
    Behaviors.supervise(worker)
      .onFailure[Exception](SupervisorStrategy.restart))

  val router = ctx.spawn(pool, "worker-pool")

  Behaviors.receiveMessage[Calc] { msg =>
    router ! msg
    Behaviors.same
  }
}
					

Проблема - сообщения перемешиваются

Актор - это не только вычисления, но и модель доступа к данным.

Пример - хранилище данных учетных записей

  • Один актор на всех - медленно
  • Каждому по актору - слишком много акторов
  • Группа акторов - случайное распределение задач

ConsistentHashingRouting - роутинг по ключу на основе сообщения

  • сохраняет порядок для сообщений с одним ключем
  • сохраняет обработчика для сообщений с одним ключем
  • Consistent - определенная логика resize пула

В классической Akka есть еще интересные роутеры

ScatterGatherFirstCompletedRouting

Отправляем всем, используем первый ответ.

Улучшаем время отклика, но выполняем работу N раз

TailChoppingRouting

Отправляем первому, через короткий таймаут второму и продолжаем, пока не получим первый ответ.

Улучшаем худшие времена отклика.

Напоминаю: