高德服務單元化方案和架構實踐

導讀:本文主要介紹了高德在服務單元化建設方面的一些實踐經驗,服務單元化建設面臨很多共性問題,如請求路由、單元封閉、數據同步,有的有成熟方案可以借鑒和使用,但不同公司的業務不盡相同,要盡可能的結合業務特點,做相應的設計和處理。

一、為什麼要做單元化

  • 單機房資源瓶頸

隨着業務體量和服務用戶群體的增長,單機房或同城雙機房無法支持服務的持續擴容。

  • 服務異地容災

異地容災已經成為核心服務的標配,有的服務雖然進行了多地多機房部署,但數據還是只在中心機房,實現真正意義上的異地多活,就需要對服務進行單元化改造。

二、高德單元化的特點

在做高德單元化項目時,我們首先要考慮的是結合高德的業務特點,看高德的單元化有什麼不一樣的訴求,這樣就清楚哪些經驗和方案是可以直接拿來用的,哪些又是需要我們去解決的。

高德業務和傳統的在線交易業務還是不太一樣,高德為用戶提供以導航為代表的出行服務,很多業務場景對服務的RT要求會很高,所以在做單元化方案時,盡可能減少對整體服務RT的影響就是我們需要重點考慮的問題,盡量做到數據離用戶近一些。轉換到單元化技術層面需要解決兩個問題:

1.用戶設備的單元接入需要盡可能的做到就近接入,用戶真實地理位置接近哪個單元就接入哪個單元,如華北用戶接入到張北,華南接入到深圳。

2.用戶的單元劃分最好能與就近接入的單元保持一致,減少單元間的跨單元路由。如用戶請求從深圳進來,用戶的單元劃分最好就在深圳單元,如果劃到張北單元就會造成跨單元路由。

另外一個區別就是高德很多業務是無須登錄的,所以我們的單元化方案除了用戶ID也要支持基於設備ID。

三、高德單元化實踐

服務的單元化架構改造需要一個至上而下的系統性設計,核心要解決請求路由、單元封閉、數據同步三方面問題。

請求路由:根據高德業務的特點,我們提供了取模路由和路由表路由兩種策略,目前上線應用使用較多的是路由表路由策略。

單元封閉:得益於集團的基礎設施建設,我們使用vipserver、hsf等服務治理能力保證服務同機房調用,從而實現單元封閉(hsf unit模式也是一種可行的方案,但個人認為同機房調用的架構和模式更簡潔且易於維護)。

數據同步:數據部分使用的是集團DB產品提供的DRC數據同步。

單元路由服務採用什麼樣的部署方案是我們另一個要面臨的問題,考慮過以下三種方案:

第一種SDK的方式因為對業務的強侵入性是首先被排除的,統一接入層進行代理和去中心化插件集成兩種方案各有利弊,但當時首批要接入單元化架構的服務很多都還沒有統一接入到gateway,所以基於現狀的考慮使用了去中心化插件集成的方式,通過在應用的nginx集成UnitRouter。

服務單元化架構

目前高德賬號,雲同步、用戶評論系統都完成了單元化改造,採用三地四機房部署,寫入量較高的雲同步服務,單元寫高峰能達到數w+QPS (存儲是mongodb集群)。

以賬號系統為例介紹下高德單元化應用的整體架構。

賬號系統服務是三地四機房部署,數據分別存儲在tair為代表的緩存和XDB里,數據存儲三地集群部署、全量同步。賬號系統服務器的Tengine上安裝UntiRouter,它請求的負責單元識別和路由,用戶單元劃分是通過記錄用戶與單元關係的路由表來控制。

PS:因歷史原因緩存使用了tair和自建的uredis(在redis基礎上添加了基於log的數據同步功能),目前已經在逐步統一到tair。數據同步依賴tair和alisql的數據同步方案,以及自建的uredis數據同步能力。

就近接入實現方案

為滿足高德業務低延時要求,就要想辦法做到數據(單元)離用戶更近,其中有兩個關鍵鏈路,一個是通過aserver接入的外網連接,另一個是服務內部路由(盡可能不產生跨單元路由)。

措施1:客戶端的外網接入通過aserver上的配置,將不同地理區域(七個大區)的設備劃分到對應近的單元,如華北用戶接入張北單元。

措施2:通過記錄用戶和單元關係的路由表來劃分用戶所屬單元,這個關係是通過系統日誌分析出來的,用戶經常從哪個單元入口進來,就會把用戶劃分到哪個單元,從而保證請求入口和單元劃分的相對一致,從而減少跨單元路由。

所以,在最終的單元路由實現上我們提供了傳統的取模路由,和為降延時而設計的基於路由表路由兩種策略。同時,為解無須登錄的業務場景問題,上述兩種策略除了支持用戶ID,我們同時也支持設備ID。

路由表設計

路由表分為兩部分,一個是用戶-分組的關係映射表,另一個是分組-單元的關係映射表。在使用時,通過路由表查對應的分組,再通過分組看用戶所屬單元。分組對應中國大陸的七個大區。

先看“用戶-(大區)分組”:

路由表是定期通過系統日誌分析出來的,看用戶最近IP屬於哪個大區就劃分進哪個分組,同時也對應上了具體單元。當一個北京的用戶長期去了深圳,因IP的變化路由表更新后將划進新大區分組,從而完成用戶從張北單元到深圳單元的遷移。

再看“分組-單元”:

分組與單元的映射有一個默認關係,這是按地理就近來配置的,比如華南對應深圳。除了默認的映射關係,還有幾個用於切流預案的關係映射。

老用戶可以通過路由表來查找單元,新用戶怎麼辦?對於新用戶的處理我們會降級成取模的策略進行單元路由,直至下次路由表的更新。所以整體上看新用戶跨單元路由比例肯定是比老用戶大的多,但因為新用戶是一個相對穩定的增量,所以整體比例在可接受範圍內。

路由計算

有了路由表,接下來就要解工程化應用的問題,性能、空間、靈活性和準確率,以及對服務穩定性的影響這幾個方面是要進行綜合考慮的,首先考慮外部存儲會增加服務的穩定性風險,後面我們在BloomFilter 、BitMap和MapDB多種方案中選擇BloomFilter,萬分之幾的誤命中率導致的跨單元路由在業務可接受範圍內。

通過日誌分析出用戶所屬大區后,我們將不同分組做成多個布隆過濾器,計算時逐層過濾。這個計算有兩種特殊情況:

1) 因為BloomFilter存在誤算率,有可能存在一種情況,華南分組的用戶被計算到華北了,這種情況比例在萬分之3 (生成BloomFilter時可調整),它對業務上沒有什麼影響,這類用戶相當於被劃分到一個非所在大區的分組裡,但這個關係是穩定的,不會影響到業務,只是存在跨單元路由,是可接受的。

2) 新用戶不在分組信息里,所以經過逐層的計算也沒有匹配到對應大區分組,此時會使用取模進行模除分組的計算。

如果業務使用的是取模路由而非路由表路由策略,則直接根據tid或uid計算對應的模除分組,原理簡單不詳表了。

單元切流

在發生單元故障進行切流時,主要分為四步驟

打開單元禁寫 (跨單元寫不敏感業務可以不配置)

檢查業務延時

切換預案

解除單元禁寫

PS:更新路由表時,也需要上述操作,只是第3步的切換預案變成切換新版本路由表;單元禁寫主要是了等待數據同步,避免數據不一致導致的業務問題。

核心指標

單元計算耗時1~2ms

跨單元路由比例底於5%

除了性能外,因就近接入的訴求,跨單元路由比例也是我們比較關心的重要指標。從線上觀察看,路由表策略單元計算基本上在1、2ms內完成,跨單元路由比例3%左右,整體底於5%。

四、後續優化

統一接入集成單元化能力

目前大部分服務都接入了統一接入網關服務,在網關集成單元化能力將大大減少服務單元化部署的成本,通過簡單的配置就可以實現單元路由,服務可將更多的精力放在業務的單元封閉和數據同步上。

分組機制的優化

按大區分組存在三個問題:

通過IP計算大區有一定的誤算率,會導致部分用戶劃分錯誤分組。

分組粒度太大,單元切流時流量不好分配。舉例,假如華東是我們用戶集中的大區,切流時把這個分組切到任意一個指定單元,都會造成單元服務壓力過大。

計算次數多,分多少個大區,理論最大計算次數是有多少次,最後採取取模策略。

針對上述幾個問題我們計劃對分組機製做如下改進

通過用戶進入單元的記錄來確認用戶所屬單元,而非根據用戶IP所在大區來判斷,解上述問題1。

每個單元劃分4個虛擬分組,支持更細粒度單元切流,解上述問題2。

用戶確實單元后,通過取模來劃分到不同的虛擬分組。每個單元只要一次計算就能完成,新用戶只需經過3次計算,解上述問題3。

熱更時的雙表計算

與取模路由策略不同,路由表策略為了把跨單元路由控制在一個較好的水平需要定期更新,目前更新時需要一個短暫的單元禁寫,這對於很多業務來說是不太能接受的。

為優化這個問題,系統將在路由表更新時做雙(路由)表計算,即將新老路由表同時加載進內存,更新時不再對業務做完全的禁寫,我們會分別計算當前用戶(或設備)在新老路由表的單元結果,如果單元一致,則說明路由表的更新沒有導致該用戶(或設備)變更單元,所以請求會被放行,相反如果計算結果是不同單元,說明發生了單元變更,該請求會被攔截,直至到達新路由表的一個完全起用時間。

優化前服務會完全禁寫比如10秒(時間取決於數據同步時間),優化後會變成觸髮禁寫的是這10秒內路由發生變更的用戶,這將大大減少對業務的影響。

服務端數據驅動的單元化場景

前面提到高德在路由策略上結合業務的特別設計,但整體單元劃分還是以用戶(或設備)為維度來進行的,但高德業務還有一個大的場景是我們未來要面對和解決的,就是以數據維度驅動的單元設計,基於終端的服務路由會變成基於數據域的服務路由。

高德很多服務是以服務數據為核心的,像地圖數據等它並非由用戶直接產生。業務的發展數據存儲也將不斷增加,包括5G和自動駕駛,對應數據的爆髮式增長單點全量存儲並不實現,以服務端數據驅動的服務單元化設計,是我們接下來要考慮的重要應用場景。

寫在最後

不同的業務場景對單元化會有不同的訴求,我們提供不同的策略和能力供業務進行選擇,對於多數據服務我們建議使用業務取模路由,簡單且易於維護;對於RT敏感的服務使用路由表的策略來盡可能的降低服務響應時長的影響。另外,要注意的是強依賴性的服務要採用相同的路由策略。

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理【其他文章推薦】

※為什麼 USB CONNECTOR 是電子產業重要的元件?

網頁設計一頭霧水??該從何著手呢? 找到專業技術的網頁設計公司,幫您輕鬆架站!

※想要讓你的商品成為最夯、最多人討論的話題?網頁設計公司讓你強力曝光

※想知道最厲害的台北網頁設計公司推薦台中網頁設計公司推薦專業設計師”嚨底家”!!

圖文詳解基於角色的權限控制模型RBAC

我們開發一個系統,必然面臨權限控制的問題,即不同的用戶具有不同的訪問、操作、數據權限。形成理論的權限控制模型有:自主訪問控制(DAC: Discretionary Access Control)、強制訪問控制(MAC: Mandatory Access Control)、基於屬性的權限驗證(ABAC: Attribute-Based Access Control)等。最常被開發者使用也是相對易用、通用的就是RBAC權限模型(Role-Based Access Control),本文就將向大家介紹該權限模型。

一、RBAC權限模型簡介

RBAC權限模型(Role-Based Access Control)即:基於角色的權限控制。模型中有幾個關鍵的術語:

  • 用戶:系統接口及訪問的操作者
  • 權限:能夠訪問某接口或者做某操作的授權資格
  • 角色:具有一類相同操作權限的用戶的總稱

RBAC權限模型核心授權邏輯如下:

  • 某用戶是什麼角色?
  • 某角色具有什麼權限?
  • 通過角色的權限推導用戶的權限

二、RBAC的演化進程

2.1.用戶與權限直接關聯

想到權限控制,人們最先想到的一定是用戶與權限直接關聯的模式,簡單地說就是:某個用戶具有某些權限。如圖:

  • 張三具有創建用戶和刪除用戶的權限,所以他可能系統維護人員
  • 李四具有產品記錄管理和銷售記錄管理權限,所以他可能是一個業務銷售人員

這種模型能夠清晰的表達用戶與權限之間的關係,足夠簡單。但同時也存在問題:

  • 現在用戶是張三、李四,以後隨着人員增加,每一個用戶都需要重新授權
  • 或者張三、李四離職,需要針對每一個用戶進行多種權限的回收

2.2.一個用戶擁有一個角色

在實際的團體業務中,都可以將用戶分類。比如對於薪水管理系統,通常按照級別分類:經理、高級工程師、中級工程師、初級工程師。也就是按照一定的角色分類,通常具有同一角色的用戶具有相同的權限。這樣改變之後,就可以將針對用戶賦權轉換為針對角色賦權。

  • 一個用戶有一個角色
  • 一個角色有多個操作(菜單)權限
  • 一個操作權限可以屬於多個角色

我們可以用下圖中的數據庫設計模型,描述這樣的關係。

2.3 一個用戶一個或多個角色

但是在實際的應用系統中,一個用戶一個角色遠遠滿足不了需求。如果我們希望一個用戶既擔任銷售角色、又暫時擔任副總角色。該怎麼做呢?為了增加系統設計的適用性,我們通常設計:

  • 一個用戶有一個或多個角色
  • 一個角色包含多個用戶
  • 一個角色有多種權限
  • 一個權限屬於多個角色

我們可以用下圖中的數據庫設計模型,描述這樣的關係。

二、頁面訪問權限與操作權限

  • 頁面訪問權限: 所有系統都是由一個個的頁面組成,頁面再組成模塊,用戶是否能看到這個頁面的菜單、是否能進入這個頁面就稱為頁面訪問權限。
  • 操作權限: 用戶在操作系統中的任何動作、交互都需要有操作權限,如增刪改查等。比如:某個按鈕,某個超鏈接用戶是否可以點擊,是否應該看見的權限。

為了適應這種需求,我們可以把頁面資源(菜單)和操作資源(按鈕)分表存放,如上圖。也可以把二者放到一個表裡面存放,用一個字段進行標誌區分。

三、數據權限

數據權限比較好理解,就是某個用戶能夠訪問和操作哪些數據。

  • 通常來說,數據權限由用戶所屬的組織來確定。比如:生產一部只能看自己部門的生產數據,生產二部只能看自己部門的生產數據;銷售部門只能看銷售數據,不能看財務部門的數據。而公司的總經理可以看所有的數據。
  • 在實際的業務系統中,數據權限往往更加複雜。非常有可能銷售部門可以看生產部門的數據,以確定銷售策略、安排計劃等。

