Flinkに基づくTencentの最適化された拡張

1.背景と現状

 

1.3つのモードの分析 

 

画像

 

現在、Flinkジョブを作成するには、JARモード、キャンバスモード、SQLモードの3つの方法があります。課題を提出するさまざまな方法は、さまざまなグループの人々を対象としています。

■ジャーモード

Jarモードは、DataStream / DataSet APIに基づいて開発されており、主に基盤となる開発者を対象としています。

  • 利点:

  • 基盤となるDataStream / DataSet APIはFlinkのネイティブAPIであり、これらを使用して任意の演算子関数またはDAGグラフを開発できるため、関数は柔軟で変更可能です。

  • パフォーマンスの最適化は便利であり、各オペレーターのパフォーマンスを的を絞って最適化することができます。

 

  • 短所:

 

  • 依存関係の更新は面倒です。拡張操作ロジックまたはFlinkバージョンのアップグレードに関係なく、操作コードと依存バージョンを更新する必要があります。

  • 学習しきい値が高い。

 

■キャンバスモード

いわゆるキャンバスモードは、一般に視覚的なドラッグアンドドロップインターフェイスを提供し、ユーザーがインターフェイスベースの方法でドラッグアンドドロップ操作を実行して、Flinkジョブの編集を完了することを可能にします。一部の初心者ユーザーを対象としています。

  • 利点:

  • 操作は便利で、Flinkジョブに含まれるさまざまな演算子をキャンバス上で簡単に定義できます。

  • 関数は比較的完全であり、Table API開発に基づいており、関数の範囲は比較的完全です。

  • 理解しやすく、DAGダイアグラムは比較的直感的で、ユーザーはジョブ全体の実行プロセスを簡単に理解できます。

  • 短所:

  • 複雑な構成:各オペレーターを1つずつ構成する必要があります。DAGグラフ全体が非常に複雑な場合、対応する構成作業は非常に大きくなります。

  • ロジックの再利用の難しさ:ジョブが多い場合、異なるジョブ間でDAGロジックを共有することは非常に困難です。

■SQLモード

SQL言語は長い間存在しており、主にデータアナリスト向けに独自の標準セットがあります。データアナリストは、既存のSQL標準に準拠している限り、さまざまなプラットフォームとコンピューティングエンジンを切り替えることができます。

  • 利点:

  • 明確で簡潔で、理解しやすく、読みやすい。

  • 計算エンジンから切り離されると、SQLは計算エンジンとそのバージョンから切り離されます。異なる計算エンジン間でビジネスロジックを移行する場合、SQL全体を変更する必要はありません。同時に、Flinkバージョンをアップグレードする場合は、SQLを変更する必要はありません。

  • ロジックの再利用は便利で、ビューを作成することでSQLロジックを再利用できます。

  • 短所:

  • フローやディメンションテーブルの結合など、文法は統一されていません。Flink1.9より前は、Lateral Table Join文法が使用されていましたが、1.9以降は、PERIOD FORSYSTEM_TIME文法に変更されました。この文法はSQLANSI2011標準に準拠しています。文法の変更により、ユーザーには一定の学習コストがかかります。

  • 不完全な関数カバレッジ:Flink SQLモジュールは長い間存在していなかったため、その関数のカバレッジが不完全になりました。

  • パフォーマンスの調整は困難です。SQLの一部の実行効率は主にいくつかの部分によって決定されます。1つはSQL自体によって表現されるビジネスロジックであり、もう1つは変換SQLによって生成される実行プランの最適化です。3番目の部分は最適なロジック実行プランの後、ネイティブコードをローカルコードに変換するときに、プランはSQLの実行効率も決定します。ユーザーにとって、最適化できるコンテンツは、SQLで表現されるビジネスロジックに限定される場合があります。

  • 問題の特定の難しさ:SQLは完全な実行プロセスです。一部のデータが正しくないことがわかった場合、どのオペレーターが問題を抱えているかを的を絞って見つけることはより困難です。一般的に言って、Flink SQLの問題を突き止めたい場合、SQLロジック全体を合理化し続けてから、出力を試み続けることしかできません。このコストは非常に高くなります。Tencentのリアルタイムコンピューティングプラットフォームは、後でトレースログとメトリック情報を追加してこの問題に対処し、ユーザーがFlinkSQLの使用に関する問題を特定できるように製品側に出力します。

