Scala
Akka

Akkaの分散されたアクター間でDBを使わずにデータを共有する方法

More than 1 year has passed since last update.

本記事は TIS Engineer Advent Calendar 2015 23日目の記事です。

はじめに

Akka で分散システムを構築したときに、分散された複数のアクター間で常に更新されるデータを共有することを考えます。DBを使わずにやろうとすると、共有データを管理するためのアクターを1つ作っておき、常にそのアクターにデータの取得と更新を依頼するという方法が思いつきます。しかし、この方法ではデータを共有するためのアクターが単一障害点になってしまうということと、その部分のスケーラビリティを確保できないという問題があります。

耐障害性とスケーラビリティを確保したまま、Akkaのアクター間でデータを共有するにはどうすれば良いのでしょうか?こういうケースでは Akka の Distributed Data というモジュールが使えます。Distributed Data を使うと、DBを使わずに Akka だけで耐障害性とスケーラビリティを兼ね備えたデータ共有がシンプルに実現できます。

Distributed Data では、RDBの悲観的ロックようにきっちりと整合性を保つことはせず、結果整合性をとる形でデータが共有されます。つまり、データを共有している複数のアクターに、各自が持っている共有データを聞いてもそれぞれ違う結果を返す場合があるということです。ただし、時間が経つと全員同じ結果を返すようになります。CAP定理で言うところの可用性と分断耐性をとるパターンです。

どうやって結果整合性を保つのか?

CRDT(Conflict-free replicated data type)という枠組みを使って結果整合性を保ちます。詳しくはこのスライドこの動画(英語)を見て下さい。簡単に言うと「データの更新があって一貫性のない状態になったとしても後でマージして一貫性を保つようにする。マージするときに競合すると困るので、競合しないようなデータ構造を定義しておく。」ということです。

試してみる

今回は、アドベントカレンダーということでクリスマスっぽいシステムを考えてみました。

christmas-system.png

Christmas System は複数の Santa Claus Village Node と City Node で構成されるクラスタシステムです。Santa Claus Village Node にはサンタクロースのアクターが複数居ます。 City Node には子どものアクターが複数居ます。子どもたちは Santa Claus Village Node にある郵便局(Post office)に手紙(Letter)を送ります。 手紙は郵便局を経由して、サンタクロースの誰か1人に届きます。サンタクロースはその手紙の送り主にプレゼント(Gift)を送ります。

サンタクロースは子どもたちが大好きなので、どのような子どもが居るか知りたがります。そこで、同じ Node に居るサンタクロースと、別の Node に居るサンタクロース全員で子どもたちの名簿(Children Name Set)を共有することにしました。それぞれのサンタクロースが自分用の名簿を持っていて、名簿には、手紙を受け取ったサンタクロースがその手紙の送り主の子どもの名前を追記していきます。すると、妖精の力(Distributed Data)によって、自動的に他のサンタクロースが持っている名簿にも追記された子どもの名前が反映されます。

実装

まず、Akka のプロジェクトに akka-distributed-data への依存を追加する必要があります。

build.sbt
val akkaVersion = "2.4.1"

libraryDependencies ++= Seq(
  // ...  省略 ...
  "com.typesafe.akka" %% "akka-distributed-data-experimental" % akkaVersion
)

コードを書く前に、まずは共有データをCRDTのどの型で取り扱うか決める必要があります。今回は子どもたちの名簿を作りたいので、Stringを格納できるようなデータ構造を選ぶことにします。子どもたちの名前は重複せず(ここではそういうことにしておきます)、追記されていくだけなので、CRDTのGSet(追加だけできるSet)を使うことにしました。

DistributedData(context.system).replicator で replicator というアクターを作ることができます。共有データの更新や取得はこのアクターを通じて行います。

src/main/scala/com/example/SantaClausActor.scala
class SantaClausActor extends Actor with ActorLogging {

  val replicator = DistributedData(context.system).replicator

  // ... 省略 ...
}

