· 閱讀時間 8 分鐘

為什麼資料庫 GUI 永遠不應直接寫入

每個資料庫 GUI 都有這樣一個時刻:一次點選變成了一次針對生產環境的寫入。我們決定讓這個時刻不再是預設。在 DynoTable 中,一次條目編輯、一次已暫存的刪除,以及一個 的改動都不會直接觸及 DynamoDB:它們各自會作為一份可審查的、逐屬性的 diff 落入一個,並且只有當人來提交時才會發出。仍有兩條特意保留的逃生通道會直接寫入,暫存文件點明瞭它們。

我們所追求的心智模型是 git:一個供審查的暫存區,以及一次行為類似 git push --force-with-lease 的提交 —— 只有當遠端仍然維持你上次所見的樣子時,它才會成功。這篇文章講的是其底層的工程:那個重塑了整個設計的跨標籤頁 bug、那次主要產出是_被刪除的程式碼_的重構,以及為什麼 AI 智慧體的到來把一項 UX 上的錦上添花變成了那面承重的安全牆。

寫入即 diff

編輯會累積在一個由本地 SQLite 支撐的暫存中,並在側邊欄中渲染成 diff 卡片 —— 舊值、新值,逐屬性呈現。網格中的行會染上色調,因此暫存狀態從資料本身就能看到,而不只是在面板裡。提交會把暫存集合作為 DynamoDB 發出,按服務的上限分塊(每個事務 100 項,並對每次操作和每次請求設有位元組預算),順序執行,每個分塊有 30 秒超時。

DynoTable 的暫存面板,將待提交的 DynamoDB 更改顯示為按屬性的差異卡片,支援逐行和批次提交。
DynoTable 的暫存面板,將待提交的 DynamoDB 更改顯示為按屬性的差異卡片,支援逐行和批次提交。

暫存文件介紹了日常工作流。它們沒有涵蓋的是暫存以什麼_作為鍵_ —— 而這最終成了整個系統中最具影響力的決定。

以表為鍵,而非以標籤頁為鍵

第一個版本將每個暫存的作用域限定在建立它的那個標籤頁上。這個設計催生了一個真實的 bug:AI 的暫存工具會從_當前活動標籤頁_推導它的目標,而它的守衛會跳過那些並非表檢視的標籤頁。因此,如果你在一個 SQL 工作臺標籤頁處於焦點時讓助手修正某一行,這次編輯就會被暫存進工作臺的暫存裡 —— 而“顯示暫存更改”的 chip 會開啟錯誤的標籤頁。

更糟的是,提交鎖也是按標籤頁劃分的。兩個檢視_同一張表_的標籤頁持有兩把獨立的鎖 —— 這意味著兩者可以並行地把同樣的暫存行提交到真實的 DynamoDB。一個針對生產環境的雙重提交競態,就這樣內建進了資料模型裡。

修復方案是把一切都改為以表身份 —— {profile, region, tableName} —— 而非以標籤頁為鍵:

  • 每張表一個暫存,從它的每一個檢視都能看到,並在標籤頁關閉再重開後依然存續。AI 甚至可以在完全沒有開啟表標籤頁的情況下暫存。
  • 每張表一把提交鎖。雙重提交競態並非被“處理”了 —— 它根本無法被表示。
  • 那一類錯標籤頁的 bug 已經_從構造上_消失了:鍵里根本沒有標籤頁。

而這次重新設鍵刪除的程式碼比它新增的還多。那個在啟動時為已關閉標籤頁回收暫存的清掃過程?一位評審者指出它在新模型下是在破壞功能 —— 暫存不再隨標籤頁消亡 —— 於是它被徹底移除了。關閉標籤頁即丟棄及其確認對話方塊:移除了。唯一真正剩下的孤兒是一個被刪除的連線設定檔案,它會被顯式地級聯清理。

這次遷移本身是本倉庫第一個會對行進行_變更_的遷移:一次手寫的 SQLite 表重建,用一個 ROW_NUMBER() OVER (PARTITION BY …) 視窗(最新的編輯勝出)對遺留的按標籤頁劃分的行去重,並用 char(0) 分隔符回填新的鍵,與執行時的鍵構造器逐位元組一致。對行進行變更的遷移在那天有了自己的先種子後遷移的測試框架。

--force-with-lease,用於 DynamoDB

如果你審查的東西並不是最終被寫入的東西,那麼審查介面就毫無價值。在暫存與提交之間,可能已經有別人改動了那一行。因此每一次提交操作都攜帶,把寫入釘死在你所審查的那個確切快照上 —— ,逐個屬性地進行:

  • 一次建立會斷言該項尚不存在。
  • 一次更新會斷言你正在改動的每一個屬性仍然保持著你暫存它時所看到的值。
  • 甚至一次移除也會斷言該屬性仍然等於它的舊值 —— 如果在你對 note 的移除處於暫存狀態時,一位隊友把 note"old" 改成了 "new",那麼這次提交絕不能悄無聲息地刪掉他們的編輯。

當某個條件不成立時,DynamoDB 會取消整個事務,並告訴我們是哪一項發生了漂移。那一行會得到一個衝突橫幅 —— 針對實時值進行變基,或是中止 —— 而不是在任一方向上悄無聲息地覆蓋。