2.Tencentのリアルタイムコンピューティングプラットフォームの現在の作業

■拡張構文

ウィンドウテーブル値関数の文法は、ユーザーがウィンドウベースのフロー結合および交差およびマージ操作を実装するのに役立つように定義されています。さらに、独自のフローおよびディメンションテーブルの結合文法を実装します。

■新機能

一部の新機能には、インクリメンタルウィンドウと拡張タンブルウィンドウの2つの新しいウィンドウタイプが含まれます。イベントタイムフィールドとテーブルソースの分離を実現しました。多くの場合、イベントタイムフィールドはテーブルソースフィールドで定義できません。たとえば、テーブルソースがサブクエリであるか、特定の時間フィールドが関数によって変換され、これらを使用する必要があります。中間世代。現在、時間フィールドはイベント時間フィールドとして使用できません。現在のソリューションは、ユーザーが物理テーブル内の任意の時間フィールドを選択してウィンドウの時間属性を定義し、WaterMarkを出力できるようにすることです。

■パフォーマンスチューニング

  • 欠点フローの最適化。

  • インラインUDF、同じUDFがLogicalProject条件とWhere条件の両方に表示される場合、UDFは複数回呼び出されます。ロジック実行プランで繰り返し呼び出されるUDFを抽出し、UDFの実行結果をキャッシュして、複数の呼び出しを回避します。

■バケット結合

 

フローテーブルディメンションテーブルの結合にデータコールドスタートの問題があります。Flinkタスクの開始時に大量の外部データが読み込まれると、バックプレッシャが発生しやすくなります。State Processor APIおよびその他の手段を使用して、起動時にすべてのデータをメモリにプリロードできます。ただし、このソリューションには問題があります。ディメンションテーブルデータをすべてのサブタスクにロードすると、メモリが大量に消費されます。したがって、私たちの解決策は、ディメンションテーブルの定義でバケット情報を指定することです。フローとディメンションテーブルが結合されると、ディメンションテーブル内の対応するシャードのデータがバケット情報とフローに基づいて読み込まれます。テーブルは実行プラン中に変換されます。バケット情報を取得して、フローテーブルとディメンションテーブルのデータが同じバケット情報に基づいて結合されるようにします。この方法により、全次元テーブルデータのプリロードによって引き起こされるメモリ消費の問題を大幅に減らすことができます。

 

2.ウィンドウ関数の拡張

Tencentのリアルタイムコンピューティングプラットフォームは、一部の拡張機能の既存のFlink SQL文法に基づいており、2つの新しいウィンドウタイプも定義しています。

1.新しいウィンドウの操作

既存の要件は次のとおりです。2つのストリームで特定の時間枠に対して結合操作またはマージ操作を実行する必要があります。

Flink SQLを使用して、特定のウィンドウに基づいてデュアルストリーム結合を実行します。既存のソリューションは2つあります。最初のソリューションは、最初に結合を実行し、次にGroup Byを実行し、2番目のソリューションは間隔結合です。まず、最初のオプションが需要を満たすことができるかどうかを分析しましょう。

■1.1最初に参加してから、ウィンドウを開きます

 

画像

 

上の図に、最初に結合してからウィンドウを開くロジックを示します。論理実行プランによると、結合ノードがウィンドウ集約ノードの下にあることがわかります。したがって、フローとフロー結合が最初に実行され、次に実行されます。ウィンドウ集約は、結合が完了した後に実行されます。

図の右側のフローチャートからわかるように、最初に2つのストリームが接続を実行し、次に結合キーに基づいてKeyby操作を実行して、2つのストリームで同じ結合キーを持つデータができることを確認します。同じタスクにシャッフルされます。左側のストリームはデータを独自の状態で保存すると同時に、一致するために右側のストリーム状態に移行します。一致できる場合は、一致した結果をダウンストリームに出力します。このスキームには2つの問題があります。

  1. ステータスをクリーンアップできません:ウィンドウが開く前にJoinのJoinにウィンドウ情報がないため、ダウンストリームのウィンドウがトリガーされて計算が完了しても、アップストリームの2つのストリームのJoinステータスをクリーンアップできません。最大で可能です。クリーンアップにはTTLベースの方法のみを使用してください。

  2. セマンティクスは要求を満たすことができません。元の要求は、同じ時間ウィンドウに基づいて2つのストリームのデータをスライスしてから結合することですが、現在のソリューションは、最初に結合を実行し、次に結合後にデータを使用するため、この要求を満たすことができません。ウィンドウを開くと、このメソッドは、2つのストリームの結合に参加しているデータが同じウィンドウに基づいていることを保証できません。

