Flink流式計算在節省資源方面的簡單分析

小米運維發表於2019-11-07

關於Flink流式計算節省資源方面你必須知道的技巧 

Flink在小米的發展簡介
Flink流式計算在節省資源方面的簡單分析
小米在流式計算方面經歷了Storm、Spark Streaming和Flink的發展歷程;從2019年1月接觸Flink到現在,已經過去了大半年的時間了。對Flink的接觸越深,越能感受到它在流式計算方面的強大能力;無論是實時性、時間語義還是對狀態計算的支援等,都讓很多之前需要複雜業務邏輯實現的功能轉變成了簡潔的API呼叫。還有不斷完善的Flink SQL功能也讓人充滿期待,相信實時資料分析的門檻會越來越低,更多的業務能夠挖掘出資料更實時更深入的價值。
在這期間,我們逐步完善了穩定性、作業管理、日誌和監控收集展示等關係到使用者易用性和運維能力的特性,幫助越來越多的業務接入到了Flink。
流式作業管理服務的介面:
Flink流式計算在節省資源方面的簡單分析
Flink作業的監控指標收集展示:
Flink流式計算在節省資源方面的簡單分析
Flink作業異常日誌的收集展示:
Flink流式計算在節省資源方面的簡單分析
Spark Streaming 遷移到Flink的效果小結
在業務從Spark Streaming遷移到Flink的過程中,我們也一直在關注著一些指標的變化,比如資料處理的延遲、資源使用的變化、作業的穩定性等。其中有一些指標的變化是在預期之中的,比如資料處理延遲大大降低了,一些狀態相關計算的“準確率”提升了;但是有一項指標的變化是超出我們預期的,那就是節省的資源。

資訊流推薦業務是小米從Spark Streaming遷移到Flink流式計算最早也是使用Flink最深的業務之一,在經過一段時間的合作最佳化後,對方同學給我們提供了一些使用效果小結,其中有幾個關鍵點:

  • 對於無狀態作業,資料處理的延遲由之前Spark Streaming的16129ms降低到Flink的926ms,有94.2%的顯著提升(有狀態作業也有提升,但是和具體業務邏輯有關,不做介紹);
  • 對後端儲存系統的寫入延遲從80ms降低到了20ms左右,如下圖(這是因為Spark Streaming的mini batch模式會在batch最後有批次寫儲存系統的操作,從而造成寫請求尖峰,Flink則沒有類似問題):
    Flink流式計算在節省資源方面的簡單分析
  • 對於簡單的從訊息佇列Talos到儲存系統HDFS的資料清洗作業(ETL),由之前Spark Streaming的佔用210個CPU Core降到了Flink的32個CPU Core,資源利用率提高了84.8%;
其中前兩點最佳化效果是比較容易理解的,主要是第三點我們覺得有點超出預期。為了驗證這一點,資訊流推薦的同學幫助我們做了一些測試,嘗試把之前的Spark Streaming作業由210個CPU Core降低到64個,但是測試結果是作業出現了資料擁堵。這個Spark Streaming測試作業的batch interval 是10s,大部分batch能夠在8s左右執行完,偶爾抖動的話會有十幾秒,但是當晚高峰流量上漲之後,這個Spark Streaming作業就會開始擁堵了,而Flink使用32個CPU Core卻沒有遇到擁堵問題。
很顯然,更低的資源佔用幫助業務更好的節省了成本,節省出來的計算資源則可以讓更多其他的業務使用;為了讓節省成本能夠得到“理論”上的支撐,我們嘗試從幾個方面研究並對比了Spark Streaming和Flink的一些區別:
排程計算VS排程資料
對於任何一個分散式計算框架而言,如果“資料”和“計算”不在同一個節點上,那麼它們中必須有一個需要移動到另一個所在的節點。如果把計算排程到資料所在的節點,那就是“排程計算”,反之則是“排程資料”;在這一點上Spark Streaming和Flink的實現是不同的。
Flink流式計算在節省資源方面的簡單分析

Spark的核心資料結構RDD包含了幾個關鍵資訊,包括資料的分片(partitions)、依賴(dependencies)等,其中還有一個用於最佳化執行的資訊,就是分片的“preferred locations”

