本文由 GodPan 發表在 ScalaCool 團隊部落格。
這次把這部分內容提到現在寫,是因為這段時間開發的專案剛好在這一塊遇到了一些難點,所以準備把經驗分享給大家,我們在使用Akka時,會經常遇到一些儲存Actor內部狀態的場景,在系統正常執行的情況下,我們不需要擔心什麼,但是當系統出錯,比如Actor錯誤需要重啟,或者記憶體溢位,亦或者整個系統崩潰,如果我們不採取一定的方案的話,在系統重啟時Actor的狀態就會丟失,這會導致我們丟失一些關鍵的資料,造成系統資料不一致的問題。Akka作為一款成熟的生產環境應用,為我們提供了相應的解決方案就是Akka persistence。
為什麼需要持久化的Actor?
萬變不離其宗,資料的一致性是永恆的主題,一個效能再好的系統,不能保證資料的正確,也稱不上是一個好的系統,一個系統在執行的時候難免會出錯,如何保證系統在出錯後能正確的恢復資料,不讓資料出現混亂是一個難題。使用Actor模型的時候,我們會有這麼一個想法,就是能不對資料庫操作就儘量不對資料庫操作(這裡我們假定我們的資料庫是安全,可靠的,能保證資料的正確性和一致性,比如使用國內某雲的雲資料庫),一方面如果大量的資料操作會使資料庫面臨的巨大的壓力,導致崩潰,另一方面即使資料庫能處理的過來,比如一些count,update的大表操作也會消耗很多的時間,遠沒有記憶體中直接操作來的快,大大影響效能。但是又有人說記憶體操作這麼快,為什麼不把資料都放記憶體中呢?答案顯而易見,當出現機器當機,或者記憶體溢位等問題時,資料很有可能就丟失了導致無法恢復。在這種背景下,我們是不是有一種比較好的解決方案,既能滿足需求又能用最小的效能消耗,答案就是上面我們的說的Akka persistence。
Akka persistence的核心架構
在具體深入Akka persistence之前,我們可以先了解一下它的核心設計理念,其實簡單來說,我們可以利用一些thing來恢復Actor的狀態,這裡的thing可以是日誌、資料庫中的資料,亦或者是檔案,所以說它的本質非常容易理解,在Actor處理的時候我們會儲存一些資料,Actor在恢復的時候能根據這些資料恢復其自身的狀態。
所以Akka persistence 有以下幾個關鍵部分組成:
- PersistentActor:任何一個需要持久化的Actor都必須繼承它,並必須定義或者實現其中的三個關鍵屬性:
def persistenceId = "example" //作為持久化Actor的唯一表示,用於持久化或者查詢時使用
def receiveCommand: Receive = ??? //Actor正常執行時處理處理訊息邏輯,可在這部分內容裡持久化自己想要的訊息
def receiveRecover: Receive = ??? //Actor重啟恢復是執行的邏輯複製程式碼
相比普通的Actor,除receiveCommand相似以外,還必須實現另外兩個屬性。
另外在持久化Actor中還有另外兩個關鍵的的概念就是Journal和Snapshot,前者用於持久化事件,後者用於儲存Actor的快照,兩者在Actor恢復狀態的時候都起到了至關重要的作用。
Akka persistence的demo實戰
這裡我首先會用一個demo讓大家能對Akka persistence的使用有一定了解的,並能大致明白它的工作原理,後面再繼續講解一些實戰可能會遇到的問題。
假定現在有這麼一個場景,現在假設有一個1w元的大紅包,瞬間可能會很多人同時來搶,每個人搶的金額也可能不一樣,場景很簡單,實現方式也有很多種,但前提是保證資料的正確性,比如最普通的使用資料庫保證,但對這方面有所瞭解的同學都知道這並不是一個很好的方案,因為需要鎖,並需要大量的資料庫操作,導致效能不高,那麼我們是否可以用Actor來實現這個需求麼?答案是當然可以。
我們首先來定義一個抽獎命令,
case class LotteryCmd(
userId: Long, // 參與使用者Id
username: String, //參與使用者名稱
email: String // 參與使用者郵箱
)複製程式碼
然後我們實現一個抽獎Actor,並繼承PersistentActor作出相應的實現:
case class LuckyEvent( //抽獎成功事件
userId: Long,
luckyMoney: Int
)
case class FailureEvent( //抽獎失敗事件
userId: Long,
reason: String
)
case class Lottery(
totalAmount: Int, //紅包總金額
remainAmount: Int //剩餘紅包金額
) {
def update(luckyMoney: Int) = {
copy(
remainAmount = remainAmount - luckyMoney
)
}
}
class LotteryActor(initState: Lottery) extends PersistentActor with ActorLogging{
override def persistenceId: String = "lottery-actor-1"
var state = initState //初始化Actor的狀態
override def receiveRecover: Receive = {
case event: LuckyEvent =>
updateState(event) //恢復Actor時根據持久化的事件恢復Actor狀態
case SnapshotOffer(_, snapshot: Lottery) =>
log.info(s"Recover actor state from snapshot and the snapshot is ${snapshot}")
state = snapshot //利用快照恢復Actor的狀態
case RecoveryCompleted => log.info("the actor recover completed")
}
def updateState(le: LuckyEvent) =
state = state.update(le.luckyMoney) //更新自身狀態
override def receiveCommand: Receive = {
case lc: LotteryCmd =>
doLottery(lc) match { //進行抽獎,並得到抽獎結果,根據結果做出不同的處理
case le: LuckyEvent => //抽到隨機紅包
persist(le) { event =>
updateState(event)
increaseEvtCountAndSnapshot()
sender() ! event
}
case fe: FailureEvent => //紅包已經抽完
sender() ! fe
}
case "saveSnapshot" => // 接收儲存快照命令執行儲存快照操作
saveSnapshot(state)
case SaveSnapshotSuccess(metadata) => ??? //你可以在快照儲存成功後做一些操作,比如刪除之前的快照等
}
private def increaseEvtCountAndSnapshot() = {
val snapShotInterval = 5
if (lastSequenceNr % snapShotInterval == 0 && lastSequenceNr != 0) { //當有持久化5個事件後我們便儲存一次當前Actor狀態的快照
self ! "saveSnapshot"
}
}
def doLottery(lc: LotteryCmd) = { //抽獎邏輯具體實現
if (state.remainAmount > 0) {
val luckyMoney = scala.util.Random.nextInt(state.remainAmount) + 1
LuckyEvent(lc.userId, luckyMoney)
}
else {
FailureEvent(lc.userId, "下次早點來,紅包已被抽完咯!")
}
}
}複製程式碼
程式很簡單,關鍵位置我也給了註釋,相信大家對Actor有所瞭解的話很容易理解,當然要是有些疑惑,可以看看我之前寫的文章,下面我們就對剛才寫的抽紅包Actor進行測試:
object PersistenceTest extends App {
val lottery = Lottery(10000,10000)
val system = ActorSystem("example-05")
val lotteryActor = system.actorOf(Props(new LotteryActor(lottery)), "LotteryActor-1") //建立抽獎Actor
val pool: ExecutorService = Executors.newFixedThreadPool(10)
val r = (1 to 100).map(i =>
new LotteryRun(lotteryActor, LotteryCmd(i.toLong,"godpan","xx@gmail.com")) //建立100個抽獎請求
)
r.map(pool.execute(_)) //使用執行緒池來發起抽獎請求,模擬同時多人參加
Thread.sleep(5000)
pool.shutdown()
system.terminate()
}
class LotteryRun(lotteryActor: ActorRef, lotteryCmd: LotteryCmd) extends Runnable { //抽獎請求
implicit val timeout = Timeout(3.seconds)
def run: Unit = {
for {
fut <- lotteryActor ? lotteryCmd
} yield fut match { //根據不同事件顯示不同的抽獎結果
case le: LuckyEvent => println(s"恭喜使用者${le.userId}抽到了${le.luckyMoney}元紅包")
case fe: FailureEvent => println(fe.reason)
case _ => println("系統錯誤,請重新抽取")
}
}
}複製程式碼
執行程式,我們可能看到以下的結果:
下面我會把persistence actor在整個執行過程的步驟給出,幫助大家理解它的原理:
1.初始化Persistence Actor
- 1.1若是第一次初始化,則與正常的Actor的初始化一致。
- 1.2若是重啟恢復Actor,這根據Actor之前持久的資料恢復。
- 1.2.1從快照恢復,可快速恢復Actor,但並非每次持久化事件都會儲存快照,在快照完整的情況下,Actor優先從快照恢復自身狀態。
- 1.2.2從事件(日誌,資料庫記錄等)恢復,通過重放持久化事件恢復Actor狀態,比較關鍵。
2.接收命令進行處理,轉化為需要持久化的事件(持久化的事件儘量只包含關鍵性的資料)使用Persistence Actor的持久化方法進行持久化(上述例子中的persist,後面我會講一下批量持久化),並處理持久化成功後的邏輯處理,比如修改Actor狀態,向外部Actor傳送訊息等。
3.若是我們需要儲存快照,那麼可以主動指定儲存快照的頻率,比如持久化事件100次我們就儲存一次快照,這個頻率應該要考慮實際的業務場景,在儲存快照成功後我們也可以執行一些操作。
總的來說Persistence Actor執行時的大致操作就是以上這些,當然它是r如何持久化事件,恢復時的機制是怎麼樣的等有興趣的可以看一下Akka原始碼。
使用Akka persistence的相關配置
首先我們必須載入相應的依賴包,在bulid.sbt
中加入以下依賴:
libraryDependencies ++= Seq(
"com.typesafe.akka" %% "akka-actor" % "2.4.16", //Akka actor 核心依賴
"com.typesafe.akka" %% "akka-persistence" % "2.4.16", //Akka persistence 依賴
"org.iq80.leveldb" % "leveldb" % "0.7", //leveldb java版本依賴
"org.fusesource.leveldbjni" % "leveldbjni-all" % "1.8", //leveldb java版本依賴
"com.twitter" %% "chill-akka" % "0.8.0" //事件序列化依賴
)複製程式碼
另外我們還需在application.conf
加入以下配置:
akka.persistence.journal.plugin = "akka.persistence.journal.leveldb"
akka.persistence.snapshot-store.plugin = "akka.persistence.snapshot-store.local"
akka.persistence.journal.leveldb.dir = "log/journal"
akka.persistence.snapshot-store.local.dir = "log/snapshots"
# DO NOT USE THIS IN PRODUCTION !!!
# See also https://github.com/typesafehub/activator/issues/287
akka.persistence.journal.leveldb.native = false //因為我們本地並沒有安裝leveldb,所以這個屬性置為false,但是生產環境並不推薦使用
akka.actor.serializers {
kryo = "com.twitter.chill.akka.AkkaSerializer"
}
akka.actor.serialization-bindings {
"scala.Product" = kryo
"akka.persistence.PersistentRepr" = kryo
}複製程式碼
至此為止我們整個Akka persistence demo已經搭建好了,可以正常執行了,有興趣的同學可以下載原始碼。原始碼連結
Akka persistence進階
1.持久化外掛
有同學可能會問,我對leveldb不是很熟悉亦或者覺得單機儲存並不是安全,有沒有支援分散式資料儲存的外掛呢,比如某爸的雲資料庫?答案當然是有咯,良心的我當然是幫你們都找好咯。
1.akka-persistence-sql-async: 支援MySQL和PostgreSQL,另外使用了全非同步的資料庫驅動,提供非同步非阻塞的API,我司用的就是它的變種版,6的飛起。專案地址
2.akka-persistence-cassandra: 官方推薦的外掛,使用寫效能very very very fast的cassandra資料庫,是幾個外掛中比較流行的一個,另外它還支援persistence query。專案地址
3.akka-persistence-redis: redis應該也很符合Akka persistence的場景,熟悉redis的同學可以使用看看。專案地址
4.akka-persistence-jdbc: 怎麼能少了jdbc呢?不然怎麼對的起java爸爸呢,支援scala和java哦。專案地址
相應的外掛的具體使用可以看該專案的具體介紹使用,我看了下相對來說都是比較容易的。
2.批量持久化
上面說到我司用的是akka-persistence-sql-async外掛,所以我們是將事件和快照持久化到資料庫的,一開始我也是像上面demo一樣,每次事件都會持久化到資料庫,但是後來在效能測試的時候,因為本身業務場景對資料庫的壓力也比較大,在當資料庫到達每秒1000+的讀寫量後,另外說明一下使用的是某雲資料庫,效能中配以上,發現每次持久化的時間將近要15ms,這樣換算一下的話Actor每秒只能處理60~70個需要持久化的事件,而實際業務場景要求Actor必須在3秒內返回處理結果,這種情況下導致大量訊息處理超時得不到反饋,另外還有大量的訊息得不到處理,導致系統錯誤暴增,使用者體驗下降,既然我們發現了問題,那麼我們能不能進行優化呢?事實上當然是可以,既然單個插入慢,那麼我們能不能批量插入呢,Akka persistence為我們提供了persistAll方法,下面我就對上面的demo進行一下改造,讓其變成批量持久化:
class LotteryActorN(initState: Lottery) extends PersistentActor with ActorLogging{
override def persistenceId: String = "lottery-actor-2"
var state = initState //初始化Actor的狀態
override def receiveRecover: Receive = {
case event: LuckyEvent =>
updateState(event) //恢復Actor時根據持久化的事件恢復Actor狀態
case SnapshotOffer(_, snapshot: Lottery) =>
log.info(s"Recover actor state from snapshot and the snapshot is ${snapshot}")
state = snapshot //利用快照恢復Actor的狀態
case RecoveryCompleted => log.info("the actor recover completed")
}
def updateState(le: LuckyEvent) =
state = state.update(le.luckyMoney) //更新自身狀態
var lotteryQueue : ArrayBuffer[(LotteryCmd, ActorRef)] = ArrayBuffer()
context.system.scheduler //定時器,定時觸發抽獎邏輯
.schedule(
0.milliseconds,
100.milliseconds,
new Runnable {
def run = {
self ! "doLottery"
}
}
)
override def receiveCommand: Receive = {
case lc: LotteryCmd =>
lotteryQueue = lotteryQueue :+ (lc, sender()) //參與資訊加入抽獎佇列
println(s"the lotteryQueue size is ${lotteryQueue.size}")
if (lotteryQueue.size > 5) //當參與人數有5個時觸發抽獎
joinN(lotteryQueue)
case "doLottery" =>
if (lotteryQueue.size > 0)
joinN(lotteryQueue)
case "saveSnapshot" => // 接收儲存快照命令執行儲存快照操作
saveSnapshot(state)
case SaveSnapshotSuccess(metadata) => ??? //你可以在快照儲存成功後做一些操作,比如刪除之前的快照等
}
private def joinN(lotteryQueue: ArrayBuffer[(LotteryCmd, ActorRef)]) = { //批量處理抽獎結果
val rs = doLotteryN(lotteryQueue)
val success = rs.collect { //得到其中中獎的相應資訊
case (event: LuckyEvent, ref: ActorRef) =>
event -> ref
}.toMap
val failure = rs.collect { //得到其中未中獎的相應資訊
case (event: FailureEvent, ref: ActorRef) => event -> ref
}
persistAll(success.keys.toIndexedSeq) { //批量持久化中獎使用者事件
case event => println(event)
updateState(event)
increaseEvtCountAndSnapshot()
success(event) ! event
}
failure.foreach {
case (event, ref) => ref ! event
}
this.lotteryQueue.clear() //清空參與佇列
}
private def increaseEvtCountAndSnapshot() = {
val snapShotInterval = 5
if (lastSequenceNr % snapShotInterval == 0 && lastSequenceNr != 0) { //當有持久化5個事件後我們便儲存一次當前Actor狀態的快照
self ! "saveSnapshot"
}
}
private def doLotteryN(lotteryQueue: ArrayBuffer[(LotteryCmd, ActorRef)]) = { //抽獎邏輯具體實現
var remainAmount = state.remainAmount
lotteryQueue.map(lq =>
if (remainAmount > 0) {
val luckyMoney = scala.util.Random.nextInt(remainAmount) + 1
remainAmount = remainAmount - luckyMoney
(LuckyEvent(lq._1.userId, luckyMoney),lq._2)
}
else {
(FailureEvent(lq._1.userId, "下次早點來,紅包已被抽完咯!"),lq._2)
}
)
}
}複製程式碼
這是改造後的參與Actor,實現了批量持久的功能,當然這裡為了給傳送者返回訊息,處理邏輯稍微複雜了一點,不過真實場景可能會更復雜,相關原始碼也在剛才的專案上。
3.Persistence Query
另外Akka Persistence還提供了Query介面,用於需要查詢持久化事件的需求,這部分內容可能要根據實際業務場景考慮是否需要應用,我就不展開講了,另外我也寫了一個小demo在專案中,想要嘗試的同學也可以試試。