Akka и акторы

Часть 7. Модель акторов и её применение для построения распределенных и отказоустойчивых приложений

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

План

  1. Статус по практическому заданию
  2. Практическое задание: http client и API vk.com
  3. Актор — асинхронный объект.
  4. Диспетчеры.
  5. Обработка исключительных ситуаций.
  6. Большой рассказ о гарантиях.
  7. CircuitBreaker.
  8. Регулировка скорости обработки.

План задания

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

По Code Review:

  • Не закрывайте merge request'ы которые мы открываем
  • В проекте должны быть только исходники - уберите .class файлы и конфиги IDEA

HTTP клиент в Play Framework

Подключаем HTTP Client: build.sbt


libraryDependencies += ws
					

В контроллере:


import play.api.libs.ws.WSClient // не путаем с Java API!

class DemoController @Inject()(cc: ControllerComponents, 
  wsClient: WSClient)
					

Делаем HTTP-запросы


wsClient
  .url(url) // куда обращаемся
  .get // выполняем метод, получаем Future
  .map(_.body[JsValue]) // читаем JSON
  .foreach(v ⇒ println(Json.prettyPrint(v)))
					

Подробнее в документации.

Обращаемся к API vk.com

Нет логина на vk.com?
что-нибудь придумаем

Предварительные шаги:

  • Создаем новое приложение на vk.com
  • Включаем приложение
  • Запрашиваем access_token с нужными правами

Смотрим документацию:
Знакомство с API ВКонтакте

Какие нужны права:

  • wall,friends - доступ к ленте новостей
  • offline - без ограничений по времени

Пример URL:


https://oauth.vk.com/authorize?client_id=NNNNN&display=page&redirect_uri=https://oauth.vk.com/blank.html&scope=wall,friends,offline&response_type=token&v=5.52
					

не дает token - перелогиньтесь

/newsfeed - сервис оценки newsfeed

  • Загружаем ленту новостей (используем написаные ранее Reads)
  • Для каждого уникального пользователя запрашиваем его свойства
  • Классифицируем все тексты
  • Отображаем ленту новостей: класс текста, текст с разметкой
  • Показываем имя пользователя и его фото, число likes текста

access_token не выкладывайте на gitlab!

Akka и акторы

Модель акторов - 1973 год,
использовалась как модель для описания параллельных систем.

Позже стала применяться в качестве базы для практических реализаций.

Популярность модель получила благодаря Erlang:
функциональному языку программирования и платформе, разработанной Ericsson (1987 г.)

Дальше будем говорить про реализацию модели в библиотеке Akka.

Подключаем в проект: build.sbt


libraryDependencies += 
  "com.typesafe.akka" %% "akka-actor" % "2.5.11"
					

в Play! зависимость уже и так есть

Актор — асинхронный объект

  • Приватное изменяемое состояние
  • Mailbox - входящая очередь сообщений
  • Логика обработки сообщений (behavior)
  • и др. (об этом позже)
Объект Актор
Вызов counter.incr(n) counter ! Incr(n)
counter.tell(Incr(n))
Запрос counter.get()
⇒ Int
counter ? Get
counter.ask(Get)
⇒ Future[Int]

В ООП логика моделируется взаимодействием объектов.

В модели акторов - взаимодействием акторов путем обмена сообщениями.

В ООП методы можно вызывать из разных потоков.

Потокобезопасность?

  • реализована при помощи блокировок, atomic и т.п.
  • отсутствует, т.е. перекладывается на пользователя

У актора обработка сообщений строго последовательна.

Акторы работают независимо в разных потоках.

Общение двух акторов последовательно
(очередь не перемешивается).

class CounterActor extends Actor {
  import CounterActor._

  private var value: Long = 0 // state

  override def receive = {            // behavior
    case Incr(n) ⇒ value += n
    case Get     ⇒ sender() ! value // или Status.Failure(ex)
  }
}

object CounterActor {        // protocol
  case class Incr(n: Long)
  case object Get
} 

object Main extends App {
  val system = ActorSystem("DemoSystem")
  val counterActor: ActorRef = 
    system.actorOf(Props[CounterActor], name = "counter")