// RDD
/**
 * Optionally overridden by subclasses to specify placement preferences.
 */
protected def getPreferredLocations(split: Partition): Seq[String] = Nil

”排程計算”的方法在批處理中有很大的優勢,因為“計算”相比於“資料”來講一般資訊量比較小,如果“計算”可以在“資料”所在的節點執行的話,會省去大量網路傳輸,節省頻寬的同時提高了計算效率。但是在流式計算中,以Spark Streaming的排程方法為例,由於需要頻繁的排程”計算“,則會有一些效率上的損

耗。首先,每次”計算“的排程都是要消耗一些時間的,比如“計算”資訊的序列化 → 傳輸 → 反序列化 → 初始化相關資源 → 計算執行→執行完的清理和結果上報等,這些都是一些“損耗”。

另外,使用者的計算中一般會有一些資源的初始化邏輯,比如初始化外部系統的客戶端(類似於Kafka Producer或Consumer);每次計算的重複排程容易導致這些資源的重複初始化,需要使用者對執行邏輯有一定的理解,才能合理地初始化資源,避免資源的重複建立;這就提高了使用門檻,容易埋下隱患;透過業務支援發現,在實際生產過程中,經常會遇到大併發的Spark Streaming作業給Kafka或HBase等儲存系統帶來巨大連線壓力的情況,就是因為使用者在計算邏輯中一直重複建立連線。

Spark在官方文件提供了一些避免重複建立網路連線的示例程式碼,其核心思想就是透過連線池來複用連線:

這個資訊提供了該分片資料的位置資訊,即所在的節點;Spark在排程該分片的計算的時候,會盡量把該分片的計算排程到資料所在的節點,從而提高計算效率。比如對於KafkaRDD,該方法返回的就是topic partition的leader節點資訊:

rdd.foreachPartition { partitionOfRecords =>
// ConnectionPool is a static, lazily initialized pool of connections
    val connection = ConnectionPool.getConnection()
    partitionOfRecords.foreach(record => connection.send(record))
    ConnectionPool.returnConnection(connection)  // return to the pool for future reuse
  }
}

需要指出的是,即使使用者程式碼層面合理的使用了連線池,由於同一個“計算”邏輯不一定排程到同一個計算節點,還是可能會出現在不同計算節點上重新建立連線的情況。
Flink流式計算在節省資源方面的簡單分析
Flink和Storm類似,都是透過“排程資料”來完成計算的,也就是“計算邏輯”初始化並啟動後,如果沒有異常會一直執行,源源不斷地消費上游的資料,處理後傳送到下游;有點像工廠裡的流水線,貨物在傳送帶上一直傳遞,每個工人專注完成自己的處理邏輯即可。
雖然“排程資料”和“排程計算”有各自的優勢,但是在流式計算的實際生產場景中,“排程計算”很可能“有力使不出來”;比如一般流式計算都是消費訊息佇列Kafka或Talos的資料進行處理,而實際生產環境中為了保證訊息佇列的低延遲和易維護,一般不會和計算節點(比如Yarn服務的節點)混布,而是有各自的機器(不過很多時候是在同一機房);所以無論是Spark還是Flink,都無法避免訊息佇列資料的跨網路傳輸。所以從實際使用體驗上講,Flink的排程資料模式,顯然更容易減少損耗,提高計算效率,同時在使用上更符合使用者“直覺”,不易出現重複建立資源的情況。
不過這裡不得不提的一點是,Spark Streaming的“排程計算”模式,對於處理計算系統中的“慢節點”或“異常節點”有天然的優勢。比如如果Yarn叢集中有一臺節點磁碟存在異常,導致計算不停地失敗,Spark可以透過blacklist機制停止排程計算到該節點,從而保證整個作業的穩定性。或者有一臺計算節點的CPU Load偏高,導致處理比較慢,Spark也可以透過speculation機制及時把同一計算排程到其他節點,避免慢節點拖慢整個作業;而以上特性在Flink中都是缺失的。
Mini batch vs streaming
Flink流式計算在節省資源方面的簡單分析
Spark Streaming並不是真正意義上的流式計算,而是從批處理衍生出來的mini batch計算。如圖所示,Spark根據RDD依賴關係中的shuffle dependency進行作業的Stage劃分,每個Stage根據RDD的partition資訊切分成不同的分片;在實際執行的時候,只有當每個分片對應的計算結束之後,整個個Stage才算計算完成。
Flink流式計算在節省資源方面的簡單分析
這種模式容易出現“長尾效應”,比如如果某個分片資料量偏大,那麼其他分片也必須等這個分片計算完成後,才能進行下一輪的計算(Spark speculation對這種情況也沒有好的作用,因為這個是由於分片資料不均勻導致的),這樣既增加了其他分片的資料處理延遲,也浪費了資源。
 而Flink則是為真正的流式計算而設計的(並且把批處理抽象成有限流的資料計算),上游資料是持續傳送到下游的,這樣就避免了某個長尾分片導致其他分片計算“空閒”的情況,而是持續在處理資料,這在一定程度上提高了計算資源的利用率,降低了延遲。