■1.2インターバル結合

画像

 

以前の書き込み方法と比較すると、インターバル結合の利点は、特定のウィンドウに基づいて左右のストリームのデータをスキャンできるため、状態をクリーンアップできないという問題がないことです。ウィンドウ時間後、状態をクリーンアップできます。

ただし、最初のソリューションと比較すると、ウィンドウの分割は特定のウィンドウに基づいておらず、データによって駆動されるため、このソリューションではデータの精度が低下する可能性があります。つまり、現在のデータは別の結合ストリームになる可能性があります。範囲上記のデータのうち、現在のデータによって運ばれるイベント時間に基づいています。このウィンドウ分割のセマンティクスと私たちのニーズの間には、まだ一定のギャップがあります。

レートが一貫していない2つの既存のストリームを想像してみてください。下限と上限の2つの境界は、結合できる左側のストリームと右側のストリームのデータ範囲を制限するために使用されます。このような厳格な範囲の制約の下では、時間内に落ちる右のストリーム。ウィンドウの外側[左+低、左+上]では、計算は十分に正確ではありません。したがって、2つのストリームで同じEventtimeを持つデータが同じ時間ウィンドウに収まるように、ウィンドウの配置方法に従って時間ウィンドウを分割することをお勧めします。

■1.3ウィンドウテーブル値関数

Tencentは、「2つのストリームで特定の時間ウィンドウの結合操作またはマージ操作」の要件を満たすことができるウィンドウテーブル値関数の文法を拡張しました。この文法の説明はSQL2016標準にあり、文法はすでにCalcite1.23に存在します。

画像

 

Windowing Table-Valued Function構文のSourceは、そのセマンティクス全体を明確に記述できます。From句には、Table Source、Eventtime Field、Window Sizeなど、ウィンドウ定義に必要なすべての情報が含まれています。

上の図の論理計画からわかるように、この文法は、LogicalTableFunctionScanというノードをLogicalTableScanに追加します。さらに、LogicalProjectノード(出力ノード)には、WindowStartとWindowEndという2つのフィールドがあります。これらの2つのフィールドに基づいて、データを特定のウィンドウに要約できます。上記の原則に基づいて、ウィンドウテーブル値関数の構文は次のことを実行できます。

画像

 

  • 単一のストリームでは、既存のグループウィンドウ構文のように時間ウィンドウを分割できます。書き込み方法は上図のようになり、すべてのウィンドウ情報がFrom句に配置され、GroupByが実行されます。この書き方は、一般の人々の時間枠の理解と一致している必要があり、FlinkSQLでグループウィンドウを書く現在の方法よりも少し直感的です。単一ストリームでウィンドウテーブル値関数の文法を変換するときにトリックを実行しました。つまり、このSQLの物理変換を実装するときに、特定のDataStream APIに変換せず、論理実行プランを直接に変換しました。現在のグループウィンドウの論理実行プラン、つまり、基になる物理実行プランを共有するコードは、論理実行プランとまったく同じです。

    さらに、Windowing Table-Valued Function構文は事前にデータを特定のウィンドウに分割しているため、Window内のデータに対してSortまたはTopN出力を実行できます。上の図に示すように、最初にFrom句でウィンドウを分割し、次にOrder ByとLimitがその直後に続き、並べ替えとTopNセマンティクスを直接表現します。

画像

 

  • デュアルストリームでは、「特定の時間枠での2つのストリームでの結合操作またはクロスマージ操作」の元の要件を満たすことができます。構文は上図のようになります。まず、2つのウィンドウのウィンドウテーブルを作成し、次に、Joinキーワードを使用して結合操作を実行します。交差とマージの操作は同じであり、交差と同じです。従来のデータベースSQLの動作の違い。

■1.4実装の詳細

以下では、ウィンドウテーブル値関数構文の実装の詳細を簡単に紹介します。

  • 1.4.1ウィンドウの伝播

