Spotify如何使用Cassandra實現個性化推薦

在Spotify我們有超過6000萬的活躍用戶,他們可以訪問超過3000萬首歌曲的龐大麴庫。用戶可以關注成千上萬的藝術家和上百個好友,並創建自己的音樂圖表。在我們的廣告平臺上,用戶還可以通過體驗各種音樂宣傳活動(專輯發行,藝人推廣)發現新的和現有的內容。這些選項增加了用戶的自主權和參與度。目前,用戶在平臺上已創建了超過15億播放列表,並且,僅去年一年就播放了超過70億小時的音樂。

但有時豐富的選擇也讓我們的用戶感到些許困惑。如何從超過10億個播放列表中找到適合鍛鍊時聽的播放列表?如何發現與自己品味契合的新專輯?通過在平臺上提供個性化的用戶體驗,我們幫助用戶發現和體驗相關內容。

個性化的用戶體驗包括在不同的場景中學習用戶的喜好和厭惡。一個喜歡金屬類音樂的人在給孩子播放睡前音樂時,可能不想收到金屬類型專輯的公告。這時,給他們推薦一張兒童音樂專輯可能更為貼切。但是這個經驗對另一個不介意在任何情況下接受金屬類型專輯推薦的金屬類型聽眾可能毫無意義。這兩個用戶有相似的聽音樂習慣,但可能有不同偏好。根據他們在不同場景下的偏好,提供在Spotify上的個性化體驗,可以讓他們更加投入。

基於以上對產品的理解,我們著手建立了一個個性化系統,它可以分析實時和歷史數據,分別瞭解用戶的場景和行為。隨著時間的推移和規模的擴大,我們基於一套靈活的架構建立了自己的個性化技術棧,並且確信我們使用了正確的工具來解決問題。

整體架構

在我們的系統中,使用Kafka收集日誌,使用Storm做實時事件處理,使用Crunch在Hadoop上運行批量map-reduce任務,使用Cassandra存儲用戶畫像(user profile)屬性和關於播放列表、藝人等實體的元數據。

下圖中,日誌由Kafka producer發出後,運行在不同的服務上,並且把不同類型的事件(例如歌曲完成、廣告展示的投遞)發送到Kafka broker。系統中有兩組Kafka consumer,分別訂閱不同的topic,消費事件:

  1. Hadoop Consumer將事件寫入HDFS。之後HDFS上的原始日誌會在Crunch中進行處理,去除重複事件,過濾掉不需要的字段,並將記錄轉化為Avro格式。
  2. 運行於Storm topology中的Spouts Consumer對事件流做實時計算。

系統中也有其他的Crunch pipeline接收和生成不同實體的元數據(類型、節奏等)。這些記錄存儲在HDFS中,並使用Crunch導出到Cassandra,以便在Storm pipeline中進行實時查找。我們將存儲實體元數據的Cassandra集群稱為實體元數據存儲(EMS)。

Storm pipeline處理來自Kafka的原始日誌事件,過濾掉不需要的事件,用從EMS獲取的元數據裝飾實體,按用戶分組,並通過某種聚合和派生的算法組合來確定用戶級屬性。合併後的這些用戶屬性描述了用戶畫像,它們存儲在Cassandra集群中,我們稱之為用戶畫像存儲(UPS)。

Spotify如何使用Cassandra實現個性化推薦

為何Cassandra適合?

由於UPS是我們個性化系統的核心,在本文中,我們將詳細說明為什麼選擇Cassandra作為存儲。當我們開始為UPS購買不同的存儲解決方案時,我們希望有一個解決方案可以:

  • 水平擴展
  • 支持複製—最好跨站點
  • 低延遲。可以為此犧牲一致性,因為我們不執行事務
  • 能夠支持Crunch的批量數據寫入和Storm的流數據寫入
  • 能為實體元數據的不同用例建模不同的數據模式,因為我們不想為EMS開發另一個解決方案,這會增加我們的運營成本。

我們考慮了在Spotify常用到的各種解決方案,如Memcached、Sparkey和Cassandra。只有Cassandra符合所有這些要求。

水平擴展

Cassandra隨著集群中節點數量的增加而擴展的能力得到了高度的宣傳,並且有很好的文檔支持,因此我們相信它對於我們的場景來說是一個很好的選擇。我們的項目從相對較小的數據量開始,但現在已經從幾GB增長到100 GB以上(譯者注:原文如此,可能是筆誤?歡迎有了解內情的讀者釋疑)。在這一過程中,我們很容易通過增加集群中的節點數量來擴展存儲容量;例如,我們最近將集群的規模增加了一倍,並觀察到延遲(中位數和99分位)幾乎減少了一半。