Flink流式計算在節省資源方面的簡單分析
當然,這裡又要說一下mini batch的優點了,那就在異常恢復的時候,可以以比較低的代價把缺失的分片資料恢復過來,這個主要歸功於RDD的依賴關係抽象;如上圖所示,如果黑色塊表示的資料丟失(比如節點異常),Spark僅需要透過重放“Good-Replay”表示的資料分片就可以把丟失的資料恢復,這個恢復效率是很高的。
Flink流式計算在節省資源方面的簡單分析
而Flink的話則需要停止整個“流水線”上的運算元,並從Checkpoint恢復和重放資料;雖然Flink對這一點有一些最佳化,比如可以配置failover strategy為region來減少受影響的運算元,不過相比於Spark只需要從上個Stage的資料恢復受影響的分片來講,代價還是有點大。
總之,透過對比可以看出,Flink的streaming模式對於低延遲處理資料比較友好,Spark的mini batch模式則於異常恢復比較友好;如果在大部分情況下作業執行穩定的話,Flink在資源利用率和資料處理效率上確實更佔優勢一些。
資料序列化
Flink流式計算在節省資源方面的簡單分析
簡單來說,資料的序列化是指把一個object轉化為byte stream,反序列化則相反。序列化主要用於物件的持久化或者網路傳輸。
常見的序列化格式有binary、json、xml、yaml等;常見的序列化框架有Java原生序列化、Kryo、Thrift、Protobuf、Avro等。
對於分散式計算來講,資料的傳輸效率非常重要。好的序列化框架可以透過較低    的序列化時間和較低的記憶體佔用大大提高計算效率和作業穩定性。在資料序列化上,Flink和Spark採用了不同的方式;Spark對於所有資料預設採用Java原生序列化方式,使用者也可以配置使用Kryo;而Flink則是自己實現了一套高效率的序列化方法。
首先說一下Java原生的序列化方式,這種方式的好處是比較簡單通用,只要物件實現了Serializable介面即可;缺點就是效率比較低,而且如果使用者沒有指定serialVersionUID的話,很容易出現作業重新編譯後,之前的資料無法反序列化出來的情況(這也是Spark Streaming Checkpoint的一個痛點,在業務使用中經常出現修改了程式碼之後,無法從Checkpoint恢復的問題);當然Java原生序列化還有一些其他弊端,這裡不做深入討論。
有意思的是,Flink官方文件裡對於不要使用Java原生序列化強調了三遍,甚至網上有傳言Oracle要拋棄Java原生序列化:
Flink流式計算在節省資源方面的簡單分析
Flink流式計算在節省資源方面的簡單分析
相比於Java原生序列化方式,無論是在序列化效率還是序列化結果的記憶體佔用上,Kryo則更好一些(Spark聲稱一般Kryo會比Java原生節省10x記憶體佔用);Spark文件中表示它們之所以沒有把Kryo設定為預設序列化框架的唯一原因是因為Kryo需要使用者自己註冊需要序列化的類,並且建議使用者透過配置開啟Kryo。
雖然如此,根據Flink的測試,Kryo依然比Flink自己實現的序列化方式效率要低一些;如圖所示是Flink序列化器(PojoSerializer、RowSerializer、TupleSerializer)和Kryo等其他序列化框架的對比,可以看出Flink序列化器還是比較佔優勢的:
Flink流式計算在節省資源方面的簡單分析
那麼Flink到底是怎麼做的呢?網上關於Flink序列化的文章已經很多了,這裡我簡單地說一下我的理解。
像Kryo這種序列化方式,在序列化資料的時候,除了資料中的“值”資訊本身,還需要把一些資料的meta資訊也寫進去(比如物件的Class資訊;如果是已經註冊過的Class,則寫一個更節省記憶體的ID)。
Flink流式計算在節省資源方面的簡單分析
但是在Flink場景中則完全不需要這樣,因為在一個Flink作業DAG中,上游和下游之間傳輸的資料型別是固定且已知的,所以在序列化的時候只需要按照一定的排列規則把“值”資訊寫入即可(當然還有一些其他資訊,比如是否為null)。
Flink流式計算在節省資源方面的簡單分析
如圖所示是一個內嵌POJO的Tuple3型別的序列化形式,可以看出這種序列化方式非常地“緊湊”,大大地節省了記憶體並提高了效率。另外,Flink自己實現的序列化方式還有一些其他優勢,比如直接操作二進位制資料等。
凡事都有兩面性,自己實現序列化方式也是有一些劣勢,比如狀態資料的格式相容性(State Schema Evolution);如果你使用Flink自帶的序列化框架序進行狀態儲存,那麼修改狀態資料的類資訊後,可能在恢復狀態時出現不相容問題(目前Flink僅支援POJO和Avro的格式相容升級)。
另外,使用者為了保證資料能使用Flink自帶的序列化器,有時候不得不自己再重寫一個POJO類,把外部系統中資料的值再“對映”到這個POJO類中;而根據開發人員對POJO的理解不同,寫出來的效果可能不一樣,比如之前有個使用者很肯定地說自己是按照POJO的規範來定義的類,我檢視後發現原來他不小心多加了個logger,這從側面說明還是有一定的使用者使用門檻的。
// Not a POJO demo.public class Person {  private Logger logger = LoggerFactory.getLogger(Person.class);  public String name;  public int age;}