所以為了面對複雜的需求,數據權限的控制通常是由程序員書寫個性化的SQL來限制數據範圍的,而不是交給權限模型或者Spring Security或shiro來控制。當然也可以從權限模型或者權限框架的角度去解決這個問題,但適用性有限。

期待您的關注

  • 向您推薦博主的系列文檔:
  • 本文轉載註明出處(必須帶連接,不能只轉文字):。

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理【其他文章推薦】

※如何讓商品強力曝光呢? 網頁設計公司幫您建置最吸引人的網站,提高曝光率!!

網頁設計一頭霧水??該從何著手呢? 找到專業技術的網頁設計公司,幫您輕鬆架站!

※想知道最厲害的台北網頁設計公司推薦台中網頁設計公司推薦專業設計師”嚨底家”!!

使用Amazon EMR和Apache Hudi在S3上插入,更新,刪除數據

將數據存儲在Amazon S3中可帶來很多好處,包括規模、可靠性、成本效率等方面。最重要的是,你可以利用Amazon EMR中的Apache Spark,Hive和Presto之類的開源工具來處理和分析數據。 儘管這些工具功能強大,但是在處理需要進行增量數據處理以及記錄級別插入,更新和刪除場景時,仍然非常具有挑戰。

與客戶交談時,我們發現有些場景需要處理對單條記錄的增量更新,例如:

  • 遵守數據隱私法規,在該法規中,用戶選擇忘記或更改應用程序對數據使用方式的協議。
  • 使用流數據,當你必須要處理特定的數據插入和更新事件時。
  • 實現變更數據捕獲(CDC)架構來跟蹤和提取企業數據倉庫或運營數據存儲中的數據庫變更日誌。
  • 恢復遲到的數據,或分析特定時間點的數據。

從今天開始,EMR 5.28.0版包含Apache Hudi(孵化中),因此你不再需要構建自定義解決方案來執行記錄級別的插入,更新和刪除操作。Hudi是Uber於2016年開始開發,以解決攝取和ETL管道效率低下的問題。最近幾個月,EMR團隊與Apache Hudi社區緊密合作,提供了一些補丁,包括將Hudi更新為Spark 2.4.4,支持Spark Avro,增加了對AWS Glue Data Catalog的支持,以及多個缺陷修復。

使用Hudi,即可以在S3上執行記錄級別的插入,更新和刪除,從而使你能夠遵守數據隱私法律、消費實時流、捕獲更新的數據、恢復遲到的數據和以開放的、供應商無關的格式跟蹤歷史記錄和回滾。 創建數據集和表,然後Hudi管理底層數據格式。Hudi使用Apache Parquet和Apache Avro進行數據存儲,並內置集成Spark,Hive和Presto,使你能夠使用與現在所使用的相同工具來查詢Hudi數據集,並且幾乎實時地訪問新數據。

啟動EMR群集時,只要選擇以下組件之一(Hive,Spark,Presto),就可以自動安裝和配置Hudi的庫和工具。你可以使用Spark創建新的Hudi數據集,以及插入,更新和刪除數據。每個Hudi數據集都會在集群的已配置元存儲庫(包括AWS Glue Data Catalog)中進行註冊,並显示為可以通過Spark,Hive和Presto查詢的表。

Hudi支持兩種存儲類型,這些存儲類型定義了如何寫入,索引和從S3讀取數據:

  • 寫時複製(Copy On Write)– 數據以列格式(Parquet)存儲,並且在寫入時更新數據數據會創建新版本文件。此存儲類型最適合用於讀取繁重的工作負載,因為數據集的最新版本在高效的列式文件中始終可用。

  • 讀時合併(Merge On Read)– 將組合列(Parquet)格式和基於行(Avro)格式來存儲數據; 更新記錄至基於行的增量文件中,並在以後進行壓縮,以創建列式文件的新版本。 此存儲類型最適合於繁重的寫工作負載,因為新提交(commit)會以增量文件格式快速寫入,但是要讀取數據集,則需要將壓縮的列文件與增量文件合併。

下面讓我們快速預覽下如何在EMR集群中設置和使用Hudi數據集。

結合Apache Hudi與Amazon EMR

從EMR控制台開始創建集群。在高級選項中,選擇EMR版本5.28.0(第一個包括Hudi的版本)和以下應用程序:Spark,Hive和Tez。在硬件選項中,添加了3個任務節點,以確保有足夠的能力運行Spark和Hive。

群集就緒后,使用在安全性選項中選擇的密鑰對,通過SSH進入主節點並訪問Spark Shell。 使用以下命令來啟動Spark Shell以將其與Hudi一起使用:

$ spark-shell --conf "spark.serializer=org.apache.spark.serializer.KryoSerializer"
              --conf "spark.sql.hive.convertMetastoreParquet=false"
              --jars /usr/lib/hudi/hudi-spark-bundle.jar,/usr/lib/spark/external/lib/spark-avro.jar

使用以下Scala代碼將一些示例ELB日誌導入寫時複製存儲類型的Hudi數據集中:

import org.apache.spark.sql.SaveMode
import org.apache.spark.sql.functions._
import org.apache.hudi.DataSourceWriteOptions
import org.apache.hudi.config.HoodieWriteConfig
import org.apache.hudi.hive.MultiPartKeysValueExtractor

//Set up various input values as variables
val inputDataPath = "s3://athena-examples-us-west-2/elb/parquet/year=2015/month=1/day=1/"
val hudiTableName = "elb_logs_hudi_cow"
val hudiTablePath = "s3://MY-BUCKET/PATH/" + hudiTableName

// Set up our Hudi Data Source Options
val hudiOptions = Map[String,String](
    DataSourceWriteOptions.RECORDKEY_FIELD_OPT_KEY -> "request_ip",
    DataSourceWriteOptions.PARTITIONPATH_FIELD_OPT_KEY -> "request_verb", 
    HoodieWriteConfig.TABLE_NAME -> hudiTableName, 
    DataSourceWriteOptions.OPERATION_OPT_KEY ->
        DataSourceWriteOptions.INSERT_OPERATION_OPT_VAL, 
    DataSourceWriteOptions.PRECOMBINE_FIELD_OPT_KEY -> "request_timestamp", 
    DataSourceWriteOptions.HIVE_SYNC_ENABLED_OPT_KEY -> "true", 
    DataSourceWriteOptions.HIVE_TABLE_OPT_KEY -> hudiTableName, 
    DataSourceWriteOptions.HIVE_PARTITION_FIELDS_OPT_KEY -> "request_verb", 
    DataSourceWriteOptions.HIVE_ASSUME_DATE_PARTITION_OPT_KEY -> "false", 
    DataSourceWriteOptions.HIVE_PARTITION_EXTRACTOR_CLASS_OPT_KEY ->
        classOf[MultiPartKeysValueExtractor].getName)

// Read data from S3 and create a DataFrame with Partition and Record Key
val inputDF = spark.read.format("parquet").load(inputDataPath)

// Write data into the Hudi dataset
inputDF.write
       .format("org.apache.hudi")
       .options(hudiOptions)
       .mode(SaveMode.Overwrite)
       .save(hudiTablePath)

在Spark Shell中,現在就可以計算Hudi數據集中的記錄:

scala> inputDF2.count()
res1: Long = 10491958

在選項(options)中,使用了與為集群中的Hive Metastore集成,以便在默認數據庫(default)中創建表。 通過這種方式,我可以使用Hive查詢Hudi數據集中的數據:

hive> use default;
hive> select count(*) from elb_logs_hudi_cow;
...
OK
10491958

現在可以更新或刪除數據集中的單條記錄。 在Spark Shell中,設置了一些用來查詢更新記錄的變量,並準備用來選擇要更改的列的值的SQL語句:

val requestIpToUpdate = "243.80.62.181"
val sqlStatement = s"SELECT elb_name FROM elb_logs_hudi_cow WHERE request_ip = '$requestIpToUpdate'"

執行SQL語句以查看列的當前值:

scala> spark.sql(sqlStatement).show()
+------------+                                                                  
|    elb_name|
+------------+
|elb_demo_003|
+------------+

然後,選擇並更新記錄:

// Create a DataFrame with a single record and update column value
val updateDF = inputDF.filter(col("request_ip") === requestIpToUpdate)
                      .withColumn("elb_name", lit("elb_demo_001"))

現在用一種類似於創建Hudi數據集的語法來更新它。 但是這次寫入的DataFrame僅包含一條記錄:

// Write the DataFrame as an update to existing Hudi dataset
updateDF.write
        .format("org.apache.hudi")
        .options(hudiOptions)
        .option(DataSourceWriteOptions.OPERATION_OPT_KEY,
                DataSourceWriteOptions.UPSERT_OPERATION_OPT_VAL)
        .mode(SaveMode.Append)
        .save(hudiTablePath)

在Spark Shell中,檢查更新的結果:

scala> spark.sql(sqlStatement).show()
+------------+                                                                  
|    elb_name|
+------------+
|elb_demo_001|
+------------+

現在想刪除相同的記錄。要刪除它,可在寫選項中傳入了EmptyHoodieRecordPayload有效負載:

// Write the DataFrame with an EmptyHoodieRecordPayload for deleting a record
updateDF.write
        .format("org.apache.hudi")
        .options(hudiOptions)
        .option(DataSourceWriteOptions.OPERATION_OPT_KEY,
                DataSourceWriteOptions.UPSERT_OPERATION_OPT_VAL)
        .option(DataSourceWriteOptions.PAYLOAD_CLASS_OPT_KEY,
                "org.apache.hudi.EmptyHoodieRecordPayload")
        .mode(SaveMode.Append)
        .save(hudiTablePath)

在Spark Shell中,可以看到該記錄不再可用:

scala> spark.sql(sqlStatement).show()
+--------+                                                                      
|elb_name|
+--------+
+--------+

Hudi是如何管理所有的更新和刪除? 我們可以通過Hudi命令行界面(CLI)連接到數據集,便可以看到這些更改被解釋為提交(commits):

可以看到,此數據集是寫時複製數據集,這意味着每次對記錄進行更新時,包含該記錄的文件將被重寫以包含更新后的值。 你可以查看每次提交(commit)寫入了多少記錄。表格的底行描述了數據集的初始創建,上方是單條記錄更新,頂部是單條記錄刪除。

使用Hudi,你可以回滾到每個提交。 例如,可以使用以下方法回滾刪除操作:

hudi:elb_logs_hudi_cow->commit rollback --commit 20191104121031

在Spark Shell中,記錄現在回退到更新之後的位置:

scala> spark.sql(sqlStatement).show()
+------------+                                                                  
|    elb_name|
+------------+
|elb_demo_001|
+------------+

寫入時複製是默認存儲類型。 通過將其添加到我們的hudiOptions中,我們可以重複上述步驟來創建和更新讀時合併數據集類型:

DataSourceWriteOptions.STORAGE_TYPE_OPT_KEY -> "MERGE_ON_READ"

如果更新讀時合併數據集並使用Hudi CLI查看提交(commit)時,則可以看到讀時合併寫時複製相比有何不同。使用讀時合併,你僅寫入更新的行,而不像寫時複製一樣寫入整個文件。這就是為什麼讀時合併對於需要更多寫入或使用較少讀取次數更新或刪除繁重工作負載的用例很有幫助的原因。增量提交作為Avro記錄(基於行的存儲)寫入磁盤,而壓縮數據作為Parquet文件(列存儲)寫入。為避免創建過多的增量文件,Hudi會自動壓縮數據集,以便使得讀取盡可能地高效。

創建讀時合併數據集時,將創建兩個Hive表:

  • 第一個表的名稱與數據集的名稱相同。
  • 第二個表的名稱後面附加了字符_rt; _rt後綴表示實時。

查詢時,第一個表返回已壓縮的數據,並不會显示最新的增量提交。使用此表可提供最佳性能,但會忽略最新數據。查詢實時表會將壓縮的數據與讀取時的增量提交合併,因此該數據集稱為讀時合併。這將導致可以使用最新數據,但會導致性能開銷,並且性能不如查詢壓縮數據。這樣,數據工程師和分析人員可以靈活地在性能和數據新鮮度之間進行選擇。

已可用

EMR 5.28.0的所有地區現在都可以使用此新功能。將Hudi與EMR結合使用無需額外費用。你可以在EMR文檔中了解更多有關Hudi的信息。 這個新工具可以簡化你在S3中處理,更新和刪除數據的方式。也讓我們知道你打算將其用於哪些場景!

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理【其他文章推薦】

※帶您來了解什麼是 USB CONNECTOR  ?

※自行創業 缺乏曝光? 下一步"網站設計"幫您第一時間規劃公司的門面形象

※如何讓商品強力曝光呢? 網頁設計公司幫您建置最吸引人的網站,提高曝光率!!

※綠能、環保無空污,成為電動車最新代名詞,目前市場使用率逐漸普及化

※廣告預算用在刀口上,網站設計公司幫您達到更多曝光效益

SpringBoot Application深入學習

本節主要介紹SpringBoot Application類相關源碼的深入學習。

主要包括:

  1. SpringBoot應用自定義啟動配置
  2. SpringBoot應用生命周期,以及在生命周期各個階段自定義配置。

本節採用SpringBoot 2.1.10.RELASE,對應示例源碼在:

SpringBoot應用啟動過程:

SpringApplication application = new SpringApplication(DemoApplication.class);
application.run(args);

一、Application類自定義啟動配置

創建SpringApplication對象后,在調用run方法之前,我們可以使用SpringApplication對象來添加一些配置,比如禁用banner、設置應用類型、設置配置文件(profile)

舉例:

@SpringBootApplication
public class DemoApplication {

    public static void main(String[] args) {
        SpringApplication application = new SpringApplication(DemoApplication.class);
        // 設置banner禁用
        application.setBannerMode(Banner.Mode.OFF);
        // 將application-test文件啟用為profile
        application.setAdditionalProfiles("test");
        // 設置應用類型為NONE,即啟動完成后自動關閉
        application.setWebApplicationType(WebApplicationType.NONE);
        application.run(args);
    }

}

​ 也可以使用SpringApplicationBuilder類來創建SpringApplication對象,builder類提供了鏈式調用的API,更方便調用,增強了可讀性。

        new SpringApplicationBuilder(YqManageCenterApplication.class)
                .bannerMode(Banner.Mode.OFF)
                .profiles("test")
                .web(WebApplicationType.NONE)
                .run(args);

二、application生命周期

SpringApplication的生命周期主要包括:

  1. 準備階段:主要包括加載配置、設置主bean源、推斷應用類型(三種)、創建和設置SpringBootInitializer、創建和設置Application監聽器、推斷主入口類
  2. 運行階段:開啟時間監聽、加載運行監聽器、創建Environment、打印banner、創建和裝載context、廣播應用已啟動、廣播應用運行中

我們先來看一下源碼的分析:

SpringBootApplication構造器:

public SpringApplication(ResourceLoader resourceLoader, Class<?>... primarySources) {
        
        // 設置默認配置
        this.sources = new LinkedHashSet();
        this.bannerMode = Mode.CONSOLE;
        this.logStartupInfo = true;
        this.addCommandLineProperties = true;
        this.addConversionService = true;
        this.headless = true;
        this.registerShutdownHook = true;
        this.additionalProfiles = new HashSet();
        this.isCustomEnvironment = false;
        this.resourceLoader = resourceLoader;
        Assert.notNull(primarySources, "PrimarySources must not be null");
        // 設置主bean源
        this.primarySources = new LinkedHashSet(Arrays.asList(primarySources));
        // 推斷和設置應用類型(三種)
        this.webApplicationType = WebApplicationType.deduceFromClasspath();
        // 創建和設置SpringBootInitializer
  this.setInitializers(this.getSpringFactoriesInstances(ApplicationContextInitializer.class));
        // 創建和設置SpringBoot監聽器
    this.setListeners(this.getSpringFactoriesInstances(ApplicationListener.class));
        // 推斷和設置主入口類
        this.mainApplicationClass = this.deduceMainApplicationClass();
    }

SpringApplication.run方法源碼:

public ConfigurableApplicationContext run(String... args) {
        // 開啟時間監聽
        StopWatch stopWatch = new StopWatch();
        stopWatch.start();
        ConfigurableApplicationContext context = null;
        Collection<SpringBootExceptionReporter> exceptionReporters = new ArrayList();
        this.configureHeadlessProperty();
    
        // 加載Spring應用運行監聽器(SpringApplicationRunListenter)
        SpringApplicationRunListeners listeners = this.getRunListeners(args);
        listeners.starting();

        Collection exceptionReporters;
        try {
            // 創建environment(包括PropertySources和Profiles)
            ApplicationArguments applicationArguments = new DefaultApplicationArguments(args);
            ConfigurableEnvironment environment = this.prepareEnvironment(listeners, applicationArguments);
            this.configureIgnoreBeanInfo(environment);
            
            // 打印banner
            Banner printedBanner = this.printBanner(environment);
            
            // 創建context(不同的應用類型對應不同的上下文)
            context = this.createApplicationContext();
            exceptionReporters = this.getSpringFactoriesInstances(SpringBootExceptionReporter.class, new Class[]{ConfigurableApplicationContext.class}, context);
            // 裝載context(其中還初始化了IOC容器)
            this.prepareContext(context, environment, listeners, applicationArguments, printedBanner);
            // 調用applicationContext.refresh
            this.refreshContext(context);
            // 空方法
            this.afterRefresh(context, applicationArguments);
            stopWatch.stop(); // 關閉時間監聽;這樣可以計算出完整的啟動時間
            if (this.logStartupInfo) {
                (new StartupInfoLogger(this.mainApplicationClass)).logStarted(this.getApplicationLog(), stopWatch);
            }

            // 廣播SpringBoot應用已啟動,會調用所有SpringBootApplicationRunListener里的started方法
            listeners.started(context);
            
            // 遍歷所有ApplicationRunner和CommadnLineRunner的實現類,執行其run方法
            this.callRunners(context, applicationArguments);
        } catch (Throwable var10) {
            this.handleRunFailure(context, var10, exceptionReporters, listeners);
            throw new IllegalStateException(var10);
        }

        try {
            // 廣播SpringBoot應用運行中,會調用所有SpringBootApplicationRunListener里的running方法
            listeners.running(context);
            return context;
        } catch (Throwable var9) {
            // run出現異常時,處理異常;會調用報錯的listener里的failed方法,廣播應用啟動失敗,將異常擴散出去
            this.handleRunFailure(context, var9, exceptionReporters, (SpringApplicationRunListeners)null);
            throw new IllegalStateException(var9);
        }
    }

三、application生命周期自定義配置

在SpringApplication的生命周期中,我們還可以添加一些自定義的配置。

下面的配置,主要是通過實現Spring提供的接口,然後在resources下新建META-INF/spring.factories文件,在裏面添加這個類而實現引入的。

準備階段,可以添加如下自定義配置:

3.1 自定義ApplicationContextInitializer的實現類

@Order(100)
public class MyInitializer implements ApplicationContextInitializer {

@Override
public void initialize(ConfigurableApplicationContext configurableApplicationContext) {
    System.out.println("自定義的應用上下文初始化器:" + configurableApplicationContext.toString());
}
}

再定義一個My2Initializer,設置@Order(101)

然後在spring.factories文件里如下配置:

# initializers
org.springframework.context.ApplicationContextInitializer=\
  com.example.applicationdemo.MyInitializer,\
  com.example.applicationdemo.My2Initializer

啟動項目:

3.2 自定義ApplicationListener的實現類

