banner
NEWS LETTER

Codex 日志库高频写盘排查:用 SQLite Trigger 拦截 TRACE 写入

Scroll down

一、问题背景

本地使用 Codex CLI 时,如果日志级别过细,~/.codex/logs_2.sqlite 可能会持续写入大量 TRACE / DEBUG 日志。

这类问题不一定会立刻表现为程序异常,但会带来几个隐患:

  • SQLite 主库文件持续变大。
  • WAL 文件频繁增长。
  • 磁盘出现持续小块写入。
  • 日志价值不高,但占用大量 IO。
  • 长时间运行后影响终端工具响应。

这次排查的目标很明确:

  1. 确认 logs 表是否被 TRACE 日志高频写入。
  2. 如果中招,先备份数据库。
  3. 使用 SQLite trigger 拦截 logs 表 insert。
  4. 对 WAL 执行 checkpoint / truncate。
  5. 采样确认 MAX(id) 和 WAL 不再增长。

二、确认真实数据库路径

先确认日志库文件存在:

1
ls -lh ~/.codex/logs_2.sqlite*

这次实际看到的文件大致如下:

1
2
3
logs_2.sqlite      150M
logs_2.sqlite-shm 32K
logs_2.sqlite-wal 20K

主库已经到 150MB,说明日志量不小。继续看表结构:

1
2
3
4
SELECT type, name, tbl_name, sql
FROM sqlite_master
WHERE type IN ('table', 'trigger', 'index')
ORDER BY type, name;

核心表是 logs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
CREATE TABLE logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
ts_nanos INTEGER NOT NULL,
level TEXT NOT NULL,
target TEXT NOT NULL,
feedback_log_body TEXT,
module_path TEXT,
file TEXT,
line INTEGER,
thread_id TEXT,
process_uuid TEXT,
estimated_bytes INTEGER NOT NULL DEFAULT 0
);

三、判断是否中招

先按日志级别统计:

1
2
3
4
SELECT level, COUNT(*)
FROM logs
GROUP BY level
ORDER BY COUNT(*) DESC;

本次结果:

1
2
3
4
5
TRACE  30567
INFO 9606
DEBUG 4433
WARN 371
ERROR 60

TRACE 是最大头,继续看来源:

1
2
3
4
5
SELECT level, target, COUNT(*)
FROM logs
GROUP BY level, target
ORDER BY COUNT(*) DESC
LIMIT 10;

典型结果:

1
2
3
4
5
6
TRACE  log                                  13368
INFO codex_otel.log_only 6487
TRACE codex_api::sse::responses 5518
TRACE codex_app_server::outgoing_message 4041
TRACE codex_tui::markdown_stream 3126
DEBUG codex_core::stream_events_utils 1830

从结果看,历史日志里 TRACE 占比很高,属于明显中招。当前库里的 MAX(id) 和总行数是:

1
2
MAX(id)=1073334
COUNT(*)=45037

MAX(id) 明显大于当前行数,说明这个表经历过大量写入或清理,不能只看当前行数判断写盘压力。

四、先做一致性备份

如果系统里有 sqlite3 命令,可以用 .backup。如果没有,也可以用 Python 标准库的 SQLite backup API。

1
2
3
4
5
6
7
8
9
python3 -c "import sqlite3, pathlib, time
src = pathlib.Path.home() / '.codex' / 'logs_2.sqlite'
bak = src.with_name(src.name + '.backup-' + time.strftime('%Y%m%d-%H%M%S'))
con = sqlite3.connect(src, timeout=10)
dst = sqlite3.connect(bak)
con.backup(dst)
dst.close()
con.close()
print(bak)"

本次备份文件:

1
~/.codex/logs_2.sqlite.backup-20260623-124240

备份大小:

1
150208512 bytes

注意不要直接只复制主库文件而忽略 WAL。使用 SQLite backup API 可以得到一致性备份,更适合数据库仍可能被进程打开的场景。

五、用 Trigger 拦截 logs 表写入

拦截方式很简单:在 logs 表上创建 BEFORE INSERT trigger,并使用 RAISE(IGNORE) 忽略插入。

1
2
3
4
5
CREATE TRIGGER IF NOT EXISTS codex_block_logs_insert
BEFORE INSERT ON logs
BEGIN
SELECT RAISE(IGNORE);
END;

这个 trigger 的效果是:

  • 任何对 logs 表的新增写入都会被忽略。
  • 不抛出错误,调用方通常不会因为 insert 失败而中断。
  • 原有历史日志仍保留。
  • 不影响其他 SQLite 表。