此外,Cassandra的複製和可用性特性也提供了巨大的幫助。雖然我們不幸遇到了一些由於GC或硬件問題導致節點崩潰的情況,但是我們訪問Cassandra集群的服務幾乎沒有受到影響,因為所有數據都在其他節點上可用,而且客戶端驅動程序足夠智能,可以透明地進行failover。

跨節點複製

Spotify在全球近60個國家提供服務。我們的後端服務運行在北美的兩個數據中心和歐洲的兩個數據中心。為了確保在任何一個數據中心發生故障時,我們的個性化系統仍能為用戶提供服務,我們必須能夠在至少兩個數據中心存儲數據。

我們目前在個性化集群中使用NetworkReplicationStrategy在歐盟數據中心和北美數據中心之間複製數據。這允許用戶訪問離自己最近的Spotify數據中心中的數據,並提供如上所述的冗餘功能。

雖然我們還沒有發生任何導致整個數據中心中的整個集群宕機的事件,但我們已經執行了從一個數據中心到另一個數據中心的用戶流量遷移測試,Cassandra完美地處理了從一個站點處理來自兩個站點的請求所帶來的流量增長。

低延遲 可調一致性

考慮到Spotify的用戶基數,實時計算用戶聽音樂的個性化數據會產生大量數據存儲到數據庫中。除了希望查詢能夠快速讀取這些數據外,存儲數據寫入路徑的低延遲對我們來說也是很重要的。

由於Cassandra中的寫入會存儲在append-only的結構中,所以寫操作通常非常快。實際上,在我們個性化推薦中使用的Cassandra,寫操作通常比讀操作快一個數量級。

由於實時計算的個性化數據本質上不是事務性的,並且丟失的數據很容易在幾分鐘內從用戶的聽音樂流中替換為新數據,我們可以調整寫和讀操作的一致性級別,以犧牲一致性,從而降低延遲(在操作成功之前不要等待所有副本響應)。

Bulkload數據寫入

在Spotify,我們對Hadoop和HDFS進行了大量投入,幾乎所有關於用戶的見解都來自於在歷史數據上運行作業。

Cassandra提供了從其他數據源(如HDFS)批量導入數據的方式,可以構建整個SSTable,然後將SSTable通過streaming傳輸到集群中。比起發送數百萬條或更多條INSERT語句,這種方式要簡單得多,速度更快,效率更高。

針對從HDFS讀取數據並bulkload寫入SSTable,Spotify開源了一個名為hdfs2cass的工具。

雖然此功能的可用性並不影響我們使用Cassandra進行個性化推薦的決定,但它使我們將HDFS中的數據集成到Cassandra中變得非常簡單和易於維護。

Cassandra數據模型

自開始這個項目以來,我們在Cassandra中個性化數據的數據模型經歷了一些演變。

最初,我們認為我們應該有兩個表——一個用於用戶屬性(鍵值對),一個用於“實體”(如藝術家、曲目、播放列表等)的類似屬性集。前者只包含帶有TTL的短期數據,而後者則是寫入不頻繁的相對靜態的數據。

將鍵值對存儲為單獨的CQL行而不是試圖為每個“屬性”創建一個CQL列(並且每個用戶有一個CQL行)的動機是允許生成此數據的服務和批處理作業獨立於使用數據的服務。使用這種方法,當數據的生產者需要增加一個新的“屬性”時,消費服務不需要做任何改動,因為這個服務只是查詢給定用戶的所有鍵值對。

這些表的結構如下:

CREATE TABLE entitymetadata (
entityid text,
featurename text,
featurevalue text,
PRIMARY KEY (entityid, featurekey)

)

CREATE TABLE userprofilelatest (
userid text,
featurename text,
featurevalue text,
PRIMARY KEY (userid, featurename)
)

在最初的原型階段,這種結構工作得很好,但是我們很快遇到了一些問題,這就需要重新考慮關於“實體”的元數據的結構:

  1. entitymetadata列的結構意味著我們可以很容易地添加新類型的entitymetadata,但是如果我們嘗試了一種新類型的數據後發現它沒有用,不再需要時,這些featurename沒法刪除。
  2. 一些實體元數據類型不能自然地表示為字符串,相反,使用CQL的某個集合類型更容易存儲。例如,在某些情況下,將值表示為list更為自然,因為這個值是有順序的事物列表;或者另一些情況下使用map來存儲實體值的排序。

我們放棄了使用一個表來存儲以(entityid,featurename)為鍵的所有值的做法,改為採用了為每個“featurename”創建一個表的方法,這些值使用適當的CQL類型。例如:

CREATE TABLE playlisttag (
entityid text,
featurevalue list,
PRIMARY KEY (entityid)
)