@FunctionalInterface
public interface ApplicationListener<E extends ApplicationEvent> extends EventListener {
    void onApplicationEvent(E var1);
}![file](https://img2018.cnblogs.com/blog/1860493/201911/1860493-20191125130012982-1676057906.png)

即監聽ApplicationEvents類的ApplicationListener接口的實現類。

首先查看有多少種ApplicationEvents:

裏面還可以進行拆分。

我們這裏設置兩個ApplicationListener,都用於監聽ApplicationEnvironmentPreparedEvent

@Order(200)
public class MyApplicationListener implements ApplicationListener<ApplicationEnvironmentPreparedEvent> {

    @Override
    public void onApplicationEvent(ApplicationEnvironmentPreparedEvent applicationEnvironmentPreparedEvent) {
        System.out.println("MyApplicationListener: 應用環境準備完畢" + applicationEnvironmentPreparedEvent.toString());
    }
}

在spring.factories中加入applicationListener的配置:

# application-listeners
org.springframework.context.ApplicationListener=\
  com.example.applicationdemo.MyApplicationListener,\
  com.example.applicationdemo.MyApplicationListener2

啟動階段,可以添加如下自定義配置:

3.3 自定義SpringBootRunListener的實現類

監聽整個SpringBoot應用生命周期

public interface SpringApplicationRunListener {
    // 應用啟動
    void starting();

    // 應用ConfigurableEnvironment準備完畢,此刻可以將其調整
    void environmentPrepared(ConfigurableEnvironment environment);

    // 上下文準備完畢
    void contextPrepared(ConfigurableApplicationContext context);

    // 上下文裝載完畢
    void contextLoaded(ConfigurableApplicationContext context);

    // 啟動完成(Beans已經加載到容器中)
    void started(ConfigurableApplicationContext context);

    // 應用運行中
    void running(ConfigurableApplicationContext context);

    // 應用運行失敗
    void failed(ConfigurableApplicationContext context, Throwable exception);
}

我們可以自定義SpringApplicationRunListener的實現類,通過重寫以上方法來定義自己的listener。

比如:

public class MyRunListener implements SpringApplicationRunListener {

    // 注意要加上這個構造器,兩個參數都不能少,否則啟動會報錯,報錯的詳情可以看這個類的最下面
    public MyRunListener(SpringApplication springApplication, String[] args) {

    }

    @Override
    public void starting() {
        System.out.println("MyRunListener: 程序開始啟動");
    }

    // 其他方法省略,不做修改
}

然後在spring.factories文件中添加這個類:

org.springframework.boot.SpringApplicationRunListener=\
  com.example.applicationdemo.MyRunListener

啟動:

3.4 自定義ApplicationRunner或CommandLineRunner

application的run方法中,有這樣一行:

this.callRunners(context, applicationArguments);

仔細分析源碼,發現這一句的作用是:SpringBoot應用啟動過程中,會遍歷所有的ApplicationRunner和CommandLineRunner,執行其run方法。

private void callRunners(ApplicationContext context, ApplicationArguments args) {
        List<Object> runners = new ArrayList();
        runners.addAll(context.getBeansOfType(ApplicationRunner.class).values());
        runners.addAll(context.getBeansOfType(CommandLineRunner.class).values());
        AnnotationAwareOrderComparator.sort(runners);
        Iterator var4 = (new LinkedHashSet(runners)).iterator();

        while(var4.hasNext()) {
            Object runner = var4.next();
            if (runner instanceof ApplicationRunner) {
                this.callRunner((ApplicationRunner)runner, args);
            }

            if (runner instanceof CommandLineRunner) {
                this.callRunner((CommandLineRunner)runner, args);
            }
        }

    }
@FunctionalInterface
public interface CommandLineRunner {
    void run(String... args) throws Exception;
}
@FunctionalInterface
public interface ApplicationRunner {
    void run(ApplicationArguments args) throws Exception;
}

分別定義一個實現類,添加@Component,這兩個實現類不需要在spring.factories中配置

好了,關於這些自定義配置的具體使用,後續會繼續進行介紹,請持續關注!感謝!

具體示例代碼請去查看。

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理【其他文章推薦】

※為什麼 USB CONNECTOR 是電子產業重要的元件?

網頁設計一頭霧水??該從何著手呢? 找到專業技術的網頁設計公司,幫您輕鬆架站!

※想要讓你的商品成為最夯、最多人討論的話題?網頁設計公司讓你強力曝光

※想知道最厲害的台北網頁設計公司推薦台中網頁設計公司推薦專業設計師”嚨底家”!!

Lombok 使用詳解,簡化Java編程

前言

在 Java 應用程序中存在許多重複相似的、生成之後幾乎不對其做更改的代碼,但是我們還不得不花費很多精力編寫它們來滿足 Java 的編譯需求

比如,在 Java 應用程序開發中,我們幾乎要為所有 Bean 的成員變量添加 get() ,set() 等方法,這些相對固定但又不得不編寫的代碼浪費程序員很多精力,同時讓類內容看着更雜亂,我們希望將有限的精力關注在更重要的地方。

Lombok 已經誕生很久了,甚至在 Spring Boot Initalizr 中都已加入了 Lombok 選項,

這裏我們將 Lombok 做一下詳細說明:

Lombok

官網的介紹:Project Lombok is a java library that automatically plugs into your editor and build tools, spicing up your java. Never write another getter or equals method again. Early access to future java features such as val, and much more.

直白的說: Lombok 是一種 Java™ 實用工具,可用來幫助開發人員消除 Java 的冗長,尤其是對於簡單的 Java 對象(POJO)。它通過註解實現這一目的,且看:

Bean 的對比

傳統的 POJO 類是這樣的

通過Lombok改造后的 POJO 類是這樣的

一眼可以觀察出來我們在編寫 Employee 這個類的時候通過 @Data 註解就已經實現了所有成員變量的 get()set() 方法等,同時 Employee 類看起來更加清晰簡潔。Lombok 的神奇之處不止這些,豐富的註解滿足了我們開發的多數需求。

Lombok的安裝

查看下圖,@Data的實現,我們發現這個註解是應用在編譯階段的

這和我們大多數使用的註解,如 Spring 的註解(在運行時,通過反射來實現業務邏輯)是有很大差別的,如Spring 的@RestController 註解

一個更直接的體現就是,普通的包在引用之後一般的 IDE 都能夠自動識別語法,但是 Lombok 的這些註解,一般的 IDE 都無法自動識別,因此如果要使用 Lombok 的話還需要配合安裝相應的插件來支持 IDE 的編譯,防止IDE 的自動檢查報錯,下面以 IntelliJ IDEA 舉例安裝插件。

在Repositories中搜索Lombok,安裝后重啟IDE即可

在Maven或Gradle工程中添加依賴

至此我們就可以應用 Lombok 提供的註解幹些事情了。

Lombok註解詳解

Lombok官網提供了許多註解,但是 “勁酒雖好,可不要貪杯哦”,接下來逐一講解官網推薦使用的註解(有些註解和原有Java編寫方式沒太大差別的也沒有在此處列舉,如@ Synchronized等)

@Getter和@Setter

該註解可應用在類或成員變量之上,和我們預想的一樣,@Getter@Setter 就是為成員變量自動生成 get 和 set 方法,默認生成訪問權限為 public 方法,當然我們也可以指定訪問權限 protected 等,如下圖:

成員變量name指定生成set方法,並且訪問權限為protected;boolean類型的成員變量 female 只生成get方法,並修改方法名稱為 isFemale()。當把該註解應用在類上,默認為所有非靜態成員變量生成 get 和 set 方法,也可以通過 AccessLevel.NONE 手動禁止生成get或set方法,如下圖:

@ToString

該註解需應用在類上,為我們生成 Object 的 toString 方法,而該註解裏面的幾個屬性能更加豐富我們想要的內容, exclude 屬性禁止在 toString 方法中使用某字段,而of屬性可以指定需要使用的字段,如下圖:

查看編譯后的Employee.class得到我們預期的結果,如下圖

@EqualsAndHashCode

該註解需應用在類上,使用該註解,lombok會為我們生成 equals(Object other) 和 hashcode() 方法,包括所有非靜態屬性和非transient的屬性,同樣該註解也可以通過 exclude 屬性排除某些字段,of 屬性指定某些字段,也可以通過 callSuper 屬性在重寫的方法中使用父類的字段,這樣我們可以更靈活的定義bean的比對,如下圖:

查看編譯后的Employee.class文件,如下圖:

@NonNull

該註解需應用在方法或構造器的參數上或屬性上,用來判斷參數的合法性,默認拋出 NullPointerException 異常

查看NonNullExample.class文件,會為我們拋出空指針異常,如下圖:

當然我們可以通過指定異常類型拋出其他異常,lombok.nonNull.exceptionType = [NullPointerException | IllegalArgumentException] , 為實現此功能我們需要在項目的根目錄新建lombok.config文件:

重新編譯NonNullExample類,已經為我們拋出非法參數異常:

@NoArgsConstructor, @RequiredArgsConstructor, @AllArgsConstructor

以上三個註解分別為我們生成無參構造器,指定參數構造器和包含所有參數的構造器,默認情況下,@RequiredArgsConstructor, @AllArgsConstructor 生成的構造器會對所有標記 @NonNull 的屬性做非空校驗。

無參構造器很好理解,我們主要看看后兩種,先看 @RequiredArgsConstructor

從上圖中我們可以看出, @RequiredArgsConstructor 註解生成有參數構造器時只會包含有 final 和 @NonNull 標識的 field,同時我們可以指定 staticName 通過生成靜態方法來構造對象

查看Employee.class文件

當我們把 staticName 屬性去掉我們來看遍以後的文件:

相信你已經注意到細節

@AllArgsConstructor 就更簡單了,請大家自行查看吧

@Data

介紹了以上的註解,再來介紹 @Data 就非常容易懂了,@Data 註解應用在類上,是@ToString, @EqualsAndHashCode, @Getter / @Setter@RequiredArgsConstructor合力的體現,如下圖:

@Builder

函數式編程或者說流式的操作越來越流行,應用在大多數語言中,讓程序更具更簡介,可讀性更高,編寫更連貫,@Builder就帶來了這個功能,生成一系列的builder API,該註解也需要應用在類上,看下面的例子就會更加清晰明了。

編譯后的Employee.class文件如下:

媽媽再也不用擔心我 set 值那麼麻煩了,流式操作搞定:

@Log

該註解需要應用到類上,在編寫服務層,需要添加一些日誌,以便定位問題,我們通常會定義一個靜態常量Logger,然後應用到我們想日誌的地方,現在一個註解就可以實現:

查看class文件,和我們預想的一樣:

Log有很多變種,CommonLog,Log4j,Log4j2,Slf4j等,lombok依舊良好的通過變種註解做良好的支持:

我實際使用的是 @Slf4j 註解

val

熟悉 Javascript 的同學都知道,var 可以定義任何類型的變量,而在 java 的實現中我們需要指定具體變量的類型,而 val 讓我們擺脫指定,編譯之後就精準匹配上類型,默認是 final 類型,就像 java8 的函數式表達式,()->System.out.println(“hello lombok”); 就可以解析到Runnable函數式接口。

查看解析后的class文件:

@Cleanup

當我們對流進行操作,我們通常需要調用 close 方法來關閉或結束某資源,而 @Cleanup 註解可以幫助我們調用 close 方法,並且放到 try/finally 處理塊中,如下圖:

編譯后的class文件如下,我們發現被try/finally包圍處理,並調用了流的close方法

其實在 JDK1.7 之後就有了 try-with-resource,不用我們顯式的關閉流,這個請大家自行看吧

總結

Lombok的基本操作流程是這樣的:

  1. 定義編譯期的註解
  2. 利用JSR269 api(Pluggable Annotation Processing API )創建編譯期的註解處理器
  3. 利用tools.jar的javac api處理AST(抽象語法樹)
  4. 將功能註冊進jar包

Lombok 當然還有很多註解,我推薦使用以上就足夠了,這個工具是帶來便利的,而不能被其捆綁,“弱水三千隻取一瓢飲,代碼千萬需抓重點看”,Lombok 能讓我更加專註有效代碼排除意義微小的障眼代碼(get,set等),另外Lombok生成的代碼還能像使用工具類一樣方便(@Builder)。

更多內容請查看官網:https://www.projectlombok.org/

靈魂追問

  1. 為什麼只有一個整體 @EqualsAndHashCode 註解?而不是 @Equals@HashCode?這涉及到一個規範哦
  2. 如果把三種構造器方式同時應用又加上了 @Builder 註解,會發生什麼?
  3. 你的燈還亮着嗎?

歡迎持續關注公眾號:「日拱一兵」

  • 前沿 Java 技術乾貨分享
  • 高效工具匯總 | 回復「工具」
  • 面試問題分析與解答
  • 技術資料領取 | 回復「資料」

以讀偵探小說思維輕鬆趣味學習 Java 技術棧相關知識,本着將複雜問題簡單化,抽象問題具體化和圖形化原則逐步分解技術問題,技術持續更新,請持續關注……

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理【其他文章推薦】

※如何讓商品強力曝光呢? 網頁設計公司幫您建置最吸引人的網站,提高曝光率!!

網頁設計一頭霧水??該從何著手呢? 找到專業技術的網頁設計公司,幫您輕鬆架站!

※想知道最厲害的台北網頁設計公司推薦台中網頁設計公司推薦專業設計師”嚨底家”!!

RALM: 實時 Look-alike 算法在微信看一看中的應用

嘉賓:劉雨丹 騰訊 高級研究員

整理:Jane Zhang

來源:DataFunTalk

出品:DataFun

注:歡迎關注DataFunTalk同名公眾號,收看第一手原創技術文章。

導讀:本次分享是微信看一看團隊在 KDD2019 上發表的一篇論文。長尾問題是推薦系統中的經典問題,但現今流行的點擊率預估方法無法從根本上解決這個問題。文章在 look-alike 方法基礎上,針對微信看一看的應用場景設計了一套實時 look-alike 框架,在解決長尾問題的同時也滿足了資訊推薦的高時效性要求。

▌背景

微信大家可能都用過,微信中的“看一看”是 feed 推薦流的形式,涵蓋了騰訊整個生態鏈的內容分發平台,包括騰訊新聞、公眾號文章、騰訊視頻等。每天總分發量在千萬級以上,面對如此大的分發量,要滿足不同興趣偏好的用戶需求,使用傳統的方法時遇到了一些問題。我們針對發現的問題做了優化和改進,接下來分享下我們優化的過程。

▌未緩解的馬太效應

馬太效應,簡單解釋,在內容的生態系統中,自然分髮狀態會造成一種現象:頭部10%的內容佔據了系統90%的流量、曝光量or點擊量,剩下90%的內容,集中在長尾的10%里。這對於內容的生產方、內容系統的生態和使用系統的用戶來說,都是不健康的狀態。造成這種現象的原因,是因為系統分發能力不夠強,無法處理信息過載的現象,推薦系統設計的初衷就是為了解決馬太效應問題。

回顧推薦系統的發展,從最開始的規則匹配 -> 協同過濾 -> 線性模型 -> deep learning,逐步緩解了馬太效應現象,但沒有完全解決。

造成這個現象的原因是傳統模型、CTR 預估和 deep model,都對部分特徵有依賴,沒有把特徵完全發掘出來,導致模型推薦結果是趨熱的,使生態系統內優質長尾內容投放依然困難。因為 CTR model 最終趨向於行為特徵,或者后驗結果較好的數據,對於優質長尾內容,如小眾興趣的音樂、電影、深度報道的新聞專題等,獲得的相應曝光依舊困難,處於馬太效應 long tail 90%的部分,這會影響推薦系統的生態,導致推薦系統內容越來越窄。

▌為什麼無法準確投放長尾?

怎樣解決這個問題?這個問題歸根結底是對內容的建模不夠完整。我們嘗試分析下問題出在哪:

先看下推薦系統建模流程。首先得到原始樣本,這是業務下的訓練數據,形式是三元組:userid,itemid 和 label。如果是 timeline 的樣本,那就是點擊或者不點擊。原始樣本中,一條樣本可以完整表示一個用戶在某個時間點對一個 item 產生了一次行為,把這個三元組當作信息的最完整形式。對於這個完整形式,直接建模很簡單,如傳統的 item CF,或者協同過濾。協同過濾是最初級的方法,直接對 uid,itemid,label 做擬合,因為可以完全利用初始樣本的信息,擬合的準確性非常好。弱點也很明顯,對原始樣本中沒有包含的 userid 或者 itemid,沒有泛化推理能力,後續新曝光的 user 和 item 是無法處理的。這個問題,就是我們要做的第二步驟,對原始樣本做抽象。既然無法獲取所有的 userid 和 itemid,那就要對 user 或者 item 做一層抽象,如 user 抽象成基礎畫像:年齡、性別或所處地域;item 抽象成語義特徵:topic、tag 等;item 歷史行為特徵,簡單做統計:過去一段時間的點擊率、曝光率、曝光次數。最後基於泛化過的特徵做擬合,得到最終模型。

問題出在哪?做原始特徵抽象,抽象意味着發生了信息損失,這部分信息損失導致模型擬合時走向了比較偏的道路。舉個簡單的例子:同一個 item,有相同的 topic tag,歷史點擊率和歷史曝光次數和點擊次數也相同,可以說這兩個 item 是相同的嗎?顯然有可能是不同的。使用統計特徵無法完整表達,同樣的 item 點擊都是0.5,PV 都是1000 or 2000。有些 item 被這群用戶看過,有些 item 被那群用戶看過。儘管語義特徵和行為特徵都相同,但兩群 user 不同,Item 的受眾也不同。這裏說的抽象的方式,是不完整的 item 行為建模,也是對 item 歷史行為不完整的刻畫,這就導致了整個 model,對 item 后驗數據十分依賴,導致推薦結果趨向於 CTR 表現好或者 PV 表現好的 item。最終后驗數據表現好的數據又會更進一步被模型推薦且曝光,這樣會造成惡性循環:一方面,加劇了頭部效應的影響,使模型陷入局部最優;另一方面,整個推薦系統邊界效應收窄,用戶趨向於看之前表現好的數據,很少看到能拓寬推薦系統邊界或者用戶視野的長尾數據。

▌Look-alike 模型

問題就是這樣產生的, 可以思考一下,問題的本質是什麼?就是因為模型無法對 item 行為完整建模,這一步信息損失太大,怎麼解決這個問題呢?我們首先想到了一種方案:look-alike。

這是廣告領域的經典方案,這類模型的方法也很簡單,首先可以有一個候選集合的 item,我們要推這部分 item,怎麼推呢?第一個步驟:找到歷史上已知的、廣告主提供的對 item 表達過興趣的用戶,這部分用戶稱為種子用戶。然後使用用戶相似度法方法,找到和種子用戶最相似的目標人群,稱為目標用戶,把這部分 item 直接推給目標用戶。這個方法在廣告系統中,是用來做定向投放的,效果很好。為什麼呢?我們來看下模型的整體思路。

把相關的 item 找到對它發生過歷史行為的種子用戶,直接用種子用戶的特徵,作為模型的輸入,這是正樣本;從全局用戶中負採樣一部分用戶作為負樣本。用歷史行為的用戶的特徵來學習 item 的歷史行為,相當於把不同用戶看過的 item 區分開,其實是對 item 的歷史行為特徵的完整建模。之前提到,行為樣本是信息量最大的樣本,它們沒有經過抽象,如果能完整的用受眾用戶的行為來計算 item 的特徵,可以說是最完整的 item 歷史特徵的建模。

Look-alike 在廣告領域的應用已經很完善,也有很多方式。可以把 look-alike 相關的研究分成兩個方向:第一種是基於相似度的 look-alike,這種 look-alike 比較簡單,大體思路是把所有用戶做 user embedding,映射到低維的向量中,對它做基於 k-means 或者局部敏感 hash 做聚類,根據當前用戶屬於哪個聚類,把這個種子用戶的類感興趣的內容推給目標用戶。這種方法的特點:性能強。因為簡單,只需要找簇中心,或者向量相似度的計算,因為簡單、性能好,模型準確性低。

第二種是和第一種相反的,基於回歸。包括 LR,或者樹模型,或者 DNN or deep model 的方法,主要思路是直接建模種子用戶的特徵。把種子用戶當做模型的正樣本, 針對每個 item 訓練一個回歸模型,做二分類,得出種子用戶的特徵規律。這種方法的優點是:準確性高,因為會針對每個 item 建模。缺點也明顯:訓練開銷大,針對每個 item 都要單獨訓練一個模型。對於廣告來說,可以接受,因為廣告的候選集沒有那麼大,更新頻率也沒那麼高。

但是對於我們的推薦場景,有一些問題:1. 對內容時效性要求高,如推薦的新聞專題,必須在5分鐘或10分鐘內要觸達用戶;2. 候選集更新頻率高,我們每天的候選集上千萬,每分鐘、每一秒都有新內容,如果新內容無法進入推薦池,會影響推薦效果。

▌核心需求

在我們的場景下,如果還用廣告領域的經典的 look-alike,是無法解決的。如果要對每個候選集建模,採用 regression-base 的方法,如每分鐘都要對新加進來的候選集做建模,包括積累種子用戶、做負採樣、訓練,等模型收斂后離線預測 target user 的相似分,這對於線上的時效性是不能接受的。

對於 similarity base 的方法,它的問題是計算過於簡單,如果直接和 CTR 模型 PK,核心指標會下降,得出來的結論是:傳統的 look-alike 不能直接照搬到我們的系統中。

針對我們的需求,我們整理出來了應該滿足的3點核心需求:

  1. 實時。新 item 分發不需要重新訓練模型,要能實時完成種子用戶的擴展;

  2. 高效。因為線上加到 rank 模型 CTR 的後面, 要保持模型核心指標 CTR 的前提下,再去加強長尾內容分發,這樣模型才有意義。要學習準確性和多樣性的用戶表達方式。

  3. 快速。Look-alike 模型要部署到線上,實時預測種子用戶和目標用戶群體的相似度,要滿足線上實時計算的耗時性能要求,也要精簡模型預測的計算次數。

▌RALM:Real-time Attention based Look-alike Model

基於這三個核心需求,我們提出了一個新的方法,全稱是 real-time attention based look-alike model,簡稱 RALM。我先簡單講下 RALM 核心的三個點。

  1. 核心點

① 模型可總結為 user-users 的 model。回想下經典的 CTR 預估模型,是 user2item 的 point-wise 的處理流程建模。User、item、label,我們做的最大的變化,是借鑒了 look-alike 的思想,把 item 替換成種子用戶。用種子用戶的用戶特徵,代替 item 的行為特徵。所以模型從 user2item 的 model,變成 user2user 的 model。圖中右側是 target user,左側是 seeds。

② 完善的 seeds representation。用種子用戶代替 item 行為特徵。這樣面臨的問題是:怎樣更好地表達一個人群。這個 seeds representation,是我們研究中的核心步驟,要得到一個高效、自適應更新的種子用戶的表達方式。

③ real-time。最終目標是部署在線上,實時預測種子用戶群體相似度,需要是能夠實現 real-time 的框架。

上述是模型表達的思路。I 是一個 item,把 item 用 seeds 的 embedding 的集合來表示,seeds embedding,是組成這個種子用戶的每個用戶的 embedding 的函數。學習了 seeds representation,就是這個函數 f。

  1. 整體結構

接下來看下離線訓練部分,這是離線訓練的整體結構。

模型離線訓練分成兩個階段:右側 user representation learning, 左側第二階段是 look alike learning。user representation learning 模型結構,最後的目標是通過一個用戶在不同領域的行為,學習到用戶在所有領域的多樣性且兼顧準確性的用戶興趣的高階畫像。這個畫像在這個位置是低維特徵,向量特徵通過 user presentation learning 的目標學到了所有用戶的 embedding 之後,第二階段是 look alike learning。Look alike learning 模型,是一個 user to user 的 model,右側是目標用戶的特徵輸入,左側是種子用戶人群的 embedding 輸入,左邊種子用戶是一群用戶的 embedding 堆疊到一起,輸入其實是一個矩陣。這兩邊的輸入來源都是第一階段 representation learning 輸出的 embedding。Look alike 的目標是學習目標用戶和候選 item 種子用戶的相似度,最上面是學習兩次相似分的,最後完成種子用戶的擴展。

▌User Representation Learning

按順序來分析下,第一階段,是用戶的表示學習,user representation learning。

這個模型大家看着會比較眼熟,它是用 Youtube 的 representation model 中演化過來的。Youbute 的基礎模型很簡單,下面是用戶在不同領域的行為,下面的基礎特徵可能會有離散值,也可能是連續值。如果是離散值,可以通過 embedding lookup,再過一個 pooling,再和所有領域的特徵做 merge,上面過一個全連接,最後輸出 embedding。右側是感興趣的 item,也會做一些 embedding lookup,整個做 sce loss,或者是多分類。要預測的是:用戶在點擊了這麼多 item 之後,下一個要點擊的 item,最後要預測的就是表達用戶興趣的 embedding。這層 merge layer,最初 Youtube 的版本是用一個 concat。可以看到最初版模型在訓練時遇到了一個問題,最下層是用到了用戶很多個域 ( 每個 field 稱為一個域,可能是每個用戶在每個分佈下的行為,如電商購物下行為,或者是公眾號閱讀的行為 )。

訓練時看到一個現象,有些域的行為學的非常強,參數來看學的非常充分,某些 field 參數分佈不大,最後的權重值較小,對最終預估的分數沒有影響。這裡有兩個名詞:強關聯和弱關聯。最終預估結果關係比較大的 field、參數學習較強的,稱為強關聯特徵域;相反,學的不充分的、對最終結果影響小的,稱為弱關聯特徵域。對於強關聯和弱關聯,如果看到參數分佈是這樣的,是不是就表明弱關聯特徵不重要呢?並不是。舉例來說,representation learning 如果訓練目標是在“看一看”中的閱讀行為,對於某些經常使用微信公眾號、或者閱讀的用戶來說,他們在公眾號平台的閱讀歷史就是非常強的關聯特徵,能夠決定再看一看中的興趣。對於這些特徵來說,這些特徵是很強的,對於其他的如在電商中的購物或者是在搜索中的 query,這些是比較弱的,對看一看的影響很有限。再思考另一種 case,比如,看一看通過某種形式,吸引了很多新用戶。新用戶進來之後,沒有在公眾號平台的閱讀歷史,但是他們在購物或者搜索中有歷史行為,此時這些歷史行為會影響他下一次閱讀的文章,或者感興趣的 item。這些特徵對這些用戶來說是非常重要的。但目前,顯然這些用戶是沒有學到這些變化的。

排查了下模型訓練的過程,可以把結果集中在這一點上,就是這個 merge layer,其實是負責把用戶不同域的特徵 merge 到一起。Merge layer,可以看到右側的圖,原始的 deep model 用的是左側的實現方法,直接用 concat。Concat 的優點是,可以學到所有 field 的參數,缺點是,無法根據輸入的不同分佈,來調整權重值。也就是說,如果80%的用戶的閱讀歷史都是看一看的種子用戶,閱讀歷史都是很豐富的,就很有可能對所有用戶都把這個特徵學的很強。如果是少量用戶,就學不到了,少量用戶關注對其它特徵的啟發作用,concat layer 是學不到的。因為它對於大部分用戶來說,已經把參數學的非常重了,小部分用戶不足以對它產生影響。所以需要一個機制,針對不同用戶的特徵域的輸入動態調整 merge layer 的方式,我們想到的最好的辦法是 attention。Attention 是最近在 NLP 中非常火的,很多模型都會用到。為什麼要用 attetnion?

右下角的結構,就是 attention。我們用到的 attention 是把用戶的輸入的所有的域當做 attention 的 query,key 和 value 都是自身 field 的本身。這是一個典型的 self-attetnion,我們最後要做的是,讓模型根據用戶自己的輸入領域的情況,動態調整不同領域的融合方式,相對於之前的 concat 的方式來說,concat 其實是把所有領域的 field 強行放在同一個向量空間中來學習,自然會有學習不充分的情況。Self-attenion merge 是讓不同的域在自己的向量空間中學習充分,再通過不同的權重組合在一起。其實是相當於讓用戶能有屬於自己的表達,而不是被歷史豐富的用戶帶着走。這是一個優化,可以明顯改善強弱特徵、訓練不均衡的問題。

可以看一下這是我之前訓練的時候在某個特徵域用 tensorboard 打出來的參數分佈的情況,可以看到 attention merge layer 前後,訓練參數有很大變化,之前這些參數基本上都是0,之後會激活出一些值,這個是最後 user presentat learning 的值:precession、recall、auc。也可以看到模型加完 attention 之後,在 auc 和 loss 上都有所優化。

經過 user representation learning 之後,我們現在擁有了所有用戶的兼顧多樣性和準確性的 embedding 表達。接下來要做的是怎麼用 embedding 來表達種子用戶人群?

▌Look-alike learning

Look-alike 要做的第一步就是如何表達 seeds user。

一個種子用戶應該包含什麼信息,這裏我們做兩點假設:

  1. 每個用戶都有自己的興趣,但對整個群體的人群信息存在不同的貢獻度,我們稱為群體的共性信息:global info。共性信息和目標用戶無關,只和用戶群體自身有關。

  2. 種子用戶群體的個性信息。種子群體中一定存在一小部分用戶和 target 用戶興趣相似,這時,當 target 人群變化時,信息會變化,稱為 local info。

種子用戶的相對表達=個性信息+共性信息。怎樣學習 local info 和 global info 呢?我們想到的是用不同的 attention 機制,學習出兩個 embedding:local & global embedding,分別表示這兩種信息。對於 local embedding,是右上角的圖,稱為 local attention unit,這個 attention,是一個乘法開始,它的公式是把種子用戶的矩陣乘以 w,再乘以 target user 的 embedding,再做一層 softmax,再乘以種子用戶自己,這是一個典型的乘法 attention。它的作用是提取種子用戶群體中和 target user 相關的部分。捕獲種子用戶的 local info。

第二部分是 global info,用 global attention,只和 user 相關,和 attention merge 的方法類似,也是一個 self-attention。作用是把種子用戶乘以矩陣轉換,再乘以種子用戶自己,所做的就是捕捉用戶群體自身內部的興趣分佈。得到的這兩種 local & global embedding 之後,進行加權和,這就是種子用戶群體的全部信息。另一個問題來了,採用兩種 attention union 來捕獲信息,這意味着要計算很多次矩陣乘法,對線上開銷很大。兩個 embedding 需要多少次計算?這裡有個表達公式,這個 h 是 embedding 的維度,K 是種子用戶用戶的數量,總的計算次數 = h * h * K * 2。對於線上耗時,一次預測超過 1000ms,無法接受。

優化耗時,第一個方法是減少種子用戶的數量,這樣會影響種子用戶的表達完整性;另一種是我們線上採取的方式,使用聚類。找到種子用戶內部比較相似的,把它們聚在一起。這種方法:1. 減少 key 的數量,2. 保持種子用戶的全部信息。聚類的方式比較簡單,用的是 k-means。

簡單看下這個模型,右側是 target user embedding,經過全連接,左邊是 series user embedding 矩陣,兩邊都經過 embedding 之後,首先對種子用戶的 embedding 做聚類,得到 k 個聚類中心,把種子用戶的向量根據 k 個聚類中心做聚和,在類似中心內部做類似於 average 的聚和,然後得到 k 個向量,在這 k 個向量之上,一邊做 global embedding,另一邊和 target user 做 local embedding。有了這兩個 embedding 之後,通過加權和的方式,做 cosine,再去擬合 user 到 item 的 label。這裏的 label 用的是點擊。

細節:

聚類的過程需要迭代,比較耗時,並非每個 batch 都去更新聚類中心,而是採取迭代更新的方式,比如把1000個 batch 一輪,訓練完1000個 batch 之後,這1000個 batch 中,不更新聚類中心;到了第二輪,根據全連接參數的變化,再去更新種子用戶的聚類中心,每通過一輪更新一次聚類中心,保證和核心參數是同步的。這樣既保證了訓練的效率,也保證了訓練的準確性。聚類的優化,使線上的計算次數減小到了 k/K 中,之前 K 是萬級別的數量,現在 k 是百級別的數量,耗時也下降了很多。

根據實驗結果,確定不同聚類中心數 k 帶來的影響,選擇了合適的 k。實驗中,k=20,線上 k 是100左右。模型訓練的 label 優化方式,是一個多分類。對不同的種子用戶人群選擇最相似的用戶。多分類的優化方式和 deep model 相似,採用 negative sampling 的方式。

▌系統架構

線上需要實現實時預測,系統實際部署到線上,需要整套系統架構。簡單介紹下 RALM 的配套體系。

大體過程,分成三個模塊,從最底下的離線訓練,到在線異步處理,到在線服務,接下來分別講一下。

  1. 離線訓練

離線訓練,就是兩個階段的訓練,representation learning,look alike learning,需要一提的是,進行完 look alike learning 之後,可以把 user 經過全連接層的 user 表達緩存起來。全量用戶,有10多億,可以 catch 到 KV 中。可以提供給在線服務做緩存,線上不用做實時全量傳播。

2.在線異步處理

離線訓練結束后,是在線異步處理,主要作用是某些可以離線且和線上請求無關的計算,可以先計算完,如更新種子用戶。每個 item 候選集都會對應一個種子用戶列表,更新種子用戶列表,可以每一分鐘更新一次,這和訪問用戶無關,只和候選集的 item 有關。可以實時拉取用戶的點擊日誌,更新點擊某個候選集的種子列表。

① 可以把 global embedding 預計算 ( gl 只和種子用戶有關,是 self-attenion,可在線做異步處理,如每隔一分鐘算一次 )。

② 計算 k-means 聚類中心,也是只和種子用戶有關,可以提前計算好,如推到推薦系統內存中。

③ 所有的東西都是定時更新,不需要線上實時計算。

3.在線服務

線上把聚類中心、global embedding 和所有用戶的 embedding 都已緩存好,只需要拉取 user embedding,和候選集的 global embedding 和聚類中心。線上只需要計算 local embedding,是 target user 到種子用戶的 attention,這需要根據線上請求的 urn 來實時計算。再計算一次 cosine,就可以得到相似度,這個計算量很小。

▌實驗結果

算出 look alike 相似度之後,相似度的分數,可直接給到排序服務,做曝光依據。這是當時寫論文之前做的 ABtest,對比的是用戶畫像匹配推送的策略,上線之後,在擴大曝光規模的前提下,CTR 基本取向穩定,而且有微小提升,多樣性也提升了很多,這都是相對提升。

▌一些細節和思考

特徵:

為什麼要用第一階段的 user representation learning 得到用戶的高階畫像?高階畫像的作用:包含了用戶在某個領域的全部信息,信息量很大,結合 look alike learning 中的行為,需要去學習用戶群體的特徵。不用高階特徵,怎樣學習用戶群體?比較簡單的方法是通過統計的方式:平均年齡分佈和平均閱讀傾向。這些都是基於離散的統計,信息損失很大。如果有了高階的特徵,高階特徵也是從低階特徵,如基礎畫像、年月分佈,這些都是可以學到高階特徵中。如果能夠直接輸出所有領域的高階特徵,之後的利用、或者作為召回、作為 CTR 特徵,都很方便。

模型調優:

① 防止模型過擬合。look alike 的結構很簡單,這樣做的原因: 直接使用用戶的高階特徵,使用了用戶特徵,如果模型不做處理,容易對高階特徵過擬合。採取了2種方式:

  1. 盡量保證 look alike learning 結構簡單;

  2. 全連接層做 dropout。

② 採用 stacking model 的形式。看一看閱讀、電商、新聞、音樂領域都做一次 user representation learning,這些特徵用 stacking 的模式都放到 look alike model 中學習,這就是不同特徵根據不同目標來訓練的,更加減少了在同一個模型中過擬合的防線。

冷啟動曝光:

Look alike model 中用了種子用戶的表達,如果線上有新的 item,怎樣做曝光?

  1. 初始投放策略。使用基於 user item 的語義特徵做線性模型的預測,當做冷啟動 item 的初始投放。這個初始投放不需要積累很多種子用戶,大概到百級別的種子用戶就可以切到 look alike 邏輯了。

  2. Look alike 出來的相似度分數,怎麼做曝光的依據?如果直接用相似度分數,需要確定曝光閾值,如對於某個 item,高於多少分才曝光。我們使用的是線上試探曝光機制:最初給1000條流量,做曝光,這次曝光后,收集在用戶側的打分,取打分的分佈統計,根據不同業務的要求,曝光 top 5% 或者 top10%, 來砍一個閾值分數,最後取曝光閾值。

本次分享就到這裏,謝謝大家。

▌Q & A

Q:這個算法有沒有在召迴環節用,曝光該如何理解?

A:目前的策略有兩種方式:

  1. 直接採用召回的方式,定一個曝光閾值,直接確定是否曝光;

  2. 把相似分數給到下游的 CTR model 作為參考。

Q:能否將兩階段學習合併成一個端到端學習?

A:End-to-End 方式存在兩個問題:

  1. 整個模型參數量很大,結構比較複雜,採用 End-to-End 方式不一定能學習到或者學習的很充分;

  2. 剛剛講到的 stacking 方式,我們最後需要的是盡可能全的表達用戶的方式,所以右側的 user representation learning 並不是從單一業務領域得出的結果,有可能是在多個領域得到的結果,比如在看一看訓練一版 user representation learning,然後用社交或者電商上的行為,再做一版用戶的表示,最後用 stacking 的方式把它們拼接起來,作為特徵輸入,這樣達到的效果會更好。

Q:如果將第一階段用戶表徵學習換成其他通用能學習表徵用戶向量的模型,效果會有什麼影響?

A:我們單獨用 user representation learning 和其它模型做過對比,比如 CTR 中的 user embedding,是針對當前業務比較精準化的表達,所在在泛化性上沒有 user representation learning 效果好。

▌參考資料

Real-time Attention Based Look-alike Model for Recommender System

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理【其他文章推薦】

※帶您來了解什麼是 USB CONNECTOR  ?

※自行創業 缺乏曝光? 下一步"網站設計"幫您第一時間規劃公司的門面形象

※如何讓商品強力曝光呢? 網頁設計公司幫您建置最吸引人的網站,提高曝光率!!

※綠能、環保無空污,成為電動車最新代名詞,目前市場使用率逐漸普及化

※廣告預算用在刀口上,網站設計公司幫您達到更多曝光效益

結合源碼,重溫 Android View 的事件處理知多少 ?

前言

  • Android View 的 事件處理在我們的編程中,可謂是無處不在了。但對於大多數人而言,一直都是簡單的使用,對其原理缺乏深入地認識。
  • 學 Android 有一段時間了,最近發現,很多基礎知識開始有些遺忘了,所以從新複習了 View 的事件分發。特地整理成了這篇文章分享給大家。
  • 本文不難,可以作為大家茶餘飯後的休閑。

祝大家閱讀愉快!

方便大家學習,我在 GitHub 上建立個 倉庫

  • 倉庫內容與博客同步更新。由於我在 稀土掘金 簡書 CSDN 博客園 等站點,都有新內容發布。所以大家可以直接關注該倉庫,即使獲得精彩內容

  • 倉庫地址:

一、View 的事件回調

  • 我們結合源碼看看 View 的事件分發是個怎樣的過程,首先我們建立一個類 MyButton 類繼承 AppCompatButton 用於測試:
public class MyButton extends AppCompatButton {

    private final String TAG = "DeBugMyButton";
        public MyButton(Context context) {
        super(context);
    }

    public MyButton(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public MyButton(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

}

1.1 事件分發流程

  • 我們都知道有一個方法叫做 public boolean dispatchTouchEvent(MotionEvent event) 。首先我們要知道,對於我們這個自定義控件,他的觸摸事件都是從我們 dispatchTouchEvent 這個方法開始往下去分發的。所以可以說:這個方法是一個入口方法。

1.1.1 onTouchEvent 作用

  • 現在我們重寫該方法和另一個方法:onTouchEvent ,並且打印一行日誌:
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
    Log.d(TAG, "----on dispatch Touch Event----");
    return super.dispatchTouchEvent(event);
}

@Override
public boolean onTouchEvent(MotionEvent event) {
    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            Log.d(TAG, "----on touch event----");
    }
    return super.onTouchEvent(event);
}
  • 然後我們在 MainActivity 中,設置一個實例化一個 MyButton 控件對象用於測試,並且給他添加一個 onClickListentersetOnTouchListener
public class MainActivity extends AppCompatActivity {

    private final String TAG = "DeBugMainActivity";

    /**
     * 自定義控件 MyButton
     */
    private MyButton mMyButton;
    
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        
        iniView();
    }

    /**
     * 實例化控件
     */
    private void iniView() {
        mMyButton = findViewById(R.id.my_button);

    mMyButton.setOnTouchListener(new View.OnTouchListener() {
        @Override
        public boolean onTouch(View v, MotionEvent event) {
            switch (event.getAction()) {
                case MotionEvent.ACTION_DOWN:
                    Log.d(TAG, "----on touch----");
                    break;
                default:
                    break;
            }
            return false;
        }
    });
    
    mMyButton.setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View v) {
            Log.d(TAG, "----on click----");
        }
    });
    }
}
  • 然後我們運行這個 Demo ,點擊 MyButton 按鈕,會的到如下日誌:
  • 我們可以看到首先回調了這個 dispatchTouchEvent ,然後是它的監聽器 OnTouch ,接着是它的 onTouchEvent,最後又執行了 dispatchTouchEvent ,那麼這是為什麼呢?

  • 這是因為我們這兒只監聽了 ACTION_DOWN 而當手指抬起時它同樣還回去回調 dispatchTouchEvent ,最後我們打印 OnClick 的回調。

  • 總結一下就是:
    dispatchTouchEvent -> setOnTouchListener -> onTouchEvent -> setOnClickListener

  • 說明我們 setOnClickListener 是通過 onTouchEvent 處理,產生了 OnClick 。一會我們再來看看其中的原理。

  • 既然說 dispatchTouchEvent 像一個入口,就先讓我們來看下它是怎麼處理和操作的: 首先,既然我們調用了 super.dispatchTouchEvent(event) ,那麼我們就來看看它父類中是怎麼實現該方法的。不信的是,它的父類 AppCompatButton 也沒有實現該方法 ,最後經過層層搜尋,我們發現這個方法是屬於 View 的方法。

