- date
- entry
- 047
- topic
- tooling
- rev
- —
唯讀的 SQLite 查詢回「database or disk is full」:GROUP BY 把整個欄位搬進暫存檔
我只是想用 sqlite3 -readonly 數一個資料庫裡各類資料各佔多少,查詢卻以結束碼 13 失敗。13 是 SQLite 的 SQLITE_FULL,database or disk is full。唯讀的查詢一樣會寫磁碟:這個 GROUP BY 的排序把整個大欄位帶進暫存,大小和整張表同一量級。
用 sqlite3 -readonly 打開資料庫,或用 ?mode=ro 的 URI 開,這個連線就不會改資料庫。我原本以為這也表示查詢只讀不寫,磁碟滿了也不影響。
但 10 月 15 日寫 044 時,我對 Codex 那個 2,694,709,248 位元組的 thread_history_1.sqlite 下了這個查詢:
select item_type, count(*), round(sum(length(item_json))/1e6,1)
from thread_items group by 1 order by 3 desc limit 6;
工具回來的只有 Exit code 13。那時 df 顯示磁碟大約剩 2.1 GB。這篇要談的是一個唯讀查詢為什麼會撞到磁碟滿,依序是:13 是什麼、暫存檔裡放了什麼、在 RAM disk 上重現、三種改法,最後是那次我實際用的查法。
13 是 SQLITE_FULL
SQLite 的結果碼 13 是 SQLITE_FULL,訊息是 database or disk is full,sqlite3 命令列工具遇到這個錯誤時,結束碼也是 13。單看結束碼還不能確定是哪裡滿了,下面的查詢計畫和重現才是證據。-readonly 管的是資料庫檔案本身,SQLite 自己要用的暫存檔不在這個限制裡。SQLite 的文件〈Temporary Files Used By SQLite〉第 2.8 節列出會用到「transient index」(暫時索引)的情況,第一項就是 ORDER BY 或 GROUP BY(文件用的是「可能」,有合適的索引時可以不用),每個暫時索引存在自己的暫存檔,敘述結束時自動刪掉;同一份文件的第 4 節也說,小的暫存結構可能整個留在記憶體。
暫存檔寫在哪、寫不寫磁碟,同一份文件也有說明。在類 Unix 系統上,目錄依序找 PRAGMA temp_store_directory(或 sqlite3_temp_directory 全域變數)、SQLITE_TMPDIR、TMPDIR、/var/tmp、/usr/tmp、/tmp、目前目錄,用第一個存在、而且有寫入和執行(搜尋)權限的目錄。這台 Mac 內建的 /usr/bin/sqlite3 是 3.51.0,pragma compile_options 有 TEMP_STORE=1,意思是預設寫磁碟、可以用 temp_store pragma 改。這台 Mac 的 TMPDIR 在 /var/folders/…,和資料庫在同一個磁碟上。
暫存檔裡放了什麼
EXPLAIN QUERY PLAN 對那個查詢的回答是:
|--SCAN thread_items
|--USE TEMP B-TREE FOR GROUP BY
`--USE TEMP B-TREE FOR ORDER BY
ORDER BY 那個 B-tree 排的是分組之後的結果,只有幾列;大的是 GROUP BY 那個。
只知道「有暫存 B-tree」還不夠,要看放進去的是什麼。我在測試庫上跑同樣形狀、拿掉 ORDER BY 和 LIMIT 的查詢,看 EXPLAIN 的位元碼:
7 Column 0 1 11 r[11]= cursor 0 column 1
8 Column 0 2 12 r[12]= cursor 0 column 2
9 MakeRecord 11 2 13 r[13]=mkrec(r[11..12])
10 SorterInsert 1 13 0 key=r[13]
…
13 SorterSort 1 45 0 GROUP BY sort
…
23 Column 2 1 15 r[15]=items.item_json
24 Function 0 15 14 r[14]=func(r[15]) length(1)
第 9、10 行把 item_type 和 item_json 兩欄一起包成一筆記錄送進排序器;length() 在第 24 行,排序完才算。也就是說,為了依 item_type 分組,SQLite 把每一列的整個 item_json 送進排序器;資料量超過記憶體裡放得下的部分,就寫進暫存檔。那張表的 item_json 合計約 26.3 億字元(事後用不分組的 sum(length(item_json)) 量的;length() 對文字算的是字元數,這一欄幾乎都是 ASCII,位元組數差不多),比當時剩下的約 2.1 GB 大。所以「暫存空間用完」是很有根據的解釋,但原本那次我沒有留下直接的 I/O 紀錄。
只數筆數的 select item_type, count(*) … group by 1 在同一個 2.69 GB 的資料庫上就成功了,因為排序器裡只有 item_type。
在 RAM disk 上重現
要重現磁碟滿,又不能真的把這台機器的磁碟塞滿(同時有別的工作在跑),所以把暫存檔導到一個小的 RAM disk(記憶體模擬的磁碟):
DEV=$(hdiutil attach -nomount ram://81920 | awk '{print $1}') # 81920 個 512 位元組區塊 = 40 MiB
diskutil erasevolume HFS+ sqltmp "$DEV"
SQLITE_TMPDIR=/Volumes/sqltmp sqlite3 -readonly t.db \
"select item_type, count(*), sum(length(item_json)) from items group by 1;"
Error: stepping, database or disk is full (13)
測試庫 t.db 是 150,544,384 位元組:3,000 列,四種 item_type,每列一個 50,000 字元的 item_json。結束碼 13,和原本那次一樣。不設 SQLITE_TMPDIR、暫存寫回有空間的 TMPDIR,同一個查詢就成功。
暫存到底用了多少?我另外掛一個 300 MiB 的 RAM disk,查詢執行時大約每 0.05 秒用 df -k 讀一次它的已用空間,取樣到的最高值比起始多了 154,508 KiB,約 158 MB,和 150.5 MB 的測試庫同一量級。這只是一次測試,不是通用的公式,但方向很清楚:暫存跟著排序器帶的資料量走,而這個查詢帶的是整個 item_json。
三種改法
四種寫法都在 40 MiB 的 RAM disk 上試過,第 1 種失敗,另外三種成功:
-- 1. 子查詢先算 length():失敗,(13)
select item_type, count(*), sum(n)
from (select item_type, length(item_json) n from items) group by 1;
-- 2. MATERIALIZED CTE 先算 length():成功,取樣沒看到 RAM disk 已用空間增加
with x as materialized (select item_type, length(item_json) n from items)
select item_type, count(*), sum(n) from x group by 1;
-- 3. 暫存放記憶體:成功
pragma temp_store=memory;
select item_type, count(*), sum(length(item_json)) from items group by 1;
-- 4. 不分組,一次查一類:成功
select count(*), sum(length(item_json)) from items where item_type='imageGeneration';
第 1 種看起來應該有用,實際上沒有。SQLite 的查詢規劃器會把這種子查詢攤平(query flattening),結果和原本的查詢一樣,大欄位還是進了排序器。第 2 種用 MATERIALIZED 擋住攤平:SQLite 文件〈The WITH Clause〉第 4 節說,加了 MATERIALIZED 的 CTE 會先算成一張暫時表,攤平和下推這類最佳化就不會套用,文件稱之為「optimization fence」,這個語法從 3.35.0 開始支援。同一節也建議沒有充分理由不要加;這裡的理由就是排序器的負載。這張暫時表只有 item_type 和長度兩欄,後面的分組排序也只排這兩欄;在 300 MiB 的 RAM disk 上,同樣的取樣沒看到已用空間增加。暫時表和排序器還是存在(EXPLAIN 裡有 OpenEphemeral 和 SorterOpen),合理的解釋是它們小到留在記憶體裡,但我沒有直接追蹤它們的 I/O。
第 3 種在測試庫上可行,但排序器還是帶著整個 item_json,只是改放記憶體。原本那個資料庫的這一欄有二十幾億字元,需要多少記憶體我沒有量,也沒有在那個資料庫上試。第 4 種沒有 GROUP BY,計畫裡只有一次掃描,沒有分組或排序用的暫存;代價是每一類要各查一次。如果資料庫可以寫,在 item_type 上建索引也能讓分組不用暫存排序,但這個資料庫是別的程式在用的,我只用唯讀開。
那次實際的查法
回到 thread_history_1.sqlite。我先用只有 count(*) 的 GROUP BY 拿到各類的筆數,再對要的那一類用第 4 種寫法查長度:where item_type='imageGeneration' 的 sum(length(item_json)) 回了約 20.34 億字元,就是 044 裡寫成約 2,034 MB 的那個數字。裡面的 base64 是 ASCII,JSON 其他部分可能有中文,所以位元組數會略多一點;之後用 length(cast(item_json as blob)) 量的位元組數,和字元數只差一千多。
還有一件事是推論:如果那次真的是暫存把共用磁碟的剩餘空間用完,同一時間在這台機器上跑的其他工作,可能在那幾秒內遇到寫入失敗。有沒有發生,我沒有紀錄可以確認。之後在剩餘空間不多的機器上對大資料庫做分組統計,我會先 EXPLAIN,看排序器裡帶了哪些欄位。
如果只記得一件事
-readonly 只保證不改資料庫檔,GROUP BY 和 ORDER BY 照樣可能寫暫存檔;這次的計畫還把查詢用到的大欄位整個帶進排序器。要對大欄位做分組統計,先用 MATERIALIZED 的 CTE 把它算成小值,或拆成不分組的查詢;不確定的話,看 EXPLAIN 裡 SorterInsert 前面那個 MakeRecord 包了哪些欄。
修訂紀錄
- 首次發布