而在任何分塊失敗時,提交器會停下。已提交的分塊保持已提交,失敗的分塊原子性地回滾,不再嘗試後續任何操作。我們刻意拒絕了盡力而為式的繼續:一個越過沖突繼續寫入的審查工具,正在應用一份無人審查過的變更集。

人工审查差异TransactWriteItems +逐属性条件条件不成立变基编辑 / AI stageItem暂存区按表划分提交DynamoDB冲突横幅:变基或中止

那次刪除了一個子系統的重構

提交是長時間執行的:應用會把每一次提交登記進一個重放登入檔,這樣一次渲染程序過載 —— 或是第二個視窗 —— 就能重新掛接並看著它完成。隨著時間推移,三條不同的程式碼路徑各自為每次提交掛接了_兩個_觀察者,而圍繞一個問題生長出了一整套仲裁機制:哪個觀察者被允許釋放提交鎖?它有自己嚇人的名字(ownsLockOnBeforeRegistration)、一段解釋某個 IPC 順序競態的註釋塊,以及一個 230 行的 toast 追蹤器。

修復方案是一個單一的所有者:一個提交會話(Commit Session),它每次提交只掛接一次,獨佔地持有那把鎖,把進度投射進 store,並保證它的啟動呼叫在每一條退出路徑上都會了結 —— 絕不掛起,絕不拒絕。那套仲裁機制和那個追蹤器並沒有被搬走;它們被刪除了。那個反覆衝擊暫存面板的記憶體基準測試,其堆佔用下降了大約三分之一。那個月我們收到的最好的程式碼評審評論是:“這個 PR 基本上都是紅色的。”

從這個所有權模型中自然推出兩個行為 —— 我們認為它們對於任何寫入生產環境的工具都是入場底線:一次進行中的提交能在渲染程序過載中倖存(會話會重新掛接並完成),它甚至能在你的許可證於提交中途翻轉為唯讀時倖存 —— 新的提交會被阻止,但一次已經在途的寫入會被觀察到底,絕不會在只應用了一半的狀態下被拋棄。

為什麼線格式是刻意做得醜陋的

暫存的值以原始的 DynamoDB 帶型別信封形式儲存 —— {N: "42"}{S: "42"} —— 而不是友好的、已 unmarshall 的 JSON。有兩個原因。diff 必須是_型別感知_的:{N: "1"}{S: "1"} 是不同的值,而一個基於已 unmarshall 值構建的主索引鍵雜湊會把它們相撞。而且往返必須是無損的:SDK 的已 unmarshall 數值包裝器無法在序列化為 JSON 存入 SQLite 再取回的過程中存續,對照使用者輸入的深度相等比較也會因此失效。提交路徑出於同樣的原因使用原始用戶端 —— 你所審查的,逐位元組就是最終被作為條件判斷並被寫入的東西。

然後智慧體來了

暫存的出現早於我們的 AI 功能,但它正是這些功能得以交付的原因。助手的寫入工具會暫存;它自己的描述逐字地告訴模型:

The user reviews + commits from the staging panel —
this tool never writes to DynamoDB directly.

根本沒有提交工具。不是受許可權門控,不是被審計 —— 而是_不存在_。MCP 伺服器在結構上暴露了同樣的邊界:它中間那一檔同意許可權範圍就直白地命名為“讀取 + 暫存”。一個處於該許可權範圍、被的智慧體可以提議一堆垃圾,而其爆炸半徑不過是一張人來閱讀的 diff 卡片。而且由於提交攜帶逐屬性的樂觀鎖,即便一個陳舊的智慧體編輯也無法悄無聲息地砸掉一個並行的人類編輯 —— 它會像其他一切一樣浮現為一個衝突。

我們寫過如何讓智慧體的查詢變得可靠;暫存則是另一半 —— 讓它的_寫入_變得平淡無奇。

對任何寫入你資料庫的工具,你應當要求什麼

  • 在意圖與寫入之間有一個審查步驟 —— 用 diff,而不是確認對話方塊。
  • 針對_所審查的快照_的樂觀並行控制,包括刪除和移除 —— 絕不是最後寫入者勝出。
  • 原子批次,失敗即停,而不是越過沖突盡力而為地繼續。
  • 一條能在崩潰或過載中倖存、不會留下只應用了一半的集合的寫入路徑。
  • 對於 AI:把暫存作為智慧體所擁有的_唯一_寫入原語 —— 一個缺失的能力勝過一個受守衛的能力。

暫存文件展示了工作流,而編輯 DynamoDB 資料介紹了它所保護的那些基礎操作。或者下載 DynoTable,用 ⌘S 暫存幾處編輯,看著一次批次更改變成某種在它發生之前你真的能讀懂的東西。

不必透過主控台就能操作 DynamoDB

一款快速的 DynamoDB 桌面用戶端,可執行 DynamoDB 無法執行的真正 SQL — JOINs、GROUP BY、聚合 — 並支援視覺化編輯與使用你自己的 Bedrock 金鑰的 AI 代理。

30 天免費試用,無需信用卡 — 之後為無時間限制的免費方案。