03_Hudiのコアコンセプト、タイムライン タイムライン、ファイル管理、インデックス インデックス、ストレージタイプ、コンピューティングモデル、バッチモデル バッチ、ストリーミングモデル ストリーム、インクリメンタルモデル インクリメンタル、クエリタイプ、データ書き込みプロセスなど

この記事は「ダークホース プログラマー」hudi コースからのものです

3. 第 3 章 Hudi コア概念
3.1 基本概念
3.1.1 タイムライン タイムライン
3.1.2 ファイル管理
3.1.3 インデックス インデックス
3.2 ストレージ タイプ
3.2.1 コンピューティング モデル
3.2.1.1 バッチ モデル (バッチ)
3.2.1.2 ストリーミング モデル (ストリーム)
3.2 .1.3 インクリメンタルモデル (Incremental)
3.2.2 クエリタイプ (Query Type)
3.2.3 Copy On Write
3.2.4 Merge On Read
3.2.5 COW と MOR の比較
3.3 データ書き込み操作プロセス
3.3.1 UPSERT 書き込みプロセス
3.3.1.1コピーオンライト
3.3.1.2 マージオンリード
3.3.2 INSERT ライト処理
3.3.2.2 マージオンリード

3. 第 3 章 Hudi のコア概念

Hudi データ レイク フレームワークの基本概念とテーブル タイプは、Hudi フレームワークの設計原則とテーブル設計の中核に属します。
ドキュメント: https://hudi.apache.org/docs/concepts.html

3.1 基本概念

Hudi は Hudi テーブルの概念を提供します。これらのテーブルは CRUD 操作をサポートします。HDFS などの既存のビッグ データ クラスターをデータ ファイル ストレージに使用し、SparkSQL や Hive などの分析エンジンをデータ分析とクエリに使用できます。
ここに画像の説明を挿入

Hudi テーブルの 3 つの主なコンポーネント: 1) データベース トランザクション ログに似た、順序付けされたタイムライン メタデータ。2) 階層レイアウトのデータ ファイル: 実際にテーブルに書き込まれるデータ; 3) インデックス (複数の実装): 指定されたレコードを含むデータ セットのマッピング。

3.1.1 時間軸タイムライン

Hudi の核心は、すべてのテーブルの異なる時点 (Instant) でのデータセットに対する操作 (追加、変更、削除など) を含むタイムライン (Timeline) を維持することです。Hudi テーブルの各データセット操作では、毎回インスタントはテーブルのタイムライン上に生成されるため、特定の時点以降に正常に送信されたデータのみをクエリできるか、または特定の時点より前のデータのみをクエリできるため、より大きな時間範囲でのデータのスキャンが効果的に回避されます。 。同時に、変更前のファイルのみを効率的にクエリできます (たとえば、インスタントによって変更操作が送信された後は、特定の時点より前のデータのみがクエリされ、変更前のデータは引き続きクエリされます)質問されました)。
ここに画像の説明を挿入

タイムラインは、Hudi がコミットを管理するために使用する抽象化であり、各コミットは固定のタイムスタンプにバインドされ、タイムライン上に分散されます。タイムラインでは、各コミットはHoodieInstantとして抽象化され、インスタントはコミット(コミット)の動作、タイムスタンプ、ステータスを記録します。HUDI の読み取りおよび書き込み API は、タイムライン インターフェイスを介してコミットに対して条件付きスクリーニングを簡単に実行し、履歴および進行中のコミットにさまざまな戦略を適用し、操作する必要があるターゲット コミットを迅速に除外できます。
ここに画像の説明を挿入

上図では、パーティションフィールドとして時刻(時間)を使用しており、10:00から順次様々なコミットが生成され、9:00のデータが10:20に来て、まだデータがタイムラインを通じて 9:00 に対応するパーティション 10:00 以降に増分更新を直接使用する場合 (新しいコミットを持つグループのみを使用する場合)、この遅延したデータも引き続き使用できます。

タイムライン (Timeline) 実装クラス ( hudi-common-xx.jarにあります) およびタイムライン関連の実装クラスは、org.apache.hudi.common.table.timelineパッケージの下にあります。
ここに画像の説明を挿入

3.1.2 ファイル管理

Hudi は、DFS 上のデータセットをベース パス ( HoodieWriteConfig.BASEPATHPROP )の下のディレクトリ構造に編成します。データセットはパーティション ( DataSourceOptions.PARTITIONPATHFIELDOPT_KEY ) に分割されます。パーティションは、Hive テーブルと同様に、そのパーティションのデータ ファイルを含むフォルダーです。
ここに画像の説明を挿入

各パーティション内では、ファイルはファイル ID によって一意に識別されるファイル グループに編成されます。各ファイル グループには複数のファイル スライスが含まれており、各スライスにはベース列ファイル (.

  • 新しい基本コミット時間は、実際には新しいデータ バージョンである新しい FileSlice に対応します。
  • Hudi の各 FileSlice には、ベース ファイル (読み取り時マージ モードでは含まれない場合があります) と複数のログ ファイル (書き込み時コピー モードでは含まれない) が含まれています。
  • 各ファイルのファイル名には、独自の FileID (つまり、FileGroup Identifier) と基本コミット時間 (つまり、InstanceTime) があります。ファイル名のグループ ID によって FileGroup の論理関係を整理し、ファイル名のベース コミット時間を通じて FileSlice の論理関係を整理します。
  • Hudi のベース ファイル (寄木細工ファイル) は、フッターのメタにレコード キーで構成される BloomFilter を記録します。これは、ファイル ベースのインデックスの実装における効率的なキー含有検出を実現するために使用されます。誤検知を排除するために、BloomFilter にないキーのみがファイル全体をスキャンする必要があります。
  • Hudi のログ (avro ファイル) は、データ バッファを蓄積することで自己エンコードされ、LogBlock 単位で書き込まれます。各 LogBlock には、データの読み取り、検証、フィルタリングのためのマジック ナンバー、サイズ、内容、フッターなどの情報が含まれています。

Hudi は MVCC (Multi-Version Concurrency Control) を使用して設計されており、圧縮操作ではログとベース ファイルをマージして新しいファイル スライスを生成し、クリーンアップ操作では未使用または古いファイル スライスを削除して DFS 上の領域を再利用します。

3.1.3 インデックスインデックス

Hudi は、インデックス メカニズムを通じて効率的な Upsert 操作を提供します。このメカニズムは、 RecordKey+PartitionPath

Hudi には 4 種類 (6) のインデックス実装が組み込まれています。これらはすべて、次のように最上位の抽象クラス HoodyIndex から継承されています。

  • グローバル インデックス: これは、キーがテーブル全体のすべてのパーティションで一意である必要があること、つまり、特定のキーに対応するレコードが 1 つだけであることを保証する必要があることを意味します。グローバル インデックスはより強力な保証を提供し、テーブルのサイズ (O(テーブル サイズ)) に応じて更新と削除の消費量も増加します。これは小さなテーブルに適しています。
  • 非グローバル インデックス: キーはテーブルの特定のパーティション内で一意であることのみが必要です。同じレコードの更新と削除に対して一貫したパーティション パスを提供するのはライターに依存しますが、同時に大幅に改善されます。インデックスクエリの複雑さがO(削除するレコード数)になるため効率が良く、書き込み量の増大にも十分対応できます。

パーカー キー (レコード キー + パーティション パス) とファイル ID (FileGroup) の間のマッピング関係は、データが初めてファイルに書き込まれた後も変更されないため、FileGroup にはレコードのバッチのすべてのバージョン レコードが含まれます。インデックスは、メッセージが INSERT であるか UPDATE であるかを区別するために使用されます。

BloomFilter Index (ブルームフィルターインデックス)

  • レコードを追加してマッピング関係を見つけます: レコード キー => ターゲット パーティション
  • 現在の最新のデータはマッピング関係を見つけます:partition => (fileID, minRecordKey, maxRecordKey) LIST (ベースファイルの場合は高速化できます)
  • レコードを追加して、検索する必要があるマッピング関係を見つけます: fileID => HoodyKey(レコード キー + パーティション パス) LIST、キーは候補ファイル ID です。
  • HoodyKeyLookupHandle を通じてターゲット ファイルを検索します (BloomFilter によって高速化されます)。

Flink 状態ベースのインデックス (状態インデックスに基づく)

  • バージョン 0.8.0 の HUDI によって実装された Flink ワイターは、基礎となるインデックス ストレージとして Flink の状態を使用します。各レコードは、書き込み前にまずターゲット バケット ID を計算します。これは、BloomFilter Index とは異なり、毎回ファイル インデックスが繰り返されることを避けます。

3.2 ストレージの種類

Hudi には、Copy on Write (COW) テーブルと Merge On Read (MOR) テーブルの 2 種類のテーブルが用意されており、主な違いは次のとおりです。

  • コピーオンライト テーブルの場合、ユーザーの更新によりデータが配置されているファイルが書き換えられるため、書き込み増幅率は非常に高くなりますが、読み取り増幅率は 0 であり、書き込み量を減らし読み取り量を増やすシナリオに適しています。
  • Merge-On-Read テーブルの場合、全体の構造は LSM ツリーに似ています。ユーザーの書き込みは最初にデルタ データに書き込まれます。データのこの部分は行ストレージを使用します。デルタ データのこの部分は手動でマージできます。ストックファイルに保存され、寄木細工の柱の保管構造として整理されます。
    ここに画像の説明を挿入

3.2.1 計算モデル

Hudi は Uber が主導するオープンソースのデータ レイク フレームワークであるため、ほとんどの出発点は、注文 ID を介してドライバー データと乗客データを結合するなど、Uber 独自のシナリオから来ています。Hudiのこれまでの利用シナリオでは、多くの企業のアーキテクチャと同様に、バッチとストリーミングが共存するLambdaアーキテクチャが採用されており、バッチ(Batch)とストリーミングを遅延、データ整合性、コストの観点から比較しています。

3.2.1.1 バッチモデル(バッチ)

バッチ モデルは、MapReduce、Hive、Spark などの一般的なバッチ コンピューティング エンジンを使用して、時間単位または日単位のタスクの形式でデータ コンピューティングを実行します

  • レイテンシ: 時間レベルの遅延または日レベルの遅延。ここでの遅延は、スケジュールされたタスクの時間だけを指すわけではありません。データ アーキテクチャでは、通常、ここでの遅延時間は、スケジュールされたタスク間の時間 + 一連の依存タスクの計算時間 + データ プラットフォームが最終的に表示できる時間になります。結果データ量が多くロジックが複雑な場合、時間単位のタスクで計算されるデータの実際の遅延時間は通常 2 ~ 3 時間になります。
  • データの整合性: データは比較的完全です。処理時間を例に挙げると、時間レベルのタスクの場合、通常計算される生データには 1 時間以内のすべてのデータがすでに含まれているため、取得されるデータは比較的完全です。しかし、ビジネス要件がイベント時である場合、端末の遅延レポート メカニズムが関係しており、バッチ コンピューティング タスクはここでは役に立ちません。
  • コスト: コストは非常に低いです。タスク計算を行う場合のみリソースを占有しますが、タスク計算を行わない場合には、これらのバッチコンピューティングリソースをオンライン業務に移管することができます。しかし、別の観点から見ると、コストが非常に高くなります。たとえば、元のデータが追加、削除、変更、確認され、データの到着が遅れた場合、バッチ タスクをすべて再計算する必要があります。

3.2.1.2 ストリーミングモデル(ストリーム)

ストリーミング モデルは通常、リアルタイム データ計算に Flink を使用します。

  • レイテンシー: 非常に短く、リアルタイムでも同様です。
  • データの整合性: 悪い。ストリーミング エンジンはすべてのデータが到着するのを待たずに計算を開始するため、ウォーターマークの概念があり、データの時間がウォーターマーク未満の場合は破棄されます。データの整合性に関する絶対的なレポートが得られます。インターネットのシナリオでは、ストリーミング モデルは主にアクティビティ中の大規模なデータ表示に使用され、データの整合性の要件はそれほど高くありません。ほとんどのシナリオでは、ユーザーは 2 つのプログラムを開発する必要があります。1 つはストリーミング結果を生成するためのストリーミング データであり、もう 1 つはリアルタイムの結果を翌日に修復するためのバッチ コンピューティング タスクです。
  • コスト: 非常に高い。ストリーミング タスクは常駐しており、マルチストリーム結合シナリオの場合、通常は状態ストレージにメモリまたはデータベースを使用する必要がありますが、シリアル化のオーバーヘッドであっても、外部コンポーネントとの対話によって生成される追加 IO であっても、大量のデータの下ではすべてを処理することはできません。無視されました。

3.2.1.3 インクリメンタルモデル(インクリメンタル)

バッチとストリーミングの長所と短所を考慮して、Uber は、バッチよりもリアルタイムで、ストリーミングよりも経済的な増分モデル (インクリメンタル モード) を提案しました。

インクリメンタル モデルは、簡単に言えば、ミニ バッチの形式で準リアルタイム タスクを実行することです。Hudi は、増分モデルの 2 つの最も重要な機能をサポートしています。

  • Upsert : これは主に、バッチ モデルでデータを挿入および更新できない問題を解決するためのもので、この機能を使用すると、毎回完全に上書きされるのではなく、増分データを Hive に書き込むことができます。(Hudi 自体がキー -> ファイル マッピングを維持するため、更新/挿入時にキーに対応するファイルを簡単に見つけることができます)
  • インクリメンタル クエリ:計算用の生データの量を減らすためのインクリメンタル クエリUber でのドライバーと乗客のデータ ストリーム結合を例にとります。2 つのデータ ストリームの増分データは、バッチ結合のたびにキャプチャできます。ストリーミング データと比較して、コストは数桁低くなります。

インクリメンタル モデルでは、Hudi はCopy-On-WriteMerge-On-Readという 2 種類のテーブルを提供します。

3.2.2 クエリの種類

Hudi は、テーブルの種類に応じて、テーブルをクエリする 3 つの異なる方法 (スナップショット クエリ、増分クエリ、読み取り最適化クエリ) をサポートできます。
ここに画像の説明を挿入

タイプ 1: スナップショット クエリ (スナップショット クエリ)

  • 増分コミット操作でデータセットの最新のスナップショットをクエリするには、まず最新の基本ファイル (Parquet) と増分ファイル (Avro) が動的にマージされ、ほぼリアルタイムのデータセットが提供されます (通常は数分の遅延が発生します)。
  • すべてのパーティションにある各ファイル グループの最新のファイル スライス内のファイルを読み取ります。コピー オン ライトは寄木細工のファイルを読み取ることを意味し、マージ オン読み取りは寄木細工とログ ファイルを読み取ることを意味します

タイプ 2: 増分クエリ (増分クエリ)

  • データセットに新しく書き込まれたファイルのみをクエリするには、コミット/圧縮のインスタント時間 (タイムライン上のインスタント) を条件として指定し、この条件の後に新しいデータをクエリする必要があります。
  • 指定されたコミット/デルタコミットの即時操作以降に新しく書き込まれたデータを表示します。変更ストリームを効果的に提供して、増分データ パイプラインを有効にします。

タイプ 3: 読み取り最適化クエリ (読み取り最適化クエリ)

  • 基本ファイル (データセットの最新のスナップショット) を直接クエリします。これは実際には列状ファイル (Parquet) です。また、Hudi 以外の列形式データセットと比較して、同じ列形式クエリのパフォーマンスを保証します。
  • 指定されたコミット/コンパクト即時操作のテーブルの最新のスナップショットを表示します。
  • スナップショット クエリなどの読み取り最適化クエリは、ベース ファイルのみにアクセスし、最後に実行された圧縮操作以降の特定のファイル スライスのデータを提供します。通常、クエリ データの鮮度の保証は圧縮戦略によって異なります。

3.2.3 コピーオンライト

COW と略され、その名前が示すように、データの書き込み時に元のコピーをコピーし、その上に新しいデータを追加します。データの読み取りリクエストは最新の完全なコピーを読み取ります。これは、Mysql の MVCC の考え方に似ています。
ここに画像の説明を挿入

上の図では、各色には、その位置が特定されるまでのすべてのデータが含まれています。古いデータのコピーは、一定の数の制限を超えると削除されますこのタイプのテーブルには、作成された時点ですでにコンパクトになっているため、コンパクトなインスタントはありません。

  • 利点: 読み取り時に、対応するパーティションの 1 つのデータ ファイルのみを読み取ることができるため、より効率的です。
  • 短所: データを書き込む場合、以前のコピーをコピーし、それを基に新しいデータ ファイルを生成する必要があり、このプロセスに時間がかかります。時間がかかるため、読み取りリクエストによって読み取られるデータは遅れます。
    ここに画像の説明を挿入

この種類のテーブルには、次の 2 種類のクエリが提供されます。

  • スナップショット クエリ: 最新のスナップショット データ、つまり最新のデータをクエリします。
  • 増分クエリ: ユーザーはコミット時間を指定する必要があります。その後、Hudi はファイル内のレコードをスキャンして、commit_time > ユーザーが指定したコミット時間であるレコードをフィルターで除外します。
    COW テーブルは主にカラムナ ファイル形式 (Parquet) を使用してデータを格納し、RDBMS の B ツリー更新と同様に、データの書き込みプロセスで同期マージ、データ バージョンの更新、データ ファイルの書き換えを実行します。
  • 1) 更新の更新: レコードを更新するとき、Hudi は最初に更新されたデータを含むファイルを検索し、次に更新された値 (最新のデータ) でファイルを再書き込みします。他のレコードを含むファイルは変更されません。突然大量の書き込み操作が発生すると、大量のファイルが書き換えられ、膨大な I/O オーバーヘッドが発生します。
  • 2)、読み取り読み取り: データ セットを読み取る場合、最新のデータ ファイルを読み取ることによって最新の更新が取得されます。このストレージ タイプは、書き込み量が少なく読み取り量が多いシナリオに適しています。
    Copy On Write タイプのテーブルが書き込まれるたびに、ベース ファイル (書き込みの瞬間に対応する) を保持する新しい FileSlice が生成されます。ユーザーがスナップショットを読み取ると、最新の FileSlice の下にあるすべてのベース ファイルがスキャンされます。

3.2.4 読み取り時のマージ

MOR と呼ばれる、新しく挿入されたデータはデルタ ログに保存され、デルタ ログは定期的に寄木細工のデータ ファイルにマージされますデータを読み取るとき、デルタ ログは古いデータ ファイルとマージされ、完全なデータが返されます。以下の図は、MOR の 2 つのデータ読み取りおよび書き込み方法を示しています。
ここに画像の説明を挿入

MOR テーブルは、COW テーブルと同様にデルタ ログを無視し、最新の完全なデータ ファイルのみを読み取ることもできます。

  • 利点: データを書き込むときに最初にデルタ ログが書き込まれ、デルタ ログが小さいため、書き込みコストが低くなります。
  • 短所: 定期的にコンパクトにマージして整理する必要があります。そうしないと、断片化されたファイルが多数発生します。デルタ ログと古いデータ ファイルをマージする必要があるため、読み取りパフォーマンスが低下します。
    このタイプのテーブルには、次の 3 つのクエリが提供されます。
  • スナップショットクエリ: 最新のスナップショットデータ、つまり最新のデータをクエリします。これは行データと列データを混合するクエリです。
  • Incrementabl Query : ユーザーはコミット時間を指定する必要があります。その後、Hudi はファイル内のレコードをスキャンして、commit_time > ユーザーが指定したコミット時間であるレコードをフィルターで除外します。これは行データと列データを混合するクエリです。
  • 読み取り最適化クエリ: すべての列指向ファイル形式が使用されるため、増分データではなくストック データのみをチェックするため、効率が高くなります。
    ここに画像の説明を挿入

MOR テーブルは COW テーブルのアップグレードされたバージョンであり、列ファイル (パーケット) と行ファイル (avro) を組み合わせてデータを格納します。レコードを更新するときは、NoSQL の LSM ツリー更新に似ています。

  • 1) 更新: レコードを更新するときは、増分ファイル (Avro) のみを更新し、次に非同期 (または同期) 圧縮を実行し、最後に新しいバージョンの列指向ファイル (parquet) を作成します。このストレージ タイプは、新しいレコードが追加モードで増分ファイルに書き込まれるため、書き込み頻度の高いワークロードに適しています。
  • 2) 読み取り: データセットを読み取るときは、最初に増分ファイルを古いファイルとマージし、列形式ファイルが正常に生成された後にクエリを実行する必要があります。

3.2.5 COW と MOR の比較

Hudi の WriteClient は、コピーオンライト (COW) ライターとマージオンリード (MOR) ライターで同じです。

  • COW テーブルの場合、ユーザーはスナップショットを読み取るときに、最新の FileSlice の下にあるすべてのベース ファイルをスキャンします。
  • READ OPTIMIZED モードの MOR テーブルは、最新の圧縮されたコミットのみを読み取ります。
    ここに画像の説明を挿入

3.3 データ書き込み動作プロセス

Hudi データ レイク フレームワークでは、UPSERT (更新の挿入)、INSERT (挿入)、および BULK INSERT (書き込みソート) の 3 つのデータ書き込み方法がサポートされています。

  • UPSERT: デフォルトの動作。データは最初にインデックス (INSERT/UPDATE) によってマークされます。ファイルのサイズを最適化するためにメッセージの構成を決定するヒューリスティック アルゴリズムがいくつかあります。
  • INSERT: インデックスをスキップし、より効率的に書き込みます
  • BULK_INSERT: 書き込みソート、大量のデータによる Hudi テーブルの初期化に優しい、ファイル サイズ制限のベストエフォート (HFile への書き込み)

3.3.1 UPSERT書き込みプロセス

Hudi ではテーブルの種類が COW と MOR に分かれているため、UPSERT がデータを書き込む際の具体的な処理も異なります。

3.3.1.1 コピーオンライト

  • 最初のステップは、レコード キーに従ってレコードの重複を排除することです。
  • 2 番目のステップでは、最初にこのデータ バッチのインデックスを作成し (HoodieKey => HoodieRecordLocation)、そのインデックスを使用してどのレコードが更新され、どのレコードが挿入されるかを区別します (キーは初めて書き込まれます)。
  • 3 番目のステップは、更新メッセージの場合、キーに対応する最新の FileSlice のベース ファイルを直接検索し、マージ後に新しいベース ファイル (新しい FileSlice) を書き込みます。
  • ステップ 4. 挿入メッセージの場合、現在のパーティションのすべての SmallFile (特定のサイズより小さいベース ファイル) がスキャンされ、マージされて新しい FileSlice が書き込まれます。SmallFile がない場合は、新しい FileGroup + FileSlice を直接書き込みます。

3.3.1.2 読み取り時のマージ

  • 最初のステップは、レコード キーに従ってレコードの重複を排除することです (オプション)
  • 2 番目のステップでは、最初にこのデータ バッチのインデックスを作成します (HoodieKey => HoudieRecordLocation)。インデックスを使用して、どのレコードが更新され、どのレコードが挿入されるかを区別します (キーは初めて書き込まれます)。
  • 3 番目のステップでは、挿入メッセージの場合、ログ ファイルにインデックスを付けることができない場合 (デフォルト)、パーティション内の最小のベース ファイル (ログ ファイルを含まない FileSlice) をマージして新しい FileSlice を生成しようとします。ベース ファイルがない場合は、新しいファイル グループ + ファイル スライス + ベース ファイルを書き込みます。ログ ファイルにインデックスを付けることができる場合は、小さなログ ファイルを追加してみます。そうでない場合は、新しいファイル グループ + ファイル スライス + ベース ファイルを書き込みます。
  • ステップ 4. 更新メッセージの場合は、対応するファイル グループ + ファイル スライスを書き込み、最新のログ ファイルを直接追加します (それが現在の最小の小さなファイルである場合は、ベース ファイルをマージして新しいファイル スライスを生成します) ). ログ ファイル サイズがしきい値に達しました。新しいログ ファイルにロールオーバーされます

3.3.2 INSERT書き込み処理

また、Hudiではテーブルの種類がCOWとMORに分かれているため、INSERTでデータを書き込む際の処理も異なります。

3.3.2.1 コピーオンライト

  • 最初のステップは、レコード キーに従ってレコードの重複を排除することです (オプション)。
  • 2 番目のステップでは、インデックスは作成されません。
  • 3 番目のステップでは、小さなベース ファイルがある場合は、ベース ファイルをマージして新しい FileSlice + ベース ファイルを生成します。それ以外の場合は、新しい FileSlice + ベース ファイルを直接書き込みます。

3.3.2.2 読み取り時のマージ

最初のステップは、レコード キーに従ってレコードの重複を排除することです (オプション)
2 番目のステップは、インデックスを作成しないことです; 3 番目の
ステップは、ログ ファイルにインデックスが作成でき、ログ ファイルが存在する場合は、最新のものを追加または書き込みます小さな FileSlice ログ ファイル。ログ ファイルがインデックス付けできない場合は、新しい FileSlice + ベース ファイルを書き込みます。

おすすめ

転載: blog.csdn.net/toto1297488504/article/details/132240729