我們如何為 DynamoDB 打造一個本機 SQL 引擎(一個沒有 JOIN 的資料庫)
DynamoDB 能匯出一張 100 GB 的表格,卻無法對它做聚合。沒有
JOIN、沒有 GROUP BY、沒有 COUNT(*) — 不是它們的慢速版本,而是
根本沒有 — 而就連 ,AWS 自家那個近似 SQL 的介面,
也沒有加上它們。於是每個團隊最後都會寫出
同一段用完即丟的腳本:對一個 分頁、把資料列摺疊進一個
map、印出答案、刪掉腳本。
DynoTable 的 Workbench 就是我們對那段腳本的回應:
一個分頁,讓你寫一句真正的 SELECT — 多表 JOIN … ON、WHERE、
GROUP BY、HAVING、DISTINCT、CASE、聚合函式 — 對著一張完全
不支援這些的表格執行。這篇文章談的是它底層如何運作、那個默默對
我們說謊的剖析器,以及為什麼我們後來把引擎的記憶體模型整個拆掉。
別打造查詢引擎
核心決策是拒絕自己寫一個關聯式執行器。DynamoDB 的分頁會串流進一個 內嵌的 SQLite 資料庫,而由 SQLite — 地表上受測最徹底的查詢引擎 之一 — 來做關聯式的工作:
每個 DynamoDB 項目都會以它原始的型別信封形式({platform: {S: "ios"}})
落進 SQLite 的單一 item 欄位裡。一個小小的使用者定義函式會在查詢時
解開屬性,所以你的 SELECT platform 會編譯成一次
attr(item, 'platform', …) 呼叫。那個函式裡有一個細節是承重的:
它把它的 JSON 剖析包在一個 catch 裡,因為 SQLite 綁定會把某一列格式
錯誤所引發的剖析錯誤,變成對整條已備妥語句的中止。少了那個 catch,
一列損毀的資料就會殺掉整個查詢。
我們保留的那個誠實的限制:DynamoDB 的存取模式物理法則依然適用。
一個 JOIN 的目標端必須是一個主索引鍵或一個 分割區
索引鍵 — 引擎是靠對串流做鍵查找來解析 join,絕不靠交叉乘積掃描。
如果你需要在非鍵屬性上做任意 join,那是一場建模的討論,
而不是查詢引擎的功能。