1.1.2 dispatchTouchEvent 的實現

  • 那麼現在我們來看看 ViewdispatchTouchEvent 怎麼實現的:
public boolean dispatchTouchEvent(MotionEvent event) {
    ......
        //noinspection SimplifiableIfStatement
        ListenerInfo li = mListenerInfo;
        if (li != null && li.mOnTouchListener != null
                && (mViewFlags & ENABLED_MASK) == ENABLED
                && li.mOnTouchListener.onTouch(this, event)) {
            result = true;
        }

        if (!result && onTouchEvent(event)) {
            result = true;
        }
    }

    if (!result && mInputEventConsistencyVerifier != null) {
        mInputEventConsistencyVerifier.onUnhandledEvent(event, 0);
    }

    // Clean up after nested scrolls if this is the end of a gesture;
    // also cancel it if we tried an ACTION_DOWN but we didn't want the rest
    // of the gesture.
    if (actionMasked == MotionEvent.ACTION_UP ||
            actionMasked == MotionEvent.ACTION_CANCEL ||
            (actionMasked == MotionEvent.ACTION_DOWN && !result)) {
        stopNestedScroll();
    }

    return result;
}
  • dispatchTouchEvent 中,我們可以發現下面這樣一個代碼塊
if (li != null && li.mOnTouchListener != null
        && (mViewFlags & ENABLED_MASK) == ENABLED
        && li.mOnTouchListener.onTouch(this, event)) {
    result = true;
}
  • 不難看出:如果執行了這個代碼段,那麼後面的方法就不會執行了,並且 dispatchTouchEvent 會返回 true 。我們再仔細觀察下其中的條件:在 if 條件中我們發現:只有當其滿足 li.mOnTouchListener != null && (mViewFlags & ENABLED_MASK) == ENABLED && li.mOnTouchListener.onTouch(this, event)) 時才會執行 if 內的操作

  • 經過上面分析,我們可以知道: onTouch 事件必須返回 true 時,才會執行該方法塊。那麼我們就回到 MainActivity 中。我們發現 setOnTouchListeneronTouch 默認返回值是 false( 不滿足返回值為 true ), 這就表明他會繼續去執行下一個代碼塊:

if (!result && onTouchEvent(event)) {
    result = true;
}
  • 執行這個 if 語句的過程中。首先調用了 onTouchEvent 方法。這就解釋了,為什麼它先執行了 mOnTouchListener ,然後再執行 onTouchEvent

  • 現在我們就可以總結一下:首先我們回調了 dispatchTouchEvent ,然後回調 OnTouchListener 。這個時候,如果 TouchListener 沒有 return true ,那麼就會接着去運行 onTouchEvent ( 當然,如果 return true 後面的層級就不會執行了 。一句話說就是:到那個層級 return true 那麼哪個層級就消費掉了這個事件 )。

1.1.3 onTouchEvent 的處理

  • 同時我們還有一個結果:我們 onClick ( 包括我們的 onLongClick ) 是來自於我們 onTouchEvent 這個方法的處理。那麼下面我們就來看看 View 中是怎麼處理 onTouchEvent 的:
public boolean onTouchEvent(MotionEvent event) {
    。。。

    if (clickable || (viewFlags & TOOLTIP) == TOOLTIP) {
        switch (action) {
            case MotionEvent.ACTION_UP:
                。。。
                break;

            case MotionEvent.ACTION_DOWN:
                if (event.getSource() == InputDevice.SOURCE_TOUCHSCREEN) {
                    mPrivateFlags3 |= PFLAG3_FINGER_DOWN;
                }
                mHasPerformedLongPress = false;

                if (!clickable) {
                    checkForLongClick(0, x, y);
                    break;
                }

                if (performButtonActionOnTouchDown(event)) {
                    break;
                }

                // Walk up the hierarchy to determine if we're inside a scrolling container.
                boolean isInScrollingContainer = isInScrollingContainer();

                // For views inside a scrolling container, delay the pressed feedback for
                // a short period in case this is a scroll.
                if (isInScrollingContainer) {
                    mPrivateFlags |= PFLAG_PREPRESSED;
                    if (mPendingCheckForTap == null) {
                        mPendingCheckForTap = new CheckForTap();
                    }
                    mPendingCheckForTap.x = event.getX();
                    mPendingCheckForTap.y = event.getY();
                    postDelayed(mPendingCheckForTap, ViewConfiguration.getTapTimeout());
                } else {
                    // Not inside a scrolling container, so show the feedback right away
                    setPressed(true, x, y);
                    checkForLongClick(0, x, y);
                }
                break;

            case MotionEvent.ACTION_CANCEL:
                。。。
                break;

            case MotionEvent.ACTION_MOVE:
                if (clickable) {
                    drawableHotspotChanged(x, y);
                }

                // Be lenient about moving outside of buttons
                if (!pointInView(x, y, mTouchSlop)) {
                    // Outside button
                    // Remove any future long press/tap checks
                    removeTapCallback();
                    removeLongPressCallback();
                    if ((mPrivateFlags & PFLAG_PRESSED) != 0) {
                        setPressed(false);
                    }
                    mPrivateFlags3 &= ~PFLAG3_FINGER_DOWN;
                }
                break;
        }

        return true;
    }

    return false;
}

二、onClick 和 OnLongClick

  • 因為我們是拿 ACTION_DOWN 作為舉例的。那麼我們先來分析一下 case MotionEvent.ACTION_DOWN : 中 onTouchEvent 是怎麼執行的,以及 onClickOnLongClick 是如何產生的:

2.1 onClick 和 OnLongClick 的產生

  • 首先,當我們手指按下時,有一個 mHasPerformedLongPress 標識會先被設為 false 。再往下會執行一行 postDelayed(mPendingCheckForTapViewConfiguration.getTapTimeout()); 我們來看看這一行的作用:

  • 首先,從名字我們就可以猜測,這是個延時執行的方法。我們進一步閱讀發現 mPendingCheckForTap 是一個 Runnable 動作; ViewConfiguration.getTapTimeout() 是一個 100mm 的延時。也就是說延時 100mm 後去執行 mPendingCheckForTap 中的動作。那麼我們就來看看 mPendingCheckForTap 中做了什麼:

private final class CheckForTap implements Runnable {
    public float x;
    public float y;

    @Override
    public void run() {
        mPrivateFlags &= ~PFLAG_PREPRESSED;
        setPressed(true, x, y);
        checkForLongClick(ViewConfiguration.getTapTimeout(), x, y);
    }
}
  • 也就是說,停一百秒后就開始檢查,用戶的手指是否離開了屏幕。( 就是當前 ACTION_DOWN 之後,有沒有觸發了 ACTION_UP 這個環節 ),但是 ACTION_DOWN 后,我們還有一個 ACTION_MOVE 過程。在這個 ACTION_MOVE 中,如果 100mm 內離開了屏幕、或者離開了這個控件就會觸發 ACTION_UP ,那麼就認為這是一個點擊事件 onClick 。如果沒有觸發 ACTION_UP 的話,就會再延時 400mm

2.2 ACTION_DOWN 之後流程

  • ACTION_DOWN 之後,會先等 100mm
  • 如果沒有離開屏幕或者離開控件,就是沒有觸發 ACTION_UP 的話,就會再延時 400mm。
  • 500mm 后就會觸發 onLongClick 事件。

2.3 那麼我們現在來驗證一下 onLongClick :

  • 首先再 MainActivity 中加上:
mMyButton.setOnLongClickListener(new View.OnLongClickListener() {
    @Override
    public boolean onLongClick(View v) {

        return true;
    }
});
  • 接着,我們發現 OnLongClick 是有返回值的,如果返回值是 false 還會接着去觸發 onClick 事件,如果返回 true 的話,那麼這個長按事件就直接被消費掉了( 也就是這個點擊事件就不會完後傳遞到 OnClickListener 中去了 )。

2.4 總結

  • 100mm 時為點擊,500mm 時為長按,接着觸髮長按事件。
  • 再看長按事件的返回值,如果時 true 就結束。
  • 如果時 false 那麼 OnClickListener 就同樣也被執行。
  • 這就是由 obTouchEvent 產生出來的 onClick/onLongClick 的來龍去脈。

總結

  • 我們 View 的事件方法,基本上就是這麼一個思路,從 dispatchTouchEventOnTouchListener 監聽器,再到 onTouchEvent,接着 onTouchEvent 由產生了 onClick/onLongClick
  • 如果大家感興趣的話可以更深入的去閱讀源碼。
  • 重點:學 Android 有一段時間了,我打算好好的梳理一下所學知識,包括 ActivityServiceBroadcastRecevier 事件分發、滑動衝突、新能優化等所有重要模塊,歡迎大家關注 ,方便及時接收更新
  • 如果有可以補充的知識點,歡迎大家在評論區指出。

碼字不易,你的點贊是我總結的最大動力!

  • 由於我在「稀土掘金」「簡書」「CSDN」「博客園」等站點,都有新內容發布。所以大家可以直接關注我的 GitHub 倉庫,以免錯過精彩內容!

  • 倉庫地址:

  • 一萬多字長文,加上精美思維導圖,記得點贊哦,歡迎關注 ,我們下篇文章見!

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理【其他文章推薦】

※為什麼 USB CONNECTOR 是電子產業重要的元件?

網頁設計一頭霧水??該從何著手呢? 找到專業技術的網頁設計公司,幫您輕鬆架站!

※想要讓你的商品成為最夯、最多人討論的話題?網頁設計公司讓你強力曝光

※想知道最厲害的台北網頁設計公司推薦台中網頁設計公司推薦專業設計師”嚨底家”!!

BloomFilter在Hudi中的應用

Bloom Filter在Hudi中的應用

介紹

Bloom Filter可以用於檢索一個元素是否在一個集合中。它的優點是空間效率和查詢時間都遠遠超過一般的算法,主要缺點是存在一定的誤判率:當其判斷元素存在時,實際上元素可能並不存在。而當判定不存在時,則元素一定不存在,Bloom Filter在對精確度要求不太嚴格的大數據量場景下運用十分廣泛。

引入

為何要引入Bloom Filter?這是Hudi為加快數據upsert採用的一種解決方案,即判斷record是否已經在文件中存在,若存在,則更新,若不存在,則插入。對於upsert顯然無法容忍出現誤判,否則可能會出現應該插入和變成了更新的錯誤,那麼Hudi是如何解決誤判問題的呢?一種簡單辦法是當Bloom Filter判斷該元素存在時,再去文件里二次確認該元素是否真的存在;而當Bloom Filter判斷該元素不存在時,則無需讀文件,通過二次確認的方法來規避Bloom Filter的誤判問題,實際上這也是Hudi採取的方案,值得一提的是,現在Delta暫時還不支持Bloom Filter,其判斷一條記錄是否存在是直接通過一次全表join來實現,效率比較低下。接下來我們來分析Bloom Filter在Hudi中的應用。

流程

Hudi從上游系統(Kafka、DFS等)消費一批數據后,會根據用戶配置的寫入模式(insert、upsert、bulkinsert)寫入Hudi數據集。而當配置為upsert時,意味着需要將數據插入更新至Hudi數據集,而第一步是需要標記哪些記錄已經存在,哪些記錄不存在,然後,對於存在的記錄進行更新,不存在記錄進行插入。