元の論理プランの変換方法は、LogicalTableScanに基づいており、ウィンドウテーブル値関数に変換され、最後にOrderByLimit句に変換されます。プロセス全体で状態が何度も保存されるため、パフォーマンスの点で比較的大きな消費になります。したがって、変換のために複数の論理Relnodeをマージするために次の最適化が行われ、中間リンクコードの生成を減らしてパフォーマンスを向上させることができます。 。

  • 1.4.2時間属性フィールド

ウィンドウテーブル値関数の構文を確認できます。

SELECT * FROM TABLE(TUMBLE(TABLE <data>, DESCRIPTOR(<timecol>), <size> [, <offset>]))

 

table <data> は、テーブルだけでなくサブクエリにすることもできます。したがって、Eventtimeフィールドを定義するときにtime属性をTable Sourceにバインドし、Table Sourceがたまたまサブクエリである場合、現時点ではニーズを満たしません。したがって、文法を実装するときは、時間属性フィールドをテーブルソースから切り離します。逆に、ユーザーは、物理テーブル内の任意の時間フィールドを時間属性として使用して、透かしを生成します。

  • 1.4.3時間透かし

ウォーターマークを使用するロジックは、他の文法と同じです。2つのストリームのすべての入力タスクの最小タイムウォーターマークによって、ウィンドウの計算をトリガーするウィンドウのタイムウォーターマークが決まります。

  • 1.4.4制約を使用する

現在、ウィンドウテーブル値関数の使用にはいくつかの制約があります。まず、2つのストリームのウィンドウタイプは同じである必要があり、ウィンドウサイズも同じである必要があります。ただし、セッションウィンドウに関連する機能はまだ実装されていません。

2.新しいウィンドウタイプ

次の概要は、2つの新しいウィンドウタイプに拡張されています。

■2.1インクリメンタルウィンドウ

次の要件があります。ユーザーは、ウィンドウの終わりが均一に出力されるのを待つのではなく、1日以内にpv / uv曲線を描画できること、つまり、1日または大きなウィンドウで複数の結果を出力できることを望んでいます。結果は一度。この要望に応えて、インクリメンタルウィンドウを拡大しました。

  • 2.1.1複数のトリガー

タンブルウィンドウに基づいて、カスタマイズされたインクリメンタルトリガー。このトリガーにより、ウィンドウ計算はWindowsの終了後にトリガーされるだけでなく、SQLで定義されたすべての間隔期間がウィンドウ計算をトリガーします。

画像

 

上の図のSQLの場合と同様に、合計ウィンドウサイズは1秒であり、0.2秒ごとにトリガーされるため、ウィンドウ内で5つのウィンドウ計算がトリガーされます。そして、次の出力結果は前の結果に基づいて計算されます。

  • 2.1.2レイジートリガー

インクリメンタルウィンドウのレイジートリガーと呼ばれる最適化を行いました。実際の生産プロセスでは、ウィンドウの同じKey値は、ウィンドウ計算を複数回トリガーした後、同じ結果を出力します。ダウンストリームの場合、この種のデータを繰り返し受信する必要はありません。したがって、レイジートリガーが構成されていて、同じウィンドウの同じキーの下にある場合、次の出力値は前の値とまったく同じになり、ダウンストリームはこの更新データを受信しないため、ダウンストリームのストレージ圧力と同時圧力が減少します。

■2.2強化されたタンブルウィンドウ

画像

 

以下の要件があります。ユーザーは、タンブルウィンドウがトリガーされた後、遅延データが破棄されないことを望んでいますが、ウィンドウ計算は再度トリガーされます。DataStream APIを使用する場合は、SideOutputを使用して要件を完了することができます。しかし、SQLの場合、現在それを行う方法はありません。したがって、既存のタンブルウィンドウが拡張され、遅延データも収集されます。同時に、遅延データはウィンドウ計算を再トリガーせず、毎回ダウンストリームに出力しますが、トリガーと時間間隔を再定義します。データをダウンストリームに送信する頻度を減らすためにSQLで定義されたウィンドウサイズ。

同時に、サイド出力ストリームは、データを蓄積するときにウィンドウのロジックを使用して別の集計を実行します。ここで、ダウンストリームがHBaseと同様のデータソースである場合、同じウィンドウと同じキーに対して、通常はウィンドウによってトリガーされた以前のデータが最新のデータによって上書きされることに注意してください。理論的には、遅延データの重要性は通常のウィンドウによってトリガーされるデータと同じであり、相互にカバーすることはできません。最後に、ダウンストリームは、同じウィンドウ内の同じキーの下で、通常のデータと遅延データの2番目の集計を実行します。