  counterActor ! Incr(1)
  println(Await.result(counterActor ? Get, 1.minutes))
}
					

ActorRef — идентификатор актора.

В Akka Typed идентификатор актора ActorRef[Protocol]

(этот API пока еще experimental)

Приватное состояние - не только переменные.

Актор может скрывать за собой внешние сущности:

  • RandomAccessFile (seek/read).
  • данные во внешнем хранилище.
  • и др.

Разделяемое изменяемое состояние + параллелизм = проблемы

Разделяемое изменяемое состояние + параллелизм = «Share nothing» архитектура

Что можно отправлять актору?

  • Неизменяемые данные
  • Ссылки на акторы - ActorRef

Изменяемые объекты посылать нельзя!

Можно ли посылать функции?

  • Можно, потому что в функции тоже неизменяемые
  • Нельзя, если в замыкании мутабельные структуры.
    И еще это сомнительно с точки зрения модели.

Можно ли посылать внешние объекты, вроде коннектов к БД, сокетов, т.п.?

  • Нельзя, это нарушение модели
  • Можно, когда понимаем, что делаем и зачем.
    Так делают на "границе" между акторами и внешним миром.

Пример актора: классификатор с обучением

Один актор на классификацию и обучение - просто, не эффективно

Актор для обучения, get запрос для модели - нужна immutable модель или функция копирования

Location transparency

Акторы могут быть расположены в разных виртуальных машинах и на разных серверах.

Взаимодействие акторов по сети не похоже на RPC (Remote Procedure Call).

  • Классический RPC — удаленные вызовы подобны локальным
  • Акторы — локальные «вызовы» подобны удаленным

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

Диспетчеры

  • Runtime для акторов
  • Работают поверх Executor'ов
  • Рабочие треды — разделяемый ресурс

Диспетчеры — проблемы

  • Не все акторы одинаковы
  • Акторы с долгими задачами могут занять все треды
  • Диалоги с большим количеством сообщений занимают диспетчер

Диспетчеры под задачи

  • Интерактивные задачи vs фоновая пакетная обработка.
  • Блокирующиеся вызовы (JDBC, файловый ввод-вывод, вычисления на CPU).
  • Свой «отсек» на каждую подсистему (bulkheading).
  • Своя JVM и свой сервер на каждую подсистему (Akka Remote / Cluster).

Факт: «Титаник» утонул из-за плохого разделения на отсеки.

Работа с Future в акторе

асинхронные API или ask на другие акторы

Пример

class CacheActor(source: ⇒ Future[Int]) extends Actor {
  private var value: Long = 0

  override def receive = {  
    case Get  ⇒ 
      sender() ! value
    case RefreshAndGet ⇒
      import context.dispatcher // нужен ExecutionContext

      source.onComplete {
        ??? // другой поток
      }
  }
}
					

override def receive = {  
  case Get  ⇒
    sender() ! value
  case RefreshAndGet ⇒
    source.onComplete {
      case Success(v) ⇒ self ! Refresh(v)
      case Failure(ex) ⇒ // handle failure  
    }
  case Refresh(v) ⇒
    value = v
  // нужно еще отправить результат 
}
					

sender() ?


case RefreshAndGet ⇒
  source.onComplete {
    case Success(v) ⇒ 
      self ! Refresh(v)
      sender() ! v // ошибка - тут sender() не определен
    case Failure(ex) ⇒ 
  }
case Refresh(v) ⇒
  value = v
  sender() ! v // ошибка - тут sender() это мы

					

Решение - сохраняем sender()


case RefreshAndGet ⇒
  val replyTo = sender()
  
  source.onComplete {
    case Success(v) ⇒
      self ! Refresh(v)
      replyTo ! v
    case Failure(ex) ⇒
  }
					

В Akka Typed нет sender(), его явно передают

pipeTo: посылаем результат актору


import akka.pattern.pipe

source pipeTo self
					

Успех - отправляет результат,
ошибка - Status.Failure(ex)

Смена behavior

Актор может переключать реализации receive:


private def waiting: Receive = {
  case GetReady ⇒
    context.become(ready)
}