可以用 Python 执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
python3 -c "import sqlite3, pathlib
p = pathlib.Path.home() / '.codex' / 'logs_2.sqlite'
con = sqlite3.connect(p, timeout=10)
con.execute('PRAGMA busy_timeout=10000')
con.execute(\"\"\"
CREATE TRIGGER IF NOT EXISTS codex_block_logs_insert
BEFORE INSERT ON logs
BEGIN
SELECT RAISE(IGNORE);
END
\"\"\")
con.commit()
con.close()"

创建后检查:

1
2
3
4
SELECT name, sql
FROM sqlite_master
WHERE type = 'trigger'
AND tbl_name = 'logs';

应能看到:

1
2
3
codex_block_logs_insert
BEFORE INSERT ON logs
SELECT RAISE(IGNORE)

六、Checkpoint 并截断 WAL

trigger 生效后,需要把 WAL 处理掉:

1
PRAGMA wal_checkpoint(TRUNCATE);

Python 执行方式:

1
2
3
4
5
python3 -c "import sqlite3, pathlib
p = pathlib.Path.home() / '.codex' / 'logs_2.sqlite'
con = sqlite3.connect(p, timeout=10)
print(con.execute('PRAGMA wal_checkpoint(TRUNCATE)').fetchall())
con.close()"

本次返回:

1
[(0, 0, 0)]

随后查看 WAL:

1
stat -c '%n %s' ~/.codex/logs_2.sqlite-wal

结果:

1
wal_size=0

七、采样确认不再增长

最后要做的是采样,而不是只看一次。

采样脚本:

1
2
3
4
5
6
7
8
9
10
python3 -c "import sqlite3, pathlib, time
p = pathlib.Path.home() / '.codex' / 'logs_2.sqlite'
wal = p.with_name(p.name + '-wal')
for i in range(6):
con = sqlite3.connect(p, timeout=10)
row = con.execute('SELECT MAX(id), COUNT(*) FROM logs').fetchone()
con.close()
wal_size = wal.stat().st_size if wal.exists() else 0
print(f'sample={i} max_id={row[0]} count={row[1]} wal_size={wal_size}')
time.sleep(3)"

本次 18 秒采样结果:

1
2
3
4
5
6
sample=0 max_id=1073334 count=45037 wal_size=0
sample=1 max_id=1073334 count=45037 wal_size=0
sample=2 max_id=1073334 count=45037 wal_size=0
sample=3 max_id=1073334 count=45037 wal_size=0
sample=4 max_id=1073334 count=45037 wal_size=0
sample=5 max_id=1073334 count=45037 wal_size=0

MAX(id) 没有增长,COUNT(*) 没有增长,WAL 也保持 0。说明 logs 表 insert 已被 trigger 拦截,高频写盘问题已经止住。

八、完整处理脚本

可以把备份、trigger 和 checkpoint 合并成一个脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
python3 -c "import sqlite3, pathlib, time
src = pathlib.Path.home() / '.codex' / 'logs_2.sqlite'
wal = src.with_name(src.name + '-wal')
bak = src.with_name(src.name + '.backup-' + time.strftime('%Y%m%d-%H%M%S'))

con = sqlite3.connect(src, timeout=10)
con.execute('PRAGMA busy_timeout=10000')

dst = sqlite3.connect(bak)
con.backup(dst)
dst.close()

before = con.execute('SELECT MAX(id), COUNT(*) FROM logs').fetchone()
con.execute(\"\"\"
CREATE TRIGGER IF NOT EXISTS codex_block_logs_insert
BEFORE INSERT ON logs
BEGIN
SELECT RAISE(IGNORE);
END
\"\"\")
con.commit()
checkpoint = con.execute('PRAGMA wal_checkpoint(TRUNCATE)').fetchall()
after = con.execute('SELECT MAX(id), COUNT(*) FROM logs').fetchone()
con.close()

print('backup:', bak)
print('before:', before)
print('checkpoint:', checkpoint)
print('after:', after)
print('wal_size:', wal.stat().st_size if wal.exists() else 0)"

九、注意事项

这个办法适合“本地日志库过度写入”的止血场景,但也有代价:

  • 之后 logs 表不会再记录新日志。
  • 如果后续需要排查 Codex 自身问题,可能需要临时删除 trigger。
  • 升级 Codex 后,表结构或日志策略可能变化,需要重新检查。
  • 如果主库已经很大,trigger 只能阻止继续增长,不会自动缩小主库文件。

如果要恢复日志写入,可以删除 trigger:

1
DROP TRIGGER IF EXISTS codex_block_logs_insert;

如果想缩小已经变大的主库文件,可以在确认不需要历史日志后再考虑清理或 VACUUM。这一步会重写数据库文件,耗时和风险都更高,不建议和止血操作混在一起做。

十、小结

这次排查的关键结论:

  • ~/.codex/logs_2.sqlite 已经达到 150MB。
  • 历史日志里 TRACE 数量最多,达到 30567 条。
  • logsMAX(id)=1073334,说明写入痕迹远大于当前保留行数。
  • 已创建一致性备份。
  • 已用 codex_block_logs_insert trigger 拦截 logs insert。
  • 已执行 PRAGMA wal_checkpoint(TRUNCATE)
  • 18 秒连续采样中,MAX(id)、行数和 WAL 大小都没有增长。

排查这类问题时,顺序很重要:先确认、再备份、再拦截、再 checkpoint,最后用采样验证结果。不要只凭一次 ls -lh 就下结论。

其他文章
目录导航 置顶
  1. 1. 一、问题背景
  2. 2. 二、确认真实数据库路径
  3. 3. 三、判断是否中招
  4. 4. 四、先做一致性备份
  5. 5. 五、用 Trigger 拦截 logs 表写入
  6. 6. 六、Checkpoint 并截断 WAL
  7. 7. 七、采样确认不再增长
  8. 8. 八、完整处理脚本
  9. 9. 九、注意事项
  10. 10. 十、小结
请输入关键词进行搜索