删除流量只让数据不可见,磁盘占用从不下降。两处原因叠加: 1. SQLite 删行仅把页挂到 freelist,而索引库建库时未启用 auto_vacuum, 文件永不收缩。256KB 以下的正文全部内联在这个库里,所以体积主体就在 这里,且只增不减。 2. ex_fts 是 contentless_delete 全文索引,DELETE 只写 tombstone、不回收 原 postings,从来没有合并过——删流量反而让索引变大(实测 exchanges 清零后 ex_fts_data 仍占 3.3MB / 840 行)。 trigram 索引约为正文体积的 2 倍,加上内联正文,抓包量大的实例会为早已 删掉的流量长期占用数倍磁盘:实测抓 6MB 文本正文 → 索引 16.2MB,删光后 仍是 16.2MB。 修复: - 新建索引库在建表前启用 auto_vacuum=incremental。该 pragma 只在库中尚 无表时可设,且只有紧随的 VACUUM 才把它写入文件头,两步都必须落在同一 条连接上,故新增 initIndex 固定一条 *sql.Conn 串起 pragma 与建表。 - 新增 reclaim/reclaimChunk:删除提交后在后台分块执行全文索引增量合并、 incremental_vacuum 与 wal_checkpoint(TRUNCATE)(不做 TRUNCATE 则空间 留在 WAL 里、文件大小不变,PASSIVE 也清不掉)。接入 DeleteHost、 HostDeleteStage.Commit 与 RecoverHostDeleteStages 三条路径。 - 回收分块进行并在块间释放写锁:record() 由 go-mitmproxy 在把响应写回 客户端之前同步调用,一次性回收数 GB 会连带卡住被测请求。Close 先发停止 信号,剩余预算留给下次删除,不拖住进程退出。 - 终止条件按「是否有进展」判定:incremental_vacuum 只能释放挪得到文件 末尾的页,残留少量空闲页是正常结果;另加步数上限兜底。fts5 无法查询 索引是否已合并完(其特殊 INSERT 自身即计一次 changes),故改为固定合并 预算,未做完的留给后续删除。 - busy_timeout 改由 DSN 提供:它是每连接设置,原先的 db.Exec 只配到池中 碰巧被选中的那一条,其余连接在有写者时会立即失败而非等待。驱动按第一个 '?' 切分 DSN,故数据目录路径含 '?' 时退回裸路径 DSN。 已有实例的索引库仍是旧模式(auto_vacuum 无法事后开启),incremental_vacuum 在其上为空操作,启动时打印提示;此类库的 tombstone 增长已止住,已占体积 待后续「存储压缩」入口一次性回收。 测试:新增 reclaim_test.go(空间回收回归、tombstone 合并、新库模式、旧库 不卡死)与 upgrade_test.go(旧装机升级后同库同数据、全文与正文可用、 legacy path 行不受影响;以及降级回旧二进制仍可读写)。
157 lines
5.2 KiB
Go
157 lines
5.2 KiB
Go
package traffic
|
||
|
||
import (
|
||
"database/sql"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// bulkRecord fills the index with inline bodies — the ones that actually make
|
||
// index.sqlite grow. Binary content type keeps them out of the full-text index so
|
||
// the test stays fast; the FTS side is covered by TestReclaimMergesFTSTombstones.
|
||
func bulkRecord(tr *Traffic, host string, n, size int) {
|
||
body := []byte(strings.Repeat("A", size))
|
||
for i := 0; i < n; i++ {
|
||
tr.record(newFlow(host, "GET", fmt.Sprintf("/blob/%d", i), nil, body,
|
||
withRespType("application/octet-stream")))
|
||
}
|
||
}
|
||
|
||
func TestNewIndexEnablesIncrementalVacuum(t *testing.T) {
|
||
tr, _ := openTraffic(t)
|
||
if !tr.incrementalVacuum {
|
||
t.Fatal("新建索引库未启用增量回收")
|
||
}
|
||
var mode int
|
||
if err := tr.DB().QueryRow(`PRAGMA auto_vacuum`).Scan(&mode); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if mode != autoVacuumIncremental {
|
||
t.Fatalf("auto_vacuum=%d,应为 %d", mode, autoVacuumIncremental)
|
||
}
|
||
}
|
||
|
||
// TestDeleteReclaimsIndexSpace is the regression: deleting traffic used to leave
|
||
// index.sqlite at its high-water mark forever, because SQLite only chains freed
|
||
// pages onto its freelist and nothing ever returned them to the filesystem.
|
||
func TestDeleteReclaimsIndexSpace(t *testing.T) {
|
||
tr, _ := openTraffic(t)
|
||
const host = "bulk.example.com"
|
||
// 30 × 200KB stays under maxInlineBody, so every body lands in the database
|
||
// itself rather than the blob store — that is where the growth was invisible.
|
||
bulkRecord(tr, host, 30, 200*1024)
|
||
grown := tr.indexBytes()
|
||
if grown < 5<<20 {
|
||
t.Fatalf("索引只有 %d 字节,样本不足以验证回收", grown)
|
||
}
|
||
|
||
if n, err := tr.DeleteHostsExact([]string{host}); err != nil || n != 30 {
|
||
t.Fatalf("DeleteHostsExact=(%d,%v),应为 (30,nil)", n, err)
|
||
}
|
||
tr.reaping.Wait() // 回收在后台分块进行
|
||
|
||
after := tr.indexBytes()
|
||
if after > grown/4 {
|
||
t.Fatalf("删除后索引仍占 %d 字节(删除前 %d),空间没有还给文件系统", after, grown)
|
||
}
|
||
// A handful of pages incremental_vacuum could not move to the end of the file
|
||
// is a normal residual; the ~1500 that the deletion freed must be gone.
|
||
var free int
|
||
if err := tr.DB().QueryRow(`PRAGMA freelist_count`).Scan(&free); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if free > 64 {
|
||
t.Fatalf("仍有 %d 个空闲页未回收", free)
|
||
}
|
||
}
|
||
|
||
// TestReclaimMergesFTSTombstones covers the second half of the leak: ex_fts is a
|
||
// contentless_delete index, so a DELETE only writes tombstones. Without a merge
|
||
// the index keeps growing on every deletion — deleting traffic made it bigger.
|
||
func TestReclaimMergesFTSTombstones(t *testing.T) {
|
||
tr, _ := openTraffic(t)
|
||
if !tr.fts {
|
||
t.Skip("驱动未启用 FTS5")
|
||
}
|
||
// Deleted in batches, which is what leaves tombstones spread over many
|
||
// segments rather than emptying the index in one shot.
|
||
for round := 0; round < 4; round++ {
|
||
host := fmt.Sprintf("fts%d.example.com", round)
|
||
for i := 0; i < 20; i++ {
|
||
tr.record(newFlow(host, "GET", fmt.Sprintf("/p/%d", i), nil,
|
||
[]byte(strings.Repeat("secret token 中文正文 padding ", 200))))
|
||
}
|
||
if _, err := tr.DeleteHostsExact([]string{host}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
tr.reaping.Wait()
|
||
}
|
||
|
||
var exchanges, segments int
|
||
if err := tr.DB().QueryRow(`SELECT COUNT(*) FROM exchanges`).Scan(&exchanges); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := tr.DB().QueryRow(`SELECT COUNT(*) FROM ex_fts_data`).Scan(&segments); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if exchanges != 0 {
|
||
t.Fatalf("还剩 %d 条流量", exchanges)
|
||
}
|
||
// A fully merged, empty contentless index keeps only its structure rows.
|
||
if segments > 8 {
|
||
t.Fatalf("全文索引残留 %d 行段数据,tombstone 未被合并回收", segments)
|
||
}
|
||
}
|
||
|
||
// TestReclaimOnLegacyIndexIsHarmless covers installs created before
|
||
// auto_vacuum=incremental became the default: incremental_vacuum is a silent
|
||
// no-op there, so reclamation must report the situation and finish rather than
|
||
// spin or fail. Only a full compaction can convert such a file.
|
||
func TestReclaimOnLegacyIndexIsHarmless(t *testing.T) {
|
||
dir := t.TempDir()
|
||
if err := os.MkdirAll(filepath.Join(dir, "_index"), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// Create the tables first, with auto_vacuum left at its default 0 — exactly the
|
||
// shape Open used to leave behind.
|
||
legacy, err := sql.Open("sqlite", filepath.Join(dir, "_index", "index.sqlite"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := legacy.Exec(indexSchema); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := legacy.Close(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
tr, err := Open(dir, "127.0.0.1:0")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
t.Cleanup(func() { tr.Close() })
|
||
if tr.incrementalVacuum {
|
||
t.Fatal("旧库不应报告已启用增量回收")
|
||
}
|
||
|
||
const host = "legacy.example.com"
|
||
bulkRecord(tr, host, 8, 200*1024)
|
||
if n, err := tr.DeleteHostsExact([]string{host}); err != nil || n != 8 {
|
||
t.Fatalf("DeleteHostsExact=(%d,%v),应为 (8,nil)", n, err)
|
||
}
|
||
tr.reaping.Wait() // 必须收敛,不能卡在预算里
|
||
|
||
// The freelist stays populated: that is the whole reason a compaction entry
|
||
// point is needed for pre-existing databases.
|
||
var free int
|
||
if err := tr.DB().QueryRow(`PRAGMA freelist_count`).Scan(&free); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if free == 0 {
|
||
t.Fatal("旧库居然回收了空闲页,说明测试没有真的构造出旧库")
|
||
}
|
||
}
|