用適當的CQL類型而不是全部用字符串表示,意味著我們不再需要對如何將非文本的數據表示為文本(上面提到的第2點)做出任何笨拙的決定,並且我們可以很容易地刪除那些實驗之後決定不再用的表。從操作性的角度來看,這也允許我們檢查每個“特性”的讀寫操作的數量。

截至2014年底,我們有近12個此類數據的表,並且發現比起把所有數據塊塞進一個表,使用這些表要容易得多。

在Cassandra中有了DateTieredCompactionStrategy之後(我們自豪地說,這是Spotify同事對Cassandra項目的貢獻),用戶數據表也經歷了類似的演變。

我們對userprofilelatency表(譯者注:原文如此,猜測可能是userprofilelatest的筆誤)的讀寫延遲不滿意,認為DTCS可能非常適合我們的用例,因為所有這些數據都是面向時間戳的,並且具有較短的ttl,因此我們嘗試將“userprofilelatest”表的STCS改為DTCS以改善延遲。

在開始進行更改之前,我們使用nodetool記錄了SSTablesPerRead的直方圖,來作為我們更改前的狀態,以便和修改後的效果做比較。當時記錄的一個直方圖如下:

SSTables per Read
1 sstables: 126733
2 sstables: 111414
3 sstables: 141385

4 sstables: 181974
5 sstables: 222921
6 sstables: 220581
7 sstables: 217314
8 sstables: 216296
10 sstables: 380294

注意,直方圖相對平坦,這意味著大量的讀取請求都需要訪問多個sstable,而且往下看會發現這些數字實際上也在增加。

Spotify如何使用Cassandra實現個性化推薦

在檢查了直方圖之後,我們知道延遲很可能是由每次讀操作所訪問的sstable絕對數量引起的,減少延遲的關鍵在於減少每次讀取必須檢查的sstable數量。

最初,啟用DTCS後的結果並不樂觀,但這並不是因為compaction策略本身的任何問題,而是因為我們把短期TTL數據和沒有TTL的用戶長期“靜態”數據混合在一個表裡面。

為了測試如果表中的所有行都有TTL,DTCS是否能夠更好地處理TTL行,我們把這個表分成了兩個表,一個表用於沒有TTL的“靜態”行,一個表用於帶有TTL的行。

在小心遷移使用這個數據的後端服務(首先將服務更改為同時從新舊錶讀取數據,然後在數據遷移到新表完成後僅從新表讀取)後,我們的實驗是成功的:對只有TTL行的表開啟DTCS後生成了SSTablesPerRead直方圖,其中只需訪問1個SSTable的讀操作與訪問2個SSTable的讀操作的比例大約在6:1到12:1之間(取決於主機)。

下面是這次改動之後nodetool cfhistograms輸出的一個例子:

SSTables per Read
1 sstables: 4178514
2 sstables: 302549
3 sstables: 254760
4 sstables: 197695
5 sstables: 154961

...

或者如下圖:

Spotify如何使用Cassandra實現個性化推薦


在解決userprofileLatest表延遲問題的過程中,我們學到了一些關於Cassandra的寶貴經驗:

  • DTCS非常適合時間序列,特別是當所有行都有TTL時(SizeTieredCompactionStrategy不適合這種類型的數據)
  1. 但是,如果把有TTL的行和沒有TTL的行混在一個表裡面,DTCS表現不是很好,因此不要以這種方式混合數據
  2. 對於帶有DTCS/TTL數據的表,我們將gc_grace_period設置為0,並有效地禁用讀修復,因為我們不需要它們:TTL比gc grace period要短。
  • nodetool cfhistograms和每次讀取所訪問的SSTables數量可能是瞭解表延遲背後原因的最佳資源,因此請確保經常測量它,並將其導入圖形系統以觀察隨時間的變化。

通過對我們的數據模型和Cassandra配置進行一些調整,我們成功地構建了一個健壯的存儲層,用於向多個後端服務提供個性化數據。在對配置進行微調之後,在Cassandra集群的後續運行中我們幾乎沒做過任何其他運維操作。我們在儀表板中展示了一組集群和數據集的指標,並配置了警報,當指標開始朝錯誤方向發展時會觸發。這有助於我們被動地跟蹤集群的健康狀況。除了把集群的大小增加一倍以適應新增的負載之外,我們還沒做過太多的集群維護。而即使是集群倍增這部分也相當簡單和無縫,值得再發一篇文章來解釋所有細節。

總的來說,我們非常滿意Cassandra作為滿足我們所有個性化推薦需求的解決方案,並相信Cassandra可以隨著我們不斷增長的用戶基數持續擴展,提供個性化體驗。

感謝PlanetCassandra鼓勵我們在blog上分享Cassandra的經驗。


分享到:


相關文章: