深入理解 Impala 效能:從架構到優化的完整指南
實際理解 Impala 的儲存架構,才能找到正確的優化方向。
深入理解 Impala 效能:從架構到優化的完整指南
實際理解 Impala 的儲存架構,才能找到正確的優化方向。
前言
如果你有在大數據的世界裡打滾過一陣子,應該多少都聽過 Apache Impala 。簡單來說,Impala 是一個跑在 Hadoop 生態系上的 MPP(Massively Parallel Processing,大規模平行處理)SQL 查詢引擎(是查尋引擎,不是資料庫管理系統喔!),最早是 Cloudera 在 2012 年受到 Google Dremel 啟發而開發的,後來貢獻給 Apache 基金會成為開源專案。
那為什麼需要 Impala?這要從 Hadoop 早期的痛點說起。在 Impala 出現以前,想在 Hadoop 上跑 SQL 查詢,最主流的選擇就是 Apache Hive。Hive 會把 SQL 翻譯成 MapReduce Job 去執行,功能上沒問題,但 MapReduce 本質上是批次處理框架,每次查詢都要走排程、啟動 JVM、把中間結果寫回磁碟……光是這些 overhead 就讓延遲高到不行。想像你只是想跑個 SELECT COUNT(*) FROM users WHERE country = 'TW',結果要等好幾分鐘,你能夠接受嗎?
Impala 就是為了解決這個問題而生的。它不再依賴 MapReduce 作為查詢執行方式,自己實作了一套專為互動式查詢設計的 MPP 分散式引擎,讓同樣的查詢延遲從分鐘級降到秒級,而且隨著節點數量增加還能近乎線性地擴展效能。再加上它直接共用 Hive 的 Metastore,既有的 table 和 schema 都能無痛沿用,SQL 語法也幾乎相容,對已經在用 Hive 的團隊來說切換成本非常低。
後來 Hive 也意識到了延遲的問題,陸續引入了 Tez 和 LLAP 等執行引擎來追趕,但 Impala 從架構層面就是為了低延遲而設計的,這種骨子裡的差異讓它在互動式查詢場景下依然保有明顯的優勢。
從儲存到查詢:Impala 如何在分散式環境中運作
在聊 Impala 怎麼查詢之前,得先理解資料是怎麼被存放的,因為這會直接影響到後面查詢的設計邏輯。
Impala 本身不儲存資料,它只是一個查詢引擎。資料實際上存在 HDFS(Hadoop Distributed File System)上。HDFS 的核心概念是把一個大檔案切成固定大小的 block(預設 128MB),分散存到叢集中的各個 DataNode(也就是負責實際儲存資料的節點)上,每個 block 還會複製幾份(預設 3 份)放在不同的節點,確保某台機器掛了資料也不會丟失。而叢集中還有一個 NameNode,你可以把它想成一本目錄索引 — — 它不存實際資料,但它記錄了每個檔案被切成哪些 block、每個 block 存在哪些 DataNode 上。後面會看到,Impala 在規劃查詢時就是去問 NameNode「我要的資料在哪裡」,才知道該把任務分配給哪些節點。
舉個例子,假設你有一張 1GB 的 table,存成 Parquet 格式放在 HDFS 上。HDFS 會把這個檔案切成大約 8 個 128MB 的 block,分散在叢集裡不同的 DataNode 上。當 Impala 要查詢這張表時,它不是把所有資料搬到某一台機器上再開始處理,而是讓每台存有相關 block 的 DataNode「就地」處理自己手上的那份資料。這就是所謂的 data locality(資料本地性)— — 盡量讓計算發生在資料所在的地方,而不是搬動資料,大幅減少網路傳輸的成本。
了解完資料怎麼存之後,來看 Impala 這邊有哪些角色在運作。 Impala 的架構主要由三個元件組成:
一、Impalad(Impala Daemon):
最核心的角色,通常會部署在各個 DataNode 上。 每個 impalad 裡面又包含三個模組:
- Query Planner 負責把 SQL 解析成執行計畫。
- Query Coordinator 負責將任務分配給其他節點,並彙整最終結果。
- Query Exec Engine 負責實際執行運算。
當使用者發送一個查詢時,接收到請求的那個 impalad 就會自動成為這次查詢的 Coordinator。在 Impala 中,每個節點都是平等的。但在大型生產環境中,為了穩定性,有時會將 Coordinator 與 Executor 角色分離部署,避免彙整海量資料時的內存壓力影響到基礎運算。

圖片來源:Apache Impala 官方網站
二、Statestore:
負責監控所有 impalad 的健康狀態,以及作為 Catalogd 廣播 metadata 變更的通道。 整個叢集只需要一個 Statestore。如果它掛了,既有 table 的查詢短期內還是可以正常運作,因為各個 impalad 之間本身有維持彼此的狀態資訊。但這段期間會有兩個風險:第一,如果某個 impalad 節點也掛了,其他節點不會被通知到;第二,任何 DDL 操作(像是 CREATE TABLE、ALTER TABLE)造成的 metadata 變更都無法被廣播到其他節點,導致各節點的 metadata 不一致。所以雖然 Statestore 不在查詢的關鍵路徑上,一旦掛了還是應該盡快恢復。
三、Catalogd(Catalog Service) :
負責管理 metadata 的同步。還記得前面提過 Impala 會共用 Hive 的 Metastore 嗎?當有人透過 Impala 執行了 CREATE TABLE 或 ALTER TABLE 之類的 DDL 語句時,Catalogd 會把這些 metadata 的變更透過 Statestore 廣播給所有 impalad,讓整個叢集的 metadata 保持一致。
補充:如果是在 Impala 以外的地方(例如透過 Hive CLI 或 Spark)修改了 HDFS 資料或 Hive Metastore,Impala 是「感應不到」的。這時必須手動執行
INVALIDATE METADATA或REFRESH語句。Catalogd 只負責處理「透過 Impala 發起」的變更。
有了上面的背景知識,我們就可以把整個查詢流程串起來了。 以這個查詢為例:
SELECT department, COUNT(*)
FROM employees
WHERE country = 'TW'
GROUP BY department;
第一步:提交查詢。 你的 SQL 會被送到叢集中某一個 impalad,這個 impalad 就成為本次查詢的 coordinator。
第二步:解析與規劃。 Coordinator 裡的 Query Planner 會先做 SQL 的詞法分析、語法分析、語義分析,同時從 Hive Metastore 取得 table 的 schema 資訊,從 HDFS NameNode 取得資料所在的 block 位置。接著它會先產生一個單機執行計畫(single-node plan) — — 也就是假設資料全在一台機器上時的邏輯計畫,在這個階段會做 predicate pushdown、join reordering 等優化。然後再把這個單機計畫轉換成分散式執行計畫(distributed plan),考慮資料分布在哪些節點上,決定怎麼拆分工作。
第三步:切分 Fragment。 分散式計畫會被切成多個 fragment(片段),每個 fragment 是一個可以獨立執行的工作單元。你可以把它想成一棵 DAG(有向無環圖):
- 底層 Fragment:負責「掃描」與「局部過濾」。
- 中間層 Fragment:負責執行「局部聚合」(例如各節點先算好自己部門的人數)或 join。
- 頂層 Fragment:跑在 Coordinator 上,負責將全叢集的數據進行最終彙總。
Fragment 之間透過 data stream(資料流)傳遞中間結果。
第四步:分配與執行。 Query Coordinator 根據 data locality 的原則,把各個 fragment 分配給對應的 impalad 去執行。每個 impalad 上的 Query Executor 開始讀取本地的 HDFS block,進行掃描、過濾、聚合等運算。
第五步:彙總回傳。 各節點完成自己的 fragment 後,把中間結果透過網路傳回 coordinator。Coordinator 執行最頂層的 fragment(例如最終的聚合或排序),然後把最終結果回傳給使用者。
整個過程中,每個 fragment 都會持續向 coordinator 回報執行狀態。如果任何一個 fragment 失敗了,coordinator 會直接取消整個查詢 — — Impala 目前不支援單一 fragment 層級的容錯重試。
別用 RDBMS 的思維來理解 Impala
上一段我們看完了 Impala 的架構和查詢流程,如果你是從傳統 RDBMS(像是 MySQL、PostgreSQL、Oracle)的背景過來的,可能會覺得「不就是寫 SQL 嗎,有什麼不一樣?」。語法上確實很像,但骨子裡的執行邏輯差異非常大,如果用 RDBMS 的思維去理解 Impala,很容易踩到效能的坑。這段我們來聊聊幾個最關鍵的差異。
儲存與計算的關係完全不同。 傳統 RDBMS 是儲存和計算綁在一起的,資料存在本機磁碟上,查詢引擎直接讀取本機的資料來處理,一切都發生在同一台機器上。但 Impala 是計算和儲存分離的架構,資料存在 HDFS 上分散在整個叢集,Impala 本身只負責查詢,不管資料怎麼存。這意味著 Impala 在查詢時需要透過網路去讀取分散在不同節點上的資料,網路 I/O 變成一個你在 RDBMS 世界裡幾乎不用擔心的效能因子。
沒有索引這回事。 用 RDBMS 的時候,效能不好的第一反應通常是「加個 index 吧」。但 Impala 沒有傳統意義上的索引機制。它的設計哲學是靠大規模平行掃描來換取速度 — — 與其花時間維護索引結構,不如把資料分散到一百台機器上,每台各掃一小塊,全部同時動工。所以在 Impala 的世界裡,你不會用 CREATE INDEX 來優化查詢,取而代之的是透過 partition、檔案格式(像 Parquet 的 column pruning 和 predicate pushdown)、以及合理的 table schema 設計來減少需要掃描的資料量。後面的優化方法段落會詳細展開這些技巧。
沒有 UPDATE 和 DELETE。 這大概是從 RDBMS 過來的人最不習慣的一點。傳統資料庫你可以隨意 UPDATE 某一行、DELETE 某幾筆資料,因為底層的儲存引擎支援 in-place 修改。但 HDFS 上的檔案是 write-once 的——寫進去之後就不能直接改了。所以 Impala 天生就不適合做 OLTP(線上交易處理)那種頻繁增刪改的場景,它的舞台是 OLAP(線上分析處理),也就是大量的讀取和聚合查詢。
容錯機制的取捨。 傳統 RDBMS 在查詢過程中遇到問題,通常有事務機制(transaction)和日誌(WAL)來保護你,確保資料一致性。而 Impala 為了追求低延遲,在容錯上做了取捨:查詢過程中如果任何一個節點掛了,整個查詢就直接失敗,沒有自動重試。相較之下,像 Hive 底層的 MapReduce 是會自動重跑失敗的 task 的。這不是 Impala 的缺陷,而是一種設計選擇 — — 對於幾秒鐘就能跑完的互動式查詢來說,重新跑一次的成本遠比建立複雜的容錯機制來得低。
查詢優化器的資訊來源不同。 RDBMS 的查詢優化器通常很成熟,它會自動收集和維護各種統計資訊(表的大小、欄位的基數、資料分布等),然後根據這些資訊自動選擇最佳的執行計畫。Impala 的優化器也會參考統計資訊,但它不會自動去收集,你得手動執行 COMPUTE STATS 來告訴 Impala 這些資訊。如果你忘了做這一步,Impala 就只能用預設的估算來規劃查詢,很容易選到不理想的執行策略(比如選錯 join 的方式),效能可能會差好幾倍。這也是後面優化段落會強調的重點之一。
總結來說,Impala 寫起來像 SQL,但跑起來的邏輯跟你熟悉的 RDBMS 完全是兩個世界。理解這些差異之後,再去看後面的效能指標和優化方法,才會真正知道「為什麼要這樣做」。
怎麼知道你的查詢慢在哪?認識 Impala 的效能指標
理解了 Impala 的架構與它跟傳統 RDBMS 的差異後,在實際遇到查詢跑很慢的時候,你需要有辦法「看到」問題出在哪裡。Impala 提供了幾個工具讓你可以診斷查詢效能,在進入優化方法之前,先來認識一下它們。
- EXPLAIN:查詢執行前的預覽。
在你真正跑一個查詢之前,可以在 SQL 前面加上
EXPLAIN來看 Impala 打算怎麼執行這個查詢。它會列出整個執行計畫的結構,包含會用到哪些 scan、join、aggregation 操作,以及預估要處理多少資料。你可以把它想成是 Impala 在告訴你「我打算這樣做,你覺得 OK 嗎?」。 特別值得注意的是,如果 EXPLAIN 的輸出裡出現了WARNING: missing table and/or column statistics這樣的警告,那幾乎可以確定你的查詢效能不會太好,因為 Impala 在沒有統計資訊的情況下只能瞎猜。 - SUMMARY:查詢執行後的概覽。 查詢跑完之後,在 impala-shell 裡輸入
SUMMARY,可以看到每個 fragment 的執行時間、處理的資料列數、記憶體使用量等資訊的摘要。它會用表格的方式呈現,讓你可以快速找到哪個階段花了最多時間。比如你可能會發現 scan 階段花了 0.3 秒但 join 階段花了 15 秒,那瓶頸就很明顯了。(SUMMARY只能在impala-shell中直接下指令查看。) - PROFILE:查詢執行後的完整報告。 如果 SUMMARY 告訴你「哪裡慢」,那 PROFILE 就是告訴你「為什麼慢」。它會輸出一份非常詳細的報告,包含每個節點的 CPU 使用量、記憶體用量、磁碟 I/O、網路傳輸量等底層指標。資訊量很大,剛開始看會有點嚇人,但一旦習慣了就會發現它是你最好的除錯工具。
那拿到這些資訊之後,到底要看什麼?以下是幾個最常見的效能瓶頸指標:
- I/O Bound(磁碟瓶頸): 如果你發現查詢花了大量時間在讀取資料,那它很可能是 I/O bound。常見的原因包括:掃描了太多不必要的 partition、使用了不適合分析的檔案格式(比如用 CSV 而不是 Parquet)、或是 HDFS 上的資料沒有好好壓縮。
- CPU Bound(計算瓶頸): 如果 I/O 時間很短但整體查詢還是慢,那可能是 CPU bound。常見場景是查詢中有大量的字串處理、複雜的表達式運算、或是涉及很多欄位的聚合操作。
- Memory Pressure 與 Spill to Disk(記憶體壓力):這是 Impala 裡非常重要的一個指標。Impala 的 join、aggregation、排序等操作都是在記憶體中進行的。當某個節點的記憶體不夠用時,Impala 會把中間資料暫時寫到磁碟上(這就是所謂的 spill to disk),雖然查詢不會因此失敗,但效能會大幅下降,因為磁碟 I/O 比記憶體慢得多。在 PROFILE 的輸出裡,如果你看到
SpilledPartitions或BytesWritten的數值不是零,那就代表發生了 spill,需要認真考慮怎麼優化了。 - Data Skew(資料傾斜): 在 SUMMARY 的輸出裡,你可以看到每個 fragment 在各個節點上的 Max Time 和 Avg Time。如果 Max Time 遠大於 Avg Time,那就代表資料分配不均 — — 某些節點分到了特別多的資料,其他節點早就做完了卻在等這個慢的節點。這種情況叫做 data skew,是分散式系統裡很經典的效能殺手。
- Network Bottleneck(網路瓶頸): 因為 Impala 的 fragment 之間需要透過網路傳遞中間結果,如果某個 join 或 aggregation 產生了巨量的中間資料需要在節點之間傳輸,網路就可能成為瓶頸。PROFILE 裡的
BytesSent和BytesReceived可以幫你確認這一點。
掌握了這些指標,你就有了一套判斷問題的框架:先用 EXPLAIN 確認執行計畫合不合理、跑完用 SUMMARY 定位哪個階段最慢、再用 PROFILE 深挖原因。接下來的優化方法段落,本質上就是在針對這些不同類型的瓶頸對症下藥。
讓查詢跑更快:Impala 效能優化實戰
前面我們知道了怎麼判斷查詢慢在哪裡,接下來就是對症下藥的部分了。這段會介紹幾個在實務上最常用也最有效的優化手段。
Partition:少讀就是快
這大概是 Impala 優化裡投資報酬率最高的一招。
還記得前面提過 Impala 沒有索引嗎?那在沒有索引的世界裡,要怎麼避免每次查詢都掃描整張表?答案就是 Partition。
Partition 的概念是把一張表的資料根據某些欄位的值,切成不同的子目錄存放在 HDFS 上。比如你有一張 orders 表,依照 year 和 month 來做 partition,那 HDFS 上就會長出像 /orders/year=2025/month=01/、/orders/year=2025/month=02/ 這樣的目錄結構,每個目錄裡面放的就是那個月份的資料。
當你的查詢帶有 WHERE year = 2025 AND month = 3 的條件時,Impala 的 Query Planner 會做 partition pruning,會直接跳過所有不相關的目錄,只讀取 /orders/year=2025/month=03/ 裡的資料。如果你的表有 5 年的資料共 60 個月份的 partition,這一招直接讓你的 I/O 量降到原本的 1/60。你可以用 EXPLAIN 來確認 partition pruning 有沒有生效,看輸出裡的 #partitions=1/60 就知道了。
不過 partition 不是切越細越好。如果你按 year/month/day/hour 四層來切,partition 數量可能會暴增到好幾萬個,每個 partition 裡面的檔案都很小。太多小檔案會讓 NameNode 的負擔變大,查詢規劃的時間也會拉長。官方建議是把 partition 數量控制在 30,000 以下,每個 partition 裡至少要有 256MB 以上的資料量才比較理想。
Join 策略:Broadcast vs Shuffle
在分散式環境裡做 join 是一件很昂貴的事,因為要 join 的兩張表的資料可能分散在不同的節點上,勢必需要透過網路來交換資料。Impala 提供了兩種 join 策略,選錯的話效能差距可以到好幾倍。
- Broadcast Join:是 Impala 的預設策略。它的做法是把 join 右邊那張表的完整內容廣播到所有參與查詢的節點上,這樣每個節點就可以用自己本地的左表資料跟完整的右表做比對。這在右表比較小的情況下非常高效,因為傳輸小表的成本很低。但如果右表也很大,那每個節點都要接收一份完整的大表,網路和記憶體都會爆掉。
- Shuffle Join(在 Impala 的文件裡也叫 partitioned join;
EXPLAIN輸出中,通常會顯示為HASH JOIN搭配PARTITIONED):會把兩張表的資料都依照 join key 做 hash,然後把 hash 值相同的資料送到同一個節點上去處理。這樣每個節點只需要處理一部分的資料,不會有某個節點需要裝下整張表的問題。當兩張表都很大且大小相近時,shuffle join 通常是更好的選擇。
如果你有跑 COMPUTE STATS(等一下會講到),Impala 的優化器通常能自動選擇合適的策略。但如果沒有統計資訊,Impala 會預設用 broadcast join,這在兩張大表 join 的時候就很容易出問題。你也可以用 hint 來手動指定:
-- 強制使用 shuffle join
SELECT *
FROM big_table_a JOIN /* +SHUFFLE */ big_table_b
ON big_table_a.id = big_table_b.id;
-- 強制使用 broadcast join
SELECT *
FROM big_table JOIN /* +BROADCAST */ small_lookup_table
ON big_table.code = small_lookup_table.code;
COMPUTE STATS:別讓優化器瞎猜
COMPUTE STATS 會掃描整張表,收集每個欄位的統計資訊,包括資料列數(cardinality)、不同值的數量(NDV, Number of Distinct Values)、NULL 值的比例等等。Impala 的 Query Planner 依賴這些資訊來做出正確的決策:要用 broadcast 還是 shuffle join?join 的順序怎麼排最好?要不要做 runtime filter?
如果你沒有執行 COMPUTE STATS,Impala 就只能用預設的估算值,很容易做出錯誤的判斷。最典型的情況就是:兩張大表做 join 時,因為沒有統計資訊,Impala 不知道右表其實很大,就用了 broadcast join 把整張表廣播出去,結果記憶體直接爆掉。
用法很簡單,在載入資料或做了大量變更之後,對相關的表跑一次就好:
COMPUTE STATS orders;
COMPUTE STATS customers;
如果表很大而你不想每次都全表掃描,可以用 COMPUTE INCREMENTAL STATS 來只更新有變動的 partition。
檔案格式:用 Parquet 就對了
如果你的表還在用 CSV 或 Text 格式,那你正在錯過 Impala 最大的優化空間之一。
Parquet 是一種列式儲存格式(columnar storage),它把同一個欄位的資料連續存放在一起。這對分析型查詢來說有巨大的優勢:當你的查詢只需要某幾個欄位的時候(SELECT name, revenue FROM ...),Impala 只需要讀取那幾個欄位的資料,完全不用碰其他欄位,這就是所謂的 column pruning。相比之下,CSV 這種 row-based 的格式,就算你只要一個欄位,也得把整行都讀進來才能切出你要的部分。
除此之外,Parquet 還自帶壓縮和編碼優化(像是 dictionary encoding、run-length encoding),以及每個 row group 的 min/max 統計資訊。當你的 WHERE 條件篩選的值不在某個 row group 的 min/max 範圍內時,Impala 可以直接跳過整個 row group 不讀,進一步減少 I/O。搭配 SORT BY 子句讓資料在寫入時就按特定欄位排序,這個 min/max 過濾的效果會更好。
Runtime Filter:Impala 自動幫你做的動態過濾
這是 Impala 2.5 以後內建的優化機制,預設就是開啟的。簡單來說,當 Impala 在執行 join 的過程中,如果發現其中一邊的表經過過濾後只剩下很少的 join key(也就是 ON 條件中用來比對的欄位值),它會把這些值打包成一個 filter,廣播給正在掃描另一張表的節點。這樣掃描端在讀取資料時就能提前丟掉不可能 match 的資料列,大幅減少需要傳輸和處理的資料量。
Runtime filter 對 Parquet 表格特別有效,因為它可以利用 Parquet 的 row group 統計資訊來整塊跳過不相關的資料。不過它要發揮最大效果有兩個前提:一是表有跑過 COMPUTE STATS,二是盡量使用 Parquet 格式(per-row filtering 只對 Parquet 生效,其他格式只能做到 partition 層級的過濾)。
如果你想確認 runtime filter 有沒有在某個查詢中生效,跑完後用 PROFILE 查看 Filter 區段,Rows rejected 的數字越大代表過濾效果越好。如果是 0,常見的原因是掃描速度太快,在 filter 廣播到達之前就已經掃完了,這時可以調大 RUNTIME_FILTER_WAIT_TIME_MS 讓掃描節點多等一下。
避免小檔案問題
最後一個容易被忽略但影響不小的問題。如果你的表裡有大量的小檔案(比如每個檔案只有幾 KB 或幾 MB),Impala 的效能會被嚴重拖累。原因有兩個:第一,每個檔案都有 metadata 的開銷,NameNode 要追蹤大量小檔案會很吃力;第二,Impala 對每個檔案都要做一次開啟、讀取、關閉的動作,大量小檔案會導致這些操作的累積開銷遠超過實際讀取資料的時間。
小檔案通常是因為頻繁做小量的 INSERT INTO 造成的,因為每次 INSERT 都會產生新的檔案。解決方法是用 INSERT ... SELECT 來做資料的 compaction,把小檔案合併成大檔案:
-- 把小檔案合併成較大的檔案
CREATE TABLE orders_compacted
STORED AS PARQUET
AS SELECT * FROM orders;
「少讀一點」就是最好的優化
寫這篇文章的初衷,是因為自己剛開始接觸 Impala 的時候,常常會不自覺地用過去在RDBMS上的經驗去理解它。但 Impala 的儲存架構跟RDBMS從根本上就不一樣,所以不能用相同的邏輯去優化它。
RDBMS 的優化思路是「怎麼更快找到那幾筆資料」,但 Impala 的優化思路是「怎麼讓它少讀一點資料」。Partition 是少讀不相關的目錄、Parquet 是少讀不需要的欄位、COMPUTE STATS 是讓優化器別選錯 join 策略多傳一堆不必要的資料。理解了這個核心差異,未來遇到新的效能問題時,你就知道該從哪個方向去思考了。
메타데이터
- post_id
- fa61cf4ef11c
- slug
- 深入理解-impala-效能-從架構到優化的完整指南-fa61cf4ef11c
- url
- https://medium.com/@yoyozheng97w/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3-impala-%E6%95%88%E8%83%BD-%E5%BE%9E%E6%9E%B6%E6%A7%8B%E5%88%B0%E5%84%AA%E5%8C%96%E7%9A%84%E5%AE%8C%E6%95%B4%E6%8C%87%E5%8D%97-fa61cf4ef11c
- canonical_url
- https://medium.com/@yoyozheng97w/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3-impala-%E6%95%88%E8%83%BD-%E5%BE%9E%E6%9E%B6%E6%A7%8B%E5%88%B0%E5%84%AA%E5%8C%96%E7%9A%84%E5%AE%8C%E6%95%B4%E6%8C%87%E5%8D%97-fa61cf4ef11c
- author_url
- https://medium.com/@yoyozheng97w
- status
- ok
- fetched_at
- 2026-06-17 08:20:12