replicator を通じてデータを共有するには、そのデータを識別するためのキーを定義しておく必要があります。GSetのためのGSetKeyを定義します。型引数には子どもたちの名前を格納するためにStringを指定しておきます。

src/main/scala/com/example/SantaClausActor.scala
class SantaClausActor extends Actor with ActorLogging {

  val childrenNameSetKey = GSetKey[String]("children-name-set")

  // ... 省略 ...
}

replicator ! Replicator.Subscribe(childrenNameSetKey, self)とすることによってchildrenNameSetKeyに紐づくデータの更新を通知してもらえるようになります。データの更新はReplicator.Changedという型で通知されます。パターンマッチで共有データのキーを取り出すことができます。key == childrenNameSetKey の時だけ処理されるようにしています(今回は Subscribe しているキーが1種類なので無くてもかまいません)。c.get(childrenNameSetKey)で現在の共有データを取得しています。このデータは結果整合性を取るので、同時に他のノードのデータを確認すると異なるデータになっている可能性があります。

src/main/scala/com/example/SantaClausActor.scala
class SantaClausActor extends Actor with ActorLogging {

  // ... 省略 ...

  def receive = {

    case c @ Replicator.Changed(key) if key == childrenNameSetKey =>
      val childrenNameSet = c.get(childrenNameSetKey)

      log.info("Ho! Ho! Ho! I'm {}. We received letters from {} children! {}", myName, childrenNameSet.elements.size, childrenNameSet)
  }

  override def preStart() = {

    replicator ! Replicator.Subscribe(childrenNameSetKey, self)
  }
}

共有データの更新はreplicator ! Replicator.Update(...) で行います。Updateには更新するデータのキー、データの初期値、データを他のノードにどのように伝播させる方法を指定します。WriteLocalを指定した場合は、即座に自ノードだけにデータの更新を反映し、後で他のノードに更新を伝播させるという動きになります。詳細は公式ドキュメントのUpdateの章を参照してください。

第4引数にはデータを更新するときに用いる演算を指定します。手紙を受け取ったら名簿に送り主の名前を追記したいので、GSet+LetterfromNameを追加します。

src/main/scala/com/example/SantaClausActor.scala
class SantaClausActor extends Actor with ActorLogging {

  // ... 省略 ...

  def receive = {

    case ChildActor.Letter(fromName) =>

      replicator ! Replicator.Update(childrenNameSetKey, GSet.empty[String], WriteLocal)(_ + fromName)

      log.info("Ho! Ho! Ho! I'm {}. I received a letter from {}!", myName, fromName)

      sender() ! Gift()
  }
}

以上でデータが共有できるようになりました!簡単ですね!

動きの確認

3人の子どもたちが居る City Node を1台、サンタクロースが3人居る Santa Claus Village Node を2台起動します。ちなみに、子どもとサンタの名前は実装を簡単にするために全てハッシュ値になっています。

