據(jù)庫)
SQLite一個文件憑什么成為世界上用得最多的數(shù)據(jù)庫如果我告訴你你口袋里的手機、你正在用的瀏覽器、電腦上的 Python 和 Node.js甚至每一臺 Mac 和絕大多數(shù) Linux 系統(tǒng)里都內(nèi)置了一個完整的數(shù)據(jù)庫你可能會問這是哪個數(shù)據(jù)庫答案是 SQLite。它沒有獨立的服務(wù)器進(jìn)程沒有安裝向?qū)]有賬號密碼沒有my.cnf配置文件。所謂一個數(shù)據(jù)庫在 SQLite 的世界里就是一個普通的文件。備份它就是復(fù)制文件刪除它就是刪除文件遷移它就是把它拷到另一個目錄。這個 2000 年就誕生的項目如今是世界上部署量最大的數(shù)據(jù)庫——官方估計的安裝量以百億計比 MySQL 和 PostgreSQL 加起來還多幾個數(shù)量級。而它的源碼放在公有域里你可以隨隨便便把它嵌進(jìn)任何商業(yè)產(chǎn)品不用署名不用付費。這篇文章想聊清楚三件事SQLite 到底是什么它內(nèi)部是怎么工作的以及什么時候該用它、什么時候不該用。數(shù)據(jù)庫住進(jìn)程序里要理解 SQLite最快的方式是先看看正常的數(shù)據(jù)庫長什么樣。用 MySQL 的時候你的程序和數(shù)據(jù)庫是兩個東西數(shù)據(jù)庫是一個獨立運行的進(jìn)程守在網(wǎng)絡(luò)的某個端口上你的程序通過 TCP 連過去把 SQL 發(fā)過去再把結(jié)果讀回來。中間隔著一次網(wǎng)絡(luò)往返隔著序列化和反序列化隔著賬號權(quán)限校驗。你要先安裝它啟動它配置它運維它。SQLite 把這套東西全部砍掉了。它就是一個 C 語言寫的庫編譯進(jìn)你的程序和你的代碼跑在同一個進(jìn)程里。所謂執(zhí)行 SQL就是一次普通的函數(shù)調(diào)用。數(shù)據(jù)存在哪磁盤上一個.db文件而已。MySQL 應(yīng)用程序 ──網(wǎng)絡(luò)── 數(shù)據(jù)庫服務(wù)器進(jìn)程 ── 磁盤 SQLite 應(yīng)用程序 ──函數(shù)調(diào)用── SQLite 引擎 ── 一個 .db 文件 同一個進(jìn)程里這個設(shè)計帶來的好處是連鎖的零配置。不用安裝不用啟動服務(wù)第一次調(diào)用就自動建庫。零運維。沒有守護(hù)進(jìn)程可以掛沒有端口可以被攻擊沒有配置可以調(diào)錯。零網(wǎng)絡(luò)。查詢不走網(wǎng)絡(luò)不存在連接池耗盡、網(wǎng)絡(luò)抖動這些問題。備份極簡。停寫拷文件完事。當(dāng)然代價也是同樣的直接數(shù)據(jù)庫和應(yīng)用綁死在一個進(jìn)程里你能用多強的機器它就有多大能耐。這是后話。一個 .db 文件里裝了什么SQLite 官方文檔里有一句很有意思的話理解 SQLite 的最好方式是理解它的文件格式。因為這個文件里裝的不是一堆看不懂的二進(jìn)制黑盒而是一棵棵結(jié)構(gòu)清晰的 B-tree。打開一個數(shù)據(jù)庫文件它由固定大小的頁默認(rèn) 4096 字節(jié)串起來就像一本書的一頁頁紙。文件開頭 100 字節(jié)是頭部魔數(shù)、頁大小、編碼、版本號。剩下的頁里住著兩類東西表和索引它們?nèi)际?B-tree。對你沒看錯——表就是一棵 B-tree。你建的每一張表是一棵樹每個索引是另一棵樹。表那棵樹的葉子節(jié)點上放著完整的行數(shù)據(jù)索引那棵樹的葉子節(jié)點上放著索引列 行指針。查詢走索引就是在索引那棵樹上定位拿到行指針再回到表那棵樹里把整行撈出來。和 MySQL 的 InnoDB 大同小異只不過這一切都濃縮在一個文件里。這里有個 SQLite 特有的細(xì)節(jié)值得單獨說。每張表其實都有一個隱藏的 64 位整數(shù)主鍵叫rowid哪怕你建表時沒寫主鍵它也存在。而如果你把主鍵聲明成INTEGER PRIMARY KEY它就是rowid的別名——這意味著主鍵查詢等于直接在樹上定位一次到位是 SQLite 里最快的查找路徑。反過來如果你用一個字符串或者 UUID 當(dāng)主鍵所有查找都要繞道二級索引。所以 SQLite 社區(qū)有個約定俗成的建議能用整數(shù)自增主鍵就用INTEGER PRIMARY KEY。這不是老派是這個文件格式?jīng)Q定的最優(yōu)解。寫入為什么慢以及 WAL 的救場SQLite 有一個名聲寫入慢。這個名聲一半是真的一半是誤會。先說真的那一半。數(shù)據(jù)庫要保證事務(wù)提交了就一定在持久性就必須在改數(shù)據(jù)時確保落盤——也就是調(diào)用fsync等磁盤真的把字節(jié)寫進(jìn)去。在默認(rèn)的 journal 模式下SQLite 的一次寫入是這么干的先把要改的頁的舊內(nèi)容復(fù)制到日志文件fsync改主文件fsync刪掉日志文件。一次提交至少兩次fsync。機械硬盤上一次fsync是毫秒級也就是說每行數(shù)據(jù)單獨提交一個事務(wù)每秒撐死幾百次寫入。這是性能懸崖也是大多數(shù)人抱怨SQLite 寫入慢的真實原因。但誤會在于這根本不是 SQLite 的上限。你把一千行數(shù)據(jù)放進(jìn)一個事務(wù)里提交fsync還是那幾次吞吐量立刻能沖到每秒幾萬行。寫入的粒度比寫入的數(shù)量重要得多。再說 WAL 的救場。SQLite 有個叫 WALWrite-Ahead Log預(yù)寫日志的模式一條命令開啟PRAGMA journal_modeWAL;開啟之后寫入不再直接改主文件而是先追加寫進(jìn)一個叫-wal的日志文件后臺再慢慢合并回主文件。讀取的時候讀主文件 WAL 里還沒合并的部分得到一個一致的快照。這帶來兩個質(zhì)變第一讀寫不再互相阻塞。舊模式下寫的時候要獨占文件所有讀都得等WAL 模式下讀者讀快照寫者寫日志互不干擾。對讀多寫少的場景這是決定性的提升。第二寫者和寫者之間也更從容。雖然同一時刻仍然只允許一個寫事務(wù)這點下面細(xì)說但沖突的窗口小了很多。代價是目錄里多了兩個文件app.db-wal和app.db-shm。這里埋著兩個經(jīng)典坑備份時只拷主文件會丟掉 WAL 里沒合并的最新數(shù)據(jù)。正確姿勢是先執(zhí)行VACUUM INTO backup.db生成一個原子快照或者先跑PRAGMA wal_checkpoint(TRUNCATE)把 WAL 合并清空。WAL 依賴共享內(nèi)存mmap所以數(shù)據(jù)庫文件必須放在本地磁盤。放 NFS、放 Windows 網(wǎng)絡(luò)共享輕則報錯重則直接損壞數(shù)據(jù)。云端容器部署時把這個路徑掛到網(wǎng)絡(luò)存儲上是真實發(fā)生過的事故。一把庫級鎖和單寫者的智慧如果說 WAL 解決了讀和寫打架那寫和寫打架的問題依然存在而且是 SQLite 最根本的架構(gòu)約束。SQLite 的鎖是庫級別的不管你的程序里開了多少個連接、多少個線程同一時刻整個數(shù)據(jù)庫只有一個寫事務(wù)在跑。寫和寫之間永遠(yuǎn)排隊。這跟 MySQL 的行級鎖完全不同——在 MySQL 里更新兩行不相干的數(shù)據(jù)可以并行在 SQLite 里不行先來后到。于是并發(fā)寫會撞上那個著名的錯誤SQLITE_BUSY: database is locked。解法有三層一層比一層根本。第一層等。每個連接都設(shè)置PRAGMA busy_timeout 5000意思是拿不到鎖就原地重試 5 秒而不是立刻報錯。光是這一條就能消掉九成的 BUSY 錯誤。注意它是 per-connection 的連接池里每個新連接都得帶上。第二層聰明地等。事務(wù)別用默認(rèn)的BEGINdeferred第一條語句才去搶鎖沖突暴露得晚、處理起來最狼狽有寫操作就用BEGIN IMMEDIATE一開始就把寫鎖搶到手——沖突提前暴露應(yīng)用層捕獲SQLITE_BUSY做指數(shù)退避重試即可。第三層從架構(gòu)上消滅競爭單寫者模式。這是 SQLite 官方推薦的高并發(fā)寫姿勢——所有寫操作投進(jìn)一個隊列由單個連接串行消費讀操作走連接池隨便并發(fā)。寫鎖永遠(yuǎn)不沖突因為寫者根本只有一個吞吐量反而比一堆連接互相搶鎖高得多。讀連接池N 個──→ ┌────────────┐ ←── 寫隊列 → 單寫協(xié)程串行 │ SQLite 文件 │ └────────────┘想明白這一點SQLite 的并發(fā)問題就從bug變成了設(shè)計約束下的正常工作方式。它不是不能高并發(fā)它是不能多寫者并發(fā)——而多數(shù)應(yīng)用的寫入量一個串行寫者綽綽有余。它其實沒那么簡陋很多人對 SQLite 的印象停留在存存配置的小數(shù)據(jù)庫這低估它了。它是一個通過了 SQL 標(biāo)準(zhǔn)大部分測試的完整關(guān)系數(shù)據(jù)庫事務(wù)、外鍵、視圖、觸發(fā)器、CTE、窗口函數(shù)一樣不缺。更驚喜的是那些白送的內(nèi)置擴展。全文檢索。FTS5 虛擬表開箱即用倒排索引、分詞、詞干還原、BM25 排序、關(guān)鍵詞高亮一套齊全。寫個站內(nèi)搜索、日志檢索CREATE VIRTUAL TABLE ... USING fts5(...)加一句MATCH就完事不用引入 Elasticsearch。JSON 處理。json_extract()能像查列一樣查 JSON 字段配合表達(dá)式索引還能直接給 JSON 里的字段建索引CREATEINDEXidx_statusONorders(json_extract(data,$.status));某種意義上這讓 SQLite 變成了一個帶索引的文檔數(shù)據(jù)庫??臻g索引。R*Tree 擴展讓經(jīng)緯度范圍查詢變成一次索引掃描地圖 POI 檢索這種活它也接得住。文件即工具。.dump導(dǎo)出文本、VACUUM INTO在線熱備、dbstat虛擬表告訴你是誰把庫撐大了、generate_series生成數(shù)字序列……這些內(nèi)置能力加起來SQLite 經(jīng)常能在小場景里頂替掉一整套中間件。動態(tài)類型一個溫柔的陷阱從 MySQL 切到 SQLite最容易被咬一口的是類型系統(tǒng)。SQLite 的列類型是建議而非規(guī)定官方術(shù)語叫 type affinity類型親和性。你寫age INTEGER它只是傾向于把值轉(zhuǎn)成整數(shù)轉(zhuǎn)不了也不會拒絕CREATETABLEt(aINTEGER);INSERTINTOtVALUES(123);-- 存成整數(shù) 123INSERTINTOtVALUES(abc);-- 也能存變成文本 abcINSERTINTOtVALUES(3.5);-- 浮點也收聽起來寬容用起來要命一列里混著整數(shù)和文本ORDER BY的結(jié)果就可能詭異SQLite 的類型排序是 NULL 數(shù)字 文本寫進(jìn) MySQL 時才會發(fā)現(xiàn)數(shù)據(jù)早臟了。應(yīng)對方式很樸素別指望列類型兜底。NOT NULL 該加就加應(yīng)用層做校驗需要的話用CHECK (typeof(a) integer)顯式約束。另外時間字段建議統(tǒng)一存 ISO8601 文本或 Unix 時間戳別讓格式自由發(fā)揮。MySQL 和 SQLite 的語法差異還有不少——沒有TRUNCATE、沒有AUTO_INCREMENT、ON DUPLICATE KEY UPDATE要換成ON CONFLICT、字符串拼接是||不是CONCAT。寫之前記住一個原則SQLite 是方言不是 MySQL 的子集重要 SQL 讓EXPLAIN QUERY PLAN驗一遍比背語法表管用。什么時候該用它什么時候別用聊了這么多回到最實際的問題什么時候選 SQLite它的甜蜜點很清晰——單機、嵌入、讀多寫少、不想運維。桌面應(yīng)用和手機 App 的本地存儲是它的主場iOS 的 Core Data、Android 的 Room 底層都可以是它CLI 工具、腳本、爬蟲的落地存儲它比寫個 CSV省心比起個 MySQL輕量微服務(wù)的本地緩存和元數(shù)據(jù)不值得為它開一個數(shù)據(jù)庫實例的場合用它正合適還有原型和測試環(huán)境——很多項目開發(fā)時用 SQLite幾行配置就能切到生產(chǎn)用的 MySQL。反過來這些場景請直接繞開多個服務(wù)實例要同時寫同一份數(shù)據(jù)。單寫者是進(jìn)程內(nèi)的紀(jì)律跨機器它管不著。要么上 LiteFS、rqlite 這類分布式 SQLite 方案要么老老實實用 MySQL。高頻并發(fā)寫。不是寫不進(jìn)去是排隊。寫入量大到一個串行寫者跟不上時換引擎比優(yōu)化更省時間。需要賬號權(quán)限、審計、行級安全。SQLite 沒有用戶概念它的安全模型就是文件權(quán)限——能讀文件的人能讀全部數(shù)據(jù)。數(shù)據(jù)庫必須放在網(wǎng)絡(luò)存儲上。WAL 依賴本地共享內(nèi)存這條是紅線。有個粗略但好用的判斷標(biāo)準(zhǔn)如果你在糾結(jié)要不要給這個數(shù)據(jù)庫寫運維腳本說明它已經(jīng)不是 SQLite 的量級了。反過來如果你發(fā)現(xiàn)自己在寫腳本啟動數(shù)據(jù)庫、檢查端口、清理連接池——那這些活 SQLite 天生就不需要。結(jié)語SQLite 不是玩具數(shù)據(jù)庫也不是 MySQL 的廉價替代品。它是一種截然不同的架構(gòu)選擇把數(shù)據(jù)庫從數(shù)據(jù)中心搬進(jìn)程序內(nèi)部用一個文件換掉了整個服務(wù)端。它用 B-tree 和頁組織數(shù)據(jù)用 journal 和 WAL 守住 ACID用一把庫級寫鎖換來實現(xiàn)的極致簡單與可靠——簡單到它的測試代碼比產(chǎn)品代碼多幾百倍穩(wěn)定到二十年前的數(shù)據(jù)庫文件今天照樣能打開。下一次當(dāng)你隨手pip install某個包、打開一個 App、或者在代碼里寫下sql.Open(sqlite, app.db)的時候可以想起這件事你沒有啟動任何服務(wù)器但你確實打開了一個完整的數(shù)據(jù)庫。它就在那個文件里。參考鏈接SQLite 官網(wǎng)https://www.sqlite.org官方文件格式文檔https://www.sqlite.org/fileformat.html官方 WikiHow To Corrupt An SQLite Database File反著讀學(xué)保命https://www.sqlite.org/howtocorrupt.htmlSQLite 在 WAL 下的并發(fā)https://www.sqlite.org/wal.html