3、リトレースメントフローの最適化

次に、リトレースメントストリームで行われたいくつかの最適化を紹介します。

1.フローテーブルのあいまいさ

FlinkSQLのリトレースメントフローに関するいくつかの概念を確認してください。

まず、連続クエリを紹介します。バッチ処理と一度に1つの結果の出力の特性と比較すると、ストリームの集約はアップストリームからのデータであり、ダウンストリームは更新されたデータを受信します。 、結果はアップストリームデータによって常に更新されています。updated。したがって、同じキーのダウンストリームは複数の更新結果を受け取る可能性があります。

2.リトレースメントフロー

画像

 

上の図のSQLを例にとると、2番目のJavaが集約演算子に到達すると、最初のJavaによって生成された状態が更新され、その結果がダウンストリームに送信されます。ダウンストリームが複数の更新の結果に対して処理を行わない場合、間違った結果が生成されます。このシナリオに対応して、FlinkSQLはリトレースメントフローの概念を導入しました。

いわゆるリトレースメントフローは、元のデータの前にフラグを追加し、それをTrue / Falseで識別することです。フラグがFalseの場合、これはリトレースメントメッセージであることを意味し、データを削除するようにダウンストリームに通知します。フラグがTrueの場合、ダウンストリームは直接挿入操作を実行します。

■2.1リトレースメントフローはいつ発生しますか?

現在、FlinkSQLで生成されるリトレースメントフローには4つのシナリオがあります。

  • ウィンドウなしのアグリゲート(ウィンドウなしのアグリゲートシーン)

  • ランク

  • 窓越し

  • 左/右/完全外部結合

外部結合がリトレースメントを生成する理由を説明します。例として左外部結合を取り上げ、左ストリームのデータが右ストリームのデータの前に到着すると仮定すると、左ストリームのデータは右ストリームのデータの状態をスキャンします。結合できますが、左側のストリームは右側のストリームを認識していません。このデータは実際に中央に存在するのでしょうか、それとも右側のストリームの対応するデータが遅れているのでしょうか。外部結合のセマンティクスを満たすために、MySQL左結合と同様に、左側のストリームデータは引き続き結合データを生成してダウンストリームに送信します。左側のストリームのフィールドには通常のテーブルフィールド値が入力され、次の図に示すように、右側のストリームはNullで埋められ、ダウンストリームに出力されます。

画像

(写真はYunqiコミュニティからのものです)

後で、右側のストリームの対応するデータが到着すると、左側のストリームの状態をスキャンして再度結合を実行します。このとき、セマンティクスの正確性を確保するために、以前にに出力された特別なデータがダウンストリームを撤回する必要があると同時に、最新の結合に関するデータがダウンストリームに出力されます。同じキーについて、リトレースメントが発生した場合、2番目のリトレースメントは発生しないことに注意してください。これは、キーのデータが後で到着した場合、別のストリームの対応するデータを結合できるためです。

■2.2リトレースメントメッセージの処理方法

画像

 

以下に、Flinkでリトレースメントメッセージを処理するロジックを紹介します。

中間コンピューティングノードの場合、上図の4つのフラグによって制御されます。これらのフラグは、現在のノードが更新情報または撤回情報を生成するかどうか、および現在のノードがこの撤回情報を消費するかどうかを示します。これらの4つのフラグは、リトラクトの生成と処理のロジック全体を決定できます。 

シンクノードの場合、現在、Flink、AppendStreamTableSink、RetractStreamTableSink、およびUpsertStreamTableSinkに3つのシンクタイプがあります。AppendStreamTableSinkが受信したアップストリームデータがRetractメッセージの場合、Append-Onlyセマンティクスしか記述できないため、エラーが直接報告されます。RetractStreamTableSinkはRetract情報を処理できます。アップストリームオペレータがRetractメッセージを送信すると、メッセージが削除されます。アップストリームオペレーターが通常の更新情報を送信すると、メッセージに対して挿入操作が実行されます。UpsertStreamTableSinkは、RetractStreamTableSinkのパフォーマンスの最適化として理解できます。シンクデータソースがべき等操作をサポートしている場合、または特定のキーに基づく更新操作をサポートしている場合、UpsertStreamTableSinkはSQL変換中にアップストリームのアップサートキーをテーブルシンクに渡し、キーに基づいて更新操作を実行します。

■2.3関連する最適化

 

ドローダウンフローに基づいて、以下の最適化を行います。

  • 2.3.1中間ノードの最適化

画像

 

ドローダウン情報を生成する最も基本的な理由の1つは、更新結果をダウンストリームに複数回継続的に送信することです。したがって、更新の頻度を減らし、同時実行性を減らすために、更新結果の一部を蓄積してから送信することができます。図に示すように:

  • 最初のシナリオはネストされたAGGシナリオです(たとえば、2つのカウント操作)。最初のレイヤーのGroup Byが更新結果をダウンストリームに送信しようとすると、最初にキャッシュが作成されるため、ダウンストリームにデータを送信する頻度が減少します。 。キャッシュトリガー条件に達すると、更新結果がダウンストリームに送信されます。

  • 2番目のシナリオは外部結合です。前述のように、外部結合は、左右のデータレートが一致しないため、リトレースメントメッセージを生成します。左外部結合を例にとると、左ストリームデータをキャッシュできます。左側のストリームデータが到着すると、右側のストリーム状態で検索されます。結合できるデータが見つかった場合、キャッシュされません。対応するデータが見つからない場合、このキーのデータは次のようになります。最初にキャッシュされ、特定のトリガーに達したときに条件が満たされたときに、正しいストリーム状態で再度検索します。対応するデータがまだ見つからない場合は、Null値を含むJoinデータをダウンストリームに送信します。ストリームに対応するデータが到着すると、キャッシュ内のキーに対応するキャッシュが空になり、リトレースメントメッセージをダウンストリームに送信します。

これにより、リトレースメントメッセージをダウンストリームに送信する頻度が減ります。

  • 2.3.2シンクノードの最適化

画像

 

シンクノードに対していくつかの最適化が行われ、シンクノードへの圧力を軽減するためにAGGノードとシンクノードの間にキャッシュが作成されました。リトレースメントメッセージがキャッシュに集約され、キャッシュのトリガー条件に達すると、更新されたデータがシンクノードに均一に送信されます。次の図のSQLを例として取り上げます。

画像

 

最適化の前後の出力結果を参照すると、最適化後、ダウンストリームで受信されるデータの量が減少していることがわかります。たとえば、ユーザーSamは、コールバックメッセージをダウンストリームに送信しようとすると、キャッシュのレイヤーは次のようになります。最初に使用され、ダウンストリームで受信されるデータの量を大幅に削減できます。

4、将来の計画

画像

 

私たちのチームのフォローアップ作業計画を紹介しましょう:

  • コストベースの最適化:Flink SQLの論理実行プランの最適化は、引き続きRBO(ルールベースの最適化)に基づいています。私たちのチームはCBOに基づいて何かをしたいと考えており、主なタスクは統計情報を収集することです。統計情報は、Flink SQL自体から取得されるだけでなく、メタデータ、さまざまなキーに対応するデータ配布、またはその他のデータ分析結果など、社内の他の製品から取得される場合もあります。社内の他の製品を利用することで、最も正確な統計データを取得し、最適な実行計画を作成できます。

  • その他の新機能(CEP構文など):CEPに関する一部のユーザーのニーズを満たすために、FlinkSQLに基づいていくつかのCEP文法を定義します。

  • 継続的なパフォーマンスの最適化(結合演算子など):私たちのチームは、実行プランレイヤーの最適化だけでなく、結合演算子またはデータシャッフルのきめ細かい最適化も行っています。

  • デバッグが容易:最後に、FlinkSQLタスクのデバッグと配置について説明します。現在、Flink SQLはこの点で比較的不足しており、特にオンラインデータの不整合の問題はトラブルシューティングが非常に困難です。現在のアイデアは、実行中にトレース情報またはメトリック情報を吐き出し、それを他のプラットフォームに送信するようにSQLを構成することです。これらのトレース情報とメトリック情報を通じて、ユーザーが問題のあるオペレーターを見つけるのに役立ちます。

おすすめ

転載: blog.csdn.net/Baron_ND/article/details/114898719