city-node-1
$ cd ~/akka-distributed-data-sample
$ activator run-city
[info] [11:08.443] I'm 2fff3f6b. I sent a letter to Santa Claus!
[info] [11:08.478] I'm 2fff3f6b. I received a gift! Thanks, Santa Claus! ;>
[info] [11:11.437] I'm 2138c53c. I sent a letter to Santa Claus!
[info] [11:11.438] I'm 59ff92c0. I sent a letter to Santa Claus!
[info] [11:11.443] I'm 2138c53c. I received a gift! Thanks, Santa Claus! ;>
[info] [11:11.460] I'm 59ff92c0. I received a gift! Thanks, Santa Claus! ;>
santa-claus-village-node-1
$ cd ~/akka-distributed-data-sample
$ activator run-santa-claus-village
[info] [11:08.468] Ho! Ho! Ho! I'm 60ac6dd8. I received a letter from 2fff3f6b!
[info] [11:08.763] Ho! Ho! Ho! I'm 51002b3a. We received letters from 1 children! GSet(Set(2fff3f6b))
[info] [11:08.763] Ho! Ho! Ho! I'm 60ac6dd8. We received letters from 1 children! GSet(Set(2fff3f6b))
[info] [11:08.764] Ho! Ho! Ho! I'm 643ee5f8. We received letters from 1 children! GSet(Set(2fff3f6b))
[info] [11:11.441] Ho! Ho! Ho! I'm 51002b3a. I received a letter from 2138c53c!
[info] [11:11.760] Ho! Ho! Ho! I'm 643ee5f8. We received letters from 2 children! GSet(Set(2fff3f6b, 2138c53c))
[info] [11:11.761] Ho! Ho! Ho! I'm 60ac6dd8. We received letters from 2 children! GSet(Set(2fff3f6b, 2138c53c))
[info] [11:11.761] Ho! Ho! Ho! I'm 51002b3a. We received letters from 2 children! GSet(Set(2fff3f6b, 2138c53c))
[info] [11:12.763] Ho! Ho! Ho! I'm 60ac6dd8. We received letters from 3 children! GSet(Set(2fff3f6b, 2138c53c, 59ff92c0))
[info] [11:12.764] Ho! Ho! Ho! I'm 643ee5f8. We received letters from 3 children! GSet(Set(2fff3f6b, 2138c53c, 59ff92c0))
[info] [11:12.764] Ho! Ho! Ho! I'm 51002b3a. We received letters from 3 children! GSet(Set(2fff3f6b, 2138c53c, 59ff92c0))
santa-claus-village-node-2
$ cd ~/akka-distributed-data-sample
$ activator add-santa-claus-village
[info] [11:09.072] Ho! Ho! Ho! I'm 5e54118d. We received letters from 1 children! GSet(Set(2fff3f6b))
[info] [11:09.072] Ho! Ho! Ho! I'm 724130e7. We received letters from 1 children! GSet(Set(2fff3f6b))
[info] [11:09.073] Ho! Ho! Ho! I'm 453c5952. We received letters from 1 children! GSet(Set(2fff3f6b))
[info] [11:11.455] Ho! Ho! Ho! I'm 453c5952. I received a letter from 59ff92c0!
[info] [11:11.565] Ho! Ho! Ho! I'm 724130e7. We received letters from 2 children! GSet(Set(2fff3f6b, 59ff92c0))
[info] [11:11.566] Ho! Ho! Ho! I'm 5e54118d. We received letters from 2 children! GSet(Set(2fff3f6b, 59ff92c0))
[info] [11:11.567] Ho! Ho! Ho! I'm 453c5952. We received letters from 2 children! GSet(Set(2fff3f6b, 59ff92c0))
[info] [11:12.564] Ho! Ho! Ho! I'm 5e54118d. We received letters from 3 children! GSet(Set(2fff3f6b, 59ff92c0, 2138c53c))
[info] [11:12.564] Ho! Ho! Ho! I'm 724130e7. We received letters from 3 children! GSet(Set(2fff3f6b, 59ff92c0, 2138c53c))
[info] [11:12.564] Ho! Ho! Ho! I'm 453c5952. We received letters from 3 children! GSet(Set(2fff3f6b, 59ff92c0, 2138c53c))

サンタクロースは手紙を受け取るとHo! Ho! Ho! I'm XXX. I received a letter from XXX!と言った後に名簿を更新します。名簿が更新されるとサンタクロース全員に通知されます(Replicator.Changed)。通知されるとHo! Ho! Ho! I'm XXX. We received letters from X children! と言います。ログを見てみると、santa-claus-village-node-1santa-claus-village-node-2でサンタクロースが受け取った名前の一覧(GSet(Set(...))の中身)が異なります(下記)。

