Presto 在騰訊資訊業務中的應用

團隊:騰訊醫療資訊與服務部 - 技術研發中心

前言:隨着產品矩陣和團隊規模的擴張,跨業務、APP 的數據處理、分析總是不可避免。一個顯而易見的問題就是異構數據源的連通。我們基於 PrestoDB 構建了業務線內適應騰訊生態的聯邦查詢引擎,連通了部門內部 20 + 數據源實例,涵蓋了 90% 的查詢場景。同時,我們參與公司級的 Presto Oteam 進行協同共建,在引擎層面做了諸多改造。在實際使用 Presto 的過程中,也發現其 SQL 表達能力的過人之處。本文將從 Presto 使用者和開發者兩種角度,給大家分享一些技術落地過程中的乾貨。

  1. 簡介 =====

Presto 是 facebook 研發的基於 SQL 進行大數據分析的高性能分佈式計算引擎,最開始是用來解決 Hive 速度慢以及異構數據源互通的問題。在大數據家族中屬於 MPP(Massive Parallel Processing)計算引擎範疇,其原理是火山(Volcano)模型:將 SQL 抽象成一個個算子(Operator),形成管線(Pipeline)。目前能夠支持 Hive、HBase、ES、Kudu、Kafka、MySQL、Redis 等幾十種數據源的讀取。它有如下特點:

1.Presto 架構圖

  1. 業務現狀 =======

無論是傳統信息流業務,或是醫療業務,或多或少都會遇到異構數據源整合問題。比如醫生、患者的狀態數據,由後臺維護,前端上報數據則在 Hive 中。另外,由於相同數據源不同版本間差異較大,往往沒有完整的解決方案,導致查詢分析速度慢,業務叫苦不迭,e.g. Hive 不同實例僅通過 MR 引擎進行互通。

2.1 業務構成

目前,個人接觸過的業務包括資訊類的騰訊看點、騰訊醫典,以及醫生問診相關的騰訊雲醫。

  1. 業務構成

2.2 痛點問題

數據互通的時候,底層的數據源可能是同一數據源的多個實例,或是不同版本、魔改版本,e.g. 司內 tHive 與 Venus 都是 Hive 數據源。在跨業務 / APP 分析時,這種問題會更加明顯。同時,由於應用場景的不同(離線計算、快速索引),天然也會存在多數據源問題。原因總結如下:

  1. 異構數據源問題

2.3 主要工作

針對 Hive 查詢提速的問題,我們在聯邦查詢引擎中適配了內部的 Hive 數據源,並且參與中臺 Oteam 項目進行 Hive 兼容、Presto 引擎層優化、改造。同時,我們進行了技術運營工作來幫助大家更好地使用 Presto。針對異構數據源打通的問題,我們進行了聯邦查詢引擎的調研與開發,在引擎層面對內部不同種類的數據源進行適配。最後是一些技術輸出的規劃工作。

  1. Presto 技術運營

  2. 聯邦查詢引擎改造適配

  3. Presto Oteam 引擎研發

  4. 技術輸出

  5. 技術運營 =======

由於身處業務的數據團隊中,除了參與中臺的技術研發,平時也會使用 Presto,並且負責 SQL 相關問題的答疑,既是開發者,也是使用者。大多數人對 Presto 的印象,僅僅停留在 “都是 SQL 引擎” 上,其實不然。Presto 的 SQL 語言能力非常出色。如 slogan 說宣傳的那樣,SQL on Everything:不僅能夠連接各種數據源,還能滿足複雜的處理邏輯。如果認爲 “Presto 在 SQL 層面上做到兼容 Hive 就差不多了”,那就沒有真正發揮出 Presto 的威力。

3.1 Reduce + Lambda

以下來自一個真實案例,數據分析同學根據 APP 上報的用戶行爲日誌進行清理、建模。

如果不是別人問,自己是不會想到可以用 SQL 來完成這種操作的。數組相鄰元素去重,乍看是非常特化的需求,SQL 不太可能滿足,但後來發現還真的可以實現。不得不說 Presto 的 reduce 函數,加上自由度極高的 lambda 表達式,以及可以承載多個變量的Row類型,使得我們幾乎可以在 SQL 中 “編程”(這裏使用針對 array 類型的 reduce 函數,更通用的聚合函數爲 reduce_agg)。最終解法如下:

-- 邏輯:6/4/6/6/10/20 -> 6/4/6/10/20
-- distinct adjacent elements
SELECT reduce(
                ARRAY ['6''4''6''6''10''20'], -- 輸入

                CAST(
                        ROW(ARRAY[]'') 
                        AS ROW(arr ARRAY(VARCHAR), prev_ele VARCHAR)
                    ),  -- 初始狀態S

                (S, T) -> CAST(
                                ROW(IF(S.prev_ele=T, S.arr, S.arr||T), T) 
                                AS ROW(arr ARRAY(VARCHAR), prev_ele VARCHAR)
                              ),  -- lambda輸入函數I

                S -> array_join(S.arr, '/') -- lambda輸出函數O
             );

以作用對象爲數組的 reduce 函數爲例,包含以下 4 個參數:

  1. 長度爲 N 的數組。每個元素將會依次送入 lambda 輸入函數

  2. 初始狀態。第一個元素和該狀態作爲 lambda 輸入函數第一次調用的參數

  3. 一個 lambda 輸入函數。調用 N 次。它接收一個狀態和一個元素,產生一個新的狀態

  4. 一個 lambda 輸出函數。調用一次。對 3 中處理完的最終狀態做一次變換

reduce(array(T), initialState S, inputFunction(S, T, S), outputFunction(S, R)) → R

可以看到,示例中的狀態 S 是一個Row類型的變量,它可以存儲多個元素。第一個是去重數組 arr,第二個是上一個元素的值 prev_ele。lambda 輸入函數每次接收到一個新的值,和 prev_ele 比較,相等則什麼也也不做,不等則將新值放入去重數組中,同時更新 prev_ele。reduce 是一種通用的模型,lambda 則最大程度地利用了 SQL 的現有能力,使得 Presto 的 SQL 表現力更加強大。

3.2 窗口函數

Presto 中的聚合函數都可以被用在窗口函數中,使用 array_agg 可以把當前的窗口截取下來,結合 Window Frame 可以操縱窗口大小,衍生出很多窗口類型。主要由兩個維度組成:

首先是相同行的處理方式,記爲 dim1:

然後是窗口的邊界指定,最後兩種僅支持與 ROWS 連用,記爲 dim2:

  1. window frame[1]

通過以下 SQL 的結果,應該能對窗口函數有更進一步的認識。爲了簡化我們假設只有一個 partition,排序爲 asc。列名取值如下所示方便大家理解:

  1. 命名方式
-- value爲關心的值
-- 以index進行排序
WITH
    t1 (value, index) AS 
    (
        SELECT * FROM (VALUES ('a', 1),
        ('b', 2),
        ('c', 3),
        ('d', 4),
        ('e', 4),
        ('f', 5),
        ('g', 5),
        ('h', 6))
    )

SELECT *,
    -- 默認
    array_agg(value) OVER 
        (ORDER BY index) res, 
    -- [開頭, 當前值]
    array_agg(value) OVER 
        (ORDER BY index RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) res_range_uc,
    -- [開頭, 當前行]
    array_agg(value) OVER 
        (ORDER BY index ROWS  BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) res_rows_uc,
    -- [當前值, 末尾]
    array_agg(value) OVER 
        (ORDER BY index RANGE BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING) res_range_cu,
    -- [當前行, 末尾]
    array_agg(value) OVER 
        (ORDER BY index ROWS  BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING) res_rows_cu,
    -- [前1個值,後1個值] 不支持
    -- array_agg(value) OVER (ORDER BY index RANGE BETWEEN 1 PRECEDING AND 1 FOLLOWING) res_range_11, not support
    -- [前1行,後1行]
    array_agg(value) OVER 
        (ORDER BY index ROWS  BETWEEN 1 PRECEDING AND 1 FOLLOWING) res_rows_11
FROM t1;

presto>

 value | index |           res            |       res_range_uc       |       res_rows_uc        |       res_range_cu       |       res_rows_cu        | res_rows_11 
-------+-------+--------------------------+--------------------------+--------------------------+--------------------------+--------------------------+-------------
 a     |     1 | [a]                      | [a]                      | [a]                      | [a, b, c, d, e, f, g, h] | [a, b, c, d, e, f, g, h] | [a, b]      
 b     |     2 | [a, b]                   | [a, b]                   | [a, b]                   | [b, c, d, e, f, g, h]    | [b, c, d, e, f, g, h]    | [a, b, c]   
 c     |     3 | [a, b, c]                | [a, b, c]                | [a, b, c]                | [c, d, e, f, g, h]       | [c, d, e, f, g, h]       | [b, c, d]   
 d     |     4 | [a, b, c, d, e]          | [a, b, c, d, e]          | [a, b, c, d]             | [d, e, f, g, h]          | [d, e, f, g, h]          | [c, d, e]   
 e     |     4 | [a, b, c, d, e]          | [a, b, c, d, e]          | [a, b, c, d, e]          | [d, e, f, g, h]          | [e, f, g, h]             | [d, e, f]   
 f     |     5 | [a, b, c, d, e, f, g]    | [a, b, c, d, e, f, g]    | [a, b, c, d, e, f]       | [f, g, h]                | [f, g, h]                | [e, f, g]   
 g     |     5 | [a, b, c, d, e, f, g]    | [a, b, c, d, e, f, g]    | [a, b, c, d, e, f, g]    | [f, g, h]                | [g, h]                   | [f, g, h]   
 h     |     6 | [a, b, c, d, e, f, g, h] | [a, b, c, d, e, f, g, h] | [a, b, c, d, e, f, g, h] | [h]                      | [h]                      | [g, h]      
(8 rows)

3.3 高階運營

一般來說,通過官方文檔就可以解答大部分問題。但有時候文檔也沒說明的細節,只能看源碼了。關於語法特點的問題,需要查看SqlBase.g4。比如以下 SQL 爲什麼可以運行?不是所有查詢語句都需要 select 開頭:

presto> (VALUES ('a', 1),('b', 2));
 _col0 | _col1 
-------+-------
 a     |     1 
 b     |     2 
(2 rows)

語義分析中的問題,需要查看StatementAnalyzer。比如窗口函數執行完成後,用標量函數做一些加工處理,必須寫在整個窗口函數func2(func1() over ())的外面,而不是func2(func1()) over ()

--報錯
array_join(array_agg(concat(col1, col2))'/') 
  over (partition by user_id order by event_time)

vs

--成功
array_join(
  array_agg(concat(col1, col2)) 
    over (partition by user_id order by event_time),
  '/')

3.4 語法、語義錯誤

還有個問題,到底怎麼區分語法、語義錯誤?對於使用者而言,不建議瞭解。對於開發者來說,還是很有必要了解的。語法錯誤是指通過簡單規則捕獲的 SQL 錯誤,在 Antlr 層面就可以截獲,跟上下文關係不大,e.g. select * from from table1; 語義錯誤需要上下文信息,比如庫表、字段是否合法?對於 Presto 而言,lambda 表達式出現的位置是否合法?瞭解語法、語義的區別,對問題的排查也是十分高效的。

  1. 聯邦查詢引擎 =========

異構數據源導致的問題:

爲此,我們引入 Presto 作爲聯邦查詢引擎,一方面利用多數據源能力,減少 ETL 相關工作量。另一方面,利用 Presto 的速度爲業務分析提速。本次介紹兩個數據源適配的工作:

4.1 tHive 連接器適配

Presto 的 Hive 連接器通過與 HMS(Hive MetaStore)通信獲取 Hive 庫表的位置信息,然後拉取數據。騰訊 tHive 有自己的一套鑑權體系 TAUTH,我們需要將這種鑑權機制引入到 Hive 連接器中。外部一般通過 Thrift RPC 協議與 HMS 通信。那麼如何加入鑑權能力呢?

  1. 獲取 Hive 庫表元數據

參考 Hive 連接器中Kerberos機制的實現(下圖),可以看到 rawTransport 作爲參數,用來構建一個新的 SaslTransport。

  1. KerberosHiveMetastoreAuthentication

結合TSaslClientTransport的源碼可以發現,這裏其實是計算機網絡分層思想的典型應用。在可靠傳輸層 rawTransport 的基礎上,再包裝了一個 Sasl 層。利用底層 rawTransport 提供的可靠傳輸能力,進一步提供安全策略。e.g. 某些 QoS 條件下,調用 Sasl 層的write(),會對數據進行加密,Sasl 進而調用下一層的write()函數,將加密後的數據發送到可靠的傳輸通道中。它們都實現了TTtransport接口,I/O 函數如下所示:

  1. 本質爲網絡協議棧

Sasl 層本身並不綁定特定的鑑權機制,它是一個框架。通過 JCA 註冊的鑑權機制都可以在運行時被指定。

  1. 鑑權機制插件化

所以如果想整合自定義的鑑權機制,需要註冊對應的SecurityProvider

  1. 底層原理

總結:對於小白來說,“爲 Hive 連接器增加一種鑑權機制” 是個很難理解的技術需求,通過前文的探索,我們發現其本質是:“如何在 HMS 的 Thrift RPC 中,爲 SASL 鑑權層增加一種自定義的安全協議。” 這裏的上下文比較多,需要對 HMS、THrift RPC、SASL、JCA、Kerberos 等概念有個大概的瞭解,才知道需要做什麼。對技術的提升還是很有幫助的。

4.2 ES 連接器踩坑

第二個 case:調研 ES 連接器的時候,發現 Presto 啓動時第一次連接 ES 集羣是成功的。但是後面哪怕沒有執行 ES 相關查詢也會無故報錯,堆棧信息顯示網絡連接失敗。

  1. 報錯信息

經過排查,發現與定時嗅探邏輯有關。Presto 底層依賴了 facebook 內部的Airlift後臺框架。在這個場景下,通過Bootstrap註冊的類會被生命週期管理器識別,@PostConstruct註解(Annotation)標記的函數會在類實例化後被自動調用。可以看到,一個refreshNodes()函數被定期調用了,該函數會獲取 ES 集羣中所有的可用節點 IP,並在下次將請求發送到其中一個節點。

  1. @PostConstruct

由於雲上 ES 集羣只開放了一個主節點的訪問端口,嗅探獲得的 IP 其實是不能用的。這也解釋了爲什麼第一次訪問是成功的(第一次訪問的主節點開放),後續訪問大概率是失敗的(其它節點端口不開放)。

  1. 自動嗅探邏輯

主要的改造就是禁用自動更新節點邏輯,位於ElasticSearchClint文件。在改造的過程中,發現已經有參數elasticsearch.ignore-publish-address可以滿足需求,但是在去年 8 月的時候 DB、SQL 的文檔裏竟然沒有記錄這個參數,github 上搜索一波發現已有 issue 了,目前社區已經補齊了文檔。

  1. 忽略嗅探 IP

總結:Airlift後臺框架雖然沒有文檔,但開發者還是要認真看。

  1. Oteam 共建 ===========

在去年,隨着 Presto 在騰訊內部的應用場景越來越多,爲了整合各部門的研發能力和技術成果,公司內部由 PCG 歐拉數據中臺牽頭髮起了 Presto Oteam 項目,主要 for Presto 引擎的研發。作爲資訊業務的數據工程同學,我們也有幸參與共建。Oteam 部分工作內容如下:

  1. Hive 語義兼容,函數遷移

  2. RBO/CBO 執行解析器

  3. Worker Tag 能力

  4. 分析函數開發

  5. 語法 / 語義擴展

  6. 動態數據源支持

  7. 查詢性能優化專項

  8. Coordinator 執行流程優化

  9. bug fix ...

限於篇幅,簡單介紹第一點:標量函數開發原理。

5.1 函數開發

不同於 Hive UDF 函數可以由用戶直接上傳,在 Presto 引擎中所有擴展部件都以插件形式被統一整合。除了最常見的連接器(Connector)插件以外,函數也是一種插件。如果業務需要自定義函數,就需要單獨開發函數插件。Presto 引擎自帶了很多函數,可以作爲開發者的參考。總共有兩種函數開發方式:

第一種方式需要使用 Presto 引擎的註解框架,官網給的例子比較簡單,各種註解搭配使用的方式實際比較複雜。同時函數的數據類型需要涉及到 Presto 引擎的SliceBlock等類型,有一定學習成本。第二種方式比較少見,而且不支持通過插件進行開發,只能寫到presto-main模塊中,它基於 Presto 自帶的字節碼框架動態生成字節碼(包com.facebook.presto.sql.gen),是比較 hack 的實現,可以參考ArrayConcatFunction

5.2 函數註解框架

以標量函數爲例。函數開發和普通的 Java 方法編寫本質上是一樣的,但是也有很多差異點:

我們把寫在函數體 / 類名上的註解稱爲函數註解,寫在函數形參前面的註解稱爲形參註解,方便下文引用。一般來說,關注前四點就夠了。後面是一些進階的使用技巧。

按註解類型區分:

vU0UJL

以下是官網 [2] 的一個例子:

public class ExampleNullFunction
{
    @ScalarFunction("is_null"calledOnNullInput = true)
    @Description("Returns TRUE if the argument is NULL")
    @SqlType(StandardTypes.BOOLEAN)
    public static boolean isNull(@SqlNullable @SqlType(StandardTypes.VARCHAR) Slice string)
    {
        return (string == null);
    }
}

對應剛剛說到的幾點:

再來看另外一個例子:

@ScalarFunction(name = "is_null"calledOnNullInput = true)
@Description("Returns TRUE if the argument is NULL")
public final class IsNullFunction
{
    @TypeParameter("T")
    @SqlType(StandardTypes.BOOLEAN)
    public static boolean isNullSlice(@SqlNullable @SqlType("T") Slice value)
    {
        return (value == null);
    }

    @TypeParameter("T")
    @SqlType(StandardTypes.BOOLEAN)
    public static boolean isNullLong(@SqlNullable @SqlType("T") Long value)
    {
        return (value == null);
    }

    @TypeParameter("T")
    @SqlType(StandardTypes.BOOLEAN)
    public static boolean isNullDouble(@SqlNullable @SqlType("T") Double value)
    {
        return (value == null);
    }

    // ...and so on for each native container type
}

可以看到,在函數體,多了@TypeParameter函數註解,引入了一個泛型變量T,可以在形參註解中被 @SqlType 引用。@SqlType註解的類型聲明爲T以後,這幾個函數的函數簽名都是一樣的。在Presto引擎看來,這幾個函數擁有相同的函數簽名,是一類函數。

其中,有很多細節的問題其實需要看源碼才知道需要怎麼寫。比如,細心的同學從上面兩個例子可以發現:

雖然 Presto 文檔只講了冰山一角,但是引擎內部自帶了很多函數,是非常有價值的參考資料。這裏有很多細節,需要看 Presto 源碼才能得到答案。以上只是註解的使用,具體這個自定義函數後續如何被 Presto 引擎解析,不關注問題也不大,註解寫錯了大部分 case 也會在插件裝載的時候被識別出來。推薦高階開發者看看ParametricScalarImplementation中標量函數的解析流程。

5.3 常用註解參考手冊

以下總結了註解框架中的一些常用註解。建議有一定基礎後作爲參考來看。

@ScalarFunction: 函數註解。定義函數的名稱,別名,可見性,純函數性,是否處理空值。

ygSZif

@Description: 函數註解。描述函數功能的字符串。在 presto 客戶端使用show functions命令可以查看。

@TypeParameter: 函數註解、形參註解。對於函數註解,聲明一個泛型變量,形參註解中的 @SqlType 可以使用它,在解析函數調用的時候會嘗試將泛型類型和具體類型進行綁定。對於形參註解,它引入一個依賴型參數。

@LiteralParameters: 函數註解、形參註解。對於函數註解,定義一些字面量變量,長整數類型。如果有@Constraint函數註解,則需要滿足它定義的表達式條件。對於形參註解,它引入一個依賴型參數。

@SqlType: 函數註解、形參註解。定義形參、返回值的 SQL 類型。大概可以分以下幾種:

9ju7hk

@SqlNullale:函數註解、形參註解。對於函數註解,表示返回值類型是否可能爲空。原始類型(e.g. int)不需要註解,包裝類型和其它類型需要聲明該註解。對於形參註解,如果該位置的實參是 null,依然執行函數體。默認情況遇到 null 直接返回 null。參考InterpretedFunctionInvoker的空值處理邏輯。

依賴型參數:一種形參註解。函數中需要用到的一些變量,從 SQL 語句自動推導而來。這些形參由框架處理,用戶不感知。

15.contains 函數

可以看到,contains函數有多種類型,但是函數簽名都是一樣的。由於在函數中需要根據實際類型來調用接口讀取元素,T 的實際類型必須通過形參的方式傳遞進來,但是用戶寫 SQL 的時候並不用顯式指定類型,因爲它可以自動推導出來,這裏涉及到 methodHandle 的綁定參數,就不詳細展開了。總之,雖然contains()有四個參數,但是用戶只感知最後兩個。

5.4 變長參數函數

一些變長參數的函數,比如 tHive 中的parse_simple_json函數,在 ETL 任務中一次調用解多個 key,是比較高效的。雖然是變長參數,但是這裏的變長,是相對不同用戶提交的 SQL 語句而言。而用戶每一次提交的 SQL,其實參數個數都是確定的,沒有必要用變長參數,e.g. 對於一個 SQL,代碼中的parse_simple_json(d4, 'key1', 'key2'),其實參數就是三個。函數聲明爲變長,但是實際中根據每個 SQL 語句轉成定長參數。針對這種情況,Presto 引擎並沒有使用註解框架。而是採用了比較 hack 的方式,直接定義一個內部函數類,裏面有一個形參爲數組的業務函數。通過引擎自帶的字節碼生成模塊,把它適配成一個定長參數函數。大概原理如下所示:

  1. 字節碼動態適配

最後附上標量函數註冊的流程圖,希望能對函數註冊的流程有更直觀的理解。

  1. 函數註冊流程

  2. 技術輸出 =======

從去年下半年開始入門 Presto 引擎開發,接觸下來感覺從零起步確實不易。雖然仔細搜索還是能找到一些不錯的資料,但是 Presto 相關的官方文檔相對於其他大數據組件來說是偏少的。比如基礎的 Airlift 框架,官方文檔僅有一句話介紹。爲了降低後續同事的學習成本,這裏特地把一些知識點梳理成腦圖(逐步完善中),也供大家參考。大多數子節點都能用一章的篇幅來展開描述,可見快速培養出一個優秀的 Presto 開發者還是不太容易。以後有機會我們也會輸出一系列技術文集。

  1. 基礎知識

  1. 執行相關概念

  2. 騰訊內部應用概覽 ===========

最後列出部分騰訊內部應用的 Presto 情況。

7.1 應用場景

7.2 合作生態

騰訊內部通過Oteam的方式來組織跨 BG / 部門的開源協同共建。目前和 Presto 關係比較密切的 Oteam 有 Alluxio、Iceberg、Impala。Alluxio 緩存技術已經在 TEG 的部分場景落地使用了。TEG 大數據團隊也和 Presto/Trino 社區同時提出了各自的 Iceberg Connector PR。Impala 在騰訊燈塔平臺已經有非常成熟的應用落地,未來和 Presto 一起加強對 MPP 引擎發展的探討。期待未來 Oteam 組織以及其它大數據團隊能有更深入的合作,助力 Presto 在更多業務中落地推廣。

  1. 後續計劃 =======

除了繼續在 Presto 引擎層面進行深耕優化,聯邦查詢引擎的應用層功能需要繼續豐富,還有很多用例需要去探索。基於數據分析同學的反饋,很多複雜的預處理邏輯以往需要 spark scala 或者 pyspark 進行處理,現在基本都可以用 Presto 代替了,後續如果能把模型訓練等調包流程整合到一起,也許能夠提供上手成本更低的數據分析體驗,也是一個值得探索的方向。最後,我們希望在服務好業務的前提下,進行一系列高質量的技術輸出來提升部門的技術影響力。

  1. 新書推薦 =======

封面第一印象:Presto 運行 SQL,就像青蛙喫蟲子一樣快?

本書的內容質量是毋庸置疑的。對於初學者來說,左手官網文檔,右手《Presto 實戰》進行入門應該是標準姿勢。其行文的層次性、結構性,內容的完整性、權威性,對高手來說是一本非常好的字典。推薦給有興趣的同學~

Matt Fuller、Manfred Moser、Martin Traverso 著
張晨 黃鵬程 傅宇 譯

  • SQL 領域重磅力作,Presto 官方指南

  • Presto 創始團隊、Kafka 聯合創作者推薦

  • 多位國內一線技術大咖力薦

  • 亞馬遜全五星好評

文章封面介紹:CH-54 Tarhe “塔赫”運輸直升機。雖然沒有運輸艙,僅是一個飛行載具,但是它可以靈活運輸集裝箱、火炮、輕型載具、直升機等物資,如果真要丟個炸彈,也是可以的。這和 Presto 存算分離的思想如出一轍,結合當前部門特性選擇了掛載醫療艙的 “塔赫” 作爲封面。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/7T00d_0inKXmXH-Su0Ncqg