那個對我們說謊的剖析器
第一版用了一個熱門的現成 SQL 剖析器。在短短幾週內,有四個各自 獨立的正確性 bug 都追溯回它身上:
- **
SELECT DISTINCT被默默丟棄了。**剖析器接受它,而發射器卻忽略 它 — 每一列重複的資料都回來了。一位使用者的 bug 回報寫著:「顯示的 是一張列出所有值的表格,而不是計數。」 - 任何未經處理的東西都會變成字面上的 SQL
NULL。CASE、CAST、 純量子查詢 — 發射器對於未知語法節點的後備做法是return 'NULL'。 查詢照跑、回傳信心滿滿的錯誤答案,且不拋出任何錯誤。 - 識別字的大小寫行為不像 SQL。當屬性是
platform時,SELECT PLATFORM FROM t卻回傳 null — 這與每一個 SQL 資料庫訓練你 去預期的恰恰相反。 - 自動完成與解析器意見相左,它會建議一些編譯器接著會無法解析的 名稱。
那個替代品 sql-parser-cst 靠著一個我們在調查中別處都找不到的原語
勝出:它的樹同時保留了每一個識別字的原始文字與正規化名稱 —
於是編譯器能夠分辨 platform 與 "PLATFORM"。光是這一個區別,就讓
單一套文法能承載正確的 ANSI 語意:未加引號的識別字以不分大小寫的
方式解析,加了引號的則是區分大小寫的逃生口,就跟 Postgres 或 SQLite
一模一樣。它還在每個節點上帶了一段原始碼範圍,所以診斷訊息會在出
問題的構造底下畫線,而不是標示整條語句。
而我們用一條如今被我們奉為政策的規則,取代了那個 return 'NULL'
後備做法:絕不、永遠不要出現默默的 NULL。發射器的預設分支是
一個帶精確範圍的錯誤。視窗函式會得到一則說明原因、而不只是說
「不行」的訊息:
Window functions are not supported in Workbench.
They have undefined semantics on the joined-row materialization.我們接受的取捨:一個 1.0 之前的相依套件,釘死在確切的版本,只以手動 方式、並以測試套件作為關卡來升級。我們評估過為舊剖析器打補丁(它 需要一個並行的預先詞法分析器 — 等於把每一筆輸入剖析兩次)、使用 編輯器的語法樹(僅到 token 層級,沒有子句結構),以及自己手刻(幾乎 等於一個完整的 SQLite 剖析器,維護成本無邊無際)。一次四重的正確性 失敗,勝過上述所有這些顧慮。
知道什麼不該擋
有一則診斷刻意不攔住你。當一個 filter 參照到一個 schema 無法確認 其大小寫的屬性時,我們無從得知它的正規大小寫 — DynamoDB 的伺服器端 filter 是區分大小寫的,而它的 schema 只宣告鍵屬性。那會發出一則警告, 而警告永遠不會停用「執行」。我們在驗證器的工作中, 用慘痛的方式學到了這類教訓:一個會拒絕最常見之正當查詢形態的關卡, 比完全沒有關卡還糟。
上限的形狀是錯的
第一版聚合引擎把掃描到的資料列緩衝進一個記憶體內的 SQLite,並 設有大小上限 — 這裡 64 MB、那裡 250 MB,還有一條完全沒有上限、 可能在大表上讓應用程式 OOM 的路徑。一旦碰到上限,你得到的就是一個 帶著溢位旗標的部分答案。
這次重新設計始於一次重新定位:**競爭對手,是開發者原本會改寫的那段 用完即丟的腳本。**而一段稱職的腳本不會先緩衝再設上限 — 它會串流, 用有限的記憶體把每一個分頁摺疊進一個持續累計的總量裡。記憶體內的 上限並不是太小;它的形狀就是錯的。更糟的是,一個部分的聚合結果並 不是「不完整」— 它是錯的。讓這件事變得具體的那一刻:我們自己的 AI 示範,信心滿滿地把一份部分的「消費最高者」排名當成事實來敘述。
重建後的 Compute 引擎,運作方式就像那段腳本,再加上那段腳本從來 懶得處理的一切:
- 一個規劃器會把每一個查詢分類到三條路徑之一:串流摺疊(單純的
代數式聚合 —
COUNT/SUM/AVG/MIN/MAX— 在主程序之外 逐頁摺疊;大小永遠不會擋住一個可摺疊的查詢,只會讓它花更久)、 實體化(排序、以鍵為界的 join — 溢寫到以磁碟為基底的 SQLite, 沒有記憶體上限),或是一次誠實的拒絕,針對我們無法服務的長尾, 推薦正確的重量級工具(DynamoDB 的 S3 匯出加上 Athena)。 - 摺疊的分類刻意設得很窄。一個
HAVING、一個ORDER BY、聚合 旁邊的一個裸欄位 — 其中任何一個都會強制走實體化路徑。一次忽略了 某個子句、卻仍回報「精確」的摺疊,正好就是這個規劃器存在的目的所 要防範的那種「錯得很有信心」的 bug。 - 精確性是結果的一部分。聚合欄位在分頁仍在串流時會顯示一個
partial(部分)徽章,並且只有在掃描抽乾之後才拿掉它。就連算術上
的誠實也被追蹤:整數總和在超過 2⁵³ 之後仍維持精確(以 64 位元整數
儲存),而一個超過浮點尾數的分數總和則會把自己標記為
partial: precision,而不是默默地捨入。 - 對機器而言,是保險絲取代了彈出視窗。互動式的應用程式會在一次 五百萬項目的掃描之前先詢問;而 AI 助理與 MCP 呼叫端 得到的則是掃描預算與分頁邊界中止。而且模型所看到的結果封裝是刻意 精簡過的 — 成本等級與大小估計都被保留不給,因為把一個過時的猜測 攤出來,等於是在誘使模型把它當成事實來敘述。
一則在內部讓大家意外的成本註記:對於一次性的聚合,一次即時掃描大約 比產生一份 DynamoDB→S3 匯出便宜 6 倍 — 這正是為什麼那條腳本形狀 的路徑是預設值,而資料倉儲是針對重複性的重度分析的推薦做法,而不是 反射動作。(定價計算器會為你 自己表格的一次完整掃描算出價格。)
把 SQL 指向正式環境安全嗎?
Workbench 在結構上是唯讀的。最終抵達本機引擎的 SQL 會通過一道縱深
防禦的關卡,它會做詞法分析,並拒絕任何不是單一 SELECT 的東西 —
而且是把它當成一次契約違反,而非使用者錯誤,因為編譯器本來就絕不
該產生它。那個 SQLite 基底在執行時停用了危險的內建功能,而在整個
應用程式裡,寫入 DynamoDB 只有恰好一條路徑:那個
可審閱的暫存區,而 SQL 觸及不到它。
如果你正在打造這樣一個引擎,有哪些可以沿用
- 別寫一個查詢引擎;改寫一個忠實的編譯器,把東西編譯到一個已經 存在的引擎上。你的 bug 會住在接縫處(識別字大小寫、型別信封、 損毀的資料列),而不在 join 演算法裡。
- 向你的剖析器要求「引號來源」。如果它無法分辨
name與"NAME", 你就無法實作 SQL 的大小寫語意,而你的使用者會比你更早發現這件事。 - 讓「未處理的語法」成為一個大聲的錯誤,絕不要當成一個預設值。默默
的
NULL正是信心滿滿的錯誤答案得以出貨的原因。 - 拿你的設計去對照你的使用者原本會寫的那段用完即丟的腳本做基準 比較。如果那段腳本會串流而你卻在緩衝,那你打造的就是錯的形狀。
- 把部分的聚合結果當成需要標記的錯誤答案,而不是可以拿到部分分數的 成果。
Workbench 說明文件涵蓋了日常的功能面向,而
DynamoDB 的 SQL則盤點了在整個生態系裡有
哪些做法行得通。或者直接下載 DynoTable,用 ⌘⌥Q
開啟一個 Workbench 分頁,對著那個根本沒有 JOIN 的資料庫執行一句
JOIN。