HoodieWriteClient中提供了對應三種寫入模式的方法(#insert、#upsert、#bulkinsert),對於使用了Bloom Filter的#upsert方法而言,其核心源代碼如下

public JavaRDD<WriteStatus> upsert(JavaRDD<HoodieRecord<T>> records, final String commitTime) {
    ...
    // perform index loop up to get existing location of records
    JavaRDD<HoodieRecord<T>> taggedRecords = index.tagLocation(dedupedRecords, jsc, table);
    ...
    return upsertRecordsInternal(taggedRecords, commitTime, table, true);
}

可以看到首先利用索引給記錄打標籤,然後再進行更新,下面主要分析打標籤的過程。

對於索引,Hudi提供了四種索引方式的實現:HBaseIndexHoodieBloomIndexHoodieGlobalBloomIndexInMemoryHashIndex,默認使用HoodieBloomIndex。其中HoodieGlobalBloomIndex與HoodieBloomIndex的區別是前者會讀取所有分區文件,而後者只讀取記錄所存在的分區下的文件。下面以HoodieBloomIndex為例進行分析。

HoodieBloomIndex#tagLocation核心代碼如下

public JavaRDD<HoodieRecord<T>> tagLocation(JavaRDD<HoodieRecord<T>> recordRDD, JavaSparkContext jsc,
      HoodieTable<T> hoodieTable) {

    // Step 0: cache the input record RDD
    if (config.getBloomIndexUseCaching()) {
      recordRDD.persist(config.getBloomIndexInputStorageLevel());
    }

    // Step 1: Extract out thinner JavaPairRDD of (partitionPath, recordKey)
    JavaPairRDD<String, String> partitionRecordKeyPairRDD =
        recordRDD.mapToPair(record -> new Tuple2<>(record.getPartitionPath(), record.getRecordKey()));

    // Lookup indexes for all the partition/recordkey pair
    JavaPairRDD<HoodieKey, HoodieRecordLocation> keyFilenamePairRDD =
        lookupIndex(partitionRecordKeyPairRDD, jsc, hoodieTable);

    // Cache the result, for subsequent stages.
    if (config.getBloomIndexUseCaching()) {
      keyFilenamePairRDD.persist(StorageLevel.MEMORY_AND_DISK_SER());
    }

    // Step 4: Tag the incoming records, as inserts or updates, by joining with existing record keys
    // Cost: 4 sec.
    JavaRDD<HoodieRecord<T>> taggedRecordRDD = tagLocationBacktoRecords(keyFilenamePairRDD, recordRDD);

    if (config.getBloomIndexUseCaching()) {
      recordRDD.unpersist(); // unpersist the input Record RDD
      keyFilenamePairRDD.unpersist();
    }

    return taggedRecordRDD;
  }

該過程會緩存記錄以便優化數據的加載。首先從記錄中解析出對應的分區路徑 -> key,接着查看索引,然後將位置信息(存在於哪個文件)回推到記錄中。

HoodieBloomIndex#lookup核心代碼如下

private JavaPairRDD<HoodieKey, HoodieRecordLocation> lookupIndex(
      JavaPairRDD<String, String> partitionRecordKeyPairRDD, final JavaSparkContext jsc,
      final HoodieTable hoodieTable) {
    // Obtain records per partition, in the incoming records
    Map<String, Long> recordsPerPartition = partitionRecordKeyPairRDD.countByKey();
    List<String> affectedPartitionPathList = new ArrayList<>(recordsPerPartition.keySet());

    // Step 2: Load all involved files as <Partition, filename> pairs
    List<Tuple2<String, BloomIndexFileInfo>> fileInfoList =
        loadInvolvedFiles(affectedPartitionPathList, jsc, hoodieTable);
    final Map<String, List<BloomIndexFileInfo>> partitionToFileInfo =
        fileInfoList.stream().collect(groupingBy(Tuple2::_1, mapping(Tuple2::_2, toList())));

    // Step 3: Obtain a RDD, for each incoming record, that already exists, with the file id,
    // that contains it.
    Map<String, Long> comparisonsPerFileGroup =
        computeComparisonsPerFileGroup(recordsPerPartition, partitionToFileInfo, partitionRecordKeyPairRDD);
    int safeParallelism = computeSafeParallelism(recordsPerPartition, comparisonsPerFileGroup);
    int joinParallelism = determineParallelism(partitionRecordKeyPairRDD.partitions().size(), safeParallelism);
    return findMatchingFilesForRecordKeys(partitionToFileInfo, partitionRecordKeyPairRDD, joinParallelism, hoodieTable,
        comparisonsPerFileGroup);
  }

該方法首先會計算出每個分區有多少條記錄和影響的分區有哪些,然後加載影響的分區的文件,最後計算并行度后,開始找記錄真正存在的文件。

對於#loadInvolvedFiles方法而言,其會查詢指定分區分區下所有的數據文件(parquet格式),並且如果開啟了hoodie.bloom.index.prune.by.ranges,還會讀取文件中的最小key和最大key(為加速後續的查找)。

HoodieBloomIndex#findMatchingFilesForRecordKeys核心代碼如下

JavaPairRDD<HoodieKey, HoodieRecordLocation> findMatchingFilesForRecordKeys(
      final Map<String, List<BloomIndexFileInfo>> partitionToFileIndexInfo,
      JavaPairRDD<String, String> partitionRecordKeyPairRDD, int shuffleParallelism, HoodieTable hoodieTable,
      Map<String, Long> fileGroupToComparisons) {
    JavaRDD<Tuple2<String, HoodieKey>> fileComparisonsRDD =
        explodeRecordRDDWithFileComparisons(partitionToFileIndexInfo, partitionRecordKeyPairRDD);

    if (config.useBloomIndexBucketizedChecking()) {
      Partitioner partitioner = new BucketizedBloomCheckPartitioner(shuffleParallelism, fileGroupToComparisons,
          config.getBloomIndexKeysPerBucket());

      fileComparisonsRDD = fileComparisonsRDD.mapToPair(t -> new Tuple2<>(Pair.of(t._1, t._2.getRecordKey()), t))
          .repartitionAndSortWithinPartitions(partitioner).map(Tuple2::_2);
    } else {
      fileComparisonsRDD = fileComparisonsRDD.sortBy(Tuple2::_1, true, shuffleParallelism);
    }

    return fileComparisonsRDD.mapPartitionsWithIndex(new HoodieBloomIndexCheckFunction(hoodieTable, config), true)
        .flatMap(List::iterator).filter(lr -> lr.getMatchingRecordKeys().size() > 0)
        .flatMapToPair(lookupResult -> lookupResult.getMatchingRecordKeys().stream()
            .map(recordKey -> new Tuple2<>(new HoodieKey(recordKey, lookupResult.getPartitionPath()),
                new HoodieRecordLocation(lookupResult.getBaseInstantTime(), lookupResult.getFileId())))
            .collect(Collectors.toList()).iterator());
  }

該方法首先會查找記錄需要進行比對的文件,然後再查詢的記錄的位置信息。

其中,對於#explodeRecordRDDWithFileComparisons方法而言,其會藉助樹/鏈表結構構造的文件過濾器來加速記錄對應文件的查找(每個record可能會對應多個文件)。

而使用Bloom Filter的核心邏輯承載在HoodieBloomIndexCheckFunction,HoodieBloomIndexCheckFunction$LazyKeyCheckIterator該迭代器完成了記錄對應文件的實際查找過程,查詢的核心邏輯在computeNext`中,其核心代碼如下

protected List<HoodieKeyLookupHandle.KeyLookupResult> computeNext() {

      List<HoodieKeyLookupHandle.KeyLookupResult> ret = new ArrayList<>();
      try {
        // process one file in each go.
        while (inputItr.hasNext()) {
          Tuple2<String, HoodieKey> currentTuple = inputItr.next();
          String fileId = currentTuple._1;
          String partitionPath = currentTuple._2.getPartitionPath();
          String recordKey = currentTuple._2.getRecordKey();
          Pair<String, String> partitionPathFilePair = Pair.of(partitionPath, fileId);

          // lazily init state
          if (keyLookupHandle == null) {
            keyLookupHandle = new HoodieKeyLookupHandle(config, hoodieTable, partitionPathFilePair);
          }

          // if continue on current file
          if (keyLookupHandle.getPartitionPathFilePair().equals(partitionPathFilePair)) {
            keyLookupHandle.addKey(recordKey);
          } else {
            // do the actual checking of file & break out
            ret.add(keyLookupHandle.getLookupResult());
            keyLookupHandle = new HoodieKeyLookupHandle(config, hoodieTable, partitionPathFilePair);
            keyLookupHandle.addKey(recordKey);
            break;
          }
        }

        // handle case, where we ran out of input, close pending work, update return val
        if (!inputItr.hasNext()) {
          ret.add(keyLookupHandle.getLookupResult());
        }
      } catch (Throwable e) {
        if (e instanceof HoodieException) {
          throw e;
        }
        throw new HoodieIndexException("Error checking bloom filter index. ", e);
      }

      return ret;
    }

該方法每次迭代只會處理一個文件,每次處理時都會生成HoodieKeyLookupHandle,然後會添加recordKey,處理完后再獲取查詢結果。

其中HoodieKeyLookupHandle#addKey方法核心代碼如下

public void addKey(String recordKey) {
    // check record key against bloom filter of current file & add to possible keys if needed
    if (bloomFilter.mightContain(recordKey)) {
      ...
      candidateRecordKeys.add(recordKey);
    }
    totalKeysChecked++;
  }

可以看到,這裏使用到了Bloom Filter來判斷該記錄是否存在,如果存在,則加入到候選隊列中,等待進一步判斷;若不存在,則無需額外處理,其中Bloom Filter會在創建HoodieKeyLookupHandle實例時初始化(從指定文件中讀取Bloom Filter)。

HoodieKeyLookupHandle#getLookupResult方法核心代碼如下

public KeyLookupResult getLookupResult() {
    ...
    HoodieDataFile dataFile = getLatestDataFile();
    List<String> matchingKeys =
        checkCandidatesAgainstFile(hoodieTable.getHadoopConf(), candidateRecordKeys, new Path(dataFile.getPath()));
    ...
    return new KeyLookupResult(partitionPathFilePair.getRight(), partitionPathFilePair.getLeft(),
        dataFile.getCommitTime(), matchingKeys);
  }

該方法首先獲取指定分區下的最新數據文件,然後判斷數據文件存在哪些recordKey,並將其封裝進KeyLookupResult后返回。其中#checkCandidatesAgainstFile會讀取文件中所有的recordKey,判斷是否存在於candidateRecordKeys,這便完成了進一步確認。

到這裏即完成了record存在於哪些文件的所有查找,查找完後會進行進一步處理,後續再給出分析。

總結

Hudi引入Bloom Filter是為了加速upsert過程,並將其存入parquet數據文件中的Footer中,在讀取文件時會從Footer中讀取該BloomFilter。在利用Bloom Filter來判斷記錄是否存在時,會採用二次確認的方式規避Bloom Filter的誤判問題。

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理【其他文章推薦】

※如何讓商品強力曝光呢? 網頁設計公司幫您建置最吸引人的網站,提高曝光率!!

網頁設計一頭霧水??該從何著手呢? 找到專業技術的網頁設計公司,幫您輕鬆架站!

※想知道最厲害的台北網頁設計公司推薦台中網頁設計公司推薦專業設計師”嚨底家”!!

Java開發中常用jar包整理及使用

本文整理了我自己在Java開發中常用的jar包以及常用的API記錄。

<!-- https://mvnrepository.com/artifact/org.apache.commons/commons-lang3 -->
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
            <version>3.8</version>
        </dependency>
        <!-- https://mvnrepository.com/artifact/commons-io/commons-io -->
        <dependency>
            <groupId>commons-io</groupId>
            <artifactId>commons-io</artifactId>
            <version>2.6</version>
        </dependency>
        <!-- https://mvnrepository.com/artifact/org.projectlombok/lombok -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.8</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>log4j</groupId>
            <artifactId>log4j</artifactId>
            <version>1.2.17</version>
        </dependency>

common-lang3

簡介

一個現在最為常用的jar包,封裝了許多常用的工具包

依賴:

<!-- https://mvnrepository.com/artifact/org.apache.commons/commons-lang3 -->
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-lang3</artifactId>
    <version>3.4</version>
</dependency>

主要常見的類如下:

  • 數組工具類 ArrayUtils
  • 日期工具類 DateUtils DateFormatUtils
  • 字符串工具類 StringUtils
  • 数字工具類 NumberUtils
  • 布爾工具類 BooleanUtils
  • 反射相關工具類 FieldUtils、MethodUtils、MemberUtils、TypeUtils、ConstructorUtils
  • 對象工具類 ObjectUtils
  • 序列化工具類 SerializationUtils

API介紹

這裏我只介紹經常使用的幾個工具類及方法,ArrayUtils,StringUtils,NumberUtils,DateUtils,其他的請查看官方API文檔吧

1.ArrayUtils

方法名 說明
add
remove
clone 複製數組
addAll
removeAll 第二個參數傳入需要刪除的下標(可以指定多個下標)
toObject 把數值(int[],double[])轉為包裝類(Int[],Double[])
indexOf 在數組按順序查找,找到第一個滿足對應的數值的下標
lastIndexOf 在數組按順序查找,找到最後一個滿足對應的數值的下標
contains 數組是否包含某個值
isEmpty 判斷數組是否為空
isNotEmpty 判斷數組是否不為空
reverse 數組反轉
subarray 指定區間截取數組,區間為半開區間,不包含末尾
toArray 接收一個多個對象,把這幾個對象轉為對應類型的數組
toMap 將一個二維數組轉為Map

2.NumberUtils

方法名 說明
min 比較三個數,返回最小值 或比較指定的幾個數,返回最小值
max 比較三個數,返回最大值 或比較指定的幾個數,返回最大值
createInt 從傳入的String中創建對應類型的數值,createDouble,createFloat…
toInt 將指定字符串轉為Int類型,可以選擇指定默認數值,如果字符串為null則返回默認數值,除此之外,還有toDouble,toLong…等轉為不同類型的方法
compare 比較兩個同類型數值的大小
isDigits 判斷字符串是否只包含数字
isParsable 判斷字符串是否可轉換為Long,Int等類型
isNumber 判斷字符串是否為數值(0x,0X開頭等進制數值)

3.DateUtils

方法名 說明
parseDate 將Date對象轉為字符串
isSameDay 判斷兩個Dated對象是否為同一天
isSameDay 判斷兩個Dated對象是否為同一天
addHour 將指定的Date對象加上指定小時,除此之外,還有addMonth,addDay..等

DateFormatUtils正如其名,是用來把時間轉為字符串,這裏就不再多說

4.StringUtils

方法名 說明
join 將指定的數組連接成字符串,並添加指定的分割字符
containOnly 字符串是否只包含某個字符串
substringBefore 截取指定字符串前面的內容
substringAfter 截取指定字符串後面的內容(不包括指定字符串)
substringBetween 截取字符串某區間內容,如substringBetween(“abcde”,”a”,”e”)=”bcd”
difference 比較兩個字符串,返回兩個字符串不同的內容,具體可以看API文檔給出的示例
isBlank 判斷字符串是否為空白,null,””,” “這三個結果都是為true
isEmpty 判斷字符串是否為空(只要不為null,或傳入的String對象的長度不為0即為true)
countMatches 判斷指定的字符串在某個字符串中出現的次數
deleteWhitespace 刪除字符串中的空格
defaultIfBlank 如果字符串為空白,則返回一個指定的默認值(null或某個String)
defaultIfEmpty 如果字符串為空,則返回一個指定的默認值(null或某個String)
capitalize 將指定字符串首字母大寫
abbreviate 將指定字符串的後面三位轉為…
swapCase 將字符串中的字母大小寫反轉,如aBc變為AbC
lowerCase 將字符串的字母全部轉為小寫
upperCase 將字符串的字母全部轉為大寫
left 取字符串左邊幾個字符,如left(“hello”,3)=”hel”,right與此相反
leftPad 字符串的長度不夠,則使用指定字符填充指定字符串,如leftPad(“hel”,5,”z”)=”zzhel”,rightPad方法與此相反
prependIfMissing 指定字符串不以某段字符串開頭,則自動添加開頭,如prependIfMissing(“hello”,”li”)=”lihello”
prependIfMissing 指定字符串不以某段字符串開頭(忽略大小寫),則自動添加開頭
getCommonPrefix 獲得多個字符串相同的開頭內容,接收參數為多個字符串
removeEnd 刪除字符串中結尾(滿足是以某段內容結尾),如removeEnd(“hello”,”llo”)=”he”
removeEndIgnoreCase 與上面一樣,忽略大小寫
removeStart 與上面的相反
remove 刪除字符串中的指定內容,如remove(“hello”,”l”)=”heo”
removeIgnoreCase 刪除字符串中的指定內容,如remove(“hello”,”l”)=”heo”
strip 清除字符串開頭和末尾指定的字符(第二個參數為null,用來清除字符串開頭和末尾的空格),如strip(” abcxy”,”xy”)=” abc”,strip(” abcxy”,”yx”)=” abc”
stripStart 清除字符串開頭指定字符
stripEnd 清除字符串末尾指定的字符

common-io

簡介

常用的IO流工具包

<!-- https://mvnrepository.com/artifact/commons-io/commons-io -->
<dependency>
    <groupId>commons-io</groupId>
    <artifactId>commons-io</artifactId>
    <version>2.6</version>
</dependency>

API

我們主要關心的就是Utils後綴的那幾個類即可,可以看到,common-io庫提供了FileUtils,FileSystemUtils,FileNameUtils,FileFilterUtils,IOUtils

FileUtils

  • 寫出文件
  • 讀取文件
  • 創建一個有父級文件夾的文件夾
  • 複製文件和文件夾
  • 刪除文件和文件夾
  • URL轉文件
  • 通過過濾器和擴展名來篩選文件和文件夾
  • 比較文件內容
  • 文件最後修改時間
  • 文件校驗

FileSystemUtils

關於文件系統的相關操作,如查看C盤的大小,剩餘大小等操作

IOUtils

字面意思,是封裝了IO流的各種操作的工具類

Log4j

簡介

Log4J 是 Apache 的一個開源項目,通過在項目中使用 Log4J,我們可以控制日誌信息輸出到控制台、文件、GUI 組件、甚至是數據庫中。

我們可以控制每一條日誌的輸出格式,通過定義日誌的輸出級別,可以更靈活的控制日誌的輸出過程,方便項目的調試。

依賴:

<dependency>
    <groupId>log4j</groupId>
    <artifactId>log4j</artifactId>
    <version>1.2.17</version>
</dependency>

結構

Log4J 主要由 Loggers (日誌記錄器)、Appenders(輸出端)和 Layout(日誌格式化器)組成。

其中Loggers 控制日誌的輸出級別與日誌是否輸出;
Appenders 指定日誌的輸出方式(輸出到控制台、文件等);
Layout 控制日誌信息的輸出格式。

日誌級別:

級別 說明
OFF 最高日誌級別,關閉左右日誌
FATAL 將會導致應用程序退出的錯誤
ERROR 發生錯誤事件,但仍不影響系統的繼續運行
WARN 警告,即潛在的錯誤情形
INFO 一般和在粗粒度級別上,強調應用程序的運行全程
DEBUG 一般用於細粒度級別上,對調試應用程序非常有幫助
ALL 最低等級,打開所有日誌記錄

我們主要使用這四個:Error>Warn>Info>Debug

使用

我們可以使用兩種方式來運行Log4j,一種是java代碼方式,另外一種則是配置文件方式

例子(Java方式)

public class Log4JTest {
    public static void main(String[] args) {   
        //獲取Logger對象的實例(傳入當前類)         
        Logger logger = Logger.getLogger(Log4JTest.class);
        //使用默認的配置信息,不需要寫log4j.properties
        BasicConfigurator.configure();
        //設置日誌輸出級別為WARN,這將覆蓋配置文件中設置的級別,只有日誌級別低於WARN的日誌才輸出
        logger.setLevel(Level.WARN);
        logger.debug("這是debug");
        logger.info("這是info");
        logger.warn("這是warn");
        logger.error("這是error");
        logger.fatal("這是fatal");
    }
}

例子(配置文件方式)

上面的例子,我們想要實現打印Log,但是每次都要寫一遍,浪費時間和精力,所以,Log4j提供了另外一種方式來配置好我們的信息

創建一個名為log4j.properties的文件,此文件需要放在項目的根目錄(約定),如果是maven項目,直接放在resources文件夾中即可

log4j.properties

#控制台
log4j.appender.Console=org.apache.log4j.ConsoleAppender
log4j.appender.Console.layout=org.apache.log4j.PatternLayout
log4j.appender.Console.layout.ConversionPattern=%d [%t] %-5p [%c] - %m%n

#log jdbc
log4j.logger.java.sql.ResultSet=INFO
log4j.logger.org.apache=WARN
log4j.logger.java.sql.Connection=DEBUG
log4j.logger.java.sql.Statement=DEBUG
log4j.logger.java.sql.PreparedStatement=DEBUG

#log mybatis設置
#log4j.logger.org.apache.ibatis=DEBUG
log4j.logger.org.apache.ibatis.jdbc=error
log4j.logger.org.apache.ibatis.io=info
log4j.logger.org.apache.ibatis.datasource=info

#springMVC日誌
log4j.logger.org.springframework.web=WARN

# 文件輸出配置
log4j.appender.A = org.apache.log4j.DailyRollingFileAppender
log4j.appender.A.File = D:/log.txt #指定日誌的輸出路徑
log4j.appender.A.Append = true
log4j.appender.A.Threshold = DEBUG
log4j.appender.A.layout = org.apache.log4j.PatternLayout #使用自定義日誌格式化器
log4j.appender.A.layout.ConversionPattern = %-d{yyyy-MM-dd HH:mm:ss}  [ %t:%r ] - [ %p ]  %m%n #指定日誌的輸出格式
log4j.appender.A.encoding=UTF-8 #指定日誌的文件編碼

#指定日誌的輸出級別與輸出端
log4j.rootLogger=DEBUG,Console,A

#指定某個包名日誌級別(不能超過上面定義的級別,否則日誌不會輸出)
log4j.logger.com.wan=DEBUG

之後使用的話就比較簡單了

//Logger的初始化(這個推薦定義為全局變量,方便使用)
Logger logger = Logger.getLogger(Log4JTest.class);
//輸出Log
logger.info("這是info");

參考鏈接:

lombok

簡介

平常我們創建實體類的時候,需要get/set方法,極其麻煩,雖然IDEA等IDE都是有提供了快捷生成,不過,最好的解決方法還是省略不寫

而lombok就是這樣的一個框架,實現省略get/set方法,當然,lombok的功能不只有此,還有equal,toString方法也可以由此框架自動生成

lombok的原理是使用註解,之後就會在編譯過程中,給Class文件自動加上get/set等方法

不過IDEA似乎無法識別,代碼檢查還是會報錯,所以,使用IDEA的時候還得安裝一個插件,在plugin搜索lombok,之後安裝重啟即可,如圖

之後為Java項目添加依賴

<!-- https://mvnrepository.com/artifact/org.projectlombok/lombok -->
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.18.8</version>
    <scope>provided</scope>
</dependency>

使用示例

1.實體類省略get/set
估計Kotlin中的data關鍵字就是參照着lombok實現的

//這裏我們只需要為類添加Data註解,就會自動生成對應屬性的get/set方法,toString,equal等方法
@Data
public class User {
    private String username;
    private String password;
}

2.需要無參構造以及get/set方法

@Getter
@Setter
@NoArgsConstructor
public class User {
    private String username;
    private String password;
}

3.鏈式調用set方法

@Data
@Accessors(chain = true)
public class User {
    private String username;
    private String password;
}

//使用
User user = new User();
user.setUsername("helo").setPassword("123");

4.參數不為空

//如果調用此方法,就會抱一個空指針錯誤
public String print(@NotNull String str){
    ...
}

5.只需要toString

@ToString(callSuper=true, includeFieldNames=true)
public class User {
    private String username;
    private String password;
    //省略的get/set方法
}

6.builder模式創建實體類對象

@Data
@Builder
public class User {
    private String username;
    private String password;
}
//使用
User user1 = User.builder().username("user1").password("123").build();

7.工具類

@UtilityClass
public class MyUtils{
    //會將此方法自動轉為靜態方法
    public void print(String str){
        ...
    }
}
//使用
MyUtils.print("hello");

8.自動關閉流

public static void main(String[] args) throws Exception {
    //使用Cleanup會自動調用close方法
    @Cleanup InputStream in = new FileInputStream(args[0]);
    @Cleanup OutputStream out = new FileOutputStream(args[1]);
    byte[] b = new byte[1024];
    while (true) {
        int r = in.read(b);
        if (r == -1) break;
        out.write(b, 0, r);
    }
}

9.省略Logger時的初始化

@Log4j
@Log
public class User{
    //會自動添加此語句
    //Logger logger = Logger.getLogger(User.class);
    ...
}

參考:

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理【其他文章推薦】

台北網頁設計公司這麼多,該如何挑選?? 網頁設計報價省錢懶人包"嚨底家"

網頁設計公司推薦更多不同的設計風格,搶佔消費者視覺第一線

※想知道購買電動車哪裡補助最多?台中電動車補助資訊懶人包彙整

坑~夏令時冬令時引發的時間換算問題

 

起因

最近接觸到一些國外的項目,由於國內外有時差這個東西,對於某些基礎數據存到數據庫的時候需要記錄時間,為了方便,這裏採用了時間戳(int或者timestamp)記錄。由於時間戳全球都是一樣的,需要的時候根據時區進行轉換就能夠拿到當地的時間。

嗯~ o(* ̄▽ ̄*)o,這樣看起來確實沒什麼毛病。眾所周知,一天有24小時,換算成秒就是:24*60*60=86400秒。

然而,我在某次使用 MySql 的 FROM_UNIXTIME 發現一個問題,兩個時間相差86400秒,但是格式化之後卻不是相差一天!!!

假設北京時間2019年11月25日 12:00:00,對應的時間戳是:1574654400,照理說這個時間戳加上一天86400秒,理論上就是北京時間2019年11月26日 12:00:00,事實上確實如此,國內的話這麼算確實沒什麼問題,但是如果是國外時區的話,那可能會出問題。

由於國外部分國家有夏令時冬令時之分(具體下面會細說),直接加上86400秒可能會有問題。

感興趣的可以拿1572764400(太平洋時間2019-11-03 00:00:00,單位:)這個時間戳驗證下

拿代碼演示下:

PHP:

<?php

echo "PST時區的時間\n";
date_default_timezone_set('PST8PDT');
echo date('Y-m-d H:i:s',1572764400);
echo "\n";
echo date('Y-m-d H:i:s',1572764400+86400);
echo "\n";

//換個時區
echo "換成上海時區看看\n";
date_default_timezone_set('Asia/Shanghai');
echo date('Y-m-d H:i:s',1572764400);
echo "\n";
echo date('Y-m-d H:i:s',1572764400+86400);
echo "\n";

運行結果:

PST時區的時間
2019-11-03 00:00:00
2019-11-03 23:00:00
換成上海時區看看
2019-11-03 15:00:00
2019-11-04 15:00:00

明明是同一個時間戳,都是加上86400(一天),為什麼在上海這個時區是第二天,而在PST(美國太平洋時區)只加了23小時?神不神奇!意不意外!

為了弄清楚這個問題,首先得先了解下什麼是夏令時,什麼是冬令時

 

夏令時

夏令時,表示為了節約能源,人為規定時間的意思。也叫夏時制,夏時令(Daylight Saving Time:DST),又稱“日光節約時制”和“夏令時間”,在這一制度實行期間所採用的統一時間稱為“夏令時間”。

一般在天亮早的夏季人為將時間調快一小時,可以使人早起早睡,減少照明量,以充分利用光照資源,從而節約照明用電。各個採納夏時制的國家具體規定不同。目前全世界有近110個國家每年要實行夏令時。[1]

 

冬令時

有夏令時就會有冬令時。高緯度和中緯度的許多國家在夏季到來前,把時針撥快一小時,新的時間就是夏令時,到下半季秋季來臨前,再把時針撥回一小時,即形成冬令時。 [2] 

 

夏令時和冬令時的影響

拿美國來說,美國各個地區的時間都不同,不像中國一樣統一使用北京時間,美國一般以三月份第二個周日凌晨兩點當成夏季的開始,十一月份第一個周日的凌晨兩點當成冬季的開始。

所以在每年的三月份第二個周日凌晨兩點過後,時間就會往前調快一個小時;同理,十一月份第一個周日把這一個小時調回來

你也可以理解成美國那邊,一年裡面有一天只有23小時(夏天開始那一天),有一天有25小時(冬天開始那一天),其他時間每天都是24小時。

所以你會發現,夏天的時候,中國的北京時間(東八區)與美國太平洋時區(西八區)的時差是15小時,而到了冬天卻變成16小時

 

解決方案

回到開頭那個問題,如果我們想直接算第二天,直接加上86400(一天)可能在其他國家就會有我上面那個夏令時和冬令時時間換算的問題,要如何避免呢?首先能夠確定的是,直接加上86400是不可取的,如果加上一天能否行得通

PHP:

<?php

echo "PST時區的時間\n";
date_default_timezone_set('PST8PDT');
echo date('Y-m-d H:i:s',1572764400);
echo "\n";
echo date('Y-m-d H:i:s',1572764400+86400);
echo "\n";

echo "--------------------------\n";
echo date('Y-m-d H:i:s',1572764400);
echo "\n";
echo date('Y-m-d H:i:s',strtotime('+1 day',1572764400));
echo "\n";

運行結果:

PST時區的時間
2019-11-03 00:00:00
2019-11-03 23:00:00
--------------------------
2019-11-03 00:00:00
2019-11-04 00:00:00

可以看出,不直接加上86400,直接在日期上加上一天是完全沒問題的。

JavaScript:

var date = new Date(1572764400*1000);
date.setDate(date.getDate()+1);
var timestamp = Math.round(date.getTime()/1000);

注意:JS的時間戳是毫秒!!!

 

結論

在經濟全球化快速發展的今天,在軟件開發的過程中,盡量養成習慣,由於夏令時和冬令時不是固定的,開發在時間計算上應該慎用86400進行加減運算,時間計算請直接對日期進行加減,展示時間給用戶看的時候盡量結合當地時間,結合夏令時和冬令時計算出準確的當地時間,避免產生不必要的分歧。

 

參考:

[1]. 

[2]. 

 

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理【其他文章推薦】

※想知道網站建置網站改版該如何進行嗎?將由專業工程師為您規劃客製化網頁設計後台網頁設計

※不管是台北網頁設計公司台中網頁設計公司,全省皆有專員為您服務

※Google地圖已可更新顯示潭子電動車充電站設置地點!!

※帶您來看台北網站建置台北網頁設計,各種案例分享