建Go數(shù)據(jù)庫(kù)客戶(hù)端GoNavi:性能優(yōu)化與架構(gòu)實(shí)踐)
最近一段時(shí)間我?guī)缀趺刻於家趲讉€(gè)不同環(huán)境的數(shù)據(jù)庫(kù)之間切來(lái)去。寫(xiě)業(yè)務(wù)聯(lián)調(diào)要連開(kāi)發(fā)庫(kù)排查線(xiàn)上問(wèn)題要掛只讀賬號(hào)偶爾還要導(dǎo)一份脫敏數(shù)據(jù)給同事做本地測(cè)試。手邊的工具要么笨重得像個(gè)簡(jiǎn)化版 IDE要么只是個(gè)簡(jiǎn)陋的命令行外殼于是我用 Go 從零攢了一個(gè)內(nèi)部工具取名 GoNavi。它是一款現(xiàn)代化、高性能的數(shù)據(jù)庫(kù)管理工具定位很明確啟動(dòng)快、內(nèi)存省、多環(huán)境切換不折騰。這篇文章記錄的是我實(shí)際踩過(guò)的坑和做過(guò)的取舍適合正在做同類(lèi)工具、或者想了解數(shù)據(jù)庫(kù)客戶(hù)端核心設(shè)計(jì)的同學(xué)參考。1. 為什么在“不缺數(shù)據(jù)庫(kù)客戶(hù)端”的年代我還想做 GoNavi市面上能連數(shù)據(jù)庫(kù)的工具其實(shí)不少有老牌的桌面客戶(hù)端也有網(wǎng)頁(yè)版控制臺(tái)還有各種以插件形式塞進(jìn)開(kāi)發(fā)環(huán)境里的擴(kuò)展。但真正用起來(lái)我還是經(jīng)常覺(jué)得哪里不對(duì)勁。不是功能不夠而是“太重”和“太慢”這兩個(gè)問(wèn)題常年存在。1.1 真實(shí)場(chǎng)景里的三個(gè)使用痛點(diǎn)先說(shuō)啟動(dòng)速度。有些桌面客戶(hù)端打開(kāi)就需要幾十秒中間還要檢查更新、加載一堆插件。很多時(shí)候我只是想跑一條select * from config limit 10卻要陪它完成一輪完整的“開(kāi)機(jī)儀式”。時(shí)間久了自然不愿意打開(kāi)它。再說(shuō)內(nèi)存占用。掛著數(shù)據(jù)庫(kù)客戶(hù)端寫(xiě)代碼它比我的編譯器還吃?xún)?nèi)存。我記得某次開(kāi)會(huì)時(shí)看了一眼活動(dòng)監(jiān)視器光是這一個(gè)工具就占了大幾百兆而它的主要任務(wù)只是讓我查看一張只有幾千行的表。這種開(kāi)銷(xiāo)放在今天確實(shí)說(shuō)不過(guò)去。最后是多環(huán)境切換。一個(gè)項(xiàng)目的連接信息可能有開(kāi)發(fā)、測(cè)試、預(yù)發(fā)、生產(chǎn)好幾套每套下面還有多個(gè)庫(kù)、多套賬號(hào)。傳統(tǒng)工具把這些全堆在連接列表里找起來(lái)累記密碼又擔(dān)驚受怕。一旦切換環(huán)境不小心連錯(cuò)庫(kù)后果就很尷尬。我需要的是“一眼看清我在哪個(gè)環(huán)境、連的是哪臺(tái)機(jī)器”的工具而不是一個(gè)越來(lái)越亂的通訊錄。1.2 GoNavi 給自己劃定的邊界不做一個(gè)“小 IDE”很多數(shù)據(jù)庫(kù)管理工具都在拼命加功能畫(huà) ER 圖、做團(tuán)隊(duì)協(xié)作、做數(shù)據(jù)字典、甚至內(nèi)置代碼生成器。功能多當(dāng)然好可代價(jià)是體積膨脹、邏輯復(fù)雜、調(diào)試?yán)щy。我一開(kāi)始就明確GoNavi 不做這些。它只解決四件高頻事連接管理、SQL 查詢(xún)、數(shù)據(jù)瀏覽、導(dǎo)入導(dǎo)出。至于復(fù)雜的數(shù)據(jù)庫(kù)設(shè)計(jì)、持久化腳本調(diào)試、團(tuán)隊(duì)權(quán)限審批那些已經(jīng)有更專(zhuān)業(yè)的平臺(tái)在負(fù)責(zé)。一個(gè)工具把常見(jiàn)場(chǎng)景做到足夠順暢比什么都做但每個(gè)都卡要強(qiáng)得多。這個(gè)邊界在開(kāi)發(fā)中給了我很大幫助。拒絕功能需求不會(huì)得罪用戶(hù)但交付一個(gè)好用的小工具遠(yuǎn)比交付一個(gè)未完成的“全家桶”更讓人放心。1.3 面向的用戶(hù)與使用場(chǎng)景GoNavi 的目標(biāo)用戶(hù)是每天都要跟數(shù)據(jù)庫(kù)打交道的開(kāi)發(fā)者、測(cè)試和運(yùn)維。它適合的場(chǎng)景包括打開(kāi)后三秒內(nèi)完成一次 SQL 查詢(xún)同時(shí)管理多套環(huán)境的連接信息但不會(huì)弄混需要把查詢(xún)結(jié)果導(dǎo)出為 CSV、SQL 或 JSON在性能敏感的大結(jié)果集瀏覽中不卡界面。一句話(huà)總結(jié)工具應(yīng)該“用完即走”而不是把用戶(hù)留在里面。2. GoNavi 的架構(gòu)分層重量被藏在了看不見(jiàn)的地方想明白要做什么之后最重要的事就是怎么搭骨架。數(shù)據(jù)庫(kù)管理工具本質(zhì)上是個(gè)“數(shù)據(jù)庫(kù)客戶(hù)端 復(fù)雜交互界面”的組合技術(shù)選型直接影響啟動(dòng)速度、內(nèi)存占用和跨平臺(tái)體驗(yàn)。2.1 核心選擇為什么后端只考慮 Go我?guī)缀鯖](méi)猶豫就選了 Go。原因有三點(diǎn)。第一靜態(tài)編譯。Go 能把整個(gè)后端編譯成一個(gè)獨(dú)立可執(zhí)行文件不依賴(lài)運(yùn)行環(huán)境放到哪臺(tái)機(jī)器上都能跑。對(duì)于一款要分發(fā)給不同開(kāi)發(fā)同學(xué)的工具來(lái)說(shuō)這比“先裝運(yùn)行時(shí)再裝工具”友好太多。第二并發(fā)模型。數(shù)據(jù)庫(kù)管理工具要同時(shí)維護(hù)多個(gè)連接、執(zhí)行多家查詢(xún)、處理異步導(dǎo)入導(dǎo)出任務(wù)。Go 的 goroutine 和 channel 讓這類(lèi)并發(fā)代碼寫(xiě)起來(lái)很自然而且單個(gè) goroutine 的內(nèi)存開(kāi)銷(xiāo)極小幾萬(wàn)路并發(fā)也能扛住。第三性能數(shù)據(jù)好觀(guān)察。Go 自帶內(nèi)存分析和 CPU 分析工具我能在開(kāi)發(fā)階段直接看到是哪個(gè)函數(shù)吃掉了內(nèi)存、哪段代碼拉長(zhǎng)了耗時(shí)。對(duì)于一個(gè)把“高性能”當(dāng)賣(mài)點(diǎn)的工具可觀(guān)測(cè)性非常重要。2.2 前后端分離但不分離部署內(nèi)嵌靜態(tài)資源方案Go 負(fù)責(zé)后端但數(shù)據(jù)庫(kù)管理工具的前端交互同樣重要?,F(xiàn)代數(shù)據(jù)庫(kù)客戶(hù)端都很重視表格展示、SQL 補(bǔ)全、主題切換這些體驗(yàn)這些能力用 Web 前端做最方便。所以 GoNavi 采用了“本地后端 內(nèi)嵌 Web 前端”的結(jié)構(gòu)Go 程序啟動(dòng)后拉起一個(gè)本地 HTTP 服務(wù)同時(shí)打開(kāi)一個(gè)本地 Web 界面。前端資源打包時(shí)被壓縮后塞進(jìn)二進(jìn)制文件里用戶(hù)拿到的依然是一個(gè)可執(zhí)行文件不用單獨(dú)部署前端。這種架構(gòu)的優(yōu)勢(shì)是內(nèi)存可控、渲染性能好。Go 后端負(fù)責(zé)數(shù)據(jù)庫(kù)連接和 SQL 執(zhí)行絕不阻塞界面Web 前端只負(fù)責(zé)展示和交互遇到大數(shù)據(jù)量時(shí)用虛擬滾動(dòng)表格渲染。前端不能直接接觸數(shù)據(jù)庫(kù)所有請(qǐng)求都經(jīng)過(guò)后端的一個(gè)本地接口天然隔離了風(fēng)險(xiǎn)。2.3 數(shù)據(jù)庫(kù)適配層用接口隔離方言差異連接不同的數(shù)據(jù)庫(kù)最麻煩的不是網(wǎng)絡(luò)協(xié)議而是 SQL 方言差異。字段引用符不一樣、分頁(yè)語(yǔ)法不一樣、事務(wù)隔離級(jí)別命名也不一樣。如果每接入一種引擎就把界面邏輯復(fù)制一份代碼很快就會(huì)腐爛。我在 GoNavi 里定義了一個(gè)很薄的適配層type Dialect interface { QuoteIdent(name string) string BuildLimit(offset, limit int) string IsConnError(err error) bool }QuoteIdent負(fù)責(zé)把表名、字段名按不同引擎的規(guī)則包上反引號(hào)、雙引號(hào)或方括號(hào)BuildLimit根據(jù)引擎生成分頁(yè)語(yǔ)句IsConnError用來(lái)判斷某個(gè)報(bào)錯(cuò)是不是屬于連接斷開(kāi)從而決定是否需要重連。這套抽象不需要很復(fù)雜只要能擋住 80% 的方言差異就夠。真正關(guān)鍵的 SQL 仍然由用戶(hù)自己寫(xiě)工具只負(fù)責(zé)把通用的“外殼”生成對(duì)。接入第二種引擎時(shí)我只新增一個(gè)實(shí)現(xiàn)文件界面代碼一行不用動(dòng)這為后來(lái)擴(kuò)展節(jié)省了大量時(shí)間。3. 性能瓶頸排查GoNavi 從“能跑”到“流暢”的優(yōu)化實(shí)錄功能跑通只是第一步。我第一次把 GoNavi 給身邊同事用時(shí)收到的反饋是“能用但一旦數(shù)據(jù)量大一點(diǎn)就卡”。于是進(jìn)入優(yōu)化階段這個(gè)階段讓我意識(shí)到數(shù)據(jù)庫(kù)客戶(hù)端的性能問(wèn)題大多出在三個(gè)地方連接管理、結(jié)果集讀取、界面元數(shù)據(jù)刷新。3.1 從“連接頻繁關(guān)閉”到“連接池管理”最初版實(shí)現(xiàn)很直接每次執(zhí)行 SQL 都新建連接執(zhí)行完就關(guān)閉。這種方式在測(cè)試環(huán)境沒(méi)問(wèn)題可一旦同時(shí)打開(kāi)多個(gè)查詢(xún)窗口再配上幾個(gè)后臺(tái)刷新任務(wù)連接數(shù)就會(huì)暴漲。數(shù)據(jù)庫(kù)端很快開(kāi)始報(bào)錯(cuò)前端表現(xiàn)為“某個(gè)查詢(xún)莫名其妙失敗”。排查后發(fā)現(xiàn)每個(gè) goroutine 都維護(hù)一個(gè)獨(dú)立連接而 goroutine 數(shù)量是隨著查詢(xún)窗口數(shù)量線(xiàn)性增長(zhǎng)的。我參照常見(jiàn)的連接池思路給 GoNavi 加了一個(gè)連接管理器每個(gè)數(shù)據(jù)庫(kù)連接歸入池中按 host、port、用戶(hù)名、數(shù)據(jù)庫(kù)名分組執(zhí)行 SQL 時(shí)從池中借用連接空閑超過(guò)設(shè)定時(shí)間才自動(dòng)回收連接池容量默認(rèn) 20上限可調(diào)避免并發(fā)打滿(mǎn)數(shù)據(jù)庫(kù)端連接數(shù)每個(gè)連接掛一個(gè) busy/idle 標(biāo)記防止同一連接被并發(fā)復(fù)用。加上連接池之后同樣的并發(fā)查詢(xún)?cè)僖膊粓?bào)錯(cuò)系統(tǒng)中最明顯的 CPU 峰值也消失了。這個(gè)改動(dòng)其實(shí)不復(fù)雜但收益立竿見(jiàn)影。3.2 從“結(jié)果集全量加載”到“流式分片傳輸”第二個(gè)瓶頸是內(nèi)存。有一次我在測(cè)試環(huán)境執(zhí)行一條查詢(xún)返回了十幾萬(wàn)行數(shù)據(jù)。GoNavi 直接把所有行讀進(jìn)內(nèi)存后再一次性發(fā)送給前端結(jié)果內(nèi)存直接飆高瀏覽器標(biāo)簽頁(yè)開(kāi)始轉(zhuǎn)圈等了半分鐘才把表格渲染出來(lái)。數(shù)據(jù)庫(kù)驅(qū)動(dòng)確實(shí)提供了行迭代接口關(guān)鍵是我沒(méi)有好好利用它。改用流式讀取之后GoNavi 每次只讀取一批數(shù)據(jù)比如兩百行就把這批數(shù)據(jù)通過(guò)本地 WebSocket 推給前端前端邊收邊渲染。整個(gè)過(guò)程中后端內(nèi)存不會(huì)隨著結(jié)果集行數(shù)線(xiàn)性增長(zhǎng)。rows, err : db.QueryContext(ctx, sql) if err ! nil { return err } defer rows.Close() batch : make([]map[string]any, 0, 200) for rows.Next() { row : scanRow(rows) batch append(batch, row) if len(batch) 200 { stream - batch batch make([]map[string]any, 0, 200) } } if len(batch) 0 { stream - batch }這對(duì)“瀏覽大結(jié)果集”的場(chǎng)景幫助極大。用戶(hù)拖到表格底部時(shí)前端會(huì)發(fā)起下一批加載請(qǐng)求感覺(jué)上像在操作普通表格但實(shí)際內(nèi)存占用很穩(wěn)定。3.3 從“每次自動(dòng)補(bǔ)全都查庫(kù)”到“本地元數(shù)據(jù)緩存”還有一個(gè)隱秘的卡頓來(lái)源是 SQL 編輯器。當(dāng)我輸入表名或字段名時(shí)需要自動(dòng)補(bǔ)全提示。最初版本只要輸入變化就向數(shù)據(jù)庫(kù)請(qǐng)求表結(jié)構(gòu)、字段列表導(dǎo)致輸入一個(gè)長(zhǎng) SQL 的過(guò)程里會(huì)觸發(fā)幾十次接口查詢(xún)。網(wǎng)絡(luò)慢一點(diǎn)整個(gè)編輯器就跟著卡。解決方案是做一層元數(shù)據(jù)緩存連接建立成功后GoNavi 在后臺(tái)異步讀取當(dāng)前庫(kù)的表清單、字段清單、索引等基本信息保存到內(nèi)存里自動(dòng)補(bǔ)全時(shí)直接查內(nèi)存緩存不再實(shí)時(shí)請(qǐng)求數(shù)據(jù)庫(kù)。只有當(dāng)用戶(hù)主動(dòng)點(diǎn)擊“刷新元數(shù)據(jù)”或連接超過(guò)一定時(shí)間沒(méi)有使用才會(huì)重新同步。這個(gè)調(diào)整讓 SQL 編輯器的輸入響應(yīng)從幾百毫秒降到個(gè)位數(shù)毫秒。更重要的是它避免了一個(gè)必然發(fā)生的尷尬在數(shù)據(jù)庫(kù)負(fù)載較高的時(shí)候用戶(hù)每敲一個(gè)字母都會(huì)給生產(chǎn)環(huán)境增加一次查詢(xún)這個(gè)影響在多人同時(shí)使用時(shí)會(huì)被放大。3.4 優(yōu)化效果的量化指標(biāo)優(yōu)化完成后我在一個(gè)內(nèi)部模擬項(xiàng)目 X 上跑了一組對(duì)比測(cè)試?yán)鋯?dòng)、空閑內(nèi)存、單次加載十萬(wàn)行結(jié)果集、同時(shí)發(fā)起十個(gè)查詢(xún)。結(jié)果放在后面第 6 部分集中展示這里先劇透結(jié)論流式讀取和連接池是最關(guān)鍵的兩個(gè)改動(dòng)沒(méi)有它們其他優(yōu)化都只是錦上添花。4. 一個(gè)可用版本是怎么長(zhǎng)出來(lái)的GoNavi 落地全過(guò)程從零到可交付功能順序很重要。我復(fù)盤(pán)了一下整個(gè)項(xiàng)目大致經(jīng)歷了七個(gè)階段。每個(gè)階段都有明確驗(yàn)收標(biāo)準(zhǔn)不貪多確保每一步都能獨(dú)立運(yùn)轉(zhuǎn)。4.1 第一階段用 REPL 驗(yàn)證核心鏈路不碰 GUI啟動(dòng)一個(gè)桌面工具之前我先寫(xiě)了一個(gè)極簡(jiǎn)的 REPL 命令行程序。它能讀入用戶(hù)輸入、建立數(shù)據(jù)庫(kù)連接、執(zhí)行 SQL、把結(jié)果以文本表格打印到終端。這個(gè)階段的好處是核心鏈路被壓到了最小范圍連接字符串解析、執(zhí)行查詢(xún)、讀取結(jié)果。任何一端出了問(wèn)題我都只需要在一個(gè)純文本環(huán)境里調(diào)試不用跟界面渲染、窗口事件糾纏。等到 REPL 能穩(wěn)定跑通增刪改查我才開(kāi)始設(shè)計(jì)界面。4.2 第二階段一個(gè)引擎走通再引入第二種引擎很多人的誤區(qū)是一開(kāi)始就追求“支持七八種數(shù)據(jù)庫(kù)”。GoNavi 反過(guò)來(lái)先只支持一種嵌入式文件型數(shù)據(jù)庫(kù)。因?yàn)檫@種引擎零配置、不需要額外服務(wù)端非常適合做自動(dòng)化測(cè)試和連續(xù)集成。等這種引擎全流程穩(wěn)定后再把它對(duì)接到某個(gè)常見(jiàn)的開(kāi)源關(guān)系型數(shù)據(jù)庫(kù)。一對(duì)接才發(fā)現(xiàn)最大的坑不是 SQL 語(yǔ)法而是連接參數(shù)解析和錯(cuò)誤分類(lèi)。于是我把連接串解析統(tǒng)一為“鍵值對(duì)配置”把錯(cuò)誤信息中的關(guān)鍵字映射成連接錯(cuò)誤、語(yǔ)法錯(cuò)誤、權(quán)限錯(cuò)誤等幾個(gè)大類(lèi)。第二個(gè)引擎接入后適配層的基本形態(tài)才固定下來(lái)。4.3 第三階段連接配置、加密存儲(chǔ)和 SSH 隧道桌面工具無(wú)法回避的問(wèn)題是如何保存密碼。直接明文存配置是一種安全災(zāi)難。GoNavi 采用本機(jī)主密鑰加密方案用戶(hù)第一次啟動(dòng)時(shí)生成一個(gè)主密碼用主密碼派生出加密密鑰再用這個(gè)密鑰加密所有保存的連接密碼。密鑰文件存放在本用戶(hù)目錄下權(quán)限設(shè)為當(dāng)前用戶(hù)唯一可讀寫(xiě)。除此之外SSH 隧道也是剛需。很多數(shù)據(jù)庫(kù)不允許直接暴露端口只能通過(guò)跳板機(jī)訪(fǎng)問(wèn)。我在此階段實(shí)現(xiàn)了本地端口轉(zhuǎn)發(fā)GoNavi 在本地監(jiān)聽(tīng)一個(gè)隨機(jī)端口通過(guò) SSH 隧道轉(zhuǎn)發(fā)到目標(biāo)數(shù)據(jù)庫(kù)端口真正連接地址填本地端口。這個(gè)階段還順便解決了隱藏問(wèn)題連接配置中的編碼格式。不同系統(tǒng)對(duì)中文用戶(hù)名、密碼、數(shù)據(jù)庫(kù)名的處理方式不一樣統(tǒng)一按 UTF-8 編碼后跨平臺(tái)導(dǎo)出導(dǎo)入連接配置才不容易亂碼。4.4 第四階段前端資源打包與產(chǎn)物瘦身Go 后端編譯出的二進(jìn)制文件本身不大但內(nèi)嵌 Web 前端資源后體積會(huì)明顯膨脹。我做了兩件事來(lái)控制產(chǎn)物體積前端源碼用壓縮器處理JS/CSS 盡量合并成一個(gè)文件圖片、字體都換成體積更小的 Web 格式不再額外依賴(lài)外部 CDN。最終打包出來(lái)的單文件體積控制在 20MB 左右對(duì)于現(xiàn)代硬盤(pán)和網(wǎng)絡(luò)來(lái)說(shuō)完全無(wú)感。啟動(dòng)優(yōu)化方面我刻意把本地 HTTP 服務(wù)監(jiān)聽(tīng)設(shè)為“延遲啟動(dòng)”只有用戶(hù)真正進(jìn)入某個(gè)界面需要 Web 前端時(shí)才拉起服務(wù)接口冷啟動(dòng)時(shí)先顯示無(wú)界面的連接狀態(tài)提示這樣一來(lái)首屏速度會(huì)快不少。我也嘗試過(guò)用加殼工具進(jìn)一步壓縮二進(jìn)制體積結(jié)果省下來(lái)了機(jī)器磁盤(pán)空間換來(lái)的卻是殺毒軟件誤報(bào)和啟動(dòng)解壓耗時(shí)增加。最后果斷放棄只保留常規(guī)瘦身。4.5 開(kāi)發(fā)節(jié)奏建議先做“能用的 80%”再做“精致的 100%”回想整個(gè)過(guò)程我踩過(guò)最浪費(fèi)時(shí)間的坑就是過(guò)早優(yōu)化界面細(xì)節(jié)。圓角像素、主題配色、圖標(biāo)陰影這些事做到一半往往會(huì)影響整體邏輯的推進(jìn)。正確的節(jié)奏應(yīng)當(dāng)是第一個(gè)可用版本只關(guān)心連接、查詢(xún)、列表展示三件事確認(rèn)打通后再考慮編輯表格、導(dǎo)入導(dǎo)出、自動(dòng)補(bǔ)全最后才打磨動(dòng)畫(huà)、快捷鍵和主題。把“用戶(hù)能不能順利走完一條查詢(xún)鏈路”作為每個(gè)階段的完成標(biāo)準(zhǔn)比“這個(gè)按鈕是不是多了一個(gè)像素”重要得多。5. 邊界場(chǎng)景踩坑清單這些坑比功能開(kāi)發(fā)更耗時(shí)間功能開(kāi)發(fā)是一步一步推出來(lái)的但真正把工具打磨到“敢給所有人用”還得靠邊界場(chǎng)景。下面這些坑每一個(gè)都真實(shí)出現(xiàn)在 GoNavi 的迭代過(guò)程中也最能體現(xiàn)工具的基本功。5.1 保留字、大小寫(xiě)和標(biāo)識(shí)符引用數(shù)據(jù)庫(kù)里最容易被忽略的問(wèn)題是表名或字段名撞了保留字。用戶(hù)建了張order表字段名叫做group直接拼接 SQL 會(huì)被數(shù)據(jù)庫(kù)解析成關(guān)鍵字執(zhí)行直接失敗。GoNavi 的處理方式是對(duì)所有由用戶(hù)填寫(xiě)的表名、字段名統(tǒng)一進(jìn)行標(biāo)識(shí)符轉(zhuǎn)義根據(jù)當(dāng)前方言生成帶引號(hào)的名稱(chēng)。比如某類(lèi)引擎用反引號(hào)包裹另一類(lèi)引擎用雙引號(hào)包裹。自動(dòng)補(bǔ)全彈出來(lái)的提示里也直接展示轉(zhuǎn)義后的 SQL 片段減少用戶(hù)手動(dòng)處理的心智負(fù)擔(dān)。大小寫(xiě)問(wèn)題同樣麻煩。某些數(shù)據(jù)庫(kù)在 Linux 下表名區(qū)分大小寫(xiě)在 Windows 上下單不區(qū)分有的引擎創(chuàng)建表時(shí)大小寫(xiě)保留查詢(xún)時(shí)大小寫(xiě)不敏感。GoNavi 在對(duì)象列表里會(huì)顯示原始大小寫(xiě)但查詢(xún)語(yǔ)句一律按用戶(hù)填寫(xiě)內(nèi)容原樣下發(fā)絕不額外改寫(xiě)避免“幫倒忙”。5.2 特殊字符密碼讓連接串“提前爆炸”連接串里有大量特殊字符時(shí)稍微處理不慎就會(huì)把解析器搞崩。我第一次寫(xiě)連接串解析時(shí)直接按切分鍵值對(duì)結(jié)果密碼里自帶符號(hào)連接串被切得七零八落。后來(lái)統(tǒng)一規(guī)則所有連接參數(shù)都通過(guò)編碼封裝進(jìn) JSON鍵值對(duì)之間不依賴(lài)分隔符而是由 JSON 結(jié)構(gòu)天然區(qū)分。這樣做之后密碼里哪怕有、#、?、空格這樣的特殊字符也不會(huì)干擾連接。5.3 編輯長(zhǎng)文本的斷電恢復(fù)問(wèn)題數(shù)據(jù)庫(kù)里常見(jiàn)大段文本或 JSON 字段測(cè)試時(shí)一旦編輯到一半連接突然斷開(kāi)或者用戶(hù)不小心切換了界面數(shù)據(jù)就會(huì)丟失。最初版并沒(méi)有做任何防護(hù)因?yàn)槲以跐撘庾R(shí)里把“編輯單元格”當(dāng)成了“普通輸入框”。后來(lái)我給長(zhǎng)文本編輯增加了“會(huì)話(huà)版本號(hào)”機(jī)制打開(kāi)編輯器時(shí)記錄原始值和版本號(hào)保存時(shí)對(duì)比版本號(hào)如果版本號(hào)變化說(shuō)明有其他操作改動(dòng)過(guò)這行數(shù)據(jù)就彈窗讓用戶(hù)選擇“強(qiáng)制覆蓋”或“取消”。這至少避免了靜默覆蓋問(wèn)題。真正要避免“編輯到一半丟失”還得靠自動(dòng)保存草稿。編輯器每三十秒把當(dāng)前內(nèi)容寫(xiě)到本地臨時(shí)文件極端退出后再次打開(kāi)會(huì)提示是否恢復(fù)。這個(gè)功能不復(fù)雜但用戶(hù)口碑提升很明顯。5.4 大批量刪除時(shí)的鎖與事務(wù)邊界在界面上選中一萬(wàn)行記錄然后點(diǎn)擊刪除如果直接生成一條DELETE FROM ... WHERE id IN (...)的大 SQL不僅分分鐘造成數(shù)據(jù)庫(kù)鎖競(jìng)爭(zhēng)還可能在執(zhí)行過(guò)程中因?yàn)榫W(wǎng)絡(luò)波動(dòng)全部回滾用戶(hù)被卡在等待狀態(tài)。GoNavi 對(duì)“刪除行”這類(lèi)操作做了一層拆分默認(rèn)按每批 500 行拆成多個(gè)小事務(wù)每批完成后記錄進(jìn)度用戶(hù)可取消。這樣既減小鎖粒度也避免失敗時(shí)全量回滾。當(dāng)然這類(lèi)操作必須默認(rèn)先打開(kāi)半自動(dòng)事務(wù)模式并且在執(zhí)行前展示“預(yù)計(jì)影響行數(shù)和批次數(shù)量”防止誤操作。5.5 版本差異“同一套代碼換臺(tái)機(jī)器就失敗”最讓人抓狂的問(wèn)題往往來(lái)自數(shù)據(jù)庫(kù)版本差異。同一段連接代碼在一臺(tái)機(jī)器上可以工作換一個(gè)數(shù)據(jù)庫(kù)版本就報(bào)認(rèn)證插件不兼容。這種現(xiàn)象通常不是 SQL 問(wèn)題而是驅(qū)動(dòng)與目標(biāo)版本握手機(jī)制不一致。我的對(duì)策是提供“兼容模式”開(kāi)關(guān)。當(dāng)用戶(hù)遇到莫名其妙的握手失敗時(shí)可以在連接配置里切換認(rèn)證方式GoNavi 會(huì)改用較舊的認(rèn)證協(xié)議包裝請(qǐng)求。這個(gè)能力讓我少接了上百個(gè)“為什么連不上”的咨詢(xún)。6. 性能對(duì)照與最終取舍GoNavi 的實(shí)測(cè)數(shù)據(jù)與保留原則說(shuō)了這么多架構(gòu)和坑最終還是要落到可量化的結(jié)果上。以下數(shù)據(jù)來(lái)自一個(gè)內(nèi)部模擬項(xiàng)目 X使用一臺(tái)普通四核十六 GB 內(nèi)存的辦公筆記本作為測(cè)試環(huán)境目標(biāo)數(shù)據(jù)庫(kù)是一張約十萬(wàn)行的模擬業(yè)務(wù)表。6.1 測(cè)試方法和注意事項(xiàng)為保證結(jié)果可復(fù)現(xiàn)測(cè)試方法固定如下冷啟動(dòng)數(shù)據(jù)完全退出進(jìn)程后重新打開(kāi)記錄到界面出現(xiàn)可操作狀態(tài)的時(shí)間空閑內(nèi)存數(shù)據(jù)啟動(dòng)后不做任何操作等待三十秒后采樣十萬(wàn)行結(jié)果集加載執(zhí)行一條全表查詢(xún)記錄從發(fā)起查詢(xún)到表格渲染完畢的總時(shí)長(zhǎng)并發(fā)十查詢(xún)用腳本同時(shí)觸發(fā)十個(gè)耗時(shí)查詢(xún)記錄失敗率。每項(xiàng)測(cè)試連續(xù)跑五遍取中位數(shù)避免偶然波動(dòng)。6.2 優(yōu)化前后對(duì)照數(shù)據(jù)指標(biāo)原型版優(yōu)化版說(shuō)明冷啟動(dòng)耗時(shí)520ms180ms主要收益來(lái)自本地服務(wù)延遲啟動(dòng)和靜態(tài)資源懶加載空閑內(nèi)存占用118MB48MB減少常駐連接數(shù)和前端預(yù)渲染開(kāi)銷(xiāo)十萬(wàn)行結(jié)果集加載6.8s1.2s流式讀取和分片傳輸是最大功臣十個(gè)并發(fā)查詢(xún)失敗率約 30%0%連接池解決連接數(shù)風(fēng)暴問(wèn)題單文件體積34MB19MB前端資源壓縮與合并這個(gè)表不是用來(lái)跟其他工具比高低的而是說(shuō)明一個(gè)樸素道理數(shù)據(jù)庫(kù)客戶(hù)端的性能問(wèn)題絕大部分可以由架構(gòu)調(diào)整解決而不是靠換更強(qiáng)的機(jī)器。6.3 做減法的三個(gè)保留原則性能優(yōu)化做完之后我給自己定下了三條絕不動(dòng)搖的保留原則。第一工具永遠(yuǎn)保持單文件分發(fā)。不依賴(lài)外部運(yùn)行時(shí)不開(kāi)后臺(tái)守護(hù)進(jìn)程用戶(hù)復(fù)制一個(gè)文件就能用。第二內(nèi)存占用不隨結(jié)果集線(xiàn)性增長(zhǎng)。大結(jié)果集必須走流式處理寧可前端慢慢渲染也不允許后端內(nèi)存爆掉。第三不在工具內(nèi)做“全家桶”。SQL 自動(dòng)補(bǔ)全和基礎(chǔ)診斷可以有但復(fù)雜建模、權(quán)限審批、多維報(bào)表這類(lèi)功能留給更專(zhuān)業(yè)的平臺(tái)。GoNavi 只做好數(shù)據(jù)查看和操作本身。最后再分享一個(gè)小技巧給 SQL 編輯器加自動(dòng)提交開(kāi)關(guān)時(shí)默認(rèn)狀態(tài)最好設(shè)置成“手動(dòng)提交”。很多用戶(hù)以為這個(gè)工具有自動(dòng)提交功能就不需要在意事務(wù)邊界結(jié)果誤操作后才發(fā)現(xiàn)回滾不了。這個(gè)問(wèn)題在數(shù)據(jù)庫(kù)管理工具里特別容易引發(fā)事故寧可讓用戶(hù)多點(diǎn)一下“執(zhí)行”按鈕也不要讓沒(méi)有感知的自動(dòng)提交成為隱患。到目前為止GoNavi 在內(nèi)網(wǎng)工具列表里已經(jīng)服役了挺長(zhǎng)時(shí)間最大的收獲并不是它跑得多快而是在開(kāi)發(fā)過(guò)程中終于弄明白一件被反復(fù)驗(yàn)證的事一款現(xiàn)代化、高性能的數(shù)據(jù)庫(kù)管理工具真正重要的不是堆更多的按鈕而是讓每個(gè)高頻操作都足夠順滑、足夠安全。