針對這一情況我們做了一些最佳化嘗試,由於在小米內部很多業務是透過Thrfit定義的資料,正常情況下Thrift類是透過Kryo的預設序列化器進行序列化和反序列化的,效率比較低。雖然官方提供了最佳化文件,可以透過如下方式進行最佳化,但是對業務來講也是存在一定使用門檻;

// KafkaRDD
override def getPreferredLocations(thePart: Partition): Seq[String] = {
    val part = thePart.asInstanceOf[KafkaRDDPartition]
    Seq(part.host) 
    // host: preferred kafka host, i.e. the leader at the time the rdd was created
  }
於是我們透過修改Flink中Kryo序列化器的相關邏輯,實現了對Thrfit類預設使用Thrift自己序列化器的最佳化,在大大提高了資料序列化效率的同時,也降低了業務的使用門檻。
總之,透過自己定製序列化器的方式,確實讓Flink在資料處理效率上更有優勢,這樣作業就可以透過佔用更低的頻寬和更少的計算資源完成計算了。
本文小結
Flink和Spark Streaming有非常大的差別,也有各自的優勢,這裡我只是簡單介紹了一下自己淺薄的理解,不是很深入。不過從實際應用效果來看,Flink確實透過高效的資料處理和資源利用,實現了成本上的最佳化;希望能有更多業務可以瞭解並試用Flink,後續我們也會透過Flink SQL為更多業務提供簡單易用的流式計算支援,謝謝!
參考文獻

{1}《Deep Dive on Apache Flink State》 - Seth Wiesman
{2}Flink 原理與實現:記憶體管理
https://ververica.cn/developers/flink-principle-memory-management
{3}Batch Processing — Apache Spark
https://blog.k2datascience.com/batch-processing-apache-spark-a67016008167

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/31559359/viewspace-2663277/,如需轉載,請註明出處,否則將追究法律責任。

相關文章