santa-claus-village-node-1
[info] [11:11.441] Ho! Ho! Ho! I'm 51002b3a. I received a letter from 2138c53c!
[info] [11:11.760] Ho! Ho! Ho! I'm 643ee5f8. We received letters from 2 children! GSet(Set(2fff3f6b, 2138c53c))
[info] [11:11.761] Ho! Ho! Ho! I'm 60ac6dd8. We received letters from 2 children! GSet(Set(2fff3f6b, 2138c53c))
[info] [11:11.761] Ho! Ho! Ho! I'm 51002b3a. We received letters from 2 children! GSet(Set(2fff3f6b, 2138c53c))
santa-claus-village-node-2
[info] [11:11.455] Ho! Ho! Ho! I'm 453c5952. I received a letter from 59ff92c0!
[info] [11:11.565] Ho! Ho! Ho! I'm 724130e7. We received letters from 2 children! GSet(Set(2fff3f6b, 59ff92c0))
[info] [11:11.566] Ho! Ho! Ho! I'm 5e54118d. We received letters from 2 children! GSet(Set(2fff3f6b, 59ff92c0))
[info] [11:11.567] Ho! Ho! Ho! I'm 453c5952. We received letters from 2 children! GSet(Set(2fff3f6b, 59ff92c0))

これは、ほぼ同時に2つの異なるノードに子どもたちからの手紙が届いたことが原因です。これが、一貫性のとれていない状態です。しかし、時間が経つとサンタクロース全員が同じ名前の一覧を受け取り、一貫性の取れた状態になっています(下記)。Setなので名前の並び順は保証されず、異なる並び順になっています。

santa-claus-village-node-1
[info] [11:12.763] Ho! Ho! Ho! I'm 60ac6dd8. We received letters from 3 children! GSet(Set(2fff3f6b, 2138c53c, 59ff92c0))
[info] [11:12.764] Ho! Ho! Ho! I'm 643ee5f8. We received letters from 3 children! GSet(Set(2fff3f6b, 2138c53c, 59ff92c0))
[info] [11:12.764] Ho! Ho! Ho! I'm 51002b3a. We received letters from 3 children! GSet(Set(2fff3f6b, 2138c53c, 59ff92c0))
santa-claus-village-node-2
[info] [11:12.564] Ho! Ho! Ho! I'm 5e54118d. We received letters from 3 children! GSet(Set(2fff3f6b, 59ff92c0, 2138c53c))
[info] [11:12.564] Ho! Ho! Ho! I'm 724130e7. We received letters from 3 children! GSet(Set(2fff3f6b, 59ff92c0, 2138c53c))
[info] [11:12.564] Ho! Ho! Ho! I'm 453c5952. We received letters from 3 children! GSet(Set(2fff3f6b, 59ff92c0, 2138c53c))

これで、結果整合性のとれたデータ共有が実現できたことを確認できました。

まとめ

Akka の Distributed Data は experimental(実験的) なモジュールです。APIが頻繁に変わったり、将来的にモジュール自体が削除される可能性があります。趣味以外のシステムで使うときは注意してください。

今回は CRDT のGSetを利用した実装を紹介しましたが、標準で下記のデータ構造がサポートされています。これらのデータ構造を組み合わせることで、競合のない安全なデータ共有が可能になります。

概要
GCounter 値を増加させるだけのカウンタ
PNCounter 値を増減させることができるカウンタ
LWWRegister ただ一つの値を持つコンテナ
最後に更新された値が取得できる
Flag falsetrueにできるフラグ
trueの値をfalseにすることはできない
GSet 追加のみ可能なSet
ORSet 追加と削除が可能なSet
ORMap Stringがキーの追加と削除が可能なMap
ORMultiMap Value がORSetORMap(同じキーへ値を追加できる)
LWWMap Value が LWWRegisterORMap
同じキーへの更新があったときは、最後に更新された値が取得できる
PNCounterMap Value が PNCounterORMap

ただし、ノードをまたいだ連番の発行など、CRDTでは実現できないこともあります。また、実装したいビジネスロジックに対してどの型が適切かを慎重に選定する必要があり、設計が難しくなります。なので、低レイテンシであることへの要求が高く、結果整合性がとれれば問題ないところには、Distributed Data を用いる。レイテンシが高くなっても問題ない部分では、DBを使ったデータ共有を用いる。というふうに、トレードオフを考える必要があると思います。

参考資料