private def ready: Receive = {
  case TakeRest ⇒
    context.become(waiting)
}

override def receive = waiting
					

Основные применения:

  • машины состояний
  • "immutable" акторы

Пример: "immutable" counter


class CounterActor extends Actor {
  import CounterActor._
  
  private def counter(value: Long): Receive = {
    case Get     ⇒ sender() ! value
    case Incr(n) ⇒ context.become(counter(value + n))
    
  }

  override def receive = counter(0)
}
					

Плюсы/минусы:

  • Более функциональный подход; безопаснее
  • Подход "по умолчанию" в Akka Typed
  • Однако мутабельная версия часто эффективнее

Stash

Можно временно отложить сообщения:

 // extends Actor with Stash
private def waiting: Receive = {
  case Process(task) ⇒
    stash()
  case GetReady ⇒
    context.become(ready)
    unstashAll()
}

private def ready: Receive = {
  case TakeRest ⇒
    context.become(waiting)
  case Process(task) ⇒
    ???
} 

Пример: последовательная обработка и Future

  • Запрашиваем Future, переходим в waiting
  • В waiting кладем запросы в Stash
  • При вычислении Future - unstash и обратно в ready
  • Не забываем про ошибки, таймауты и пр.

Вместо Stash иногда эффективнее
накапливать запросы в state

Таймеры и Deadline

Deadline - точка во времени


import scala.concurrent.duration._

val deadline: Deadline = 5 minutes fromNow
					
  • Использует монотонные часы
  • Используется в акторах для прерывания обработки запроса

deadline.hasTimeLeft() // проверки
deadline.isOverdue()

// величина таймаута
val timeout: Duration = deadline.timeLeft
					

Таймеры


class CounterActor extends Actor with Timers {
  import CounterActor._

  // key для уникальности таймера
  // msg - отправляемое сообщение
  timers.startSingleTimer(key = Tick, msg = Tick, 1 minute)

  timers.startPeriodicTimer(key = Tick, msg = Tick, 1 minute)
  
  timers.cancel(Tick)

  timers.cancelAll()
					

Зачем?

  • Обработка таймаутов
  • Периодическое обновление, очистка
  • Печать статистики работы

Receive Timeout - реакция на отсутствие сообщений

Защитный механизм для коротокоживущих акторов

context.setReceiveTimeout(5 minutes)

// отправляет ReceiveTimeout при отсутствии активности
					

Игнорирует сообщения расширяющие NotInfluenceReceiveTimeout

Обработка исключительных ситуаций

Виды ошибок в автомате с газировкой:

  • «Не достаточно средств», «закончилась кола» — пользователю
  • NullPointerException, CocaColaJam — владельцу (супервизору)

Пример: драйвер базы данных

  • Дублирование первичного ключа, нарушение foreign key — пользователю драйвера
  • «БД перезагружается», «Соединение разорвано» — супервизору

Иерархия: DriverActor → ConnectionActor → TransactionActor

Стратегии супервизора

  • Restart — незаметный перезапуск.
  • Stop — остановка.
  • Escalate — обрушивает супервизор.

Пример для ConnectionActor


override val supervisorStrategy =
  OneForOneStrategy(maxNrOfRetries = 10, 
   withinTimeRange = 1 minute) {
    case _: ConnectionResetException        ⇒ Restart
    case _: DatabaseWasDeletedException     ⇒ Stop
    case _: SharedMemoryCorruptionException ⇒ Escalate
  }
						
					

Кроме быстрого перезапуска есть еще exponential back off

Death pact & death watch

Пример: один запрос – три иерархии.

Ожидание остановки актора:


context.watch(anotherActorRef)
	

Либо ловим сообщение Terminated(actorRef), либо «death pact» — останавливаемся вместе.

Отсутствие гарантий доставки

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Вернемся к Akka

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

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

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

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

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

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

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

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

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

«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-м занятии.

Напоминаю:

Роутеры

Дополнительный материал

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

  • RoundRobinRouting
  • SmallestMailboxRouting
  • RandomRouting

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

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

ScatterGatherFirstCompletedRouting

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

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

TailChoppingRouting

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

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

Напоминаю: