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

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

План

  1. Доставка сообщений и сбои.
  2. CircuitBreaker.
  3. Back pressure
  4. Роутеры
  5. Статус по практическому заданию

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

За одно отметим:

  • Блокировки охватывающие несколько подсистем не возможны
  • Глобальной консистентности нет пока идут операции
  • Каждая операция консистентна только в конечном счете

Вернемся к Akka

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

Сбой и таймаут не отличимы.

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

А еще доставка внутри JVM гарантирована.

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

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

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

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

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

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


object Requester {
  sealed trait Protocol

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

  private implicit val timeout: Timeout = 10.seconds
  private val recheck: FiniteDuration = 1.second

  def behavior(worker: ActorRef[Protocol]): Behavior[Protocol]
}
					

private case object Tick extends Protocol

Behaviors.withTimers { timers =>
  timers.startTimerWithFixedDelay(Tick, recheck)

  ...
}
					

private case class Finished(request: Request, response: String) 
  extends Protocol
private case class Failed(id: UUID, ex: Throwable) 
  extends Protocol

def process(processing: Map[UUID, (Request, Deadline)]) = {
  def start(ctx: ActorContext[Protocol])(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, timeout.duration.fromNow)
  }

  ...
					

Behaviors.receive[Protocol] {
  case (ctx, rq: Request) =>
    ctx.ask(worker, Request(rq.id, rq.query, _)) {
      case Success(r) => Finished(rq, r)
      case Failure(ex) => Failed(rq.id, ex)
    }

    process(processing + start(ctx)(rq))
  ...
					

case (_, Finished(rq, response)) =>
  rq.replyTo ! response
  process(processing - rq.id)
case (_, Failed(id, ex)) =>
  Behaviors.same // logging?
case (ctx, Tick) =>
  val restarted = processing.values
                            .filter(_._2.isOverdue()).map(_._1)

  process(processing ++ restarted.map(start(ctx)))
					

Особенности реализации:

  • Могут быть дубли (идемпотентность или фильтрация дублей)
  • Сообщения перемеживаются
  • Нет общего таймаута на запрос

Нужено сохранить порядок?

пронумеруем сообщения

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

«Happy path & self heal»

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

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

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

Circuit Breaker

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

«Разрывает цепь» при частых ошибках.

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

Можно использовать вне Akka.

Пример:


  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)
    }
  }
	

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

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

Решение: выбрасываем то, что не успеваем обработать

Решение: замедляем producer'а (back pressure)

Back pressure

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

Сейчас используем Akka Streams:
поговорим об этом на 8-м занятии.

Роутеры

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

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

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

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

  • RoundRobinRouting
  • RandomRouting

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

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

  ...
}
					

def calculator: Behavior[Calc] = Behaviors.setup[Calc] { ctx =>
  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 - роутинг по ключу на основе сообщения

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

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

ScatterGatherFirstCompletedRouting

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

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

TailChoppingRouting

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

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

Напоминаю:

Статус по практическому заданию

План задания

  1. Классификатор текстов
  2. Reads для vk.com (только reads! writes не нужно)
  3. Стемминг и диагностика
  4. Получаем сообщения API vk.com
  5. Сервис категоризации
    -------- мы находимся здесь -------
  6. Опрос новых записей
  7. Хранение состояния на диске
  8. Realtime обновление

Практическое задание: обновление ленты в акторе

  • Загрузку ленты и классификацию выносим в актор
  • Актор хранит ленту в памяти, запросы Get и Refresh
  • Дубликаты выбрасываем по id
  • Ленту автоматически обновляем раз в 3 минуты

Как использовать акторы в контроллерах Play?

Play использует "классический" API


import akka.actor.ActorSystem

@Singleton // обязательно! иначе акторы будут размножаться
class Classifier @Inject()(cc: ControllerComponents, 
                           actorSystem: ActorSystem) 
			     extends AbstractController(cc) {
					

import akka.actor.typed.scaladsl.adapter._

val typedActor: ActorRef[Protocol] = 
  actorSystem.spawn(behavior, "name")